From e4c57fae5558d47af06fc82412ff61dc5ae54698 Mon Sep 17 00:00:00 2001 From: Russell Wheatley Date: Thu, 14 Nov 2024 15:10:50 +0000 Subject: [PATCH] feat(firestore): implement `sum()` & `average()` aggregate queries (#8115) * feat(firestore): support for aggregate queries including `sum()` & `average()` * feat(firestore, android): working version of aggregate query * feat: iOS implementation of aggregate queries * test: getAggregateFromServer() * test: update e2e tests * chore: improve typing * chore: format * chore: rm assertions * chore: format * feat: 'other' platform support * tes: fix test scopes * fix: firestore lite has different name for API * test: ensure exposed to end user * test: fix broken tests * fix(android): allow null value for average * chore: fix typo * fix(firestore, android): send null errors through promise reject path having native module exceptions vs promise rejects requires JS level code to handle multiple types of error vs being able to use one style * test: update aggregate query to see what happens with float handling * fix: update exception handling iOS * chore: AggregateQuerySnapshot type update * fix: return after promise rejection * fix: android, fieldPath can be null for count. fix promise.reject * chore: remove tag * test: edge cases for aggregate queries * chore: remove only() for test * test: update what test produces * test: correct return type expected * test: ensure aggregate fields are exposed to end user --------- Co-authored-by: Mike Hardy --- .../firestore/__tests__/firestore.test.ts | 20 + ...tiveFirebaseFirestoreCollectionModule.java | 111 ++++ .../e2e/Aggregate/AggregateQuery.e2e.js | 567 ++++++++++++++++++ packages/firestore/e2e/Aggregate/count.e2e.js | 4 +- .../RNFBFirestoreCollectionModule.m | 75 +++ packages/firestore/lib/FirestoreAggregate.js | 46 +- packages/firestore/lib/index.d.ts | 10 +- packages/firestore/lib/modular/index.d.ts | 70 +++ packages/firestore/lib/modular/index.js | 93 +++ .../firestore/lib/web/RNFBFirestoreModule.js | 42 ++ packages/firestore/lib/web/query.js | 2 +- 11 files changed, 1031 insertions(+), 9 deletions(-) create mode 100644 packages/firestore/e2e/Aggregate/AggregateQuery.e2e.js diff --git a/packages/firestore/__tests__/firestore.test.ts b/packages/firestore/__tests__/firestore.test.ts index 28e232c53f..29f661fe9b 100644 --- a/packages/firestore/__tests__/firestore.test.ts +++ b/packages/firestore/__tests__/firestore.test.ts @@ -4,6 +4,10 @@ import firestore, { firebase, Filter, getFirestore, + getAggregateFromServer, + count, + average, + sum, addDoc, doc, collection, @@ -651,6 +655,22 @@ describe('Firestore', function () { it('`enablePersistentCacheIndexAutoCreation` is properly exposed to end user', function () { expect(enablePersistentCacheIndexAutoCreation).toBeDefined(); }); + + it('`getAggregateFromServer` is properly exposed to end user', function () { + expect(getAggregateFromServer).toBeDefined(); + }); + + it('`count` is properly exposed to end user', function () { + expect(count).toBeDefined(); + }); + + it('`average` is properly exposed to end user', function () { + expect(average).toBeDefined(); + }); + + it('`sum` is properly exposed to end user', function () { + expect(sum).toBeDefined(); + }); }); describe('FirestorePersistentCacheIndexManager', function () { 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 982e38680c..b44b87170f 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 @@ -17,6 +17,8 @@ * */ +import static com.google.firebase.firestore.AggregateField.average; +import static com.google.firebase.firestore.AggregateField.sum; import static io.invertase.firebase.firestore.ReactNativeFirebaseFirestoreCommon.rejectPromiseFirestoreException; import static io.invertase.firebase.firestore.ReactNativeFirebaseFirestoreSerialize.snapshotToWritableMap; import static io.invertase.firebase.firestore.UniversalFirebaseFirestoreCommon.getFirestoreForApp; @@ -28,6 +30,7 @@ import com.google.firebase.firestore.*; import io.invertase.firebase.common.ReactNativeFirebaseEventEmitter; import io.invertase.firebase.common.ReactNativeFirebaseModule; +import java.util.ArrayList; public class ReactNativeFirebaseFirestoreCollectionModule extends ReactNativeFirebaseModule { private static final String SERVICE_NAME = "FirestoreCollection"; @@ -193,6 +196,114 @@ public void collectionCount( }); } + @ReactMethod + public void aggregateQuery( + String appName, + String databaseId, + String path, + String type, + ReadableArray filters, + ReadableArray orders, + ReadableMap options, + ReadableArray aggregateQueries, + Promise promise) { + FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName, databaseId); + ReactNativeFirebaseFirestoreQuery firestoreQuery = + new ReactNativeFirebaseFirestoreQuery( + appName, + databaseId, + getQueryForFirestore(firebaseFirestore, path, type), + filters, + orders, + options); + + ArrayList aggregateFields = new ArrayList<>(); + + for (int i = 0; i < aggregateQueries.size(); i++) { + ReadableMap aggregateQuery = aggregateQueries.getMap(i); + String aggregateType = aggregateQuery.getString("aggregateType"); + if (aggregateType == null) aggregateType = ""; + String fieldPath = aggregateQuery.getString("field"); + + switch (aggregateType) { + case "count": + aggregateFields.add(AggregateField.count()); + break; + case "sum": + aggregateFields.add(AggregateField.sum(fieldPath)); + break; + case "average": + aggregateFields.add(AggregateField.average(fieldPath)); + break; + default: + rejectPromiseWithCodeAndMessage( + promise, "firestore/invalid-argument", "Invalid AggregateType: " + aggregateType); + return; + } + } + AggregateQuery firestoreAggregateQuery = + firestoreQuery.query.aggregate( + aggregateFields.get(0), + aggregateFields.subList(1, aggregateFields.size()).toArray(new AggregateField[0])); + + firestoreAggregateQuery + .get(AggregateSource.SERVER) + .addOnCompleteListener( + task -> { + if (task.isSuccessful()) { + WritableMap result = Arguments.createMap(); + AggregateQuerySnapshot snapshot = task.getResult(); + + for (int k = 0; k < aggregateQueries.size(); k++) { + ReadableMap aggQuery = aggregateQueries.getMap(k); + String aggType = aggQuery.getString("aggregateType"); + if (aggType == null) aggType = ""; + String field = aggQuery.getString("field"); + String key = aggQuery.getString("key"); + + if (key == null) { + rejectPromiseWithCodeAndMessage( + promise, "firestore/invalid-argument", "key may not be null"); + return; + } + + switch (aggType) { + case "count": + result.putDouble(key, Long.valueOf(snapshot.getCount()).doubleValue()); + break; + case "sum": + Number sum = (Number) snapshot.get(sum(field)); + if (sum == null) { + rejectPromiseWithCodeAndMessage( + promise, "firestore/unknown", "sum unexpectedly null"); + return; + } + result.putDouble(key, sum.doubleValue()); + break; + case "average": + Number average = snapshot.get(average(field)); + if (average == null) { + result.putNull(key); + } else { + result.putDouble(key, average.doubleValue()); + } + break; + default: + rejectPromiseWithCodeAndMessage( + promise, + "firestore/invalid-argument", + "Invalid AggregateType: " + aggType); + return; + } + } + + promise.resolve(result); + } else { + rejectPromiseFirestoreException(promise, task.getException()); + } + }); + } + @ReactMethod public void collectionGet( String appName, diff --git a/packages/firestore/e2e/Aggregate/AggregateQuery.e2e.js b/packages/firestore/e2e/Aggregate/AggregateQuery.e2e.js new file mode 100644 index 0000000000..1d593ceb92 --- /dev/null +++ b/packages/firestore/e2e/Aggregate/AggregateQuery.e2e.js @@ -0,0 +1,567 @@ +/* + * 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('getAggregateFromServer()', function () { + before(async function () { + return await wipe(); + }); + + describe('throws exceptions for incorrect inputs', function () { + it('throws if incorrect `query` argument', function () { + const { getAggregateFromServer, count } = firestoreModular; + const aggregateSpec = { + count: count(), + }; + try { + getAggregateFromServer(null, aggregateSpec); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + 'getAggregateFromServer(*, aggregateSpec)` `query` must be an instance of `FirestoreQuery`', + ); + } + + try { + getAggregateFromServer(undefined, aggregateSpec); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + 'getAggregateFromServer(*, aggregateSpec)` `query` must be an instance of `FirestoreQuery`', + ); + } + + try { + getAggregateFromServer('some-string', aggregateSpec); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + 'getAggregateFromServer(*, aggregateSpec)` `query` must be an instance of `FirestoreQuery`', + ); + } + return Promise.resolve(); + }); + + it('throws if incorrect `aggregateSpec` argument', function () { + const { getAggregateFromServer, collection, getFirestore, count } = firestoreModular; + + const colRef = collection(getFirestore(), `firestore`); + + try { + getAggregateFromServer(colRef, 'not an object'); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + '`getAggregateFromServer(query, *)` `aggregateSpec` must be an object', + ); + } + + const aggregateSpec = { + count: "doesn't contain an aggregate field", + }; + + try { + getAggregateFromServer(colRef, aggregateSpec); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + '`getAggregateFromServer(query, *)` `aggregateSpec` must contain at least one `AggregateField`', + ); + } + + const aggField = count(); + aggField.aggregateType = 'change underlying type'; + + const aggregateSpec2 = { + count: aggField, + }; + + try { + getAggregateFromServer(colRef, aggregateSpec2); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql("'AggregateField' has an an unknown 'AggregateType'"); + } + return Promise.resolve(); + }); + }); + + describe('count(), average() & sum()', function () { + it('single path using `string`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, count, average, sum } = + firestoreModular; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count/collection`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { bar: 0.4, baz: 3 }), + setDoc(doc(colRef, 'two'), { bar: 0.5, baz: 3 }), + setDoc(doc(colRef, 'three'), { bar: 0.6, baz: 3 }), + ]); + + const aggregateSpec = { + ignoreThisProperty: 'not aggregate field', + countCollection: count(), + averageBar: average('bar'), + sumBaz: sum('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.countCollection.should.eql(3); + data.averageBar.should.eql(0.5); + data.sumBaz.should.eql(9); + // should only return the aggregate field requests + data.should.not.have.property('ignoreThisProperty'); + }); + + it('single path using `FieldPath`', async function () { + const { + getAggregateFromServer, + doc, + setDoc, + collection, + getFirestore, + count, + average, + sum, + FieldPath, + } = firestoreModular; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count-field-path/collection`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { bar: 5, baz: 4 }), + setDoc(doc(colRef, 'two'), { bar: 5, baz: 4 }), + setDoc(doc(colRef, 'three'), { bar: 5, baz: 4 }), + ]); + + const aggregateSpec = { + ignoreThisProperty: 'not aggregate field', + countCollection: count(), + averageBar: average(new FieldPath('bar')), + sumBaz: sum(new FieldPath('baz')), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.countCollection.should.eql(3); + data.averageBar.should.eql(5); + data.sumBaz.should.eql(12); + // should only return the aggregate field requests + data.should.not.have.property('ignoreThisProperty'); + }); + + it('nested object using `string`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, count, average, sum } = + firestoreModular; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count-nested/collection`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { foo: { bar: 5, baz: 4 } }), + setDoc(doc(colRef, 'two'), { foo: { bar: 5, baz: 4 } }), + setDoc(doc(colRef, 'three'), { foo: { bar: 5, baz: 4 } }), + ]); + + const aggregateSpec = { + ignoreThisProperty: 'not aggregate field', + countCollection: count(), + averageBar: average('foo.bar'), + sumBaz: sum('foo.baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.countCollection.should.eql(3); + data.averageBar.should.eql(5); + data.sumBaz.should.eql(12); + // should only return the aggregate field requests + data.should.not.have.property('ignoreThisProperty'); + }); + + it('nested object using `FieldPath`', async function () { + const { + getAggregateFromServer, + doc, + setDoc, + collection, + getFirestore, + count, + average, + sum, + FieldPath, + } = firestoreModular; + const firestore = getFirestore(); + + const colRef = collection( + firestore, + `${COLLECTION}/aggregate-count-nested-field-path/collection`, + ); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { foo: { bar: 5, baz: 4 } }), + setDoc(doc(colRef, 'two'), { foo: { bar: 5, baz: 4 } }), + setDoc(doc(colRef, 'three'), { foo: { bar: 5, baz: 4 } }), + ]); + + const aggregateSpec = { + ignoreThisProperty: 'not aggregate field', + countCollection: count(), + averageBar: average(new FieldPath('foo.bar')), + sumBaz: sum(new FieldPath('foo.baz')), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.countCollection.should.eql(3); + data.averageBar.should.eql(5); + data.sumBaz.should.eql(12); + // should only return the aggregate field requests + data.should.not.have.property('ignoreThisProperty'); + }); + + describe('edge cases for aggregate query', function () { + it('no existing collection responses for average(), sum() & count()', async function () { + const { getAggregateFromServer, collection, getFirestore, count, average, sum } = + firestoreModular; + const firestore = getFirestore(); + + const colRefNoDocs = collection(firestore, `${COLLECTION}/aggregate-count/no-docs`); + + const aggregateSpecNoDocuments = { + countCollection: count(), + averageBar: average('bar'), + sumBaz: sum('baz'), + }; + + const resultNoDocs = await getAggregateFromServer(colRefNoDocs, aggregateSpecNoDocuments); + + const dataNoDocs = resultNoDocs.data(); + + // average returns null, whilst sum and count return 0 + dataNoDocs.countCollection.should.eql(0); + should(dataNoDocs.averageBar).be.null(); + dataNoDocs.sumBaz.should.eql(0); + }); + + it('sum of `0.3`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, sum } = + firestoreModular; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count/sum-0-3`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { bar: 0.4, baz: 0.1 }), + setDoc(doc(colRef, 'two'), { bar: 0.5, baz: 0.1 }), + setDoc(doc(colRef, 'three'), { bar: 0.6, baz: 0.1 }), + ]); + + const aggregateSpec = { + sumBaz: sum('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.sumBaz.should.eql(0.30000000000000004); + }); + + it('return JavaScript single max safe integer for `sum()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, sum } = + firestoreModular; + const MAX_INT = Number.MAX_SAFE_INTEGER; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count/max-int`); + + await Promise.all([setDoc(doc(colRef, 'one'), { baz: MAX_INT })]); + + const aggregateSpec = { + sumBaz: sum('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.sumBaz.should.eql(MAX_INT); + }); + + it('return JavaScript nine max safe integers for `sum()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, sum } = + firestoreModular; + const MAX_INT = Number.MAX_SAFE_INTEGER; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count/max-int-2`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { baz: MAX_INT }), + setDoc(doc(colRef, 'two'), { baz: MAX_INT }), + setDoc(doc(colRef, 'three'), { baz: MAX_INT }), + setDoc(doc(colRef, 'four'), { baz: MAX_INT }), + setDoc(doc(colRef, 'five'), { baz: MAX_INT }), + setDoc(doc(colRef, 'six'), { baz: MAX_INT }), + setDoc(doc(colRef, 'seven'), { baz: MAX_INT }), + setDoc(doc(colRef, 'eight'), { baz: MAX_INT }), + setDoc(doc(colRef, 'nine'), { baz: MAX_INT }), + ]); + + const aggregateSpec = { + sumBaz: sum('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.sumBaz.should.eql(MAX_INT * 9); + }); + + it('return JavaScript single max safe number for `sum()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, sum } = + firestoreModular; + const MAX_NUMBER = Number.MAX_VALUE; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count/max-number`); + + await Promise.all([setDoc(doc(colRef, 'one'), { baz: MAX_NUMBER })]); + + const aggregateSpec = { + sumBaz: sum('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.sumBaz.should.eql(MAX_NUMBER); + }); + + it('returns `MAX_NUMBER` for JavaScript max safe number + 1 for `sum()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, sum } = + firestoreModular; + const MAX_NUMBER = Number.MAX_VALUE; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count/max-number`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { baz: MAX_NUMBER }), + setDoc(doc(colRef, 'two'), { baz: 1 }), + ]); + + const aggregateSpec = { + sumBaz: sum('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + // Doesn't add 1, just returns MAX_NUMBER + data.sumBaz.should.eql(MAX_NUMBER); + }); + + it('returns `MAX_NUMBER` for JavaScript max safe number + 100 for `sum()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, sum } = + firestoreModular; + const MAX_NUMBER = Number.MAX_VALUE; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count/max-number-2`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { baz: MAX_NUMBER }), + setDoc(doc(colRef, 'two'), { baz: 100 }), + ]); + + const aggregateSpec = { + sumBaz: sum('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + // Doesn't add 100, just returns MAX_NUMBER + data.sumBaz.should.eql(MAX_NUMBER); + }); + + it('returns `Infinity` for JavaScript two max safe numbers for `sum()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, sum } = + firestoreModular; + const MAX_NUMBER = Number.MAX_VALUE; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-count/max-number-3`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { baz: MAX_NUMBER }), + setDoc(doc(colRef, 'two'), { baz: MAX_NUMBER }), + ]); + + const aggregateSpec = { + sumBaz: sum('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + // Returns Infinity + data.sumBaz.should.eql(Infinity); + }); + + it('returns `0` for properties with `0` for `average()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, average } = + firestoreModular; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-average/0-values`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { baz: 0 }), + setDoc(doc(colRef, 'two'), { baz: 0 }), + ]); + + const aggregateSpec = { + averageBaz: average('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.averageBaz.should.eql(0); + }); + + it('returns `-1` for properties with `-1` for `average()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, average } = + firestoreModular; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-average/minus-one-values`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { baz: -1 }), + setDoc(doc(colRef, 'two'), { baz: -1 }), + ]); + + const aggregateSpec = { + averageBaz: average('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.averageBaz.should.eql(-1); + }); + + it('returns `-3` for properties with `-3` for `average()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, average } = + firestoreModular; + const firestore = getFirestore(); + + const colRef = collection(firestore, `${COLLECTION}/aggregate-average/minus-three-values`); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { baz: -3 }), + setDoc(doc(colRef, 'two'), { baz: -3 }), + setDoc(doc(colRef, 'three'), { baz: -3 }), + ]); + + const aggregateSpec = { + averageBaz: average('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.averageBaz.should.eql(-3); + }); + + it('returns `-2` for properties with `-1`, `-2`,`-3` for `average()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, average } = + firestoreModular; + const firestore = getFirestore(); + + const colRef = collection( + firestore, + `${COLLECTION}/aggregate-average/minus-various-values`, + ); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { baz: -1 }), + setDoc(doc(colRef, 'two'), { baz: -2 }), + setDoc(doc(colRef, 'three'), { baz: -3 }), + ]); + + const aggregateSpec = { + averageBaz: average('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.averageBaz.should.eql(-2); + }); + + it('returns `-0.19999999999999998` for properties with `-1`, `-2`,`-3` for `average()`', async function () { + const { getAggregateFromServer, doc, setDoc, collection, getFirestore, average } = + firestoreModular; + const firestore = getFirestore(); + + const colRef = collection( + firestore, + `${COLLECTION}/aggregate-average/minus-various-float-values`, + ); + + await Promise.all([ + setDoc(doc(colRef, 'one'), { baz: -0.1 }), + setDoc(doc(colRef, 'two'), { baz: -0.2 }), + setDoc(doc(colRef, 'three'), { baz: -0.3 }), + ]); + + const aggregateSpec = { + averageBaz: average('baz'), + }; + + const result = await getAggregateFromServer(colRef, aggregateSpec); + + const data = result.data(); + + data.averageBaz.should.eql(-0.19999999999999998); + }); + }); + }); +}); diff --git a/packages/firestore/e2e/Aggregate/count.e2e.js b/packages/firestore/e2e/Aggregate/count.e2e.js index 80997cb59b..126714320d 100644 --- a/packages/firestore/e2e/Aggregate/count.e2e.js +++ b/packages/firestore/e2e/Aggregate/count.e2e.js @@ -17,8 +17,8 @@ const COLLECTION = 'firestore'; const { wipe } = require('../helpers'); describe('firestore().collection().count()', function () { - before(function () { - return wipe(); + before(async function () { + return await wipe(); }); describe('v8 compatibility', function () { diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m index 963f6fec11..9ed2aa8b7b 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m @@ -216,6 +216,81 @@ - (void)invalidate { }]; } +RCT_EXPORT_METHOD(aggregateQuery + : (FIRApp *)firebaseApp + : (NSString *)databaseId + : (NSString *)path + : (NSString *)type + : (NSArray *)filters + : (NSArray *)orders + : (NSDictionary *)options + : (NSArray *)aggregateQueries + : (RCTPromiseResolveBlock)resolve + : (RCTPromiseRejectBlock)reject) { + FIRFirestore *firestore = [RNFBFirestoreCommon getFirestoreForApp:firebaseApp + databaseId:databaseId]; + FIRQuery *query = [RNFBFirestoreCommon getQueryForFirestore:firestore path:path type:type]; + + NSMutableArray *aggregateFields = + [[NSMutableArray alloc] init]; + + for (NSDictionary *aggregateQuery in aggregateQueries) { + NSString *aggregateType = aggregateQuery[@"aggregateType"]; + NSString *fieldPath = aggregateQuery[@"field"]; + + if ([aggregateType isEqualToString:@"count"]) { + [aggregateFields addObject:[FIRAggregateField aggregateFieldForCount]]; + } else if ([aggregateType isEqualToString:@"sum"]) { + [aggregateFields addObject:[FIRAggregateField aggregateFieldForSumOfField:fieldPath]]; + } else if ([aggregateType isEqualToString:@"average"]) { + [aggregateFields addObject:[FIRAggregateField aggregateFieldForAverageOfField:fieldPath]]; + } else { + NSString *reason = [@"Invalid Aggregate Type: " stringByAppendingString:aggregateType]; + [RNFBFirestoreCommon + promiseRejectFirestoreException:reject + error:[NSException exceptionWithName: + @"RNFB Firestore: Invalid Aggregate Type" + reason:reason + userInfo:nil]]; + return; + } + } + + FIRAggregateQuery *aggregateQuery = [query aggregate:aggregateFields]; + + [aggregateQuery + aggregationWithSource:FIRAggregateSourceServer + completion:^(FIRAggregateQuerySnapshot *_Nullable snapshot, + NSError *_Nullable error) { + if (error) { + [RNFBFirestoreCommon promiseRejectFirestoreException:reject error:error]; + } else { + NSMutableDictionary *snapshotMap = [NSMutableDictionary dictionary]; + + for (NSDictionary *aggregateQuery in aggregateQueries) { + NSString *aggregateType = aggregateQuery[@"aggregateType"]; + NSString *fieldPath = aggregateQuery[@"field"]; + NSString *key = aggregateQuery[@"key"]; + + if ([aggregateType isEqualToString:@"count"]) { + snapshotMap[key] = snapshot.count; + } else if ([aggregateType isEqualToString:@"sum"]) { + NSNumber *sum = [snapshot + valueForAggregateField:[FIRAggregateField + aggregateFieldForSumOfField:fieldPath]]; + snapshotMap[key] = sum; + } else if ([aggregateType isEqualToString:@"average"]) { + NSNumber *average = [snapshot + valueForAggregateField:[FIRAggregateField + aggregateFieldForAverageOfField:fieldPath]]; + snapshotMap[key] = (average == nil ? [NSNull null] : average); + } + } + resolve(snapshotMap); + } + }]; +} + RCT_EXPORT_METHOD(collectionGet : (FIRApp *)firebaseApp : (NSString *)databaseId diff --git a/packages/firestore/lib/FirestoreAggregate.js b/packages/firestore/lib/FirestoreAggregate.js index 2b8b0ae8a0..a2437bee75 100644 --- a/packages/firestore/lib/FirestoreAggregate.js +++ b/packages/firestore/lib/FirestoreAggregate.js @@ -15,6 +15,8 @@ * */ +import FirestoreFieldPath, { fromDotSeparatedString } from './FirestoreFieldPath'; + export class FirestoreAggregateQuery { constructor(firestore, query, collectionPath, modifiers) { this._firestore = firestore; @@ -36,17 +38,55 @@ export class FirestoreAggregateQuery { this._modifiers.orders, this._modifiers.options, ) - .then(data => new FirestoreAggregateQuerySnapshot(this._query, data)); + .then(data => new FirestoreAggregateQuerySnapshot(this._query, data, true)); } } export class FirestoreAggregateQuerySnapshot { - constructor(query, data) { + constructor(query, data, isGetCountFromServer) { this._query = query; this._data = data; + this._isGetCountFromServer = isGetCountFromServer; } data() { - return { count: this._data.count }; + if (this._isGetCountFromServer) { + return { count: this._data.count }; + } else { + return { ...this._data }; + } + } +} + +export const AggregateType = { + SUM: 'sum', + AVG: 'average', + COUNT: 'count', +}; + +export class AggregateField { + /** Indicates the aggregation operation of this AggregateField. */ + aggregateType; + _fieldPath; + + /** + * Create a new AggregateField + * @param aggregateType Specifies the type of aggregation operation to perform. + * @param _fieldPath Optionally specifies the field that is aggregated. + * @internal + */ + constructor(aggregateType, fieldPath) { + this.aggregateType = aggregateType; + this._fieldPath = fieldPath; + } +} + +export function fieldPathFromArgument(path) { + if (path instanceof FirestoreFieldPath) { + return path; + } else if (typeof path === 'string') { + return fromDotSeparatedString(path); + } else { + throw new Error('Field path arguments must be of type `string` or `FieldPath`'); } } diff --git a/packages/firestore/lib/index.d.ts b/packages/firestore/lib/index.d.ts index 261250473c..bdbdc551b0 100644 --- a/packages/firestore/lib/index.d.ts +++ b/packages/firestore/lib/index.d.ts @@ -921,12 +921,16 @@ export namespace FirebaseFirestoreTypes { /** * The results of executing an aggregation query. */ - export interface AggregateQuerySnapshot { + export interface AggregateQuerySnapshot< + AggregateSpecType extends AggregateSpec, + AppModelType = DocumentData, + DbModelType extends DocumentData = DocumentData, + > { /** * The underlying query over which the aggregations recorded in this * `AggregateQuerySnapshot` were performed. */ - get query(): Query; + get query(): Query; /** * Returns the results of the aggregations performed over the underlying @@ -939,7 +943,7 @@ export namespace FirebaseFirestoreTypes { * @returns The results of the aggregations performed over the underlying * query. */ - data(): AggregateSpecData; + data(): AggregateSpecData; } /** diff --git a/packages/firestore/lib/modular/index.d.ts b/packages/firestore/lib/modular/index.d.ts index 179bf65a80..df51ad2ace 100644 --- a/packages/firestore/lib/modular/index.d.ts +++ b/packages/firestore/lib/modular/index.d.ts @@ -10,6 +10,7 @@ import Query = FirebaseFirestoreTypes.Query; import FieldValue = FirebaseFirestoreTypes.FieldValue; import FieldPath = FirebaseFirestoreTypes.FieldPath; import PersistentCacheIndexManager = FirebaseFirestoreTypes.PersistentCacheIndexManager; +import AggregateQuerySnapshot = FirebaseFirestoreTypes.AggregateQuerySnapshot; /** Primitive types. */ export type Primitive = string | number | boolean | undefined | null; @@ -495,6 +496,75 @@ export function getCountFromServer >; +/** + * Specifies a set of aggregations and their aliases. + */ +interface AggregateSpec { + [field: string]: AggregateFieldType; +} + +/** + * The union of all `AggregateField` types that are supported by Firestore. + */ +export type AggregateFieldType = + | ReturnType + | ReturnType + | ReturnType; + +export function getAggregateFromServer< + AggregateSpecType extends AggregateSpec, + AppModelType, + DbModelType extends DocumentData, +>( + query: Query, + aggregateSpec: AggregateSpecType, +): Promise>; + +/** + * Create an AggregateField object that can be used to compute the sum of + * a specified field over a range of documents in the result set of a query. + * @param field Specifies the field to sum across the result set. + */ +export function sum(field: string | FieldPath): AggregateField; + +/** + * Create an AggregateField object that can be used to compute the average of + * a specified field over a range of documents in the result set of a query. + * @param field Specifies the field to average across the result set. + */ +export function average(field: string | FieldPath): AggregateField; + +/** + * Create an AggregateField object that can be used to compute the count of + * documents in the result set of a query. + */ +export function count(): AggregateField; + +/** + * 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. */ + readonly type = 'AggregateField'; + + /** Indicates the aggregation operation of this AggregateField. */ + readonly aggregateType: AggregateType; + + /** + * Create a new AggregateField + * @param aggregateType Specifies the type of aggregation operation to perform. + * @param _internalFieldPath Optionally specifies the field that is aggregated. + * @internal + */ + constructor( + aggregateType: AggregateType = 'count', + readonly _internalFieldPath?: InternalFieldPath, + ) { + this.aggregateType = aggregateType; + } +} + /** * Represents the task of loading a Firestore bundle. * It provides progress of bundle loading, as well as task completion and error events. diff --git a/packages/firestore/lib/modular/index.js b/packages/firestore/lib/modular/index.js index 46eb2d8c4c..4242e983eb 100644 --- a/packages/firestore/lib/modular/index.js +++ b/packages/firestore/lib/modular/index.js @@ -13,6 +13,14 @@ */ import { firebase } from '../index'; +import { isObject } from '@react-native-firebase/app/lib/common'; +import { + FirestoreAggregateQuerySnapshot, + AggregateField, + AggregateType, + fieldPathFromArgument, +} from '../FirestoreAggregate'; +import FirestoreQuery from '../FirestoreQuery'; /** * @param {FirebaseApp?} app @@ -192,6 +200,91 @@ export function getCountFromServer(query) { return query.count().get(); } +export function getAggregateFromServer(query, aggregateSpec) { + if (!(query instanceof FirestoreQuery)) { + throw new Error( + '`getAggregateFromServer(*, aggregateSpec)` `query` must be an instance of `FirestoreQuery`', + ); + } + + if (!isObject(aggregateSpec)) { + throw new Error('`getAggregateFromServer(query, *)` `aggregateSpec` must be an object'); + } else { + const containsOneAggregateField = Object.values(aggregateSpec).find( + value => value instanceof AggregateField, + ); + + if (!containsOneAggregateField) { + throw new Error( + '`getAggregateFromServer(query, *)` `aggregateSpec` must contain at least one `AggregateField`', + ); + } + } + const aggregateQueries = []; + for (const key in aggregateSpec) { + if (aggregateSpec.hasOwnProperty(key)) { + const aggregateField = aggregateSpec[key]; + // we ignore any fields that are not `AggregateField` + if (aggregateField instanceof AggregateField) { + switch (aggregateField.aggregateType) { + case AggregateType.AVG: + case AggregateType.SUM: + case AggregateType.COUNT: + const aggregateQuery = { + aggregateType: aggregateField.aggregateType, + field: + aggregateField._fieldPath === null ? null : aggregateField._fieldPath._toPath(), + key, + }; + aggregateQueries.push(aggregateQuery); + break; + default: + throw new Error( + `'AggregateField' has an an unknown 'AggregateType' : ${aggregateField.aggregateType}`, + ); + } + } + } + } + + return query._firestore.native + .aggregateQuery( + query._collectionPath.relativeName, + query._modifiers.type, + query._modifiers.filters, + query._modifiers.orders, + query._modifiers.options, + aggregateQueries, + ) + .then(data => new FirestoreAggregateQuerySnapshot(query, data, false)); +} + +/** + * Create an AggregateField object that can be used to compute the sum of + * a specified field over a range of documents in the result set of a query. + * @param field Specifies the field to sum across the result set. + */ +export function sum(field) { + return new AggregateField(AggregateType.SUM, fieldPathFromArgument(field)); +} + +/** + * Create an AggregateField object that can be used to compute the average of + * a specified field over a range of documents in the result set of a query. + * @param field Specifies the field to average across the result set. + */ +export function average(field) { + return new AggregateField(AggregateType.AVG, fieldPathFromArgument(field)); +} + +/** + * Create an AggregateField object that can be used to compute the count of + * documents in the result set of a query. + */ +export function count() { + return new AggregateField(AggregateType.COUNT, null); +} + /** * @param {Firestore} firestore * @param {ReadableStream | ArrayBuffer | string} bundleData diff --git a/packages/firestore/lib/web/RNFBFirestoreModule.js b/packages/firestore/lib/web/RNFBFirestoreModule.js index ac942a55b0..c69edb7bea 100644 --- a/packages/firestore/lib/web/RNFBFirestoreModule.js +++ b/packages/firestore/lib/web/RNFBFirestoreModule.js @@ -11,6 +11,10 @@ import { getDoc, getDocs, getCount, + getAggregate, + count, + average, + sum, deleteDoc, setDoc, updateDoc, @@ -215,6 +219,44 @@ export default { }); }, + aggregateQuery(appName, databaseId, path, type, filters, orders, options, aggregateQueries) { + return guard(async () => { + const firestore = getCachedFirestoreInstance(appName, databaseId); + const queryRef = + type === 'collectionGroup' ? collectionGroup(firestore, path) : collection(firestore, path); + const query = buildQuery(queryRef, filters, orders, options); + const aggregateSpec = {}; + + for (let i = 0; i < aggregateQueries.length; i++) { + const aggregateQuery = aggregateQueries[i]; + const { aggregateType, field, key } = aggregateQuery; + + switch (aggregateType) { + case 'count': + aggregateSpec[key] = count(); + break; + case 'average': + aggregateSpec[key] = average(field); + break; + case 'sum': + aggregateSpec[key] = sum(field); + break; + } + } + const result = await getAggregate(query, aggregateSpec); + + const data = result.data(); + const response = {}; + for (let i = 0; i < aggregateQueries.length; i++) { + const aggregateQuery = aggregateQueries[i]; + const { key } = aggregateQuery; + response[key] = data[key]; + } + + return response; + }); + }, + /** * Get a collection from Firestore. * @param {string} appName - The app name. diff --git a/packages/firestore/lib/web/query.js b/packages/firestore/lib/web/query.js index e181e56980..eed7df8cdd 100644 --- a/packages/firestore/lib/web/query.js +++ b/packages/firestore/lib/web/query.js @@ -108,5 +108,5 @@ function getFilterConstraint(filter) { throw new Error('Invalid filter operator'); } - throw new Error('Invaldi filter.'); + throw new Error('Invalid filter.'); }