Skip to content

Commit b395277

Browse files
Fix: Aggregate query order-by normalization (#7507)
Fix aggregate query order-by normalization to support future aggregate operations.
1 parent e201e53 commit b395277

File tree

5 files changed

+163
-70
lines changed

5 files changed

+163
-70
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

+100-64
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,18 @@ 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

70-
// The corresponding `Target` of this `Query` instance.
70+
// The corresponding `Target` of this `Query` instance, for use with
71+
// non-aggregate queries.
7172
memoizedTarget: Target | null = null;
7273

74+
// The corresponding `Target` of this `Query` instance, for use with
75+
// aggregate queries. Unlike targets for non-aggregate queries,
76+
// aggregate query targets do not contain normalized order-bys, they only
77+
// contain explicit order-bys.
78+
memoizedAggregateTarget: Target | null = null;
79+
7380
/**
7481
* Initializes a Query with a path and optional additional query constraints.
7582
* Path must currently be empty if this is a collection group query.
@@ -86,13 +93,13 @@ export class QueryImpl implements Query {
8693
) {
8794
if (this.startAt) {
8895
debugAssert(
89-
this.startAt.position.length <= queryOrderBy(this).length,
96+
this.startAt.position.length <= queryNormalizedOrderBy(this).length,
9097
'Bound is longer than orderBy'
9198
);
9299
}
93100
if (this.endAt) {
94101
debugAssert(
95-
this.endAt.position.length <= queryOrderBy(this).length,
102+
this.endAt.position.length <= queryNormalizedOrderBy(this).length,
96103
'Bound is longer than orderBy'
97104
);
98105
}
@@ -211,14 +218,16 @@ export function isCollectionGroupQuery(query: Query): boolean {
211218
}
212219

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

223232
const inequalityField = getInequalityFilterField(queryImpl);
224233
const firstOrderByField = getFirstOrderByField(queryImpl);
@@ -227,9 +236,9 @@ export function queryOrderBy(query: Query): OrderBy[] {
227236
// inequality filter field for it to be a valid query.
228237
// Note that the default inequality field and key ordering is ascending.
229238
if (!inequalityField.isKeyField()) {
230-
queryImpl.memoizedOrderBy.push(new OrderBy(inequalityField));
239+
queryImpl.memoizedNormalizedOrderBy.push(new OrderBy(inequalityField));
231240
}
232-
queryImpl.memoizedOrderBy.push(
241+
queryImpl.memoizedNormalizedOrderBy.push(
233242
new OrderBy(FieldPath.keyField(), Direction.ASCENDING)
234243
);
235244
} else {
@@ -241,76 +250,103 @@ export function queryOrderBy(query: Query): OrderBy[] {
241250
);
242251
let foundKeyOrdering = false;
243252
for (const orderBy of queryImpl.explicitOrderBy) {
244-
queryImpl.memoizedOrderBy.push(orderBy);
253+
queryImpl.memoizedNormalizedOrderBy.push(orderBy);
245254
if (orderBy.field.isKeyField()) {
246255
foundKeyOrdering = true;
247256
}
248257
}
249258
if (!foundKeyOrdering) {
250259
// The order of the implicit key ordering always matches the last
251-
// explicit order by
260+
// explicit order-by
252261
const lastDirection =
253262
queryImpl.explicitOrderBy.length > 0
254263
? queryImpl.explicitOrderBy[queryImpl.explicitOrderBy.length - 1]
255264
.dir
256265
: Direction.ASCENDING;
257-
queryImpl.memoizedOrderBy.push(
266+
queryImpl.memoizedNormalizedOrderBy.push(
258267
new OrderBy(FieldPath.keyField(), lastDirection)
259268
);
260269
}
261270
}
262271
}
263-
return queryImpl.memoizedOrderBy;
272+
return queryImpl.memoizedNormalizedOrderBy;
264273
}
265274

266275
/**
267-
* Converts this `Query` instance to it's corresponding `Target` representation.
276+
* Converts this `Query` instance to its corresponding `Target` representation.
268277
*/
269278
export function queryToTarget(query: Query): Target {
270279
const queryImpl = debugCast(query, QueryImpl);
271280
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-
}
281+
queryImpl.memoizedTarget = _queryToTarget(
282+
queryImpl,
283+
queryNormalizedOrderBy(query)
284+
);
285+
}
292286

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

316352
export function queryWithAddedFilter(query: Query, filter: Filter): Query {
@@ -461,14 +497,14 @@ function queryMatchesPathAndCollectionGroup(
461497
* in the results.
462498
*/
463499
function queryMatchesOrderBy(query: Query, doc: Document): boolean {
464-
// We must use `queryOrderBy()` to get the list of all orderBys (both implicit and explicit).
500+
// We must use `queryNormalizedOrderBy()` to get the list of all orderBys (both implicit and explicit).
465501
// Note that for OR queries, orderBy applies to all disjunction terms and implicit orderBys must
466502
// be taken into account. For example, the query "a > 1 || b==1" has an implicit "orderBy a" due
467503
// to the inequality, and is evaluated as "a > 1 orderBy a || b==1 orderBy a".
468504
// A document with content of {b:1} matches the filters, but does not match the orderBy because
469505
// it's missing the field 'a'.
470-
for (const orderBy of queryOrderBy(query)) {
471-
// order by key always matches
506+
for (const orderBy of queryNormalizedOrderBy(query)) {
507+
// order-by key always matches
472508
if (!orderBy.field.isKeyField() && doc.data.field(orderBy.field) === null) {
473509
return false;
474510
}
@@ -489,13 +525,13 @@ function queryMatchesFilters(query: Query, doc: Document): boolean {
489525
function queryMatchesBounds(query: Query, doc: Document): boolean {
490526
if (
491527
query.startAt &&
492-
!boundSortsBeforeDocument(query.startAt, queryOrderBy(query), doc)
528+
!boundSortsBeforeDocument(query.startAt, queryNormalizedOrderBy(query), doc)
493529
) {
494530
return false;
495531
}
496532
if (
497533
query.endAt &&
498-
!boundSortsAfterDocument(query.endAt, queryOrderBy(query), doc)
534+
!boundSortsAfterDocument(query.endAt, queryNormalizedOrderBy(query), doc)
499535
) {
500536
return false;
501537
}
@@ -526,7 +562,7 @@ export function newQueryComparator(
526562
): (d1: Document, d2: Document) => number {
527563
return (d1: Document, d2: Document): number => {
528564
let comparedOnKeyField = false;
529-
for (const orderBy of queryOrderBy(query)) {
565+
for (const orderBy of queryNormalizedOrderBy(query)) {
530566
const comp = compareDocs(orderBy, d1, d2);
531567
if (comp !== 0) {
532568
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)