-
Notifications
You must be signed in to change notification settings - Fork 943
[2.8/3] Add limitToLast implementation and tests. #2296
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
[2.8/3] Add limitToLast implementation and tests. #2296
Conversation
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.
Thank you for this! This looks pretty reasonable. I added some comments for cleanup, but I do think this is close. I am excited to see the multi-tab spec tests in the following PR, but the test coverage here makes me feel confident. Thank you also for cleaning up the comments/code in Index Free.
packages/firebase/index.d.ts
Outdated
@@ -8079,14 +8079,23 @@ declare namespace firebase.firestore { | |||
): Query; | |||
|
|||
/** | |||
* Creates and returns a new Query where the results are limited to the | |||
* specified number of documents. | |||
* Creates and returns a new Query where only the first matching documents |
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.
What do you think of making this slightly more concise?
"Creates and returns a new Query that only returns the first matching documents".
I tried to come up with a way to include the original "specified number of documents" phrasing, but I can't come up with a good idea short of: "the first n
matching documents". However, this would require us to rename the argument. Maybe you have an idea on how we can combine "first" and "specified"?
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 think your version is good enough.
* Creates and returns a new Query where the results are limited to the | ||
* specified number of documents. | ||
* Creates and returns a new Query where only the first matching documents | ||
* are returned as results. | ||
* | ||
* @param limit The maximum number of items to return. | ||
* @return The created Query. | ||
*/ | ||
limit(limit: number): Query; |
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.
The original API proposal suggests to make this method as deprecated. Should we do that and introduce limitToFirst()
? In that case, I would also leave the comments for limit
as they were.
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.
Actually the decision was to keep limit
and add limitToLast
only.
See the comments in go/limitToLast
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.
Sounds good. Do you mind updating the proposed API so this is immediately apparent? The API proposal should be the source of truth and we should in general update the proposal to match the decision from the API council.
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.
Made some edit suggestions there.
@@ -1091,6 +1091,15 @@ export class Query { | |||
*/ | |||
limit(limit: number): Query; |
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.
Please make sure that both API definitions/comments are in sync (they seemed to be slightly different even before this PR).
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.
Done.
@@ -1539,14 +1539,24 @@ export class Query implements firestore.Query { | |||
limit(n: number): firestore.Query { | |||
validateExactNumberOfArgs('Query.limit', arguments, 1); | |||
validateArgType('Query.limit', 'number', 1, n); | |||
this.verifyPositiveLimit(n); |
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.
What do you think of moving this to util/input_validation
, which means that we could make this generic and include the callsite (Query.limitToFirst
, Query.limitToLast
)?
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.
Done.
get(options?: firestore.GetOptions): Promise<firestore.QuerySnapshot> { | ||
validateBetweenNumberOfArgs('Query.get', arguments, 0, 1); | ||
validateGetOptions('Query.get', options); | ||
return new Promise( | ||
(resolve: Resolver<firestore.QuerySnapshot>, reject: Rejecter) => { | ||
this.validateHasExplicitOrderByForLimitToLast(this._query); |
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.
This validation should happen outside of the Promise. Validation errors should throw, but if you throw inside of a Promise this is converted to a rejected Promise (which would indicate a backend error).
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.
Done.
}); | ||
}); | ||
|
||
it('can unlisten to mirror queries', () => { |
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.
s/to/from
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.
Done.
{ k: 'a', sort: 0 }, | ||
{ k: 'e', sort: -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.
This test might be easier to reason about if you remove the expect(toDataArray(snapshot))
asserts that you have already verified in the test above.
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 think this assertion is beneficial because it checks unlistening from one of the mirror queries will not stop others from functioning.
Added a comment to make the intention more clear.
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.
Is there maybe another way to reduce the test case size? 40 lines of shared setup seems a little much in my personal opinion.
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 have removed some unnecessary asserts that are repeating what other tests already covered, this should help a little bit?
I understand you concern, but IMHO it's not worth to make them sharing setup in this case, the added abstraction would probably make these tests harder to read.
snapshot = await storeLimitToLastEvent.awaitEvent(); | ||
expect(toDataArray(snapshot)).to.deep.equal([ | ||
{ k: 'e', sort: -1 }, | ||
{ k: 'a', sort: -2 } |
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.
Same comment as above - the test would be more concise if it focused on relistening, rather than verifying each individual result. If you can keep the same test coverage, it might be worth removing some of the duplicated asserts.
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.
Added comment like above.
@@ -264,7 +264,28 @@ describe('IndexFreeQueryEngine', () => { | |||
it('does not use initial results for limit query with document removal', async () => { | |||
const query = Query.atPath(path('coll')) | |||
.addFilter(filter('matches', '==', true)) | |||
.withLimit(1); | |||
.withLimitToFirst(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.
Instead of duplicating the limit() tests, what do you think about making one of them use limitToLast()
and keeping just limitToFirst()
for the rest of them?
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.
Done.
fromLimit.toTargetQuery().canonicalId() | ||
); | ||
|
||
expect(limitQuery.isEqual(fromLimitToLast.toTargetQuery())).to.be.true; |
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 am still concerned about this method. The contract just doesn't feel right and seeing this spelled out in a test makes this obvious. I understand that there may be a need for this conversion, but since this is limited to Multi-Tab and Web-only, what to you think of handling this conversion in a private method in Sync Engine (or a free-standing function):
function convertTargetToMultiTabQuery(...)
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 point that this is multi-tab only. Moved it to SyncEngine.
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.
Thanks for make some time to review this!
packages/firebase/index.d.ts
Outdated
@@ -8079,14 +8079,23 @@ declare namespace firebase.firestore { | |||
): Query; | |||
|
|||
/** | |||
* Creates and returns a new Query where the results are limited to the | |||
* specified number of documents. | |||
* Creates and returns a new Query where only the first matching documents |
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 think your version is good enough.
* Creates and returns a new Query where the results are limited to the | ||
* specified number of documents. | ||
* Creates and returns a new Query where only the first matching documents | ||
* are returned as results. | ||
* | ||
* @param limit The maximum number of items to return. | ||
* @return The created Query. | ||
*/ | ||
limit(limit: number): Query; |
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.
Actually the decision was to keep limit
and add limitToLast
only.
See the comments in go/limitToLast
* Creates and returns a new Query where only the last matching documents | ||
* are returned as results. | ||
* | ||
* @param limit The maximum number of items to return. |
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.
Done.
@@ -1091,6 +1091,15 @@ export class Query { | |||
*/ | |||
limit(limit: number): Query; |
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.
Done.
@@ -1539,14 +1539,24 @@ export class Query implements firestore.Query { | |||
limit(n: number): firestore.Query { | |||
validateExactNumberOfArgs('Query.limit', arguments, 1); | |||
validateArgType('Query.limit', 'number', 1, n); | |||
this.verifyPositiveLimit(n); |
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.
Done.
}); | ||
}); | ||
|
||
it('can unlisten to mirror queries', () => { |
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.
Done.
{ k: 'a', sort: 0 }, | ||
{ k: 'e', sort: -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 think this assertion is beneficial because it checks unlistening from one of the mirror queries will not stop others from functioning.
Added a comment to make the intention more clear.
snapshot = await storeLimitToLastEvent.awaitEvent(); | ||
expect(toDataArray(snapshot)).to.deep.equal([ | ||
{ k: 'e', sort: -1 }, | ||
{ k: 'a', sort: -2 } |
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.
Added comment like above.
@@ -264,7 +264,28 @@ describe('IndexFreeQueryEngine', () => { | |||
it('does not use initial results for limit query with document removal', async () => { | |||
const query = Query.atPath(path('coll')) | |||
.addFilter(filter('matches', '==', true)) | |||
.withLimit(1); | |||
.withLimitToFirst(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.
Done.
fromLimit.toTargetQuery().canonicalId() | ||
); | ||
|
||
expect(limitQuery.isEqual(fromLimitToLast.toTargetQuery())).to.be.true; |
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 point that this is multi-tab only. Moved it to SyncEngine.
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.
Thanks for the update. Can you see if you can come up with a strategy to remove the test duplication? Ideally, our GZIP compression rate for our tests would be pretty low :-P
* Creates and returns a new Query where the results are limited to the | ||
* specified number of documents. | ||
* Creates and returns a new Query where only the first matching documents | ||
* are returned as results. | ||
* | ||
* @param limit The maximum number of items to return. | ||
* @return The created Query. | ||
*/ | ||
limit(limit: number): Query; |
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.
Sounds good. Do you mind updating the proposed API so this is immediately apparent? The API proposal should be the source of truth and we should in general update the proposal to match the decision from the API council.
* Creates and returns a new Query where only the last matching documents | ||
* are returned as results. | ||
* | ||
* @param limit The maximum number of items to return. |
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.
How about:
You must specify at least one `orderBy` clause for `limitToLast` queries, otherwise an exception will be thrown thrown during execution.
If you like your version better, we should still make it "an exception will be thrown".
@@ -1861,11 +1872,21 @@ export class Query implements firestore.Query { | |||
}; | |||
} | |||
|
|||
private validateHasExplicitOrderByForLimitToLast(query: InternalQuery): void { |
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.
As per our standup discussion, let's stay consistent with the rest of the Query methods (and explore the option to allow startAt().order()
). Let's figure out what is feasible and hopefully we can leave this PR as is.
* different from the original `Query` from which we obtained this instance. | ||
*/ | ||
// PORTING NOTE: Multi-tab only | ||
private targetToQuery(target: Target): Query { |
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.
Is there a way to indicate that this conversion is lossy? We use "synthesized" in other parts of the API already 9
static createSynthesizedRemoteEventForCurrentChange( |
targetToSynthesizedQuery
or synthesizeTargetToQuery
?
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.
Done.
!objUtils.contains(this.listenTargets, queryData.targetId), | ||
'listen called with duplicate targetId!' | ||
); | ||
if (objUtils.contains(this.listenTargets, queryData.targetId)) { |
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.
Sounds good. As per standup discussion, let's leave this as is.
if (n <= 0) { | ||
throw new FirestoreError( | ||
Code.INVALID_ARGUMENT, | ||
`Function "${functionName}" created invalid Query. Query limit (${n}) is invalid. Limit must be positive.` |
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.
This method should now be generic:
export function validatePositiveNumber(functionName: string, position: number, n: number): void {
if (n <= 0) {
const description = valueDescription(argument);
throw new FirestoreError(
Code.INVALID_ARGUMENT,
`Function "${functionName}()" requires its ${ordinal(position)} argument to be a positive number, but it was: ${description}`);
}
}
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.
Done.
}); | ||
|
||
it('can listen to mirror queries', () => { | ||
const testDocs = { |
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.
How about something like:
Queries that mapped to the same target ID are referred to as "mirror queries". An example for a mirror query is a limitToLast() query and a limit() query that share the same backend Target ID. Since limitToLast() queries are sent to the backend with a modified orderBy() clause, they can map to the same target representation as limit()
query, even if both queries appear separate to the user.
{ k: 'a', sort: 0 }, | ||
{ k: 'e', sort: -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.
Is there maybe another way to reduce the test case size? 40 lines of shared setup seems a little much in my personal opinion.
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.
Thanks for addressing the feedback. Some small cleanup left, but ship it :)
@@ -1540,14 +1540,14 @@ export class Query implements firestore.Query { | |||
limit(n: number): firestore.Query { | |||
validateExactNumberOfArgs('Query.limit', arguments, 1); | |||
validateArgType('Query.limit', 'number', 1, n); | |||
validatePositiveLimit('limit', n); | |||
validatePositiveNumber('limit', 1, n); |
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.
The function name here should be "Query.limit" (here and below).
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.
Done.
@@ -1008,11 +1008,12 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer { | |||
return activeQueries; | |||
} | |||
/** | |||
* Creates a `Query` object from this `Target`. Note the result might be | |||
* different from the original `Query` from which we obtained this instance. | |||
* Creates a `Query` object from this `Target`. There is no way to obtain |
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.
s/this/the specified
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.
Done.
* different from the original `Query` from which we obtained this instance. | ||
* Creates a `Query` object from this `Target`. There is no way to obtain | ||
* the original `Query`, so we synthesize a `Query` from the `Target` object. | ||
* The result might be different from the original `Query`, |
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.
Replace comma with period. Maybe also mention that is used for multi-tab and hence the limitation described here does not alter the behavior?
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.
Done.
@@ -140,8 +140,12 @@ apiDescribe('Queries', (persistence: boolean) => { | |||
}); | |||
}); | |||
|
|||
// Mirror queries are `Query`s mapped to the same `Target`, meaning | |||
// they are supported by the same backend Query. | |||
// Queries that mapped to the same target ID are referred to as |
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.
s/mapped/map (I take credit for that one)
Also maybe start with "Two queries...".
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.
Done.
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.
Thanks for the help!
@@ -1540,14 +1540,14 @@ export class Query implements firestore.Query { | |||
limit(n: number): firestore.Query { | |||
validateExactNumberOfArgs('Query.limit', arguments, 1); | |||
validateArgType('Query.limit', 'number', 1, n); | |||
validatePositiveLimit('limit', n); | |||
validatePositiveNumber('limit', 1, n); |
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.
Done.
@@ -1861,11 +1872,21 @@ export class Query implements firestore.Query { | |||
}; | |||
} | |||
|
|||
private validateHasExplicitOrderByForLimitToLast(query: InternalQuery): void { |
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.
@@ -1008,11 +1008,12 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer { | |||
return activeQueries; | |||
} | |||
/** | |||
* Creates a `Query` object from this `Target`. Note the result might be | |||
* different from the original `Query` from which we obtained this instance. | |||
* Creates a `Query` object from this `Target`. There is no way to obtain |
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.
Done.
* different from the original `Query` from which we obtained this instance. | ||
* Creates a `Query` object from this `Target`. There is no way to obtain | ||
* the original `Query`, so we synthesize a `Query` from the `Target` object. | ||
* The result might be different from the original `Query`, |
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.
Done.
@@ -140,8 +140,12 @@ apiDescribe('Queries', (persistence: boolean) => { | |||
}); | |||
}); | |||
|
|||
// Mirror queries are `Query`s mapped to the same `Target`, meaning | |||
// they are supported by the same backend Query. | |||
// Queries that mapped to the same target ID are referred to as |
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.
Done.
* [1/3] Query/Target split -- create Target and make remote/ and local/ work with Target. (#2254) * [2/3] Query/Target split: make SyncEngine able to handle the mapping between Target and Query. (#2281) * [2.8/3] Add limitToLast implementation and tests. (#2296) * [3/3] Spec tests for limitToLast+multi-tab (#2300) * Add spec test for single client (#2313) * add change log for limitToLast * Address comments
Spec tests are not in this PR, as I need to basically implement the target:query split and mapping in spec tests builder/runner.
Figured it might be a good idea to have it reviewed without spec tests. Please let me know if you want them to be together.