Skip to content

Add composite filters in support of OR queries #6385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 26, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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