Skip to content

Commit 335d84f

Browse files
author
Michael Lehenbauer
committed
Collection Group Queries
1 parent 6d7b42c commit 335d84f

28 files changed

+1980
-49
lines changed

firebase-firestore/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Unreleased
2+
- [feature] You can now query across all collections in your database with a
3+
given collection ID using the `FirebaseFirestore.collectionGroup()` method.
24

35
# 18.0.1
46
- [fixed] Fixed an issue where Firestore would crash if handling write batches

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

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.querySnapshotToValues;
1919
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollection;
2020
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollectionWithDocs;
21+
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirestore;
2122
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor;
2223
import static com.google.firebase.firestore.testutil.TestUtil.map;
2324
import static java.util.Arrays.asList;
@@ -425,4 +426,119 @@ public void testQueriesCanUseArrayContainsFilters() {
425426
// NOTE: The backend doesn't currently support null, NaN, objects, or arrays, so there isn't
426427
// much of anything else interesting to test.
427428
}
429+
430+
@Test
431+
public void testCollectionGroupQueries() {
432+
FirebaseFirestore db = testFirestore();
433+
// Use .document() to get a random collection group name to use but ensure it starts with 'b'
434+
// for predictable ordering.
435+
String collectionGroup = "b" + db.collection("foo").document().getId();
436+
437+
String[] docPaths =
438+
new String[] {
439+
"abc/123/${collectionGroup}/cg-doc1",
440+
"abc/123/${collectionGroup}/cg-doc2",
441+
"${collectionGroup}/cg-doc3",
442+
"${collectionGroup}/cg-doc4",
443+
"def/456/${collectionGroup}/cg-doc5",
444+
"${collectionGroup}/virtual-doc/nested-coll/not-cg-doc",
445+
"x${collectionGroup}/not-cg-doc",
446+
"${collectionGroup}x/not-cg-doc",
447+
"abc/123/${collectionGroup}x/not-cg-doc",
448+
"abc/123/x${collectionGroup}/not-cg-doc",
449+
"abc/${collectionGroup}"
450+
};
451+
WriteBatch batch = db.batch();
452+
for (String path : docPaths) {
453+
batch.set(db.document(path.replace("${collectionGroup}", collectionGroup)), map("x", 1));
454+
}
455+
waitFor(batch.commit());
456+
457+
QuerySnapshot querySnapshot = waitFor(db.collectionGroup(collectionGroup).get());
458+
assertEquals(
459+
asList("cg-doc1", "cg-doc2", "cg-doc3", "cg-doc4", "cg-doc5"),
460+
querySnapshotToIds(querySnapshot));
461+
}
462+
463+
@Test
464+
public void testCollectionGroupQueriesWithStartAtEndAtWithArbitraryDocumentIds() {
465+
FirebaseFirestore db = testFirestore();
466+
// Use .document() to get a random collection group name to use but ensure it starts with 'b'
467+
// for predictable ordering.
468+
String collectionGroup = "b" + db.collection("foo").document().getId();
469+
470+
String[] docPaths =
471+
new String[] {
472+
"a/a/${collectionGroup}/cg-doc1",
473+
"a/b/a/b/${collectionGroup}/cg-doc2",
474+
"a/b/${collectionGroup}/cg-doc3",
475+
"a/b/c/d/${collectionGroup}/cg-doc4",
476+
"a/c/${collectionGroup}/cg-doc5",
477+
"${collectionGroup}/cg-doc6",
478+
"a/b/nope/nope"
479+
};
480+
WriteBatch batch = db.batch();
481+
for (String path : docPaths) {
482+
batch.set(db.document(path.replace("${collectionGroup}", collectionGroup)), map("x", 1));
483+
}
484+
waitFor(batch.commit());
485+
486+
QuerySnapshot querySnapshot =
487+
waitFor(
488+
db.collectionGroup(collectionGroup)
489+
.orderBy(FieldPath.documentId())
490+
.startAt("a/b")
491+
.endAt("a/b0")
492+
.get());
493+
assertEquals(asList("cg-doc2", "cg-doc3", "cg-doc4"), querySnapshotToIds(querySnapshot));
494+
495+
querySnapshot =
496+
waitFor(
497+
db.collectionGroup(collectionGroup)
498+
.orderBy(FieldPath.documentId())
499+
.startAfter("a/b")
500+
.endBefore("a/b/" + collectionGroup + "/cg-doc3")
501+
.get());
502+
assertEquals(asList("cg-doc2"), querySnapshotToIds(querySnapshot));
503+
}
504+
505+
@Test
506+
public void testCollectionGroupQueriesWithWhereFiltersOnArbitraryDocumentIds() {
507+
FirebaseFirestore db = testFirestore();
508+
// Use .document() to get a random collection group name to use but ensure it starts with 'b'
509+
// for predictable ordering.
510+
String collectionGroup = "b" + db.collection("foo").document().getId();
511+
512+
String[] docPaths =
513+
new String[] {
514+
"a/a/${collectionGroup}/cg-doc1",
515+
"a/b/a/b/${collectionGroup}/cg-doc2",
516+
"a/b/${collectionGroup}/cg-doc3",
517+
"a/b/c/d/${collectionGroup}/cg-doc4",
518+
"a/c/${collectionGroup}/cg-doc5",
519+
"${collectionGroup}/cg-doc6",
520+
"a/b/nope/nope"
521+
};
522+
WriteBatch batch = db.batch();
523+
for (String path : docPaths) {
524+
batch.set(db.document(path.replace("${collectionGroup}", collectionGroup)), map("x", 1));
525+
}
526+
waitFor(batch.commit());
527+
528+
QuerySnapshot querySnapshot =
529+
waitFor(
530+
db.collectionGroup(collectionGroup)
531+
.whereGreaterThanOrEqualTo(FieldPath.documentId(), "a/b")
532+
.whereLessThanOrEqualTo(FieldPath.documentId(), "a/b0")
533+
.get());
534+
assertEquals(asList("cg-doc2", "cg-doc3", "cg-doc4"), querySnapshotToIds(querySnapshot));
535+
536+
querySnapshot =
537+
waitFor(
538+
db.collectionGroup(collectionGroup)
539+
.whereGreaterThan(FieldPath.documentId(), "a/b")
540+
.whereLessThan(FieldPath.documentId(), "a/b/" + collectionGroup + "/cg-doc3")
541+
.get());
542+
assertEquals(asList("cg-doc2"), querySnapshotToIds(querySnapshot));
543+
}
428544
}

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -499,14 +499,21 @@ public void queriesMustNotHaveMoreComponentsThanOrderBy() {
499499

500500
@Test
501501
public void queryOrderByKeyBoundsMustBeStringsWithoutSlashes() {
502-
CollectionReference collection = testCollection();
503-
Query query = collection.orderBy(FieldPath.documentId());
502+
Query query = testFirestore().collection("collection").orderBy(FieldPath.documentId());
503+
Query cgQuery = testFirestore().collectionGroup("collection").orderBy(FieldPath.documentId());
504504
expectError(
505505
() -> query.startAt(1),
506506
"Invalid query. Expected a string for document ID in startAt(), but got 1.");
507507
expectError(
508508
() -> query.startAt("foo/bar"),
509-
"Invalid query. Document ID 'foo/bar' contains a slash in startAt().");
509+
"Invalid query. When querying a collection and ordering by "
510+
+ "FieldPath.documentId(), the value passed to startAt() must be a plain "
511+
+ "document ID, but 'foo/bar' contains a slash.");
512+
expectError(
513+
() -> cgQuery.startAt("foo"),
514+
"Invalid query. When querying a collection group and ordering by "
515+
+ "FieldPath.documentId(), the value passed to startAt() must result in a valid "
516+
+ "document path, but 'foo' is not because it contains an odd number of segments.");
510517
}
511518

512519
@Test
@@ -563,8 +570,8 @@ public void queriesFilteredByDocumentIDMustUseStringsOrDocumentReferences() {
563570
expectError(() -> collection.whereGreaterThanOrEqualTo(FieldPath.documentId(), ""), reason);
564571

565572
reason =
566-
"Invalid query. When querying with FieldPath.documentId() you must provide "
567-
+ "a valid document ID, but 'foo/bar/baz' contains a '/' character.";
573+
"Invalid query. When querying a collection by FieldPath.documentId() you must provide "
574+
+ "a plain document ID, but 'foo/bar/baz' contains a '/' character.";
568575
expectError(
569576
() -> collection.whereGreaterThanOrEqualTo(FieldPath.documentId(), "foo/bar/baz"), reason);
570577

@@ -573,6 +580,17 @@ public void queriesFilteredByDocumentIDMustUseStringsOrDocumentReferences() {
573580
+ "a valid String or DocumentReference, but it was of type: java.lang.Integer";
574581
expectError(() -> collection.whereGreaterThanOrEqualTo(FieldPath.documentId(), 1), reason);
575582

583+
reason =
584+
"Invalid query. When querying a collection group by FieldPath.documentId(), the value "
585+
+ "provided must result in a valid document path, but 'foo' is not because it has "
586+
+ "an odd number of segments (1).";
587+
expectError(
588+
() ->
589+
testFirestore()
590+
.collectionGroup("collection")
591+
.whereGreaterThanOrEqualTo(FieldPath.documentId(), "foo"),
592+
reason);
593+
576594
reason =
577595
"Invalid query. You can't perform array-contains queries on FieldPath.documentId() since "
578596
+ "document IDs are not arrays.";

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,28 @@ public DocumentReference document(@NonNull String documentPath) {
235235
return DocumentReference.forPath(ResourcePath.fromString(documentPath), this);
236236
}
237237

238+
/**
239+
* Creates and returns a new Query that includes all documents in the database that are contained
240+
* in a collection or subcollection with the given collectionId.
241+
*
242+
* @param collectionId Identifies the collections to query over. Every collection or subcollection
243+
* with this ID as the last segment of its path will be included. Cannot contain a slash.
244+
* @return The created Query.
245+
*/
246+
@NonNull
247+
@PublicApi
248+
public Query collectionGroup(@NonNull String collectionId) {
249+
checkNotNull(collectionId, "Provided collection ID must not be null.");
250+
if (collectionId.contains("/")) {
251+
throw new IllegalArgumentException(
252+
String.format(
253+
"Invalid collectionId '%s'. Collection IDs must not contain '/'.", collectionId));
254+
}
255+
ensureClientConfigured();
256+
return new Query(
257+
new com.google.firebase.firestore.core.Query(ResourcePath.EMPTY, collectionId), this);
258+
}
259+
238260
/**
239261
* Executes the given updateFunction and then attempts to commit the changes applied within the
240262
* transaction. If any document read within the transaction has changed, the updateFunction will

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

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -324,20 +324,28 @@ private Query whereHelper(@NonNull FieldPath fieldPath, Operator op, Object valu
324324
}
325325
if (value instanceof String) {
326326
String documentKey = (String) value;
327-
if (documentKey.contains("/")) {
328-
// TODO: Allow slashes once ancestor queries are supported
327+
if (documentKey.isEmpty()) {
329328
throw new IllegalArgumentException(
330329
"Invalid query. When querying with FieldPath.documentId() you must provide a valid "
331-
+ "document ID, but '"
330+
+ "document ID, but it was an empty string.");
331+
}
332+
if (!this.query.isCollectionGroupQuery() && documentKey.contains("/")) {
333+
throw new IllegalArgumentException(
334+
"Invalid query. When querying a collection by FieldPath.documentId() you must "
335+
+ "provide a plain document ID, but '"
332336
+ documentKey
333337
+ "' contains a '/' character.");
334-
} else if (documentKey.isEmpty()) {
338+
}
339+
ResourcePath path = this.query.getPath().append(ResourcePath.fromString(documentKey));
340+
if (!DocumentKey.isDocumentKey(path)) {
335341
throw new IllegalArgumentException(
336-
"Invalid query. When querying with FieldPath.documentId() you must provide a valid "
337-
+ "document ID, but it was an empty string.");
342+
"Invalid query. When querying a collection group by FieldPath.documentId(), the "
343+
+ "value provided must result in a valid document path, but '"
344+
+ path
345+
+ "' is not because it has an odd number of segments ("
346+
+ path.length()
347+
+ ").");
338348
}
339-
ResourcePath path = this.query.getPath().append(documentKey);
340-
hardAssert(path.length() % 2 == 0, "Path should be a document key");
341349
fieldValue =
342350
ReferenceValue.valueOf(this.getFirestore().getDatabaseId(), DocumentKey.fromPath(path));
343351
} else if (value instanceof DocumentReference) {
@@ -655,15 +663,26 @@ private Bound boundFromFields(String methodName, Object[] values, boolean before
655663
+ ".");
656664
}
657665
String documentId = (String) rawValue;
658-
if (documentId.contains("/")) {
666+
if (!this.query.isCollectionGroupQuery() && documentId.contains("/")) {
659667
throw new IllegalArgumentException(
660-
"Invalid query. Document ID '"
668+
"Invalid query. When querying a collection and ordering by FieldPath.documentId(), "
669+
+ "the value passed to "
670+
+ methodName
671+
+ "() must be a plain document ID, but '"
661672
+ documentId
662-
+ "' contains a slash in "
673+
+ "' contains a slash.");
674+
}
675+
ResourcePath path = query.getPath().append(ResourcePath.fromString(documentId));
676+
if (!DocumentKey.isDocumentKey(path)) {
677+
throw new IllegalArgumentException(
678+
"Invalid query. When querying a collection group and ordering by "
679+
+ "FieldPath.documentId(), the value passed to "
663680
+ methodName
664-
+ "().");
681+
+ "() must result in a valid document path, but '"
682+
+ path
683+
+ "' is not because it contains an odd number of segments.");
665684
}
666-
DocumentKey key = DocumentKey.fromPath(query.getPath().append(documentId));
685+
DocumentKey key = DocumentKey.fromPath(path);
667686
components.add(ReferenceValue.valueOf(firestore.getDatabaseId(), key));
668687
} else {
669688
FieldValue wrapped = firestore.getDataConverter().parseQueryValue(rawValue);

0 commit comments

Comments
 (0)