Skip to content

Fix array-contains queries #1913

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 7 commits into from
Jun 25, 2019
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/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,7 +1515,7 @@ export class Query implements firestore.Query {
value
);
}
const filter = Filter.create(fieldPath, operator, fieldValue);
const filter = FieldFilter.create(fieldPath, operator, fieldValue);
this.validateNewFilter(filter);
return new Query(this._query.addFilter(filter), this.firestore);
}
Expand Down
108 changes: 56 additions & 52 deletions packages/firestore/src/core/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -443,57 +443,6 @@ export abstract class Filter {
abstract matches(doc: Document): boolean;
abstract canonicalId(): string;
abstract isEqual(filter: Filter): boolean;

/**
* Creates a filter based on the provided arguments.
*/
static create(field: FieldPath, op: Operator, value: FieldValue): Filter {
if (field.isKeyField()) {
assert(
value instanceof RefValue,
'Comparing on key, but filter value not a RefValue'
);
assert(
op !== Operator.ARRAY_CONTAINS &&
op !== Operator.ARRAY_CONTAINS_ANY &&
op !== Operator.IN,
`'${op.toString()}' queries don't make sense on document keys.`
);
return new KeyFieldFilter(field, op, value as RefValue);
} else if (value.isEqual(NullValue.INSTANCE)) {
if (op !== Operator.EQUAL) {
throw new FirestoreError(
Code.INVALID_ARGUMENT,
'Invalid query. You can only perform equals comparisons on null.'
);
}
return new FieldFilter(field, op, value);
} else if (value.isEqual(DoubleValue.NAN)) {
if (op !== Operator.EQUAL) {
throw new FirestoreError(
Code.INVALID_ARGUMENT,
'Invalid query. You can only perform equals comparisons on NaN.'
);
}
return new FieldFilter(field, op, value);
} else if (op === Operator.ARRAY_CONTAINS) {
return new ArrayContainsFilter(field, value);
} else if (op === Operator.IN) {
assert(
value instanceof ArrayValue,
'IN filter has invalid value: ' + value.toString()
);
return new InFilter(field, value as ArrayValue);
} else if (op === Operator.ARRAY_CONTAINS_ANY) {
assert(
value instanceof ArrayValue,
'ARRAY_CONTAINS_ANY filter has invalid value: ' + value.toString()
);
return new ArrayContainsAnyFilter(field, value as ArrayValue);
} else {
return new FieldFilter(field, op, value);
}
}
}

export class Operator {
Expand Down Expand Up @@ -541,14 +490,69 @@ export class Operator {
}

export class FieldFilter extends Filter {
constructor(
protected constructor(
public field: FieldPath,
public op: Operator,
public value: FieldValue
) {
super();
}

/**
* Creates a filter based on the provided arguments.
*/
static create(
field: FieldPath,
op: Operator,
value: FieldValue
): FieldFilter {
if (field.isKeyField()) {
assert(
value instanceof RefValue,
'Comparing on key, but filter value not a RefValue'
);
assert(
op !== Operator.ARRAY_CONTAINS &&
op !== Operator.ARRAY_CONTAINS_ANY &&
op !== Operator.IN,
`'${op.toString()}' queries don't make sense on document keys.`
);
return new KeyFieldFilter(field, op, value as RefValue);
} else if (value.isEqual(NullValue.INSTANCE)) {
if (op !== Operator.EQUAL) {
throw new FirestoreError(
Code.INVALID_ARGUMENT,
'Invalid query. You can only perform equals comparisons on null.'
);
}
return new FieldFilter(field, op, value);
} else if (value.isEqual(DoubleValue.NAN)) {
if (op !== Operator.EQUAL) {
throw new FirestoreError(
Code.INVALID_ARGUMENT,
'Invalid query. You can only perform equals comparisons on NaN.'
);
}
return new FieldFilter(field, op, value);
} else if (op === Operator.ARRAY_CONTAINS) {
return new ArrayContainsFilter(field, value);
} else if (op === Operator.IN) {
assert(
value instanceof ArrayValue,
'IN filter has invalid value: ' + value.toString()
);
return new InFilter(field, value as ArrayValue);
} else if (op === Operator.ARRAY_CONTAINS_ANY) {
assert(
value instanceof ArrayValue,
'ARRAY_CONTAINS_ANY filter has invalid value: ' + value.toString()
);
return new ArrayContainsAnyFilter(field, value as ArrayValue);
} else {
return new FieldFilter(field, op, value);
}
}

matches(doc: Document): boolean {
const other = doc.field(this.field);

Expand Down
10 changes: 7 additions & 3 deletions packages/firestore/src/remote/serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1282,7 +1282,7 @@ export class JsonProtoSerializer {
}

fromFieldFilter(filter: api.Filter): Filter {
return new FieldFilter(
return FieldFilter.create(
this.fromFieldPathReference(filter.fieldFilter!.field!),
this.fromOperatorName(filter.fieldFilter!.op!),
this.fromValue(filter.fieldFilter!.value!)
Expand Down Expand Up @@ -1323,12 +1323,16 @@ export class JsonProtoSerializer {
const nanField = this.fromFieldPathReference(
filter.unaryFilter!.field!
);
return new FieldFilter(nanField, Operator.EQUAL, DoubleValue.NAN);
return FieldFilter.create(nanField, Operator.EQUAL, DoubleValue.NAN);
case 'IS_NULL':
const nullField = this.fromFieldPathReference(
filter.unaryFilter!.field!
);
return new FieldFilter(nullField, Operator.EQUAL, NullValue.INSTANCE);
return FieldFilter.create(
nullField,
Operator.EQUAL,
NullValue.INSTANCE
);
case 'OPERATOR_UNSPECIFIED':
return fail('Unspecified filter');
default:
Expand Down
60 changes: 51 additions & 9 deletions packages/firestore/test/unit/remote/node/serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,12 @@ import { GeoPoint } from '../../../../src/api/geo_point';
import { Timestamp } from '../../../../src/api/timestamp';
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 couldn't figure out how to test KeyFieldFilter due to problems with creating a query with this filter.

Copy link
Contributor

@mikelehen mikelehen Jun 24, 2019

Choose a reason for hiding this comment

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

Not sure if this is what you mean, but it might look something like:

const input = filter(DOCUMENT_KEY_NAME, '==', ref('project/database', 'coll/doc'));

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks! That worked.

import { DatabaseId } from '../../../../src/core/database_info';
import {
ArrayContainsAnyFilter,
ArrayContainsFilter,
Direction,
FieldFilter,
InFilter,
KeyFieldFilter,
Operator,
OrderBy,
Query
Expand Down Expand Up @@ -684,7 +688,7 @@ describe('Serializer', () => {

it('makes dotted-property names', () => {
const path = new FieldPath(['item', 'part', 'top']);
const input = new FieldFilter(path, Operator.EQUAL, wrap('food'));
const input = FieldFilter.create(path, Operator.EQUAL, wrap('food'));
const actual = s.toUnaryOrFieldFilter(input);
expect(actual).to.deep.equal({
fieldFilter: {
Expand All @@ -693,7 +697,9 @@ describe('Serializer', () => {
value: { stringValue: 'food' }
}
});
expect(s.fromFieldFilter(actual)).to.deep.equal(input);
const roundtripped = s.fromFieldFilter(actual);
expect(roundtripped).to.deep.equal(input);
expect(roundtripped).to.be.instanceof(FieldFilter);
Copy link
Contributor

Choose a reason for hiding this comment

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

FWIW we could instead do:

expect(Object.getPrototypeOf(roundtripped)).to.equal(Object.getPrototypeOf(input));

to avoid the redundancy of specifying the type of input... but in practice it doesn't matter and it's easier to read as-is... so probably not a good improvement (unless we wanted to refactor these tests to share code or something).

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 don't know how getPrototypeOf works -- if it checks for the exact class, then it could be an improvement, because given, say, ArrayContainsFilter, it will nevertheless satisfy a check for instanceof FieldFilter (due to inheritance).

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, yes. I believe the prototype check would be more strict than that... that said it's not super normal to deal with an object's prototype directly or do equal checks on it. I am not 100% confident it wouldn't bite us in the future some day (due to browser differences or something). So I don't feel strongly that it's a better approach or anything (i.e. feel free to leave as-is).

});

it('converts LessThan', () => {
Expand All @@ -706,7 +712,9 @@ describe('Serializer', () => {
value: { integerValue: '42' }
}
});
expect(s.fromFieldFilter(actual)).to.deep.equal(input);
const roundtripped = s.fromFieldFilter(actual);
expect(roundtripped).to.deep.equal(input);
expect(roundtripped).to.be.instanceof(FieldFilter);
});

it('converts LessThanOrEqual', () => {
Expand All @@ -719,7 +727,9 @@ describe('Serializer', () => {
value: { stringValue: 'food' }
}
});
expect(s.fromFieldFilter(actual)).to.deep.equal(input);
const roundtripped = s.fromFieldFilter(actual);
expect(roundtripped).to.deep.equal(input);
expect(roundtripped).to.be.instanceof(FieldFilter);
});

it('converts GreaterThan', () => {
Expand All @@ -732,7 +742,9 @@ describe('Serializer', () => {
value: { booleanValue: false }
}
});
expect(s.fromFieldFilter(actual)).to.deep.equal(input);
const roundtripped = s.fromFieldFilter(actual);
expect(roundtripped).to.deep.equal(input);
expect(roundtripped).to.be.instanceof(FieldFilter);
});

it('converts GreaterThanOrEqual', () => {
Expand All @@ -745,7 +757,31 @@ describe('Serializer', () => {
value: { doubleValue: 1e100 }
}
});
expect(s.fromFieldFilter(actual)).to.deep.equal(input);
const roundtripped = s.fromFieldFilter(actual);
expect(roundtripped).to.deep.equal(input);
expect(roundtripped).to.be.instanceof(FieldFilter);
});

it('converts key field', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for adding!

const input = filter(
DOCUMENT_KEY_NAME,
'==',
ref('project/database', 'coll/doc')
);
const actual = s.toUnaryOrFieldFilter(input);
expect(actual).to.deep.equal({
fieldFilter: {
field: { fieldPath: '__name__' },
op: 'EQUAL',
value: {
referenceValue:
'projects/project/databases/database/documents/coll/doc'
}
}
});
const roundtripped = s.fromFieldFilter(actual);
expect(roundtripped).to.deep.equal(input);
expect(roundtripped).to.be.instanceof(KeyFieldFilter);
});

it('converts array-contains', () => {
Expand All @@ -758,7 +794,9 @@ describe('Serializer', () => {
value: { integerValue: '42' }
}
});
expect(s.fromFieldFilter(actual)).to.deep.equal(input);
const roundtripped = s.fromFieldFilter(actual);
expect(roundtripped).to.deep.equal(input);
expect(roundtripped).to.be.instanceof(ArrayContainsFilter);
});

it('converts IN', () => {
Expand All @@ -779,7 +817,9 @@ describe('Serializer', () => {
}
}
});
expect(s.fromFieldFilter(actual)).to.deep.equal(input);
const roundtripped = s.fromFieldFilter(actual);
expect(roundtripped).to.deep.equal(input);
expect(roundtripped).to.be.instanceof(InFilter);
});

it('converts array-contains-any', () => {
Expand All @@ -800,7 +840,9 @@ describe('Serializer', () => {
}
}
});
expect(s.fromFieldFilter(actual)).to.deep.equal(input);
const roundtripped = s.fromFieldFilter(actual);
expect(roundtripped).to.deep.equal(input);
expect(roundtripped).to.be.instanceof(ArrayContainsAnyFilter);
});
});

Expand Down
22 changes: 22 additions & 0 deletions packages/firestore/test/unit/specs/listen_spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,28 @@ describeSpec('Listens:', [], () => {
.expectEvents(query, {});
});

specTest('Array-contains queries support resuming', [], () => {
const query = Query.atPath(path('collection')).addFilter(
filter('array', 'array-contains', 42)
);
const docA = doc('collection/a', 2000, { foo: 'bar', array: [1, 42, 3] });
return spec()
.withGCEnabled(false)
.userListens(query)
.watchAcksFull(query, 1000)
.expectEvents(query, {})
.watchSends({ affects: [query] }, docA)
.watchSnapshots(2000, [query], 'resume-token-2000')
.watchSnapshots(2000)
.expectEvents(query, { added: [docA] })
.userUnlistens(query)
.watchRemoves(query)
.userListens(query, 'resume-token-2000')
.expectEvents(query, { added: [docA], fromCache: true })
.watchAcksFull(query, 3000)
.expectEvents(query, {});
});

specTest('Persists global resume tokens on unlisten', [], () => {
const query = Query.atPath(path('collection'));
const docA = doc('collection/a', 1000, { key: 'a' });
Expand Down
3 changes: 1 addition & 2 deletions packages/firestore/test/util/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import {
Bound,
Direction,
FieldFilter,
Filter,
Operator,
OrderBy
} from '../../src/core/query';
Expand Down Expand Up @@ -190,7 +189,7 @@ export function blob(...bytes: number[]): Blob {
export function filter(path: string, op: string, value: unknown): FieldFilter {
const dataValue = wrap(value);
const operator = Operator.fromString(op);
const filter = Filter.create(field(path), operator, dataValue);
const filter = FieldFilter.create(field(path), operator, dataValue);

if (filter instanceof FieldFilter) {
return filter;
Expand Down