Skip to content

Commit 21c0f3c

Browse files
authored
Collection Group Queries w/ indexing (#1440)
* Collection Group Queries w/ indexing * [AUTOMATED]: Prettier Code Styling * Initial review feedback. * CR Feedback: QueryIndexes renames. * QueryIndexes => IndexManager * indexCollectionParent() => addToCollectionParentIndex() * Create index entries from MutationQueue.addMutationBatch(). * Add spec tests. * Index existing data. * [AUTOMATED]: Prettier Code Styling * lint * Fix FieldPath.documentId() handling with CG queries. * Cleanup * Re-add accidentally-removed test. * Delete accidentally checked-in vim swap file. * Update changelog. * Tweak test to use path(). * Add asserts to verify collection paths. * Simplify schema migration test. * CR feedback. * Tweak comment. * CR Feedback. * Port minor CR feedback back to JS. * Hide public API for CG queries until backend support is ready.
1 parent 455d7db commit 21c0f3c

28 files changed

+1144
-77
lines changed

packages/firebase/index.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6006,6 +6006,20 @@ declare namespace firebase.firestore {
60066006
*/
60076007
doc(documentPath: string): DocumentReference;
60086008

6009+
// TODO(b/116617988): Uncomment method and change jsdoc comment to "/**"
6010+
// once backend support is ready.
6011+
/*
6012+
* Creates and returns a new Query that includes all documents in the
6013+
* database that are contained in a collection or subcollection with the
6014+
* given collectionId.
6015+
*
6016+
* @param collectionId Identifies the collections to query over. Every
6017+
* collection or subcollection with this ID as the last segment of its path
6018+
* will be included. Cannot contain a slash.
6019+
* @return The created Query.
6020+
*/
6021+
//collectionGroup(collectionId: string): Query;
6022+
60096023
/**
60106024
* Executes the given `updateFunction` and then attempts to commit the changes
60116025
* applied within the transaction. If any document read within the transaction

packages/firestore-types/index.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,20 @@ export class FirebaseFirestore {
156156
*/
157157
doc(documentPath: string): DocumentReference;
158158

159+
// TODO(b/116617988): Uncomment method and change jsdoc comment to "/**"
160+
// once backend support is ready.
161+
/*
162+
* Creates and returns a new Query that includes all documents in the
163+
* database that are contained in a collection or subcollection with the
164+
* given collectionId.
165+
*
166+
* @param collectionId Identifies the collections to query over. Every
167+
* collection or subcollection with this ID as the last segment of its path
168+
* will be included. Cannot contain a slash.
169+
* @return The created Query.
170+
*/
171+
//collectionGroup(collectionId: string): Query;
172+
159173
/**
160174
* Executes the given updateFunction and then attempts to commit the
161175
* changes applied within the transaction. If any document read within the

packages/firestore/src/api/database.ts

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -486,28 +486,38 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService {
486486
collection(pathString: string): firestore.CollectionReference {
487487
validateExactNumberOfArgs('Firestore.collection', arguments, 1);
488488
validateArgType('Firestore.collection', 'non-empty string', 1, pathString);
489-
if (!pathString) {
490-
throw new FirestoreError(
491-
Code.INVALID_ARGUMENT,
492-
'Must provide a non-empty collection path to collection()'
493-
);
494-
}
495-
496489
this.ensureClientConfigured();
497490
return new CollectionReference(ResourcePath.fromString(pathString), this);
498491
}
499492

500493
doc(pathString: string): firestore.DocumentReference {
501494
validateExactNumberOfArgs('Firestore.doc', arguments, 1);
502495
validateArgType('Firestore.doc', 'non-empty string', 1, pathString);
503-
if (!pathString) {
496+
this.ensureClientConfigured();
497+
return DocumentReference.forPath(ResourcePath.fromString(pathString), this);
498+
}
499+
500+
// TODO(b/116617988): Fix name, uncomment d.ts definitions, and update CHANGELOG.md.
501+
_collectionGroup(collectionId: string): firestore.Query {
502+
validateExactNumberOfArgs('Firestore.collectionGroup', arguments, 1);
503+
validateArgType(
504+
'Firestore.collectionGroup',
505+
'non-empty string',
506+
1,
507+
collectionId
508+
);
509+
if (collectionId.indexOf('/') >= 0) {
504510
throw new FirestoreError(
505511
Code.INVALID_ARGUMENT,
506-
'Must provide a non-empty document path to doc()'
512+
`Invalid collection ID '${collectionId}' passed to function ` +
513+
`Firestore.collectionGroup(). Collection IDs must not contain '/'.`
507514
);
508515
}
509516
this.ensureClientConfigured();
510-
return DocumentReference.forPath(ResourcePath.fromString(pathString), this);
517+
return new Query(
518+
new InternalQuery(ResourcePath.EMPTY_PATH, collectionId),
519+
this
520+
);
511521
}
512522

513523
runTransaction<T>(
@@ -1356,25 +1366,36 @@ export class Query implements firestore.Query {
13561366
);
13571367
}
13581368
if (typeof value === 'string') {
1359-
if (value.indexOf('/') !== -1) {
1360-
// TODO(dimond): Allow slashes once ancestor queries are supported
1369+
if (value === '') {
13611370
throw new FirestoreError(
13621371
Code.INVALID_ARGUMENT,
13631372
'Function Query.where() requires its third parameter to be a ' +
13641373
'valid document ID if the first parameter is ' +
1365-
'FieldPath.documentId(), but it contains a slash.'
1374+
'FieldPath.documentId(), but it was an empty string.'
13661375
);
13671376
}
1368-
if (value === '') {
1377+
if (
1378+
!this._query.isCollectionGroupQuery() &&
1379+
value.indexOf('/') !== -1
1380+
) {
13691381
throw new FirestoreError(
13701382
Code.INVALID_ARGUMENT,
1371-
'Function Query.where() requires its third parameter to be a ' +
1372-
'valid document ID if the first parameter is ' +
1373-
'FieldPath.documentId(), but it was an empty string.'
1383+
`Invalid third parameter to Query.where(). When querying a collection by ` +
1384+
`FieldPath.documentId(), the value provided must be a plain document ID, but ` +
1385+
`'${value}' contains a slash.`
1386+
);
1387+
}
1388+
const path = this._query.path.child(ResourcePath.fromString(value));
1389+
if (!DocumentKey.isDocumentKey(path)) {
1390+
throw new FirestoreError(
1391+
Code.INVALID_ARGUMENT,
1392+
`Invalid third parameter to Query.where(). When querying a collection group by ` +
1393+
`FieldPath.documentId(), the value provided must result in a valid document path, ` +
1394+
`but '${path}' is not because it has an odd number of segments (${
1395+
path.length
1396+
}).`
13741397
);
13751398
}
1376-
const path = this._query.path.child(new ResourcePath([value]));
1377-
assert(path.length % 2 === 0, 'Path should be a document key');
13781399
fieldValue = new RefValue(
13791400
this.firestore._databaseId,
13801401
new DocumentKey(path)
@@ -1639,14 +1660,28 @@ export class Query implements firestore.Query {
16391660
`${methodName}(), but got a ${typeof rawValue}`
16401661
);
16411662
}
1642-
if (rawValue.indexOf('/') !== -1) {
1663+
if (
1664+
!this._query.isCollectionGroupQuery() &&
1665+
rawValue.indexOf('/') !== -1
1666+
) {
1667+
throw new FirestoreError(
1668+
Code.INVALID_ARGUMENT,
1669+
`Invalid query. When querying a collection and ordering by FieldPath.documentId(), ` +
1670+
`the value passed to ${methodName}() must be a plain document ID, but ` +
1671+
`'${rawValue}' contains a slash.`
1672+
);
1673+
}
1674+
const path = this._query.path.child(ResourcePath.fromString(rawValue));
1675+
if (!DocumentKey.isDocumentKey(path)) {
16431676
throw new FirestoreError(
16441677
Code.INVALID_ARGUMENT,
1645-
`Invalid query. Document ID '${rawValue}' contains a slash in ` +
1646-
`${methodName}()`
1678+
`Invalid query. When querying a collection group and ordering by ` +
1679+
`FieldPath.documentId(), the value passed to ${methodName}() must result in a ` +
1680+
`valid document path, but '${path}' is not because it contains an odd number ` +
1681+
`of segments.`
16471682
);
16481683
}
1649-
const key = new DocumentKey(this._query.path.child(rawValue));
1684+
const key = new DocumentKey(path);
16501685
components.push(new RefValue(this.firestore._databaseId, key));
16511686
} else {
16521687
const wrapped = this.firestore._dataConverter.parseQueryValue(

packages/firestore/src/core/query.ts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,13 @@ export class Query {
3737
private memoizedCanonicalId: string | null = null;
3838
private memoizedOrderBy: OrderBy[] | null = null;
3939

40+
/**
41+
* Initializes a Query with a path and optional additional query constraints.
42+
* Path must currently be empty if this is a collection group query.
43+
*/
4044
constructor(
4145
readonly path: ResourcePath,
46+
readonly collectionGroup: string | null = null,
4247
readonly explicitOrderBy: OrderBy[] = [],
4348
readonly filters: Filter[] = [],
4449
readonly limit: number | null = null,
@@ -111,13 +116,12 @@ export class Query {
111116
'Query must only have one inequality field.'
112117
);
113118

114-
assert(
115-
!DocumentKey.isDocumentKey(this.path),
116-
'No filtering allowed for document query'
117-
);
119+
assert(!this.isDocumentQuery(), 'No filtering allowed for document query');
120+
118121
const newFilters = this.filters.concat([filter]);
119122
return new Query(
120123
this.path,
124+
this.collectionGroup,
121125
this.explicitOrderBy.slice(),
122126
newFilters,
123127
this.limit,
@@ -127,15 +131,12 @@ export class Query {
127131
}
128132

129133
addOrderBy(orderBy: OrderBy): Query {
130-
assert(
131-
!DocumentKey.isDocumentKey(this.path),
132-
'No ordering allowed for document query'
133-
);
134134
assert(!this.startAt && !this.endAt, 'Bounds must be set after orderBy');
135135
// TODO(dimond): validate that orderBy does not list the same key twice.
136136
const newOrderBy = this.explicitOrderBy.concat([orderBy]);
137137
return new Query(
138138
this.path,
139+
this.collectionGroup,
139140
newOrderBy,
140141
this.filters.slice(),
141142
this.limit,
@@ -147,6 +148,7 @@ export class Query {
147148
withLimit(limit: number | null): Query {
148149
return new Query(
149150
this.path,
151+
this.collectionGroup,
150152
this.explicitOrderBy.slice(),
151153
this.filters.slice(),
152154
limit,
@@ -158,6 +160,7 @@ export class Query {
158160
withStartAt(bound: Bound): Query {
159161
return new Query(
160162
this.path,
163+
this.collectionGroup,
161164
this.explicitOrderBy.slice(),
162165
this.filters.slice(),
163166
this.limit,
@@ -169,6 +172,7 @@ export class Query {
169172
withEndAt(bound: Bound): Query {
170173
return new Query(
171174
this.path,
175+
this.collectionGroup,
172176
this.explicitOrderBy.slice(),
173177
this.filters.slice(),
174178
this.limit,
@@ -177,12 +181,33 @@ export class Query {
177181
);
178182
}
179183

184+
/**
185+
* Helper to convert a collection group query into a collection query at a
186+
* specific path. This is used when executing collection group queries, since
187+
* we have to split the query into a set of collection queries at multiple
188+
* paths.
189+
*/
190+
asCollectionQueryAtPath(path: ResourcePath): Query {
191+
return new Query(
192+
path,
193+
/*collectionGroup=*/ null,
194+
this.explicitOrderBy.slice(),
195+
this.filters.slice(),
196+
this.limit,
197+
this.startAt,
198+
this.endAt
199+
);
200+
}
201+
180202
// TODO(b/29183165): This is used to get a unique string from a query to, for
181203
// example, use as a dictionary key, but the implementation is subject to
182204
// collisions. Make it collision-free.
183205
canonicalId(): string {
184206
if (this.memoizedCanonicalId === null) {
185207
let canonicalId = this.path.canonicalString();
208+
if (this.isCollectionGroupQuery()) {
209+
canonicalId += '|cg:' + this.collectionGroup;
210+
}
186211
canonicalId += '|f:';
187212
for (const filter of this.filters) {
188213
canonicalId += filter.canonicalId();
@@ -213,6 +238,9 @@ export class Query {
213238

214239
toString(): string {
215240
let str = 'Query(' + this.path.canonicalString();
241+
if (this.isCollectionGroupQuery()) {
242+
str += ' collectionGroup=' + this.collectionGroup;
243+
}
216244
if (this.filters.length > 0) {
217245
str += `, filters: [${this.filters.join(', ')}]`;
218246
}
@@ -257,6 +285,10 @@ export class Query {
257285
}
258286
}
259287

288+
if (this.collectionGroup !== other.collectionGroup) {
289+
return false;
290+
}
291+
260292
if (!this.path.isEqual(other.path)) {
261293
return false;
262294
}
@@ -291,7 +323,7 @@ export class Query {
291323

292324
matches(doc: Document): boolean {
293325
return (
294-
this.matchesAncestor(doc) &&
326+
this.matchesPathAndCollectionGroup(doc) &&
295327
this.matchesOrderBy(doc) &&
296328
this.matchesFilters(doc) &&
297329
this.matchesBounds(doc)
@@ -328,19 +360,32 @@ export class Query {
328360
}
329361

330362
isDocumentQuery(): boolean {
331-
return DocumentKey.isDocumentKey(this.path) && this.filters.length === 0;
363+
return (
364+
DocumentKey.isDocumentKey(this.path) &&
365+
this.collectionGroup === null &&
366+
this.filters.length === 0
367+
);
368+
}
369+
370+
isCollectionGroupQuery(): boolean {
371+
return this.collectionGroup !== null;
332372
}
333373

334-
private matchesAncestor(doc: Document): boolean {
374+
private matchesPathAndCollectionGroup(doc: Document): boolean {
335375
const docPath = doc.key.path;
336-
if (DocumentKey.isDocumentKey(this.path)) {
376+
if (this.collectionGroup !== null) {
377+
// NOTE: this.path is currently always empty since we don't expose Collection
378+
// Group queries rooted at a document path yet.
379+
return (
380+
doc.key.hasCollectionId(this.collectionGroup) &&
381+
this.path.isPrefixOf(docPath)
382+
);
383+
} else if (DocumentKey.isDocumentKey(this.path)) {
337384
// exact match for document queries
338385
return this.path.isEqual(docPath);
339386
} else {
340387
// shallow ancestor queries by default
341-
return (
342-
this.path.isPrefixOf(docPath) && this.path.length === docPath.length - 1
343-
);
388+
return this.path.isImmediateParentOf(docPath);
344389
}
345390
}
346391

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Copyright 2019 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { ResourcePath } from '../model/path';
18+
import { PersistenceTransaction } from './persistence';
19+
import { PersistencePromise } from './persistence_promise';
20+
21+
/**
22+
* Represents a set of indexes that are used to execute queries efficiently.
23+
*
24+
* Currently the only index is a [collection id] => [parent path] index, used
25+
* to execute Collection Group queries.
26+
*/
27+
export interface IndexManager {
28+
/**
29+
* Creates an index entry mapping the collectionId (last segment of the path)
30+
* to the parent path (either the containing document location or the empty
31+
* path for root-level collections). Index entries can be retrieved via
32+
* getCollectionParents().
33+
*
34+
* NOTE: Currently we don't remove index entries. If this ends up being an
35+
* issue we can devise some sort of GC strategy.
36+
*/
37+
addToCollectionParentIndex(
38+
transaction: PersistenceTransaction,
39+
collectionPath: ResourcePath
40+
): PersistencePromise<void>;
41+
42+
/**
43+
* Retrieves all parent locations containing the given collectionId, as a
44+
* list of paths (each path being either a document location or the empty
45+
* path for a root-level collection).
46+
*/
47+
getCollectionParents(
48+
transaction: PersistenceTransaction,
49+
collectionId: string
50+
): PersistencePromise<ResourcePath[]>;
51+
}

0 commit comments

Comments
 (0)