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.'); }