Skip to content

Support encoding and decoding Composite Filters #6402

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
Show file tree
Hide file tree
Changes from 6 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
69 changes: 62 additions & 7 deletions packages/firestore/src/core/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -732,7 +732,7 @@ export class CompositeFilter extends Filter {
}

matches(doc: Document): boolean {
if (this.isConjunction()) {
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 {
Expand Down Expand Up @@ -780,10 +780,38 @@ export class CompositeFilter extends Filter {

return null;
}
}

export function compositeFilterIsConjunction(
compositeFilter: CompositeFilter
): boolean {
return compositeFilter.op === CompositeOperator.AND;
}

/**
* 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)
);
}

isConjunction(): boolean {
return this.op === CompositeOperator.AND;
/**
* 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 {
Expand Down Expand Up @@ -811,18 +839,45 @@ export function canonifyFilter(filter: Filter): string {
}

export function filterEquals(f1: Filter, f2: Filter): boolean {
debugAssert(
f1 instanceof FieldFilter && f2 instanceof FieldFilter,
'Only FieldFilters can be compared'
);
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 debug description for `filter`. */
export function stringifyFilter(filter: Filter): string {
debugAssert(
Expand Down
135 changes: 103 additions & 32 deletions packages/firestore/src/remote/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ import {
Filter,
targetIsDocumentTarget,
Operator,
CompositeOperator,
OrderBy,
Target
Target,
CompositeFilter,
compositeFilterIsFlatConjunction
} from '../core/target';
import { TargetId } from '../core/types';
import { Timestamp } from '../lite-api/timestamp';
Expand Down Expand Up @@ -64,6 +67,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,
Expand Down Expand Up @@ -122,6 +126,14 @@ const OPERATORS = (() => {
return ops;
})();

const COMPOSITE_OPERATORS = (() => {
const ops: { [op: string]: ProtoCompositeFilterOp } = {};
ops[CompositeOperator.AND] = 'AND';
// TODO(orquery) change 'OPERATOR_UNSPECIFIED' to 'OR' when the updated protos are published
ops[CompositeOperator.OR] = 'OPERATOR_UNSPECIFIED';
return ops;
})();

function assertPresent(value: unknown, description: string): asserts value {
debugAssert(!isNullOrUndefined(value), description + ' is missing');
}
Expand Down Expand Up @@ -827,7 +839,7 @@ export function toQueryTarget(
result.structuredQuery!.from = [{ collectionId: path.lastSegment() }];
}

const where = toFilter(target.filters);
const where = encodeFilters(target.filters);
if (where) {
result.structuredQuery!.where = where;
}
Expand Down Expand Up @@ -873,7 +885,7 @@ export function convertQueryTargetToQuery(target: ProtoQueryTarget): Query {

let filterBy: Filter[] = [];
if (query.where) {
filterBy = fromFilter(query.where);
filterBy = decodeFilters(query.where);
}

let orderBy: OrderBy[] = [];
Expand Down Expand Up @@ -972,34 +984,38 @@ export function toTarget(
return result;
}

function toFilter(filters: Filter[]): ProtoFilter | undefined {
function encodeFilters(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 encodeFilter(CompositeFilter.create(filters, CompositeOperator.AND));
}

function decodeFilters(filter: ProtoFilter): Filter[] {
const result = decodeFilter(filter);

// Instead of a singletonList containing AND(F1, F2, ...), we can return a list containing F1,
// F2, ...
// TODO(orquery): Once proper support for composite filters has been completed, we can remove
// this flattening from here.
if (
result instanceof CompositeFilter &&
compositeFilterIsFlatConjunction(result)
) {
return Object.assign([], 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 decodeFilter(filter: ProtoFilter): Filter {
if (filter.unaryFilter !== undefined) {
return decodeUnaryFilter(filter);
} else if (filter.fieldFilter !== undefined) {
return [fromFieldFilter(filter)];
return decodeFieldFilter(filter);
} else if (filter.compositeFilter !== undefined) {
return filter.compositeFilter
.filters!.map(f => fromFilter(f))
.reduce((accum, current) => accum.concat(current));
return decodeCompositeFilter(filter);
} else {
return fail('Unknown filter: ' + JSON.stringify(filter));
}
Expand Down Expand Up @@ -1066,6 +1082,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':
Expand Down Expand Up @@ -1095,6 +1117,23 @@ export function fromOperatorName(op: ProtoFieldFilterOp): Operator {
}
}

export function fromCompositeOperatorName(
op: ProtoCompositeFilterOp
): CompositeOperator {
// TODO(orquery) support OR
switch (op) {
case 'AND':
return CompositeOperator.AND;
// TODO(orquery) update when OR operatore is supported in ProtoCompositeFilterOp
// case 'OPERATOR_UNSPECIFIED':
// return fail('Unspecified operator');
case 'OPERATOR_UNSPECIFIED':
return CompositeOperator.OR;
default:
return fail('Unknown operator');
}
}

export function toFieldPathReference(path: FieldPath): ProtoFieldReference {
return { fieldPath: path.canonicalString() };
}
Expand All @@ -1120,16 +1159,33 @@ 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 encodeFilter(filter: Filter): ProtoFilter {
if (filter instanceof FieldFilter) {
return encodeUnaryOrFieldFilter(filter);
} else if (filter instanceof CompositeFilter) {
return encodeCompositeFilter(filter);
} else {
return fail('Unrecognized filter type ' + JSON.stringify(filter));
}
}

// visible for testing
export function toUnaryOrFieldFilter(filter: FieldFilter): ProtoFilter {
export function encodeCompositeFilter(filter: CompositeFilter): ProtoFilter {
const protos = filter.getFilters().map(filter => encodeFilter(filter));

if (protos.length === 1) {
return protos[0];
}

return {
compositeFilter: {
op: toCompositeOperatorName(filter.op),
filters: protos
}
};
}

export function encodeUnaryOrFieldFilter(filter: FieldFilter): ProtoFilter {
if (filter.op === Operator.EQUAL) {
if (isNanValue(filter.value)) {
return {
Expand Down Expand Up @@ -1172,7 +1228,7 @@ export function toUnaryOrFieldFilter(filter: FieldFilter): ProtoFilter {
};
}

export function fromUnaryFilter(filter: ProtoFilter): Filter {
export function decodeUnaryFilter(filter: ProtoFilter): Filter {
switch (filter.unaryFilter!.op!) {
case 'IS_NAN':
const nanField = fromFieldPathReference(filter.unaryFilter!.field!);
Expand Down Expand Up @@ -1201,6 +1257,21 @@ export function fromUnaryFilter(filter: ProtoFilter): Filter {
}
}

export function decodeFieldFilter(filter: ProtoFilter): FieldFilter {
return FieldFilter.create(
fromFieldPathReference(filter.fieldFilter!.field!),
fromOperatorName(filter.fieldFilter!.op!),
filter.fieldFilter!.value!
);
}

export function decodeCompositeFilter(filter: ProtoFilter): CompositeFilter {
return CompositeFilter.create(
filter.compositeFilter!.filters!.map(filter => decodeFilter(filter)),
fromCompositeOperatorName(filter.compositeFilter!.op!)
);
}

export function toDocumentMask(fieldMask: FieldMask): ProtoDocumentMask {
const canonicalFields: string[] = [];
fieldMask.fields.forEach(field =>
Expand Down
1 change: 1 addition & 0 deletions packages/firestore/test/unit/core/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ describe('Query', () => {
expect(queryMatches(query1, doc5)).to.equal(true);
});

// TODO(orquery) this will be useful for testing
it('filters based on array value', () => {
const baseQuery = query('collection');
const doc1 = doc('collection/doc', 0, { tags: ['foo', 1, true] });
Expand Down
Loading