Skip to content

Port Filter/Target changes #5929

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 5 commits into from
Jan 31, 2022
Merged
Show file tree
Hide file tree
Changes from all 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 packages/firestore/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export {
QueryConstraintType,
OrderByDirection,
WhereFilterOp
} from './api/query';
} from './api/filter';

export { Unsubscribe, SnapshotListenOptions } from './api/reference_impl';

Expand Down
4 changes: 4 additions & 0 deletions packages/firestore/src/core/database_info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export class DatabaseId {
this.database = database ? database : DEFAULT_DATABASE_NAME;
}

static empty(): DatabaseId {
return new DatabaseId('', '');
}

get isDefaultDatabase(): boolean {
return this.database === DEFAULT_DATABASE_NAME;
}
Expand Down
264 changes: 262 additions & 2 deletions packages/firestore/src/core/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,26 @@

import { Document } from '../model/document';
import { DocumentKey } from '../model/document_key';
import {
FieldIndex,
fieldIndexGetArraySegment,
fieldIndexGetDirectionalSegments
} from '../model/field_index';
import { FieldPath, ResourcePath } from '../model/path';
import {
arrayValueContains,
canonicalId,
isArray,
isReferenceValue,
MAX_VALUE,
MIN_VALUE,
typeOrder,
valueCompare,
valueEquals
valueEquals,
valuesGetLowerBound,
valuesGetUpperBound,
valuesMax,
valuesMin
} from '../model/values';
import { Value as ProtoValue } from '../protos/firestore_proto_api';
import { debugAssert, debugCast, fail } from '../util/assert';
Expand Down Expand Up @@ -187,14 +198,262 @@ export function targetEquals(left: Target, right: Target): boolean {
return boundEquals(left.endAt, right.endAt);
}

export function isDocumentTarget(target: Target): boolean {
export function targetIsDocumentTarget(target: Target): boolean {
return (
DocumentKey.isDocumentKey(target.path) &&
target.collectionGroup === null &&
target.filters.length === 0
);
}

/** Returns the field filters that target the given field path. */
export function targetGetFieldFiltersForPath(
target: Target,
path: FieldPath
): FieldFilter[] {
return target.filters.filter(
f => f instanceof FieldFilter && f.field.isEqual(path)
) as FieldFilter[];
}

/**
* Returns the values that are used in ARRAY_CONTAINS or ARRAY_CONTAINS_ANY
* filters. Returns `null` if there are no such filters.
*/
export function targetGetArrayValues(
target: Target,
fieldIndex: FieldIndex
): ProtoValue[] | null {
const segment = fieldIndexGetArraySegment(fieldIndex);
if (segment === undefined) {
return null;
}

for (const fieldFilter of targetGetFieldFiltersForPath(
target,
segment.fieldPath
)) {
switch (fieldFilter.op) {
case Operator.ARRAY_CONTAINS_ANY:
return fieldFilter.value.arrayValue!.values || [];
case Operator.ARRAY_CONTAINS:
return [fieldFilter.value];
default:
// Remaining filters are not array filters.
}
}
return null;
}

/**
* Returns the list of values that are used in != or NOT_IN filters. Returns
* `null` if there are no such filters.
*/
export function targetGetNotInValues(
target: Target,
fieldIndex: FieldIndex
): ProtoValue[] | null {
const values: ProtoValue[] = [];

for (const segment of fieldIndexGetDirectionalSegments(fieldIndex)) {
for (const fieldFilter of targetGetFieldFiltersForPath(
target,
segment.fieldPath
)) {
switch (fieldFilter.op) {
case Operator.EQUAL:
case Operator.IN:
// Encode equality prefix, which is encoded in the index value before
// the inequality (e.g. `a == 'a' && b != 'b'` is encoded to
// `value != 'ab'`).
values.push(fieldFilter.value);
break;
case Operator.NOT_IN:
case Operator.NOT_EQUAL:
// NotIn/NotEqual is always a suffix. There cannot be any remaining
// segments and hence we can return early here.
values.push(fieldFilter.value);
return values;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it guaranteed that EQUAL and IN come before NOT_IN and NOT_EQUAL during these iterations?
I know this is the same way the code is written in the Android SDK. I'm just curious where does this guarantee come from? and I'm thinking whether this will continue to work for sub-targets that we create for dnf terms in the future.

No action needed for this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to think about this a bit when I ported (and also recently added a unit test for it - https://github.com/firebase/firebase-android-sdk/pull/3351/files#diff-216e85d9cb7dc6a9ca6b6bf1ae2d9e7751d20a13802e9a7c97a8f517b0bff67cR128). The index encoding order is not specified by the query but rather the index. The order that the values is encoded in is specific to the index.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will try to improve the comment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Thanks

default:
// Remaining filters cannot be used as notIn bounds.
}
}
}

return null;
}

/**
* Returns a lower bound of field values that can be used as a starting point to
* scan the index defined by `fieldIndex`. Returns `null` if no lower bound
* exists.
*/
export function targetGetLowerBound(
target: Target,
fieldIndex: FieldIndex
): Bound | null {
const values: ProtoValue[] = [];
let inclusive = true;

// For each segment, retrieve a lower bound if there is a suitable filter or
// startAt.
for (const segment of fieldIndexGetDirectionalSegments(fieldIndex)) {
let segmentValue: ProtoValue | undefined = undefined;
let segmentInclusive = true;

// Process all filters to find a value for the current field segment
for (const fieldFilter of targetGetFieldFiltersForPath(
target,
segment.fieldPath
)) {
let filterValue: ProtoValue | undefined = undefined;
let filterInclusive = true;

switch (fieldFilter.op) {
case Operator.LESS_THAN:
case Operator.LESS_THAN_OR_EQUAL:
filterValue = valuesGetLowerBound(fieldFilter.value);
break;
case Operator.EQUAL:
case Operator.IN:
case Operator.GREATER_THAN_OR_EQUAL:
filterValue = fieldFilter.value;
break;
case Operator.GREATER_THAN:
filterValue = fieldFilter.value;
filterInclusive = false;
break;
case Operator.NOT_EQUAL:
filterValue = MIN_VALUE;
break;
case Operator.NOT_IN:
const length = (fieldFilter.value.arrayValue!.values || []).length;
filterValue = {
arrayValue: { values: new Array(length).fill(MIN_VALUE) }
};
break;
default:
// Remaining filters cannot be used as lower bounds.
}

if (valuesMax(segmentValue, filterValue) === filterValue) {
segmentValue = filterValue;
segmentInclusive = filterInclusive;
}
}

// If there is a startAt bound, compare the values against the existing
// boundary to see if we can narrow the scope.
if (target.startAt !== null) {
for (let i = 0; i < target.orderBy.length; ++i) {
const orderBy = target.orderBy[i];
if (orderBy.field.isEqual(segment.fieldPath)) {
const cursorValue = target.startAt.position[i];
if (valuesMax(segmentValue, cursorValue) === cursorValue) {
segmentValue = cursorValue;
segmentInclusive = !target.startAt.before;
}
break;
}
}
}

if (segmentValue === undefined) {
// No lower bound exists
return null;
}
values.push(segmentValue);
inclusive &&= segmentInclusive;
}
return new Bound(values, !inclusive);
}
/**
* Returns an upper bound of field values that can be used as an ending point
* when scanning the index defined by `fieldIndex`. Returns `null` if no
* upper bound exists.
*/
export function targetGetUpperBound(
target: Target,
fieldIndex: FieldIndex
): Bound | null {
const values: ProtoValue[] = [];
let inclusive = true;

// For each segment, retrieve an upper bound if there is a suitable filter or
// endAt.
for (const segment of fieldIndexGetDirectionalSegments(fieldIndex)) {
let segmentValue: ProtoValue | undefined = undefined;
let segmentInclusive = true;

// Process all filters to find a value for the current field segment
for (const fieldFilter of targetGetFieldFiltersForPath(
target,
segment.fieldPath
)) {
let filterValue: ProtoValue | undefined = undefined;
let filterInclusive = true;

switch (fieldFilter.op) {
case Operator.GREATER_THAN_OR_EQUAL:
case Operator.GREATER_THAN:
filterValue = valuesGetUpperBound(fieldFilter.value);
filterInclusive = false;
break;
case Operator.EQUAL:
case Operator.IN:
case Operator.LESS_THAN_OR_EQUAL:
filterValue = fieldFilter.value;
break;
case Operator.LESS_THAN:
filterValue = fieldFilter.value;
filterInclusive = false;
break;
case Operator.NOT_EQUAL:
filterValue = MAX_VALUE;
break;
case Operator.NOT_IN:
const length = (fieldFilter.value.arrayValue!.values || []).length;
filterValue = {
arrayValue: { values: new Array(length).fill(MIN_VALUE) }
};
break;
default:
// Remaining filters cannot be used as upper bounds.
}

if (valuesMin(segmentValue, filterValue) === filterValue) {
segmentValue = filterValue;
segmentInclusive = filterInclusive;
}
}

// If there is a endAt bound, compare the values against the existing
// boundary to see if we can narrow the scope.
if (target.endAt !== null) {
for (let i = 0; i < target.orderBy.length; ++i) {
const orderBy = target.orderBy[i];
if (orderBy.field.isEqual(segment.fieldPath)) {
const cursorValue = target.endAt.position[i];
if (valuesMin(segmentValue, cursorValue) === cursorValue) {
segmentValue = cursorValue;
segmentInclusive = !target.endAt.before;
}
break;
}
}
}

if (segmentValue === undefined) {
// No lower bound exists
return null;
}
values.push(segmentValue);
inclusive &&= segmentInclusive;
}

return new Bound(values, !inclusive);
}

export abstract class Filter {
abstract matches(doc: Document): boolean;
}
Expand Down Expand Up @@ -513,6 +772,7 @@ export class ArrayContainsAnyFilter extends FieldFilter {
}
}

// TODO(indexing): Change Bound.before to "inclusive"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. I found inclusive/exclusive terminology easier to understand than before/!before.

/**
* Represents a bound of a query.
*
Expand Down
4 changes: 2 additions & 2 deletions packages/firestore/src/local/local_serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Timestamp } from '../api/timestamp';
import { BundleMetadata, NamedQuery } from '../core/bundle';
import { LimitType, Query, queryWithLimit } from '../core/query';
import { SnapshotVersion } from '../core/snapshot_version';
import { canonifyTarget, isDocumentTarget, Target } from '../core/target';
import { canonifyTarget, targetIsDocumentTarget, Target } from '../core/target';
import { MutableDocument } from '../model/document';
import { DocumentKey } from '../model/document_key';
import { MutationBatch } from '../model/mutation_batch';
Expand Down Expand Up @@ -276,7 +276,7 @@ export function toDbTarget(
targetData.lastLimboFreeSnapshotVersion
);
let queryProto: DbQuery;
if (isDocumentTarget(targetData.target)) {
if (targetIsDocumentTarget(targetData.target)) {
queryProto = toDocumentsTarget(
localSerializer.remoteSerializer,
targetData.target
Expand Down
14 changes: 14 additions & 0 deletions packages/firestore/src/model/field_index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ export class FieldIndex {
) {}
}

/** Returns the ArrayContains/ArrayContainsAny segment for this index. */
export function fieldIndexGetArraySegment(
fieldIndex: FieldIndex
): IndexSegment | undefined {
return fieldIndex.segments.find(s => s.kind === IndexKind.CONTAINS);
}

/** Returns all directional (ascending/descending) segments for this index. */
export function fieldIndexGetDirectionalSegments(
fieldIndex: FieldIndex
): IndexSegment[] {
return fieldIndex.segments.filter(s => s.kind !== IndexKind.CONTAINS);
}

/**
* Compares indexes by collection group and segments. Ignores update time and
* index ID.
Expand Down
Loading