Skip to content

Commit a7e96c2

Browse files
committed
[2.8/3] Add limitToLast implementation and tests. (#2296)
1 parent 1cd4fcf commit a7e96c2

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
@@ -8118,14 +8118,26 @@ declare namespace firebase.firestore {
81188118
): Query;
81198119

81208120
/**
8121-
* Creates and returns a new Query where the results are limited to the
8122-
* specified number of documents.
8121+
* Creates and returns a new Query that only returns the first matching
8122+
* documents.
81238123
*
81248124
* @param limit The maximum number of items to return.
81258125
* @return The created Query.
81268126
*/
81278127
limit(limit: number): Query;
81288128

8129+
/**
8130+
* Creates and returns a new Query that only returns the last matching
8131+
* documents.
8132+
*
8133+
* You must specify at least one `orderBy` clause for `limitToLast` queries,
8134+
* otherwise an exception will be thrown thrown during execution.
8135+
*
8136+
* @param limit The maximum number of items to return.
8137+
* @return The created Query.
8138+
*/
8139+
limitToLast(limit: number): Query;
8140+
81298141
/**
81308142
* Creates and returns a new Query that starts at the provided document
81318143
* (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
@@ -1089,14 +1089,26 @@ export class Query {
10891089
): Query;
10901090

10911091
/**
1092-
* Creates and returns a new Query that's additionally limited to only
1093-
* return up to the specified number of documents.
1092+
* Creates and returns a new Query that only returns the first matching
1093+
* documents.
10941094
*
10951095
* @param limit The maximum number of items to return.
10961096
* @return The created Query.
10971097
*/
10981098
limit(limit: number): Query;
10991099

1100+
/**
1101+
* Creates and returns a new Query that only returns the last matching
1102+
* documents.
1103+
*
1104+
* Queries with `limitToLast` must have at least one `orderBy` clause on
1105+
* one of the document fields, or an Exception will throw during execution.
1106+
*
1107+
* @param limit The maximum number of items to return.
1108+
* @return The created Query.
1109+
*/
1110+
limitToLast(limit: number): Query;
1111+
11001112
/**
11011113
* Creates and returns a new Query that starts at the provided document
11021114
* (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';
@@ -1542,14 +1543,15 @@ export class Query implements firestore.Query {
15421543
limit(n: number): firestore.Query {
15431544
validateExactNumberOfArgs('Query.limit', arguments, 1);
15441545
validateArgType('Query.limit', 'number', 1, n);
1545-
if (n <= 0) {
1546-
throw new FirestoreError(
1547-
Code.INVALID_ARGUMENT,
1548-
`Invalid Query. Query limit (${n}) is invalid. Limit must be ` +
1549-
'positive.'
1550-
);
1551-
}
1552-
return new Query(this._query.withLimit(n), this.firestore);
1546+
validatePositiveNumber('Query.limit', 1, n);
1547+
return new Query(this._query.withLimitToFirst(n), this.firestore);
1548+
}
1549+
1550+
limitToLast(n: number): firestore.Query {
1551+
validateExactNumberOfArgs('Query.limitToLast', arguments, 1);
1552+
validateArgType('Query.limitToLast', 'number', 1, n);
1553+
validatePositiveNumber('Query.limitToLast', 1, n);
1554+
return new Query(this._query.withLimitToLast(n), this.firestore);
15531555
}
15541556

15551557
startAt(
@@ -1829,6 +1831,7 @@ export class Query implements firestore.Query {
18291831
complete: args[currArg + 2] as CompleteFn
18301832
};
18311833
}
1834+
this.validateHasExplicitOrderByForLimitToLast(this._query);
18321835
return this.onSnapshotInternal(options, observer);
18331836
}
18341837

@@ -1864,9 +1867,19 @@ export class Query implements firestore.Query {
18641867
};
18651868
}
18661869

1870+
private validateHasExplicitOrderByForLimitToLast(query: InternalQuery): void {
1871+
if (query.hasLimitToLast() && query.explicitOrderBy.length === 0) {
1872+
throw new FirestoreError(
1873+
Code.UNIMPLEMENTED,
1874+
'limitToLast() queries require specifying at least one orderBy() clause'
1875+
);
1876+
}
1877+
}
1878+
18671879
get(options?: firestore.GetOptions): Promise<firestore.QuerySnapshot> {
18681880
validateBetweenNumberOfArgs('Query.get', arguments, 0, 1);
18691881
validateGetOptions('Query.get', options);
1882+
this.validateHasExplicitOrderByForLimitToLast(this._query);
18701883
return new Promise(
18711884
(resolve: Resolver<firestore.QuerySnapshot>, reject: Rejecter) => {
18721885
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)