diff --git a/.changeset/lucky-games-arrive.md b/.changeset/lucky-games-arrive.md new file mode 100644 index 00000000000..bf5681ad634 --- /dev/null +++ b/.changeset/lucky-games-arrive.md @@ -0,0 +1,8 @@ +--- +"@firebase/firestore": minor +"firebase": minor +--- + +Functions in the Firestore package that return QueryConstraints (for example: `where(...)`, `limit(...)`, and `orderBy(...)`) +now return a more specific type, which extends QueryConstraint. Refactoring and code that supports future features is also +included in this release. diff --git a/common/api-review/firestore-lite.api.md b/common/api-review/firestore-lite.api.md index b336e0d0c8b..331b9dd2f36 100644 --- a/common/api-review/firestore-lite.api.md +++ b/common/api-review/firestore-lite.api.md @@ -140,16 +140,16 @@ export class DocumentSnapshot { export { EmulatorMockTokenOptions } // @public -export function endAt(snapshot: DocumentSnapshot): QueryConstraint; +export function endAt(snapshot: DocumentSnapshot): QueryEndAtConstraint; // @public -export function endAt(...fieldValues: unknown[]): QueryConstraint; +export function endAt(...fieldValues: unknown[]): QueryEndAtConstraint; // @public -export function endBefore(snapshot: DocumentSnapshot): QueryConstraint; +export function endBefore(snapshot: DocumentSnapshot): QueryEndAtConstraint; // @public -export function endBefore(...fieldValues: unknown[]): QueryConstraint; +export function endBefore(...fieldValues: unknown[]): QueryEndAtConstraint; // @public export class FieldPath { @@ -222,10 +222,10 @@ export function increment(n: number): FieldValue; export function initializeFirestore(app: FirebaseApp, settings: Settings): Firestore; // @public -export function limit(limit: number): QueryConstraint; +export function limit(limit: number): QueryLimitConstraint; // @public -export function limitToLast(limit: number): QueryConstraint; +export function limitToLast(limit: number): QueryLimitConstraint; export { LogLevel } @@ -235,7 +235,7 @@ export type NestedUpdateFields> = UnionToInter }[keyof T & string]>; // @public -export function orderBy(fieldPath: string | FieldPath, directionStr?: OrderByDirection): QueryConstraint; +export function orderBy(fieldPath: string | FieldPath, directionStr?: OrderByDirection): QueryOrderByConstraint; // @public export type OrderByDirection = 'desc' | 'asc'; @@ -275,9 +275,32 @@ export class QueryDocumentSnapshot extends DocumentSnapshot data(): T; } +// @public +export class QueryEndAtConstraint extends QueryConstraint { + readonly type: 'endBefore' | 'endAt'; +} + // @public export function queryEqual(left: Query, right: Query): boolean; +// @public +export class QueryFieldFilterConstraint extends QueryConstraint { + readonly type = "where"; +} + +// @public +export class QueryLimitConstraint extends QueryConstraint { + readonly type: 'limit' | 'limitToLast'; +} + +// @public +export type QueryNonFilterConstraint = QueryOrderByConstraint | QueryLimitConstraint | QueryStartAtConstraint | QueryEndAtConstraint; + +// @public +export class QueryOrderByConstraint extends QueryConstraint { + readonly type = "orderBy"; +} + // @public export class QuerySnapshot { get docs(): Array>; @@ -287,6 +310,11 @@ export class QuerySnapshot { get size(): number; } +// @public +export class QueryStartAtConstraint extends QueryConstraint { + readonly type: 'startAt' | 'startAfter'; +} + // @public export function refEqual(left: DocumentReference | CollectionReference, right: DocumentReference | CollectionReference): boolean; @@ -323,16 +351,16 @@ export interface Settings { export function snapshotEqual(left: DocumentSnapshot | QuerySnapshot, right: DocumentSnapshot | QuerySnapshot): boolean; // @public -export function startAfter(snapshot: DocumentSnapshot): QueryConstraint; +export function startAfter(snapshot: DocumentSnapshot): QueryStartAtConstraint; // @public -export function startAfter(...fieldValues: unknown[]): QueryConstraint; +export function startAfter(...fieldValues: unknown[]): QueryStartAtConstraint; // @public -export function startAt(snapshot: DocumentSnapshot): QueryConstraint; +export function startAt(snapshot: DocumentSnapshot): QueryStartAtConstraint; // @public -export function startAt(...fieldValues: unknown[]): QueryConstraint; +export function startAt(...fieldValues: unknown[]): QueryStartAtConstraint; // @public export function terminate(firestore: Firestore): Promise; @@ -388,7 +416,7 @@ export function updateDoc(reference: DocumentReference, data: UpdateData, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): Promise; // @public -export function where(fieldPath: string | FieldPath, opStr: WhereFilterOp, value: unknown): QueryConstraint; +export function where(fieldPath: string | FieldPath, opStr: WhereFilterOp, value: unknown): QueryFieldFilterConstraint; // @public export type WhereFilterOp = '<' | '<=' | '==' | '!=' | '>=' | '>' | 'array-contains' | 'in' | 'array-contains-any' | 'not-in'; diff --git a/common/api-review/firestore.api.md b/common/api-review/firestore.api.md index d61b7730781..5dd5742b318 100644 --- a/common/api-review/firestore.api.md +++ b/common/api-review/firestore.api.md @@ -170,16 +170,16 @@ export function enableMultiTabIndexedDbPersistence(firestore: Firestore): Promis export function enableNetwork(firestore: Firestore): Promise; // @public -export function endAt(snapshot: DocumentSnapshot): QueryConstraint; +export function endAt(snapshot: DocumentSnapshot): QueryEndAtConstraint; // @public -export function endAt(...fieldValues: unknown[]): QueryConstraint; +export function endAt(...fieldValues: unknown[]): QueryEndAtConstraint; // @public -export function endBefore(snapshot: DocumentSnapshot): QueryConstraint; +export function endBefore(snapshot: DocumentSnapshot): QueryEndAtConstraint; // @public -export function endBefore(...fieldValues: unknown[]): QueryConstraint; +export function endBefore(...fieldValues: unknown[]): QueryEndAtConstraint; // @public export class FieldPath { @@ -298,10 +298,10 @@ export interface IndexField { export function initializeFirestore(app: FirebaseApp, settings: FirestoreSettings, databaseId?: string): Firestore; // @public -export function limit(limit: number): QueryConstraint; +export function limit(limit: number): QueryLimitConstraint; // @public -export function limitToLast(limit: number): QueryConstraint; +export function limitToLast(limit: number): QueryLimitConstraint; // @public export function loadBundle(firestore: Firestore, bundleData: ReadableStream | ArrayBuffer | string): LoadBundleTask; @@ -383,7 +383,7 @@ export function onSnapshotsInSync(firestore: Firestore, observer: { export function onSnapshotsInSync(firestore: Firestore, onSync: () => void): Unsubscribe; // @public -export function orderBy(fieldPath: string | FieldPath, directionStr?: OrderByDirection): QueryConstraint; +export function orderBy(fieldPath: string | FieldPath, directionStr?: OrderByDirection): QueryOrderByConstraint; // @public export type OrderByDirection = 'desc' | 'asc'; @@ -428,9 +428,32 @@ export class QueryDocumentSnapshot extends DocumentSnapshot data(options?: SnapshotOptions): T; } +// @public +export class QueryEndAtConstraint extends QueryConstraint { + readonly type: 'endBefore' | 'endAt'; +} + // @public export function queryEqual(left: Query, right: Query): boolean; +// @public +export class QueryFieldFilterConstraint extends QueryConstraint { + readonly type = "where"; +} + +// @public +export class QueryLimitConstraint extends QueryConstraint { + readonly type: 'limit' | 'limitToLast'; +} + +// @public +export type QueryNonFilterConstraint = QueryOrderByConstraint | QueryLimitConstraint | QueryStartAtConstraint | QueryEndAtConstraint; + +// @public +export class QueryOrderByConstraint extends QueryConstraint { + readonly type = "orderBy"; +} + // @public export class QuerySnapshot { docChanges(options?: SnapshotListenOptions): Array>; @@ -442,6 +465,11 @@ export class QuerySnapshot { get size(): number; } +// @public +export class QueryStartAtConstraint extends QueryConstraint { + readonly type: 'startAt' | 'startAfter'; +} + // @public export function refEqual(left: DocumentReference | CollectionReference, right: DocumentReference | CollectionReference): boolean; @@ -494,16 +522,16 @@ export interface SnapshotOptions { } // @public -export function startAfter(snapshot: DocumentSnapshot): QueryConstraint; +export function startAfter(snapshot: DocumentSnapshot): QueryStartAtConstraint; // @public -export function startAfter(...fieldValues: unknown[]): QueryConstraint; +export function startAfter(...fieldValues: unknown[]): QueryStartAtConstraint; // @public -export function startAt(snapshot: DocumentSnapshot): QueryConstraint; +export function startAt(snapshot: DocumentSnapshot): QueryStartAtConstraint; // @public -export function startAt(...fieldValues: unknown[]): QueryConstraint; +export function startAt(...fieldValues: unknown[]): QueryStartAtConstraint; // @public export type TaskState = 'Error' | 'Running' | 'Success'; @@ -570,7 +598,7 @@ export function updateDoc(reference: DocumentReference, field: string | export function waitForPendingWrites(firestore: Firestore): Promise; // @public -export function where(fieldPath: string | FieldPath, opStr: WhereFilterOp, value: unknown): QueryConstraint; +export function where(fieldPath: string | FieldPath, opStr: WhereFilterOp, value: unknown): QueryFieldFilterConstraint; // @public export type WhereFilterOp = '<' | '<=' | '==' | '!=' | '>=' | '>' | 'array-contains' | 'in' | 'array-contains-any' | 'not-in'; diff --git a/packages/firestore/lite/index.ts b/packages/firestore/lite/index.ts index 5e3faa48e31..3cf337310df 100644 --- a/packages/firestore/lite/index.ts +++ b/packages/firestore/lite/index.ts @@ -68,19 +68,29 @@ export { } from '../src/lite-api/reference'; export { + and, endAt, endBefore, startAt, startAfter, limit, limitToLast, - orderBy, - OrderByDirection, where, - WhereFilterOp, + or, + orderBy, query, QueryConstraint, - QueryConstraintType + QueryConstraintType, + QueryCompositeFilterConstraint, + QueryFilterConstraint, + QueryFieldFilterConstraint, + QueryOrderByConstraint, + QueryLimitConstraint, + QueryNonFilterConstraint, + QueryStartAtConstraint, + QueryEndAtConstraint, + OrderByDirection, + WhereFilterOp } from '../src/lite-api/query'; export { diff --git a/packages/firestore/package.json b/packages/firestore/package.json index ec2ba937eec..6837e42f85d 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -30,6 +30,8 @@ "test:all:ci": "run-p test:browser test:lite:browser test:travis", "test:all": "run-p test:browser test:lite:browser test:travis test:minified", "test:browser": "karma start --single-run", + "test:browser:emulator:debug": "karma start --browsers=Chrome --local", + "test:browser:emulator": "karma start --single-run --local", "test:browser:unit": "karma start --single-run --unit", "test:browser:debug": "karma start --browsers=Chrome --auto-watch", "test:node": "node ./scripts/run-tests.js --main=test/register.ts --emulator 'test/{,!(browser|lite)/**/}*.test.ts'", diff --git a/packages/firestore/src/api.ts b/packages/firestore/src/api.ts index f05db09f568..2742195eecd 100644 --- a/packages/firestore/src/api.ts +++ b/packages/firestore/src/api.ts @@ -85,6 +85,7 @@ export { } from './api/reference'; export { + and, endAt, endBefore, startAt, @@ -92,10 +93,19 @@ export { limit, limitToLast, where, + or, orderBy, query, QueryConstraint, QueryConstraintType, + QueryCompositeFilterConstraint, + QueryFilterConstraint, + QueryFieldFilterConstraint, + QueryOrderByConstraint, + QueryLimitConstraint, + QueryNonFilterConstraint, + QueryStartAtConstraint, + QueryEndAtConstraint, OrderByDirection, WhereFilterOp } from './api/filter'; diff --git a/packages/firestore/src/api/filter.ts b/packages/firestore/src/api/filter.ts index b8a7cc90720..035aca66aba 100644 --- a/packages/firestore/src/api/filter.ts +++ b/packages/firestore/src/api/filter.ts @@ -16,17 +16,27 @@ */ export { + and, endAt, endBefore, startAfter, startAt, limitToLast, limit, + or, orderBy, OrderByDirection, where, WhereFilterOp, query, + QueryCompositeFilterConstraint, QueryConstraint, - QueryConstraintType + QueryConstraintType, + QueryFilterConstraint, + QueryFieldFilterConstraint, + QueryOrderByConstraint, + QueryLimitConstraint, + QueryStartAtConstraint, + QueryEndAtConstraint, + QueryNonFilterConstraint } from '../lite-api/query'; diff --git a/packages/firestore/src/core/bound.ts b/packages/firestore/src/core/bound.ts new file mode 100644 index 00000000000..20b688fa7e1 --- /dev/null +++ b/packages/firestore/src/core/bound.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Document } from '../model/document'; +import { DocumentKey } from '../model/document_key'; +import { isReferenceValue, valueCompare, valueEquals } from '../model/values'; +import { Value as ProtoValue } from '../protos/firestore_proto_api'; +import { debugAssert } from '../util/assert'; + +import { Direction, OrderBy } from './order_by'; + +/** + * Represents a bound of a query. + * + * The bound is specified with the given components representing a position and + * whether it's just before or just after the position (relative to whatever the + * query order is). + * + * The position represents a logical index position for a query. It's a prefix + * of values for the (potentially implicit) order by clauses of a query. + * + * Bound provides a function to determine whether a document comes before or + * after a bound. This is influenced by whether the position is just before or + * just after the provided values. + */ +export class Bound { + constructor(readonly position: ProtoValue[], readonly inclusive: boolean) {} +} + +function boundCompareToDocument( + bound: Bound, + orderBy: OrderBy[], + doc: Document +): number { + debugAssert( + bound.position.length <= orderBy.length, + "Bound has more components than query's orderBy" + ); + let comparison = 0; + for (let i = 0; i < bound.position.length; i++) { + const orderByComponent = orderBy[i]; + const component = bound.position[i]; + if (orderByComponent.field.isKeyField()) { + debugAssert( + isReferenceValue(component), + 'Bound has a non-key value where the key path is being used.' + ); + comparison = DocumentKey.comparator( + DocumentKey.fromName(component.referenceValue), + doc.key + ); + } else { + const docValue = doc.data.field(orderByComponent.field); + debugAssert( + docValue !== null, + 'Field should exist since document matched the orderBy already.' + ); + comparison = valueCompare(component, docValue); + } + if (orderByComponent.dir === Direction.DESCENDING) { + comparison = comparison * -1; + } + if (comparison !== 0) { + break; + } + } + return comparison; +} + +/** + * Returns true if a document sorts after a bound using the provided sort + * order. + */ +export function boundSortsAfterDocument( + bound: Bound, + orderBy: OrderBy[], + doc: Document +): boolean { + const comparison = boundCompareToDocument(bound, orderBy, doc); + return bound.inclusive ? comparison >= 0 : comparison > 0; +} + +/** + * Returns true if a document sorts before a bound using the provided sort + * order. + */ +export function boundSortsBeforeDocument( + bound: Bound, + orderBy: OrderBy[], + doc: Document +): boolean { + const comparison = boundCompareToDocument(bound, orderBy, doc); + return bound.inclusive ? comparison <= 0 : comparison < 0; +} + +export function boundEquals(left: Bound | null, right: Bound | null): boolean { + if (left === null) { + return right === null; + } else if (right === null) { + return false; + } + + if ( + left.inclusive !== right.inclusive || + left.position.length !== right.position.length + ) { + return false; + } + for (let i = 0; i < left.position.length; i++) { + const leftPosition = left.position[i]; + const rightPosition = right.position[i]; + if (!valueEquals(leftPosition, rightPosition)) { + return false; + } + } + return true; +} diff --git a/packages/firestore/src/core/filter.ts b/packages/firestore/src/core/filter.ts new file mode 100644 index 00000000000..4322c926658 --- /dev/null +++ b/packages/firestore/src/core/filter.ts @@ -0,0 +1,543 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Document } from '../model/document'; +import { DocumentKey } from '../model/document_key'; +import { FieldPath } from '../model/path'; +import { + arrayValueContains, + canonicalId, + isArray, + isReferenceValue, + typeOrder, + valueCompare, + valueEquals +} from '../model/values'; +import { Value as ProtoValue } from '../protos/firestore_proto_api'; +import { debugAssert, fail } from '../util/assert'; + +// The operator of a FieldFilter +export const enum Operator { + LESS_THAN = '<', + LESS_THAN_OR_EQUAL = '<=', + EQUAL = '==', + NOT_EQUAL = '!=', + GREATER_THAN = '>', + GREATER_THAN_OR_EQUAL = '>=', + ARRAY_CONTAINS = 'array-contains', + IN = 'in', + NOT_IN = 'not-in', + ARRAY_CONTAINS_ANY = 'array-contains-any' +} + +// The operator of a CompositeFilter +export const enum CompositeOperator { + OR = 'or', + AND = 'and' +} + +export abstract class Filter { + abstract matches(doc: Document): boolean; + + abstract getFlattenedFilters(): readonly FieldFilter[]; + + abstract getFilters(): Filter[]; + + abstract getFirstInequalityField(): FieldPath | null; +} + +export class FieldFilter extends Filter { + protected constructor( + public readonly field: FieldPath, + public readonly op: Operator, + public readonly value: ProtoValue + ) { + super(); + } + + /** + * Creates a filter based on the provided arguments. + */ + static create( + field: FieldPath, + op: Operator, + value: ProtoValue + ): FieldFilter { + if (field.isKeyField()) { + if (op === Operator.IN || op === Operator.NOT_IN) { + return this.createKeyFieldInFilter(field, op, value); + } else { + debugAssert( + isReferenceValue(value), + 'Comparing on key, but filter value not a RefValue' + ); + debugAssert( + op !== Operator.ARRAY_CONTAINS && op !== Operator.ARRAY_CONTAINS_ANY, + `'${op.toString()}' queries don't make sense on document keys.` + ); + return new KeyFieldFilter(field, op, value); + } + } else if (op === Operator.ARRAY_CONTAINS) { + return new ArrayContainsFilter(field, value); + } else if (op === Operator.IN) { + debugAssert( + isArray(value), + 'IN filter has invalid value: ' + value.toString() + ); + return new InFilter(field, value); + } else if (op === Operator.NOT_IN) { + debugAssert( + isArray(value), + 'NOT_IN filter has invalid value: ' + value.toString() + ); + return new NotInFilter(field, value); + } else if (op === Operator.ARRAY_CONTAINS_ANY) { + debugAssert( + isArray(value), + 'ARRAY_CONTAINS_ANY filter has invalid value: ' + value.toString() + ); + return new ArrayContainsAnyFilter(field, value); + } else { + return new FieldFilter(field, op, value); + } + } + + private static createKeyFieldInFilter( + field: FieldPath, + op: Operator.IN | Operator.NOT_IN, + value: ProtoValue + ): FieldFilter { + debugAssert( + isArray(value), + `Comparing on key with ${op.toString()}` + + ', but filter value not an ArrayValue' + ); + debugAssert( + (value.arrayValue.values || []).every(elem => isReferenceValue(elem)), + `Comparing on key with ${op.toString()}` + + ', but an array value was not a RefValue' + ); + + return op === Operator.IN + ? new KeyFieldInFilter(field, value) + : new KeyFieldNotInFilter(field, value); + } + + matches(doc: Document): boolean { + const other = doc.data.field(this.field); + // Types do not have to match in NOT_EQUAL filters. + if (this.op === Operator.NOT_EQUAL) { + return ( + other !== null && + this.matchesComparison(valueCompare(other!, this.value)) + ); + } + + // Only compare types with matching backend order (such as double and int). + return ( + other !== null && + typeOrder(this.value) === typeOrder(other) && + this.matchesComparison(valueCompare(other, this.value)) + ); + } + + protected matchesComparison(comparison: number): boolean { + switch (this.op) { + case Operator.LESS_THAN: + return comparison < 0; + case Operator.LESS_THAN_OR_EQUAL: + return comparison <= 0; + case Operator.EQUAL: + return comparison === 0; + case Operator.NOT_EQUAL: + return comparison !== 0; + case Operator.GREATER_THAN: + return comparison > 0; + case Operator.GREATER_THAN_OR_EQUAL: + return comparison >= 0; + default: + return fail('Unknown FieldFilter operator: ' + this.op); + } + } + + isInequality(): boolean { + return ( + [ + Operator.LESS_THAN, + Operator.LESS_THAN_OR_EQUAL, + Operator.GREATER_THAN, + Operator.GREATER_THAN_OR_EQUAL, + Operator.NOT_EQUAL, + Operator.NOT_IN + ].indexOf(this.op) >= 0 + ); + } + + getFlattenedFilters(): readonly FieldFilter[] { + return [this]; + } + + getFilters(): Filter[] { + return [this]; + } + + getFirstInequalityField(): FieldPath | null { + if (this.isInequality()) { + return this.field; + } + return null; + } +} + +export class CompositeFilter extends Filter { + private memoizedFlattenedFilters: FieldFilter[] | null = null; + + protected constructor( + public readonly filters: readonly Filter[], + public readonly op: CompositeOperator + ) { + super(); + } + + /** + * Creates a filter based on the provided arguments. + */ + static create(filters: Filter[], op: CompositeOperator): CompositeFilter { + return new CompositeFilter(filters, op); + } + + matches(doc: Document): boolean { + if (compositeFilterIsConjunction(this)) { + // For conjunctions, all filters must match, so return false if any filter doesn't match. + return this.filters.find(filter => !filter.matches(doc)) === undefined; + } else { + // For disjunctions, at least one filter should match. + return this.filters.find(filter => filter.matches(doc)) !== undefined; + } + } + + getFlattenedFilters(): readonly FieldFilter[] { + if (this.memoizedFlattenedFilters !== null) { + return this.memoizedFlattenedFilters; + } + + this.memoizedFlattenedFilters = this.filters.reduce((result, subfilter) => { + return result.concat(subfilter.getFlattenedFilters()); + }, [] as FieldFilter[]); + + return this.memoizedFlattenedFilters; + } + + // Returns a mutable copy of `this.filters` + getFilters(): Filter[] { + return Object.assign([], this.filters); + } + + getFirstInequalityField(): FieldPath | null { + const found = this.findFirstMatchingFilter(filter => filter.isInequality()); + + if (found !== null) { + return found.field; + } + return null; + } + + // Performs a depth-first search to find and return the first FieldFilter in the composite filter + // that satisfies the predicate. Returns `null` if none of the FieldFilters satisfy the + // predicate. + private findFirstMatchingFilter( + predicate: (filter: FieldFilter) => boolean + ): FieldFilter | null { + for (const fieldFilter of this.getFlattenedFilters()) { + if (predicate(fieldFilter)) { + return fieldFilter; + } + } + + return null; + } +} + +export function compositeFilterIsConjunction( + compositeFilter: CompositeFilter +): boolean { + return compositeFilter.op === CompositeOperator.AND; +} + +export function compositeFilterIsDisjunction( + compositeFilter: CompositeFilter +): boolean { + return compositeFilter.op === CompositeOperator.OR; +} + +/** + * Returns true if this filter is a conjunction of field filters only. Returns false otherwise. + */ +export function compositeFilterIsFlatConjunction( + compositeFilter: CompositeFilter +): boolean { + return ( + compositeFilterIsFlat(compositeFilter) && + compositeFilterIsConjunction(compositeFilter) + ); +} + +/** + * Returns true if this filter does not contain any composite filters. Returns false otherwise. + */ +export function compositeFilterIsFlat( + compositeFilter: CompositeFilter +): boolean { + for (const filter of compositeFilter.filters) { + if (filter instanceof CompositeFilter) { + return false; + } + } + return true; +} + +export function canonifyFilter(filter: Filter): string { + debugAssert( + filter instanceof FieldFilter || filter instanceof CompositeFilter, + 'canonifyFilter() only supports FieldFilters and CompositeFilters' + ); + + if (filter instanceof FieldFilter) { + // TODO(b/29183165): Technically, this won't be unique if two values have + // the same description, such as the int 3 and the string "3". So we should + // add the types in here somehow, too. + return ( + filter.field.canonicalString() + + filter.op.toString() + + canonicalId(filter.value) + ); + } else { + // filter instanceof CompositeFilter + const canonicalIdsString = filter.filters + .map(filter => canonifyFilter(filter)) + .join(','); + return `${filter.op}(${canonicalIdsString})`; + } +} + +export function filterEquals(f1: Filter, f2: Filter): boolean { + if (f1 instanceof FieldFilter) { + return fieldFilterEquals(f1, f2); + } else if (f1 instanceof CompositeFilter) { + return compositeFilterEquals(f1, f2); + } else { + fail('Only FieldFilters and CompositeFilters can be compared'); + } +} + +export function fieldFilterEquals(f1: FieldFilter, f2: Filter): boolean { + return ( + f2 instanceof FieldFilter && + f1.op === f2.op && + f1.field.isEqual(f2.field) && + valueEquals(f1.value, f2.value) + ); +} + +export function compositeFilterEquals( + f1: CompositeFilter, + f2: Filter +): boolean { + if ( + f2 instanceof CompositeFilter && + f1.op === f2.op && + f1.filters.length === f2.filters.length + ) { + const subFiltersMatch: boolean = f1.filters.reduce( + (result: boolean, f1Filter: Filter, index: number): boolean => + result && filterEquals(f1Filter, f2.filters[index]), + true + ); + + return subFiltersMatch; + } + + return false; +} + +/** + * Returns a new composite filter that contains all filter from + * `compositeFilter` plus all the given filters in `otherFilters`. + */ +export function compositeFilterWithAddedFilters( + compositeFilter: CompositeFilter, + otherFilters: Filter[] +): CompositeFilter { + const mergedFilters = compositeFilter.filters.concat(otherFilters); + return CompositeFilter.create(mergedFilters, compositeFilter.op); +} + +/** Returns a debug description for `filter`. */ +export function stringifyFilter(filter: Filter): string { + debugAssert( + filter instanceof FieldFilter || filter instanceof CompositeFilter, + 'stringifyFilter() only supports FieldFilters and CompositeFilters' + ); + if (filter instanceof FieldFilter) { + return stringifyFieldFilter(filter); + } else if (filter instanceof CompositeFilter) { + return stringifyCompositeFilter(filter); + } else { + return 'Filter'; + } +} + +export function stringifyCompositeFilter(filter: CompositeFilter): string { + return ( + filter.op.toString() + + ` {` + + filter.getFilters().map(stringifyFilter).join(' ,') + + '}' + ); +} + +export function stringifyFieldFilter(filter: FieldFilter): string { + return `${filter.field.canonicalString()} ${filter.op} ${canonicalId( + filter.value + )}`; +} + +/** Filter that matches on key fields (i.e. '__name__'). */ +export class KeyFieldFilter extends FieldFilter { + private readonly key: DocumentKey; + + constructor(field: FieldPath, op: Operator, value: ProtoValue) { + super(field, op, value); + debugAssert( + isReferenceValue(value), + 'KeyFieldFilter expects a ReferenceValue' + ); + this.key = DocumentKey.fromName(value.referenceValue); + } + + matches(doc: Document): boolean { + const comparison = DocumentKey.comparator(doc.key, this.key); + return this.matchesComparison(comparison); + } +} + +/** Filter that matches on key fields within an array. */ +export class KeyFieldInFilter extends FieldFilter { + private readonly keys: DocumentKey[]; + + constructor(field: FieldPath, value: ProtoValue) { + super(field, Operator.IN, value); + this.keys = extractDocumentKeysFromArrayValue(Operator.IN, value); + } + + matches(doc: Document): boolean { + return this.keys.some(key => key.isEqual(doc.key)); + } +} + +/** Filter that matches on key fields not present within an array. */ +export class KeyFieldNotInFilter extends FieldFilter { + private readonly keys: DocumentKey[]; + + constructor(field: FieldPath, value: ProtoValue) { + super(field, Operator.NOT_IN, value); + this.keys = extractDocumentKeysFromArrayValue(Operator.NOT_IN, value); + } + + matches(doc: Document): boolean { + return !this.keys.some(key => key.isEqual(doc.key)); + } +} + +function extractDocumentKeysFromArrayValue( + op: Operator.IN | Operator.NOT_IN, + value: ProtoValue +): DocumentKey[] { + debugAssert( + isArray(value), + 'KeyFieldInFilter/KeyFieldNotInFilter expects an ArrayValue' + ); + return (value.arrayValue?.values || []).map(v => { + debugAssert( + isReferenceValue(v), + `Comparing on key with ${op.toString()}, but an array value was not ` + + `a ReferenceValue` + ); + return DocumentKey.fromName(v.referenceValue); + }); +} + +/** A Filter that implements the array-contains operator. */ +export class ArrayContainsFilter extends FieldFilter { + constructor(field: FieldPath, value: ProtoValue) { + super(field, Operator.ARRAY_CONTAINS, value); + } + + matches(doc: Document): boolean { + const other = doc.data.field(this.field); + return isArray(other) && arrayValueContains(other.arrayValue, this.value); + } +} + +/** A Filter that implements the IN operator. */ +export class InFilter extends FieldFilter { + constructor(field: FieldPath, value: ProtoValue) { + super(field, Operator.IN, value); + debugAssert(isArray(value), 'InFilter expects an ArrayValue'); + } + + matches(doc: Document): boolean { + const other = doc.data.field(this.field); + return other !== null && arrayValueContains(this.value.arrayValue!, other); + } +} + +/** A Filter that implements the not-in operator. */ +export class NotInFilter extends FieldFilter { + constructor(field: FieldPath, value: ProtoValue) { + super(field, Operator.NOT_IN, value); + debugAssert(isArray(value), 'NotInFilter expects an ArrayValue'); + } + + matches(doc: Document): boolean { + if ( + arrayValueContains(this.value.arrayValue!, { nullValue: 'NULL_VALUE' }) + ) { + return false; + } + const other = doc.data.field(this.field); + return other !== null && !arrayValueContains(this.value.arrayValue!, other); + } +} + +/** A Filter that implements the array-contains-any operator. */ +export class ArrayContainsAnyFilter extends FieldFilter { + constructor(field: FieldPath, value: ProtoValue) { + super(field, Operator.ARRAY_CONTAINS_ANY, value); + debugAssert(isArray(value), 'ArrayContainsAnyFilter expects an ArrayValue'); + } + + matches(doc: Document): boolean { + const other = doc.data.field(this.field); + if (!isArray(other) || !other.arrayValue.values) { + return false; + } + return other.arrayValue.values.some(val => + arrayValueContains(this.value.arrayValue!, val) + ); + } +} diff --git a/packages/firestore/src/core/order_by.ts b/packages/firestore/src/core/order_by.ts new file mode 100644 index 00000000000..5685b1eb2c3 --- /dev/null +++ b/packages/firestore/src/core/order_by.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FieldPath } from '../model/path'; + +/** + * The direction of sorting in an order by. + */ +export const enum Direction { + ASCENDING = 'asc', + DESCENDING = 'desc' +} + +/** + * An ordering on a field, in some Direction. Direction defaults to ASCENDING. + */ +export class OrderBy { + constructor( + readonly field: FieldPath, + readonly dir: Direction = Direction.ASCENDING + ) {} +} + +export function canonifyOrderBy(orderBy: OrderBy): string { + // TODO(b/29183165): Make this collision robust. + return orderBy.field.canonicalString() + orderBy.dir; +} + +export function stringifyOrderBy(orderBy: OrderBy): string { + return `${orderBy.field.canonicalString()} (${orderBy.dir})`; +} + +export function orderByEquals(left: OrderBy, right: OrderBy): boolean { + return left.dir === right.dir && left.field.isEqual(right.field); +} diff --git a/packages/firestore/src/core/query.ts b/packages/firestore/src/core/query.ts index 2b4fca74c3a..310f7f6ac4b 100644 --- a/packages/firestore/src/core/query.ts +++ b/packages/firestore/src/core/query.ts @@ -22,18 +22,17 @@ import { debugAssert, debugCast, fail } from '../util/assert'; import { Bound, + boundSortsAfterDocument, + boundSortsBeforeDocument +} from './bound'; +import { CompositeFilter, Filter } from './filter'; +import { Direction, OrderBy } from './order_by'; +import { canonifyTarget, - Direction, - FieldFilter, - Filter, newTarget, - Operator, - OrderBy, - boundSortsBeforeDocument, stringifyTarget, Target, - targetEquals, - boundSortsAfterDocument + targetEquals } from './target'; export const enum LimitType { @@ -166,6 +165,13 @@ export function queryMatchesAllDocuments(query: Query): boolean { ); } +export function queryContainsCompositeFilters(query: Query): boolean { + return ( + query.filters.find(filter => filter instanceof CompositeFilter) !== + undefined + ); +} + export function getFirstOrderByField(query: Query): FieldPath | null { return query.explicitOrderBy.length > 0 ? query.explicitOrderBy[0].field @@ -174,34 +180,12 @@ export function getFirstOrderByField(query: Query): FieldPath | null { export function getInequalityFilterField(query: Query): FieldPath | null { for (const filter of query.filters) { - debugAssert( - filter instanceof FieldFilter, - 'Only FieldFilters are supported' - ); - if (filter.isInequality()) { - return filter.field; + const result = filter.getFirstInequalityField(); + if (result !== null) { + return result; } } - return null; -} -/** - * Checks if any of the provided Operators are included in the query and - * returns the first one that is, or null if none are. - */ -export function findFilterOperator( - query: Query, - operators: Operator[] -): Operator | null { - for (const filter of query.filters) { - debugAssert( - filter instanceof FieldFilter, - 'Only FieldFilters are supported' - ); - if (operators.indexOf(filter.op) >= 0) { - return filter.op; - } - } return null; } @@ -337,11 +321,13 @@ export function queryToTarget(query: Query): Target { } export function queryWithAddedFilter(query: Query, filter: Filter): Query { + const newInequalityField = filter.getFirstInequalityField(); + const queryInequalityField = getInequalityFilterField(query); + debugAssert( - getInequalityFilterField(query) == null || - !(filter instanceof FieldFilter) || - !filter.isInequality() || - filter.field.isEqual(getInequalityFilterField(query)!), + queryInequalityField == null || + newInequalityField == null || + newInequalityField.isEqual(queryInequalityField), 'Query must only have one inequality field.' ); @@ -482,7 +468,13 @@ function queryMatchesPathAndCollectionGroup( * in the results. */ function queryMatchesOrderBy(query: Query, doc: Document): boolean { - for (const orderBy of query.explicitOrderBy) { + // We must use `queryOrderBy()` to get the list of all orderBys (both implicit and explicit). + // Note that for OR queries, orderBy applies to all disjunction terms and implicit orderBys must + // be taken into account. For example, the query "a > 1 || b==1" has an implicit "orderBy a" due + // to the inequality, and is evaluated as "a > 1 orderBy a || b==1 orderBy a". + // A document with content of {b:1} matches the filters, but does not match the orderBy because + // it's missing the field 'a'. + for (const orderBy of queryOrderBy(query)) { // order by key always matches if (!orderBy.field.isKeyField() && doc.data.field(orderBy.field) === null) { return false; diff --git a/packages/firestore/src/core/target.ts b/packages/firestore/src/core/target.ts index 72a760a4529..4b12857fc2a 100644 --- a/packages/firestore/src/core/target.ts +++ b/packages/firestore/src/core/target.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { Document } from '../model/document'; import { DocumentKey } from '../model/document_key'; import { FieldIndex, @@ -25,25 +24,35 @@ import { } from '../model/field_index'; import { FieldPath, ResourcePath } from '../model/path'; import { - arrayValueContains, canonicalId, - isArray, - isReferenceValue, MAX_VALUE, MIN_VALUE, lowerBoundCompare, - typeOrder, upperBoundCompare, - valueCompare, - valueEquals, valuesGetLowerBound, valuesGetUpperBound } from '../model/values'; import { Value as ProtoValue } from '../protos/firestore_proto_api'; -import { debugAssert, debugCast, fail } from '../util/assert'; +import { debugCast } from '../util/assert'; import { SortedSet } from '../util/sorted_set'; import { isNullOrUndefined } from '../util/types'; +import { Bound, boundEquals } from './bound'; +import { + Filter, + FieldFilter, + canonifyFilter, + stringifyFilter, + filterEquals, + Operator +} from './filter'; +import { + canonifyOrderBy, + OrderBy, + orderByEquals, + stringifyOrderBy +} from './order_by'; + /** * A Target represents the WatchTarget representation of a Query, which is used * by the LocalStore and the RemoteStore to keep track of and to execute @@ -500,26 +509,25 @@ export function targetGetSegmentCount(target: Target): number { let hasArraySegment = false; for (const filter of target.filters) { - // TODO(orquery): Use the flattened filters here - const fieldFilter = filter as FieldFilter; - - // __name__ is not an explicit segment of any index, so we don't need to - // count it. - if (fieldFilter.field.isKeyField()) { - continue; - } + for (const subFilter of filter.getFlattenedFilters()) { + // __name__ is not an explicit segment of any index, so we don't need to + // count it. + if (subFilter.field.isKeyField()) { + continue; + } - // ARRAY_CONTAINS or ARRAY_CONTAINS_ANY filters must be counted separately. - // For instance, it is possible to have an index for "a ARRAY a ASC". Even - // though these are on the same field, they should be counted as two - // separate segments in an index. - if ( - fieldFilter.op === Operator.ARRAY_CONTAINS || - fieldFilter.op === Operator.ARRAY_CONTAINS_ANY - ) { - hasArraySegment = true; - } else { - fields = fields.add(fieldFilter.field); + // ARRAY_CONTAINS or ARRAY_CONTAINS_ANY filters must be counted separately. + // For instance, it is possible to have an index for "a ARRAY a ASC". Even + // though these are on the same field, they should be counted as two + // separate segments in an index. + if ( + subFilter.op === Operator.ARRAY_CONTAINS || + subFilter.op === Operator.ARRAY_CONTAINS_ANY + ) { + hasArraySegment = true; + } else { + fields = fields.add(subFilter.field); + } } } @@ -534,450 +542,6 @@ export function targetGetSegmentCount(target: Target): number { return fields.size + (hasArraySegment ? 1 : 0); } -export abstract class Filter { - abstract matches(doc: Document): boolean; -} - -export const enum Operator { - LESS_THAN = '<', - LESS_THAN_OR_EQUAL = '<=', - EQUAL = '==', - NOT_EQUAL = '!=', - GREATER_THAN = '>', - GREATER_THAN_OR_EQUAL = '>=', - ARRAY_CONTAINS = 'array-contains', - IN = 'in', - NOT_IN = 'not-in', - ARRAY_CONTAINS_ANY = 'array-contains-any' -} - -/** - * The direction of sorting in an order by. - */ -export const enum Direction { - ASCENDING = 'asc', - DESCENDING = 'desc' -} - -export class FieldFilter extends Filter { - protected constructor( - public field: FieldPath, - public op: Operator, - public value: ProtoValue - ) { - super(); - } - - /** - * Creates a filter based on the provided arguments. - */ - static create( - field: FieldPath, - op: Operator, - value: ProtoValue - ): FieldFilter { - if (field.isKeyField()) { - if (op === Operator.IN || op === Operator.NOT_IN) { - return this.createKeyFieldInFilter(field, op, value); - } else { - debugAssert( - isReferenceValue(value), - 'Comparing on key, but filter value not a RefValue' - ); - debugAssert( - op !== Operator.ARRAY_CONTAINS && op !== Operator.ARRAY_CONTAINS_ANY, - `'${op.toString()}' queries don't make sense on document keys.` - ); - return new KeyFieldFilter(field, op, value); - } - } else if (op === Operator.ARRAY_CONTAINS) { - return new ArrayContainsFilter(field, value); - } else if (op === Operator.IN) { - debugAssert( - isArray(value), - 'IN filter has invalid value: ' + value.toString() - ); - return new InFilter(field, value); - } else if (op === Operator.NOT_IN) { - debugAssert( - isArray(value), - 'NOT_IN filter has invalid value: ' + value.toString() - ); - return new NotInFilter(field, value); - } else if (op === Operator.ARRAY_CONTAINS_ANY) { - debugAssert( - isArray(value), - 'ARRAY_CONTAINS_ANY filter has invalid value: ' + value.toString() - ); - return new ArrayContainsAnyFilter(field, value); - } else { - return new FieldFilter(field, op, value); - } - } - - private static createKeyFieldInFilter( - field: FieldPath, - op: Operator.IN | Operator.NOT_IN, - value: ProtoValue - ): FieldFilter { - debugAssert( - isArray(value), - `Comparing on key with ${op.toString()}` + - ', but filter value not an ArrayValue' - ); - debugAssert( - (value.arrayValue.values || []).every(elem => isReferenceValue(elem)), - `Comparing on key with ${op.toString()}` + - ', but an array value was not a RefValue' - ); - - return op === Operator.IN - ? new KeyFieldInFilter(field, value) - : new KeyFieldNotInFilter(field, value); - } - - matches(doc: Document): boolean { - const other = doc.data.field(this.field); - // Types do not have to match in NOT_EQUAL filters. - if (this.op === Operator.NOT_EQUAL) { - return ( - other !== null && - this.matchesComparison(valueCompare(other!, this.value)) - ); - } - - // Only compare types with matching backend order (such as double and int). - return ( - other !== null && - typeOrder(this.value) === typeOrder(other) && - this.matchesComparison(valueCompare(other, this.value)) - ); - } - - protected matchesComparison(comparison: number): boolean { - switch (this.op) { - case Operator.LESS_THAN: - return comparison < 0; - case Operator.LESS_THAN_OR_EQUAL: - return comparison <= 0; - case Operator.EQUAL: - return comparison === 0; - case Operator.NOT_EQUAL: - return comparison !== 0; - case Operator.GREATER_THAN: - return comparison > 0; - case Operator.GREATER_THAN_OR_EQUAL: - return comparison >= 0; - default: - return fail('Unknown FieldFilter operator: ' + this.op); - } - } - - isInequality(): boolean { - return ( - [ - Operator.LESS_THAN, - Operator.LESS_THAN_OR_EQUAL, - Operator.GREATER_THAN, - Operator.GREATER_THAN_OR_EQUAL, - Operator.NOT_EQUAL, - Operator.NOT_IN - ].indexOf(this.op) >= 0 - ); - } -} - -export function canonifyFilter(filter: Filter): string { - debugAssert( - filter instanceof FieldFilter, - 'canonifyFilter() only supports FieldFilters' - ); - // TODO(b/29183165): Technically, this won't be unique if two values have - // the same description, such as the int 3 and the string "3". So we should - // add the types in here somehow, too. - return ( - filter.field.canonicalString() + - filter.op.toString() + - canonicalId(filter.value) - ); -} - -export function filterEquals(f1: Filter, f2: Filter): boolean { - debugAssert( - f1 instanceof FieldFilter && f2 instanceof FieldFilter, - 'Only FieldFilters can be compared' - ); - - return ( - f1.op === f2.op && - f1.field.isEqual(f2.field) && - valueEquals(f1.value, f2.value) - ); -} - -/** Returns a debug description for `filter`. */ -export function stringifyFilter(filter: Filter): string { - debugAssert( - filter instanceof FieldFilter, - 'stringifyFilter() only supports FieldFilters' - ); - return `${filter.field.canonicalString()} ${filter.op} ${canonicalId( - filter.value - )}`; -} - -/** Filter that matches on key fields (i.e. '__name__'). */ -export class KeyFieldFilter extends FieldFilter { - private readonly key: DocumentKey; - - constructor(field: FieldPath, op: Operator, value: ProtoValue) { - super(field, op, value); - debugAssert( - isReferenceValue(value), - 'KeyFieldFilter expects a ReferenceValue' - ); - this.key = DocumentKey.fromName(value.referenceValue); - } - - matches(doc: Document): boolean { - const comparison = DocumentKey.comparator(doc.key, this.key); - return this.matchesComparison(comparison); - } -} - -/** Filter that matches on key fields within an array. */ -export class KeyFieldInFilter extends FieldFilter { - private readonly keys: DocumentKey[]; - - constructor(field: FieldPath, value: ProtoValue) { - super(field, Operator.IN, value); - this.keys = extractDocumentKeysFromArrayValue(Operator.IN, value); - } - - matches(doc: Document): boolean { - return this.keys.some(key => key.isEqual(doc.key)); - } -} - -/** Filter that matches on key fields not present within an array. */ -export class KeyFieldNotInFilter extends FieldFilter { - private readonly keys: DocumentKey[]; - - constructor(field: FieldPath, value: ProtoValue) { - super(field, Operator.NOT_IN, value); - this.keys = extractDocumentKeysFromArrayValue(Operator.NOT_IN, value); - } - - matches(doc: Document): boolean { - return !this.keys.some(key => key.isEqual(doc.key)); - } -} - -function extractDocumentKeysFromArrayValue( - op: Operator.IN | Operator.NOT_IN, - value: ProtoValue -): DocumentKey[] { - debugAssert( - isArray(value), - 'KeyFieldInFilter/KeyFieldNotInFilter expects an ArrayValue' - ); - return (value.arrayValue?.values || []).map(v => { - debugAssert( - isReferenceValue(v), - `Comparing on key with ${op.toString()}, but an array value was not ` + - `a ReferenceValue` - ); - return DocumentKey.fromName(v.referenceValue); - }); -} - -/** A Filter that implements the array-contains operator. */ -export class ArrayContainsFilter extends FieldFilter { - constructor(field: FieldPath, value: ProtoValue) { - super(field, Operator.ARRAY_CONTAINS, value); - } - - matches(doc: Document): boolean { - const other = doc.data.field(this.field); - return isArray(other) && arrayValueContains(other.arrayValue, this.value); - } -} - -/** A Filter that implements the IN operator. */ -export class InFilter extends FieldFilter { - constructor(field: FieldPath, value: ProtoValue) { - super(field, Operator.IN, value); - debugAssert(isArray(value), 'InFilter expects an ArrayValue'); - } - - matches(doc: Document): boolean { - const other = doc.data.field(this.field); - return other !== null && arrayValueContains(this.value.arrayValue!, other); - } -} - -/** A Filter that implements the not-in operator. */ -export class NotInFilter extends FieldFilter { - constructor(field: FieldPath, value: ProtoValue) { - super(field, Operator.NOT_IN, value); - debugAssert(isArray(value), 'NotInFilter expects an ArrayValue'); - } - - matches(doc: Document): boolean { - if ( - arrayValueContains(this.value.arrayValue!, { nullValue: 'NULL_VALUE' }) - ) { - return false; - } - const other = doc.data.field(this.field); - return other !== null && !arrayValueContains(this.value.arrayValue!, other); - } -} - -/** A Filter that implements the array-contains-any operator. */ -export class ArrayContainsAnyFilter extends FieldFilter { - constructor(field: FieldPath, value: ProtoValue) { - super(field, Operator.ARRAY_CONTAINS_ANY, value); - debugAssert(isArray(value), 'ArrayContainsAnyFilter expects an ArrayValue'); - } - - matches(doc: Document): boolean { - const other = doc.data.field(this.field); - if (!isArray(other) || !other.arrayValue.values) { - return false; - } - return other.arrayValue.values.some(val => - arrayValueContains(this.value.arrayValue!, val) - ); - } -} - -/** - * Represents a bound of a query. - * - * The bound is specified with the given components representing a position and - * whether it's just before or just after the position (relative to whatever the - * query order is). - * - * The position represents a logical index position for a query. It's a prefix - * of values for the (potentially implicit) order by clauses of a query. - * - * Bound provides a function to determine whether a document comes before or - * after a bound. This is influenced by whether the position is just before or - * just after the provided values. - */ -export class Bound { - constructor(readonly position: ProtoValue[], readonly inclusive: boolean) {} -} - -/** - * An ordering on a field, in some Direction. Direction defaults to ASCENDING. - */ -export class OrderBy { - constructor( - readonly field: FieldPath, - readonly dir: Direction = Direction.ASCENDING - ) {} -} - -export function canonifyOrderBy(orderBy: OrderBy): string { - // TODO(b/29183165): Make this collision robust. - return orderBy.field.canonicalString() + orderBy.dir; -} - -export function stringifyOrderBy(orderBy: OrderBy): string { - return `${orderBy.field.canonicalString()} (${orderBy.dir})`; -} - -export function orderByEquals(left: OrderBy, right: OrderBy): boolean { - return left.dir === right.dir && left.field.isEqual(right.field); -} - -function boundCompareToDocument( - bound: Bound, - orderBy: OrderBy[], - doc: Document -): number { - debugAssert( - bound.position.length <= orderBy.length, - "Bound has more components than query's orderBy" - ); - let comparison = 0; - for (let i = 0; i < bound.position.length; i++) { - const orderByComponent = orderBy[i]; - const component = bound.position[i]; - if (orderByComponent.field.isKeyField()) { - debugAssert( - isReferenceValue(component), - 'Bound has a non-key value where the key path is being used.' - ); - comparison = DocumentKey.comparator( - DocumentKey.fromName(component.referenceValue), - doc.key - ); - } else { - const docValue = doc.data.field(orderByComponent.field); - debugAssert( - docValue !== null, - 'Field should exist since document matched the orderBy already.' - ); - comparison = valueCompare(component, docValue); - } - if (orderByComponent.dir === Direction.DESCENDING) { - comparison = comparison * -1; - } - if (comparison !== 0) { - break; - } - } - return comparison; -} - -/** - * Returns true if a document sorts after a bound using the provided sort - * order. - */ -export function boundSortsAfterDocument( - bound: Bound, - orderBy: OrderBy[], - doc: Document -): boolean { - const comparison = boundCompareToDocument(bound, orderBy, doc); - return bound.inclusive ? comparison >= 0 : comparison > 0; -} - -/** - * Returns true if a document sorts before a bound using the provided sort - * order. - */ -export function boundSortsBeforeDocument( - bound: Bound, - orderBy: OrderBy[], - doc: Document -): boolean { - const comparison = boundCompareToDocument(bound, orderBy, doc); - return bound.inclusive ? comparison <= 0 : comparison < 0; -} - -export function boundEquals(left: Bound | null, right: Bound | null): boolean { - if (left === null) { - return right === null; - } else if (right === null) { - return false; - } - - if ( - left.inclusive !== right.inclusive || - left.position.length !== right.position.length - ) { - return false; - } - for (let i = 0; i < left.position.length; i++) { - const leftPosition = left.position[i]; - const rightPosition = right.position[i]; - if (!valueEquals(leftPosition, rightPosition)) { - return false; - } - } - return true; +export function targetHasLimit(target: Target): boolean { + return target.limit !== null; } diff --git a/packages/firestore/src/lite-api/query.ts b/packages/firestore/src/lite-api/query.ts index 0a762918b33..2f1d7ecbf8f 100644 --- a/packages/firestore/src/lite-api/query.ts +++ b/packages/firestore/src/lite-api/query.ts @@ -17,9 +17,17 @@ import { getModularInstance } from '@firebase/util'; +import { Bound } from '../core/bound'; import { DatabaseId } from '../core/database_info'; import { - findFilterOperator, + CompositeFilter, + CompositeOperator, + FieldFilter, + Filter, + Operator +} from '../core/filter'; +import { Direction, OrderBy } from '../core/order_by'; +import { getFirstOrderByField, getInequalityFilterField, isCollectionGroupQuery, @@ -32,21 +40,12 @@ import { queryWithLimit, queryWithStartAt } from '../core/query'; -import { - Bound, - Direction, - FieldFilter, - Filter, - Operator, - OrderBy -} from '../core/target'; import { Document } from '../model/document'; import { DocumentKey } from '../model/document_key'; import { FieldPath as InternalFieldPath, ResourcePath } from '../model/path'; import { isServerTimestamp } from '../model/server_timestamps'; import { refValue } from '../model/values'; import { Value as ProtoValue } from '../protos/firestore_proto_api'; -import { debugAssert } from '../util/assert'; import { Code, FirestoreError } from '../util/error'; import { validatePositiveNumber, @@ -87,30 +86,64 @@ export type QueryConstraintType = | 'endAt' | 'endBefore'; +/** + * An `AppliableConstraint` is an abstraction of a constraint that can be applied + * to a Firestore query. + */ +export abstract class AppliableConstraint { + /** + * Takes the provided {@link Query} and returns a copy of the {@link Query} with this + * {@link AppliableConstraint} applied. + */ + abstract _apply(query: Query): Query; +} + /** * A `QueryConstraint` is used to narrow the set of documents returned by a * Firestore query. `QueryConstraint`s are created by invoking {@link where}, - * {@link orderBy}, {@link (startAt:1)}, {@link (startAfter:1)}, {@link - * endBefore:1}, {@link (endAt:1)}, {@link limit} or {@link limitToLast} and + * {@link orderBy}, {@link startAt}, {@link startAfter}, {@link + * endBefore}, {@link endAt}, {@link limit}, {@link limitToLast} and * can then be passed to {@link query} to create a new query instance that * also contains this `QueryConstraint`. */ -export abstract class QueryConstraint { - /** The type of this query constraints */ +export abstract class QueryConstraint extends AppliableConstraint { + /** The type of this query constraint */ abstract readonly type: QueryConstraintType; /** * Takes the provided {@link Query} and returns a copy of the {@link Query} with this - * {@link QueryConstraint} applied. + * {@link AppliableConstraint} applied. */ abstract _apply(query: Query): Query; } /** - * Creates a new immutable instance of {@link Query} that is extended to also include - * additional query constraints. + * Creates a new immutable instance of {@link Query} that is extended to also + * include additional query constraints. * - * @param query - The {@link Query} instance to use as a base for the new constraints. + * @param query - The {@link Query} instance to use as a base for the new + * constraints. + * @param compositeFilter - The {@link QueryCompositeFilterConstraint} to + * apply. Create {@link QueryCompositeFilterConstraint} using {@link and} or + * {@link or}. + * @param queryConstraints - Additional {@link QueryNonFilterConstraint}s to + * apply (e.g. {@link orderBy}, {@link limit}). + * @throws if any of the provided query constraints cannot be combined with the + * existing or new constraints. + * @internal TODO remove this internal tag with OR Query support in the server + */ +export function query( + query: Query, + compositeFilter: QueryCompositeFilterConstraint, + ...queryConstraints: QueryNonFilterConstraint[] +): Query; + +/** + * Creates a new immutable instance of {@link Query} that is extended to also + * include additional query constraints. + * + * @param query - The {@link Query} instance to use as a base for the new + * constraints. * @param queryConstraints - The list of {@link QueryConstraint}s to apply. * @throws if any of the provided query constraints cannot be combined with the * existing or new constraints. @@ -118,17 +151,46 @@ export abstract class QueryConstraint { export function query( query: Query, ...queryConstraints: QueryConstraint[] +): Query; + +export function query( + query: Query, + queryConstraint: QueryCompositeFilterConstraint | QueryConstraint | undefined, + ...additionalQueryConstraints: Array< + QueryConstraint | QueryNonFilterConstraint + > ): Query { + let queryConstraints: AppliableConstraint[] = []; + + if (queryConstraint instanceof AppliableConstraint) { + queryConstraints.push(queryConstraint); + } + + queryConstraints = queryConstraints.concat(additionalQueryConstraints); + + validateQueryConstraintArray(queryConstraints); + for (const constraint of queryConstraints) { query = constraint._apply(query); } return query; } -class QueryFilterConstraint extends QueryConstraint { +/** + * A `QueryFieldFilterConstraint` is used to narrow the set of documents returned by + * a Firestore query by filtering on one or more document fields. + * `QueryFieldFilterConstraint`s are created by invoking {@link where} and can then + * be passed to {@link query} to create a new query instance that also contains + * this `QueryFieldFilterConstraint`. + */ +export class QueryFieldFilterConstraint extends QueryConstraint { + /** The type of this query constraint */ readonly type = 'where'; - constructor( + /** + * @internal + */ + protected constructor( private readonly _field: InternalFieldPath, private _op: Operator, private _value: unknown @@ -136,7 +198,25 @@ class QueryFilterConstraint extends QueryConstraint { super(); } + static _create( + _field: InternalFieldPath, + _op: Operator, + _value: unknown + ): QueryFieldFilterConstraint { + return new QueryFieldFilterConstraint(_field, _op, _value); + } + _apply(query: Query): Query { + const filter = this._parse(query); + validateNewFieldFilter(query._query, filter); + return new Query( + query.firestore, + query.converter, + queryWithAddedFilter(query._query, filter) + ); + } + + _parse(query: Query): FieldFilter { const reader = newUserDataReader(query.firestore); const filter = newQueryFilter( query._query, @@ -147,11 +227,7 @@ class QueryFilterConstraint extends QueryConstraint { this._op, this._value ); - return new Query( - query.firestore, - query.converter, - queryWithAddedFilter(query._query, filter) - ); + return filter; } } @@ -173,36 +249,198 @@ export type WhereFilterOp = | 'not-in'; /** - * Creates a {@link QueryConstraint} that enforces that documents must contain the - * specified field and that the value should satisfy the relation constraint - * provided. + * Creates a {@link QueryFieldFilterConstraint} that enforces that documents + * must contain the specified field and that the value should satisfy the + * relation constraint provided. * * @param fieldPath - The path to compare * @param opStr - The operation string (e.g "<", "<=", "==", "<", * "<=", "!="). * @param value - The value for comparison - * @returns The created {@link Query}. + * @returns The created {@link QueryFieldFilterConstraint}. */ export function where( fieldPath: string | FieldPath, opStr: WhereFilterOp, value: unknown -): QueryConstraint { +): QueryFieldFilterConstraint { const op = opStr as Operator; const field = fieldPathFromArgument('where', fieldPath); - return new QueryFilterConstraint(field, op, value); + return QueryFieldFilterConstraint._create(field, op, value); +} + +/** + * A `QueryCompositeFilterConstraint` is used to narrow the set of documents + * returned by a Firestore query by performing the logical OR or AND of multiple + * {@link QueryFieldFilterConstraint}s or {@link QueryCompositeFilterConstraint}s. + * `QueryCompositeFilterConstraint`s are created by invoking {@link or} or + * {@link and} and can then be passed to {@link query} to create a new query + * instance that also contains the `QueryCompositeFilterConstraint`. + * @internal TODO remove this internal tag with OR Query support in the server + */ +export class QueryCompositeFilterConstraint extends AppliableConstraint { + /** + * @internal + */ + protected constructor( + /** The type of this query constraint */ + readonly type: 'or' | 'and', + private readonly _queryConstraints: QueryFilterConstraint[] + ) { + super(); + } + + static _create( + type: 'or' | 'and', + _queryConstraints: QueryFilterConstraint[] + ): QueryCompositeFilterConstraint { + return new QueryCompositeFilterConstraint(type, _queryConstraints); + } + + _parse(query: Query): Filter { + const parsedFilters = this._queryConstraints + .map(queryConstraint => { + return queryConstraint._parse(query); + }) + .filter(parsedFilter => parsedFilter.getFilters().length > 0); + + if (parsedFilters.length === 1) { + return parsedFilters[0]; + } + + return CompositeFilter.create(parsedFilters, this._getOperator()); + } + + _apply(query: Query): Query { + const parsedFilter = this._parse(query); + if (parsedFilter.getFilters().length === 0) { + // Return the existing query if not adding any more filters (e.g. an empty + // composite filter). + return query; + } + validateNewFilter(query._query, parsedFilter); + + return new Query( + query.firestore, + query.converter, + queryWithAddedFilter(query._query, parsedFilter) + ); + } + + _getQueryConstraints(): readonly AppliableConstraint[] { + return this._queryConstraints; + } + + _getOperator(): CompositeOperator { + return this.type === 'and' ? CompositeOperator.AND : CompositeOperator.OR; + } } -class QueryOrderByConstraint extends QueryConstraint { +/** + * `QueryNonFilterConstraint` is a helper union type that represents + * QueryConstraints which are used to narrow or order the set of documents, + * but that do not explicitly filter on a document field. + * `QueryNonFilterConstraint`s are created by invoking {@link orderBy}, + * {@link startAt}, {@link startAfter}, {@link endBefore}, {@link endAt}, + * {@link limit} or {@link limitToLast} and can then be passed to {@link query} + * to create a new query instance that also contains the `QueryConstraint`. + */ +export type QueryNonFilterConstraint = + | QueryOrderByConstraint + | QueryLimitConstraint + | QueryStartAtConstraint + | QueryEndAtConstraint; + +/** + * `QueryFilterConstraint` is a helper union type that represents + * {@link QueryFieldFilterConstraint} and {@link QueryCompositeFilterConstraint}. + * `QueryFilterConstraint`s are created by invoking {@link or} or {@link and} + * and can then be passed to {@link query} to create a new query instance that + * also contains the `QueryConstraint`. + * @internal TODO remove this internal tag with OR Query support in the server + */ +export type QueryFilterConstraint = + | QueryFieldFilterConstraint + | QueryCompositeFilterConstraint; + +/** + * Creates a {@link QueryCompositeFilterConstraint} that performs a logical OR + * of all the provided {@link QueryFilterConstraint}s. + * + * @param queryConstraints - Optional. The {@link QueryFilterConstraint}s + * for OR operation. These must be created with calls to {@link where}, + * {@link or}, or {@link and}. + * @returns The created {@link QueryCompositeFilterConstraint}. + * @internal TODO remove this internal tag with OR Query support in the server + */ +export function or( + ...queryConstraints: QueryFilterConstraint[] +): QueryCompositeFilterConstraint { + // Only support QueryFilterConstraints + queryConstraints.forEach(queryConstraint => + validateQueryFilterConstraint('or', queryConstraint) + ); + + return QueryCompositeFilterConstraint._create( + CompositeOperator.OR, + queryConstraints as QueryFilterConstraint[] + ); +} + +/** + * Creates a {@link QueryCompositeFilterConstraint} that performs a logical AND + * of all the provided {@link QueryFilterConstraint}s. + * + * @param queryConstraints - Optional. The {@link QueryFilterConstraint}s + * for AND operation. These must be created with calls to {@link where}, + * {@link or}, or {@link and}. + * @returns The created {@link QueryCompositeFilterConstraint}. + * @internal TODO remove this internal tag with OR Query support in the server + */ +export function and( + ...queryConstraints: QueryFilterConstraint[] +): QueryCompositeFilterConstraint { + // Only support QueryFilterConstraints + queryConstraints.forEach(queryConstraint => + validateQueryFilterConstraint('and', queryConstraint) + ); + + return QueryCompositeFilterConstraint._create( + CompositeOperator.AND, + queryConstraints as QueryFilterConstraint[] + ); +} + +/** + * A `QueryOrderByConstraint` is used to sort the set of documents returned by a + * Firestore query. `QueryOrderByConstraint`s are created by invoking + * {@link orderBy} and can then be passed to {@link query} to create a new query + * instance that also contains this `QueryOrderByConstraint`. + * + * Note: Documents that do not contain the orderBy field will not be present in + * the query result. + */ +export class QueryOrderByConstraint extends QueryConstraint { + /** The type of this query constraint */ readonly type = 'orderBy'; - constructor( + /** + * @internal + */ + protected constructor( private readonly _field: InternalFieldPath, private _direction: Direction ) { super(); } + static _create( + _field: InternalFieldPath, + _direction: Direction + ): QueryOrderByConstraint { + return new QueryOrderByConstraint(_field, _direction); + } + _apply(query: Query): Query { const orderBy = newQueryOrderBy(query._query, this._field, this._direction); return new Query( @@ -220,25 +458,39 @@ class QueryOrderByConstraint extends QueryConstraint { export type OrderByDirection = 'desc' | 'asc'; /** - * Creates a {@link QueryConstraint} that sorts the query result by the + * Creates a {@link QueryOrderByConstraint} that sorts the query result by the * specified field, optionally in descending order instead of ascending. * + * Note: Documents that do not contain the specified field will not be present + * in the query result. + * * @param fieldPath - The field to sort by. * @param directionStr - Optional direction to sort by ('asc' or 'desc'). If * not specified, order will be ascending. - * @returns The created {@link Query}. + * @returns The created {@link QueryOrderByConstraint}. */ export function orderBy( fieldPath: string | FieldPath, directionStr: OrderByDirection = 'asc' -): QueryConstraint { +): QueryOrderByConstraint { const direction = directionStr as Direction; const path = fieldPathFromArgument('orderBy', fieldPath); - return new QueryOrderByConstraint(path, direction); + return QueryOrderByConstraint._create(path, direction); } -class QueryLimitConstraint extends QueryConstraint { - constructor( +/** + * A `QueryLimitConstraint` is used to limit the number of documents returned by + * a Firestore query. + * `QueryLimitConstraint`s are created by invoking {@link limit} or + * {@link limitToLast} and can then be passed to {@link query} to create a new + * query instance that also contains this `QueryLimitConstraint`. + */ +export class QueryLimitConstraint extends QueryConstraint { + /** + * @internal + */ + protected constructor( + /** The type of this query constraint */ readonly type: 'limit' | 'limitToLast', private readonly _limit: number, private readonly _limitType: LimitType @@ -246,6 +498,14 @@ class QueryLimitConstraint extends QueryConstraint { super(); } + static _create( + type: 'limit' | 'limitToLast', + _limit: number, + _limitType: LimitType + ): QueryLimitConstraint { + return new QueryLimitConstraint(type, _limit, _limitType); + } + _apply(query: Query): Query { return new Query( query.firestore, @@ -256,32 +516,45 @@ class QueryLimitConstraint extends QueryConstraint { } /** - * Creates a {@link QueryConstraint} that only returns the first matching documents. + * Creates a {@link QueryLimitConstraint} that only returns the first matching + * documents. * * @param limit - The maximum number of items to return. - * @returns The created {@link Query}. + * @returns The created {@link QueryLimitConstraint}. */ -export function limit(limit: number): QueryConstraint { +export function limit(limit: number): QueryLimitConstraint { validatePositiveNumber('limit', limit); - return new QueryLimitConstraint('limit', limit, LimitType.First); + return QueryLimitConstraint._create('limit', limit, LimitType.First); } /** - * Creates a {@link QueryConstraint} that only returns the last matching documents. + * Creates a {@link QueryLimitConstraint} that only returns the last matching + * documents. * * You must specify at least one `orderBy` clause for `limitToLast` queries, * otherwise an exception will be thrown during execution. * * @param limit - The maximum number of items to return. - * @returns The created {@link Query}. + * @returns The created {@link QueryLimitConstraint}. */ -export function limitToLast(limit: number): QueryConstraint { +export function limitToLast(limit: number): QueryLimitConstraint { validatePositiveNumber('limitToLast', limit); - return new QueryLimitConstraint('limitToLast', limit, LimitType.Last); + return QueryLimitConstraint._create('limitToLast', limit, LimitType.Last); } -class QueryStartAtConstraint extends QueryConstraint { - constructor( +/** + * A `QueryStartAtConstraint` is used to exclude documents from the start of a + * result set returned by a Firestore query. + * `QueryStartAtConstraint`s are created by invoking {@link (startAt:1)} or + * {@link (startAfter:1)} and can then be passed to {@link query} to create a + * new query instance that also contains this `QueryStartAtConstraint`. + */ +export class QueryStartAtConstraint extends QueryConstraint { + /** + * @internal + */ + protected constructor( + /** The type of this query constraint */ readonly type: 'startAt' | 'startAfter', private readonly _docOrFields: Array>, private readonly _inclusive: boolean @@ -289,6 +562,14 @@ class QueryStartAtConstraint extends QueryConstraint { super(); } + static _create( + type: 'startAt' | 'startAfter', + _docOrFields: Array>, + _inclusive: boolean + ): QueryStartAtConstraint { + return new QueryStartAtConstraint(type, _docOrFields, _inclusive); + } + _apply(query: Query): Query { const bound = newQueryBoundFromDocOrFields( query, @@ -305,29 +586,31 @@ class QueryStartAtConstraint extends QueryConstraint { } /** - * Creates a {@link QueryConstraint} that modifies the result set to start at the - * provided document (inclusive). The starting position is relative to the order - * of the query. The document must contain all of the fields provided in the - * `orderBy` of this query. + * Creates a {@link QueryStartAtConstraint} that modifies the result set to + * start at the provided document (inclusive). The starting position is relative + * to the order of the query. The document must contain all of the fields + * provided in the `orderBy` of this query. * * @param snapshot - The snapshot of the document to start at. - * @returns A {@link QueryConstraint} to pass to `query()`. + * @returns A {@link QueryStartAtConstraint} to pass to `query()`. */ -export function startAt(snapshot: DocumentSnapshot): QueryConstraint; +export function startAt( + snapshot: DocumentSnapshot +): QueryStartAtConstraint; /** - * Creates a {@link QueryConstraint} that modifies the result set to start at the - * provided fields relative to the order of the query. The order of the field - * values must match the order of the order by clauses of the query. + * Creates a {@link QueryStartAtConstraint} that modifies the result set to + * start at the provided fields relative to the order of the query. The order of + * the field values must match the order of the order by clauses of the query. * * @param fieldValues - The field values to start this query at, in order * of the query's order by. - * @returns A {@link QueryConstraint} to pass to `query()`. + * @returns A {@link QueryStartAtConstraint} to pass to `query()`. */ -export function startAt(...fieldValues: unknown[]): QueryConstraint; +export function startAt(...fieldValues: unknown[]): QueryStartAtConstraint; export function startAt( ...docOrFields: Array> -): QueryConstraint { - return new QueryStartAtConstraint( +): QueryStartAtConstraint { + return QueryStartAtConstraint._create( 'startAt', docOrFields, /*inclusive=*/ true @@ -335,39 +618,50 @@ export function startAt( } /** - * Creates a {@link QueryConstraint} that modifies the result set to start after the - * provided document (exclusive). The starting position is relative to the order - * of the query. The document must contain all of the fields provided in the - * orderBy of the query. + * Creates a {@link QueryStartAtConstraint} that modifies the result set to + * start after the provided document (exclusive). The starting position is + * relative to the order of the query. The document must contain all of the + * fields provided in the orderBy of the query. * * @param snapshot - The snapshot of the document to start after. - * @returns A {@link QueryConstraint} to pass to `query()` + * @returns A {@link QueryStartAtConstraint} to pass to `query()` */ export function startAfter( snapshot: DocumentSnapshot -): QueryConstraint; +): QueryStartAtConstraint; /** - * Creates a {@link QueryConstraint} that modifies the result set to start after the - * provided fields relative to the order of the query. The order of the field - * values must match the order of the order by clauses of the query. + * Creates a {@link QueryStartAtConstraint} that modifies the result set to + * start after the provided fields relative to the order of the query. The order + * of the field values must match the order of the order by clauses of the query. * * @param fieldValues - The field values to start this query after, in order * of the query's order by. - * @returns A {@link QueryConstraint} to pass to `query()` + * @returns A {@link QueryStartAtConstraint} to pass to `query()` */ -export function startAfter(...fieldValues: unknown[]): QueryConstraint; +export function startAfter(...fieldValues: unknown[]): QueryStartAtConstraint; export function startAfter( ...docOrFields: Array> -): QueryConstraint { - return new QueryStartAtConstraint( +): QueryStartAtConstraint { + return QueryStartAtConstraint._create( 'startAfter', docOrFields, /*inclusive=*/ false ); } -class QueryEndAtConstraint extends QueryConstraint { - constructor( +/** + * A `QueryEndAtConstraint` is used to exclude documents from the end of a + * result set returned by a Firestore query. + * `QueryEndAtConstraint`s are created by invoking {@link (endAt:1)} or + * {@link (endBefore:1)} and can then be passed to {@link query} to create a new + * query instance that also contains this `QueryEndAtConstraint`. + */ +export class QueryEndAtConstraint extends QueryConstraint { + /** + * @internal + */ + protected constructor( + /** The type of this query constraint */ readonly type: 'endBefore' | 'endAt', private readonly _docOrFields: Array>, private readonly _inclusive: boolean @@ -375,6 +669,14 @@ class QueryEndAtConstraint extends QueryConstraint { super(); } + static _create( + type: 'endBefore' | 'endAt', + _docOrFields: Array>, + _inclusive: boolean + ): QueryEndAtConstraint { + return new QueryEndAtConstraint(type, _docOrFields, _inclusive); + } + _apply(query: Query): Query { const bound = newQueryBoundFromDocOrFields( query, @@ -391,29 +693,31 @@ class QueryEndAtConstraint extends QueryConstraint { } /** - * Creates a {@link QueryConstraint} that modifies the result set to end before the - * provided document (exclusive). The end position is relative to the order of - * the query. The document must contain all of the fields provided in the - * orderBy of the query. + * Creates a {@link QueryEndAtConstraint} that modifies the result set to end + * before the provided document (exclusive). The end position is relative to the + * order of the query. The document must contain all of the fields provided in + * the orderBy of the query. * * @param snapshot - The snapshot of the document to end before. - * @returns A {@link QueryConstraint} to pass to `query()` + * @returns A {@link QueryEndAtConstraint} to pass to `query()` */ -export function endBefore(snapshot: DocumentSnapshot): QueryConstraint; +export function endBefore( + snapshot: DocumentSnapshot +): QueryEndAtConstraint; /** - * Creates a {@link QueryConstraint} that modifies the result set to end before the - * provided fields relative to the order of the query. The order of the field - * values must match the order of the order by clauses of the query. + * Creates a {@link QueryEndAtConstraint} that modifies the result set to end + * before the provided fields relative to the order of the query. The order of + * the field values must match the order of the order by clauses of the query. * * @param fieldValues - The field values to end this query before, in order * of the query's order by. - * @returns A {@link QueryConstraint} to pass to `query()` + * @returns A {@link QueryEndAtConstraint} to pass to `query()` */ -export function endBefore(...fieldValues: unknown[]): QueryConstraint; +export function endBefore(...fieldValues: unknown[]): QueryEndAtConstraint; export function endBefore( ...docOrFields: Array> -): QueryConstraint { - return new QueryEndAtConstraint( +): QueryEndAtConstraint { + return QueryEndAtConstraint._create( 'endBefore', docOrFields, /*inclusive=*/ false @@ -421,29 +725,35 @@ export function endBefore( } /** - * Creates a {@link QueryConstraint} that modifies the result set to end at the - * provided document (inclusive). The end position is relative to the order of - * the query. The document must contain all of the fields provided in the + * Creates a {@link QueryEndAtConstraint} that modifies the result set to end at + * the provided document (inclusive). The end position is relative to the order + * of the query. The document must contain all of the fields provided in the * orderBy of the query. * * @param snapshot - The snapshot of the document to end at. - * @returns A {@link QueryConstraint} to pass to `query()` + * @returns A {@link QueryEndAtConstraint} to pass to `query()` */ -export function endAt(snapshot: DocumentSnapshot): QueryConstraint; +export function endAt( + snapshot: DocumentSnapshot +): QueryEndAtConstraint; /** - * Creates a {@link QueryConstraint} that modifies the result set to end at the - * provided fields relative to the order of the query. The order of the field + * Creates a {@link QueryEndAtConstraint} that modifies the result set to end at + * the provided fields relative to the order of the query. The order of the field * values must match the order of the order by clauses of the query. * * @param fieldValues - The field values to end this query at, in order * of the query's order by. - * @returns A {@link QueryConstraint} to pass to `query()` + * @returns A {@link QueryEndAtConstraint} to pass to `query()` */ -export function endAt(...fieldValues: unknown[]): QueryConstraint; +export function endAt(...fieldValues: unknown[]): QueryEndAtConstraint; export function endAt( ...docOrFields: Array> -): QueryConstraint { - return new QueryEndAtConstraint('endAt', docOrFields, /*inclusive=*/ true); +): QueryEndAtConstraint { + return QueryEndAtConstraint._create( + 'endAt', + docOrFields, + /*inclusive=*/ true + ); } /** Helper function to create a bound from a document or fields */ @@ -518,7 +828,6 @@ export function newQueryFilter( ); } const filter = FieldFilter.create(fieldPath, op, fieldValue); - validateNewFilter(query, filter); return filter; } @@ -792,46 +1101,84 @@ function conflictingOps(op: Operator): Operator[] { } } -function validateNewFilter(query: InternalQuery, filter: Filter): void { - debugAssert(filter instanceof FieldFilter, 'Only FieldFilters are supported'); +function validateNewFieldFilter( + query: InternalQuery, + fieldFilter: FieldFilter +): void { + if (fieldFilter.isInequality()) { + const existingInequality = getInequalityFilterField(query); + const newInequality = fieldFilter.field; - if (filter.isInequality()) { - const existingField = getInequalityFilterField(query); - if (existingField !== null && !existingField.isEqual(filter.field)) { + if ( + existingInequality !== null && + !existingInequality.isEqual(newInequality) + ) { throw new FirestoreError( Code.INVALID_ARGUMENT, 'Invalid query. All where filters with an inequality' + ' (<, <=, !=, not-in, >, or >=) must be on the same field. But you have' + - ` inequality filters on '${existingField.toString()}'` + - ` and '${filter.field.toString()}'` + ` inequality filters on '${existingInequality.toString()}'` + + ` and '${newInequality.toString()}'` ); } const firstOrderByField = getFirstOrderByField(query); if (firstOrderByField !== null) { - validateOrderByAndInequalityMatch(query, filter.field, firstOrderByField); + validateOrderByAndInequalityMatch( + query, + newInequality, + firstOrderByField + ); } } - const conflictingOp = findFilterOperator(query, conflictingOps(filter.op)); + const conflictingOp = findOpInsideFilters( + query.filters, + conflictingOps(fieldFilter.op) + ); if (conflictingOp !== null) { // Special case when it's a duplicate op to give a slightly clearer error message. - if (conflictingOp === filter.op) { + if (conflictingOp === fieldFilter.op) { throw new FirestoreError( Code.INVALID_ARGUMENT, 'Invalid query. You cannot use more than one ' + - `'${filter.op.toString()}' filter.` + `'${fieldFilter.op.toString()}' filter.` ); } else { throw new FirestoreError( Code.INVALID_ARGUMENT, - `Invalid query. You cannot use '${filter.op.toString()}' filters ` + + `Invalid query. You cannot use '${fieldFilter.op.toString()}' filters ` + `with '${conflictingOp.toString()}' filters.` ); } } } +function validateNewFilter(query: InternalQuery, filter: Filter): void { + let testQuery = query; + const subFilters = filter.getFlattenedFilters(); + for (const subFilter of subFilters) { + validateNewFieldFilter(testQuery, subFilter); + testQuery = queryWithAddedFilter(testQuery, subFilter); + } +} + +// Checks if any of the provided filter operators are included in the given list of filters and +// returns the first one that is, or null if none are. +function findOpInsideFilters( + filters: Filter[], + operators: Operator[] +): Operator | null { + for (const filter of filters) { + for (const fieldFilter of filter.getFlattenedFilters()) { + if (operators.indexOf(fieldFilter.op) >= 0) { + return fieldFilter.op; + } + } + } + return null; +} + function validateNewOrderBy(query: InternalQuery, orderBy: OrderBy): void { if (getFirstOrderByField(query) === null) { // This is the first order by. It must match any inequality. @@ -858,3 +1205,43 @@ function validateOrderByAndInequalityMatch( ); } } + +export function validateQueryFilterConstraint( + functionName: string, + queryConstraint: AppliableConstraint +): void { + if ( + !(queryConstraint instanceof QueryFieldFilterConstraint) && + !(queryConstraint instanceof QueryCompositeFilterConstraint) + ) { + throw new FirestoreError( + Code.INVALID_ARGUMENT, + `Function ${functionName}() requires AppliableConstraints created with a call to 'where(...)', 'or(...)', or 'and(...)'.` + ); + } +} + +function validateQueryConstraintArray( + queryConstraint: AppliableConstraint[] +): void { + const compositeFilterCount = queryConstraint.filter( + filter => filter instanceof QueryCompositeFilterConstraint + ).length; + const fieldFilterCount = queryConstraint.filter( + filter => filter instanceof QueryFieldFilterConstraint + ).length; + + if ( + compositeFilterCount > 1 || + (compositeFilterCount > 0 && fieldFilterCount > 0) + ) { + throw new FirestoreError( + Code.INVALID_ARGUMENT, + 'InvalidQuery. When using composite filters, you cannot use ' + + 'more than one filter at the top level. Consider nesting the multiple ' + + 'filters within an `and(...)` statement. For example: ' + + 'change `query(query, where(...), or(...))` to ' + + '`query(query, and(where(...), or(...)))`.' + ); + } +} diff --git a/packages/firestore/src/local/indexeddb_index_manager.ts b/packages/firestore/src/local/indexeddb_index_manager.ts index 16827806beb..a776c95c1da 100644 --- a/packages/firestore/src/local/indexeddb_index_manager.ts +++ b/packages/firestore/src/local/indexeddb_index_manager.ts @@ -16,19 +16,26 @@ */ import { User } from '../auth/user'; +import { Bound } from '../core/bound'; import { DatabaseId } from '../core/database_info'; import { - Bound, - canonifyTarget, + CompositeFilter, + CompositeOperator, FieldFilter, - Operator, + Filter, + Operator +} from '../core/filter'; +import { + canonifyTarget, + newTarget, Target, targetEquals, targetGetArrayValues, targetGetLowerBound, targetGetNotInValues, targetGetSegmentCount, - targetGetUpperBound + targetGetUpperBound, + targetHasLimit } from '../core/target'; import { FirestoreIndexValueWriter } from '../index/firestore_index_value_writer'; import { IndexByteEncoder } from '../index/index_byte_encoder'; @@ -53,6 +60,7 @@ import { isArray, refValue } from '../model/values'; import { Value as ProtoValue } from '../protos/firestore_proto_api'; import { debugAssert, fail, hardAssert } from '../util/assert'; import { logDebug } from '../util/log'; +import { getDnfTerms } from '../util/logic_utils'; import { immediateSuccessor, primitiveComparator } from '../util/misc'; import { ObjectMap } from '../util/obj_map'; import { diffSortedSets, SortedSet } from '../util/sorted_set'; @@ -333,8 +341,28 @@ export class IndexedDbIndexManager implements IndexManager { if (subTargets) { return subTargets; } - // TODO(orquery): Implement DNF transform - subTargets = [target]; + + if (target.filters.length === 0) { + subTargets = [target]; + } else { + // There is an implicit AND operation between all the filters stored in the target + const dnf: Filter[] = getDnfTerms( + CompositeFilter.create(target.filters, CompositeOperator.AND) + ); + + subTargets = dnf.map(term => + newTarget( + target.path, + target.collectionGroup, + target.orderBy, + term.getFilters(), + target.limit, + target.startAt, + target.endAt + ) + ); + } + this.targetToDnfSubTargets.set(target, subTargets); return subTargets; } @@ -459,21 +487,32 @@ export class IndexedDbIndexManager implements IndexManager { target: Target ): PersistencePromise { let indexType = IndexType.FULL; - return PersistencePromise.forEach( - this.getSubTargets(target), - (target: Target) => { - return this.getFieldIndex(transaction, target).next(index => { - if (!index) { - indexType = IndexType.NONE; - } else if ( - indexType !== IndexType.NONE && - index.fields.length < targetGetSegmentCount(target) - ) { - indexType = IndexType.PARTIAL; - } - }); + const subTargets = this.getSubTargets(target); + return PersistencePromise.forEach(subTargets, (target: Target) => { + return this.getFieldIndex(transaction, target).next(index => { + if (!index) { + indexType = IndexType.NONE; + } else if ( + indexType !== IndexType.NONE && + index.fields.length < targetGetSegmentCount(target) + ) { + indexType = IndexType.PARTIAL; + } + }); + }).next(() => { + // OR queries have more than one sub-target (one sub-target per DNF term). We currently consider + // OR queries that have a `limit` to have a partial index. For such queries we perform sorting + // and apply the limit in memory as a post-processing step. + if ( + targetHasLimit(target) && + subTargets.length > 1 && + indexType === IndexType.FULL + ) { + return IndexType.PARTIAL; } - ).next(() => indexType); + + return indexType; + }); } /** @@ -533,18 +572,18 @@ export class IndexedDbIndexManager implements IndexManager { private encodeValues( fieldIndex: FieldIndex, target: Target, - bound: ProtoValue[] | null + values: ProtoValue[] | null ): Uint8Array[] { - if (bound === null) { + if (values === null) { return []; } let encoders: IndexByteEncoder[] = []; encoders.push(new IndexByteEncoder()); - let boundIdx = 0; + let valueIdx = 0; for (const segment of fieldIndexGetDirectionalSegments(fieldIndex)) { - const value = bound[boundIdx++]; + const value = values[valueIdx++]; for (const encoder of encoders) { if (this.isInFilter(target, segment.fieldPath) && isArray(value)) { encoders = this.expandIndexValues(encoders, segment, value); @@ -942,30 +981,41 @@ export class IndexedDbIndexManager implements IndexManager { const ranges: IDBKeyRange[] = []; for (let i = 0; i < bounds.length; i += 2) { - ranges.push( - IDBKeyRange.bound( - [ - bounds[i].indexId, - this.uid, - bounds[i].arrayValue, - bounds[i].directionalValue, - EMPTY_VALUE, - [] - ] as DbIndexEntryKey, - [ - bounds[i + 1].indexId, - this.uid, - bounds[i + 1].arrayValue, - bounds[i + 1].directionalValue, - EMPTY_VALUE, - [] - ] as DbIndexEntryKey - ) - ); + // If we encounter two bounds that will create an unmatchable key range, + // then we return an empty set of key ranges. + if (this.isRangeMatchable(bounds[i], bounds[i + 1])) { + return []; + } + + const lowerBound = [ + bounds[i].indexId, + this.uid, + bounds[i].arrayValue, + bounds[i].directionalValue, + EMPTY_VALUE, + [] + ] as DbIndexEntryKey; + + const upperBound = [ + bounds[i + 1].indexId, + this.uid, + bounds[i + 1].arrayValue, + bounds[i + 1].directionalValue, + EMPTY_VALUE, + [] + ] as DbIndexEntryKey; + + ranges.push(IDBKeyRange.bound(lowerBound, upperBound)); } return ranges; } + isRangeMatchable(lowerBound: IndexEntry, upperBound: IndexEntry): boolean { + // If lower bound is greater than the upper bound, then the key + // range can never be matched. + return indexEntryComparator(lowerBound, upperBound) > 0; + } + getMinOffsetFromCollectionGroup( transaction: PersistenceTransaction, collectionGroup: string diff --git a/packages/firestore/src/model/target_index_matcher.ts b/packages/firestore/src/model/target_index_matcher.ts index d78fe0af215..d58d7792e34 100644 --- a/packages/firestore/src/model/target_index_matcher.ts +++ b/packages/firestore/src/model/target_index_matcher.ts @@ -15,14 +15,10 @@ * limitations under the License. */ -import { - Direction, - FieldFilter, - Operator, - OrderBy, - Target -} from '../core/target'; -import { debugAssert } from '../util/assert'; +import { FieldFilter, Operator } from '../core/filter'; +import { Direction, OrderBy } from '../core/order_by'; +import { Target } from '../core/target'; +import { debugAssert, hardAssert } from '../util/assert'; import { FieldIndex, @@ -107,7 +103,7 @@ export class TargetIndexMatcher { * omitted. */ servedByIndex(index: FieldIndex): boolean { - debugAssert( + hardAssert( index.collectionGroup === this.collectionId, 'Collection IDs do not match' ); diff --git a/packages/firestore/src/protos/compile.sh b/packages/firestore/src/protos/compile.sh new file mode 100755 index 00000000000..26c46d1a40d --- /dev/null +++ b/packages/firestore/src/protos/compile.sh @@ -0,0 +1,27 @@ +#!/bin/bash + +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -euo pipefail + +# Variables +PROTOS_DIR="." +PBJS="$(npm bin)/pbjs" + +"${PBJS}" --proto_path=. --target=json -o protos.json \ + -r firestore_v1 \ + "${PROTOS_DIR}/google/firestore/v1/*.proto" \ + "${PROTOS_DIR}/google/protobuf/*.proto" "${PROTOS_DIR}/google/type/*.proto" \ + "${PROTOS_DIR}/google/rpc/*.proto" "${PROTOS_DIR}/google/api/*.proto" diff --git a/packages/firestore/src/protos/firestore_proto_api.ts b/packages/firestore/src/protos/firestore_proto_api.ts index e7dfe0a88b2..46b00e0e6f7 100644 --- a/packages/firestore/src/protos/firestore_proto_api.ts +++ b/packages/firestore/src/protos/firestore_proto_api.ts @@ -30,7 +30,7 @@ export declare type Timestamp = | string | { seconds?: string | number; nanos?: number }; -export declare type CompositeFilterOp = 'OPERATOR_UNSPECIFIED' | 'AND'; +export declare type CompositeFilterOp = 'OPERATOR_UNSPECIFIED' | 'AND' | 'OR'; export interface ICompositeFilterOpEnum { OPERATOR_UNSPECIFIED: CompositeFilterOp; AND: CompositeFilterOp; diff --git a/packages/firestore/src/protos/google/firestore/v1/query.proto b/packages/firestore/src/protos/google/firestore/v1/query.proto index 1bb2b6cdd01..e3d95534bbf 100644 --- a/packages/firestore/src/protos/google/firestore/v1/query.proto +++ b/packages/firestore/src/protos/google/firestore/v1/query.proto @@ -65,8 +65,11 @@ message StructuredQuery { // Unspecified. This value must not be used. OPERATOR_UNSPECIFIED = 0; - // The results are required to satisfy each of the combined filters. + // Documents are required to satisfy all of the combined filters. AND = 1; + + // Documents are required to satisfy at least one of the combined filters. + OR = 2; } // The operator for combining multiple filters. diff --git a/packages/firestore/src/protos/protos.json b/packages/firestore/src/protos/protos.json index 093e22c6451..2b6cac8ddd6 100644 --- a/packages/firestore/src/protos/protos.json +++ b/packages/firestore/src/protos/protos.json @@ -2279,7 +2279,8 @@ "Operator": { "values": { "OPERATOR_UNSPECIFIED": 0, - "AND": 1 + "AND": 1, + "OR": 2 } } } diff --git a/packages/firestore/src/remote/serializer.ts b/packages/firestore/src/remote/serializer.ts index 21091d55a36..c537648775f 100644 --- a/packages/firestore/src/remote/serializer.ts +++ b/packages/firestore/src/remote/serializer.ts @@ -15,7 +15,17 @@ * limitations under the License. */ +import { Bound } from '../core/bound'; import { DatabaseId } from '../core/database_info'; +import { + CompositeFilter, + compositeFilterIsFlatConjunction, + CompositeOperator, + FieldFilter, + Filter, + Operator +} from '../core/filter'; +import { Direction, OrderBy } from '../core/order_by'; import { LimitType, newQuery, @@ -24,16 +34,7 @@ import { queryToTarget } from '../core/query'; import { SnapshotVersion } from '../core/snapshot_version'; -import { - Bound, - Direction, - FieldFilter, - Filter, - targetIsDocumentTarget, - Operator, - OrderBy, - Target -} from '../core/target'; +import { targetIsDocumentTarget, Target } from '../core/target'; import { TargetId } from '../core/types'; import { Timestamp } from '../lite-api/timestamp'; import { TargetData, TargetPurpose } from '../local/target_data'; @@ -64,6 +65,7 @@ import { isNanValue, isNullValue } from '../model/values'; import { ApiClientObjectMap as ProtoApiClientObjectMap, BatchGetDocumentsResponse as ProtoBatchGetDocumentsResponse, + CompositeFilterOp as ProtoCompositeFilterOp, Cursor as ProtoCursor, Document as ProtoDocument, DocumentMask as ProtoDocumentMask, @@ -123,6 +125,13 @@ const OPERATORS = (() => { return ops; })(); +const COMPOSITE_OPERATORS = (() => { + const ops: { [op: string]: ProtoCompositeFilterOp } = {}; + ops[CompositeOperator.AND] = 'AND'; + ops[CompositeOperator.OR] = 'OR'; + return ops; +})(); + function assertPresent(value: unknown, description: string): asserts value { debugAssert(!isNullOrUndefined(value), description + ' is missing'); } @@ -828,7 +837,7 @@ export function toQueryTarget( result.structuredQuery!.from = [{ collectionId: path.lastSegment() }]; } - const where = toFilter(target.filters); + const where = toFilters(target.filters); if (where) { result.structuredQuery!.where = where; } @@ -894,7 +903,7 @@ export function convertQueryTargetToQuery(target: ProtoQueryTarget): Query { let filterBy: Filter[] = []; if (query.where) { - filterBy = fromFilter(query.where); + filterBy = fromFilters(query.where); } let orderBy: OrderBy[] = []; @@ -993,34 +1002,34 @@ export function toTarget( return result; } -function toFilter(filters: Filter[]): ProtoFilter | undefined { +function toFilters(filters: Filter[]): ProtoFilter | undefined { if (filters.length === 0) { return; } - const protos = filters.map(filter => { - debugAssert( - filter instanceof FieldFilter, - 'Only FieldFilters are supported' - ); - return toUnaryOrFieldFilter(filter); - }); - if (protos.length === 1) { - return protos[0]; + + return toFilter(CompositeFilter.create(filters, CompositeOperator.AND)); +} + +function fromFilters(filter: ProtoFilter): Filter[] { + const result = fromFilter(filter); + + if ( + result instanceof CompositeFilter && + compositeFilterIsFlatConjunction(result) + ) { + return result.getFilters(); } - return { compositeFilter: { op: 'AND', filters: protos } }; + + return [result]; } -function fromFilter(filter: ProtoFilter | undefined): Filter[] { - if (!filter) { - return []; - } else if (filter.unaryFilter !== undefined) { - return [fromUnaryFilter(filter)]; +function fromFilter(filter: ProtoFilter): Filter { + if (filter.unaryFilter !== undefined) { + return fromUnaryFilter(filter); } else if (filter.fieldFilter !== undefined) { - return [fromFieldFilter(filter)]; + return fromFieldFilter(filter); } else if (filter.compositeFilter !== undefined) { - return filter.compositeFilter - .filters!.map(f => fromFilter(f)) - .reduce((accum, current) => accum.concat(current)); + return fromCompositeFilter(filter); } else { return fail('Unknown filter: ' + JSON.stringify(filter)); } @@ -1087,6 +1096,12 @@ export function toOperatorName(op: Operator): ProtoFieldFilterOp { return OPERATORS[op]; } +export function toCompositeOperatorName( + op: CompositeOperator +): ProtoCompositeFilterOp { + return COMPOSITE_OPERATORS[op]; +} + export function fromOperatorName(op: ProtoFieldFilterOp): Operator { switch (op) { case 'EQUAL': @@ -1116,6 +1131,19 @@ export function fromOperatorName(op: ProtoFieldFilterOp): Operator { } } +export function fromCompositeOperatorName( + op: ProtoCompositeFilterOp +): CompositeOperator { + switch (op) { + case 'AND': + return CompositeOperator.AND; + case 'OR': + return CompositeOperator.OR; + default: + return fail('Unknown operator'); + } +} + export function toFieldPathReference(path: FieldPath): ProtoFieldReference { return { fieldPath: path.canonicalString() }; } @@ -1141,15 +1169,32 @@ export function fromPropertyOrder(orderBy: ProtoOrder): OrderBy { ); } -export function fromFieldFilter(filter: ProtoFilter): Filter { - return FieldFilter.create( - fromFieldPathReference(filter.fieldFilter!.field!), - fromOperatorName(filter.fieldFilter!.op!), - filter.fieldFilter!.value! - ); +// visible for testing +export function toFilter(filter: Filter): ProtoFilter { + if (filter instanceof FieldFilter) { + return toUnaryOrFieldFilter(filter); + } else if (filter instanceof CompositeFilter) { + return toCompositeFilter(filter); + } else { + return fail('Unrecognized filter type ' + JSON.stringify(filter)); + } +} + +export function toCompositeFilter(filter: CompositeFilter): ProtoFilter { + const protos = filter.getFilters().map(filter => toFilter(filter)); + + if (protos.length === 1) { + return protos[0]; + } + + return { + compositeFilter: { + op: toCompositeOperatorName(filter.op), + filters: protos + } + }; } -// visible for testing export function toUnaryOrFieldFilter(filter: FieldFilter): ProtoFilter { if (filter.op === Operator.EQUAL) { if (isNanValue(filter.value)) { @@ -1222,6 +1267,21 @@ export function fromUnaryFilter(filter: ProtoFilter): Filter { } } +export function fromFieldFilter(filter: ProtoFilter): FieldFilter { + return FieldFilter.create( + fromFieldPathReference(filter.fieldFilter!.field!), + fromOperatorName(filter.fieldFilter!.op!), + filter.fieldFilter!.value! + ); +} + +export function fromCompositeFilter(filter: ProtoFilter): CompositeFilter { + return CompositeFilter.create( + filter.compositeFilter!.filters!.map(filter => fromFilter(filter)), + fromCompositeOperatorName(filter.compositeFilter!.op!) + ); +} + export function toDocumentMask(fieldMask: FieldMask): ProtoDocumentMask { const canonicalFields: string[] = []; fieldMask.fields.forEach(field => diff --git a/packages/firestore/src/util/logic_utils.ts b/packages/firestore/src/util/logic_utils.ts new file mode 100644 index 00000000000..3c3a6b19fd8 --- /dev/null +++ b/packages/firestore/src/util/logic_utils.ts @@ -0,0 +1,363 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + CompositeFilter, + compositeFilterIsConjunction, + compositeFilterIsDisjunction, + compositeFilterIsFlat, + compositeFilterIsFlatConjunction, + compositeFilterWithAddedFilters, + CompositeOperator, + FieldFilter, + Filter, + InFilter, + Operator +} from '../core/filter'; + +import { hardAssert } from './assert'; + +/** + * Provides utility functions that help with boolean logic transformations needed for handling + * complex filters used in queries. + */ + +/** + * The `in` filter is only a syntactic sugar over a disjunction of equalities. For instance: `a in + * [1,2,3]` is in fact `a==1 || a==2 || a==3`. This method expands any `in` filter in the given + * input into a disjunction of equality filters and returns the expanded filter. + */ +export function computeInExpansion(filter: Filter): Filter { + hardAssert( + filter instanceof FieldFilter || filter instanceof CompositeFilter, + 'Only field filters and composite filters are accepted.' + ); + + if (filter instanceof FieldFilter) { + if (filter instanceof InFilter) { + const expandedFilters = + filter.value.arrayValue?.values?.map(value => + FieldFilter.create(filter.field, Operator.EQUAL, value) + ) || []; + + return CompositeFilter.create(expandedFilters, CompositeOperator.OR); + } else { + // We have reached other kinds of field filters. + return filter; + } + } + + // We have a composite filter. + const expandedFilters = filter.filters.map(subfilter => + computeInExpansion(subfilter) + ); + return CompositeFilter.create(expandedFilters, filter.op); +} + +/** + * Given a composite filter, returns the list of terms in its disjunctive normal form. + * + *

Each element in the return value is one term of the resulting DNF. For instance: For the + * input: (A || B) && C, the DNF form is: (A && C) || (B && C), and the return value is a list + * with two elements: a composite filter that performs (A && C), and a composite filter that + * performs (B && C). + * + * @param filter the composite filter to calculate DNF transform for. + * @return the terms in the DNF transform. + */ +export function getDnfTerms(filter: CompositeFilter): Filter[] { + if (filter.getFilters().length === 0) { + return []; + } + + const result: Filter = computeDistributedNormalForm( + computeInExpansion(filter) + ); + + hardAssert( + isDisjunctiveNormalForm(result), + 'computeDistributedNormalForm did not result in disjunctive normal form' + ); + + if (isSingleFieldFilter(result) || isFlatConjunction(result)) { + return [result]; + } + + return result.getFilters(); +} + +/** Returns true if the given filter is a single field filter. e.g. (a == 10). */ +function isSingleFieldFilter(filter: Filter): boolean { + return filter instanceof FieldFilter; +} + +/** + * Returns true if the given filter is the conjunction of one or more field filters. e.g. (a == 10 + * && b == 20) + */ +function isFlatConjunction(filter: Filter): boolean { + return ( + filter instanceof CompositeFilter && + compositeFilterIsFlatConjunction(filter) + ); +} + +/** + * Returns whether or not the given filter is in disjunctive normal form (DNF). + * + *

In boolean logic, a disjunctive normal form (DNF) is a canonical normal form of a logical + * formula consisting of a disjunction of conjunctions; it can also be described as an OR of ANDs. + * + *

For more info, visit: https://en.wikipedia.org/wiki/Disjunctive_normal_form + */ +function isDisjunctiveNormalForm(filter: Filter): boolean { + return ( + isSingleFieldFilter(filter) || + isFlatConjunction(filter) || + isDisjunctionOfFieldFiltersAndFlatConjunctions(filter) + ); +} + +/** + * Returns true if the given filter is the disjunction of one or more "flat conjunctions" and + * field filters. e.g. (a == 10) || (b==20 && c==30) + */ +function isDisjunctionOfFieldFiltersAndFlatConjunctions( + filter: Filter +): boolean { + if (filter instanceof CompositeFilter) { + if (compositeFilterIsDisjunction(filter)) { + for (const subFilter of filter.getFilters()) { + if (!isSingleFieldFilter(subFilter) && !isFlatConjunction(subFilter)) { + return false; + } + } + + return true; + } + } + + return false; +} + +export function computeDistributedNormalForm(filter: Filter): Filter { + hardAssert( + filter instanceof FieldFilter || filter instanceof CompositeFilter, + 'Only field filters and composite filters are accepted.' + ); + + if (filter instanceof FieldFilter) { + return filter; + } + + if (filter.filters.length === 1) { + return computeDistributedNormalForm(filter.filters[0]); + } + + // Compute DNF for each of the subfilters first + const result = filter.filters.map(subfilter => + computeDistributedNormalForm(subfilter) + ); + + let newFilter: Filter = CompositeFilter.create(result, filter.op); + newFilter = applyAssociation(newFilter); + + if (isDisjunctiveNormalForm(newFilter)) { + return newFilter; + } + + hardAssert( + newFilter instanceof CompositeFilter, + 'field filters are already in DNF form' + ); + hardAssert( + compositeFilterIsConjunction(newFilter), + 'Disjunction of filters all of which are already in DNF form is itself in DNF form.' + ); + hardAssert( + newFilter.filters.length > 1, + 'Single-filter composite filters are already in DNF form.' + ); + + return newFilter.filters.reduce((runningResult, filter) => + applyDistribution(runningResult, filter) + ); +} + +export function applyDistribution(lhs: Filter, rhs: Filter): Filter { + hardAssert( + lhs instanceof FieldFilter || lhs instanceof CompositeFilter, + 'Only field filters and composite filters are accepted.' + ); + hardAssert( + rhs instanceof FieldFilter || rhs instanceof CompositeFilter, + 'Only field filters and composite filters are accepted.' + ); + + let result: Filter; + + if (lhs instanceof FieldFilter) { + if (rhs instanceof FieldFilter) { + // FieldFilter FieldFilter + result = applyDistributionFieldFilters(lhs, rhs); + } else { + // FieldFilter CompositeFilter + result = applyDistributionFieldAndCompositeFilters(lhs, rhs); + } + } else { + if (rhs instanceof FieldFilter) { + // CompositeFilter FieldFilter + result = applyDistributionFieldAndCompositeFilters(rhs, lhs); + } else { + // CompositeFilter CompositeFilter + result = applyDistributionCompositeFilters(lhs, rhs); + } + } + + return applyAssociation(result); +} + +function applyDistributionFieldFilters( + lhs: FieldFilter, + rhs: FieldFilter +): Filter { + // Conjunction distribution for two field filters is the conjunction of them. + return CompositeFilter.create([lhs, rhs], CompositeOperator.AND); +} + +function applyDistributionCompositeFilters( + lhs: CompositeFilter, + rhs: CompositeFilter +): Filter { + hardAssert( + lhs.filters.length > 0 && rhs.filters.length > 0, + 'Found an empty composite filter' + ); + + // There are four cases: + // (A & B) & (C & D) --> (A & B & C & D) + // (A & B) & (C | D) --> (A & B & C) | (A & B & D) + // (A | B) & (C & D) --> (C & D & A) | (C & D & B) + // (A | B) & (C | D) --> (A & C) | (A & D) | (B & C) | (B & D) + + // Case 1 is a merge. + if (compositeFilterIsConjunction(lhs) && compositeFilterIsConjunction(rhs)) { + return compositeFilterWithAddedFilters(lhs, rhs.getFilters()); + } + + // Case 2,3,4 all have at least one side (lhs or rhs) that is a disjunction. In all three cases + // we should take each element of the disjunction and distribute it over the other side, and + // return the disjunction of the distribution results. + const disjunctionSide = compositeFilterIsDisjunction(lhs) ? lhs : rhs; + const otherSide = compositeFilterIsDisjunction(lhs) ? rhs : lhs; + const results = disjunctionSide.filters.map(subfilter => + applyDistribution(subfilter, otherSide) + ); + return CompositeFilter.create(results, CompositeOperator.OR); +} + +function applyDistributionFieldAndCompositeFilters( + fieldFilter: FieldFilter, + compositeFilter: CompositeFilter +): Filter { + // There are two cases: + // A & (B & C) --> (A & B & C) + // A & (B | C) --> (A & B) | (A & C) + if (compositeFilterIsConjunction(compositeFilter)) { + // Case 1 + return compositeFilterWithAddedFilters( + compositeFilter, + fieldFilter.getFilters() + ); + } else { + // Case 2 + const newFilters = compositeFilter.filters.map(subfilter => + applyDistribution(fieldFilter, subfilter) + ); + + return CompositeFilter.create(newFilters, CompositeOperator.OR); + } +} + +/** + * Applies the associativity property to the given filter and returns the resulting filter. + * + *

    + *
  • A | (B | C) == (A | B) | C == (A | B | C) + *
  • A & (B & C) == (A & B) & C == (A & B & C) + *
+ * + *

For more info, visit: https://en.wikipedia.org/wiki/Associative_property#Propositional_logic + */ +export function applyAssociation(filter: Filter): Filter { + hardAssert( + filter instanceof FieldFilter || filter instanceof CompositeFilter, + 'Only field filters and composite filters are accepted.' + ); + + if (filter instanceof FieldFilter) { + return filter; + } + + const filters = filter.getFilters(); + + // If the composite filter only contains 1 filter, apply associativity to it. + if (filters.length === 1) { + return applyAssociation(filters[0]); + } + + // Associativity applied to a flat composite filter results is itself. + if (compositeFilterIsFlat(filter)) { + return filter; + } + + // First apply associativity to all subfilters. This will in turn recursively apply + // associativity to all nested composite filters and field filters. + const updatedFilters = filters.map(subfilter => applyAssociation(subfilter)); + + // For composite subfilters that perform the same kind of logical operation as `compositeFilter` + // take out their filters and add them to `compositeFilter`. For example: + // compositeFilter = (A | (B | C | D)) + // compositeSubfilter = (B | C | D) + // Result: (A | B | C | D) + // Note that the `compositeSubfilter` has been eliminated, and its filters (B, C, D) have been + // added to the top-level "compositeFilter". + const newSubfilters: Filter[] = []; + updatedFilters.forEach(subfilter => { + if (subfilter instanceof FieldFilter) { + newSubfilters.push(subfilter); + } else if (subfilter instanceof CompositeFilter) { + if (subfilter.op === filter.op) { + // compositeFilter: (A | (B | C)) + // compositeSubfilter: (B | C) + // Result: (A | B | C) + newSubfilters.push(...subfilter.filters); + } else { + // compositeFilter: (A | (B & C)) + // compositeSubfilter: (B & C) + // Result: (A | (B & C)) + newSubfilters.push(subfilter); + } + } + }); + + if (newSubfilters.length === 1) { + return newSubfilters[0]; + } + + return CompositeFilter.create(newSubfilters, filter.op); +} diff --git a/packages/firestore/test/integration/api/query.test.ts b/packages/firestore/test/integration/api/query.test.ts index 68752f44743..f76baf19b9f 100644 --- a/packages/firestore/test/integration/api/query.test.ts +++ b/packages/firestore/test/integration/api/query.test.ts @@ -22,6 +22,7 @@ import { Deferred } from '../../util/promise'; import { EventsAccumulator } from '../util/events_accumulator'; import { addDoc, + and, Bytes, collection, collectionGroup, @@ -36,10 +37,14 @@ import { endBefore, GeoPoint, getDocs, + getDocsFromCache, + getDocsFromServer, limit, limitToLast, onSnapshot, + or, orderBy, + Query, query, QuerySnapshot, setDoc, @@ -54,6 +59,7 @@ import { apiDescribe, toChangesArray, toDataArray, + toIds, withEmptyTestCollection, withTestCollection, withTestDb @@ -1323,6 +1329,253 @@ apiDescribe('Queries', (persistence: boolean) => { }); }); + // TODO(orquery): Enable these tests when prod supports OR queries. + // eslint-disable-next-line no-restricted-properties + (false && persistence ? describe : describe.skip)('OR Queries', () => { + it('can use query overloads', () => { + const testDocs = { + doc1: { a: 1, b: 0 }, + doc2: { a: 2, b: 1 }, + doc3: { a: 3, b: 2 }, + doc4: { a: 1, b: 3 }, + doc5: { a: 1, b: 1 } + }; + + return withTestCollection(persistence, testDocs, async coll => { + // a == 1 + await checkOnlineAndOfflineResultsMatch( + query(coll, where('a', '==', 1)), + 'doc1', + 'doc4', + 'doc5' + ); + + // Implicit AND: a == 1 && b == 3 + await checkOnlineAndOfflineResultsMatch( + query(coll, where('a', '==', 1), where('b', '==', 3)), + 'doc4' + ); + + // explicit AND: a == 1 && b == 3 + await checkOnlineAndOfflineResultsMatch( + query(coll, and(where('a', '==', 1), where('b', '==', 3))), + 'doc4' + ); + + // a == 1, limit 2 + await checkOnlineAndOfflineResultsMatch( + query(coll, where('a', '==', 1), limit(2)), + 'doc1', + 'doc4' + ); + + // a == 1, limit 2, b - desc + await checkOnlineAndOfflineResultsMatch( + query(coll, where('a', '==', 1), limit(2), orderBy('b', 'desc')), + 'doc4', + 'doc5' + ); + + // explicit OR: a == 1 || b == 1 with limit 2 + await checkOnlineAndOfflineResultsMatch( + query(coll, or(where('a', '==', 1), where('b', '==', 1)), limit(2)), + 'doc1', + 'doc2' + ); + + // only limit 2 + await checkOnlineAndOfflineResultsMatch( + query(coll, limit(2)), + 'doc1', + 'doc2' + ); + + // limit 2 and order by b desc + await checkOnlineAndOfflineResultsMatch( + query(coll, limit(2), orderBy('b', 'desc')), + 'doc4', + 'doc3' + ); + }); + }); + + it('can use or queries', () => { + const testDocs = { + doc1: { a: 1, b: 0 }, + doc2: { a: 2, b: 1 }, + doc3: { a: 3, b: 2 }, + doc4: { a: 1, b: 3 }, + doc5: { a: 1, b: 1 } + }; + + return withTestCollection(persistence, testDocs, async coll => { + // Two equalities: a==1 || b==1. + await checkOnlineAndOfflineResultsMatch( + query(coll, or(where('a', '==', 1), where('b', '==', 1))), + 'doc1', + 'doc2', + 'doc4', + 'doc5' + ); + + // with one inequality: a>2 || b==1. + await checkOnlineAndOfflineResultsMatch( + query(coll, or(where('a', '>', 2), where('b', '==', 1))), + 'doc5', + 'doc2', + 'doc3' + ); + + // (a==1 && b==0) || (a==3 && b==2) + await checkOnlineAndOfflineResultsMatch( + query( + coll, + or( + and(where('a', '==', 1), where('b', '==', 0)), + and(where('a', '==', 3), where('b', '==', 2)) + ) + ), + 'doc1', + 'doc3' + ); + + // a==1 && (b==0 || b==3). + await checkOnlineAndOfflineResultsMatch( + query( + coll, + and( + where('a', '==', 1), + or(where('b', '==', 0), where('b', '==', 3)) + ) + ), + 'doc1', + 'doc4' + ); + + // (a==2 || b==2) && (a==3 || b==3) + await checkOnlineAndOfflineResultsMatch( + query( + coll, + and( + or(where('a', '==', 2), where('b', '==', 2)), + or(where('a', '==', 3), where('b', '==', 3)) + ) + ), + 'doc3' + ); + + // Test with limits (implicit order by ASC): (a==1) || (b > 0) LIMIT 2 + await checkOnlineAndOfflineResultsMatch( + query(coll, or(where('a', '==', 1), where('b', '>', 0)), limit(2)), + 'doc1', + 'doc2' + ); + + // Test with limits (explicit order by): (a==1) || (b > 0) LIMIT_TO_LAST 2 + // Note: The public query API does not allow implicit ordering when limitToLast is used. + await checkOnlineAndOfflineResultsMatch( + query( + coll, + or(where('a', '==', 1), where('b', '>', 0)), + limitToLast(2), + orderBy('b') + ), + 'doc3', + 'doc4' + ); + + // Test with limits (explicit order by ASC): (a==2) || (b == 1) ORDER BY a LIMIT 1 + await checkOnlineAndOfflineResultsMatch( + query( + coll, + or(where('a', '==', 2), where('b', '==', 1)), + limit(1), + orderBy('a') + ), + 'doc5' + ); + + // Test with limits (explicit order by DESC): (a==2) || (b == 1) ORDER BY a LIMIT_TO_LAST 1 + await checkOnlineAndOfflineResultsMatch( + query( + coll, + or(where('a', '==', 2), where('b', '==', 1)), + limitToLast(1), + orderBy('a') + ), + 'doc2' + ); + + // Test with limits without orderBy (the __name__ ordering is the tie breaker). + await checkOnlineAndOfflineResultsMatch( + query(coll, or(where('a', '==', 2), where('b', '==', 1)), limit(1)), + 'doc2' + ); + }); + }); + + it('can use or queries with in and not-in', () => { + const testDocs = { + doc1: { a: 1, b: 0 }, + doc2: { b: 1 }, + doc3: { a: 3, b: 2 }, + doc4: { a: 1, b: 3 }, + doc5: { a: 1 }, + doc6: { a: 2 } + }; + + return withTestCollection(persistence, testDocs, async coll => { + // a==2 || b in [2,3] + await checkOnlineAndOfflineResultsMatch( + query(coll, or(where('a', '==', 2), where('b', 'in', [2, 3]))), + 'doc3', + 'doc4', + 'doc6' + ); + + // a==2 || b not-in [2,3] + // Has implicit orderBy b. + await checkOnlineAndOfflineResultsMatch( + query(coll, or(where('a', '==', 2), where('b', 'not-in', [2, 3]))), + 'doc1', + 'doc2' + ); + }); + }); + + it('can use or queries with array membership', () => { + const testDocs = { + doc1: { a: 1, b: [0] }, + doc2: { b: [1] }, + doc3: { a: 3, b: [2, 7] }, + doc4: { a: 1, b: [3, 7] }, + doc5: { a: 1 }, + doc6: { a: 2 } + }; + + return withTestCollection(persistence, testDocs, async coll => { + // a==2 || b array-contains 7 + await checkOnlineAndOfflineResultsMatch( + query(coll, or(where('a', '==', 2), where('b', 'array-contains', 7))), + 'doc3', + 'doc4', + 'doc6' + ); + + // a==2 || b array-contains-any [0, 3] + await checkOnlineAndOfflineResultsMatch( + query( + coll, + or(where('a', '==', 2), where('b', 'array-contains-any', [0, 3])) + ), + 'doc1', + 'doc4', + 'doc6' + ); + }); + }); + }); + // Reproduces https://github.com/firebase/firebase-js-sdk/issues/5873 // eslint-disable-next-line no-restricted-properties (persistence ? describe : describe.skip)('Caching empty results', () => { @@ -1375,3 +1628,25 @@ function verifyDocumentChange( expect(change.oldIndex).to.equal(oldIndex); expect(change.newIndex).to.equal(newIndex); } + +/** + * Checks that running the query while online (against the backend/emulator) results in the same + * documents as running the query while offline. If `expectedDocs` is provided, it also checks + * that both online and offline query result is equal to the expected documents. + * + * @param query The query to check + * @param expectedDocs Ordered list of document keys that are expected to match the query + */ +async function checkOnlineAndOfflineResultsMatch( + query: Query, + ...expectedDocs: string[] +): Promise { + const docsFromServer = await getDocsFromServer(query); + + if (expectedDocs.length !== 0) { + expect(expectedDocs).to.deep.equal(toIds(docsFromServer)); + } + + const docsFromCache = await getDocsFromCache(query); + expect(toIds(docsFromServer)).to.deep.equal(toIds(docsFromCache)); +} diff --git a/packages/firestore/test/integration/api/type.test.ts b/packages/firestore/test/integration/api/type.test.ts index 48b8d0ac4c8..de7614459fa 100644 --- a/packages/firestore/test/integration/api/type.test.ts +++ b/packages/firestore/test/integration/api/type.test.ts @@ -96,7 +96,7 @@ apiDescribe('Firestore', (persistence: boolean) => { const validateSnapshots = !persistence; await expectRoundtrip( db, - { a: 1, b: NaN, c: Infinity, d: persistence ? 0.0 : -0.0 }, + { a: 1, b: NaN, c: Infinity, d: persistence ? 0.0 : 0.0 }, validateSnapshots ); }); diff --git a/packages/firestore/test/integration/api/validation.test.ts b/packages/firestore/test/integration/api/validation.test.ts index 2943a8b0c06..c118e485310 100644 --- a/packages/firestore/test/integration/api/validation.test.ts +++ b/packages/firestore/test/integration/api/validation.test.ts @@ -50,6 +50,8 @@ import { setDoc, updateDoc, where, + or, + and, newTestApp } from '../util/firebase_export'; import { @@ -1317,6 +1319,148 @@ apiDescribe('Validation:', (persistence: boolean) => { 'Function startAt() called with invalid data. Unsupported field value: undefined' ); }); + + validationIt(persistence, 'invalid query filters fail', db => { + // Multiple inequalities, one of which is inside a nested composite filter. + const coll = collection(db, 'test'); + expect(() => + query( + coll, + and( + or( + and(where('a', '==', 'b'), where('c', '>', 'd')), + and(where('e', '==', 'f'), where('g', '==', 'h')) + ), + where('r', '>', 's') + ) + ) + ).to.throw( + "Invalid query. All where filters with an inequality (<, <=, !=, not-in, >, or >=) must be on the same field. But you have inequality filters on 'c' and 'r'" + ); + + // OrderBy and inequality on different fields. Inequality inside a nested composite filter. + expect(() => + query( + coll, + or( + and(where('a', '==', 'b'), where('c', '>', 'd')), + and(where('e', '==', 'f'), where('g', '==', 'h')) + ), + orderBy('r') + ) + ).to.throw( + "Invalid query. You have a where filter with an inequality (<, <=, !=, not-in, >, or >=) on field 'c' and so you must also use 'c' as your first argument to orderBy(), but your first orderBy() is on field 'r' instead." + ); + + // Conflicting operations within a composite filter. + expect(() => + query( + coll, + or( + and(where('a', '==', 'b'), where('c', 'in', ['d', 'e'])), + and(where('e', '==', 'f'), where('c', 'not-in', ['f', 'g'])) + ) + ) + ).to.throw( + "Invalid query. You cannot use 'not-in' filters with 'in' filters." + ); + + // Conflicting operations between a field filter and a composite filter. + expect(() => + query( + coll, + and( + or( + and(where('a', '==', 'b'), where('c', 'in', ['d', 'e'])), + and(where('e', '==', 'f'), where('g', '==', 'h')) + ), + where('i', 'not-in', ['j', 'k']) + ) + ) + ).to.throw( + "Invalid query. You cannot use 'not-in' filters with 'in' filters." + ); + + // Conflicting operations between two composite filters. + expect(() => + query( + coll, + and( + or( + and(where('a', '==', 'b'), where('c', 'in', ['d', 'e'])), + and(where('e', '==', 'f'), where('g', '==', 'h')) + ), + or( + and(where('i', '==', 'j'), where('l', 'not-in', ['m', 'n'])), + and(where('o', '==', 'p'), where('q', '==', 'r')) + ) + ) + ) + ).to.throw( + "Invalid query. You cannot use 'not-in' filters with 'in' filters." + ); + + // Multiple top level composite filters + expect(() => + // @ts-ignore + query(coll, and(where('a', '==', 'b')), or(where('b', '==', 'a'))) + ).to.throw( + 'InvalidQuery. When using composite filters, you cannot use ' + + 'more than one filter at the top level. Consider nesting the multiple ' + + 'filters within an `and(...)` statement. For example: ' + + 'change `query(query, where(...), or(...))` to ' + + '`query(query, and(where(...), or(...)))`.' + ); + + // Once top level composite filter and one top level field filter + expect(() => + // @ts-ignore + query(coll, or(where('a', '==', 'b')), where('b', '==', 'a')) + ).to.throw( + 'InvalidQuery. When using composite filters, you cannot use ' + + 'more than one filter at the top level. Consider nesting the multiple ' + + 'filters within an `and(...)` statement. For example: ' + + 'change `query(query, where(...), or(...))` to ' + + '`query(query, and(where(...), or(...)))`.' + ); + }); + + validationIt( + persistence, + 'passing non-filters to composite operators fails', + db => { + const compositeOperators = [ + { name: 'or', func: or }, + { name: 'and', func: and } + ]; + const nonFilterOps = [ + limit(1), + limitToLast(2), + startAt(1), + startAfter(1), + endAt(1), + endBefore(1), + orderBy('a') + ]; + + for (const compositeOp of compositeOperators) { + for (const nonFilterOp of nonFilterOps) { + const coll = collection(db, 'test'); + expect(() => + query( + coll, + compositeOp.func( + // @ts-ignore + nonFilterOp + ) + ) + ).to.throw( + `Function ${compositeOp.name}() requires AppliableConstraints created with a call to 'where(...)', 'or(...)', or 'and(...)'.` + ); + } + } + } + ); }); }); diff --git a/packages/firestore/test/unit/core/filter.test.ts b/packages/firestore/test/unit/core/filter.test.ts new file mode 100644 index 00000000000..3011e3f1647 --- /dev/null +++ b/packages/firestore/test/unit/core/filter.test.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; + +import { + compositeFilterIsConjunction, + compositeFilterIsDisjunction, + compositeFilterIsFlat, + compositeFilterIsFlatConjunction, + FieldFilter, + Operator +} from '../../../src/core/filter'; +import { andFilter, filter, orFilter } from '../../util/helpers'; + +describe('FieldFilter', () => { + it('exposes field filter members', () => { + const f = filter('foo', '==', 'bar'); + + expect(f.field.toString()).to.equal('foo'); + expect(f.value.stringValue).to.equal('bar'); + expect(f.op).to.equal(Operator.EQUAL); + }); +}); + +describe('CompositeFilter', () => { + let a: FieldFilter; + let b: FieldFilter; + let c: FieldFilter; + let d: FieldFilter; + + function nameFilter(name: string): FieldFilter { + return filter('name', '==', name); + } + + beforeEach(async () => { + a = nameFilter('A'); + b = nameFilter('B'); + c = nameFilter('C'); + d = nameFilter('D'); + }); + + it('exposes composite filter members for AND filter', () => { + const f = andFilter(a, b, c); + + expect(compositeFilterIsConjunction(f)).to.be.true; + expect(f.getFilters()).to.deep.equal([a, b, c]); + }); + + it('exposes composite filter members for OR filter', () => { + const f = orFilter(a, b, c); + + expect(compositeFilterIsDisjunction(f)).to.be.true; + expect(f.getFilters()).to.deep.equal([a, b, c]); + }); + + it('has working composite filter nested checks', () => { + const andFilter1 = andFilter(a, b, c); + expect(compositeFilterIsFlat(andFilter1)).true; + expect(compositeFilterIsConjunction(andFilter1)).true; + expect(compositeFilterIsDisjunction(andFilter1)).false; + expect(compositeFilterIsFlatConjunction(andFilter1)).true; + + const orFilter1 = orFilter(a, b, c); + expect(compositeFilterIsConjunction(orFilter1)).false; + expect(compositeFilterIsDisjunction(orFilter1)).true; + expect(compositeFilterIsFlat(orFilter1)).true; + expect(compositeFilterIsFlatConjunction(orFilter1)).false; + + const andFilter2 = andFilter(d, andFilter1); + expect(compositeFilterIsConjunction(andFilter2)).true; + expect(compositeFilterIsDisjunction(andFilter2)).false; + expect(compositeFilterIsFlat(andFilter2)).false; + expect(compositeFilterIsFlatConjunction(andFilter2)).false; + + const orFilter2 = orFilter(d, andFilter1); + expect(compositeFilterIsConjunction(orFilter2)).false; + expect(compositeFilterIsDisjunction(orFilter2)).true; + expect(compositeFilterIsFlat(orFilter2)).false; + expect(compositeFilterIsFlatConjunction(orFilter2)).false; + }); +}); diff --git a/packages/firestore/test/unit/core/query.test.ts b/packages/firestore/test/unit/core/query.test.ts index 2acfbc65cda..c88532474be 100644 --- a/packages/firestore/test/unit/core/query.test.ts +++ b/packages/firestore/test/unit/core/query.test.ts @@ -18,6 +18,8 @@ import { expect } from 'chai'; import { Bytes, GeoPoint, Timestamp } from '../../../src'; +import { Bound, boundEquals } from '../../../src/core/bound'; +import { OrderBy } from '../../../src/core/order_by'; import { canonifyQuery, LimitType, @@ -38,21 +40,19 @@ import { queryCollectionGroup, newQueryForCollectionGroup } from '../../../src/core/query'; -import { - Bound, - boundEquals, - canonifyTarget, - OrderBy -} from '../../../src/core/target'; +import { canonifyTarget } from '../../../src/core/target'; +import { MutableDocument } from '../../../src/model/document'; import { DOCUMENT_KEY_NAME, ResourcePath } from '../../../src/model/path'; import { addEqualityMatcher } from '../../util/equality_matcher'; import { + andFilter, bound, doc, expectCorrectComparisons, expectEqualitySets, filter, orderBy, + orFilter, query, ref, wrap @@ -800,6 +800,71 @@ describe('Query', () => { expect(queryMatchesAllDocuments(query1)).to.be.false; }); + it('matches composite queries', () => { + const doc1 = doc('collection/1', 0, { a: 1, b: 0 }); + const doc2 = doc('collection/2', 0, { a: 2, b: 1 }); + const doc3 = doc('collection/3', 0, { a: 3, b: 2 }); + const doc4 = doc('collection/4', 0, { a: 1, b: 3 }); + const doc5 = doc('collection/5', 0, { a: 1, b: 1 }); + + // Two equalities: a==1 || b==1. + const query1 = query( + 'collection', + orFilter(filter('a', '==', 1), filter('b', '==', 1)) + ); + assertQueryMatches(query1, [doc1, doc2, doc4, doc5], [doc3]); + + // with one inequality: a>2 || b==1. + const query2 = query( + 'collection', + orFilter(filter('a', '>', 2), filter('b', '==', 1)) + ); + assertQueryMatches(query2, [doc2, doc3, doc5], [doc1, doc4]); + + // (a==1 && b==0) || (a==3 && b==2) + const query3 = query( + 'collection', + orFilter( + andFilter(filter('a', '==', 1), filter('b', '==', 0)), + andFilter(filter('a', '==', 3), filter('b', '==', 2)) + ) + ); + assertQueryMatches(query3, [doc1, doc3], [doc2, doc4, doc5]); + + // a==1 && (b==0 || b==3). + const query4 = query( + 'collection', + andFilter( + filter('a', '==', 1), + orFilter(filter('b', '==', 0), filter('b', '==', 3)) + ) + ); + assertQueryMatches(query4, [doc1, doc4], [doc2, doc3, doc5]); + + // (a==2 || b==2) && (a==3 || b==3) + const query5 = query( + 'collection', + andFilter( + orFilter(filter('a', '==', 2), filter('b', '==', 2)), + orFilter(filter('a', '==', 3), filter('b', '==', 3)) + ) + ); + assertQueryMatches(query5, [doc3], [doc1, doc2, doc4, doc5]); + }); + + function assertQueryMatches( + query: Query, + matching: MutableDocument[], + nonMatching: MutableDocument[] + ): void { + for (const doc of matching) { + expect(queryMatches(query, doc)).to.be.true; + } + for (const doc of nonMatching) { + expect(queryMatches(query, doc)).to.be.false; + } + } + function assertImplicitOrderBy(query: Query, ...orderBys: OrderBy[]): void { expect(queryOrderBy(query)).to.deep.equal(orderBys); } diff --git a/packages/firestore/test/unit/local/index_manager.test.ts b/packages/firestore/test/unit/local/index_manager.test.ts index dc8c7225901..1d96ca90e84 100644 --- a/packages/firestore/test/unit/local/index_manager.test.ts +++ b/packages/firestore/test/unit/local/index_manager.test.ts @@ -18,6 +18,7 @@ import { expect } from 'chai'; import { User } from '../../../src/auth/user'; +import { FieldFilter } from '../../../src/core/filter'; import { LimitType, newQueryForCollectionGroup, @@ -29,7 +30,6 @@ import { queryWithLimit, queryWithStartAt } from '../../../src/core/query'; -import { FieldFilter } from '../../../src/core/target'; import { IndexType } from '../../../src/local/index_manager'; import { IndexedDbPersistence } from '../../../src/local/indexeddb_persistence'; import { Persistence } from '../../../src/local/persistence'; @@ -636,6 +636,20 @@ describe('IndexedDbIndexManager', async () => { .be.null; }); + it('handles when no matching filter exists', async () => { + await setUpSingleValueFilter(); + const q = queryWithAddedFilter( + query('coll'), + filter('unknown', '==', true) + ); + + expect(await indexManager.getIndexType(queryToTarget(q))).to.equal( + IndexType.NONE + ); + expect(await indexManager.getDocumentsMatchingTarget(queryToTarget(q))).to + .be.null; + }); + it('returns empty results when no matching documents exists', async () => { await setUpSingleValueFilter(); const q = queryWithAddedFilter(query('coll'), filter('count', '==', -1)); @@ -755,6 +769,19 @@ describe('IndexedDbIndexManager', async () => { await verifyResults(q, 'coll/val2', 'coll/val1b', 'coll/val1a'); }); + it('supports order by filter', async () => { + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['count', IndexKind.ASCENDING]] }) + ); + + await addDoc('coll/val1a', { 'count': 1 }); + await addDoc('coll/val1b', { 'count': 1 }); + await addDoc('coll/val2', { 'count': 2 }); + + const q = queryWithAddedOrderBy(query('coll'), orderBy('count')); + await verifyResults(q, 'coll/val1a', 'coll/val1b', 'coll/val2'); + }); + it('supports ascending order with greater than filter', async () => { await setUpMultipleOrderBys(); diff --git a/packages/firestore/test/unit/local/query_engine.test.ts b/packages/firestore/test/unit/local/query_engine.test.ts index 44f22e8b234..0762bad2a42 100644 --- a/packages/firestore/test/unit/local/query_engine.test.ts +++ b/packages/firestore/test/unit/local/query_engine.test.ts @@ -41,8 +41,8 @@ import { TargetCache } from '../../../src/local/target_cache'; import { documentKeySet, DocumentMap, - newMutationMap, - documentMap + documentMap, + newMutationMap } from '../../../src/model/collections'; import { Document, MutableDocument } from '../../../src/model/document'; import { DocumentKey } from '../../../src/model/document_key'; @@ -56,14 +56,16 @@ import { import { Mutation } from '../../../src/model/mutation'; import { debugAssert } from '../../../src/util/assert'; import { + andFilter, deleteMutation, doc, + fieldIndex, filter, key, orderBy, - query, - fieldIndex, + orFilter, patchMutation, + query, setMutation, version } from '../../util/helpers'; @@ -107,9 +109,11 @@ class TestLocalDocumentsView extends LocalDocumentsView { } describe('MemoryQueryEngine', async () => { + /* not durable and without client side indexing */ genericQueryEngineTest( - /* durable= */ false, - persistenceHelpers.testMemoryEagerPersistence + false, + persistenceHelpers.testMemoryEagerPersistence, + false ); }); @@ -124,7 +128,11 @@ describe('IndexedDbQueryEngine', async () => { persistencePromise = persistenceHelpers.testIndexedDbPersistence(); }); - genericQueryEngineTest(/* durable= */ true, () => persistencePromise); + /* durable but without client side indexing */ + genericQueryEngineTest(true, () => persistencePromise, false); + + /* durable and with client side indexing */ + genericQueryEngineTest(true, () => persistencePromise, true); }); /** @@ -134,10 +142,13 @@ describe('IndexedDbQueryEngine', async () => { * @param durable Whether the provided persistence is backed by IndexedDB * @param persistencePromise A factory function that returns an initialized * persistence layer. + * @param configureCsi Whether tests should configure client side indexing + * or use full table scans. */ function genericQueryEngineTest( durable: boolean, - persistencePromise: () => Promise + persistencePromise: () => Promise, + configureCsi: boolean ): void { let persistence!: Persistence; let remoteDocumentCache!: RemoteDocumentCache; @@ -279,343 +290,1125 @@ function genericQueryEngineTest( } }); - it('uses target mapping for initial view', async () => { - const query1 = query('coll', filter('matches', '==', true)); - - await addDocument(MATCHING_DOC_A, MATCHING_DOC_B); - await persistQueryMapping(MATCHING_DOC_A.key, MATCHING_DOC_B.key); - - const docs = await expectOptimizedCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - - verifyResult(docs, [MATCHING_DOC_A, MATCHING_DOC_B]); - }); - - it('filters non-matching changes since initial results', async () => { - const query1 = query('coll', filter('matches', '==', true)); - - await addDocument(MATCHING_DOC_A, MATCHING_DOC_B); - await persistQueryMapping(MATCHING_DOC_A.key, MATCHING_DOC_B.key); - - // Add a mutation that is not yet part of query's set of remote keys. - await addMutation(patchMutation('coll/a', { 'matches': false })); - - const docs = await expectOptimizedCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - - verifyResult(docs, [MATCHING_DOC_B]); - }); - - it('includes changes since initial results', async () => { - const query1 = query('coll', filter('matches', '==', true)); - - await addDocument(MATCHING_DOC_A, MATCHING_DOC_B); - await persistQueryMapping(MATCHING_DOC_A.key, MATCHING_DOC_B.key); + // Tests in this section do not support client side indexing + if (!configureCsi) { + it('uses target mapping for initial view', async () => { + const query1 = query('coll', filter('matches', '==', true)); - let docs = await expectOptimizedCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, [MATCHING_DOC_A, MATCHING_DOC_B]); + await addDocument(MATCHING_DOC_A, MATCHING_DOC_B); + await persistQueryMapping(MATCHING_DOC_A.key, MATCHING_DOC_B.key); - // Add a mutated document that is not yet part of query's set of remote keys. - await addDocument(UPDATED_MATCHING_DOC_B); + const docs = await expectOptimizedCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); - docs = await expectOptimizedCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, [MATCHING_DOC_A, UPDATED_MATCHING_DOC_B]); - }); - - it('does not use initial results without limbo free snapshot version', async () => { - const query1 = query('coll', filter('matches', '==', true)); - - const docs = await expectFullCollectionQuery(() => - runQuery(query1, MISSING_LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, []); - }); + verifyResult(docs, [MATCHING_DOC_A, MATCHING_DOC_B]); + }); - it('does not use initial results for unfiltered collection query', async () => { - const query1 = query('coll'); + it('filters non-matching changes since initial results', async () => { + const query1 = query('coll', filter('matches', '==', true)); - const docs = await expectFullCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, []); - }); + await addDocument(MATCHING_DOC_A, MATCHING_DOC_B); + await persistQueryMapping(MATCHING_DOC_A.key, MATCHING_DOC_B.key); - it('does not use initial results for limit query with document removal', async () => { - const query1 = queryWithLimit( - query('coll', filter('matches', '==', true)), - 1, - LimitType.First - ); + // Add a mutation that is not yet part of query's set of remote keys. + await addMutation(patchMutation('coll/a', { 'matches': false })); - // While the backend would never add DocA to the set of remote keys, this - // allows us to easily simulate what would happen when a document no longer - // matches due to an out-of-band update. - await addDocument(NON_MATCHING_DOC_A); - await persistQueryMapping(NON_MATCHING_DOC_A.key); + const docs = await expectOptimizedCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); - await addDocument(MATCHING_DOC_B); + verifyResult(docs, [MATCHING_DOC_B]); + }); - const docs = await expectFullCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); + it('includes changes since initial results', async () => { + const query1 = query('coll', filter('matches', '==', true)); - verifyResult(docs, [MATCHING_DOC_B]); - }); + await addDocument(MATCHING_DOC_A, MATCHING_DOC_B); + await persistQueryMapping(MATCHING_DOC_A.key, MATCHING_DOC_B.key); - it('does not use initial results for limitToLast query with document removal', async () => { - const query1 = queryWithLimit( - query('coll', filter('matches', '==', true), orderBy('order', 'desc')), - 1, - LimitType.Last - ); + let docs = await expectOptimizedCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, [MATCHING_DOC_A, MATCHING_DOC_B]); - // While the backend would never add DocA to the set of remote keys, this - // allows us to easily simulate what would happen when a document no longer - // matches due to an out-of-band update. - await addDocument(NON_MATCHING_DOC_A); - await persistQueryMapping(NON_MATCHING_DOC_A.key); + // Add a mutated document that is not yet part of query's set of remote keys. + await addDocument(UPDATED_MATCHING_DOC_B); - await addDocument(MATCHING_DOC_B); + docs = await expectOptimizedCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, [MATCHING_DOC_A, UPDATED_MATCHING_DOC_B]); + }); - const docs = await expectFullCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); + it('does not use initial results without limbo free snapshot version', async () => { + const query1 = query('coll', filter('matches', '==', true)); - verifyResult(docs, [MATCHING_DOC_B]); - }); + const docs = await expectFullCollectionQuery(() => + runQuery(query1, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, []); + }); - it('does not use initial results for limit query when last document has pending write', async () => { - const query1 = queryWithLimit( - query('coll', filter('matches', '==', true), orderBy('order', 'desc')), - 1, - LimitType.First - ); + it('does not use initial results for unfiltered collection query', async () => { + const query1 = query('coll'); - // Add a query mapping for a document that matches, but that sorts below - // another document due to a pending write. - await addDocument(MATCHING_DOC_A); - await addMutation(patchMutation('coll/a', { order: 1 })); - await persistQueryMapping(MATCHING_DOC_A.key); + const docs = await expectFullCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, []); + }); - await addDocument(MATCHING_DOC_B); + it('does not use initial results for limit query with document removal', async () => { + const query1 = queryWithLimit( + query('coll', filter('matches', '==', true)), + 1, + LimitType.First + ); - const docs = await expectFullCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, [MATCHING_DOC_B]); - }); + // While the backend would never add DocA to the set of remote keys, this + // allows us to easily simulate what would happen when a document no longer + // matches due to an out-of-band update. + await addDocument(NON_MATCHING_DOC_A); + await persistQueryMapping(NON_MATCHING_DOC_A.key); - it('does not use initial results for limitToLast query when first document has pending write', async () => { - const query1 = queryWithLimit( - query('coll', filter('matches', '==', true), orderBy('order')), - 1, - LimitType.Last - ); - // Add a query mapping for a document that matches, but that sorts below - // another document due to a pending write. - await addDocument(MATCHING_DOC_A); - await addMutation(patchMutation('coll/a', { order: 2 })); - await persistQueryMapping(MATCHING_DOC_A.key); + await addDocument(MATCHING_DOC_B); - await addDocument(MATCHING_DOC_B); + const docs = await expectFullCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); - const docs = await expectFullCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, [MATCHING_DOC_B]); - }); + verifyResult(docs, [MATCHING_DOC_B]); + }); - it('does not use initial results for limit query when last document has been updated out of band', async () => { - const query1 = queryWithLimit( - query('coll', filter('matches', '==', true), orderBy('order', 'desc')), - 1, - LimitType.First - ); + it('does not use initial results for limitToLast query with document removal', async () => { + const query1 = queryWithLimit( + query('coll', filter('matches', '==', true), orderBy('order', 'desc')), + 1, + LimitType.Last + ); - // Add a query mapping for a document that matches, but that sorts below - // another document based on an update that the SDK received after the - // query's snapshot was persisted. - await addDocument(UPDATED_DOC_A); - await persistQueryMapping(UPDATED_DOC_A.key); + // While the backend would never add DocA to the set of remote keys, this + // allows us to easily simulate what would happen when a document no longer + // matches due to an out-of-band update. + await addDocument(NON_MATCHING_DOC_A); + await persistQueryMapping(NON_MATCHING_DOC_A.key); - await addDocument(MATCHING_DOC_B); + await addDocument(MATCHING_DOC_B); - const docs = await expectFullCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, [MATCHING_DOC_B]); - }); + const docs = await expectFullCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); - it('does not use initial results for limitToLast query when first document in limit has been updated out of band', async () => { - const query1 = queryWithLimit( - query('coll', filter('matches', '==', true), orderBy('order')), - 1, - LimitType.Last - ); - // Add a query mapping for a document that matches, but that sorts below - // another document based on an update that the SDK received after the - // query's snapshot was persisted. - await addDocument(UPDATED_DOC_A); - await persistQueryMapping(UPDATED_DOC_A.key); + verifyResult(docs, [MATCHING_DOC_B]); + }); - await addDocument(MATCHING_DOC_B); + it('does not use initial results for limit query when last document has pending write', async () => { + const query1 = queryWithLimit( + query('coll', filter('matches', '==', true), orderBy('order', 'desc')), + 1, + LimitType.First + ); + + // Add a query mapping for a document that matches, but that sorts below + // another document due to a pending write. + await addDocument(MATCHING_DOC_A); + await addMutation(patchMutation('coll/a', { order: 1 })); + await persistQueryMapping(MATCHING_DOC_A.key); + + await addDocument(MATCHING_DOC_B); + + const docs = await expectFullCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, [MATCHING_DOC_B]); + }); - const docs = await expectFullCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, [MATCHING_DOC_B]); - }); + it('does not use initial results for limitToLast query when first document has pending write', async () => { + const query1 = queryWithLimit( + query('coll', filter('matches', '==', true), orderBy('order')), + 1, + LimitType.Last + ); + // Add a query mapping for a document that matches, but that sorts below + // another document due to a pending write. + await addDocument(MATCHING_DOC_A); + await addMutation(patchMutation('coll/a', { order: 2 })); + await persistQueryMapping(MATCHING_DOC_A.key); + + await addDocument(MATCHING_DOC_B); + + const docs = await expectFullCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, [MATCHING_DOC_B]); + }); - it('uses initial results if last document in limit is unchanged', async () => { - const query1 = queryWithLimit( - query('coll', orderBy('order')), - 2, - LimitType.First - ); + it('does not use initial results for limit query when last document has been updated out of band', async () => { + const query1 = queryWithLimit( + query('coll', filter('matches', '==', true), orderBy('order', 'desc')), + 1, + LimitType.First + ); + + // Add a query mapping for a document that matches, but that sorts below + // another document based on an update that the SDK received after the + // query's snapshot was persisted. + await addDocument(UPDATED_DOC_A); + await persistQueryMapping(UPDATED_DOC_A.key); + + await addDocument(MATCHING_DOC_B); + + const docs = await expectFullCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, [MATCHING_DOC_B]); + }); - await addDocument(doc('coll/a', 1, { order: 1 })); - await addDocument(doc('coll/b', 1, { order: 3 })); - await persistQueryMapping(key('coll/a'), key('coll/b')); + it('does not use initial results for limitToLast query when first document in limit has been updated out of band', async () => { + const query1 = queryWithLimit( + query('coll', filter('matches', '==', true), orderBy('order')), + 1, + LimitType.Last + ); + // Add a query mapping for a document that matches, but that sorts below + // another document based on an update that the SDK received after the + // query's snapshot was persisted. + await addDocument(UPDATED_DOC_A); + await persistQueryMapping(UPDATED_DOC_A.key); + + await addDocument(MATCHING_DOC_B); + + const docs = await expectFullCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, [MATCHING_DOC_B]); + }); - // Update "coll/a" but make sure it still sorts before "coll/b" - await addMutation(patchMutation('coll/a', { order: 2 })); + it('uses initial results if last document in limit is unchanged', async () => { + const query1 = queryWithLimit( + query('coll', orderBy('order')), + 2, + LimitType.First + ); + + await addDocument(doc('coll/a', 1, { order: 1 })); + await addDocument(doc('coll/b', 1, { order: 3 })); + await persistQueryMapping(key('coll/a'), key('coll/b')); + + // Update "coll/a" but make sure it still sorts before "coll/b" + await addMutation(patchMutation('coll/a', { order: 2 })); + + // Since the last document in the limit didn't change (and hence we know + // that all documents written prior to query execution still sort after + // "coll/b"), we should use an Index-Free query. + const docs = await expectOptimizedCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, [ + doc('coll/a', 1, { order: 2 }).setHasLocalMutations(), + doc('coll/b', 1, { order: 3 }) + ]); + }); - // Since the last document in the limit didn't change (and hence we know - // that all documents written prior to query execution still sort after - // "coll/b"), we should use an Index-Free query. - const docs = await expectOptimizedCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, [ - doc('coll/a', 1, { order: 2 }).setHasLocalMutations(), - doc('coll/b', 1, { order: 3 }) - ]); - }); + it('does not include documents deleted by mutation', async () => { + const query1 = query('coll'); + await addDocument(MATCHING_DOC_A, MATCHING_DOC_B); + await persistQueryMapping(MATCHING_DOC_A.key, MATCHING_DOC_B.key); + + // Add an unacknowledged mutation + await addMutation(deleteMutation('coll/b')); + const docs = await expectFullCollectionQuery(() => + runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(docs, [MATCHING_DOC_A]); + }); - it('does not include documents deleted by mutation', async () => { - const query1 = query('coll'); - await addDocument(MATCHING_DOC_A, MATCHING_DOC_B); - await persistQueryMapping(MATCHING_DOC_A.key, MATCHING_DOC_B.key); + it('can perform OR queries using full collection scan', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': 0 }); + const doc2 = doc('coll/2', 1, { 'a': 2, 'b': 1 }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': 2 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': 3 }); + const doc5 = doc('coll/5', 1, { 'a': 1, 'b': 1 }); + + await addDocument(doc1, doc2, doc3, doc4, doc5); + + // Two equalities: a==1 || b==1. + const query1 = query( + 'coll', + orFilter(filter('a', '==', 1), filter('b', '==', 1)) + ); + const result1 = await expectFullCollectionQuery(() => + runQuery(query1, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result1, [doc1, doc2, doc4, doc5]); + + // with one inequality: a>2 || b==1. + const query2 = query( + 'coll', + orFilter(filter('a', '>', 2), filter('b', '==', 1)) + ); + const result2 = await expectFullCollectionQuery(() => + runQuery(query2, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result2, [doc2, doc3, doc5]); + + // (a==1 && b==0) || (a==3 && b==2) + const query3 = query( + 'coll', + orFilter( + andFilter(filter('a', '==', 1), filter('b', '==', 0)), + andFilter(filter('a', '==', 3), filter('b', '==', 2)) + ) + ); + const result3 = await expectFullCollectionQuery(() => + runQuery(query3, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result3, [doc1, doc3]); + + // a==1 && (b==0 || b==3) + const query4 = query( + 'coll', + andFilter( + filter('a', '==', 1), + orFilter(filter('b', '==', 0), filter('b', '==', 3)) + ) + ); + const result4 = await expectFullCollectionQuery(() => + runQuery(query4, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result4, [doc1, doc4]); + + // (a==2 || b==2) && (a==3 || b==3) + const query5 = query( + 'coll', + andFilter( + orFilter(filter('a', '==', 2), filter('b', '==', 2)), + orFilter(filter('a', '==', 3), filter('b', '==', 3)) + ) + ); + const result5 = await expectFullCollectionQuery(() => + runQuery(query5, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result5, [doc3]); + + // Test with limits (implicit order by ASC): (a==1) || (b > 0) LIMIT 2 + const query6 = queryWithLimit( + query('coll', orFilter(filter('a', '==', 1), filter('b', '>', 0))), + 2, + LimitType.First + ); + const result6 = await expectFullCollectionQuery(() => + runQuery(query6, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result6, [doc1, doc2]); + + // Test with limits (implicit order by DESC): (a==1) || (b > 0) LIMIT_TO_LAST 2 + const query7 = queryWithLimit( + query('coll', orFilter(filter('a', '==', 1), filter('b', '>', 0))), + 2, + LimitType.Last + ); + const result7 = await expectFullCollectionQuery(() => + runQuery(query7, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result7, [doc3, doc4]); + + // Test with limits (explicit order by ASC): (a==2) || (b == 1) ORDER BY a LIMIT 1 + const query8 = queryWithAddedOrderBy( + queryWithLimit( + query('coll', orFilter(filter('a', '==', 2), filter('b', '==', 1))), + 1, + LimitType.First + ), + orderBy('a', 'asc') + ); + const result8 = await expectFullCollectionQuery(() => + runQuery(query8, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result8, [doc5]); + + // Test with limits (explicit order by DESC): (a==2) || (b == 1) ORDER BY a LIMIT_TO_LAST 1 + const query9 = queryWithAddedOrderBy( + queryWithLimit( + query('coll', orFilter(filter('a', '==', 2), filter('b', '==', 1))), + 1, + LimitType.Last + ), + orderBy('a', 'desc') + ); + const result9 = await expectFullCollectionQuery(() => + runQuery(query9, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result9, [doc5]); + + // Test with limits without orderBy (the __name__ ordering is the tie breaker). + const query10 = queryWithLimit( + query('coll', orFilter(filter('a', '==', 2), filter('b', '==', 1))), + 1, + LimitType.First + ); + const result10 = await expectFullCollectionQuery(() => + runQuery(query10, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result10, [doc2]); + }); - // Add an unacknowledged mutation - await addMutation(deleteMutation('coll/b')); - const docs = await expectFullCollectionQuery(() => - runQuery(query1, LAST_LIMBO_FREE_SNAPSHOT) - ); - verifyResult(docs, [MATCHING_DOC_A]); - }); + it('query does not include documents with missing fields', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': 0 }); + const doc2 = doc('coll/2', 1, { 'b': 1 }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': 2 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': 3 }); + const doc5 = doc('coll/5', 1, { 'a': 1 }); + const doc6 = doc('coll/6', 1, { 'a': 2 }); + await addDocument(doc1, doc2, doc3, doc4, doc5, doc6); + + // Query: a==1 || b==1 order by a. + // doc2 should not be included because it's missing the field 'a', and we have "orderBy a". + const query1 = query( + 'coll', + orFilter(filter('a', '==', 1), filter('b', '==', 1)), + orderBy('a', 'asc') + ); + const result1 = await expectFullCollectionQuery(() => + runQuery(query1, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result1, [doc1, doc4, doc5]); + + // Query: a==1 || b==1 order by b. + // doc5 should not be included because it's missing the field 'b', and we have "orderBy b". + const query2 = query( + 'coll', + orFilter(filter('a', '==', 1), filter('b', '==', 1)), + orderBy('b', 'asc') + ); + const result2 = await expectFullCollectionQuery(() => + runQuery(query2, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result2, [doc1, doc2, doc4]); + + // Query: a>2 || b==1. + // This query has an implicit 'order by a'. + // doc2 should not be included because it's missing the field 'a'. + const query3 = query( + 'coll', + orFilter(filter('a', '>', 2), filter('b', '==', 1)) + ); + const result3 = await expectFullCollectionQuery(() => + runQuery(query3, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result3, [doc3]); + + // Query: a>1 || b==1 order by a order by b. + // doc6 should not be included because it's missing the field 'b'. + // doc2 should not be included because it's missing the field 'a'. + const query4 = query( + 'coll', + orFilter(filter('a', '>', 1), filter('b', '==', 1)), + orderBy('a', 'asc'), + orderBy('b', 'asc') + ); + const result4 = await expectFullCollectionQuery(() => + runQuery(query4, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result4, [doc3]); + + // Query: a==1 || b==1 + // There's no explicit nor implicit orderBy. Documents with missing 'a' or missing 'b' should be + // allowed if the document matches at least one disjunction term. + const query5 = query( + 'coll', + orFilter(filter('a', '==', 1), filter('b', '==', 1)) + ); + const result5 = await expectFullCollectionQuery(() => + runQuery(query5, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result5, [doc1, doc2, doc4, doc5]); + }); - if (!durable) { - return; - } + // Tests in this section require client side indexing + if (configureCsi) { + it('combines indexed with non-indexed results', async () => { + debugAssert(configureCsi, 'Test requires durable persistence'); + + const doc1 = doc('coll/a', 1, { 'foo': true }); + const doc2 = doc('coll/b', 2, { 'foo': true }); + const doc3 = doc('coll/c', 3, { 'foo': true }); + const doc4 = doc('coll/d', 3, { 'foo': true }).setHasLocalMutations(); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['foo', IndexKind.ASCENDING]] }) + ); + + await addDocument(doc1); + await addDocument(doc2); + await indexManager.updateIndexEntries(documentMap(doc1, doc2)); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc2) + ); + + await addDocument(doc3); + await addMutation(setMutation('coll/d', { 'foo': true })); + + const queryWithFilter = queryWithAddedFilter( + query('coll'), + filter('foo', '==', true) + ); + const results = await expectOptimizedCollectionQuery(() => + runQuery(queryWithFilter, SnapshotVersion.min()) + ); + + verifyResult(results, [doc1, doc2, doc3, doc4]); + }); + + it('uses partial index for limit queries', async () => { + debugAssert(configureCsi, 'Test requires durable persistence'); + + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': 0 }); + const doc2 = doc('coll/2', 1, { 'a': 1, 'b': 1 }); + const doc3 = doc('coll/3', 1, { 'a': 1, 'b': 2 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': 3 }); + const doc5 = doc('coll/5', 1, { 'a': 2, 'b': 3 }); + await addDocument(doc1, doc2, doc3, doc4, doc5); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.updateIndexEntries( + documentMap(doc1, doc2, doc3, doc4, doc5) + ); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc5) + ); + + const q = queryWithLimit( + queryWithAddedFilter( + queryWithAddedFilter(query('coll'), filter('a', '==', 1)), + filter('b', '==', 1) + ), + 3, + LimitType.First + ); + const results = await expectOptimizedCollectionQuery(() => + runQuery(q, SnapshotVersion.min()) + ); + + verifyResult(results, [doc2]); + }); + + it('re-fills indexed limit queries', async () => { + debugAssert(configureCsi, 'Test requires durable persistence'); + + const doc1 = doc('coll/1', 1, { 'a': 1 }); + const doc2 = doc('coll/2', 1, { 'a': 2 }); + const doc3 = doc('coll/3', 1, { 'a': 3 }); + const doc4 = doc('coll/4', 1, { 'a': 4 }); + await addDocument(doc1, doc2, doc3, doc4); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.updateIndexEntries( + documentMap(doc1, doc2, doc3, doc4) + ); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc4) + ); + + await addMutation(patchMutation('coll/3', { 'a': 5 })); + + const q = queryWithLimit( + queryWithAddedOrderBy(query('coll'), orderBy('a')), + 3, + LimitType.First + ); + const results = await expectOptimizedCollectionQuery(() => + runQuery(q, SnapshotVersion.min()) + ); + + verifyResult(results, [doc1, doc2, doc4]); + }); + } - it('combines indexed with non-indexed results', async () => { - debugAssert(durable, 'Test requires durable persistence'); + // Tests below this line execute with and without client side indexing + it('query with multiple ins on the same field', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': 0 }); + const doc2 = doc('coll/2', 1, { 'b': 1 }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': 2 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': 3 }); + const doc5 = doc('coll/5', 1, { 'a': 1 }); + const doc6 = doc('coll/6', 1, { 'a': 2 }); + await addDocument(doc1, doc2, doc3, doc4, doc5, doc6); + + let expectFunction = expectFullCollectionQuery; + let lastLimboFreeSnapshot = MISSING_LAST_LIMBO_FREE_SNAPSHOT; + + if (configureCsi) { + expectFunction = expectOptimizedCollectionQuery; + lastLimboFreeSnapshot = SnapshotVersion.min(); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.DESCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.DESCENDING]] }) + ); + await indexManager.updateIndexEntries( + documentMap(doc1, doc3, doc4, doc5, doc6) + ); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc6) + ); + } - const doc1 = doc('coll/a', 1, { 'foo': true }); - const doc2 = doc('coll/b', 2, { 'foo': true }); - const doc3 = doc('coll/c', 3, { 'foo': true }); - const doc4 = doc('coll/d', 3, { 'foo': true }).setHasLocalMutations(); + // a IN [1,2,3] && a IN [0,1,4] should result in "a==1". + const query1 = query( + 'coll', + andFilter(filter('a', 'in', [1, 2, 3]), filter('a', 'in', [0, 1, 4])) + ); + const result1 = await expectFunction(() => + runQuery(query1, lastLimboFreeSnapshot) + ); + verifyResult(result1, [doc1, doc4, doc5]); + + // a IN [2,3] && a IN [0,1,4] is never true and so the result should be an empty set. + const query2 = query( + 'coll', + andFilter(filter('a', 'in', [2, 3]), filter('a', 'in', [0, 1, 4])) + ); + const result2 = await expectFunction(() => + runQuery(query2, lastLimboFreeSnapshot) + ); + verifyResult(result2, []); + + // a IN [0,3] || a IN [0,2] should union them (similar to: a IN [0,2,3]). + const query3 = query( + 'coll', + orFilter(filter('a', 'in', [0, 3]), filter('a', 'in', [0, 2])) + ); + const result3 = await expectFunction(() => + runQuery(query3, lastLimboFreeSnapshot) + ); + verifyResult(result3, [doc3, doc6]); + + // Nested composite filter: (a IN [0,1,2,3] && (a IN [0,2] || (b>1 && a IN [1,3])) + const query4 = query( + 'coll', + andFilter( + filter('a', 'in', [1, 2, 3]), + orFilter( + filter('a', 'in', [0, 2]), + andFilter(filter('b', '>=', 1), filter('a', 'in', [1, 3])) + ) + ) + ); + const result4 = await expectFunction(() => + runQuery(query4, lastLimboFreeSnapshot) + ); + verifyResult(result4, [doc3, doc4]); + }); - await indexManager.addFieldIndex( - fieldIndex('coll', { fields: [['foo', IndexKind.ASCENDING]] }) - ); + it('query with ins and not-ins on the same field', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': 0 }); + const doc2 = doc('coll/2', 1, { 'b': 1 }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': 2 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': 3 }); + const doc5 = doc('coll/5', 1, { 'a': 1 }); + const doc6 = doc('coll/6', 1, { 'a': 2 }); + await addDocument(doc1, doc2, doc3, doc4, doc5, doc6); + + let expectFunction = expectFullCollectionQuery; + let lastLimboFreeSnapshot = MISSING_LAST_LIMBO_FREE_SNAPSHOT; + + if (configureCsi) { + expectFunction = expectOptimizedCollectionQuery; + lastLimboFreeSnapshot = SnapshotVersion.min(); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.DESCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.DESCENDING]] }) + ); + await indexManager.updateIndexEntries( + documentMap(doc1, doc3, doc4, doc5, doc6) + ); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc6) + ); + } - await addDocument(doc1); - await addDocument(doc2); - await indexManager.updateIndexEntries(documentMap(doc1, doc2)); - await indexManager.updateCollectionGroup( - 'coll', - newIndexOffsetFromDocument(doc2) - ); + // a IN [1,2,3] && a IN [0,1,3,4] && a NOT-IN [1] should result in + // "a==1 && a!=1 || a==3 && a!=1" or just "a == 3" + const query1 = query( + 'coll', + andFilter( + filter('a', 'in', [1, 2, 3]), + filter('a', 'in', [0, 1, 3, 4]), + filter('a', 'not-in', [1]) + ) + ); + const result1 = await expectFunction(() => + runQuery(query1, lastLimboFreeSnapshot) + ); + verifyResult(result1, [doc3]); + + // a IN [2,3] && a IN [0,1,2,4] && a NOT-IN [1,2] is never true and so the + // result should be an empty set. + const query2 = query( + 'coll', + andFilter( + filter('a', 'in', [2, 3]), + filter('a', 'in', [0, 1, 2, 4]), + filter('a', 'not-in', [1, 2]) + ) + ); + const result2 = await expectFunction(() => + runQuery(query2, lastLimboFreeSnapshot) + ); + verifyResult(result2, []); + + // a IN [] || a NOT-IN [0,1,2] should union them (similar to: a NOT-IN [0,1,2]). + const query3 = query( + 'coll', + orFilter(filter('a', 'in', []), filter('a', 'not-in', [0, 1, 2])) + ); + const result3 = await expectFunction(() => + runQuery(query3, lastLimboFreeSnapshot) + ); + verifyResult(result3, [doc3]); + + const query4 = query( + 'coll', + andFilter( + filter('a', '<=', 1), + filter('a', 'in', [1, 2, 3, 4]), + filter('a', 'not-in', [0, 2]) + ) + ); + const result4 = await expectFunction(() => + runQuery(query4, lastLimboFreeSnapshot) + ); + verifyResult(result4, [doc1, doc4, doc5]); + }); - await addDocument(doc3); - await addMutation(setMutation('coll/d', { 'foo': true })); + it('query with multiple ins on different fields', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': 0 }); + const doc2 = doc('coll/2', 1, { 'b': 1 }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': 2 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': 3 }); + const doc5 = doc('coll/5', 1, { 'a': 1 }); + const doc6 = doc('coll/6', 1, { 'a': 2 }); + await addDocument(doc1, doc2, doc3, doc4, doc5, doc6); + + let expectFunction = expectFullCollectionQuery; + let lastLimboFreeSnapshot = MISSING_LAST_LIMBO_FREE_SNAPSHOT; + + if (configureCsi) { + expectFunction = expectOptimizedCollectionQuery; + lastLimboFreeSnapshot = SnapshotVersion.min(); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.DESCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.DESCENDING]] }) + ); + await indexManager.updateIndexEntries( + documentMap(doc1, doc2, doc3, doc4, doc5, doc6) + ); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc6) + ); + } - const queryWithFilter = queryWithAddedFilter( - query('coll'), - filter('foo', '==', true) - ); - const results = await expectOptimizedCollectionQuery(() => - runQuery(queryWithFilter, SnapshotVersion.min()) - ); + const query1 = query( + 'coll', + orFilter(filter('a', 'in', [2, 3]), filter('b', 'in', [0, 2])) + ); + const result1 = await expectFunction(() => + runQuery(query1, MISSING_LAST_LIMBO_FREE_SNAPSHOT) + ); + verifyResult(result1, [doc1, doc3, doc6]); + + const query2 = query( + 'coll', + andFilter(filter('a', 'in', [2, 3]), filter('b', 'in', [0, 2])) + ); + const result2 = await expectFunction(() => + runQuery(query2, lastLimboFreeSnapshot) + ); + verifyResult(result2, [doc3]); + + // Nested composite filter: (a IN [0,1,2,3] && (a IN [0,2] || (b>1 && a IN [1,3])) + const query3 = query( + 'coll', + andFilter( + filter('b', 'in', [0, 3]), + orFilter( + filter('b', 'in', [1]), + andFilter(filter('b', 'in', [2, 3]), filter('a', 'in', [1, 3])) + ) + ) + ); + const result3 = await expectFunction(() => + runQuery(query3, lastLimboFreeSnapshot) + ); + verifyResult(result3, [doc4]); + }); - verifyResult(results, [doc1, doc2, doc3, doc4]); - }); + it('query in with array-contains-any', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': [0] }); + const doc2 = doc('coll/2', 1, { 'b': [1] }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': [2, 7], 'c': 10 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': [3, 7] }); + const doc5 = doc('coll/5', 1, { 'a': 1 }); + const doc6 = doc('coll/6', 1, { 'a': 2, 'c': 20 }); + await addDocument(doc1, doc2, doc3, doc4, doc5, doc6); + + let expectFunction = expectFullCollectionQuery; + let lastLimboFreeSnapshot = MISSING_LAST_LIMBO_FREE_SNAPSHOT; + + if (configureCsi) { + expectFunction = expectOptimizedCollectionQuery; + lastLimboFreeSnapshot = SnapshotVersion.min(); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.DESCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.CONTAINS]] }) + ); + await indexManager.updateIndexEntries( + documentMap(doc1, doc2, doc3, doc4, doc5, doc6) + ); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc6) + ); + } - it('uses partial index for limit queries', async () => { - debugAssert(durable, 'Test requires durable persistence'); + const query1 = query( + 'coll', + orFilter( + filter('a', 'in', [2, 3]), + filter('b', 'array-contains-any', [0, 7]) + ) + ); + const result1 = await expectFunction(() => + runQuery(query1, lastLimboFreeSnapshot) + ); + verifyResult(result1, [doc1, doc3, doc4, doc6]); + + const query2 = query( + 'coll', + andFilter( + filter('a', 'in', [2, 3]), + filter('b', 'array-contains-any', [0, 7]) + ) + ); + const result2 = await expectFunction(() => + runQuery(query2, lastLimboFreeSnapshot) + ); + verifyResult(result2, [doc3]); + + const query3 = query( + 'coll', + orFilter( + andFilter(filter('a', 'in', [2, 3]), filter('c', '==', 10)), + filter('b', 'array-contains-any', [0, 7]) + ) + ); + const result3 = await expectFunction(() => + runQuery(query3, lastLimboFreeSnapshot) + ); + verifyResult(result3, [doc1, doc3, doc4]); + + const query4 = query( + 'coll', + andFilter( + filter('a', 'in', [2, 3]), + orFilter( + filter('b', 'array-contains-any', [0, 7]), + filter('c', '==', 20) + ) + ) + ); + const result4 = await expectFunction(() => + runQuery(query4, lastLimboFreeSnapshot) + ); + verifyResult(result4, [doc3, doc6]); + }); - const doc1 = doc('coll/1', 1, { 'a': 1, 'b': 0 }); - const doc2 = doc('coll/2', 1, { 'a': 1, 'b': 1 }); - const doc3 = doc('coll/3', 1, { 'a': 1, 'b': 2 }); - const doc4 = doc('coll/4', 1, { 'a': 1, 'b': 3 }); - const doc5 = doc('coll/5', 1, { 'a': 2, 'b': 3 }); - await addDocument(doc1, doc2, doc3, doc4, doc5); + it('query in with array-contains', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': [0] }); + const doc2 = doc('coll/2', 1, { 'b': [1] }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': [2, 7], 'c': 10 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': [3, 7] }); + const doc5 = doc('coll/5', 1, { 'a': 1 }); + const doc6 = doc('coll/6', 1, { 'a': 2, 'c': 20 }); + await addDocument(doc1, doc2, doc3, doc4, doc5, doc6); + + let expectFunction = expectFullCollectionQuery; + let lastLimboFreeSnapshot = MISSING_LAST_LIMBO_FREE_SNAPSHOT; + + if (configureCsi) { + expectFunction = expectOptimizedCollectionQuery; + lastLimboFreeSnapshot = SnapshotVersion.min(); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.DESCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.CONTAINS]] }) + ); + + await indexManager.updateIndexEntries( + documentMap(doc1, doc2, doc3, doc4, doc5, doc6) + ); + + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc6) + ); + } - await indexManager.addFieldIndex( - fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) - ); - await indexManager.updateIndexEntries( - documentMap(doc1, doc2, doc3, doc4, doc5) - ); - await indexManager.updateCollectionGroup( - 'coll', - newIndexOffsetFromDocument(doc5) - ); + const query1 = query( + 'coll', + orFilter(filter('a', 'in', [2, 3]), filter('b', 'array-contains', 3)) + ); + const result1 = await expectFunction(() => + runQuery(query1, lastLimboFreeSnapshot) + ); + verifyResult(result1, [doc3, doc4, doc6]); + + const query2 = query( + 'coll', + andFilter(filter('a', 'in', [2, 3]), filter('b', 'array-contains', 7)) + ); + const result2 = await expectFunction(() => + runQuery(query2, lastLimboFreeSnapshot) + ); + verifyResult(result2, [doc3]); + + const query3 = query( + 'coll', + orFilter( + filter('a', 'in', [2, 3]), + andFilter(filter('b', 'array-contains', 3), filter('a', '==', 1)) + ) + ); + const result3 = await expectFunction(() => + runQuery(query3, lastLimboFreeSnapshot) + ); + verifyResult(result3, [doc3, doc4, doc6]); + + const query4 = query( + 'coll', + andFilter( + filter('a', 'in', [2, 3]), + orFilter(filter('b', 'array-contains', 7), filter('a', '==', 1)) + ) + ); + const result4 = await expectFunction(() => + runQuery(query4, lastLimboFreeSnapshot) + ); + verifyResult(result4, [doc3]); + }); - const q = queryWithLimit( - queryWithAddedFilter( - queryWithAddedFilter(query('coll'), filter('a', '==', 1)), - filter('b', '==', 1) - ), - 3, - LimitType.First - ); - const results = await expectOptimizedCollectionQuery(() => - runQuery(q, SnapshotVersion.min()) - ); + it('order by equality', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': [0] }); + const doc2 = doc('coll/2', 1, { 'b': [1] }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': [2, 7], 'c': 10 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': [3, 7] }); + const doc5 = doc('coll/5', 1, { 'a': 1 }); + const doc6 = doc('coll/6', 1, { 'a': 2, 'c': 20 }); + await addDocument(doc1, doc2, doc3, doc4, doc5, doc6); + + let expectFunction = expectFullCollectionQuery; + let lastLimboFreeSnapshot = MISSING_LAST_LIMBO_FREE_SNAPSHOT; + + if (configureCsi) { + expectFunction = expectOptimizedCollectionQuery; + lastLimboFreeSnapshot = SnapshotVersion.min(); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.DESCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.CONTAINS]] }) + ); + await indexManager.updateIndexEntries( + documentMap(doc1, doc2, doc3, doc4, doc5, doc6) + ); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc6) + ); + } - verifyResult(results, [doc2]); - }); + const query1 = query('coll', filter('a', '==', 1), orderBy('a')); + const result1 = await expectFunction(() => + runQuery(query1, lastLimboFreeSnapshot) + ); + verifyResult(result1, [doc1, doc4, doc5]); - it('re-fills indexed limit queries', async () => { - debugAssert(durable, 'Test requires durable persistence'); + const query2 = query('coll', filter('a', 'in', [2, 3]), orderBy('a')); - const doc1 = doc('coll/1', 1, { 'a': 1 }); - const doc2 = doc('coll/2', 1, { 'a': 2 }); - const doc3 = doc('coll/3', 1, { 'a': 3 }); - const doc4 = doc('coll/4', 1, { 'a': 4 }); - await addDocument(doc1, doc2, doc3, doc4); + const result2 = await expectFunction(() => + runQuery(query2, lastLimboFreeSnapshot) + ); + verifyResult(result2, [doc6, doc3]); + }); - await indexManager.addFieldIndex( - fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) - ); - await indexManager.updateIndexEntries(documentMap(doc1, doc2, doc3, doc4)); - await indexManager.updateCollectionGroup( - 'coll', - newIndexOffsetFromDocument(doc4) - ); + it('or query with in and not-in', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': 0 }); + const doc2 = doc('coll/2', 1, { 'b': 1 }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': 2 }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': 3 }); + const doc5 = doc('coll/5', 1, { 'a': 1 }); + const doc6 = doc('coll/6', 1, { 'a': 2 }); + await addDocument(doc1, doc2, doc3, doc4, doc5, doc6); + + let expectFunction = expectFullCollectionQuery; + let lastLimboFreeSnapshot = MISSING_LAST_LIMBO_FREE_SNAPSHOT; + + if (configureCsi) { + expectFunction = expectOptimizedCollectionQuery; + lastLimboFreeSnapshot = SnapshotVersion.min(); + + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.DESCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.DESCENDING]] }) + ); + + await indexManager.updateIndexEntries( + documentMap(doc1, doc2, doc3, doc4, doc5, doc6) + ); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc6) + ); + } - await addMutation(patchMutation('coll/3', { 'a': 5 })); + const query1 = query( + 'coll', + orFilter(filter('a', '==', 2), filter('b', 'in', [2, 3])) + ); + const result1 = await expectFunction(() => + runQuery(query1, lastLimboFreeSnapshot) + ); + verifyResult(result1, [doc3, doc4, doc6]); + + // a==2 || (b != 2 && b != 3) + // Has implicit "orderBy b" + const query2 = query( + 'coll', + orFilter(filter('a', '==', 2), filter('b', 'not-in', [2, 3])) + ); + const result2 = await expectFunction(() => + runQuery(query2, lastLimboFreeSnapshot) + ); + verifyResult(result2, [doc1, doc2]); + }); - const q = queryWithLimit( - queryWithAddedOrderBy(query('coll'), orderBy('a')), - 3, - LimitType.First - ); - const results = await expectOptimizedCollectionQuery(() => - runQuery(q, SnapshotVersion.min()) - ); + it('query with array membership', async () => { + const doc1 = doc('coll/1', 1, { 'a': 1, 'b': [0] }); + const doc2 = doc('coll/2', 1, { 'b': [1] }); + const doc3 = doc('coll/3', 1, { 'a': 3, 'b': [2, 7] }); + const doc4 = doc('coll/4', 1, { 'a': 1, 'b': [3, 7] }); + const doc5 = doc('coll/5', 1, { 'a': 1 }); + const doc6 = doc('coll/6', 1, { 'a': 2 }); + await addDocument(doc1, doc2, doc3, doc4, doc5, doc6); + + let expectFunction = expectFullCollectionQuery; + let lastLimboFreeSnapshot = MISSING_LAST_LIMBO_FREE_SNAPSHOT; + + if (configureCsi) { + expectFunction = expectOptimizedCollectionQuery; + lastLimboFreeSnapshot = SnapshotVersion.min(); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.ASCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['a', IndexKind.DESCENDING]] }) + ); + await indexManager.addFieldIndex( + fieldIndex('coll', { fields: [['b', IndexKind.CONTAINS]] }) + ); + + await indexManager.updateIndexEntries( + documentMap(doc1, doc2, doc3, doc4, doc5, doc6) + ); + await indexManager.updateCollectionGroup( + 'coll', + newIndexOffsetFromDocument(doc6) + ); + } - verifyResult(results, [doc1, doc2, doc4]); - }); + const query1 = query( + 'coll', + orFilter(filter('a', '==', 2), filter('b', 'array-contains', 7)) + ); + const result1 = await expectFunction(() => + runQuery(query1, lastLimboFreeSnapshot) + ); + verifyResult(result1, [doc3, doc4, doc6]); + + const query2 = query( + 'coll', + orFilter( + filter('a', '==', 2), + filter('b', 'array-contains-any', [0, 3]) + ) + ); + const result2 = await expectFunction(() => + runQuery(query2, lastLimboFreeSnapshot) + ); + verifyResult(result2, [doc1, doc4, doc6]); + }); + } } function verifyResult(actualDocs: DocumentSet, expectedDocs: Document[]): void { diff --git a/packages/firestore/test/unit/model/target.test.ts b/packages/firestore/test/unit/model/target.test.ts index 624dc520660..bbeea5dec83 100644 --- a/packages/firestore/test/unit/model/target.test.ts +++ b/packages/firestore/test/unit/model/target.test.ts @@ -17,13 +17,13 @@ import { expect } from 'chai'; +import { Bound } from '../../../src/core/bound'; import { queryToTarget, queryWithEndAt, queryWithStartAt } from '../../../src/core/query'; import { - Bound, targetGetUpperBound, targetGetLowerBound, targetGetArrayValues diff --git a/packages/firestore/test/unit/remote/serializer.helper.ts b/packages/firestore/test/unit/remote/serializer.helper.ts index 7f9bb3a78b9..1c0a49edda9 100644 --- a/packages/firestore/test/unit/remote/serializer.helper.ts +++ b/packages/firestore/test/unit/remote/serializer.helper.ts @@ -30,29 +30,28 @@ import { } from '../../../src'; import { ExpUserDataWriter } from '../../../src/api/reference_impl'; import { DatabaseId } from '../../../src/core/database_info'; -import { - LimitType, - queryToTarget, - queryWithEndAt, - queryWithLimit, - queryWithStartAt -} from '../../../src/core/query'; -import { SnapshotVersion } from '../../../src/core/snapshot_version'; import { ArrayContainsAnyFilter, ArrayContainsFilter, - Direction, + CompositeFilter, FieldFilter, + Filter, filterEquals, InFilter, KeyFieldFilter, NotInFilter, - Operator, - OrderBy, - Target, - targetEquals, - TargetImpl -} from '../../../src/core/target'; + Operator +} from '../../../src/core/filter'; +import { Direction, OrderBy } from '../../../src/core/order_by'; +import { + LimitType, + queryToTarget, + queryWithEndAt, + queryWithLimit, + queryWithStartAt +} from '../../../src/core/query'; +import { SnapshotVersion } from '../../../src/core/snapshot_version'; +import { Target, targetEquals, TargetImpl } from '../../../src/core/target'; import { parseQueryValue } from '../../../src/lite-api/user_data_reader'; import { TargetData, TargetPurpose } from '../../../src/local/target_data'; import { FieldMask } from '../../../src/model/field_mask'; @@ -94,7 +93,9 @@ import { toQueryTarget, toTarget, toUnaryOrFieldFilter, - toVersion + toVersion, + fromCompositeFilter, + toCompositeFilter } from '../../../src/remote/serializer'; import { DocumentWatchChange, @@ -113,6 +114,8 @@ import { doc, field, filter, + andFilter, + orFilter, key, orderBy, patchMutation, @@ -129,6 +132,8 @@ const userDataWriter = new ExpUserDataWriter(firestore()); const protobufJsonReader = testUserDataReader(/* useProto3Json= */ true); const protoJsReader = testUserDataReader(/* useProto3Json= */ false); +const protoCompositeFilterOrOp: api.CompositeFilterOp = 'OR'; + /** * Runs the serializer test with an optional ProtobufJS verification step * (only provided in Node). @@ -810,7 +815,7 @@ export function serializerTest( expect(fromDocument(s, serialized, undefined).isEqual(d)).to.equal(true); }); - describe('to/from FieldFilter', () => { + describe('to/from UnaryOrieldFilter', () => { addEqualityMatcher({ equalsFn: filterEquals, forType: FieldFilter }); it('makes dotted-property names', () => { @@ -1082,6 +1087,174 @@ export function serializerTest( }); }); + describe('to/from CompositeFilter', () => { + addEqualityMatcher({ equalsFn: filterEquals, forType: Filter }); + + /* eslint-disable no-restricted-properties */ + it('converts deep collections', () => { + const input = orFilter( + filter('prop', '<', 42), + andFilter( + filter('author', '==', 'ehsann'), + filter('tags', 'array-contains', 'pending'), + orFilter(filter('version', '==', 4), filter('version', '==', NaN)) + ) + ); + + // Encode + const actual = toCompositeFilter(input); + + const propProtoFilter = { + fieldFilter: { + field: { fieldPath: 'prop' }, + op: 'LESS_THAN', + value: { integerValue: '42' } + } + }; + + const authorProtoFilter = { + fieldFilter: { + field: { fieldPath: 'author' }, + op: 'EQUAL', + value: { stringValue: 'ehsann' } + } + }; + + const tagsProtoFilter = { + fieldFilter: { + field: { fieldPath: 'tags' }, + op: 'ARRAY_CONTAINS', + value: { stringValue: 'pending' } + } + }; + + const versionProtoFilter = { + fieldFilter: { + field: { fieldPath: 'version' }, + op: 'EQUAL', + value: { integerValue: '4' } + } + }; + + const nanVersionProtoFilter = { + unaryFilter: { + field: { fieldPath: 'version' }, + op: 'IS_NAN' + } + }; + + const innerOrProtoFilter = { + compositeFilter: { + op: protoCompositeFilterOrOp, + filters: [versionProtoFilter, nanVersionProtoFilter] + } + }; + + const innerAndProtoFilter = { + compositeFilter: { + op: 'AND', + filters: [authorProtoFilter, tagsProtoFilter, innerOrProtoFilter] + } + }; + + expect(actual).to.deep.equal({ + compositeFilter: { + op: protoCompositeFilterOrOp, + filters: [propProtoFilter, innerAndProtoFilter] + } + }); + + // Decode + const roundtripped = fromCompositeFilter(actual); + expect(roundtripped).to.deep.equal(input); + expect(roundtripped).to.be.instanceof(CompositeFilter); + }); + }); + + describe('to/from CompositeFilter', () => { + addEqualityMatcher({ equalsFn: filterEquals, forType: Filter }); + + /* eslint-disable no-restricted-properties */ + it('converts deep collections', () => { + const input = orFilter( + filter('prop', '<', 42), + andFilter( + filter('author', '==', 'ehsann'), + filter('tags', 'array-contains', 'pending'), + orFilter(filter('version', '==', 4), filter('version', '==', NaN)) + ) + ); + + // Encode + const actual = toCompositeFilter(input); + + const propProtoFilter = { + fieldFilter: { + field: { fieldPath: 'prop' }, + op: 'LESS_THAN', + value: { integerValue: '42' } + } + }; + + const authorProtoFilter = { + fieldFilter: { + field: { fieldPath: 'author' }, + op: 'EQUAL', + value: { stringValue: 'ehsann' } + } + }; + + const tagsProtoFilter = { + fieldFilter: { + field: { fieldPath: 'tags' }, + op: 'ARRAY_CONTAINS', + value: { stringValue: 'pending' } + } + }; + + const versionProtoFilter = { + fieldFilter: { + field: { fieldPath: 'version' }, + op: 'EQUAL', + value: { integerValue: '4' } + } + }; + + const nanVersionProtoFilter = { + unaryFilter: { + field: { fieldPath: 'version' }, + op: 'IS_NAN' + } + }; + + const innerOrProtoFilter = { + compositeFilter: { + op: protoCompositeFilterOrOp, + filters: [versionProtoFilter, nanVersionProtoFilter] + } + }; + + const innerAndProtoFilter = { + compositeFilter: { + op: 'AND', + filters: [authorProtoFilter, tagsProtoFilter, innerOrProtoFilter] + } + }; + + expect(actual).to.deep.equal({ + compositeFilter: { + op: protoCompositeFilterOrOp, + filters: [propProtoFilter, innerAndProtoFilter] + } + }); + + // Decode + const roundtripped = fromCompositeFilter(actual); + expect(roundtripped).to.deep.equal(input); + expect(roundtripped).to.be.instanceof(CompositeFilter); + }); + }); + it('encodes listen request labels', () => { const target = queryToTarget(query('collection/key')); let targetData = new TargetData(target, 2, TargetPurpose.Listen, 3); @@ -1305,6 +1478,178 @@ export function serializerTest( expect(fromQueryTarget(toQueryTarget(s, q))).to.deep.equal(q); }); + it('converts multi-layer composite filters with OR at the first layer', () => { + const q = queryToTarget( + query( + 'docs', + orFilter( + filter('prop', '<', 42), + filter('name', '==', 'dimond'), + andFilter( + filter('nan', '==', NaN), + filter('null', '==', null), + filter('tags', 'array-contains', 'pending') + ) + ) + ) + ); + const result = toTarget(s, wrapTargetData(q)); + const expected = { + query: { + parent: 'projects/p/databases/d/documents', + structuredQuery: { + from: [{ collectionId: 'docs' }], + where: { + compositeFilter: { + op: protoCompositeFilterOrOp, + filters: [ + { + fieldFilter: { + field: { fieldPath: 'prop' }, + op: 'LESS_THAN', + value: { integerValue: '42' } + } + }, + { + fieldFilter: { + field: { fieldPath: 'name' }, + op: 'EQUAL', + value: { stringValue: 'dimond' } + } + }, + { + compositeFilter: { + op: 'AND', + filters: [ + { + unaryFilter: { + field: { fieldPath: 'nan' }, + op: 'IS_NAN' + } + }, + { + unaryFilter: { + field: { fieldPath: 'null' }, + op: 'IS_NULL' + } + }, + { + fieldFilter: { + field: { fieldPath: 'tags' }, + op: 'ARRAY_CONTAINS', + value: { stringValue: 'pending' } + } + } + ] + } + } + ] + } + }, + orderBy: [ + { + field: { fieldPath: 'prop' }, + direction: 'ASCENDING' + }, + { + field: { fieldPath: DOCUMENT_KEY_NAME }, + direction: 'ASCENDING' + } + ] + } + }, + targetId: 1 + }; + expect(result).to.deep.equal(expected); + expect(fromQueryTarget(toQueryTarget(s, q))).to.deep.equal(q); + }); + + it('converts multi-layer composite filters with AND at the first layer', () => { + const q = queryToTarget( + query( + 'docs', + andFilter( + filter('prop', '<', 42), + filter('name', '==', 'dimond'), + orFilter( + filter('nan', '==', NaN), + filter('null', '==', null), + filter('tags', 'array-contains', 'pending') + ) + ) + ) + ); + const result = toTarget(s, wrapTargetData(q)); + const expected = { + query: { + parent: 'projects/p/databases/d/documents', + structuredQuery: { + from: [{ collectionId: 'docs' }], + where: { + compositeFilter: { + op: 'AND', + filters: [ + { + fieldFilter: { + field: { fieldPath: 'prop' }, + op: 'LESS_THAN', + value: { integerValue: '42' } + } + }, + { + fieldFilter: { + field: { fieldPath: 'name' }, + op: 'EQUAL', + value: { stringValue: 'dimond' } + } + }, + { + compositeFilter: { + op: protoCompositeFilterOrOp, + filters: [ + { + unaryFilter: { + field: { fieldPath: 'nan' }, + op: 'IS_NAN' + } + }, + { + unaryFilter: { + field: { fieldPath: 'null' }, + op: 'IS_NULL' + } + }, + { + fieldFilter: { + field: { fieldPath: 'tags' }, + op: 'ARRAY_CONTAINS', + value: { stringValue: 'pending' } + } + } + ] + } + } + ] + } + }, + orderBy: [ + { + field: { fieldPath: 'prop' }, + direction: 'ASCENDING' + }, + { + field: { fieldPath: DOCUMENT_KEY_NAME }, + direction: 'ASCENDING' + } + ] + } + }, + targetId: 1 + }; + expect(result).to.deep.equal(expected); + expect(fromQueryTarget(toQueryTarget(s, q))).to.deep.equal(q); + }); + it('converts order bys', () => { const q = queryToTarget(query('docs', orderBy('prop', 'asc'))); const result = toTarget(s, wrapTargetData(q)); diff --git a/packages/firestore/test/unit/specs/spec_builder.ts b/packages/firestore/test/unit/specs/spec_builder.ts index 6f17689a882..fe3f079edb4 100644 --- a/packages/firestore/test/unit/specs/spec_builder.ts +++ b/packages/firestore/test/unit/specs/spec_builder.ts @@ -17,6 +17,7 @@ import { IndexConfiguration } from '../../../src/api/index_configuration'; import { ExpUserDataWriter } from '../../../src/api/reference_impl'; +import { FieldFilter, Filter } from '../../../src/core/filter'; import { LimitType, newQueryForPath, @@ -24,13 +25,7 @@ import { queryEquals, queryToTarget } from '../../../src/core/query'; -import { - canonifyTarget, - FieldFilter, - Filter, - Target, - targetEquals -} from '../../../src/core/target'; +import { canonifyTarget, Target, targetEquals } from '../../../src/core/target'; import { TargetIdGenerator } from '../../../src/core/target_id_generator'; import { TargetId } from '../../../src/core/types'; import { Document } from '../../../src/model/document'; diff --git a/packages/firestore/test/unit/util/logic_utils.test.ts b/packages/firestore/test/unit/util/logic_utils.test.ts new file mode 100644 index 00000000000..4d9e74e1b14 --- /dev/null +++ b/packages/firestore/test/unit/util/logic_utils.test.ts @@ -0,0 +1,447 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; + +import { + CompositeFilter, + FieldFilter, + Filter, + filterEquals +} from '../../../src/core/filter'; +import { + applyAssociation, + applyDistribution, + computeDistributedNormalForm, + computeInExpansion, + getDnfTerms +} from '../../../src/util/logic_utils'; +import { addEqualityMatcher } from '../../util/equality_matcher'; +import { filter, andFilter, orFilter } from '../../util/helpers'; + +describe('LogicUtils', () => { + addEqualityMatcher({ equalsFn: filterEquals, forType: Filter }); + + function nameFilter(name: string): FieldFilter { + return filter('name', '==', name); + } + + let A: FieldFilter; + let B: FieldFilter; + let C: FieldFilter; + let D: FieldFilter; + let E: FieldFilter; + let F: FieldFilter; + let G: FieldFilter; + let H: FieldFilter; + let I: FieldFilter; + + beforeEach(() => { + A = nameFilter('A'); + B = nameFilter('B'); + C = nameFilter('C'); + D = nameFilter('D'); + E = nameFilter('E'); + F = nameFilter('F'); + G = nameFilter('G'); + H = nameFilter('H'); + I = nameFilter('I'); + }); + + it('implements field filter associativity', () => { + const f = filter('foo', '==', 'bar'); + expect(f).to.equal(applyAssociation(f)); + }); + + it('implements composite filter associativity', () => { + // AND(AND(X)) --> X + const compositeFilter1 = andFilter(andFilter(A)); + const actualResult1 = applyAssociation(compositeFilter1); + expect(filterEquals(A, actualResult1)).to.be.true; + + // OR(OR(X)) --> X + const compositeFilter2 = orFilter(orFilter(A)); + const actualResult2 = applyAssociation(compositeFilter2); + expect(filterEquals(A, actualResult2)).to.be.true; + + // (A | (B) | ((C) | (D | E)) | (F | (G & (H & I))) --> A | B | C | D | E | F | (G & H & I) + const complexFilter = orFilter( + A, + andFilter(B), + orFilter(orFilter(C), orFilter(D, E)), + orFilter(F, andFilter(G, andFilter(H, I))) + ); + const expectedResult = orFilter(A, B, C, D, E, F, andFilter(G, H, I)); + const actualResult3 = applyAssociation(complexFilter); + expect(filterEquals(expectedResult, actualResult3)).to.be.true; + }); + + it('implements field filter distribution over field filter', () => { + expect(filterEquals(applyDistribution(A, B), andFilter(A, B))).to.be.true; + expect(filterEquals(applyDistribution(B, A), andFilter(B, A))).to.be.true; + }); + + it('implements field filter distribution over and filter', () => { + expect( + filterEquals( + applyDistribution(andFilter(A, B, C), D), + andFilter(A, B, C, D) + ) + ).to.be.true; + }); + + it('implements field filter distribution over or filter', () => { + // A & (B | C | D) = (A & B) | (A & C) | (A & D) + // (B | C | D) & A = (A & B) | (A & C) | (A & D) + const expected = orFilter( + andFilter(A, B), + andFilter(A, C), + andFilter(A, D) + ); + expect(filterEquals(applyDistribution(A, orFilter(B, C, D)), expected)).to + .be.true; + expect(filterEquals(applyDistribution(orFilter(B, C, D), A), expected)).to + .be.true; + }); + + it('implements in expansion for field filters', () => { + const input1: FieldFilter = filter('a', 'in', [1, 2, 3]); + const input2: FieldFilter = filter('a', '<', 1); + const input3: FieldFilter = filter('a', '<=', 1); + const input4: FieldFilter = filter('a', '==', 1); + const input5: FieldFilter = filter('a', '!=', 1); + const input6: FieldFilter = filter('a', '>', 1); + const input7: FieldFilter = filter('a', '>=', 1); + const input8: FieldFilter = filter('a', 'array-contains', 1); + const input9: FieldFilter = filter('a', 'array-contains-any', [1, 2]); + const input10: FieldFilter = filter('a', 'not-in', [1, 2]); + + expect(computeInExpansion(input1)).to.deep.equal( + orFilter(filter('a', '==', 1), filter('a', '==', 2), filter('a', '==', 3)) + ); + + // Other operators should remain the same + expect(computeInExpansion(input2)).to.deep.equal(input2); + expect(computeInExpansion(input3)).to.deep.equal(input3); + expect(computeInExpansion(input4)).to.deep.equal(input4); + expect(computeInExpansion(input5)).to.deep.equal(input5); + expect(computeInExpansion(input6)).to.deep.equal(input6); + expect(computeInExpansion(input7)).to.deep.equal(input7); + expect(computeInExpansion(input8)).to.deep.equal(input8); + expect(computeInExpansion(input9)).to.deep.equal(input9); + expect(computeInExpansion(input10)).to.deep.equal(input10); + }); + + it('implements in expansion for composite filters', () => { + const cf1: CompositeFilter = andFilter( + filter('a', '==', 1), + filter('b', 'in', [2, 3, 4]) + ); + + expect(computeInExpansion(cf1)).to.deep.equal( + andFilter( + filter('a', '==', 1), + orFilter( + filter('b', '==', 2), + filter('b', '==', 3), + filter('b', '==', 4) + ) + ) + ); + + const cf2: CompositeFilter = orFilter( + filter('a', '==', 1), + filter('b', 'in', [2, 3, 4]) + ); + + expect(computeInExpansion(cf2)).to.deep.equal( + orFilter( + filter('a', '==', 1), + orFilter( + filter('b', '==', 2), + filter('b', '==', 3), + filter('b', '==', 4) + ) + ) + ); + + const cf3: CompositeFilter = andFilter( + filter('a', '==', 1), + orFilter(filter('b', '==', 2), filter('c', 'in', [2, 3, 4])) + ); + + expect(computeInExpansion(cf3)).to.deep.equal( + andFilter( + filter('a', '==', 1), + orFilter( + filter('b', '==', 2), + orFilter( + filter('c', '==', 2), + filter('c', '==', 3), + filter('c', '==', 4) + ) + ) + ) + ); + + const cf4: CompositeFilter = orFilter( + filter('a', '==', 1), + andFilter(filter('b', '==', 2), filter('c', 'in', [2, 3, 4])) + ); + + expect(computeInExpansion(cf4)).to.deep.equal( + orFilter( + filter('a', '==', 1), + andFilter( + filter('b', '==', 2), + orFilter( + filter('c', '==', 2), + filter('c', '==', 3), + filter('c', '==', 4) + ) + ) + ) + ); + }); + + it('implements field filter distribution over or filter', () => { + // A & (B | C | D) = (A & B) | (A & C) | (A & D) + // (B | C | D) & A = (A & B) | (A & C) | (A & D) + const expected = orFilter( + andFilter(A, B), + andFilter(A, C), + andFilter(A, D) + ); + expect(filterEquals(applyDistribution(A, orFilter(B, C, D)), expected)).to + .be.true; + expect(filterEquals(applyDistribution(orFilter(B, C, D), A), expected)).to + .be.true; + }); + + // The following four tests cover: + // AND distribution for AND filter and AND filter. + // AND distribution for OR filter and AND filter. + // AND distribution for AND filter and OR filter. + // AND distribution for OR filter and OR filter. + + it('implements and filter distribution with and filter', () => { + // (A & B) & (C & D) --> (A & B & C & D) + const expectedResult = andFilter(A, B, C, D); + expect( + filterEquals( + applyDistribution(andFilter(A, B), andFilter(C, D)), + expectedResult + ) + ).to.be.true; + }); + + it('implements and filter distribution with or filter', () => { + // (A & B) & (C | D) --> (A & B & C) | (A & B & D) + const expectedResult = orFilter(andFilter(A, B, C), andFilter(A, B, D)); + expect( + filterEquals( + applyDistribution(andFilter(A, B), orFilter(C, D)), + expectedResult + ) + ).to.be.true; + }); + + it('implements or filter distribution with and filter', () => { + // (A | B) & (C & D) --> (A & C & D) | (B & C & D) + const expectedResult = orFilter(andFilter(C, D, A), andFilter(C, D, B)); + expect( + filterEquals( + applyDistribution(orFilter(A, B), andFilter(C, D)), + expectedResult + ) + ).to.be.true; + }); + + it('implements or filter distribution with or filter', () => { + // (A | B) & (C | D) --> (A & C) | (A & D) | (B & C) | (B & D) + const expectedResult = orFilter( + andFilter(A, C), + andFilter(A, D), + andFilter(B, C), + andFilter(B, D) + ); + expect( + filterEquals( + applyDistribution(orFilter(A, B), orFilter(C, D)), + expectedResult + ) + ).to.be.true; + }); + + it('implements field filter compute DNF', () => { + expect(computeDistributedNormalForm(A)).to.deep.equal(A); + expect(getDnfTerms(andFilter(A))).to.deep.equal([A]); + expect(getDnfTerms(orFilter(A))).to.deep.equal([A]); + }); + + it('implements compute dnf flat AND filter', () => { + const compositeFilter = andFilter(A, B, C); + expect(computeDistributedNormalForm(compositeFilter)).to.deep.equal( + compositeFilter + ); + expect(getDnfTerms(compositeFilter)).to.deep.equal([compositeFilter]); + }); + + it('implements compute dnf flat OR filter', () => { + const compositeFilter = orFilter(A, B, C); + expect(computeDistributedNormalForm(compositeFilter)).to.deep.equal( + compositeFilter + ); + const expectedDnfTerms = [A, B, C]; + expect(getDnfTerms(compositeFilter)).to.deep.equal(expectedDnfTerms); + }); + + it('compute DNF1', () => { + // A & (B | C) --> (A & B) | (A & C) + const compositeFilter = andFilter(A, orFilter(B, C)); + const expectedDnfTerms = [andFilter(A, B), andFilter(A, C)]; + const expectedResult = orFilter(...expectedDnfTerms); + const actualResult = computeDistributedNormalForm(compositeFilter); + expect(actualResult).to.deep.equal(expectedResult); + expect(getDnfTerms(compositeFilter)).to.deep.equal(expectedDnfTerms); + }); + + it('compute DNF2', () => { + // ((A)) & (B & C) --> A & B & C + const compositeFilter = andFilter(andFilter(andFilter(A)), andFilter(B, C)); + const expectedResult = andFilter(A, B, C); + expect(computeDistributedNormalForm(compositeFilter)).to.deep.equal( + expectedResult + ); + expect(getDnfTerms(compositeFilter)).to.deep.equal([expectedResult]); + }); + + it('compute DNF3', () => { + // A | (B & C) + const compositeFilter = orFilter(A, andFilter(B, C)); + expect(computeDistributedNormalForm(compositeFilter)).to.deep.equal( + compositeFilter + ); + const expectedDnfTerms = [A, andFilter(B, C)]; + expect(getDnfTerms(compositeFilter)).to.deep.equal(expectedDnfTerms); + }); + + it('compute DNF4', () => { + // A | (B & C) | ( ((D)) | (E | F) | (G & H) ) --> A | (B & C) | D | E | F | (G & H) + const compositeFilter = orFilter( + A, + andFilter(B, C), + orFilter(andFilter(orFilter(D)), orFilter(E, F), andFilter(G, H)) + ); + const expectedDnfTerms = [A, andFilter(B, C), D, E, F, andFilter(G, H)]; + const expectedResult = orFilter(...expectedDnfTerms); + expect(computeDistributedNormalForm(compositeFilter)).to.deep.equal( + expectedResult + ); + expect(getDnfTerms(compositeFilter)).to.deep.equal(expectedDnfTerms); + }); + + it('compute DNF5', () => { + // A & (B | C) & ( ((D)) & (E | F) & (G & H) ) + // -> A & (B | C) & D & (E | F) & G & H + // -> ((A & B) | (A & C)) & D & (E | F) & G & H + // -> ((A & B & D) | (A & C & D)) & (E|F) & G & H + // -> ((A & B & D & E) | (A & B & D & F) | (A & C & D & E) | (A & C & D & F)) & G & H + // -> ((A&B&D&E&G) | (A & B & D & F & G) | (A & C & D & E & G) | (A & C & D & F & G)) & H + // -> (A&B&D&E&G&H) | (A&B&D&F&G&H) | (A & C & D & E & G & H) | (A & C & D & F & G & H) + const compositeFilter = andFilter( + A, + orFilter(B, C), + andFilter(andFilter(orFilter(D)), orFilter(E, F), andFilter(G, H)) + ); + const expectedDnfTerms = [ + andFilter(D, E, G, H, A, B), + andFilter(D, F, G, H, A, B), + andFilter(D, E, G, H, A, C), + andFilter(D, F, G, H, A, C) + ]; + const expectedResult = orFilter(...expectedDnfTerms); + expect(computeDistributedNormalForm(compositeFilter)).to.deep.equal( + expectedResult + ); + expect(getDnfTerms(compositeFilter)).to.deep.equal(expectedDnfTerms); + }); + + it('compute DNF6', () => { + // A & (B | (C & (D | (E & F)))) + // -> A & (B | (C & D) | (C & E & F)) + // -> (A & B) | (A & C & D) | (A & C & E & F) + const compositeFilter = andFilter( + A, + orFilter(B, andFilter(C, orFilter(D, andFilter(E, F)))) + ); + const expectedDnfTerms = [ + andFilter(A, B), + andFilter(C, D, A), + andFilter(E, F, C, A) + ]; + const expectedResult = orFilter(...expectedDnfTerms); + expect(computeDistributedNormalForm(compositeFilter)).to.deep.equal( + expectedResult + ); + expect(getDnfTerms(compositeFilter)).to.deep.equal(expectedDnfTerms); + }); + + it('compute DNF7', () => { + // ( (A|B) & (C|D) ) | ( (E|F) & (G|H) ) + // -> (A&C)|(A&D)|(B&C)(B&D)|(E&G)|(E&H)|(F&G)|(F&H) + const compositeFilter = orFilter( + andFilter(orFilter(A, B), orFilter(C, D)), + andFilter(orFilter(E, F), orFilter(G, H)) + ); + const expectedDnfTerms = [ + andFilter(A, C), + andFilter(A, D), + andFilter(B, C), + andFilter(B, D), + andFilter(E, G), + andFilter(E, H), + andFilter(F, G), + andFilter(F, H) + ]; + const expectedResult = orFilter(...expectedDnfTerms); + expect(computeDistributedNormalForm(compositeFilter)).to.deep.equal( + expectedResult + ); + expect(getDnfTerms(compositeFilter)).to.deep.equal(expectedDnfTerms); + }); + + it('compute DNF8', () => { + // ( (A&B) | (C&D) ) & ( (E&F) | (G&H) ) + // -> A&B&E&F | A&B&G&H | C&D&E&F | C&D&G&H + const compositeFilter = andFilter( + orFilter(andFilter(A, B), andFilter(C, D)), + orFilter(andFilter(E, F), andFilter(G, H)) + ); + const expectedDnfTerms = [ + andFilter(E, F, A, B), + andFilter(G, H, A, B), + andFilter(E, F, C, D), + andFilter(G, H, C, D) + ]; + const expectedResult = orFilter(...expectedDnfTerms); + expect(computeDistributedNormalForm(compositeFilter)).to.deep.equal( + expectedResult + ); + expect(getDnfTerms(compositeFilter)).to.deep.equal(expectedDnfTerms); + }); +}); diff --git a/packages/firestore/test/util/helpers.ts b/packages/firestore/test/util/helpers.ts index de06453f685..24cb7bccf0d 100644 --- a/packages/firestore/test/util/helpers.ts +++ b/packages/firestore/test/util/helpers.ts @@ -18,8 +18,17 @@ import { expect } from 'chai'; import { Bytes, DocumentReference, Timestamp } from '../../src'; +import { Bound } from '../../src/core/bound'; import { BundledDocuments } from '../../src/core/bundle'; import { DatabaseId } from '../../src/core/database_info'; +import { + FieldFilter, + CompositeFilter, + Filter, + Operator, + CompositeOperator +} from '../../src/core/filter'; +import { Direction, OrderBy } from '../../src/core/order_by'; import { newQueryForPath, Query, @@ -28,14 +37,6 @@ import { queryWithAddedOrderBy } from '../../src/core/query'; import { SnapshotVersion } from '../../src/core/snapshot_version'; -import { - Bound, - Direction, - FieldFilter, - Filter, - Operator, - OrderBy -} from '../../src/core/target'; import { TargetId } from '../../src/core/types'; import { AddedLimboDocument, @@ -262,6 +263,14 @@ export function filter(path: string, op: string, value: unknown): FieldFilter { return FieldFilter.create(field(path), operator, dataValue); } +export function andFilter(...filters: Filter[]): CompositeFilter { + return CompositeFilter.create(filters, CompositeOperator.AND); +} + +export function orFilter(...filters: Filter[]): CompositeFilter { + return CompositeFilter.create(filters, CompositeOperator.OR); +} + export function setMutation( keyStr: string, json: JsonObject