From 47b14168b4ffc3f14b959747fd1140d6c22f910a Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Mon, 4 Nov 2024 16:01:54 +0000 Subject: [PATCH 1/3] feat(firestore): support for aggregate queries including `sum()` & `average()` --- ...tiveFirebaseFirestoreCollectionModule.java | 78 +++++++++++++++++++ packages/firestore/lib/FirestoreAggregate.js | 35 +++++++++ packages/firestore/lib/modular/index.d.ts | 71 +++++++++++++++++ packages/firestore/lib/modular/index.js | 65 ++++++++++++++++ 4 files changed, 249 insertions(+) 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..00ea393237 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 @@ -26,6 +26,9 @@ import com.facebook.react.bridge.*; import com.google.android.gms.tasks.Tasks; import com.google.firebase.firestore.*; + +import java.util.ArrayList; + import io.invertase.firebase.common.ReactNativeFirebaseEventEmitter; import io.invertase.firebase.common.ReactNativeFirebaseModule; @@ -193,6 +196,79 @@ public void collectionCount( }); } + @ReactMethod + public void aggregateQuery( + String appName, + String databaseId, + String path, + String type, + ReadableArray filters, + ReadableArray orders, + ReadableMap options, + ReadableArray aggregateQueries + ){ + 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"); + String fieldPath = aggregateQuery.getString("fieldPath"); + if (aggregateType && fieldPath) { + switch (aggregateType) { + case "count": + aggregateFields.add(AggregateField.count(fieldPath)); + break; + case "sum": + aggregateFields.add(AggregateField.sum(fieldPath)); + break; + case "average": + aggregateFields.add(AggregateField.avg(fieldPath)); + break; + default: + break; + } + } + + AggregateQuery aggregateQuery = firestoreQuery.query.aggregate(aggregateFields); + aggregateQuery + .get(AggregateSource.SERVER) + .addOnCompleteListener( + task -> { + if (task.isSuccessful()) { + WritableMap result = Arguments.createMap(); + AggregateQuerySnapshot snapshot = task.getResult(); + aggregateFields.forEach(aggregateField -> { + switch (aggregateField.getOperator()) { + case "count": + result.putDouble(aggregateField.getFieldPath(), Long.valueOf(snapshot.getCount()).doubleValue()); + break; + case "sum": + result.putDouble(aggregateField.getFieldPath(), snapshot.getSum(aggregateField.getFieldPath())); + break; + case "average": + result.putDouble(aggregateField.getFieldPath(), snapshot.getAverage(aggregateField.getFieldPath())); + break; + default: + break; + } + } + promise.resolve(result); + } else { + rejectPromiseFirestoreException(promise, task.getException()); + } + }); + } + } + @ReactMethod public void collectionGet( String appName, @@ -214,6 +290,8 @@ public void collectionGet( orders, options); handleQueryGet(firestoreQuery, getSource(getOptions), promise); + + } private void handleQueryOnSnapshot( diff --git a/packages/firestore/lib/FirestoreAggregate.js b/packages/firestore/lib/FirestoreAggregate.js index 2b8b0ae8a0..cd1e14c163 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; @@ -50,3 +52,36 @@ export class FirestoreAggregateQuerySnapshot { return { count: this._data.count }; } } + +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 _internalFieldPath 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(methodName, path); + } else { + throw new Error('Field path arguments must be of type `string` or `FieldPath`'); + } +} diff --git a/packages/firestore/lib/modular/index.d.ts b/packages/firestore/lib/modular/index.d.ts index 179bf65a80..f7cbc91de6 100644 --- a/packages/firestore/lib/modular/index.d.ts +++ b/packages/firestore/lib/modular/index.d.ts @@ -495,6 +495,77 @@ 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 FirebaseFirestoreTypes.DocumentData, +>( + query: Query, + aggregateSpec: AggregateSpecType, +): Promise< + FirebaseFirestoreTypes.AggregateQuerySnapshot +>; + +/** + * 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..3244f09841 100644 --- a/packages/firestore/lib/modular/index.js +++ b/packages/firestore/lib/modular/index.js @@ -13,6 +13,8 @@ */ import { firebase } from '../index'; +import { AggregateField, AggregateType } from '../FirestoreAggregate'; +import FirestorePath from '../FirestorePath'; /** * @param {FirebaseApp?} app @@ -192,6 +194,69 @@ export function getCountFromServer(query) { return query.count().get(); } +export async function getAggregateFromServer(query, aggregateSpec) { + const aggregateQueries = []; + for (const key in aggregateSpec) { + if (aggregateSpec.hasOwnProperty(key)) { + const value = aggregateSpec[key]; + + if (value instanceof AggregateField) { + switch (value.aggregateType) { + case AggregateType.AVG: + case AggregateType.SUM: + case AggregateType.COUNT: + const aggregateQuery = { + aggregateType: value.aggregateType, + // TODO - how is this sent over the wire? Think it is serialized automatically + field: value.fieldPath, + }; + aggregateQueries.push(aggregateQuery); + break; + default: + throw new Error( + `"AggregateField" has an an unknown "AggregateType" : ${value.aggregateType}`, + ); + } + } + } + } + + return query._firestore.native.aggregateQuery( + query._collectionPath.relativeName, + query._modifiers.type, + query._modifiers.filters, + query._modifiers.orders, + query._modifiers.options, + aggregateQueries, + ); +} + +/** + * 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, FirestorePath.fromName(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, FirestorePath.fromName(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); +} + /** * @param {Firestore} firestore * @param {ReadableStream | ArrayBuffer | string} bundleData From c92547ee91690cc344ade76ceb64bb4cf69e4fc7 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 5 Nov 2024 14:18:05 +0000 Subject: [PATCH 2/3] feat(firestore, android): working version of aggregate query --- ...tiveFirebaseFirestoreCollectionModule.java | 54 +++++++++++++------ packages/firestore/lib/FirestoreAggregate.js | 13 +++-- packages/firestore/lib/modular/index.js | 46 +++++++++------- 3 files changed, 73 insertions(+), 40 deletions(-) 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 00ea393237..0022ffda2a 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; @@ -205,7 +207,8 @@ public void aggregateQuery( ReadableArray filters, ReadableArray orders, ReadableMap options, - ReadableArray aggregateQueries + ReadableArray aggregateQueries, + Promise promise ){ FirebaseFirestore firebaseFirestore = getFirestoreForApp(appName, databaseId); ReactNativeFirebaseFirestoreQuery firestoreQuery = @@ -216,57 +219,76 @@ public void aggregateQuery( 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"); - String fieldPath = aggregateQuery.getString("fieldPath"); - if (aggregateType && fieldPath) { + String fieldPath = aggregateQuery.getString("field"); + + assert aggregateType != null; switch (aggregateType) { case "count": - aggregateFields.add(AggregateField.count(fieldPath)); + aggregateFields.add(AggregateField.count()); break; case "sum": + assert fieldPath != null; aggregateFields.add(AggregateField.sum(fieldPath)); break; case "average": - aggregateFields.add(AggregateField.avg(fieldPath)); + assert fieldPath != null; + aggregateFields.add(AggregateField.average(fieldPath)); break; default: - break; + throw new Error("Invalid AggregateType: " + aggregateType); } - } + } + AggregateQuery firestoreAggregateQuery = firestoreQuery.query.aggregate(aggregateFields.get(0), + aggregateFields.subList(1, aggregateFields.size()).toArray(new AggregateField[0])); - AggregateQuery aggregateQuery = firestoreQuery.query.aggregate(aggregateFields); - aggregateQuery + firestoreAggregateQuery .get(AggregateSource.SERVER) .addOnCompleteListener( task -> { if (task.isSuccessful()) { WritableMap result = Arguments.createMap(); AggregateQuerySnapshot snapshot = task.getResult(); - aggregateFields.forEach(aggregateField -> { - switch (aggregateField.getOperator()) { + + for (int k = 0; k < aggregateQueries.size(); k++) { + ReadableMap aggQuery = aggregateQueries.getMap(k); + String aggType = aggQuery.getString("aggregateType"); + String field = aggQuery.getString("field"); + String key = aggQuery.getString("key"); + assert key != null; + assert aggType != null; + switch (aggType) { case "count": - result.putDouble(aggregateField.getFieldPath(), Long.valueOf(snapshot.getCount()).doubleValue()); + result.putDouble(key, Long.valueOf(snapshot.getCount()).doubleValue()); break; case "sum": - result.putDouble(aggregateField.getFieldPath(), snapshot.getSum(aggregateField.getFieldPath())); + assert field != null; + Number sum = (Number) snapshot.get(sum(field)); + assert sum != null; + result.putDouble(key, sum.doubleValue()); break; case "average": - result.putDouble(aggregateField.getFieldPath(), snapshot.getAverage(aggregateField.getFieldPath())); + assert field != null; + Number average = snapshot.get(average(field)); + assert average != null; + result.putDouble(key, average.doubleValue()); break; default: - break; + throw new Error("Invalid AggregateType: " + aggType); } } + promise.resolve(result); } else { rejectPromiseFirestoreException(promise, task.getException()); } }); - } } @ReactMethod diff --git a/packages/firestore/lib/FirestoreAggregate.js b/packages/firestore/lib/FirestoreAggregate.js index cd1e14c163..a52c96c104 100644 --- a/packages/firestore/lib/FirestoreAggregate.js +++ b/packages/firestore/lib/FirestoreAggregate.js @@ -38,18 +38,23 @@ 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 }; + } } } @@ -80,7 +85,7 @@ export function fieldPathFromArgument(path) { if (path instanceof FirestoreFieldPath) { return path; } else if (typeof path === 'string') { - return fromDotSeparatedString(methodName, path); + return fromDotSeparatedString(path); } else { throw new Error('Field path arguments must be of type `string` or `FieldPath`'); } diff --git a/packages/firestore/lib/modular/index.js b/packages/firestore/lib/modular/index.js index 3244f09841..95d067f360 100644 --- a/packages/firestore/lib/modular/index.js +++ b/packages/firestore/lib/modular/index.js @@ -13,8 +13,12 @@ */ import { firebase } from '../index'; -import { AggregateField, AggregateType } from '../FirestoreAggregate'; -import FirestorePath from '../FirestorePath'; +import { + FirestoreAggregateQuerySnapshot, + AggregateField, + AggregateType, + fieldPathFromArgument, +} from '../FirestoreAggregate'; /** * @param {FirebaseApp?} app @@ -198,37 +202,39 @@ export async function getAggregateFromServer(query, aggregateSpec) { const aggregateQueries = []; for (const key in aggregateSpec) { if (aggregateSpec.hasOwnProperty(key)) { - const value = aggregateSpec[key]; + const aggregateField = aggregateSpec[key]; - if (value instanceof AggregateField) { - switch (value.aggregateType) { + if (aggregateField instanceof AggregateField) { + switch (aggregateField.aggregateType) { case AggregateType.AVG: case AggregateType.SUM: case AggregateType.COUNT: const aggregateQuery = { - aggregateType: value.aggregateType, - // TODO - how is this sent over the wire? Think it is serialized automatically - field: value.fieldPath, + 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" : ${value.aggregateType}`, + `"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, - ); + 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)); } /** @@ -237,7 +243,7 @@ export async function getAggregateFromServer(query, aggregateSpec) { * @param field Specifies the field to sum across the result set. */ export function sum(field) { - return new AggregateField(AggregateType.SUM, FirestorePath.fromName(field)); + return new AggregateField(AggregateType.SUM, fieldPathFromArgument(field)); } /** @@ -246,7 +252,7 @@ export function sum(field) { * @param field Specifies the field to average across the result set. */ export function average(field) { - return new AggregateField(AggregateType.AVG, FirestorePath.fromName(field)); + return new AggregateField(AggregateType.AVG, fieldPathFromArgument(field)); } /** @@ -254,7 +260,7 @@ export function average(field) { * documents in the result set of a query. */ export function count() { - return new AggregateField(AggregateType.COUNT); + return new AggregateField(AggregateType.COUNT, null); } /** From 7f01f41f9d4836b8b06e86f37905d38469999d04 Mon Sep 17 00:00:00 2001 From: russellwheatley Date: Tue, 5 Nov 2024 16:56:05 +0000 Subject: [PATCH 3/3] feat: iOS implementation of aggregate queries --- .../RNFBFirestoreCollectionModule.m | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m index 963f6fec11..4182acc5f8 100644 --- a/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m +++ b/packages/firestore/ios/RNFBFirestore/RNFBFirestoreCollectionModule.m @@ -216,6 +216,83 @@ - (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"]; + assert(aggregateType); + assert(fieldPath); + + 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]; + @throw [NSException exceptionWithName:@"RNFB Firestore: Invalid Aggregate Type" + reason:reason + userInfo:nil]; + } + } + + 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"]; + assert(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