Skip to content

Collection Group Queries #233

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 10 commits into from
Mar 8, 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
3 changes: 3 additions & 0 deletions firebase-firestore/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
- [fixed] Fixed calculation of SQLite database size on Android 9 Pie devices.
Previous method could be off by a few MBs on these devices, potentially
delaying garbage collection.
- [changed] Prepared the persistence layer to support collection group queries.
While this feature is not yet available, all schema changes are included
in this release.

# 18.0.1
- [fixed] Fixed an issue where Firestore would crash if handling write batches
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.querySnapshotToValues;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollection;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testCollectionWithDocs;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.testFirestore;
import static com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor;
import static com.google.firebase.firestore.testutil.TestUtil.map;
import static java.util.Arrays.asList;
Expand Down Expand Up @@ -425,4 +426,119 @@ public void testQueriesCanUseArrayContainsFilters() {
// NOTE: The backend doesn't currently support null, NaN, objects, or arrays, so there isn't
// much of anything else interesting to test.
}

@Test
public void testCollectionGroupQueries() {
FirebaseFirestore db = testFirestore();
// Use .document() to get a random collection group name to use but ensure it starts with 'b'
// for predictable ordering.
String collectionGroup = "b" + db.collection("foo").document().getId();

String[] docPaths =
new String[] {
"abc/123/${collectionGroup}/cg-doc1",
"abc/123/${collectionGroup}/cg-doc2",
"${collectionGroup}/cg-doc3",
"${collectionGroup}/cg-doc4",
"def/456/${collectionGroup}/cg-doc5",
"${collectionGroup}/virtual-doc/nested-coll/not-cg-doc",
"x${collectionGroup}/not-cg-doc",
"${collectionGroup}x/not-cg-doc",
"abc/123/${collectionGroup}x/not-cg-doc",
"abc/123/x${collectionGroup}/not-cg-doc",
"abc/${collectionGroup}"
};
WriteBatch batch = db.batch();
for (String path : docPaths) {
batch.set(db.document(path.replace("${collectionGroup}", collectionGroup)), map("x", 1));
}
waitFor(batch.commit());

QuerySnapshot querySnapshot = waitFor(db.collectionGroup(collectionGroup).get());
assertEquals(
asList("cg-doc1", "cg-doc2", "cg-doc3", "cg-doc4", "cg-doc5"),
querySnapshotToIds(querySnapshot));
}

@Test
public void testCollectionGroupQueriesWithStartAtEndAtWithArbitraryDocumentIds() {
FirebaseFirestore db = testFirestore();
// Use .document() to get a random collection group name to use but ensure it starts with 'b'
// for predictable ordering.
String collectionGroup = "b" + db.collection("foo").document().getId();

String[] docPaths =
new String[] {
"a/a/${collectionGroup}/cg-doc1",
"a/b/a/b/${collectionGroup}/cg-doc2",
"a/b/${collectionGroup}/cg-doc3",
"a/b/c/d/${collectionGroup}/cg-doc4",
"a/c/${collectionGroup}/cg-doc5",
"${collectionGroup}/cg-doc6",
"a/b/nope/nope"
};
WriteBatch batch = db.batch();
for (String path : docPaths) {
batch.set(db.document(path.replace("${collectionGroup}", collectionGroup)), map("x", 1));
}
waitFor(batch.commit());

QuerySnapshot querySnapshot =
waitFor(
db.collectionGroup(collectionGroup)
.orderBy(FieldPath.documentId())
.startAt("a/b")
.endAt("a/b0")
.get());
assertEquals(asList("cg-doc2", "cg-doc3", "cg-doc4"), querySnapshotToIds(querySnapshot));

querySnapshot =
waitFor(
db.collectionGroup(collectionGroup)
.orderBy(FieldPath.documentId())
.startAfter("a/b")
.endBefore("a/b/" + collectionGroup + "/cg-doc3")
.get());
assertEquals(asList("cg-doc2"), querySnapshotToIds(querySnapshot));
}

@Test
public void testCollectionGroupQueriesWithWhereFiltersOnArbitraryDocumentIds() {
FirebaseFirestore db = testFirestore();
// Use .document() to get a random collection group name to use but ensure it starts with 'b'
// for predictable ordering.
String collectionGroup = "b" + db.collection("foo").document().getId();

String[] docPaths =
new String[] {
"a/a/${collectionGroup}/cg-doc1",
"a/b/a/b/${collectionGroup}/cg-doc2",
"a/b/${collectionGroup}/cg-doc3",
"a/b/c/d/${collectionGroup}/cg-doc4",
"a/c/${collectionGroup}/cg-doc5",
"${collectionGroup}/cg-doc6",
"a/b/nope/nope"
};
WriteBatch batch = db.batch();
for (String path : docPaths) {
batch.set(db.document(path.replace("${collectionGroup}", collectionGroup)), map("x", 1));
}
waitFor(batch.commit());

QuerySnapshot querySnapshot =
waitFor(
db.collectionGroup(collectionGroup)
.whereGreaterThanOrEqualTo(FieldPath.documentId(), "a/b")
.whereLessThanOrEqualTo(FieldPath.documentId(), "a/b0")
.get());
assertEquals(asList("cg-doc2", "cg-doc3", "cg-doc4"), querySnapshotToIds(querySnapshot));

querySnapshot =
waitFor(
db.collectionGroup(collectionGroup)
.whereGreaterThan(FieldPath.documentId(), "a/b")
.whereLessThan(FieldPath.documentId(), "a/b/" + collectionGroup + "/cg-doc3")
.get());
assertEquals(asList("cg-doc2"), querySnapshotToIds(querySnapshot));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -499,14 +499,21 @@ public void queriesMustNotHaveMoreComponentsThanOrderBy() {

@Test
public void queryOrderByKeyBoundsMustBeStringsWithoutSlashes() {
CollectionReference collection = testCollection();
Query query = collection.orderBy(FieldPath.documentId());
Query query = testFirestore().collection("collection").orderBy(FieldPath.documentId());
Query cgQuery = testFirestore().collectionGroup("collection").orderBy(FieldPath.documentId());
expectError(
() -> query.startAt(1),
"Invalid query. Expected a string for document ID in startAt(), but got 1.");
expectError(
() -> query.startAt("foo/bar"),
"Invalid query. Document ID 'foo/bar' contains a slash in startAt().");
"Invalid query. When querying a collection and ordering by "
+ "FieldPath.documentId(), the value passed to startAt() must be a plain "
+ "document ID, but 'foo/bar' contains a slash.");
expectError(
() -> cgQuery.startAt("foo"),
"Invalid query. When querying a collection group and ordering by "
+ "FieldPath.documentId(), the value passed to startAt() must result in a valid "
+ "document path, but 'foo' is not because it contains an odd number of segments.");
}

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

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

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

reason =
"Invalid query. When querying a collection group by FieldPath.documentId(), the value "
+ "provided must result in a valid document path, but 'foo' is not because it has "
+ "an odd number of segments (1).";
expectError(
() ->
testFirestore()
.collectionGroup("collection")
.whereGreaterThanOrEqualTo(FieldPath.documentId(), "foo"),
reason);

reason =
"Invalid query. You can't perform array-contains queries on FieldPath.documentId() since "
+ "document IDs are not arrays.";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,30 @@ public DocumentReference document(@NonNull String documentPath) {
return DocumentReference.forPath(ResourcePath.fromString(documentPath), this);
}

// TODO(b/116617988): Expose API publicly once backend support is ready (and add to CHANGELOG.md).
/**
* Creates and returns a new @link{Query} that includes all documents in the database that are
* contained in a collection or subcollection with the given @code{collectionId}.
*
* @param collectionId Identifies the collections to query over. Every collection or subcollection
* with this ID as the last segment of its path will be included. Cannot contain a slash.
* @return The created Query.
*/
@NonNull
// @PublicApi
/* public */ Query collectionGroup(@NonNull String collectionId) {
checkNotNull(collectionId, "Provided collection ID must not be null.");
if (collectionId.contains("/")) {
throw new IllegalArgumentException(
String.format(
"Invalid collectionId '%s'. Collection IDs must not contain '/'.", collectionId));
}

ensureClientConfigured();
return new Query(
new com.google.firebase.firestore.core.Query(ResourcePath.EMPTY, collectionId), this);
}

/**
* Executes the given updateFunction and then attempts to commit the changes applied within the
* transaction. If any document read within the transaction has changed, the updateFunction will
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,20 +324,28 @@ private Query whereHelper(@NonNull FieldPath fieldPath, Operator op, Object valu
}
if (value instanceof String) {
String documentKey = (String) value;
if (documentKey.contains("/")) {
// TODO: Allow slashes once ancestor queries are supported
if (documentKey.isEmpty()) {
throw new IllegalArgumentException(
"Invalid query. When querying with FieldPath.documentId() you must provide a valid "
+ "document ID, but '"
+ "document ID, but it was an empty string.");
}
if (!query.isCollectionGroupQuery() && documentKey.contains("/")) {
throw new IllegalArgumentException(
"Invalid query. When querying a collection by FieldPath.documentId() you must "
+ "provide a plain document ID, but '"
+ documentKey
+ "' contains a '/' character.");
} else if (documentKey.isEmpty()) {
}
ResourcePath path = query.getPath().append(ResourcePath.fromString(documentKey));
if (!DocumentKey.isDocumentKey(path)) {
throw new IllegalArgumentException(
"Invalid query. When querying with FieldPath.documentId() you must provide a valid "
+ "document ID, but it was an empty string.");
"Invalid query. When querying a collection group by FieldPath.documentId(), the "
+ "value provided must result in a valid document path, but '"
+ path
+ "' is not because it has an odd number of segments ("
+ path.length()
+ ").");
}
ResourcePath path = this.query.getPath().append(documentKey);
hardAssert(path.length() % 2 == 0, "Path should be a document key");
fieldValue =
ReferenceValue.valueOf(this.getFirestore().getDatabaseId(), DocumentKey.fromPath(path));
} else if (value instanceof DocumentReference) {
Expand Down Expand Up @@ -655,15 +663,26 @@ private Bound boundFromFields(String methodName, Object[] values, boolean before
+ ".");
}
String documentId = (String) rawValue;
if (documentId.contains("/")) {
if (!query.isCollectionGroupQuery() && documentId.contains("/")) {
throw new IllegalArgumentException(
"Invalid query. Document ID '"
"Invalid query. When querying a collection and ordering by FieldPath.documentId(), "
+ "the value passed to "
+ methodName
+ "() must be a plain document ID, but '"
+ documentId
+ "' contains a slash in "
+ "' contains a slash.");
}
ResourcePath path = query.getPath().append(ResourcePath.fromString(documentId));
if (!DocumentKey.isDocumentKey(path)) {
throw new IllegalArgumentException(
"Invalid query. When querying a collection group and ordering by "
+ "FieldPath.documentId(), the value passed to "
+ methodName
+ "().");
+ "() must result in a valid document path, but '"
+ path
Copy link
Contributor

Choose a reason for hiding this comment

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

Optional nit: the wording of this exception is different from the one above, even though they communicate the same thing. Would it be possible to add a helper method like validateDocumentId and move the checks there? Might not be a good idea if it requires too much configurability.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So these errors are actually redundant. I could get rid of the first one, but I think it will be hit more often, and by handling it separately we can make the error message a little clearer. That said, I did go back and forth on this. I'll think about it a bit more as I do the iOS port.

+ "' is not because it contains an odd number of segments.");
}
DocumentKey key = DocumentKey.fromPath(query.getPath().append(documentId));
DocumentKey key = DocumentKey.fromPath(path);
components.add(ReferenceValue.valueOf(firestore.getDatabaseId(), key));
} else {
FieldValue wrapped = firestore.getDataConverter().parseQueryValue(rawValue);
Expand Down
Loading