Skip to content

Commit aa9390e

Browse files
authored
[2.8/3] Add limitToLast implementation and tests. (#2296)
1 parent ca58a29 commit aa9390e

25 files changed

+619
-163
lines changed

packages/firebase/index.d.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8079,14 +8079,26 @@ declare namespace firebase.firestore {
80798079
): Query;
80808080

80818081
/**
8082-
* Creates and returns a new Query where the results are limited to the
8083-
* specified number of documents.
8082+
* Creates and returns a new Query that only returns the first matching
8083+
* documents.
80848084
*
80858085
* @param limit The maximum number of items to return.
80868086
* @return The created Query.
80878087
*/
80888088
limit(limit: number): Query;
80898089

8090+
/**
8091+
* Creates and returns a new Query that only returns the last matching
8092+
* documents.
8093+
*
8094+
* You must specify at least one `orderBy` clause for `limitToLast` queries,
8095+
* otherwise an exception will be thrown thrown during execution.
8096+
*
8097+
* @param limit The maximum number of items to return.
8098+
* @return The created Query.
8099+
*/
8100+
limitToLast(limit: number): Query;
8101+
80908102
/**
80918103
* Creates and returns a new Query that starts at the provided document
80928104
* (inclusive). The starting position is relative to the order of the query.

packages/firestore-types/index.d.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,14 +1083,26 @@ export class Query {
10831083
): Query;
10841084

10851085
/**
1086-
* Creates and returns a new Query that's additionally limited to only
1087-
* return up to the specified number of documents.
1086+
* Creates and returns a new Query that only returns the first matching
1087+
* documents.
10881088
*
10891089
* @param limit The maximum number of items to return.
10901090
* @return The created Query.
10911091
*/
10921092
limit(limit: number): Query;
10931093

1094+
/**
1095+
* Creates and returns a new Query that only returns the last matching
1096+
* documents.
1097+
*
1098+
* Queries with `limitToLast` must have at least one `orderBy` clause on
1099+
* one of the document fields, or an Exception will throw during execution.
1100+
*
1101+
* @param limit The maximum number of items to return.
1102+
* @return The created Query.
1103+
*/
1104+
limitToLast(limit: number): Query;
1105+
10941106
/**
10951107
* Creates and returns a new Query that starts at the provided document
10961108
* (inclusive). The starting position is relative to the order of the query.

packages/firestore/src/api/database.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
validateOptionalArgType,
7272
validateOptionalArrayElements,
7373
validateOptionNames,
74+
validatePositiveNumber,
7475
validateStringEnum,
7576
valueDescription
7677
} from '../util/input_validation';
@@ -1539,14 +1540,15 @@ export class Query implements firestore.Query {
15391540
limit(n: number): firestore.Query {
15401541
validateExactNumberOfArgs('Query.limit', arguments, 1);
15411542
validateArgType('Query.limit', 'number', 1, n);
1542-
if (n <= 0) {
1543-
throw new FirestoreError(
1544-
Code.INVALID_ARGUMENT,
1545-
`Invalid Query. Query limit (${n}) is invalid. Limit must be ` +
1546-
'positive.'
1547-
);
1548-
}
1549-
return new Query(this._query.withLimit(n), this.firestore);
1543+
validatePositiveNumber('Query.limit', 1, n);
1544+
return new Query(this._query.withLimitToFirst(n), this.firestore);
1545+
}
1546+
1547+
limitToLast(n: number): firestore.Query {
1548+
validateExactNumberOfArgs('Query.limitToLast', arguments, 1);
1549+
validateArgType('Query.limitToLast', 'number', 1, n);
1550+
validatePositiveNumber('Query.limitToLast', 1, n);
1551+
return new Query(this._query.withLimitToLast(n), this.firestore);
15501552
}
15511553

15521554
startAt(
@@ -1826,6 +1828,7 @@ export class Query implements firestore.Query {
18261828
complete: args[currArg + 2] as CompleteFn
18271829
};
18281830
}
1831+
this.validateHasExplicitOrderByForLimitToLast(this._query);
18291832
return this.onSnapshotInternal(options, observer);
18301833
}
18311834

@@ -1861,9 +1864,19 @@ export class Query implements firestore.Query {
18611864
};
18621865
}
18631866

1867+
private validateHasExplicitOrderByForLimitToLast(query: InternalQuery): void {
1868+
if (query.hasLimitToLast() && query.explicitOrderBy.length === 0) {
1869+
throw new FirestoreError(
1870+
Code.UNIMPLEMENTED,
1871+
'limitToLast() queries require specifying at least one orderBy() clause'
1872+
);
1873+
}
1874+
}
1875+
18641876
get(options?: firestore.GetOptions): Promise<firestore.QuerySnapshot> {
18651877
validateBetweenNumberOfArgs('Query.get', arguments, 0, 1);
18661878
validateGetOptions('Query.get', options);
1879+
this.validateHasExplicitOrderByForLimitToLast(this._query);
18671880
return new Promise(
18681881
(resolve: Resolver<firestore.QuerySnapshot>, reject: Rejecter) => {
18691882
if (options && options.source === 'cache') {

packages/firestore/src/core/query.ts

Lines changed: 84 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import { Code, FirestoreError } from '../util/error';
3030
import { isNullOrUndefined } from '../util/types';
3131
import { Target } from './target';
3232

33+
export enum LimitType {
34+
First = 'F',
35+
Last = 'L'
36+
}
37+
3338
/**
3439
* Query encapsulates all the query attributes we support in the SDK. It can
3540
* be run against the LocalStore, as well as be converted to a `Target` to
@@ -55,6 +60,7 @@ export class Query {
5560
readonly explicitOrderBy: OrderBy[] = [],
5661
readonly filters: Filter[] = [],
5762
readonly limit: number | null = null,
63+
readonly limitType: LimitType = LimitType.First,
5864
readonly startAt: Bound | null = null,
5965
readonly endAt: Bound | null = null
6066
) {
@@ -133,6 +139,7 @@ export class Query {
133139
this.explicitOrderBy.slice(),
134140
newFilters,
135141
this.limit,
142+
this.limitType,
136143
this.startAt,
137144
this.endAt
138145
);
@@ -148,18 +155,33 @@ export class Query {
148155
newOrderBy,
149156
this.filters.slice(),
150157
this.limit,
158+
this.limitType,
159+
this.startAt,
160+
this.endAt
161+
);
162+
}
163+
164+
withLimitToFirst(limit: number | null): Query {
165+
return new Query(
166+
this.path,
167+
this.collectionGroup,
168+
this.explicitOrderBy.slice(),
169+
this.filters.slice(),
170+
limit,
171+
LimitType.First,
151172
this.startAt,
152173
this.endAt
153174
);
154175
}
155176

156-
withLimit(limit: number | null): Query {
177+
withLimitToLast(limit: number | null): Query {
157178
return new Query(
158179
this.path,
159180
this.collectionGroup,
160181
this.explicitOrderBy.slice(),
161182
this.filters.slice(),
162183
limit,
184+
LimitType.Last,
163185
this.startAt,
164186
this.endAt
165187
);
@@ -172,6 +194,7 @@ export class Query {
172194
this.explicitOrderBy.slice(),
173195
this.filters.slice(),
174196
this.limit,
197+
this.limitType,
175198
bound,
176199
this.endAt
177200
);
@@ -184,6 +207,7 @@ export class Query {
184207
this.explicitOrderBy.slice(),
185208
this.filters.slice(),
186209
this.limit,
210+
this.limitType,
187211
this.startAt,
188212
bound
189213
);
@@ -202,6 +226,7 @@ export class Query {
202226
this.explicitOrderBy.slice(),
203227
this.filters.slice(),
204228
this.limit,
229+
this.limitType,
205230
this.startAt,
206231
this.endAt
207232
);
@@ -227,15 +252,20 @@ export class Query {
227252
// example, use as a dictionary key, but the implementation is subject to
228253
// collisions. Make it collision-free.
229254
canonicalId(): string {
230-
return this.toTarget().canonicalId();
255+
return `${this.toTarget().canonicalId()}|lt:${this.limitType}`;
231256
}
232257

233258
toString(): string {
234-
return `Query(target=${this.toTarget().toString()})`;
259+
return `Query(target=${this.toTarget().toString()}; limitType=${
260+
this.limitType
261+
})`;
235262
}
236263

237264
isEqual(other: Query): boolean {
238-
return this.toTarget().isEqual(other.toTarget());
265+
return (
266+
this.toTarget().isEqual(other.toTarget()) &&
267+
this.limitType === other.limitType
268+
);
239269
}
240270

241271
docComparator(d1: Document, d2: Document): number {
@@ -264,8 +294,12 @@ export class Query {
264294
);
265295
}
266296

267-
hasLimit(): boolean {
268-
return !isNullOrUndefined(this.limit);
297+
hasLimitToFirst(): boolean {
298+
return !isNullOrUndefined(this.limit) && this.limitType === LimitType.First;
299+
}
300+
301+
hasLimitToLast(): boolean {
302+
return !isNullOrUndefined(this.limit) && this.limitType === LimitType.Last;
269303
}
270304

271305
getFirstOrderByField(): FieldPath | null {
@@ -304,17 +338,52 @@ export class Query {
304338
return this.collectionGroup !== null;
305339
}
306340

341+
/**
342+
* Converts this `Query` instance to it's corresponding `Target`
343+
* representation.
344+
*/
307345
toTarget(): Target {
308346
if (!this.target) {
309-
this.target = new Target(
310-
this.path,
311-
this.collectionGroup,
312-
this.orderBy,
313-
this.filters,
314-
this.limit,
315-
this.startAt,
316-
this.endAt
317-
);
347+
if (this.limitType === LimitType.First) {
348+
this.target = new Target(
349+
this.path,
350+
this.collectionGroup,
351+
this.orderBy,
352+
this.filters,
353+
this.limit,
354+
this.startAt,
355+
this.endAt
356+
);
357+
} else {
358+
// Flip the orderBy directions since we want the last results
359+
const orderBys = [] as OrderBy[];
360+
for (const orderBy of this.orderBy) {
361+
const dir =
362+
orderBy.dir === Direction.DESCENDING
363+
? Direction.ASCENDING
364+
: Direction.DESCENDING;
365+
orderBys.push(new OrderBy(orderBy.field, dir));
366+
}
367+
368+
// We need to swap the cursors to match the now-flipped query ordering.
369+
const startAt = this.endAt
370+
? new Bound(this.endAt.position, !this.endAt.before)
371+
: null;
372+
const endAt = this.startAt
373+
? new Bound(this.startAt.position, !this.startAt.before)
374+
: null;
375+
376+
// Now return as a LimitType.First query.
377+
this.target = new Target(
378+
this.path,
379+
this.collectionGroup,
380+
orderBys,
381+
this.filters,
382+
this.limit,
383+
startAt,
384+
endAt
385+
);
386+
}
318387
}
319388
return this.target!;
320389
}

packages/firestore/src/core/sync_engine.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,9 @@ import {
4949
import * as objUtils from '../util/obj';
5050
import { SortedSet } from '../util/sorted_set';
5151
import { ListenSequence } from './listen_sequence';
52-
import { Query } from './query';
52+
import { Query, LimitType } from './query';
5353
import { SnapshotVersion } from './snapshot_version';
54+
import { Target } from './target';
5455
import { TargetIdGenerator } from './target_id_generator';
5556
import { Transaction } from './transaction';
5657
import {
@@ -216,7 +217,8 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
216217
this.sharedClientState.addLocalQueryTarget(targetId);
217218
viewSnapshot = queryView.view.computeInitialSnapshot();
218219
} else {
219-
const queryData = await this.localStore.allocateQuery(query);
220+
const queryData = await this.localStore.allocateTarget(query.toTarget());
221+
220222
const status = this.sharedClientState.addLocalQueryTarget(
221223
queryData.targetId
222224
);
@@ -305,8 +307,18 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
305307
const queryView = this.queryViewsByQuery.get(query)!;
306308
assert(!!queryView, 'Trying to unlisten on query not found:' + query);
307309

308-
// TODO(wuandy): Note this does not handle the case where multiple queries
309-
// map to one target, and user request to unlisten on of the queries.
310+
// If it's not the only query mapped to the target, only clean up
311+
// query view and keep the target active.
312+
const queries = this.queriesByTarget[queryView.targetId];
313+
if (queries.length > 1) {
314+
this.queriesByTarget[queryView.targetId] = queries.filter(
315+
q => !q.isEqual(query)
316+
);
317+
this.queryViewsByQuery.delete(query);
318+
return;
319+
}
320+
321+
// No other queries are mapped to the target, clean up the query and the target.
310322
if (this.isPrimary) {
311323
// We need to remove the local query target first to allow us to verify
312324
// whether any other client is still interested in this target.
@@ -983,7 +995,7 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
983995
assert(!!target, `Target for id ${targetId} not found`);
984996
queryData = await this.localStore.allocateTarget(target!);
985997
await this.initializeViewAndComputeSnapshot(
986-
target!.toTargetQuery(),
998+
this.synthesizeTargetToQuery(target!),
987999
targetId,
9881000
/*current=*/ false
9891001
);
@@ -996,6 +1008,30 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
9961008
return activeQueries;
9971009
}
9981010

1011+
/**
1012+
* Creates a `Query` object from the specified `Target`. There is no way to
1013+
* obtain the original `Query`, so we synthesize a `Query` from the `Target`
1014+
* object.
1015+
*
1016+
* The synthesized result might be different from the original `Query`, but
1017+
* since the synthesized `Query` should return the same results as the
1018+
* original one (only the presentation of results might differ), and this is
1019+
* only used for multi-tab, the potential difference will not cause issues.
1020+
*/
1021+
// PORTING NOTE: Multi-tab only
1022+
private synthesizeTargetToQuery(target: Target): Query {
1023+
return new Query(
1024+
target.path,
1025+
target.collectionGroup,
1026+
target.orderBy,
1027+
target.filters,
1028+
target.limit,
1029+
LimitType.First,
1030+
target.startAt,
1031+
target.endAt
1032+
);
1033+
}
1034+
9991035
// PORTING NOTE: Multi-tab only
10001036
getActiveClients(): Promise<ClientId[]> {
10011037
return this.localStore.getActiveClients();
@@ -1061,7 +1097,7 @@ export class SyncEngine implements RemoteSyncer, SharedClientStateSyncer {
10611097
assert(!!target, `Query data for active target ${targetId} not found`);
10621098
const queryData = await this.localStore.allocateTarget(target!);
10631099
await this.initializeViewAndComputeSnapshot(
1064-
target!.toTargetQuery(),
1100+
this.synthesizeTargetToQuery(target!),
10651101
queryData.targetId,
10661102
/*current=*/ false
10671103
);

0 commit comments

Comments
 (0)