diff --git a/docs/firestore/querying-collections.md b/docs/firestore/querying-collections.md index 87ada4c92..a3d849374 100644 --- a/docs/firestore/querying-collections.md +++ b/docs/firestore/querying-collections.md @@ -180,4 +180,23 @@ export class AppComponent { } ``` +## Collection Group Queries + +To query across collections and sub-collections with the same name anywhere in Firestore, you can use collection group queries. + +Collection Group Queries allow you to have a more nested data-structure without sacrificing performance. For example, we could easily query all comments a user posted; even if the comments were stored as a sub-collection under `Articles/**` or even nested deeply (`Articles/**/Comments/**/Comments/**/...`): + +```ts +constructor(private afs: AngularFirestore) { } + +ngOnInit() { + ... + // Get all the user's comments, no matter how deeply nested + this.comments$ = afs.collectionGroup('Comments', ref => ref.where('user', '==', userId)) + .valueChanges({ idField }); +} +``` + +`collectionGroup` returns an `AngularFirestoreCollectionGroup` which is similar to `AngularFirestoreCollection`. The main difference is that `AngularFirestoreCollectionGroup` has no data operation methods such as `add` because it doesn't have a concrete reference. + ### [Next Step: Getting started with Firebase Authentication](../auth/getting-started.md) diff --git a/src/firestore/collection-group/collection-group.spec.ts b/src/firestore/collection-group/collection-group.spec.ts new file mode 100644 index 000000000..cb5c7b527 --- /dev/null +++ b/src/firestore/collection-group/collection-group.spec.ts @@ -0,0 +1,469 @@ +import { FirebaseApp, AngularFireModule } from '@angular/fire'; +import { AngularFirestore } from '../firestore'; +import { AngularFirestoreModule } from '../firestore.module'; +import { AngularFirestoreDocument } from '../document/document'; +import { AngularFirestoreCollectionGroup } from './collection-group'; +import { QueryGroupFn, Query } from '../interfaces'; +import { Observable, BehaviorSubject, Subscription } from 'rxjs'; +import { skip, take, switchMap } from 'rxjs/operators'; + +import { TestBed, inject } from '@angular/core/testing'; +import { COMMON_CONFIG } from '../test-config'; + +import { Stock, randomName, FAKE_STOCK_DATA, createRandomStocks, delayAdd, delayDelete, delayUpdate, deleteThemAll } from '../utils.spec'; + +async function collectionHarness(afs: AngularFirestore, items: number, queryGroupFn?: QueryGroupFn) { + const randomCollectionName = randomName(afs.firestore); + const ref = afs.firestore.collection(`${randomCollectionName}`); + const firestore: any = afs.firestore; + const collectionGroup: Query = firestore.collectionGroup(randomCollectionName); + const queryFn = queryGroupFn || (ref => ref); + const stocks = new AngularFirestoreCollectionGroup(queryFn(collectionGroup), afs); + let names = await createRandomStocks(afs.firestore, ref, items); + return { randomCollectionName, ref, stocks, names }; +} + +describe('AngularFirestoreCollectionGroup', () => { + let app: FirebaseApp; + let afs: AngularFirestore; + let sub: Subscription; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + AngularFireModule.initializeApp(COMMON_CONFIG), + AngularFirestoreModule.enablePersistence({synchronizeTabs:true}) + ] + }); + inject([FirebaseApp, AngularFirestore], (_app: FirebaseApp, _afs: AngularFirestore) => { + app = _app; + afs = _afs; + })(); + }); + + afterEach(done => { + app.delete(); + done(); + }); + + describe('valueChanges()', () => { + + it('should get unwrapped snapshot', async (done: any) => { + const ITEMS = 4; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + + const sub = stocks.valueChanges().subscribe(data => { + // unsub immediately as we will be deleting data at the bottom + // and that will trigger another subscribe callback and fail + // the test + sub.unsubscribe(); + // We added four things. This should be four. + // This could not be four if the batch failed or + // if the collection state is altered during a test run + expect(data.length).toEqual(ITEMS); + data.forEach(stock => { + // We used the same piece of data so they should all equal + expect(stock).toEqual(FAKE_STOCK_DATA); + }); + // Delete them all + const promises = names.map(name => ref.doc(name).delete()); + Promise.all(promises).then(done).catch(fail); + }); + + }); + + it('should handle multiple subscriptions (hot)', async (done: any) => { + const ITEMS = 4; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const changes = stocks.valueChanges(); + const sub = changes.subscribe(() => {}).add( + changes.pipe(take(1)).subscribe(data => { + expect(data.length).toEqual(ITEMS); + sub.unsubscribe(); + }) + ).add(() => { + deleteThemAll(names, ref).then(done).catch(done.fail); + }); + }); + + it('should handle multiple subscriptions (warm)', async (done: any) => { + const ITEMS = 4; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const changes = stocks.valueChanges(); + changes.pipe(take(1)).subscribe(() => {}).add(() => { + const sub = changes.pipe(take(1)).subscribe(data => { + expect(data.length).toEqual(ITEMS); + }).add(() => { + deleteThemAll(names, ref).then(done).catch(done.fail); + }); + }); + }); + + it('should handle dynamic queries that return empty sets', async (done) => { + const ITEMS = 10; + let count = 0; + let firstIndex = 0; + let pricefilter$ = new BehaviorSubject(null); + const randomCollectionName = randomName(afs.firestore); + const ref = afs.firestore.collection(`${randomCollectionName}`); + let names = await createRandomStocks(afs.firestore, ref, ITEMS); + const sub = pricefilter$.pipe(switchMap(price => { + return afs.collection(randomCollectionName, ref => price ? ref.where('price', '==', price) : ref).valueChanges() + })).subscribe(data => { + count = count + 1; + // the first time should all be 'added' + if(count === 1) { + expect(data.length).toEqual(ITEMS); + pricefilter$.next(-1); + } + // on the second round, we should have filtered out everything + if(count === 2) { + expect(data.length).toEqual(0); + sub.unsubscribe(); + deleteThemAll(names, ref).then(done).catch(done.fail); + } + }); + }); + + }); + + describe('snapshotChanges()', () => { + + it('should listen to all snapshotChanges() by default', async (done) => { + const ITEMS = 10; + let count = 0; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const sub = stocks.snapshotChanges().subscribe(data => { + const ids = data.map(d => d.payload.doc.id); + count = count + 1; + // the first time should all be 'added' + if(count === 1) { + // make an update + ref.doc(names[0]).update({ price: 2}); + } + // on the second round, make sure the array is still the same + // length but the updated item is now modified + if(count === 2) { + expect(data.length).toEqual(ITEMS); + const change = data.filter(x => x.payload.doc.id === names[0])[0]; + expect(change.type).toEqual('modified'); + sub.unsubscribe(); + deleteThemAll(names, ref).then(done).catch(done.fail); + } + }); + }); + + it('should handle multiple subscriptions (hot)', async (done: any) => { + const ITEMS = 4; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const changes = stocks.snapshotChanges(); + const sub = changes.subscribe(() => {}).add( + changes.pipe(take(1)).subscribe(data => { + expect(data.length).toEqual(ITEMS); + sub.unsubscribe(); + }) + ).add(() => { + deleteThemAll(names, ref).then(done).catch(done.fail); + }); + }); + + it('should handle multiple subscriptions (warm)', async (done: any) => { + const ITEMS = 4; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const changes = stocks.snapshotChanges(); + changes.pipe(take(1)).subscribe(() => {}).add(() => { + const sub = changes.pipe(take(1)).subscribe(data => { + expect(data.length).toEqual(ITEMS); + }).add(() => { + deleteThemAll(names, ref).then(done).catch(done.fail); + }); + }); + }); + + it('should update order on queries', async (done) => { + const ITEMS = 10; + let count = 0; + let firstIndex = 0; + const { randomCollectionName, ref, stocks, names } = + await collectionHarness(afs, ITEMS, ref => ref.orderBy('price', 'desc')); + const sub = stocks.snapshotChanges().subscribe(data => { + count = count + 1; + // the first time should all be 'added' + if(count === 1) { + // make an update + firstIndex = data.filter(d => d.payload.doc.id === names[0])[0].payload.newIndex; + ref.doc(names[0]).update({ price: 2 }); + } + // on the second round, make sure the array is still the same + // length but the updated item is now modified + if(count === 2) { + expect(data.length).toEqual(ITEMS); + const change = data.filter(x => x.payload.doc.id === names[0])[0]; + expect(change.type).toEqual('modified'); + expect(change.payload.oldIndex).toEqual(firstIndex); + sub.unsubscribe(); + deleteThemAll(names, ref).then(done).catch(done.fail); + } + }); + }); + + it('should be able to filter snapshotChanges() types - modified', async (done) => { + const ITEMS = 10; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + + const sub = stocks.snapshotChanges(['modified']).pipe(skip(1)).subscribe(data => { + sub.unsubscribe(); + const change = data.filter(x => x.payload.doc.id === names[0])[0]; + expect(data.length).toEqual(1); + expect(change.payload.doc.data().price).toEqual(2); + expect(change.type).toEqual('modified'); + deleteThemAll(names, ref).then(done).catch(done.fail); + }); + + delayUpdate(ref, names[0], { price: 2 }); + }); + + it('should be able to filter snapshotChanges() types - added', async (done) => { + const ITEMS = 10; + let { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const nextId = ref.doc('a').id; + + const sub = stocks.snapshotChanges(['added']).pipe(skip(1)).subscribe(data => { + sub.unsubscribe(); + const change = data.filter(x => x.payload.doc.id === nextId)[0]; + expect(data.length).toEqual(ITEMS + 1); + expect(change.payload.doc.data().price).toEqual(2); + expect(change.type).toEqual('added'); + deleteThemAll(names, ref).then(done).catch(done.fail); + done(); + }); + + + names = names.concat([nextId]); + // TODO these two add tests are the only one really testing collection-group queries + // should flex more, maybe split the stocks between more than one collection + delayAdd(ref.doc(names[0]).collection(randomCollectionName), nextId, { price: 2 }); + }); + + it('should be able to filter snapshotChanges() types - added w/same id', async (done) => { + const ITEMS = 10; + let { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + + const sub = stocks.snapshotChanges(['added']).pipe(skip(1)).subscribe(data => { + sub.unsubscribe(); + const change = data.filter(x => x.payload.doc.id === names[0])[1]; + expect(data.length).toEqual(ITEMS + 1); + expect(change.payload.doc.data().price).toEqual(3); + expect(change.type).toEqual('added'); + ref.doc(names[0]).collection(randomCollectionName).doc(names[0]).delete() + .then(() => deleteThemAll(names, ref)) + .then(done).catch(done.fail); + done(); + }); + + delayAdd(ref.doc(names[0]).collection(randomCollectionName), names[0], { price: 3 }); + }); + + it('should be able to filter snapshotChanges() types - added/modified', async (done) => { + const ITEMS = 10; + let { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const nextId = ref.doc('a').id; + let count = 0; + + const sub = stocks.snapshotChanges(['added', 'modified']).pipe(skip(1),take(2)).subscribe(data => { + count += 1; + if (count == 1) { + const change = data.filter(x => x.payload.doc.id === nextId)[0]; + expect(data.length).toEqual(ITEMS + 1); + expect(change.payload.doc.data().price).toEqual(2); + expect(change.type).toEqual('added'); + delayUpdate(ref, names[0], { price: 2 }); + } + if (count == 2) { + const change = data.filter(x => x.payload.doc.id === names[0])[0]; + expect(data.length).toEqual(ITEMS + 1); + expect(change.payload.doc.data().price).toEqual(2); + expect(change.type).toEqual('modified'); + } + }).add(() => { + deleteThemAll(names, ref).then(done).catch(done.fail); + }); + + names = names.concat([nextId]); + delayAdd(ref, nextId, { price: 2 }); + }); + + it('should be able to filter snapshotChanges() types - removed', async (done) => { + const ITEMS = 10; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + + const sub = stocks.snapshotChanges(['added', 'removed']).pipe(skip(1)).subscribe(data => { + sub.unsubscribe(); + const change = data.filter(x => x.payload.doc.id === names[0]); + expect(data.length).toEqual(ITEMS - 1); + expect(change.length).toEqual(0); + deleteThemAll(names, ref).then(done).catch(done.fail); + done(); + }); + + delayDelete(ref, names[0], 400); + }); + + }); + + describe('stateChanges()', () => { + + it('should get stateChanges() updates', async (done: any) => { + const ITEMS = 10; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + + const sub = stocks.stateChanges().subscribe(data => { + // unsub immediately as we will be deleting data at the bottom + // and that will trigger another subscribe callback and fail + // the test + sub.unsubscribe(); + // We added ten things. This should be ten. + // This could not be ten if the batch failed or + // if the collection state is altered during a test run + expect(data.length).toEqual(ITEMS); + data.forEach(action => { + // We used the same piece of data so they should all equal + expect(action.payload.doc.data()).toEqual(FAKE_STOCK_DATA); + }); + deleteThemAll(names, ref).then(done).catch(done.fail); + }); + + }); + + it('should listen to all stateChanges() by default', async (done) => { + const ITEMS = 10; + let count = 0; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const sub = stocks.stateChanges().subscribe(data => { + count = count + 1; + if(count === 1) { + ref.doc(names[0]).update({ price: 2}); + } + if(count === 2) { + expect(data.length).toEqual(1); + expect(data[0].type).toEqual('modified'); + deleteThemAll(names, ref).then(done).catch(done.fail); + } + }); + }); + + it('should handle multiple subscriptions (hot)', async (done: any) => { + const ITEMS = 4; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const changes = stocks.stateChanges(); + const sub = changes.subscribe(() => {}).add( + changes.pipe(take(1)).subscribe(data => { + expect(data.length).toEqual(ITEMS); + sub.unsubscribe(); + }) + ).add(() => { + deleteThemAll(names, ref).then(done).catch(done.fail); + }); + }); + + it('should handle multiple subscriptions (warm)', async (done: any) => { + const ITEMS = 4; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const changes = stocks.stateChanges(); + changes.pipe(take(1)).subscribe(() => {}).add(() => { + const sub = changes.pipe(take(1)).subscribe(data => { + expect(data.length).toEqual(ITEMS); + }).add(() => { + deleteThemAll(names, ref).then(done).catch(done.fail); + }); + }); + }); + + it('should be able to filter stateChanges() types - modified', async (done) => { + const ITEMS = 10; + let count = 0; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + + const sub = stocks.stateChanges(['modified']).subscribe(data => { + sub.unsubscribe(); + expect(data.length).toEqual(1); + expect(data[0].payload.doc.data().price).toEqual(2); + expect(data[0].type).toEqual('modified'); + deleteThemAll(names, ref).then(done).catch(done.fail); + done(); + }); + + delayUpdate(ref, names[0], { price: 2 }); + }); + + it('should be able to filter stateChanges() types - added', async (done) => { + const ITEMS = 10; + let count = 0; + let { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + + const sub = stocks.stateChanges(['added']).pipe(skip(1)).subscribe(data => { + sub.unsubscribe(); + expect(data.length).toEqual(1); + expect(data[0].payload.doc.data().price).toEqual(2); + expect(data[0].type).toEqual('added'); + deleteThemAll(names, ref).then(done).catch(done.fail); + done(); + }); + + const nextId = ref.doc('a').id; + names = names.concat([nextId]); + delayAdd(ref, nextId, { price: 2 }); + }); + + it('should be able to filter stateChanges() types - removed', async (done) => { + const ITEMS = 10; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + + const sub = stocks.stateChanges(['removed']).subscribe(data => { + sub.unsubscribe(); + expect(data.length).toEqual(1); + expect(data[0].type).toEqual('removed'); + deleteThemAll(names, ref).then(done).catch(done.fail); + done(); + }); + + delayDelete(ref, names[0], 400); + }); + }); + + describe('auditTrail()', () => { + it('should listen to all events for auditTrail() by default', async (done) => { + const ITEMS = 10; + let count = 0; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + const sub = stocks.auditTrail().subscribe(data => { + count = count + 1; + if(count === 1) { + ref.doc(names[0]).update({ price: 2}); + } + if(count === 2) { + sub.unsubscribe(); + expect(data.length).toEqual(ITEMS + 1); + expect(data[data.length - 1].type).toEqual('modified'); + deleteThemAll(names, ref).then(done).catch(done.fail); + } + }); + }); + + it('should be able to filter auditTrail() types - removed', async (done) => { + const ITEMS = 10; + const { randomCollectionName, ref, stocks, names } = await collectionHarness(afs, ITEMS); + + const sub = stocks.auditTrail(['removed']).subscribe(data => { + sub.unsubscribe(); + expect(data.length).toEqual(1); + expect(data[0].type).toEqual('removed'); + deleteThemAll(names, ref).then(done).catch(done.fail); + done(); + }); + + delayDelete(ref, names[0], 400); + }); + }); + +}); diff --git a/src/firestore/collection-group/collection-group.ts b/src/firestore/collection-group/collection-group.ts new file mode 100644 index 000000000..8d2b9433f --- /dev/null +++ b/src/firestore/collection-group/collection-group.ts @@ -0,0 +1,109 @@ +import { Observable, from } from 'rxjs'; +import { fromCollectionRef } from '../observable/fromRef'; +import { map, filter, scan } from 'rxjs/operators'; +import { firestore } from 'firebase/app'; + +import { DocumentChangeType, CollectionReference, Query, DocumentReference, DocumentData, DocumentChangeAction } from '../interfaces'; +import { validateEventsArray } from '../collection/collection'; +import { docChanges, sortedChanges } from '../collection/changes'; +import { AngularFirestore } from '../firestore'; +import { runInZone } from '@angular/fire'; + +/** + * AngularFirestoreCollectionGroup service + * + * This class holds a reference to a Firestore Collection Group Query. + * + * This class uses Symbol.observable to transform into Observable using Observable.from(). + * + * This class is rarely used directly and should be created from the AngularFirestore service. + * + * Example: + * + * const collectionGroup = firebase.firestore.collectionGroup('stocks'); + * const query = collectionRef.where('price', '>', '0.01'); + * const fakeStock = new AngularFirestoreCollectionGroup(query, afs); + * + * // Subscribe to changes as snapshots. This provides you data updates as well as delta updates. + * fakeStock.valueChanges().subscribe(value => console.log(value)); + */ +export class AngularFirestoreCollectionGroup { + /** + * The constructor takes in a CollectionGroupQuery to provide wrapper methods + * for data operations and data streaming. + * @param query + * @param afs + */ + constructor( + private readonly query: Query, + private readonly afs: AngularFirestore) { } + + /** + * Listen to the latest change in the stream. This method returns changes + * as they occur and they are not sorted by query order. This allows you to construct + * your own data structure. + * @param events + */ + stateChanges(events?: DocumentChangeType[]): Observable[]> { + if(!events || events.length === 0) { + return this.afs.scheduler.keepUnstableUntilFirst( + this.afs.scheduler.runOutsideAngular( + docChanges(this.query) + ) + ); + } + return this.afs.scheduler.keepUnstableUntilFirst( + this.afs.scheduler.runOutsideAngular( + docChanges(this.query) + ) + ) + .pipe( + map(actions => actions.filter(change => events.indexOf(change.type) > -1)), + filter(changes => changes.length > 0) + ); + } + + /** + * Create a stream of changes as they occur it time. This method is similar to stateChanges() + * but it collects each event in an array over time. + * @param events + */ + auditTrail(events?: DocumentChangeType[]): Observable[]> { + return this.stateChanges(events).pipe(scan((current, action) => [...current, ...action], [])); + } + + /** + * Create a stream of synchronized changes. This method keeps the local array in sorted + * query order. + * @param events + */ + snapshotChanges(events?: DocumentChangeType[]): Observable[]> { + const validatedEvents = validateEventsArray(events); + const sortedChanges$ = sortedChanges(this.query, validatedEvents); + const scheduledSortedChanges$ = this.afs.scheduler.runOutsideAngular(sortedChanges$); + return this.afs.scheduler.keepUnstableUntilFirst(scheduledSortedChanges$); + } + + /** + * Listen to all documents in the collection and its possible query as an Observable. + */ + valueChanges(): Observable { + const fromCollectionRef$ = fromCollectionRef(this.query); + const scheduled$ = this.afs.scheduler.runOutsideAngular(fromCollectionRef$); + return this.afs.scheduler.keepUnstableUntilFirst(scheduled$) + .pipe( + map(actions => actions.payload.docs.map(a => a.data())) + ); + } + + /** + * Retrieve the results of the query once. + * @param options + */ + get(options?: firestore.GetOptions) { + return from(this.query.get(options)).pipe( + runInZone(this.afs.scheduler.zone) + ); + } + +} diff --git a/src/firestore/collection/changes.ts b/src/firestore/collection/changes.ts index f02260539..7b16c5f77 100644 --- a/src/firestore/collection/changes.ts +++ b/src/firestore/collection/changes.ts @@ -54,14 +54,14 @@ export function combineChanges(current: DocumentChange[], changes: Documen export function combineChange(combined: DocumentChange[], change: DocumentChange): DocumentChange[] { switch(change.type) { case 'added': - if (combined[change.newIndex] && combined[change.newIndex].doc.id == change.doc.id) { + if (combined[change.newIndex] && combined[change.newIndex].doc.ref.isEqual(change.doc.ref)) { // Not sure why the duplicates are getting fired } else { combined.splice(change.newIndex, 0, change); } break; case 'modified': - if (combined[change.oldIndex] == null || combined[change.oldIndex].doc.id == change.doc.id) { + if (combined[change.oldIndex] == null || combined[change.oldIndex].doc.ref.isEqual(change.doc.ref)) { // When an item changes position we first remove it // and then add it's new position if(change.oldIndex !== change.newIndex) { @@ -73,7 +73,7 @@ export function combineChange(combined: DocumentChange[], change: Document } break; case 'removed': - if (combined[change.oldIndex] && combined[change.oldIndex].doc.id == change.doc.id) { + if (combined[change.oldIndex] && combined[change.oldIndex].doc.ref.isEqual(change.doc.ref)) { combined.splice(change.oldIndex, 1); } break; diff --git a/src/firestore/firestore.ts b/src/firestore/firestore.ts index 3b7ff7e4b..c860a57f5 100644 --- a/src/firestore/firestore.ts +++ b/src/firestore/firestore.ts @@ -2,9 +2,10 @@ import { InjectionToken, NgZone, PLATFORM_ID, Injectable, Inject, Optional } fro import { Observable, of, from } from 'rxjs'; -import { Settings, PersistenceSettings, CollectionReference, DocumentReference, QueryFn, AssociatedReference } from './interfaces'; +import { Settings, PersistenceSettings, CollectionReference, DocumentReference, QueryFn, Query, QueryGroupFn, AssociatedReference } from './interfaces'; import { AngularFirestoreDocument } from './document/document'; import { AngularFirestoreCollection } from './collection/collection'; +import { AngularFirestoreCollectionGroup } from './collection-group/collection-group'; import { FirebaseFirestore, FirebaseOptions, FirebaseAppConfig, FirebaseOptionsToken, FirebaseNameOrConfigToken, _firebaseAppFactory, FirebaseZoneScheduler } from '@angular/fire'; import { isPlatformServer } from '@angular/common'; @@ -166,6 +167,21 @@ export class AngularFirestore { return new AngularFirestoreCollection(ref, query, this); } + /** + * Create a reference to a Firestore Collection Group based on a collectionId + * and an optional query function to narrow the result + * set. + * @param collectionId + * @param queryGroupFn + */ + collectionGroup(collectionId: string, queryGroupFn?: QueryGroupFn): AngularFirestoreCollectionGroup { + if (major < 6) { throw "collection group queries require Firebase JS SDK >= 6.0"} + const queryFn = queryGroupFn || (ref => ref); + const firestore: any = this.firestore; // SEMVER: ditch any once targeting >= 6.0 + const collectionGroup: Query = firestore.collectionGroup(collectionId); + return new AngularFirestoreCollectionGroup(queryFn(collectionGroup), this); + } + /** * Create a reference to a Firestore Document based on a path or * DocumentReference. Note that documents are not queryable because they are diff --git a/src/firestore/index.spec.ts b/src/firestore/index.spec.ts index fec1dc433..5824fa9c2 100644 --- a/src/firestore/index.spec.ts +++ b/src/firestore/index.spec.ts @@ -1,3 +1,4 @@ export * from './firestore.spec'; export * from './document/document.spec'; export * from './collection/collection.spec'; +export * from './collection-group/collection-group.spec'; \ No newline at end of file diff --git a/src/firestore/interfaces.ts b/src/firestore/interfaces.ts index e1d186a31..549022742 100644 --- a/src/firestore/interfaces.ts +++ b/src/firestore/interfaces.ts @@ -56,6 +56,8 @@ export interface Reference { // Example: const query = (ref) => ref.where('name', == 'david'); export type QueryFn = (ref: CollectionReference) => Query; +export type QueryGroupFn = (query: Query) => Query; + /** * A structure that provides an association between a reference * and a query on that reference. Note: Performing operations diff --git a/src/firestore/public_api.ts b/src/firestore/public_api.ts index e5cbd8250..dbfa0fdaa 100644 --- a/src/firestore/public_api.ts +++ b/src/firestore/public_api.ts @@ -1,6 +1,7 @@ export * from './firestore'; export * from './firestore.module'; export * from './collection/collection'; +export * from './collection-group/collection-group'; export * from './document/document'; export * from './collection/changes'; export * from './observable/fromRef'; diff --git a/src/firestore/utils.spec.ts b/src/firestore/utils.spec.ts index 3280cc6f1..d9de73aa0 100644 --- a/src/firestore/utils.spec.ts +++ b/src/firestore/utils.spec.ts @@ -32,19 +32,19 @@ export function deleteThemAll(names, ref) { return Promise.all(promises); } -export function delayUpdate(collection: AngularFirestoreCollection, path, data, delay = 250) { +export function delayUpdate(collection: AngularFirestoreCollection|firestore.CollectionReference, path, data, delay = 250) { setTimeout(() => { collection.doc(path).update(data); }, delay); } -export function delayAdd(collection: AngularFirestoreCollection, path, data, delay = 250) { +export function delayAdd(collection: AngularFirestoreCollection|firestore.CollectionReference, path, data, delay = 250) { setTimeout(() => { collection.doc(path).set(data); }, delay); } -export function delayDelete(collection: AngularFirestoreCollection, path, delay = 250) { +export function delayDelete(collection: AngularFirestoreCollection|firestore.CollectionReference, path, delay = 250) { setTimeout(() => { collection.doc(path).delete(); }, delay); diff --git a/src/root.spec.js b/src/root.spec.js index 27cfec26b..352ed6c06 100644 --- a/src/root.spec.js +++ b/src/root.spec.js @@ -4,6 +4,7 @@ export * from './packages-dist/auth/auth.spec'; export * from './packages-dist/firestore/firestore.spec'; export * from './packages-dist/firestore/document/document.spec'; export * from './packages-dist/firestore/collection/collection.spec'; +export * from './packages-dist/firestore/collection-group/collection-group.spec'; export * from './packages-dist/functions/functions.spec'; export * from './packages-dist/database/database.spec'; export * from './packages-dist/database/utils.spec';