Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(firestore): support for aggregate queries including sum() & average() #8115

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +28,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;

Expand Down Expand Up @@ -193,6 +198,99 @@ 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<AggregateField> 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("field");

assert aggregateType != null;
switch (aggregateType) {
case "count":
aggregateFields.add(AggregateField.count());
break;
case "sum":
assert fieldPath != null;
aggregateFields.add(AggregateField.sum(fieldPath));
break;
case "average":
assert fieldPath != null;
aggregateFields.add(AggregateField.average(fieldPath));
break;
default:
throw new Error("Invalid AggregateType: " + aggregateType);
}
}
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");
String field = aggQuery.getString("field");
String key = aggQuery.getString("key");
assert key != null;
assert aggType != null;
Comment on lines +264 to +265
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are any of these asserts useful? It was my understanding that assert verifications are disabled by default so this would never really trigger anything, and if it did trigger the whole app dies (via an AssertionError) without notice of what did it since there is no exception handler set to catch Error (vs a try/catch with typical catch of Exception wrapping a block where you use Object.requireNonNull

switch (aggType) {
case "count":
result.putDouble(key, Long.valueOf(snapshot.getCount()).doubleValue());
break;
case "sum":
assert field != null;
Number sum = (Number) snapshot.get(sum(field));
assert sum != null;
result.putDouble(key, sum.doubleValue());
break;
case "average":
assert field != null;
Number average = snapshot.get(average(field));
assert average != null;
result.putDouble(key, average.doubleValue());
break;
default:
throw new Error("Invalid AggregateType: " + aggType);
}
}

promise.resolve(result);
} else {
rejectPromiseFirestoreException(promise, task.getException());
}
});
}

@ReactMethod
public void collectionGet(
String appName,
Expand All @@ -214,6 +312,8 @@ public void collectionGet(
orders,
options);
handleQueryGet(firestoreQuery, getSource(getOptions), promise);


Comment on lines +315 to +316
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low-value comment: that should fail yarn lint:android shouldn't it? (but just by running lint:android it'll auto-format it and you can just commit result)

}

private void handleQueryOnSnapshot(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FIRAggregateField *> *aggregateFields =
[[NSMutableArray<FIRAggregateField *> 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"]){
Copy link
Collaborator

@mikehardy mikehardy Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also really low-value comment (because, lint...) but yarn lint:ios:fix will clean that up for you

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
Expand Down
46 changes: 43 additions & 3 deletions packages/firestore/lib/FirestoreAggregate.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
*
*/

import FirestoreFieldPath, { fromDotSeparatedString } from './FirestoreFieldPath';

export class FirestoreAggregateQuery {
constructor(firestore, query, collectionPath, modifiers) {
this._firestore = firestore;
Expand All @@ -36,17 +38,55 @@
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) {

Check warning on line 46 in packages/firestore/lib/FirestoreAggregate.js

View check run for this annotation

Codecov / codecov/patch

packages/firestore/lib/FirestoreAggregate.js#L46

Added line #L46 was not covered by tests
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 };

Check warning on line 56 in packages/firestore/lib/FirestoreAggregate.js

View check run for this annotation

Codecov / codecov/patch

packages/firestore/lib/FirestoreAggregate.js#L55-L56

Added lines #L55 - L56 were not covered by tests
}
}
}

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<T>
* @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;

Check warning on line 80 in packages/firestore/lib/FirestoreAggregate.js

View check run for this annotation

Codecov / codecov/patch

packages/firestore/lib/FirestoreAggregate.js#L78-L80

Added lines #L78 - L80 were not covered by tests
}
}

export function fieldPathFromArgument(path) {

Check warning on line 84 in packages/firestore/lib/FirestoreAggregate.js

View check run for this annotation

Codecov / codecov/patch

packages/firestore/lib/FirestoreAggregate.js#L84

Added line #L84 was not covered by tests
if (path instanceof FirestoreFieldPath) {
return path;

Check warning on line 86 in packages/firestore/lib/FirestoreAggregate.js

View check run for this annotation

Codecov / codecov/patch

packages/firestore/lib/FirestoreAggregate.js#L86

Added line #L86 was not covered by tests
} else if (typeof path === 'string') {
return fromDotSeparatedString(path);
} else {
throw new Error('Field path arguments must be of type `string` or `FieldPath`');

Check warning on line 90 in packages/firestore/lib/FirestoreAggregate.js

View check run for this annotation

Codecov / codecov/patch

packages/firestore/lib/FirestoreAggregate.js#L88-L90

Added lines #L88 - L90 were not covered by tests
}
}
71 changes: 71 additions & 0 deletions packages/firestore/lib/modular/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,77 @@ export function getCountFromServer<AppModelType, DbModelType extends DocumentDat
>
>;

/**
* 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<typeof sum>
| ReturnType<typeof average>
| ReturnType<typeof count>;

export function getAggregateFromServer<
AggregateSpecType extends AggregateSpec,
AppModelType,
DbModelType extends FirebaseFirestoreTypes.DocumentData,
>(
query: Query<AppModelType, DbModelType>,
aggregateSpec: AggregateSpecType,
): Promise<
FirebaseFirestoreTypes.AggregateQuerySnapshot<AggregateSpecType, AppModelType, DbModelType>
>;

/**
* 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<number>;

/**
* 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<number | null>;

/**
* 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<number>;

/**
* Represents an aggregation that can be performed by Firestore.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export class AggregateField<T> {
/** 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<T>
* @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.
Expand Down
Loading
Loading