-
-
Notifications
You must be signed in to change notification settings - Fork 722
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add "LIMIT 1" to fetchOne
requests
#515
Conversation
Tests currently fail
Hints are ignored by SQLRequest. When you write raw SQL you probably don't want GRDB to modify your query.
This problem was masked by the fact all queries in the test expected the same SQL query.
@@ -380,6 +381,7 @@ class RecordMinimalPrimaryKeyRowIDTests : GRDBTestCase { | |||
|
|||
let fetchedRecord = try MinimalRowID.filter(key: ["id": record.id]).fetchOne(db)! | |||
XCTAssertTrue(fetchedRecord.id == record.id) | |||
XCTAssertEqual(lastSQLQuery, "SELECT * FROM \"minimalRowIDs\" WHERE (\"id\" = \(record.id!)) LIMIT 1") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm very happy you added this test.
I'm somewhat sorry, because this technique for fetching one record by primary or unique key wasn't listed in my "exhaustive" list in #513:
let request = Player.filter(key: 123) // or Player.filter(key: ["email": "me@example.com"])
let player = try dbQueue.read { db in try request.fetchOne(db) }
For the context, this alternative way to perform fetchOne(_:key:)
is useful because filter(key:)
requests can be observed so that the application is notified of db changes:
let observation = ValueObservation(trackingOne: Player.filter(key: 123))
try observation.start(in: dbQueue) { (player: Player?) in
print("Player has changed: \(player)")
}
What about trying to remove LIMIT 1
here as well?
One way to achieve this goal is to add a flag to SQLSelectQuery. When this flag is set, we could ignore the limitOne
hint. And maybe we'd no longer need the primaryKeyOrUnique
hint after that?
We'd need to make sure that all SQLSelectQuery derivation methods transfer this flag from one query to another. But after a quick glance, it looks like all those methods are already information-preserving.
This would be a safe technique because all request derivation methods can only increase the amount of filtering, never relax it:
// Player 123, along with its team, but only if it is active:
//
// SELECT player.*, team.*
// FROM player
// JOIN team ON team.id = player.teamId
// WHERE player.id = 123 AND player.isActive = 1
let request = Player
.filter(key: 123)
.filter(Column("isActive") == true)
.including(required: Player.team)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NB: this change is not required for this pull request to enter review. We can proceed without it if you prefer. But if you agree with the suggestion, and if you have a little more time, this change would be appreciated.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hint: look for filter<PrimaryKeyType: DatabaseValueConvertible>(key:
and filter(key:
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good suggestion @groue, and thanks for the hints 😄
Definitely agree it'd be good to remove LIMIT 1 from the SQL there. I can look into it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool, thank you!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok I think I've got that implemented as you described it. Tests are updated to exclude LIMIT 1 from the SQL in that scenario.
GRDB/Core/FetchRequest.swift
Outdated
@@ -15,10 +15,20 @@ public protocol FetchRequest: DatabaseRegionConvertible { | |||
/// Returns a tuple that contains a prepared statement that is ready to be | |||
/// executed, and an eventual row adapter. | |||
/// | |||
/// Default implementation uses `prepare(db, hint: nil)`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Second question: what is the purpose of keeping prepare(_:)
in the protocol? Is there any reason for a type to implement both methods? Isn't prepare(_:hint:)
sufficient?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because the protocol's public
I had this idea of not wanting to break compatibility with anything already calling prepare(_:)
. Also thought hint
could be confusing when you don't need it.
I'd have preferred to declare it as:
func prepare(_ db: Database, hint: FetchRequestHint? = nil) throws -> (SelectStatement, RowAdapter?)
… but you can't have a default argument in a protocol.
Anyway, I'm not too attached to it. Happy to take it out if you'd prefer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All right, I understand. The good news is that we can break things, because we are targeting the next major version. GRDB 4 will break several things (see #479 for the list of changes). So we can keep a single method.
I agree the hint adds a layer of complexity to the API, but I don't expect many users to write their own concrete type which conforms to FetchRequest. This is an advanced technique. If they do, I guess they'll be able to handle it. And we don't want a default value for the hint, because being explicit is better here.
But we have to document clearly that this hint is only an opportunity for optimization, and can safely be ignored.
Towards this goal of clarity, I think we'll also have to look for alternative names for hint
and FetchRequestHint
, and wonder if we really want an optional. Those currently sound too much like an implementation detail, and not enough like a public API.
I don't know... The radical solution is:
func prepare(_ db: Database, forSingleResult singleResult: Bool) -> ... {
if singleResult {
// Add LIMIT 1
return ...
} else {
// Don't add LIMIT 1
return ...
}
}
But a boolean has little room for further extensions (even if I can't foresee any right now). I agree with your idea of an enum:
func prepare(_ db: Database, forCardinality cardinality: FetchCardinality) -> ... {
switch cardinality {
case .single:
// Add LIMIT 1
return ...
case .multiple:
// Don't add LIMIT 1
return ...
}
}
But "cardinality" can't really be extended either. Not really an improvement over a boolean. Let's go all general:
func prepare(_ db: Database, forPurpose purpose: FetchPurpose) -> ... {
switch purpose {
case .singleResult:
// Add LIMIT 1
return ...
case .multipleResults:
// Don't add LIMIT 1
return ...
}
}
I wish we could have merged FetchRequest.fetchCount
here:
func prepare(_ db: Database, forPurpose purpose: FetchPurpose) -> ... {
switch purpose {
case .singleResult:
// Add LIMIT 1
return ...
case .multipleResults:
// Don't add LIMIT 1
return ...
case .count:
// Build the counting statement
return ...
}
}
But we can't choose this last route, because fetchCount
is currently an optional method with a non-trivial default implementation: users would now have real difficulties adopting the FetchRequest protocol.
Well.. As you see, we have much room in our way to the best design. I'm curious about your ideas as well. Our goal: turn our implementation strategy into a public API that can make sense for people who don't have a clue about our implementation strategy. I mean that most users are utterly indifferent about how the library works, and only care about their needs. Good public APIs acknowledge this hard fact. It may look difficult, but this can also be a funny game :-)
A technique that has constantly helped me finding an answer to this kind of question has been the README. We currently write, below the introduction to the FetchRequest protocol:
The
prepare
method returns a prepared statement and an optional row adapter. The prepared statement tells which SQL query should be executed. The row adapter helps presenting the fetched rows in the way expected by the row decoders (see row adapter).
We can try to update this paragraph so that it explains how to use the hint. Experience shows that when the documentation looks like it fills its teaching goal, the API is good as well.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Applying my own advice makes the plain and simple boolean approach look not so bad, after all:
The
prepare(_:forSingleResult:)
method accepts a database connection, asingleResult
hint, and returns a prepared statement and an optional row adapter. Conforming types can use thesingleResult
hint as an optimization opportunity, and return a prepared statement that fetches at most one row, with aLIMIT
SQL clause, when possible. The optional row adapter helps presenting the fetched rows in the way expected by the row decoders (see row adapters).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm coming around to the idea of a plain old boolean. Complicating the API for unknown future cases probably isn't worth it. If other scenarios do come up in the future, it can be changed in a way that really makes sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Complicating the API for unknown future cases probably isn't worth it.
Indeed, if we really can't find any extension for the "hint" today, then let's not try to outsmart ourselves. As you say, we'll always be able to revise our opinion in the future, if needed!
We have done our exploration job: if the boolean approach is OK with you too, then I think we can just relax :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool. I've switched it to a boolean and updated the README.
|
||
switch hint { | ||
case .limitOne?: | ||
query = self.query.limit(1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here we break the following code:
let player = try Player.limit(1, offset: 10).fetchOne(db)
We have to honor the eventual offset, if present.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Whoops! Should be fixed in the latest commits (and added a test to catch it).
Fixes previous test failure
Tests now fail
Adds a flag to SQLSelectQuery indicating that it expects a single record (for example when querying by primary or unique key). Ensures that this example doesn't add an unnecessary LIMIT 1 to the resulting query: let request = Player.filter(key: 123) // or Player.filter(key: ["email": "me@example.com"]) let player = try dbQueue.read { db in try request.fetchOne(db) }
Ready for another look @groue 😊 |
This better hides this flag inside SQLSelectQuery, and avoids explicit support from FetchableRecord and TableRecord
@alextrob, this is really great. I added a couple commits:
Now I'll look at tests, which I'm sure are excellent, and we'll merge this PR very shortly :-) |
Nice one. I like your changes @groue 👍 |
We do not add LIMIT 1 to raw SQL requests because it is difficult. But it would not be wrong to do so.
We usually update all those tests together
@alextrob, this looks very good. I have removed tests about the lack of extra |
Merged. Thank you very much @alextrob and @ealymbaev: I did not immediately recognize the value of this change, but I'm now totally convinced. Alex, I'd be happy to grant you push access to the repository. Drop me a line if you are interested! |
Thanks @groue! Happy I got to contribute 😄 |
Invitation sent 🤝 |
Adds "LIMIT 1" to
fetchOne
requests when appropriate. More discussion and reasoning about this is in #513."LIMIT 1" is not added when querying by primary key or a unique key.
But it is added in other cases where only one record needs to be fetched.
This PR includes a change to the
FetchRequest
protocol.prepare
now has asingleResult
parameter that conforming types can optionally use to generate a more suitable query.For the built-in
FetchRequest
conformers:QueryInterfaceRequest
usessingleResult
to add (or not add) "LIMIT 1" to a query.SQLRequest
ignoressingleResult
because when you opt in to raw SQL you probably don't expect GRDB to modify your query.Pull Request Checklist
This pull request is submitted against the(@groue requested that this target thedevelopment
branch.GRDB-4.0
branch).