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

Merged
merged 28 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
47b1416
feat(firestore): support for aggregate queries including `sum()` & `a…
russellwheatley Nov 4, 2024
c92547e
feat(firestore, android): working version of aggregate query
russellwheatley Nov 5, 2024
7f01f41
feat: iOS implementation of aggregate queries
russellwheatley Nov 5, 2024
8203f96
test: getAggregateFromServer()
russellwheatley Nov 6, 2024
5727264
test: update e2e tests
russellwheatley Nov 6, 2024
988d7af
chore: improve typing
russellwheatley Nov 6, 2024
4264338
chore: format
russellwheatley Nov 6, 2024
fa1a1e8
chore: rm assertions
russellwheatley Nov 6, 2024
8b76a9b
chore: format
russellwheatley Nov 6, 2024
9cd5ebc
feat: 'other' platform support
russellwheatley Nov 6, 2024
5f54a16
tes: fix test scopes
russellwheatley Nov 6, 2024
8db15d5
fix: firestore lite has different name for API
russellwheatley Nov 6, 2024
5c38c5b
test: ensure exposed to end user
russellwheatley Nov 6, 2024
148cfb1
test: fix broken tests
russellwheatley Nov 7, 2024
de34cc2
fix(android): allow null value for average
russellwheatley Nov 7, 2024
bb76c4d
chore: fix typo
russellwheatley Nov 7, 2024
9f8542b
fix(firestore, android): send null errors through promise reject path
mikehardy Nov 7, 2024
f66838f
test: update aggregate query to see what happens with float handling
russellwheatley Nov 8, 2024
056f45c
fix: update exception handling iOS
russellwheatley Nov 8, 2024
b442827
chore: AggregateQuerySnapshot type update
russellwheatley Nov 8, 2024
f5a2fe5
fix: return after promise rejection
russellwheatley Nov 8, 2024
5bdd26a
fix: android, fieldPath can be null for count. fix promise.reject
russellwheatley Nov 8, 2024
97124c9
chore: remove tag
russellwheatley Nov 8, 2024
eb65fd8
test: edge cases for aggregate queries
russellwheatley Nov 12, 2024
01e2d94
chore: remove only() for test
russellwheatley Nov 12, 2024
d18a67c
test: update what test produces
russellwheatley Nov 12, 2024
a89ca19
test: correct return type expected
russellwheatley Nov 13, 2024
cb54669
test: ensure aggregate fields are exposed to end user
russellwheatley Nov 13, 2024
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;
mikehardy marked this conversation as resolved.
Show resolved Hide resolved
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);


russellwheatley marked this conversation as resolved.
Show resolved Hide resolved
}

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"]){
russellwheatley marked this conversation as resolved.
Show resolved Hide resolved
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',
};
mikehardy marked this conversation as resolved.
Show resolved Hide resolved

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