-
Notifications
You must be signed in to change notification settings - Fork 938
Add NOT_IN and != queries (not public) #3557
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
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1436,7 +1436,7 @@ export function newQueryFilter( | |
`Invalid Query. You can't perform '${op}' ` + | ||
'queries on FieldPath.documentId().' | ||
); | ||
} else if (op === Operator.IN) { | ||
} else if (op === Operator.IN || op === Operator.NOT_IN) { | ||
validateDisjunctiveFilterElements(value, op); | ||
const referenceList: api.Value[] = []; | ||
for (const arrayValue of value as api.Value[]) { | ||
|
@@ -1447,14 +1447,18 @@ export function newQueryFilter( | |
fieldValue = parseDocumentIdValue(databaseId, query, value); | ||
} | ||
} else { | ||
if (op === Operator.IN || op === Operator.ARRAY_CONTAINS_ANY) { | ||
if ( | ||
op === Operator.IN || | ||
op === Operator.NOT_IN || | ||
op === Operator.ARRAY_CONTAINS_ANY | ||
) { | ||
validateDisjunctiveFilterElements(value, op); | ||
} | ||
fieldValue = parseQueryValue( | ||
dataReader, | ||
methodName, | ||
value, | ||
op === Operator.IN | ||
/* allowArrays= */ op === Operator.IN || op === Operator.NOT_IN | ||
); | ||
} | ||
const filter = FieldFilter.create(fieldPath, op, fieldValue); | ||
|
@@ -1684,14 +1688,17 @@ function validateDisjunctiveFilterElements( | |
'maximum of 10 elements in the value array.' | ||
); | ||
} | ||
if (value.indexOf(null) >= 0) { | ||
if (value.indexOf(null) >= 0 && operator !== Operator.NOT_IN) { | ||
throw new FirestoreError( | ||
Code.INVALID_ARGUMENT, | ||
`Invalid Query. '${operator.toString()}' filters cannot contain 'null' ` + | ||
'in the value array.' | ||
); | ||
} | ||
if (value.filter(element => Number.isNaN(element)).length > 0) { | ||
if ( | ||
value.filter(element => Number.isNaN(element)).length > 0 && | ||
operator !== Operator.NOT_IN | ||
) { | ||
throw new FirestoreError( | ||
Code.INVALID_ARGUMENT, | ||
`Invalid Query. '${operator.toString()}' filters cannot contain 'NaN' ` + | ||
|
@@ -1700,14 +1707,41 @@ function validateDisjunctiveFilterElements( | |
} | ||
} | ||
|
||
function conflictingOps(op: Operator): Operator[] { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comments please. In particular please document the context in which these operations could be conflicting. Within the body of this function it would be useful to spell out the rules by which you arrived at these results. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Attempted. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These comments are mostly just repeating what the code is doing. What I was hoping for was a higher-level comment (outside the switch statement) that gave the collective rules together. For example, as you've implemented this now, you've repeated "Only one ARRAY operator can be used" in multiple places and I'm prompting for it to be in one place. Basically: when you constructed these lists, you had to evaluate a list of rules. Then for each operator pair you evaluated those rules to determine if they were incompatible. What I want you to document is the list of rules you used to build this structure. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense. Documented at the top of the function. |
||
switch (op) { | ||
case Operator.NOT_EQUAL: | ||
return [Operator.NOT_IN, Operator.NOT_EQUAL]; | ||
case Operator.ARRAY_CONTAINS: | ||
return [ | ||
Operator.ARRAY_CONTAINS, | ||
Operator.ARRAY_CONTAINS_ANY, | ||
Operator.NOT_IN | ||
]; | ||
case Operator.IN: | ||
return [Operator.ARRAY_CONTAINS_ANY, Operator.IN, Operator.NOT_IN]; | ||
case Operator.ARRAY_CONTAINS_ANY: | ||
return [ | ||
Operator.ARRAY_CONTAINS, | ||
Operator.ARRAY_CONTAINS_ANY, | ||
Operator.IN, | ||
Operator.NOT_IN | ||
]; | ||
case Operator.NOT_IN: | ||
return [ | ||
Operator.ARRAY_CONTAINS, | ||
Operator.ARRAY_CONTAINS_ANY, | ||
Operator.IN, | ||
Operator.NOT_IN, | ||
Operator.NOT_EQUAL | ||
]; | ||
default: | ||
return []; | ||
} | ||
} | ||
|
||
function validateNewFilter(query: InternalQuery, filter: Filter): void { | ||
debugAssert(filter instanceof FieldFilter, 'Only FieldFilters are supported'); | ||
|
||
const arrayOps = [Operator.ARRAY_CONTAINS, Operator.ARRAY_CONTAINS_ANY]; | ||
const disjunctiveOps = [Operator.IN, Operator.ARRAY_CONTAINS_ANY]; | ||
const isArrayOp = arrayOps.indexOf(filter.op) >= 0; | ||
const isDisjunctiveOp = disjunctiveOps.indexOf(filter.op) >= 0; | ||
|
||
if (filter.isInequality()) { | ||
const existingField = query.getInequalityFilterField(); | ||
if (existingField !== null && !existingField.isEqual(filter.field)) { | ||
|
@@ -1724,31 +1758,23 @@ function validateNewFilter(query: InternalQuery, filter: Filter): void { | |
if (firstOrderByField !== null) { | ||
validateOrderByAndInequalityMatch(query, filter.field, firstOrderByField); | ||
} | ||
} else if (isDisjunctiveOp || isArrayOp) { | ||
// You can have at most 1 disjunctive filter and 1 array filter. Check if | ||
// the new filter conflicts with an existing one. | ||
let conflictingOp: Operator | null = null; | ||
if (isDisjunctiveOp) { | ||
conflictingOp = query.findFilterOperator(disjunctiveOps); | ||
} | ||
if (conflictingOp === null && isArrayOp) { | ||
conflictingOp = query.findFilterOperator(arrayOps); | ||
} | ||
if (conflictingOp !== null) { | ||
// We special case when it's a duplicate op to give a slightly clearer error message. | ||
if (conflictingOp === filter.op) { | ||
throw new FirestoreError( | ||
Code.INVALID_ARGUMENT, | ||
'Invalid query. You cannot use more than one ' + | ||
`'${filter.op.toString()}' filter.` | ||
); | ||
} else { | ||
throw new FirestoreError( | ||
Code.INVALID_ARGUMENT, | ||
`Invalid query. You cannot use '${filter.op.toString()}' filters ` + | ||
`with '${conflictingOp.toString()}' filters.` | ||
); | ||
} | ||
} | ||
|
||
const conflictingOp = query.findFilterOperator(conflictingOps(filter.op)); | ||
if (conflictingOp !== null) { | ||
// We special case when it's a duplicate op to give a slightly clearer error message. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When commenting in code like this, you can drop the "we" and write as if you're telling the computer what to do (i.e. in the imperative mood with an implicit subject).
(Though I see you're just moving the comment from below.) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done. |
||
if (conflictingOp === filter.op) { | ||
throw new FirestoreError( | ||
Code.INVALID_ARGUMENT, | ||
'Invalid query. You cannot use more than one ' + | ||
`'${filter.op.toString()}' filter.` | ||
); | ||
} else { | ||
throw new FirestoreError( | ||
Code.INVALID_ARGUMENT, | ||
`Invalid query. You cannot use '${filter.op.toString()}' filters ` + | ||
`with '${conflictingOp.toString()}' filters.` | ||
); | ||
} | ||
} | ||
} | ||
|
@@ -1806,18 +1832,25 @@ export class Query<T = firestore.DocumentData> implements firestore.Query<T> { | |
validateExactNumberOfArgs('Query.where', arguments, 3); | ||
validateDefined('Query.where', 3, value); | ||
|
||
// Enumerated from the WhereFilterOp type in index.d.ts. | ||
const whereFilterOpEnums = [ | ||
Operator.LESS_THAN, | ||
Operator.LESS_THAN_OR_EQUAL, | ||
Operator.EQUAL, | ||
Operator.GREATER_THAN_OR_EQUAL, | ||
Operator.GREATER_THAN, | ||
Operator.ARRAY_CONTAINS, | ||
Operator.IN, | ||
Operator.ARRAY_CONTAINS_ANY | ||
]; | ||
const op = validateStringEnum('Query.where', whereFilterOpEnums, 2, opStr); | ||
// TODO(ne-queries): Add 'not-in' and '!=' to validation. | ||
let op: Operator; | ||
if ((opStr as unknown) === 'not-in' || (opStr as unknown) === '!=') { | ||
op = opStr as Operator; | ||
} else { | ||
// Enumerated from the WhereFilterOp type in index.d.ts. | ||
const whereFilterOpEnums = [ | ||
Operator.LESS_THAN, | ||
Operator.LESS_THAN_OR_EQUAL, | ||
Operator.EQUAL, | ||
Operator.GREATER_THAN_OR_EQUAL, | ||
Operator.GREATER_THAN, | ||
Operator.ARRAY_CONTAINS, | ||
Operator.IN, | ||
Operator.ARRAY_CONTAINS_ANY | ||
]; | ||
op = validateStringEnum('Query.where', whereFilterOpEnums, 2, opStr); | ||
} | ||
|
||
const fieldPath = fieldPathFromArgument('Query.where', field); | ||
const filter = newQueryFilter( | ||
this._query, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -566,10 +566,12 @@ export const enum Operator { | |
LESS_THAN = '<', | ||
LESS_THAN_OR_EQUAL = '<=', | ||
EQUAL = '==', | ||
NOT_EQUAL = '!=', | ||
GREATER_THAN = '>', | ||
GREATER_THAN_OR_EQUAL = '>=', | ||
ARRAY_CONTAINS = 'array-contains', | ||
IN = 'in', | ||
NOT_IN = 'not-in', | ||
ARRAY_CONTAINS_ANY = 'array-contains-any' | ||
} | ||
|
||
|
@@ -587,16 +589,8 @@ export class FieldFilter extends Filter { | |
*/ | ||
static create(field: FieldPath, op: Operator, value: api.Value): FieldFilter { | ||
if (field.isKeyField()) { | ||
if (op === Operator.IN) { | ||
debugAssert( | ||
isArray(value), | ||
'Comparing on key with IN, but filter value not an ArrayValue' | ||
); | ||
debugAssert( | ||
(value.arrayValue.values || []).every(elem => isReferenceValue(elem)), | ||
'Comparing on key with IN, but an array value was not a RefValue' | ||
); | ||
return new KeyFieldInFilter(field, value); | ||
if (op === Operator.IN || op === Operator.NOT_IN) { | ||
return this.createKeyFieldInFilter(field, op, value); | ||
} else { | ||
debugAssert( | ||
isReferenceValue(value), | ||
|
@@ -609,15 +603,17 @@ export class FieldFilter extends Filter { | |
return new KeyFieldFilter(field, op, value); | ||
} | ||
} else if (isNullValue(value)) { | ||
if (op !== Operator.EQUAL) { | ||
if (op !== Operator.EQUAL && op !== Operator.NOT_EQUAL) { | ||
// TODO(ne-queries): Update error message to include != comparison. | ||
throw new FirestoreError( | ||
Code.INVALID_ARGUMENT, | ||
'Invalid query. Null supports only equality comparisons.' | ||
); | ||
} | ||
return new FieldFilter(field, op, value); | ||
} else if (isNanValue(value)) { | ||
if (op !== Operator.EQUAL) { | ||
if (op !== Operator.EQUAL && op !== Operator.NOT_EQUAL) { | ||
// TODO(ne-queries): Update error message to include != comparison. | ||
throw new FirestoreError( | ||
Code.INVALID_ARGUMENT, | ||
'Invalid query. NaN supports only equality comparisons.' | ||
|
@@ -632,6 +628,12 @@ export class FieldFilter extends Filter { | |
'IN filter has invalid value: ' + value.toString() | ||
); | ||
return new InFilter(field, value); | ||
} else if (op === Operator.NOT_IN) { | ||
debugAssert( | ||
isArray(value), | ||
'NOT_IN filter has invalid value: ' + value.toString() | ||
); | ||
return new NotInFilter(field, value); | ||
} else if (op === Operator.ARRAY_CONTAINS_ANY) { | ||
debugAssert( | ||
isArray(value), | ||
|
@@ -643,8 +645,40 @@ export class FieldFilter extends Filter { | |
} | ||
} | ||
|
||
private static createKeyFieldInFilter( | ||
field: FieldPath, | ||
op: Operator, | ||
value: api.Value | ||
): FieldFilter { | ||
debugAssert( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can avoid this assertion by making this a part of the type of the parameter. I believe you can declare op: Operator.IN | Operator.NOT_IN and then the caller will have to be in a context where this is true. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Much cleaner, thanks! |
||
op === Operator.IN || op === Operator.NOT_IN, | ||
'createKeyFieldInFilter requires an IN or NOT_IN operator' | ||
); | ||
debugAssert( | ||
isArray(value), | ||
`Comparing on key with ${op.toString()}` + | ||
', but filter value not an ArrayValue' | ||
); | ||
debugAssert( | ||
(value.arrayValue.values || []).every(elem => isReferenceValue(elem)), | ||
`Comparing on key with ${op.toString()}` + | ||
', but an array value was not a RefValue' | ||
); | ||
|
||
return op === Operator.IN | ||
? new KeyFieldInFilter(field, value) | ||
: new KeyFieldNotInFilter(field, value); | ||
} | ||
|
||
matches(doc: Document): boolean { | ||
const other = doc.field(this.field); | ||
// Types do not have to match in NOT_EQUAL filters. | ||
if (this.op === Operator.NOT_EQUAL) { | ||
return ( | ||
other !== null && | ||
this.matchesComparison(valueCompare(other!, this.value)) | ||
); | ||
} | ||
|
||
// Only compare types with matching backend order (such as double and int). | ||
return ( | ||
|
@@ -662,6 +696,8 @@ export class FieldFilter extends Filter { | |
return comparison <= 0; | ||
case Operator.EQUAL: | ||
return comparison === 0; | ||
case Operator.NOT_EQUAL: | ||
return comparison !== 0; | ||
case Operator.GREATER_THAN: | ||
return comparison > 0; | ||
case Operator.GREATER_THAN_OR_EQUAL: | ||
|
@@ -677,7 +713,8 @@ export class FieldFilter extends Filter { | |
Operator.LESS_THAN, | ||
Operator.LESS_THAN_OR_EQUAL, | ||
Operator.GREATER_THAN, | ||
Operator.GREATER_THAN_OR_EQUAL | ||
Operator.GREATER_THAN_OR_EQUAL, | ||
Operator.NOT_EQUAL | ||
].indexOf(this.op) >= 0 | ||
); | ||
} | ||
|
@@ -762,6 +799,27 @@ export class KeyFieldInFilter extends FieldFilter { | |
} | ||
} | ||
|
||
/** Filter that matches on key fields not present within an array. */ | ||
export class KeyFieldNotInFilter extends FieldFilter { | ||
private readonly keys: DocumentKey[]; | ||
|
||
constructor(field: FieldPath, value: api.Value) { | ||
super(field, Operator.NOT_IN, value); | ||
debugAssert(isArray(value), 'KeyFieldNotInFilter expects an ArrayValue'); | ||
this.keys = (value.arrayValue.values || []).map(v => { | ||
debugAssert( | ||
isReferenceValue(v), | ||
'Comparing on key with NOT_IN, but an array value was not a ReferenceValue' | ||
); | ||
return DocumentKey.fromName(v.referenceValue); | ||
}); | ||
} | ||
|
||
matches(doc: Document): boolean { | ||
return !this.keys.some(key => key.isEqual(doc.key)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The difference between these types is the operator name and this exclamation point. It seems like you could either:
(listed in order of my decreasing preference, but my preference isn't strong.) Just duplicating the code seems size unfriendly. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extracted into helper. |
||
} | ||
} | ||
|
||
/** A Filter that implements the array-contains operator. */ | ||
export class ArrayContainsFilter extends FieldFilter { | ||
constructor(field: FieldPath, value: api.Value) { | ||
|
@@ -787,6 +845,19 @@ export class InFilter extends FieldFilter { | |
} | ||
} | ||
|
||
/** A Filter that implements the not-in operator. */ | ||
export class NotInFilter extends FieldFilter { | ||
constructor(field: FieldPath, value: api.Value) { | ||
super(field, Operator.NOT_IN, value); | ||
debugAssert(isArray(value), 'NotInFilter expects an ArrayValue'); | ||
} | ||
|
||
matches(doc: Document): boolean { | ||
const other = doc.field(this.field); | ||
return other !== null && !arrayValueContains(this.value.arrayValue!, other); | ||
} | ||
} | ||
|
||
/** A Filter that implements the array-contains-any operator. */ | ||
export class ArrayContainsAnyFilter extends FieldFilter { | ||
constructor(field: FieldPath, value: api.Value) { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid double negatives if you can. In this case, the only valid operators are IN and NOT_IN, so you could make this check
=== Operator.IN
and be clearer without loss of functionality.Also, if you put the operator check first then we won't have to search the array for
null
in cases where that doesn't apply.Same feedback below, though also consider factoring out the common if check:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Refactored. However, it's possible for the operator to be
ARRAY_CONTAINS_ANY
insidenewQueryFilter()
, so I added that as well.