Skip to content

Commit 1eff916

Browse files
author
Brian Chen
authored
Allow IN queries with arrays of documentIds (#560)
1 parent cedb8c3 commit 1eff916

File tree

2 files changed

+179
-130
lines changed

2 files changed

+179
-130
lines changed

firebase-firestore/src/androidTest/java/com/google/firebase/firestore/ValidationTest.java

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -701,11 +701,6 @@ public void queriesFilteredByDocumentIDMustUseStringsOrDocumentReferences() {
701701
+ "a valid String or DocumentReference, but it was of type: java.lang.Integer";
702702
expectError(() -> collection.whereGreaterThanOrEqualTo(FieldPath.documentId(), 1), reason);
703703

704-
reason =
705-
"Invalid query. When querying with FieldPath.documentId() you must provide "
706-
+ "a valid String or DocumentReference, but it was of type: java.util.Arrays$ArrayList";
707-
expectError(() -> collection.whereIn(FieldPath.documentId(), asList(1, 2)), reason);
708-
709704
reason =
710705
"Invalid query. When querying a collection group by FieldPath.documentId(), the value "
711706
+ "provided must result in a valid document path, but 'foo' is not because it has "
@@ -726,6 +721,38 @@ public void queriesFilteredByDocumentIDMustUseStringsOrDocumentReferences() {
726721
() -> collection.whereArrayContainsAny(FieldPath.documentId(), asList(1, 2)), reason);
727722
}
728723

724+
@Test
725+
public void queriesUsingInAndDocumentIdMustHaveProperDocumentReferencesInArray() {
726+
CollectionReference collection = testCollection();
727+
collection.whereIn(FieldPath.documentId(), asList(collection.getPath()));
728+
729+
String reason =
730+
"Invalid query. When querying with FieldPath.documentId() you must provide "
731+
+ "a valid document ID, but it was an empty string.";
732+
expectError(() -> collection.whereIn(FieldPath.documentId(), asList("")), reason);
733+
734+
reason =
735+
"Invalid query. When querying a collection by FieldPath.documentId() you must provide "
736+
+ "a plain document ID, but 'foo/bar/baz' contains a '/' character.";
737+
expectError(() -> collection.whereIn(FieldPath.documentId(), asList("foo/bar/baz")), reason);
738+
739+
reason =
740+
"Invalid query. When querying with FieldPath.documentId() you must provide "
741+
+ "a valid String or DocumentReference, but it was of type: java.lang.Integer";
742+
expectError(() -> collection.whereIn(FieldPath.documentId(), asList(1, 2)), reason);
743+
744+
reason =
745+
"Invalid query. When querying a collection group by FieldPath.documentId(), the value "
746+
+ "provided must result in a valid document path, but 'foo' is not because it has "
747+
+ "an odd number of segments (1).";
748+
expectError(
749+
() ->
750+
testFirestore()
751+
.collectionGroup("collection")
752+
.whereIn(FieldPath.documentId(), asList("foo")),
753+
reason);
754+
}
755+
729756
// Helpers
730757

731758
/** Performs a write using each write API and makes sure it fails with the expected reason. */

firebase-firestore/src/main/java/com/google/firebase/firestore/Query.java

Lines changed: 147 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import com.google.firebase.firestore.model.Document;
4040
import com.google.firebase.firestore.model.DocumentKey;
4141
import com.google.firebase.firestore.model.ResourcePath;
42+
import com.google.firebase.firestore.model.value.ArrayValue;
4243
import com.google.firebase.firestore.model.value.FieldValue;
4344
import com.google.firebase.firestore.model.value.ReferenceValue;
4445
import com.google.firebase.firestore.model.value.ServerTimestampValue;
@@ -84,76 +85,6 @@ public FirebaseFirestore getFirestore() {
8485
return firestore;
8586
}
8687

87-
private void validateOrderByFieldMatchesInequality(
88-
com.google.firebase.firestore.model.FieldPath orderBy,
89-
com.google.firebase.firestore.model.FieldPath inequality) {
90-
if (!orderBy.equals(inequality)) {
91-
String inequalityString = inequality.canonicalString();
92-
throw new IllegalArgumentException(
93-
String.format(
94-
"Invalid query. You have an inequality where filter (whereLessThan(), "
95-
+ "whereGreaterThan(), etc.) on field '%s' and so you must also have '%s' as "
96-
+ "your first orderBy() field, but your first orderBy() is currently on field "
97-
+ "'%s' instead.",
98-
inequalityString, inequalityString, orderBy.canonicalString()));
99-
}
100-
}
101-
102-
private void validateNewFilter(Filter filter) {
103-
if (filter instanceof RelationFilter) {
104-
Operator filterOp = ((RelationFilter) filter).getOperator();
105-
List<Operator> arrayOps = Arrays.asList(Operator.ARRAY_CONTAINS, Operator.ARRAY_CONTAINS_ANY);
106-
List<Operator> disjunctiveOps = Arrays.asList(Operator.ARRAY_CONTAINS_ANY, Operator.IN);
107-
boolean isArrayOp = arrayOps.contains(filterOp);
108-
boolean isDisjunctiveOp = disjunctiveOps.contains(filterOp);
109-
110-
RelationFilter relationFilter = (RelationFilter) filter;
111-
if (relationFilter.isInequality()) {
112-
com.google.firebase.firestore.model.FieldPath existingInequality = query.inequalityField();
113-
com.google.firebase.firestore.model.FieldPath newInequality = filter.getField();
114-
115-
if (existingInequality != null && !existingInequality.equals(newInequality)) {
116-
throw new IllegalArgumentException(
117-
String.format(
118-
"All where filters other than whereEqualTo() must be on the same field. But you "
119-
+ "have filters on '%s' and '%s'",
120-
existingInequality.canonicalString(), newInequality.canonicalString()));
121-
}
122-
com.google.firebase.firestore.model.FieldPath firstOrderByField =
123-
query.getFirstOrderByField();
124-
if (firstOrderByField != null) {
125-
validateOrderByFieldMatchesInequality(firstOrderByField, newInequality);
126-
}
127-
} else if (isDisjunctiveOp || isArrayOp) {
128-
// You can have at most 1 disjunctive filter and 1 array filter. Check if the new filter
129-
// conflicts with an existing one.
130-
Operator conflictingOp = null;
131-
if (isDisjunctiveOp) {
132-
conflictingOp = this.query.findOperatorFilter(disjunctiveOps);
133-
}
134-
if (conflictingOp == null && isArrayOp) {
135-
conflictingOp = this.query.findOperatorFilter(arrayOps);
136-
}
137-
if (conflictingOp != null) {
138-
// We special case when it's a duplicate op to give a slightly clearer error message.
139-
if (conflictingOp == filterOp) {
140-
throw new IllegalArgumentException(
141-
"Invalid Query. You cannot use more than one '"
142-
+ filterOp.toString()
143-
+ "' filter.");
144-
} else {
145-
throw new IllegalArgumentException(
146-
"Invalid Query. You cannot use '"
147-
+ filterOp.toString()
148-
+ "' filters with '"
149-
+ conflictingOp.toString()
150-
+ "' filters.");
151-
}
152-
}
153-
}
154-
}
155-
}
156-
15788
/**
15889
* Creates and returns a new Query with the additional filter that documents must contain the
15990
* specified field and the value should be equal to the specified value.
@@ -421,66 +352,19 @@ private Query whereHelper(@NonNull FieldPath fieldPath, Operator op, Object valu
421352
"Invalid query. You can't perform '"
422353
+ op.toString()
423354
+ "' queries on FieldPath.documentId().");
424-
}
425-
if (value instanceof String) {
426-
String documentKey = (String) value;
427-
if (documentKey.isEmpty()) {
428-
throw new IllegalArgumentException(
429-
"Invalid query. When querying with FieldPath.documentId() you must provide a valid "
430-
+ "document ID, but it was an empty string.");
355+
} else if (op == Operator.IN) {
356+
validateDisjunctiveFilterElements(value, op);
357+
List<FieldValue> referenceList = new ArrayList<>();
358+
for (Object arrayValue : (List) value) {
359+
referenceList.add(parseDocumentIdValue(arrayValue));
431360
}
432-
if (!query.isCollectionGroupQuery() && documentKey.contains("/")) {
433-
throw new IllegalArgumentException(
434-
"Invalid query. When querying a collection by FieldPath.documentId() you must "
435-
+ "provide a plain document ID, but '"
436-
+ documentKey
437-
+ "' contains a '/' character.");
438-
}
439-
ResourcePath path = query.getPath().append(ResourcePath.fromString(documentKey));
440-
if (!DocumentKey.isDocumentKey(path)) {
441-
throw new IllegalArgumentException(
442-
"Invalid query. When querying a collection group by FieldPath.documentId(), the "
443-
+ "value provided must result in a valid document path, but '"
444-
+ path
445-
+ "' is not because it has an odd number of segments ("
446-
+ path.length()
447-
+ ").");
448-
}
449-
fieldValue =
450-
ReferenceValue.valueOf(this.getFirestore().getDatabaseId(), DocumentKey.fromPath(path));
451-
} else if (value instanceof DocumentReference) {
452-
DocumentReference ref = (DocumentReference) value;
453-
fieldValue = ReferenceValue.valueOf(this.getFirestore().getDatabaseId(), ref.getKey());
361+
fieldValue = ArrayValue.fromList(referenceList);
454362
} else {
455-
throw new IllegalArgumentException(
456-
"Invalid query. When querying with FieldPath.documentId() you must provide a valid "
457-
+ "String or DocumentReference, but it was of type: "
458-
+ Util.typeName(value));
363+
fieldValue = parseDocumentIdValue(value);
459364
}
460365
} else {
461366
if (op == Operator.IN || op == Operator.ARRAY_CONTAINS_ANY) {
462-
if (!(value instanceof List) || ((List) value).size() == 0) {
463-
throw new IllegalArgumentException(
464-
"Invalid Query. A non-empty array is required for '" + op.toString() + "' filters.");
465-
}
466-
if (((List) value).size() > 10) {
467-
throw new IllegalArgumentException(
468-
"Invalid Query. '"
469-
+ op.toString()
470-
+ "' filters support a maximum of 10 elements in the value array.");
471-
}
472-
if (((List) value).contains(null)) {
473-
throw new IllegalArgumentException(
474-
"Invalid Query. '"
475-
+ op.toString()
476-
+ "' filters cannot contain 'null' in the value array.");
477-
}
478-
if (((List) value).contains(Double.NaN) || ((List) value).contains(Float.NaN)) {
479-
throw new IllegalArgumentException(
480-
"Invalid Query. '"
481-
+ op.toString()
482-
+ "' filters cannot contain 'NaN' in the value array.");
483-
}
367+
validateDisjunctiveFilterElements(value, op);
484368
}
485369
fieldValue = firestore.getDataConverter().parseQueryValue(value);
486370
}
@@ -497,6 +381,144 @@ private void validateOrderByField(com.google.firebase.firestore.model.FieldPath
497381
}
498382
}
499383

384+
/**
385+
* Parses the given documentIdValue into a ReferenceValue, throwing appropriate errors if the
386+
* value is anything other than a DocumentReference or String, or if the string is malformed.
387+
*/
388+
private ReferenceValue parseDocumentIdValue(Object documentIdValue) {
389+
if (documentIdValue instanceof String) {
390+
String documentId = (String) documentIdValue;
391+
if (documentId.isEmpty()) {
392+
throw new IllegalArgumentException(
393+
"Invalid query. When querying with FieldPath.documentId() you must provide a valid "
394+
+ "document ID, but it was an empty string.");
395+
}
396+
if (!query.isCollectionGroupQuery() && documentId.contains("/")) {
397+
throw new IllegalArgumentException(
398+
"Invalid query. When querying a collection by FieldPath.documentId() you must "
399+
+ "provide a plain document ID, but '"
400+
+ documentId
401+
+ "' contains a '/' character.");
402+
}
403+
ResourcePath path = query.getPath().append(ResourcePath.fromString(documentId));
404+
if (!DocumentKey.isDocumentKey(path)) {
405+
throw new IllegalArgumentException(
406+
"Invalid query. When querying a collection group by FieldPath.documentId(), the "
407+
+ "value provided must result in a valid document path, but '"
408+
+ path
409+
+ "' is not because it has an odd number of segments ("
410+
+ path.length()
411+
+ ").");
412+
}
413+
return ReferenceValue.valueOf(
414+
this.getFirestore().getDatabaseId(), DocumentKey.fromPath(path));
415+
} else if (documentIdValue instanceof DocumentReference) {
416+
DocumentReference ref = (DocumentReference) documentIdValue;
417+
return ReferenceValue.valueOf(this.getFirestore().getDatabaseId(), ref.getKey());
418+
} else {
419+
throw new IllegalArgumentException(
420+
"Invalid query. When querying with FieldPath.documentId() you must provide a valid "
421+
+ "String or DocumentReference, but it was of type: "
422+
+ Util.typeName(documentIdValue));
423+
}
424+
}
425+
426+
/** Validates that the value passed into a disjunctive filter satisfies all array requirements. */
427+
private void validateDisjunctiveFilterElements(Object value, Operator op) {
428+
if (!(value instanceof List) || ((List) value).size() == 0) {
429+
throw new IllegalArgumentException(
430+
"Invalid Query. A non-empty array is required for '" + op.toString() + "' filters.");
431+
}
432+
if (((List) value).size() > 10) {
433+
throw new IllegalArgumentException(
434+
"Invalid Query. '"
435+
+ op.toString()
436+
+ "' filters support a maximum of 10 elements in the value array.");
437+
}
438+
if (((List) value).contains(null)) {
439+
throw new IllegalArgumentException(
440+
"Invalid Query. '"
441+
+ op.toString()
442+
+ "' filters cannot contain 'null' in the value array.");
443+
}
444+
if (((List) value).contains(Double.NaN) || ((List) value).contains(Float.NaN)) {
445+
throw new IllegalArgumentException(
446+
"Invalid Query. '"
447+
+ op.toString()
448+
+ "' filters cannot contain 'NaN' in the value array.");
449+
}
450+
}
451+
452+
private void validateOrderByFieldMatchesInequality(
453+
com.google.firebase.firestore.model.FieldPath orderBy,
454+
com.google.firebase.firestore.model.FieldPath inequality) {
455+
if (!orderBy.equals(inequality)) {
456+
String inequalityString = inequality.canonicalString();
457+
throw new IllegalArgumentException(
458+
String.format(
459+
"Invalid query. You have an inequality where filter (whereLessThan(), "
460+
+ "whereGreaterThan(), etc.) on field '%s' and so you must also have '%s' as "
461+
+ "your first orderBy() field, but your first orderBy() is currently on field "
462+
+ "'%s' instead.",
463+
inequalityString, inequalityString, orderBy.canonicalString()));
464+
}
465+
}
466+
467+
private void validateNewFilter(Filter filter) {
468+
if (filter instanceof RelationFilter) {
469+
Operator filterOp = ((RelationFilter) filter).getOperator();
470+
List<Operator> arrayOps = Arrays.asList(Operator.ARRAY_CONTAINS, Operator.ARRAY_CONTAINS_ANY);
471+
List<Operator> disjunctiveOps = Arrays.asList(Operator.ARRAY_CONTAINS_ANY, Operator.IN);
472+
boolean isArrayOp = arrayOps.contains(filterOp);
473+
boolean isDisjunctiveOp = disjunctiveOps.contains(filterOp);
474+
475+
RelationFilter relationFilter = (RelationFilter) filter;
476+
if (relationFilter.isInequality()) {
477+
com.google.firebase.firestore.model.FieldPath existingInequality = query.inequalityField();
478+
com.google.firebase.firestore.model.FieldPath newInequality = filter.getField();
479+
480+
if (existingInequality != null && !existingInequality.equals(newInequality)) {
481+
throw new IllegalArgumentException(
482+
String.format(
483+
"All where filters other than whereEqualTo() must be on the same field. But you "
484+
+ "have filters on '%s' and '%s'",
485+
existingInequality.canonicalString(), newInequality.canonicalString()));
486+
}
487+
com.google.firebase.firestore.model.FieldPath firstOrderByField =
488+
query.getFirstOrderByField();
489+
if (firstOrderByField != null) {
490+
validateOrderByFieldMatchesInequality(firstOrderByField, newInequality);
491+
}
492+
} else if (isDisjunctiveOp || isArrayOp) {
493+
// You can have at most 1 disjunctive filter and 1 array filter. Check if the new filter
494+
// conflicts with an existing one.
495+
Operator conflictingOp = null;
496+
if (isDisjunctiveOp) {
497+
conflictingOp = this.query.findOperatorFilter(disjunctiveOps);
498+
}
499+
if (conflictingOp == null && isArrayOp) {
500+
conflictingOp = this.query.findOperatorFilter(arrayOps);
501+
}
502+
if (conflictingOp != null) {
503+
// We special case when it's a duplicate op to give a slightly clearer error message.
504+
if (conflictingOp == filterOp) {
505+
throw new IllegalArgumentException(
506+
"Invalid Query. You cannot use more than one '"
507+
+ filterOp.toString()
508+
+ "' filter.");
509+
} else {
510+
throw new IllegalArgumentException(
511+
"Invalid Query. You cannot use '"
512+
+ filterOp.toString()
513+
+ "' filters with '"
514+
+ conflictingOp.toString()
515+
+ "' filters.");
516+
}
517+
}
518+
}
519+
}
520+
}
521+
500522
/**
501523
* Creates and returns a new Query that's additionally sorted by the specified field.
502524
*

0 commit comments

Comments
 (0)