Skip to content

Commit f355a46

Browse files
Merge 6ace334 into 80a7f7c
2 parents 80a7f7c + 6ace334 commit f355a46

File tree

5 files changed

+156
-65
lines changed

5 files changed

+156
-65
lines changed

.changeset/quiet-gorillas-smoke.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@firebase/firestore": patch
3+
---
4+
5+
Refactored aggregate query order-by normalization to support future aggregate operations.

packages/firestore/src/core/query.ts

+93-59
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ export const enum LimitType {
4343
/**
4444
* The Query interface defines all external properties of a query.
4545
*
46-
* QueryImpl implements this interface to provide memoization for `queryOrderBy`
46+
* QueryImpl implements this interface to provide memoization for `queryNormalizedOrderBy`
4747
* and `queryToTarget`.
4848
*/
4949
export interface Query {
@@ -65,11 +65,15 @@ export interface Query {
6565
* Visible for testing.
6666
*/
6767
export class QueryImpl implements Query {
68-
memoizedOrderBy: OrderBy[] | null = null;
68+
memoizedNormalizedOrderBy: OrderBy[] | null = null;
6969

7070
// The corresponding `Target` of this `Query` instance.
7171
memoizedTarget: Target | null = null;
7272

73+
// The corresponding `Target` of this `Query` instance for use with
74+
// aggregate queries
75+
memoizedAggregateTarget: Target | null = null;
76+
7377
/**
7478
* Initializes a Query with a path and optional additional query constraints.
7579
* Path must currently be empty if this is a collection group query.
@@ -86,13 +90,13 @@ export class QueryImpl implements Query {
8690
) {
8791
if (this.startAt) {
8892
debugAssert(
89-
this.startAt.position.length <= queryOrderBy(this).length,
93+
this.startAt.position.length <= queryNormalizedOrderBy(this).length,
9094
'Bound is longer than orderBy'
9195
);
9296
}
9397
if (this.endAt) {
9498
debugAssert(
95-
this.endAt.position.length <= queryOrderBy(this).length,
99+
this.endAt.position.length <= queryNormalizedOrderBy(this).length,
96100
'Bound is longer than orderBy'
97101
);
98102
}
@@ -211,14 +215,16 @@ export function isCollectionGroupQuery(query: Query): boolean {
211215
}
212216

213217
/**
214-
* Returns the implicit order by constraint that is used to execute the Query,
218+
* Returns the normalized order by constraint that is used to execute the Query,
215219
* which can be different from the order by constraints the user provided (e.g.
216-
* the SDK and backend always orders by `__name__`).
220+
* the SDK and backend always orders by `__name__`). The normalized order-by
221+
* includes implicit order-bys in addition to the explicit user provided
222+
* order-bys.
217223
*/
218-
export function queryOrderBy(query: Query): OrderBy[] {
224+
export function queryNormalizedOrderBy(query: Query): OrderBy[] {
219225
const queryImpl = debugCast(query, QueryImpl);
220-
if (queryImpl.memoizedOrderBy === null) {
221-
queryImpl.memoizedOrderBy = [];
226+
if (queryImpl.memoizedNormalizedOrderBy === null) {
227+
queryImpl.memoizedNormalizedOrderBy = [];
222228

223229
const inequalityField = getInequalityFilterField(queryImpl);
224230
const firstOrderByField = getFirstOrderByField(queryImpl);
@@ -227,9 +233,9 @@ export function queryOrderBy(query: Query): OrderBy[] {
227233
// inequality filter field for it to be a valid query.
228234
// Note that the default inequality field and key ordering is ascending.
229235
if (!inequalityField.isKeyField()) {
230-
queryImpl.memoizedOrderBy.push(new OrderBy(inequalityField));
236+
queryImpl.memoizedNormalizedOrderBy.push(new OrderBy(inequalityField));
231237
}
232-
queryImpl.memoizedOrderBy.push(
238+
queryImpl.memoizedNormalizedOrderBy.push(
233239
new OrderBy(FieldPath.keyField(), Direction.ASCENDING)
234240
);
235241
} else {
@@ -241,7 +247,7 @@ export function queryOrderBy(query: Query): OrderBy[] {
241247
);
242248
let foundKeyOrdering = false;
243249
for (const orderBy of queryImpl.explicitOrderBy) {
244-
queryImpl.memoizedOrderBy.push(orderBy);
250+
queryImpl.memoizedNormalizedOrderBy.push(orderBy);
245251
if (orderBy.field.isKeyField()) {
246252
foundKeyOrdering = true;
247253
}
@@ -254,13 +260,13 @@ export function queryOrderBy(query: Query): OrderBy[] {
254260
? queryImpl.explicitOrderBy[queryImpl.explicitOrderBy.length - 1]
255261
.dir
256262
: Direction.ASCENDING;
257-
queryImpl.memoizedOrderBy.push(
263+
queryImpl.memoizedNormalizedOrderBy.push(
258264
new OrderBy(FieldPath.keyField(), lastDirection)
259265
);
260266
}
261267
}
262268
}
263-
return queryImpl.memoizedOrderBy;
269+
return queryImpl.memoizedNormalizedOrderBy;
264270
}
265271

266272
/**
@@ -269,48 +275,76 @@ export function queryOrderBy(query: Query): OrderBy[] {
269275
export function queryToTarget(query: Query): Target {
270276
const queryImpl = debugCast(query, QueryImpl);
271277
if (!queryImpl.memoizedTarget) {
272-
if (queryImpl.limitType === LimitType.First) {
273-
queryImpl.memoizedTarget = newTarget(
274-
queryImpl.path,
275-
queryImpl.collectionGroup,
276-
queryOrderBy(queryImpl),
277-
queryImpl.filters,
278-
queryImpl.limit,
279-
queryImpl.startAt,
280-
queryImpl.endAt
281-
);
282-
} else {
283-
// Flip the orderBy directions since we want the last results
284-
const orderBys = [] as OrderBy[];
285-
for (const orderBy of queryOrderBy(queryImpl)) {
286-
const dir =
287-
orderBy.dir === Direction.DESCENDING
288-
? Direction.ASCENDING
289-
: Direction.DESCENDING;
290-
orderBys.push(new OrderBy(orderBy.field, dir));
291-
}
278+
queryImpl.memoizedTarget = _queryToTarget(
279+
queryImpl,
280+
queryNormalizedOrderBy(query)
281+
);
282+
}
292283

293-
// We need to swap the cursors to match the now-flipped query ordering.
294-
const startAt = queryImpl.endAt
295-
? new Bound(queryImpl.endAt.position, queryImpl.endAt.inclusive)
296-
: null;
297-
const endAt = queryImpl.startAt
298-
? new Bound(queryImpl.startAt.position, queryImpl.startAt.inclusive)
299-
: null;
300-
301-
// Now return as a LimitType.First query.
302-
queryImpl.memoizedTarget = newTarget(
303-
queryImpl.path,
304-
queryImpl.collectionGroup,
305-
orderBys,
306-
queryImpl.filters,
307-
queryImpl.limit,
308-
startAt,
309-
endAt
310-
);
311-
}
284+
return queryImpl.memoizedTarget;
285+
}
286+
287+
/**
288+
* Converts this `Query` instance to it's corresponding `Target` representation,
289+
* for use within an aggregate query.
290+
*/
291+
export function queryToAggregateTarget(query: Query): Target {
292+
const queryImpl = debugCast(query, QueryImpl);
293+
294+
if (!queryImpl.memoizedAggregateTarget) {
295+
// Do not include implicit order-bys for aggregate queries.
296+
queryImpl.memoizedAggregateTarget = _queryToTarget(
297+
queryImpl,
298+
query.explicitOrderBy
299+
);
300+
}
301+
302+
return queryImpl.memoizedAggregateTarget;
303+
}
304+
305+
export function _queryToTarget(
306+
queryImpl: QueryImpl,
307+
orderBys: OrderBy[]
308+
): Target {
309+
if (queryImpl.limitType === LimitType.First) {
310+
return newTarget(
311+
queryImpl.path,
312+
queryImpl.collectionGroup,
313+
orderBys,
314+
queryImpl.filters,
315+
queryImpl.limit,
316+
queryImpl.startAt,
317+
queryImpl.endAt
318+
);
319+
} else {
320+
// Flip the orderBy directions since we want the last results
321+
orderBys = orderBys.map(orderBy => {
322+
const dir =
323+
orderBy.dir === Direction.DESCENDING
324+
? Direction.ASCENDING
325+
: Direction.DESCENDING;
326+
return new OrderBy(orderBy.field, dir);
327+
});
328+
329+
// We need to swap the cursors to match the now-flipped query ordering.
330+
const startAt = queryImpl.endAt
331+
? new Bound(queryImpl.endAt.position, queryImpl.endAt.inclusive)
332+
: null;
333+
const endAt = queryImpl.startAt
334+
? new Bound(queryImpl.startAt.position, queryImpl.startAt.inclusive)
335+
: null;
336+
337+
// Now return as a LimitType.First query.
338+
return newTarget(
339+
queryImpl.path,
340+
queryImpl.collectionGroup,
341+
orderBys,
342+
queryImpl.filters,
343+
queryImpl.limit,
344+
startAt,
345+
endAt
346+
);
312347
}
313-
return queryImpl.memoizedTarget!;
314348
}
315349

316350
export function queryWithAddedFilter(query: Query, filter: Filter): Query {
@@ -461,13 +495,13 @@ function queryMatchesPathAndCollectionGroup(
461495
* in the results.
462496
*/
463497
function queryMatchesOrderBy(query: Query, doc: Document): boolean {
464-
// We must use `queryOrderBy()` to get the list of all orderBys (both implicit and explicit).
498+
// We must use `queryNormalizedOrderBy()` to get the list of all orderBys (both implicit and explicit).
465499
// Note that for OR queries, orderBy applies to all disjunction terms and implicit orderBys must
466500
// be taken into account. For example, the query "a > 1 || b==1" has an implicit "orderBy a" due
467501
// to the inequality, and is evaluated as "a > 1 orderBy a || b==1 orderBy a".
468502
// A document with content of {b:1} matches the filters, but does not match the orderBy because
469503
// it's missing the field 'a'.
470-
for (const orderBy of queryOrderBy(query)) {
504+
for (const orderBy of queryNormalizedOrderBy(query)) {
471505
// order by key always matches
472506
if (!orderBy.field.isKeyField() && doc.data.field(orderBy.field) === null) {
473507
return false;
@@ -489,13 +523,13 @@ function queryMatchesFilters(query: Query, doc: Document): boolean {
489523
function queryMatchesBounds(query: Query, doc: Document): boolean {
490524
if (
491525
query.startAt &&
492-
!boundSortsBeforeDocument(query.startAt, queryOrderBy(query), doc)
526+
!boundSortsBeforeDocument(query.startAt, queryNormalizedOrderBy(query), doc)
493527
) {
494528
return false;
495529
}
496530
if (
497531
query.endAt &&
498-
!boundSortsAfterDocument(query.endAt, queryOrderBy(query), doc)
532+
!boundSortsAfterDocument(query.endAt, queryNormalizedOrderBy(query), doc)
499533
) {
500534
return false;
501535
}
@@ -526,7 +560,7 @@ export function newQueryComparator(
526560
): (d1: Document, d2: Document) => number {
527561
return (d1: Document, d2: Document): number => {
528562
let comparedOnKeyField = false;
529-
for (const orderBy of queryOrderBy(query)) {
563+
for (const orderBy of queryNormalizedOrderBy(query)) {
530564
const comp = compareDocs(orderBy, d1, d2);
531565
if (comp !== 0) {
532566
return comp;

packages/firestore/src/lite-api/query.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
isCollectionGroupQuery,
3434
LimitType,
3535
Query as InternalQuery,
36-
queryOrderBy,
36+
queryNormalizedOrderBy,
3737
queryWithAddedFilter,
3838
queryWithAddedOrderBy,
3939
queryWithEndAt,
@@ -907,7 +907,7 @@ export function newQueryBoundFromDocument(
907907
// the provided document. Without the key (by using the explicit sort
908908
// orders), multiple documents could match the position, yielding duplicate
909909
// results.
910-
for (const orderBy of queryOrderBy(query)) {
910+
for (const orderBy of queryNormalizedOrderBy(query)) {
911911
if (orderBy.field.isKeyField()) {
912912
components.push(refValue(databaseId, doc.key));
913913
} else {

packages/firestore/src/remote/datastore.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import { CredentialsProvider } from '../api/credentials';
1919
import { User } from '../auth/user';
2020
import { Aggregate } from '../core/aggregate';
21-
import { Query, queryToTarget } from '../core/query';
21+
import { queryToAggregateTarget, Query, queryToTarget } from '../core/query';
2222
import { Document } from '../model/document';
2323
import { DocumentKey } from '../model/document_key';
2424
import { Mutation } from '../model/mutation';
@@ -248,7 +248,7 @@ export async function invokeRunAggregationQueryRpc(
248248
const datastoreImpl = debugCast(datastore, DatastoreImpl);
249249
const { request, aliasMap } = toRunAggregationQueryRequest(
250250
datastoreImpl.serializer,
251-
queryToTarget(query),
251+
queryToAggregateTarget(query),
252252
aggregates
253253
);
254254

packages/firestore/test/unit/core/query.test.ts

+54-2
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,13 @@ import {
2727
newQueryForPath,
2828
Query,
2929
queryMatches,
30-
queryOrderBy,
30+
queryNormalizedOrderBy,
3131
queryWithAddedFilter,
3232
queryWithEndAt,
3333
queryWithLimit,
3434
queryWithStartAt,
3535
stringifyQuery,
36+
queryToAggregateTarget,
3637
queryToTarget,
3738
QueryImpl,
3839
queryEquals,
@@ -852,6 +853,57 @@ describe('Query', () => {
852853
assertQueryMatches(query5, [doc3], [doc1, doc2, doc4, doc5]);
853854
});
854855

856+
it('generates appropriate order-bys for aggregate and non-aggregate targets', () => {
857+
const col = newQueryForPath(ResourcePath.fromString('collection'));
858+
859+
// Build two identical queries
860+
const query1 = queryWithAddedFilter(col, filter('foo', '>', 1));
861+
const query2 = queryWithAddedFilter(col, filter('foo', '>', 1));
862+
863+
// Compute an aggregate and non-aggregate target from the queries
864+
const aggregateTarget = queryToAggregateTarget(query1);
865+
const target = queryToTarget(query2);
866+
867+
expect(aggregateTarget.orderBy.length).to.equal(0);
868+
expect(target.orderBy.length).to.equal(2);
869+
expect(target.orderBy[0].dir).to.equal('asc');
870+
expect(target.orderBy[0].field.canonicalString()).to.equal('foo');
871+
expect(target.orderBy[1].dir).to.equal('asc');
872+
expect(target.orderBy[1].field.canonicalString()).to.equal('__name__');
873+
});
874+
875+
it('generated order-bys are not affected by previously memoized targets', () => {
876+
const col = newQueryForPath(ResourcePath.fromString('collection'));
877+
878+
// Build two identical queries
879+
const query1 = queryWithAddedFilter(col, filter('foo', '>', 1));
880+
const query2 = queryWithAddedFilter(col, filter('foo', '>', 1));
881+
882+
// query1 - first to aggregate target, then to non-aggregate target
883+
const aggregateTarget1 = queryToAggregateTarget(query1);
884+
const target1 = queryToTarget(query1);
885+
886+
// query2 - first to non-aggregate target, then to aggregate target
887+
const target2 = queryToTarget(query2);
888+
const aggregateTarget2 = queryToAggregateTarget(query2);
889+
890+
expect(aggregateTarget1.orderBy.length).to.equal(0);
891+
892+
expect(aggregateTarget2.orderBy.length).to.equal(0);
893+
894+
expect(target1.orderBy.length).to.equal(2);
895+
expect(target1.orderBy[0].dir).to.equal('asc');
896+
expect(target1.orderBy[0].field.canonicalString()).to.equal('foo');
897+
expect(target1.orderBy[1].dir).to.equal('asc');
898+
expect(target1.orderBy[1].field.canonicalString()).to.equal('__name__');
899+
900+
expect(target2.orderBy.length).to.equal(2);
901+
expect(target2.orderBy[0].dir).to.equal('asc');
902+
expect(target2.orderBy[0].field.canonicalString()).to.equal('foo');
903+
expect(target2.orderBy[1].dir).to.equal('asc');
904+
expect(target2.orderBy[1].field.canonicalString()).to.equal('__name__');
905+
});
906+
855907
function assertQueryMatches(
856908
query: Query,
857909
matching: MutableDocument[],
@@ -866,7 +918,7 @@ describe('Query', () => {
866918
}
867919

868920
function assertImplicitOrderBy(query: Query, ...orderBys: OrderBy[]): void {
869-
expect(queryOrderBy(query)).to.deep.equal(orderBys);
921+
expect(queryNormalizedOrderBy(query)).to.deep.equal(orderBys);
870922
}
871923

872924
function assertCanonicalId(query: Query, expectedCanonicalId: string): void {

0 commit comments

Comments
 (0)