Skip to content

Commit

Permalink
Add composite filters in support of OR queries (#6385)
Browse files Browse the repository at this point in the history
Add composite filters. Port of firebase/firebase-android-sdk#3290
  • Loading branch information
MarkDuckworth authored Jul 26, 2022
1 parent 8260149 commit a722abc
Show file tree
Hide file tree
Showing 6 changed files with 316 additions and 72 deletions.
2 changes: 1 addition & 1 deletion common/api-review/firestore-lite.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ export abstract class QueryConstraint {
}

// @public
export type QueryConstraintType = 'where' | 'orderBy' | 'limit' | 'limitToLast' | 'startAt' | 'startAfter' | 'endAt' | 'endBefore';
export type QueryConstraintType = 'where' | 'orderBy' | 'limit' | 'limitToLast' | 'startAt' | 'startAfter' | 'endAt' | 'endBefore' | 'and' | 'or';

// @public
export class QueryDocumentSnapshot<T = DocumentData> extends DocumentSnapshot<T> {
Expand Down
2 changes: 1 addition & 1 deletion common/api-review/firestore.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ export abstract class QueryConstraint {
}

// @public
export type QueryConstraintType = 'where' | 'orderBy' | 'limit' | 'limitToLast' | 'startAt' | 'startAfter' | 'endAt' | 'endBefore';
export type QueryConstraintType = 'where' | 'orderBy' | 'limit' | 'limitToLast' | 'startAt' | 'startAfter' | 'endAt' | 'endBefore' | 'and' | 'or';

// @public
export class QueryDocumentSnapshot<T = DocumentData> extends DocumentSnapshot<T> {
Expand Down
50 changes: 18 additions & 32 deletions packages/firestore/src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,15 @@ import {
Bound,
canonifyTarget,
Direction,
FieldFilter,
Filter,
newTarget,
Operator,
OrderBy,
boundSortsBeforeDocument,
stringifyTarget,
Target,
targetEquals,
boundSortsAfterDocument
boundSortsAfterDocument,
CompositeFilter
} from './target';

export const enum LimitType {
Expand Down Expand Up @@ -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
Expand All @@ -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;
}

Expand Down Expand Up @@ -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.'
);

Expand Down
134 changes: 121 additions & 13 deletions packages/firestore/src/core/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,12 @@ export function targetGetSegmentCount(target: Target): number {

export abstract class Filter {
abstract matches(doc: Document): boolean;

abstract getFlattenedFilters(): readonly FieldFilter[];

abstract getFilters(): readonly Filter[];

abstract getFirstInequalityField(): FieldPath | null;
}

export const enum Operator {
Expand All @@ -551,6 +557,11 @@ export const enum Operator {
ARRAY_CONTAINS_ANY = 'array-contains-any'
}

export const enum CompositeOperator {
OR = 'or',
AND = 'and'
}

/**
* The direction of sorting in an order by.
*/
Expand All @@ -559,11 +570,12 @@ export const enum Direction {
DESCENDING = 'desc'
}

// TODO(orquery) move Filter classes to a new file, e.g. filter.ts
export class FieldFilter extends Filter {
protected constructor(
public field: FieldPath,
public op: Operator,
public value: ProtoValue
public readonly field: FieldPath,
public readonly op: Operator,
public readonly value: ProtoValue
) {
super();
}
Expand Down Expand Up @@ -685,21 +697,117 @@ export class FieldFilter extends Filter {
].indexOf(this.op) >= 0
);
}

getFlattenedFilters(): readonly FieldFilter[] {
return [this];
}

getFilters(): readonly 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 (this.isConjunction()) {
// 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;
}

getFilters(): readonly Filter[] {
return 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;
}

isConjunction(): boolean {
return this.op === CompositeOperator.AND;
}
}

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)
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 {
Expand Down
Loading

0 comments on commit a722abc

Please sign in to comment.