From bd52301ad4ef35eeb4dbdab1bc4926db72d40949 Mon Sep 17 00:00:00 2001 From: Elliot Hesp Date: Tue, 11 Oct 2022 09:17:28 +0100 Subject: [PATCH] feat(firestore, count): implement AggregateQuery count() on collections --- ...tiveFirebaseFirestoreCollectionModule.java | 30 ++++++ packages/firestore/e2e/Aggregate/count.e2e.js | 70 ++++++++++++++ .../RNFBFirestoreCollectionModule.m | 37 +++++++ packages/firestore/lib/FirestoreAggregate.js | 52 ++++++++++ packages/firestore/lib/FirestoreQuery.js | 10 ++ packages/firestore/lib/index.d.ts | 96 +++++++++++++++++++ 6 files changed, 295 insertions(+) create mode 100644 packages/firestore/e2e/Aggregate/count.e2e.js create mode 100644 packages/firestore/lib/FirestoreAggregate.js diff --git a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java index 042401b7a6..84f15c5906 100644 --- a/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java +++ b/packages/firestore/android/src/reactnative/java/io/invertase/firebase/firestore/ReactNativeFirebaseFirestoreCollectionModule.java @@ -148,6 +148,36 @@ public void namedQueryGet( }); } + @ReactMethod + public void collectionCount( + String appName, + String path, + String type, + ReadableArray filters, + ReadableArray orders, + ReadableMap options, + Promise promise) { + FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName); + ReactNativeFirebaseFirestoreQuery firestoreQuery = + new ReactNativeFirebaseFirestoreQuery( + appName, getQueryForFirestore(firebaseFirestore, path, type), filters, orders, options); + + AggregateQuery aggregateQuery = firestoreQuery.query.count(); + + aggregateQuery + .get(AggregateSource.SERVER) + .addOnCompleteListener( + task -> { + if (task.isSuccessful()) { + WritableMap result = Arguments.createMap(); + result.putDouble("count", Long.valueOf(task.getResult().getCount()).doubleValue()); + promise.resolve(result); + } else { + rejectPromiseFirestoreException(promise, task.getException()); + } + }); + } + @ReactMethod public void collectionGet( String appName, diff --git a/packages/firestore/e2e/Aggregate/count.e2e.js b/packages/firestore/e2e/Aggregate/count.e2e.js new file mode 100644 index 0000000000..1dc733eed4 --- /dev/null +++ b/packages/firestore/e2e/Aggregate/count.e2e.js @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +const COLLECTION = 'firestore'; +const { wipe } = require('../helpers'); +describe('firestore().collection().count()', function () { + before(function () { + return wipe(); + }); + + it('throws if no argument provided', function () { + try { + firebase.firestore().collection(COLLECTION).startAt(); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + 'Expected a DocumentSnapshot or list of field values but got undefined', + ); + return Promise.resolve(); + } + }); + + it('gets count of collection reference - unfiltered', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/count/collection`); + + const doc1 = colRef.doc('doc1'); + const doc2 = colRef.doc('doc2'); + const doc3 = colRef.doc('doc3'); + await Promise.all([ + doc1.set({ foo: 1, bar: { value: 1 } }), + doc2.set({ foo: 2, bar: { value: 2 } }), + doc3.set({ foo: 3, bar: { value: 3 } }), + ]); + + const qs = await colRef.count().get(); + qs.data().count.should.eql(3); + }); + it('gets correct count of collection reference - where equal', async function () { + const colRef = firebase.firestore().collection(`${COLLECTION}/count/collection`); + + const doc1 = colRef.doc('doc1'); + const doc2 = colRef.doc('doc2'); + const doc3 = colRef.doc('doc3'); + await Promise.all([ + doc1.set({ foo: 1, bar: { value: 1 } }), + doc2.set({ foo: 2, bar: { value: 2 } }), + doc3.set({ foo: 3, bar: { value: 3 } }), + ]); + + const qs = await colRef.where('foo', '==', 3).count().get(); + qs.data().count.should.eql(1); + }); + + // TODO + // - test behavior when firestore is offline (network disconnected or actually offline?) + // - test AggregateQuery.query() +}); diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m index 82538ad04d..9700238c1e 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m @@ -165,6 +165,43 @@ - (void)invalidate { }]; } +RCT_EXPORT_METHOD(collectionCount + : (FIRApp *)firebaseApp + : (NSString *)path + : (NSString *)type + : (NSArray *)filters + : (NSArray *)orders + : (NSDictionary *)options + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + FIRFirestore *firestore = [RNFBFirestoreCommon getFirestoreForApp:firebaseApp]; + FIRQuery *query = [RNFBFirestoreCommon getQueryForFirestore:firestore path:path type:type]; + RNFBFirestoreQuery *firestoreQuery = [[RNFBFirestoreQuery alloc] initWithModifiers:firestore + query:query + filters:filters + orders:orders + options:options]; + + // NOTE: There is only "server" as the source at the moment. So this + // is unused for the time being. Using "FIRAggregateSourceServer". + // NSString *source = arguments[@"source"]; + + FIRAggregateQuery *aggregateQuery = [firestoreQuery.query count]; + + [aggregateQuery + aggregationWithSource:FIRAggregateSourceServer + completion:^(FIRAggregateQuerySnapshot *_Nullable snapshot, + NSError *_Nullable error) { + if (error) { + [RNFBFirestoreCommon promiseRejectFirestoreException:reject error:error]; + } else { + NSMutableDictionary *snapshotMap = [NSMutableDictionary dictionary]; + snapshotMap[@"count"] = snapshot.count; + resolve(snapshotMap); + } + }]; +} + RCT_EXPORT_METHOD(collectionGet : (FIRApp *)firebaseApp : (NSString *)path diff --git a/packages/firestore/lib/FirestoreAggregate.js b/packages/firestore/lib/FirestoreAggregate.js new file mode 100644 index 0000000000..2b8b0ae8a0 --- /dev/null +++ b/packages/firestore/lib/FirestoreAggregate.js @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2022-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export class FirestoreAggregateQuery { + constructor(firestore, query, collectionPath, modifiers) { + this._firestore = firestore; + this._query = query; + this._collectionPath = collectionPath; + this._modifiers = modifiers; + } + + get query() { + return this._query; + } + + get() { + return this._firestore.native + .collectionCount( + this._collectionPath.relativeName, + this._modifiers.type, + this._modifiers.filters, + this._modifiers.orders, + this._modifiers.options, + ) + .then(data => new FirestoreAggregateQuerySnapshot(this._query, data)); + } +} + +export class FirestoreAggregateQuerySnapshot { + constructor(query, data) { + this._query = query; + this._data = data; + } + + data() { + return { count: this._data.count }; + } +} diff --git a/packages/firestore/lib/FirestoreQuery.js b/packages/firestore/lib/FirestoreQuery.js index ff501f2246..f418c152f3 100644 --- a/packages/firestore/lib/FirestoreQuery.js +++ b/packages/firestore/lib/FirestoreQuery.js @@ -26,6 +26,7 @@ import NativeError from '@react-native-firebase/app/lib/internal/NativeFirebaseE import FirestoreDocumentSnapshot from './FirestoreDocumentSnapshot'; import FirestoreFieldPath, { fromDotSeparatedString } from './FirestoreFieldPath'; import FirestoreQuerySnapshot from './FirestoreQuerySnapshot'; +import { FirestoreAggregateQuery } from './FirestoreAggregate'; import { parseSnapshotArgs } from './utils'; let _id = 0; @@ -130,6 +131,15 @@ export default class FirestoreQuery { return modifiers.setFieldsCursor(cursor, allFields); } + count() { + return new FirestoreAggregateQuery( + this._firestore, + this, + this._collectionPath, + this._modifiers, + ); + } + endAt(docOrField, ...fields) { return new FirestoreQuery( this._firestore, diff --git a/packages/firestore/lib/index.d.ts b/packages/firestore/lib/index.d.ts index 51d9c017d5..e05714c6fd 100644 --- a/packages/firestore/lib/index.d.ts +++ b/packages/firestore/lib/index.d.ts @@ -840,11 +840,107 @@ export namespace FirebaseFirestoreTypes { source: 'default' | 'server' | 'cache'; } + /** + * Represents an aggregation that can be performed by Firestore. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export class AggregateField { + /** A type string to uniquely identify instances of this class. */ + type = 'AggregateField'; + } + + /** + * The union of all `AggregateField` types that are supported by Firestore. + */ + export type AggregateFieldType = AggregateField; + + /** + * A type whose property values are all `AggregateField` objects. + */ + export interface AggregateSpec { + [field: string]: AggregateFieldType; + } + + /** + * A type whose keys are taken from an `AggregateSpec`, and whose values are the + * result of the aggregation performed by the corresponding `AggregateField` + * from the input `AggregateSpec`. + */ + export type AggregateSpecData = { + [P in keyof T]: T[P] extends AggregateField ? U : never; + }; + + /** + * The results of executing an aggregation query. + */ + export interface AggregateQuerySnapshot { + /** + * The underlying query over which the aggregations recorded in this + * `AggregateQuerySnapshot` were performed. + */ + get query(): Query; + + /** + * Returns the results of the aggregations performed over the underlying + * query. + * + * The keys of the returned object will be the same as those of the + * `AggregateSpec` object specified to the aggregation method, and the values + * will be the corresponding aggregation result. + * + * @returns The results of the aggregations performed over the underlying + * query. + */ + data(): AggregateSpecData; + } + + /** + * The results of requesting an aggregated query. + */ + export interface AggregateQuery { + /** + * The underlying query for this instance. + */ + get query(): Query; + + /** + * Executes the query and returns the results as a AggregateQuerySnapshot. + * + * + * #### Example + * + * ```js + * const querySnapshot = await firebase.firestore() + * .collection('users') + * .count() + * .get(); + * ``` + * + * @param options An object to configure the get behavior. + */ + get(): Promise>; + } + /** * A Query refers to a `Query` which you can read or listen to. You can also construct refined `Query` objects by * adding filters and ordering. */ export interface Query { + /** + * Calculates the number of documents in the result set of the given query, without actually downloading + * the documents. + * + * Using this function to count the documents is efficient because only the final count, not the + * documents' data, is downloaded. This function can even count the documents if the result set + * would be prohibitively large to download entirely (e.g. thousands of documents). + * + * The result received from the server is presented, unaltered, without considering any local state. + * That is, documents in the local cache are not taken into consideration, neither are local + * modifications not yet synchronized with the server. Previously-downloaded results, if any, + * are not used: every request using this source necessarily involves a round trip to the server. + */ + count(): AggregateQuery<{ count: AggregateField }>; + /** * Creates and returns a new Query that ends at the provided document (inclusive). The end * position is relative to the order of the query. The document must contain all of the