diff --git a/dev/src/reference.ts b/dev/src/reference.ts index f2ea86229..503be5852 100644 --- a/dev/src/reference.ts +++ b/dev/src/reference.ts @@ -710,9 +710,6 @@ abstract class FilterInternal { /** Returns a list of all filters that are contained within this filter */ abstract getFilters(): FilterInternal[]; - /** Returns the field of the first filter that's an inequality, or null if none. */ - abstract getFirstInequalityField(): FieldPath | null; - /** Returns the proto representation of this filter */ abstract toProto(): Filter; @@ -735,13 +732,6 @@ class CompositeFilterInternal extends FilterInternal { return this.filters; } - public getFirstInequalityField(): FieldPath | null { - return ( - this.getFlattenedFilters().find(filter => filter.isInequalityFilter()) - ?.field ?? null - ); - } - public isConjunction(): boolean { return this.operator === 'AND'; } @@ -807,14 +797,6 @@ class FieldFilterInternal extends FilterInternal { return [this]; } - getFirstInequalityField(): FieldPath | null { - if (this.isInequalityFilter()) { - return this.field; - } else { - return null; - } - } - /** * @param serializer The Firestore serializer * @param field The path of the property value to compare. @@ -843,6 +825,8 @@ class FieldFilterInternal extends FilterInternal { case 'GREATER_THAN_OR_EQUAL': case 'LESS_THAN': case 'LESS_THAN_OR_EQUAL': + case 'NOT_EQUAL': + case 'NOT_IN': return true; default: return false; @@ -1821,6 +1805,25 @@ export class Query implements firestore.Query { ); } + /** + * Returns the sorted array of inequality filter fields used in this query. + * + * @return An array of inequality filter fields sorted lexicographically by FieldPath. + */ + private getInequalityFilterFields(): FieldPath[] { + const inequalityFields: FieldPath[] = []; + + for (const filter of this._queryOptions.filters) { + for (const subFilter of filter.getFlattenedFilters()) { + if (subFilter.isInequalityFilter()) { + inequalityFields.push(subFilter.field); + } + } + } + + return inequalityFields.sort((a, b) => a.compareTo(b)); + } + /** * Computes the backend ordering semantics for DocumentSnapshot cursors. * @@ -1855,29 +1858,36 @@ export class Query implements firestore.Query { } const fieldOrders = this._queryOptions.fieldOrders.slice(); - - // If no explicit ordering is specified, use the first inequality to - // define an implicit order. - if (fieldOrders.length === 0) { - for (const filter of this._queryOptions.filters) { - const fieldReference = filter.getFirstInequalityField(); - if (fieldReference !== null) { - fieldOrders.push(new FieldOrder(fieldReference)); - break; - } + const fieldsNormalized = new Set([ + ...fieldOrders.map(item => item.field.toString()), + ]); + + /** The order of the implicit ordering always matches the last explicit order by. */ + const lastDirection = + fieldOrders.length === 0 + ? directionOperators.ASC + : fieldOrders[fieldOrders.length - 1].direction; + + /** + * Any inequality fields not explicitly ordered should be implicitly ordered in a + * lexicographical order. When there are multiple inequality filters on the same field, the + * field should be added only once. + * Note: getInequalityFilterFields function sorts the key field before + * other fields. However, we want the key field to be sorted last. + */ + const inequalityFields = this.getInequalityFilterFields(); + for (const field of inequalityFields) { + if ( + !fieldsNormalized.has(field.toString()) && + !field.isEqual(FieldPath.documentId()) + ) { + fieldOrders.push(new FieldOrder(field, lastDirection)); + fieldsNormalized.add(field.toString()); } } - const hasDocumentId = !!fieldOrders.find(fieldOrder => - FieldPath.documentId().isEqual(fieldOrder.field) - ); - if (!hasDocumentId) { - // Add implicit sorting by name, using the last specified direction. - const lastDirection = - fieldOrders.length === 0 - ? directionOperators.ASC - : fieldOrders[fieldOrders.length - 1].direction; - + // Add the document key field to the last if it is not explicitly ordered. + if (!fieldsNormalized.has(FieldPath.documentId().toString())) { fieldOrders.push(new FieldOrder(FieldPath.documentId(), lastDirection)); } diff --git a/dev/system-test/firestore.ts b/dev/system-test/firestore.ts index 12a43e478..4a8153e7e 100644 --- a/dev/system-test/firestore.ts +++ b/dev/system-test/firestore.ts @@ -2544,6 +2544,396 @@ describe('Query class', () => { unsubscribe(); }); }); + + (process.env.FIRESTORE_EMULATOR_HOST === undefined + ? describe.skip + : describe)('multiple inequality', () => { + it('supports multiple inequality queries', async () => { + const collection = await testCollectionWithDocs({ + doc1: {key: 'a', sort: 0, v: 0}, + doc2: {key: 'b', sort: 3, v: 1}, + doc3: {key: 'c', sort: 1, v: 3}, + doc4: {key: 'd', sort: 2, v: 2}, + }); + + // Multiple inequality fields + let results = await collection + .where('key', '!=', 'a') + .where('sort', '<=', 2) + .where('v', '>', 2) + .get(); + expectDocs(results, 'doc3'); + + // Duplicate inequality fields + results = await collection + .where('key', '!=', 'a') + .where('sort', '<=', 2) + .where('sort', '>', 1) + .get(); + expectDocs(results, 'doc4'); + + // With multiple IN + results = await collection + .where('key', '>=', 'a') + .where('sort', '<=', 2) + .where('v', 'in', [2, 3, 4]) + .where('sort', 'in', [2, 3]) + .get(); + expectDocs(results, 'doc4'); + + // With NOT-IN + results = await collection + .where('key', '>=', 'a') + .where('sort', '<=', 2) + .where('v', 'not-in', [2, 4, 5]) + .get(); + expectDocs(results, 'doc1', 'doc3'); + + // With orderby + results = await collection + .where('key', '>=', 'a') + .where('sort', '<=', 2) + .orderBy('v', 'desc') + .get(); + expectDocs(results, 'doc3', 'doc4', 'doc1'); + + // With limit + results = await collection + .where('key', '>=', 'a') + .where('sort', '<=', 2) + .orderBy('v', 'desc') + .limit(2) + .get(); + expectDocs(results, 'doc3', 'doc4'); + + // With limitToLast + results = await collection + .where('key', '>=', 'a') + .where('sort', '<=', 2) + .orderBy('v', 'desc') + .limitToLast(2) + .get(); + expectDocs(results, 'doc4', 'doc1'); + }); + + it('can use on special values', async () => { + const collection = await testCollectionWithDocs({ + doc1: {key: 'a', sort: 0, v: 0}, + doc2: {key: 'b', sort: NaN, v: 1}, + doc3: {key: 'c', sort: null, v: 3}, + doc4: {key: 'd', v: 0}, + doc5: {key: 'e', sort: 1}, + doc6: {key: 'f', sort: 1, v: 1}, + }); + + let results = await collection + .where('key', '!=', 'a') + .where('sort', '<=', 2) + .get(); + expectDocs(results, 'doc5', 'doc6'); + + results = await collection + .where('key', '!=', 'a') + .where('sort', '<=', 2) + .where('v', '<=', 1) + .get(); + expectDocs(results, 'doc6'); + }); + + it('can use with array membership', async () => { + const collection = await testCollectionWithDocs({ + doc1: {key: 'a', sort: 0, v: [0]}, + doc2: {key: 'b', sort: 1, v: [0, 1, 3]}, + doc3: {key: 'c', sort: 1, v: []}, + doc4: {key: 'd', sort: 2, v: [1]}, + doc5: {key: 'e', sort: 3, v: [2, 4]}, + doc6: {key: 'f', sort: 4, v: [NaN]}, + doc7: {key: 'g', sort: 4, v: [null]}, + }); + + let results = await collection + .where('key', '!=', 'a') + .where('sort', '>=', 1) + .where('v', 'array-contains', 0) + .get(); + expectDocs(results, 'doc2'); + + results = await collection + .where('key', '!=', 'a') + .where('sort', '>=', 1) + .where('v', 'array-contains-any', [0, 1]) + .get(); + expectDocs(results, 'doc2', 'doc4'); + }); + + // Use cursor in following test cases to add implicit order by fields in the sdk and compare the + // result with the query fields normalized in the server. + it('can use with nested field', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const testData = (n?: number): any => { + n = n || 1; + return { + name: 'room ' + n, + metadata: { + createdAt: n, + }, + field: 'field ' + n, + 'field.dot': n, + 'field\\slash': n, + }; + }; + + const collection = await testCollectionWithDocs({ + doc1: testData(400), + doc2: testData(200), + doc3: testData(100), + doc4: testData(300), + }); + + // ordered by: name asc, metadata.createdAt asc, __name__ asc + let query = collection + .where('metadata.createdAt', '<=', 500) + .where('metadata.createdAt', '>', 100) + .where('name', '!=', 'room 200') + .orderBy('name'); + let docSnap = await collection.doc('doc4').get(); + let queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc4', 'doc1'); + expectDocs(await queryWithCursor.get(), 'doc4', 'doc1'); + + // ordered by: name desc, field desc, field.dot desc, field\\slash desc, __name__ desc + query = collection + .where('field', '>=', 'field 100') + .where(new FieldPath('field.dot'), '!=', 300) + .where('field\\slash', '<', 400) + .orderBy('name', 'desc'); + docSnap = await collection.doc('doc2').get(); + queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc2', 'doc3'); + expectDocs(await queryWithCursor.get(), 'doc2', 'doc3'); + }); + + it('can use with nested composite filters', async () => { + const collection = await testCollectionWithDocs({ + doc1: {key: 'a', sort: 0, v: 5}, + doc2: {key: 'aa', sort: 4, v: 4}, + doc3: {key: 'c', sort: 3, v: 3}, + doc4: {key: 'b', sort: 2, v: 2}, + doc5: {key: 'b', sort: 2, v: 1}, + doc6: {key: 'b', sort: 0, v: 0}, + }); + + // Implicitly ordered by: 'key' asc, 'sort' asc, 'v' asc, __name__ asc + let query = collection.where( + Filter.or( + Filter.and( + Filter.where('key', '==', 'b'), + Filter.where('sort', '<=', 2) + ), + Filter.and(Filter.where('key', '!=', 'b'), Filter.where('v', '>', 4)) + ) + ); + let docSnap = await collection.doc('doc1').get(); + let queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc1', 'doc6', 'doc5', 'doc4'); + expectDocs(await queryWithCursor.get(), 'doc1', 'doc6', 'doc5', 'doc4'); + + // Ordered by: 'sort' desc, 'key' asc, 'v' asc, __name__ asc + query = collection + .where( + Filter.or( + Filter.and( + Filter.where('key', '==', 'b'), + Filter.where('sort', '<=', 2) + ), + Filter.and( + Filter.where('key', '!=', 'b'), + Filter.where('v', '>', 4) + ) + ) + ) + .orderBy('sort', 'desc') + .orderBy('key'); + docSnap = await collection.doc('doc5').get(); + queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc5', 'doc4', 'doc1', 'doc6'); + expectDocs(await queryWithCursor.get(), 'doc5', 'doc4', 'doc1', 'doc6'); + + // Implicitly ordered by: 'key' asc, 'sort' asc, 'v' asc, __name__ asc + query = collection.where( + Filter.and( + Filter.or( + Filter.and( + Filter.where('key', '==', 'b'), + Filter.where('sort', '<=', 4) + ), + Filter.and( + Filter.where('key', '!=', 'b'), + Filter.where('v', '>=', 4) + ) + ), + Filter.or( + Filter.and( + Filter.where('key', '>', 'b'), + Filter.where('sort', '>=', 1) + ), + Filter.and(Filter.where('key', '<', 'b'), Filter.where('v', '>', 0)) + ) + ) + ); + docSnap = await collection.doc('doc1').get(); + queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc1', 'doc2'); + expectDocs(await queryWithCursor.get(), 'doc1', 'doc2'); + }); + + it('inequality fields will be implicitly ordered lexicographically by the server', async () => { + const collection = await testCollectionWithDocs({ + doc1: {key: 'a', sort: 0, v: 5}, + doc2: {key: 'aa', sort: 4, v: 4}, + doc3: {key: 'b', sort: 3, v: 3}, + doc4: {key: 'b', sort: 2, v: 2}, + doc5: {key: 'b', sort: 2, v: 1}, + doc6: {key: 'b', sort: 0, v: 0}, + }); + + const docSnap = await collection.doc('doc2').get(); + + // Implicitly ordered by: 'key' asc, 'sort' asc, __name__ asc + let query = collection + .where('key', '!=', 'a') + .where('sort', '>', 1) + .where('v', 'in', [1, 2, 3, 4]); + let queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc2', 'doc4', 'doc5', 'doc3'); + expectDocs(await queryWithCursor.get(), 'doc2', 'doc4', 'doc5', 'doc3'); + + // Changing filters order will not effect implicit order. + // Implicitly ordered by: 'key' asc, 'sort' asc, __name__ asc + query = collection + .where('sort', '>', 1) + .where('key', '!=', 'a') + .where('v', 'in', [1, 2, 3, 4]); + queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc2', 'doc4', 'doc5', 'doc3'); + expectDocs(await queryWithCursor.get(), 'doc2', 'doc4', 'doc5', 'doc3'); + }); + + it('can use multiple explicit order by field', async () => { + const collection = await testCollectionWithDocs({ + doc1: {key: 'a', sort: 5, v: 0}, + doc2: {key: 'aa', sort: 4, v: 0}, + doc3: {key: 'b', sort: 3, v: 1}, + doc4: {key: 'b', sort: 2, v: 1}, + doc5: {key: 'bb', sort: 1, v: 1}, + doc6: {key: 'c', sort: 0, v: 2}, + }); + + let docSnap = await collection.doc('doc2').get(); + + // Ordered by: 'v' asc, 'key' asc, 'sort' asc, __name__ asc + let query = collection + .where('key', '>', 'a') + .where('sort', '>=', 1) + .orderBy('v'); + let queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc2', 'doc4', 'doc3', 'doc5'); + expectDocs(await queryWithCursor.get(), 'doc2', 'doc4', 'doc3', 'doc5'); + + // Ordered by: 'v asc, 'sort' asc, 'key' asc, __name__ asc + query = collection + .where('key', '>', 'a') + .where('sort', '>=', 1) + .orderBy('v') + .orderBy('sort'); + queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc2', 'doc5', 'doc4', 'doc3'); + expectDocs(await queryWithCursor.get(), 'doc2', 'doc5', 'doc4', 'doc3'); + + docSnap = await collection.doc('doc5').get(); + + // Implicit order by matches the direction of last explicit order by. + // Ordered by: 'v' desc, 'key' desc, 'sort' desc, __name__ desc + query = collection + .where('key', '>', 'a') + .where('sort', '>=', 1) + .orderBy('v', 'desc'); + queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc5', 'doc3', 'doc4', 'doc2'); + expectDocs(await queryWithCursor.get(), 'doc5', 'doc3', 'doc4', 'doc2'); + + // Ordered by: 'v desc, 'sort' asc, 'key' asc, __name__ asc + query = collection + .where('key', '>', 'a') + .where('sort', '>=', 1) + .orderBy('v', 'desc') + .orderBy('sort'); + queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc5', 'doc4', 'doc3', 'doc2'); + expectDocs(await queryWithCursor.get(), 'doc5', 'doc4', 'doc3', 'doc2'); + }); + + it('can use in aggregate query', async () => { + const collection = await testCollectionWithDocs({ + doc1: {key: 'a', sort: 5, v: 0}, + doc2: {key: 'aa', sort: 4, v: 0}, + doc3: {key: 'b', sort: 3, v: 1}, + doc4: {key: 'b', sort: 2, v: 1}, + doc5: {key: 'bb', sort: 1, v: 1}, + }); + + const results = await collection + .where('key', '>', 'a') + .where('sort', '>=', 1) + .orderBy('v') + .count() + .get(); + expect(results.data().count).to.be.equal(4); + //TODO(MIEQ): Add sum and average when they are public. + }); + + it('can use document ID im multiple inequality query', async () => { + const collection = await testCollectionWithDocs({ + doc1: {key: 'a', sort: 5}, + doc2: {key: 'aa', sort: 4}, + doc3: {key: 'b', sort: 3}, + doc4: {key: 'b', sort: 2}, + doc5: {key: 'bb', sort: 1}, + }); + + const docSnap = await collection.doc('doc2').get(); + + // Document Key in inequality field will implicitly ordered to the last. + // Implicitly ordered by: 'key' asc, 'sort' asc, __name__ asc + let query = collection + .where('sort', '>=', 1) + .where('key', '!=', 'a') + .where(FieldPath.documentId(), '<', 'doc5'); + let queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc2', 'doc4', 'doc3'); + expectDocs(await queryWithCursor.get(), 'doc2', 'doc4', 'doc3'); + + // Changing filters order will not effect implicit order. + // Implicitly ordered by: 'key' asc, 'sort' asc, __name__ asc + query = collection + .where(FieldPath.documentId(), '<', 'doc5') + .where('sort', '>=', 1) + .where('key', '!=', 'a'); + queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc2', 'doc4', 'doc3'); + expectDocs(await queryWithCursor.get(), 'doc2', 'doc4', 'doc3'); + + // Ordered by: 'sort' desc,'key' desc, __name__ desc + query = collection + .where(FieldPath.documentId(), '<', 'doc5') + .where('sort', '>=', 1) + .where('key', '!=', 'a') + .orderBy('sort', 'desc'); + queryWithCursor = query.startAt(docSnap); + expectDocs(await query.get(), 'doc2', 'doc3', 'doc4'); + expectDocs(await queryWithCursor.get(), 'doc2', 'doc3', 'doc4'); + }); + }); }); describe('Aggregates', () => { diff --git a/dev/test/query.ts b/dev/test/query.ts index d65dde537..1e8458f61 100644 --- a/dev/test/query.ts +++ b/dev/test/query.ts @@ -2440,6 +2440,570 @@ describe('startAt() interface', () => { }); }); + describe('inequality fields are implicitly ordered lexicographically for cursors', () => { + it('upper and lower case characters', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + 'A', + 'ASCENDING', + 'a', + 'ASCENDING', + 'aa', + 'ASCENDING', + 'b', + 'ASCENDING', + '__name__', + 'ASCENDING' + ), + startAt(true, 'A', 'a', 'aa', 'b', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }), + fieldFiltersQuery( + 'a', + 'LESS_THAN', + 'value', + 'a', + 'GREATER_THAN_OR_EQUAL', + 'value', + 'aa', + 'GREATER_THAN', + 'value', + 'b', + 'GREATER_THAN', + 'value', + 'A', + 'GREATER_THAN', + 'value' + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', { + a: 'a', + aa: 'aa', + b: 'b', + A: 'A', + }).then(doc => { + const query = firestore + .collection('collectionId') + .where('a', '<', 'value') + .where('a', '>=', 'value') + .where('aa', '>', 'value') + .where('b', '>', 'value') + .where('A', '>', 'value') + .startAt(doc); + return query.get(); + }); + }); + }); + + it('characters and numbers', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + '`1`', + 'ASCENDING', + '`19`', + 'ASCENDING', + '`2`', + 'ASCENDING', + 'a', + 'ASCENDING', + '__name__', + 'ASCENDING' + ), + startAt(true, '1', '19', '2', 'a', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }), + fieldFiltersQuery( + 'a', + 'LESS_THAN', + 'value', + '`1`', + 'GREATER_THAN', + 'value', + '`19`', + 'GREATER_THAN', + 'value', + '`2`', + 'GREATER_THAN', + 'value' + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', { + a: 'a', + 1: '1', + 19: '19', + 2: '2', + }).then(doc => { + const query = firestore + .collection('collectionId') + .where('a', '<', 'value') + .where('1', '>', 'value') + .where('19', '>', 'value') + .where('2', '>', 'value') + .startAt(doc); + return query.get(); + }); + }); + }); + + it('nested fields', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + 'a', + 'ASCENDING', + 'a.a', + 'ASCENDING', + 'aa', + 'ASCENDING', + '__name__', + 'ASCENDING' + ), + startAt( + true, + { + mapValue: { + fields: { + a: { + stringValue: 'a.a', + }, + }, + }, + }, + 'a.a', + 'aa', + { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + } + ), + fieldFiltersQuery( + 'a', + 'LESS_THAN', + 'value', + 'a.a', + 'GREATER_THAN', + 'value', + 'aa', + 'GREATER_THAN', + 'value' + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {a: {a: 'a.a'}, aa: 'aa'}).then( + doc => { + const query = firestore + .collection('collectionId') + .where('a', '<', 'value') + .where('a.a', '>', 'value') + .where('aa', '>', 'value') + .startAt(doc); + return query.get(); + } + ); + }); + }); + + it('special characters', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + '_a', + 'ASCENDING', + 'a', + 'ASCENDING', + 'a.a', + 'ASCENDING', + '__name__', + 'ASCENDING' + ), + startAt( + true, + '_a', + { + mapValue: { + fields: { + a: { + stringValue: 'a.a', + }, + }, + }, + }, + 'a.a', + { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + } + ), + fieldFiltersQuery( + 'a', + 'LESS_THAN', + 'a', + '_a', + 'GREATER_THAN', + '_a', + 'a.a', + 'GREATER_THAN', + 'a.a' + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {a: {a: 'a.a'}, _a: '_a'}).then( + doc => { + const query = firestore + .collection('collectionId') + .where('a', '<', 'a') + .where('_a', '>', '_a') + .where('a.a', '>', 'a.a') + .startAt(doc); + return query.get(); + } + ); + }); + }); + + it('field name with dot', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + 'a', + 'ASCENDING', + 'a.z', + 'ASCENDING', + '`a.a`', + 'ASCENDING', + '__name__', + 'ASCENDING' + ), + startAt( + true, + { + mapValue: { + fields: { + z: { + stringValue: 'a.z', + }, + }, + }, + }, + 'a.z', + 'a.a', + { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + } + ), + fieldFiltersQuery( + 'a', + 'LESS_THAN', + 'value', + '`a.a`', + 'GREATER_THAN', + 'value', + 'a.z', + 'GREATER_THAN', + 'value' + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {a: {z: 'a.z'}, 'a.a': 'a.a'}).then( + doc => { + const query = firestore + .collection('collectionId') + .where('a', '<', 'value') + .where(new FieldPath('a.a'), '>', 'value') // field name with dot + .where('a.z', '>', 'value') // nested field + .startAt(doc); + return query.get(); + } + ); + }); + }); + + it('composite filter', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + 'a', + 'ASCENDING', + 'b', + 'ASCENDING', + 'c', + 'ASCENDING', + 'd', + 'ASCENDING', + '__name__', + 'ASCENDING' + ), + startAt(true, 'a', 'b', 'c', 'd', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }), + where( + compositeFilter( + 'AND', + fieldFilter('a', 'LESS_THAN', 'value'), + + compositeFilter( + 'AND', + compositeFilter( + 'OR', + fieldFilter('b', 'GREATER_THAN_OR_EQUAL', 'value'), + fieldFilter('c', 'LESS_THAN_OR_EQUAL', 'value') + ), + compositeFilter( + 'OR', + fieldFilter('d', 'GREATER_THAN', 'value'), + fieldFilter('e', 'EQUAL', 'value') + ) + ) + ) + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', { + a: 'a', + b: 'b', + c: 'c', + d: 'd', + e: 'e', + }).then(doc => { + const query = firestore + .collection('collectionId') + .where('a', '<', 'value') + .where( + Filter.and( + Filter.or( + Filter.where('b', '>=', 'value'), + Filter.where('c', '<=', 'value') + ), + Filter.or( + Filter.where('d', '>', 'value'), + Filter.where('e', '==', 'value') + ) + ) + ) + .startAt(doc); + return query.get(); + }); + }); + }); + + it('explicit orderby', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + 'z', + 'ASCENDING', + 'a', + 'ASCENDING', + 'b', + 'ASCENDING', + '__name__', + 'ASCENDING' + ), + startAt(true, 'z', 'a', 'b', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }), + fieldFiltersQuery( + 'b', + 'LESS_THAN', + 'value', + 'a', + 'GREATER_THAN', + 'value', + 'z', + 'GREATER_THAN', + 'value' + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', { + a: 'a', + b: 'b', + z: 'z', + }).then(doc => { + const query = firestore + .collection('collectionId') + .where('b', '<', 'value') + .where('a', '>', 'value') + .where('z', '>', 'value') + .orderBy('z') + .startAt(doc); + return query.get(); + }); + }); + }); + + it('explicit order by direction', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + 'z', + 'DESCENDING', + 'a', + 'DESCENDING', + 'b', + 'DESCENDING', + '__name__', + 'DESCENDING' + ), + startAt(true, 'z', 'a', 'b', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }), + fieldFiltersQuery( + 'b', + 'LESS_THAN', + 'value', + 'a', + 'GREATER_THAN', + 'value' + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', { + a: 'a', + b: 'b', + z: 'z', + }).then(doc => { + const query = firestore + .collection('collectionId') + .where('b', '<', 'value') + .where('a', '>', 'value') + .orderBy('z', 'desc') + .startAt(doc); + return query.get(); + }); + }); + }); + + it('last explicit order by direction', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + 'z', + 'DESCENDING', + 'c', + 'ASCENDING', + 'a', + 'ASCENDING', + 'b', + 'ASCENDING', + '__name__', + 'ASCENDING' + ), + startAt(true, 'z', 'c', 'a', 'b', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }), + fieldFiltersQuery( + 'b', + 'LESS_THAN', + 'value', + 'a', + 'GREATER_THAN', + 'value' + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', { + a: 'a', + b: 'b', + c: 'c', + z: 'z', + }).then(doc => { + const query = firestore + .collection('collectionId') + .where('b', '<', 'value') + .where('a', '>', 'value') + .orderBy('z', 'desc') + .orderBy('c') + .startAt(doc); + return query.get(); + }); + }); + }); + }); + it('validates field exists in document snapshot', () => { const query = firestore.collection('collectionId').orderBy('foo', 'desc');