Skip to content

Commit c15333a

Browse files
Markduckworth/or queries pr 4 (#6527)
* Add composite filters. Port of firebase/firebase-android-sdk#3290 * Support encoding and decoding Composite Filters * Perform DNF transform * Use client-side index if we have composite filters * Update the orderBy filtering mechanism for OR queries and steps to serve OR queries from client-side index. * Update protos with OR operator. * Add compile.sh to compile proto updates. * Update stringifyFilter to support CompositeFilters
1 parent bf21b93 commit c15333a

File tree

24 files changed

+1784
-80
lines changed

24 files changed

+1784
-80
lines changed

common/api-review/firestore.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export type AddPrefixToKeys<Prefix extends string, T extends Record<string, unkn
1717
[K in keyof T & string as `${Prefix}.${K}`]+?: T[K];
1818
};
1919

20+
// @public
21+
export function and(...queryConstraints: QueryConstraint[]): QueryConstraint;
22+
2023
// @public
2124
export function arrayRemove(...elements: unknown[]): FieldValue;
2225

@@ -324,6 +327,9 @@ export function onSnapshotsInSync(firestore: Firestore, observer: {
324327
// @public
325328
export function onSnapshotsInSync(firestore: Firestore, onSync: () => void): Unsubscribe;
326329

330+
// @public
331+
export function or(...queryConstraints: QueryConstraint[]): QueryConstraint;
332+
327333
// @public
328334
export function orderBy(fieldPath: string | FieldPath, directionStr?: OrderByDirection): QueryConstraint;
329335

packages/firestore/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"test:all:ci": "run-p test:browser test:lite:browser test:travis",
3131
"test:all": "run-p test:browser test:lite:browser test:travis test:minified",
3232
"test:browser": "karma start --single-run",
33+
"test:browser:emulator": "karma start --single-run --local",
3334
"test:browser:unit": "karma start --single-run --unit",
3435
"test:browser:debug": "karma start --browsers=Chrome --auto-watch",
3536
"test:node": "node ./scripts/run-tests.js --main=test/register.ts --emulator 'test/{,!(browser|lite)/**/}*.test.ts'",

packages/firestore/src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,15 @@ export {
7272
} from './api/reference';
7373

7474
export {
75+
and,
7576
endAt,
7677
endBefore,
7778
startAt,
7879
startAfter,
7980
limit,
8081
limitToLast,
8182
where,
83+
or,
8284
orderBy,
8385
query,
8486
QueryConstraint,

packages/firestore/src/api/filter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
*/
1717

1818
export {
19+
and,
1920
endAt,
2021
endBefore,
2122
startAfter,
2223
startAt,
2324
limitToLast,
2425
limit,
26+
or,
2527
orderBy,
2628
OrderByDirection,
2729
where,

packages/firestore/src/core/query.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,13 @@ function queryMatchesPathAndCollectionGroup(
468468
* in the results.
469469
*/
470470
function queryMatchesOrderBy(query: Query, doc: Document): boolean {
471-
for (const orderBy of query.explicitOrderBy) {
471+
// We must use `queryOrderBy()` to get the list of all orderBys (both implicit and explicit).
472+
// Note that for OR queries, orderBy applies to all disjunction terms and implicit orderBys must
473+
// be taken into account. For example, the query "a > 1 || b==1" has an implicit "orderBy a" due
474+
// to the inequality, and is evaluated as "a > 1 orderBy a || b==1 orderBy a".
475+
// A document with content of {b:1} matches the filters, but does not match the orderBy because
476+
// it's missing the field 'a'.
477+
for (const orderBy of queryOrderBy(query)) {
472478
// order by key always matches
473479
if (!orderBy.field.isKeyField() && doc.data.field(orderBy.field) === null) {
474480
return false;

packages/firestore/src/core/target.ts

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -500,26 +500,25 @@ export function targetGetSegmentCount(target: Target): number {
500500
let hasArraySegment = false;
501501

502502
for (const filter of target.filters) {
503-
// TODO(orquery): Use the flattened filters here
504-
const fieldFilter = filter as FieldFilter;
505-
506-
// __name__ is not an explicit segment of any index, so we don't need to
507-
// count it.
508-
if (fieldFilter.field.isKeyField()) {
509-
continue;
510-
}
503+
for (const subFilter of filter.getFlattenedFilters()) {
504+
// __name__ is not an explicit segment of any index, so we don't need to
505+
// count it.
506+
if (subFilter.field.isKeyField()) {
507+
continue;
508+
}
511509

512-
// ARRAY_CONTAINS or ARRAY_CONTAINS_ANY filters must be counted separately.
513-
// For instance, it is possible to have an index for "a ARRAY a ASC". Even
514-
// though these are on the same field, they should be counted as two
515-
// separate segments in an index.
516-
if (
517-
fieldFilter.op === Operator.ARRAY_CONTAINS ||
518-
fieldFilter.op === Operator.ARRAY_CONTAINS_ANY
519-
) {
520-
hasArraySegment = true;
521-
} else {
522-
fields = fields.add(fieldFilter.field);
510+
// ARRAY_CONTAINS or ARRAY_CONTAINS_ANY filters must be counted separately.
511+
// For instance, it is possible to have an index for "a ARRAY a ASC". Even
512+
// though these are on the same field, they should be counted as two
513+
// separate segments in an index.
514+
if (
515+
subFilter.op === Operator.ARRAY_CONTAINS ||
516+
subFilter.op === Operator.ARRAY_CONTAINS_ANY
517+
) {
518+
hasArraySegment = true;
519+
} else {
520+
fields = fields.add(subFilter.field);
521+
}
523522
}
524523
}
525524

@@ -534,12 +533,16 @@ export function targetGetSegmentCount(target: Target): number {
534533
return fields.size + (hasArraySegment ? 1 : 0);
535534
}
536535

536+
export function targetHasLimit(target: Target): boolean {
537+
return target.limit !== null;
538+
}
539+
537540
export abstract class Filter {
538541
abstract matches(doc: Document): boolean;
539542

540543
abstract getFlattenedFilters(): readonly FieldFilter[];
541544

542-
abstract getFilters(): readonly Filter[];
545+
abstract getFilters(): Filter[];
543546

544547
abstract getFirstInequalityField(): FieldPath | null;
545548
}
@@ -702,7 +705,7 @@ export class FieldFilter extends Filter {
702705
return [this];
703706
}
704707

705-
getFilters(): readonly Filter[] {
708+
getFilters(): Filter[] {
706709
return [this];
707710
}
708711

@@ -753,8 +756,9 @@ export class CompositeFilter extends Filter {
753756
return this.memoizedFlattenedFilters;
754757
}
755758

756-
getFilters(): readonly Filter[] {
757-
return this.filters;
759+
// Returns a mutable copy of `this.filters`
760+
getFilters(): Filter[] {
761+
return Object.assign([], this.filters);
758762
}
759763

760764
getFirstInequalityField(): FieldPath | null {
@@ -782,12 +786,31 @@ export class CompositeFilter extends Filter {
782786
}
783787
}
784788

789+
/**
790+
* Returns a new composite filter that contains all filter from
791+
* `compositeFilter` plus all the given filters in `otherFilters`.
792+
* TODO(orquery) move compositeFilterWithAddedFilters to filter.ts in future refactor
793+
*/
794+
export function compositeFilterWithAddedFilters(
795+
compositeFilter: CompositeFilter,
796+
otherFilters: Filter[]
797+
): CompositeFilter {
798+
const mergedFilters = compositeFilter.filters.concat(otherFilters);
799+
return CompositeFilter.create(mergedFilters, compositeFilter.op);
800+
}
801+
785802
export function compositeFilterIsConjunction(
786803
compositeFilter: CompositeFilter
787804
): boolean {
788805
return compositeFilter.op === CompositeOperator.AND;
789806
}
790807

808+
export function compositeFilterIsDisjunction(
809+
compositeFilter: CompositeFilter
810+
): boolean {
811+
return compositeFilter.op === CompositeOperator.OR;
812+
}
813+
791814
/**
792815
* Returns true if this filter is a conjunction of field filters only. Returns false otherwise.
793816
*/
@@ -881,9 +904,28 @@ export function compositeFilterEquals(
881904
/** Returns a debug description for `filter`. */
882905
export function stringifyFilter(filter: Filter): string {
883906
debugAssert(
884-
filter instanceof FieldFilter,
885-
'stringifyFilter() only supports FieldFilters'
907+
filter instanceof FieldFilter || filter instanceof CompositeFilter,
908+
'stringifyFilter() only supports FieldFilters and CompositeFilters'
886909
);
910+
if (filter instanceof FieldFilter) {
911+
return stringifyFieldFilter(filter);
912+
} else if (filter instanceof CompositeFilter) {
913+
return stringifyCompositeFilter(filter);
914+
} else {
915+
return 'Filter';
916+
}
917+
}
918+
919+
export function stringifyCompositeFilter(filter: CompositeFilter): string {
920+
return (
921+
filter.op.toString() +
922+
` {` +
923+
filter.getFilters().map(stringifyFilter).join(' ,') +
924+
'}'
925+
);
926+
}
927+
928+
export function stringifyFieldFilter(filter: FieldFilter): string {
887929
return `${filter.field.canonicalString()} ${filter.op} ${canonicalId(
888930
filter.value
889931
)}`;

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -994,7 +994,10 @@ export function validateQueryFilterConstraint(
994994
functionName: string,
995995
queryConstraint: QueryConstraint
996996
): void {
997-
if (!(queryConstraint instanceof QueryFilterConstraint)) {
997+
if (
998+
!(queryConstraint instanceof QueryFilterConstraint) &&
999+
!(queryConstraint instanceof QueryCompositeFilterConstraint)
1000+
) {
9981001
throw new FirestoreError(
9991002
Code.INVALID_ARGUMENT,
10001003
`Function ${functionName}() requires QueryContraints created with a call to 'where(...)'.`

packages/firestore/src/local/indexeddb_index_manager.ts

Lines changed: 58 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,20 @@ import { DatabaseId } from '../core/database_info';
2020
import {
2121
Bound,
2222
canonifyTarget,
23+
CompositeFilter,
24+
CompositeOperator,
2325
FieldFilter,
26+
Filter,
27+
newTarget,
2428
Operator,
2529
Target,
2630
targetEquals,
2731
targetGetArrayValues,
2832
targetGetLowerBound,
2933
targetGetNotInValues,
3034
targetGetSegmentCount,
31-
targetGetUpperBound
35+
targetGetUpperBound,
36+
targetHasLimit
3237
} from '../core/target';
3338
import { FirestoreIndexValueWriter } from '../index/firestore_index_value_writer';
3439
import { IndexByteEncoder } from '../index/index_byte_encoder';
@@ -53,6 +58,7 @@ import { isArray, refValue } from '../model/values';
5358
import { Value as ProtoValue } from '../protos/firestore_proto_api';
5459
import { debugAssert, fail, hardAssert } from '../util/assert';
5560
import { logDebug } from '../util/log';
61+
import { getDnfTerms } from '../util/logic_utils';
5662
import { immediateSuccessor, primitiveComparator } from '../util/misc';
5763
import { ObjectMap } from '../util/obj_map';
5864
import { diffSortedSets, SortedSet } from '../util/sorted_set';
@@ -333,8 +339,28 @@ export class IndexedDbIndexManager implements IndexManager {
333339
if (subTargets) {
334340
return subTargets;
335341
}
336-
// TODO(orquery): Implement DNF transform
337-
subTargets = [target];
342+
343+
if (target.filters.length === 0) {
344+
subTargets = [target];
345+
} else {
346+
// There is an implicit AND operation between all the filters stored in the target
347+
const dnf: Filter[] = getDnfTerms(
348+
CompositeFilter.create(target.filters, CompositeOperator.AND)
349+
);
350+
351+
subTargets = dnf.map(term =>
352+
newTarget(
353+
target.path,
354+
target.collectionGroup,
355+
target.orderBy,
356+
term.getFilters(),
357+
target.limit,
358+
target.startAt,
359+
target.endAt
360+
)
361+
);
362+
}
363+
338364
this.targetToDnfSubTargets.set(target, subTargets);
339365
return subTargets;
340366
}
@@ -459,21 +485,32 @@ export class IndexedDbIndexManager implements IndexManager {
459485
target: Target
460486
): PersistencePromise<IndexType> {
461487
let indexType = IndexType.FULL;
462-
return PersistencePromise.forEach(
463-
this.getSubTargets(target),
464-
(target: Target) => {
465-
return this.getFieldIndex(transaction, target).next(index => {
466-
if (!index) {
467-
indexType = IndexType.NONE;
468-
} else if (
469-
indexType !== IndexType.NONE &&
470-
index.fields.length < targetGetSegmentCount(target)
471-
) {
472-
indexType = IndexType.PARTIAL;
473-
}
474-
});
488+
const subTargets = this.getSubTargets(target);
489+
return PersistencePromise.forEach(subTargets, (target: Target) => {
490+
return this.getFieldIndex(transaction, target).next(index => {
491+
if (!index) {
492+
indexType = IndexType.NONE;
493+
} else if (
494+
indexType !== IndexType.NONE &&
495+
index.fields.length < targetGetSegmentCount(target)
496+
) {
497+
indexType = IndexType.PARTIAL;
498+
}
499+
});
500+
}).next(() => {
501+
// OR queries have more than one sub-target (one sub-target per DNF term). We currently consider
502+
// OR queries that have a `limit` to have a partial index. For such queries we perform sorting
503+
// and apply the limit in memory as a post-processing step.
504+
if (
505+
targetHasLimit(target) &&
506+
subTargets.length > 1 &&
507+
indexType === IndexType.FULL
508+
) {
509+
return IndexType.PARTIAL;
475510
}
476-
).next(() => indexType);
511+
512+
return indexType;
513+
});
477514
}
478515

479516
/**
@@ -533,18 +570,18 @@ export class IndexedDbIndexManager implements IndexManager {
533570
private encodeValues(
534571
fieldIndex: FieldIndex,
535572
target: Target,
536-
bound: ProtoValue[] | null
573+
values: ProtoValue[] | null
537574
): Uint8Array[] {
538-
if (bound === null) {
575+
if (values === null) {
539576
return [];
540577
}
541578

542579
let encoders: IndexByteEncoder[] = [];
543580
encoders.push(new IndexByteEncoder());
544581

545-
let boundIdx = 0;
582+
let valueIdx = 0;
546583
for (const segment of fieldIndexGetDirectionalSegments(fieldIndex)) {
547-
const value = bound[boundIdx++];
584+
const value = values[valueIdx++];
548585
for (const encoder of encoders) {
549586
if (this.isInFilter(target, segment.fieldPath) && isArray(value)) {
550587
encoders = this.expandIndexValues(encoders, segment, value);

packages/firestore/src/local/query_engine.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
LimitType,
2020
newQueryComparator,
2121
Query,
22-
queryContainsCompositeFilters,
2322
queryMatches,
2423
queryMatchesAllDocuments,
2524
queryToTarget,
@@ -134,10 +133,7 @@ export class QueryEngine {
134133
transaction: PersistenceTransaction,
135134
query: Query
136135
): PersistencePromise<DocumentMap | null> {
137-
138-
if (
139-
queryMatchesAllDocuments(query)
140-
) {
136+
if (queryMatchesAllDocuments(query)) {
141137
// Queries that match all documents don't benefit from using
142138
// key-based lookups. It is more efficient to scan all documents in a
143139
// collection, rather than to perform individual lookups.
@@ -226,10 +222,7 @@ export class QueryEngine {
226222
remoteKeys: DocumentKeySet,
227223
lastLimboFreeSnapshotVersion: SnapshotVersion
228224
): PersistencePromise<DocumentMap> {
229-
if (
230-
queryMatchesAllDocuments(query) ||
231-
queryContainsCompositeFilters(query)
232-
) {
225+
if (queryMatchesAllDocuments(query)) {
233226
// Queries that match all documents don't benefit from using
234227
// key-based lookups. It is more efficient to scan all documents in a
235228
// collection, rather than to perform individual lookups.

0 commit comments

Comments
 (0)