Skip to content

Commit 7b85096

Browse files
committed
feat(firestore): global option to turn on implicit orderby
1 parent f82d304 commit 7b85096

9 files changed

Lines changed: 290 additions & 23 deletions

File tree

.DS_Store

6 KB
Binary file not shown.

handwritten/firestore/dev/src/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,13 @@ export class Firestore implements firestore.Firestore {
783783
validateBoolean('settings.ssl', settings.ssl);
784784
}
785785

786+
if (settings.alwaysUseImplicitOrderBy !== undefined) {
787+
validateBoolean(
788+
'settings.alwaysUseImplicitOrderBy',
789+
settings.alwaysUseImplicitOrderBy,
790+
);
791+
}
792+
786793
if (settings.maxIdleChannels !== undefined) {
787794
validateInteger('settings.maxIdleChannels', settings.maxIdleChannels, {
788795
minValue: 0,
@@ -845,6 +852,14 @@ export class Firestore implements firestore.Firestore {
845852
return this._databaseId || DEFAULT_DATABASE_ID;
846853
}
847854

855+
/**
856+
* Whether to always use implicit order by clauses.
857+
* @internal
858+
*/
859+
get alwaysUseImplicitOrderBy(): boolean {
860+
return this._settings.alwaysUseImplicitOrderBy ?? false;
861+
}
862+
848863
/**
849864
* Returns the root path of the database. Validates that
850865
* `initializeIfNeeded()` was called before.

handwritten/firestore/dev/src/reference/query.ts

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -933,9 +933,6 @@ export class Query<
933933

934934
private createImplicitOrderBy(ignoreInequalityFields = false): FieldOrder[] {
935935
const fieldOrders = this._queryOptions.fieldOrders.slice();
936-
const fieldsNormalized = new Set([
937-
...fieldOrders.map(item => item.field.toString()),
938-
]);
939936

940937
/** The order of the implicit ordering always matches the last explicit order by. */
941938
const lastDirection =
@@ -954,17 +951,16 @@ export class Query<
954951
const inequalityFields = this.getInequalityFilterFields();
955952
for (const field of inequalityFields) {
956953
if (
957-
!fieldsNormalized.has(field.toString()) &&
954+
!fieldOrders.some(item => item.field.isEqual(field)) &&
958955
!field.isEqual(FieldPath.documentId())
959956
) {
960957
fieldOrders.push(new FieldOrder(field, lastDirection));
961-
fieldsNormalized.add(field.toString());
962958
}
963959
}
964960
}
965961

966962
// Add the document key field to the last if it is not explicitly ordered.
967-
if (!fieldsNormalized.has(FieldPath.documentId().toString())) {
963+
if (!fieldOrders.some(item => item.field.isEqual(FieldPath.documentId()))) {
968964
fieldOrders.push(new FieldOrder(FieldPath.documentId(), lastDirection));
969965
}
970966

@@ -1475,6 +1471,7 @@ export class Query<
14751471
toProto(
14761472
transactionOrReadTime?: Uint8Array | Timestamp | api.ITransactionOptions,
14771473
explainOptions?: firestore.ExplainOptions,
1474+
forceImplicitOrderBy?: boolean,
14781475
): api.IRunQueryRequest {
14791476
const projectId = this.firestore.projectId;
14801477
const databaseId = this.firestore.databaseId;
@@ -1483,18 +1480,18 @@ export class Query<
14831480
databaseId,
14841481
);
14851482

1486-
const structuredQuery = this.toStructuredQuery();
1483+
const structuredQuery = this.toStructuredQuery(forceImplicitOrderBy);
14871484

14881485
// For limitToLast queries, the structured query has to be translated to a version with
14891486
// reversed ordered, and flipped startAt/endAt to work properly.
14901487
if (this._queryOptions.limitType === LimitType.Last) {
1491-
if (!this._queryOptions.hasFieldOrders()) {
1492-
throw new Error(
1493-
'limitToLast() queries require specifying at least one orderBy() clause.',
1494-
);
1495-
}
1488+
const forceImplicit =
1489+
forceImplicitOrderBy || this._firestore.alwaysUseImplicitOrderBy;
1490+
const fieldOrders = forceImplicit
1491+
? this.createImplicitOrderBy()
1492+
: this._queryOptions.fieldOrders;
14961493

1497-
structuredQuery.orderBy = this._queryOptions.fieldOrders!.map(order => {
1494+
structuredQuery.orderBy = fieldOrders.map(order => {
14981495
// Flip the orderBy directions since we want the last results
14991496
const dir =
15001497
order.direction === 'DESCENDING' ? 'ASCENDING' : 'DESCENDING';
@@ -1564,7 +1561,9 @@ export class Query<
15641561
return bundledQuery;
15651562
}
15661563

1567-
private toStructuredQuery(): api.IStructuredQuery {
1564+
private toStructuredQuery(
1565+
forceImplicitOrderBy?: boolean,
1566+
): api.IStructuredQuery {
15681567
const structuredQuery: api.IStructuredQuery = {
15691568
from: [{}],
15701569
};
@@ -1586,9 +1585,19 @@ export class Query<
15861585
).toProto();
15871586
}
15881587

1589-
if (this._queryOptions.hasFieldOrders()) {
1590-
structuredQuery.orderBy = this._queryOptions.fieldOrders.map(o =>
1591-
o.toProto(),
1588+
// orders
1589+
const forceImplicit =
1590+
forceImplicitOrderBy || this._firestore.alwaysUseImplicitOrderBy;
1591+
let fieldOrders = this._queryOptions.fieldOrders;
1592+
if (forceImplicit) {
1593+
fieldOrders = this.createImplicitOrderBy();
1594+
}
1595+
1596+
if (fieldOrders.length > 0) {
1597+
structuredQuery.orderBy = fieldOrders.map(o => o.toProto());
1598+
} else if (this._queryOptions.limitType === LimitType.Last) {
1599+
throw new Error(
1600+
'limitToLast() queries require specifying at least one orderBy() clause.',
15921601
);
15931602
}
15941603

handwritten/firestore/dev/src/watch.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -941,7 +941,12 @@ export class QueryWatch<
941941
}
942942

943943
getTarget(resumeToken?: Uint8Array): google.firestore.v1.ITarget {
944-
const query = this.query.toProto();
944+
const forceImplicitOrderBy = !this.firestore.alwaysUseImplicitOrderBy;
945+
const query = this.query.toProto(
946+
undefined,
947+
undefined,
948+
forceImplicitOrderBy,
949+
);
945950
return {query, targetId: WATCH_TARGET_ID, resumeToken};
946951
}
947952
}

handwritten/firestore/dev/system-test/query.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2291,5 +2291,66 @@ describe.skipClassic('Query and Pipeline Compare - Enterprise DB', () => {
22912291
expectDocs(await compareQueryAndPipeline(query), 'doc2', 'doc3', 'doc4');
22922292
expectDocs(await queryWithCursor.get(), 'doc2', 'doc3', 'doc4');
22932293
});
2294+
2295+
it('alwaysUseImplicitOrderBy returns same results', async () => {
2296+
const collection = getTestRoot();
2297+
const docs = {
2298+
doc01: {sort: 1},
2299+
doc02: {sort: 2},
2300+
doc03: {sort: 3},
2301+
doc04: {sort: 4},
2302+
doc05: {sort: 5},
2303+
doc06: {sort: 6},
2304+
doc07: {sort: 7},
2305+
doc08: {sort: 8},
2306+
doc09: {sort: 9},
2307+
doc10: {sort: 10},
2308+
};
2309+
2310+
for (const [id, data] of Object.entries(docs)) {
2311+
await collection.doc(id).set(data);
2312+
}
2313+
2314+
const expectedOrder = [
2315+
'doc02',
2316+
'doc03',
2317+
'doc04',
2318+
'doc05',
2319+
'doc06',
2320+
'doc07',
2321+
'doc08',
2322+
'doc09',
2323+
'doc10',
2324+
];
2325+
2326+
const originalQuery = collection.where('sort', '>', 1);
2327+
const originalSnapshot = await originalQuery.get();
2328+
const originalResult = originalSnapshot.docs.map(d => d.id);
2329+
2330+
// In Enterprise, the order might not be strictly deterministic without implicit order by
2331+
// unless it's Standard edition. Standard Edition always has implicit order by.
2332+
// This test is intended to run against Enterprise where possible.
2333+
// If it's Standard, originalResult will be equal to expectedOrder.
2334+
// If it's Enterprise, it might not be.
2335+
2336+
const modifiedFirestore = new Firestore({
2337+
...firestore.settings,
2338+
alwaysUseImplicitOrderBy: true,
2339+
});
2340+
const modifiedCollection = modifiedFirestore.collection(collection.id);
2341+
const query = modifiedCollection.where('sort', '>', 1);
2342+
const snapshot = await query.get();
2343+
const result = snapshot.docs.map(d => d.id);
2344+
2345+
// since alwaysUseImplicitOrderBy is true, we expect strict ordering.
2346+
expect(result).to.deep.equal(expectedOrder);
2347+
2348+
if (originalResult.length === expectedOrder.length) {
2349+
// We can't easily check if it's Enterprise vs Standard from within the test
2350+
// without more setup, but we can at least verify that our new option
2351+
// produces the expected deterministic result.
2352+
expect(result).to.have.members(originalResult);
2353+
}
2354+
});
22942355
});
22952356
});

handwritten/firestore/dev/test/aggregateQuery.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {google} from '../protos/firestore_v1_proto_api';
2525
import api = google.firestore.v1;
2626
import * as chaiAsPromised from 'chai-as-promised';
2727
import {setTimeoutHandler} from '../src/backoff';
28+
import * as extend from 'extend';
29+
2830
use(chaiAsPromised);
2931

3032
describe('aggregate query interface', () => {
@@ -100,6 +102,57 @@ describe('aggregate query interface', () => {
100102
});
101103
});
102104

105+
it('supports alwaysUseImplicitOrderBy', async () => {
106+
const result: api.IRunAggregationQueryResponse = {
107+
result: {
108+
aggregateFields: {
109+
aggregate_0: {integerValue: '99'},
110+
},
111+
},
112+
readTime: {seconds: 5, nanos: 6},
113+
};
114+
const overrides: ApiOverride = {
115+
runAggregationQuery: request => {
116+
let actualStructuredQuery =
117+
request!.structuredAggregationQuery?.structuredQuery;
118+
actualStructuredQuery = extend(true, {}, actualStructuredQuery);
119+
expect(actualStructuredQuery).to.deep.equal({
120+
from: [{collectionId: 'collectionId'}],
121+
where: {
122+
fieldFilter: {
123+
field: {fieldPath: 'foo'},
124+
op: 'GREATER_THAN' as api.StructuredQuery.FieldFilter.Operator,
125+
value: {stringValue: 'bar'},
126+
},
127+
},
128+
orderBy: [
129+
{
130+
direction: 'ASCENDING' as api.StructuredQuery.Direction,
131+
field: {fieldPath: 'foo'},
132+
},
133+
{
134+
direction: 'ASCENDING' as api.StructuredQuery.Direction,
135+
field: {fieldPath: '__name__'},
136+
},
137+
],
138+
});
139+
return stream(result);
140+
},
141+
};
142+
143+
firestore = await createInstance(overrides, {
144+
alwaysUseImplicitOrderBy: true,
145+
});
146+
147+
const query = firestore
148+
.collection('collectionId')
149+
.where('foo', '>', 'bar')
150+
.count();
151+
return query.get().then(results => {
152+
expect(results.data().count).to.be.equal(99);
153+
});
154+
});
155+
103156
it('handles stream exception at initialization', async () => {
104157
let attempts = 0;
105158
const query = firestore.collection('collectionId').count();

handwritten/firestore/dev/test/query.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,48 @@ describe('query interface', () => {
609609
expect(results.readTime.isEqual(new Timestamp(5, 6))).to.be.true;
610610
});
611611

612+
it('supports alwaysUseImplicitOrderBy with limitToLast', async () => {
613+
const overrides: ApiOverride = {
614+
runQuery: request => {
615+
queryEquals(
616+
request,
617+
where(fieldFilters('foo', 'GREATER_THAN_OR_EQUAL', 'bar')),
618+
{
619+
orderBy: [
620+
{
621+
field: {fieldPath: 'foo'},
622+
direction: 'DESCENDING',
623+
},
624+
{
625+
field: {fieldPath: '__name__'},
626+
direction: 'DESCENDING',
627+
},
628+
],
629+
limit: {value: 1},
630+
},
631+
);
632+
return stream({readTime: {seconds: 5, nanos: 6}});
633+
},
634+
};
635+
636+
firestore = await createInstance(overrides, {
637+
alwaysUseImplicitOrderBy: true,
638+
});
639+
const query = firestore
640+
.collection('collectionId')
641+
.where('foo', '>=', 'bar')
642+
.limitToLast(1);
643+
await query.get();
644+
});
645+
646+
it('throws for limitToLast without orderBy', async () => {
647+
firestore = await createInstance();
648+
const query = firestore.collection('collectionId').limitToLast(1);
649+
expect(() => query.toProto()).to.throw(
650+
'limitToLast() queries require specifying at least one orderBy() clause.',
651+
);
652+
});
653+
612654
it('retries on stream failure', async () => {
613655
let attempts = 0;
614656
const overrides: ApiOverride = {

0 commit comments

Comments
 (0)