Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/dart_firebase_admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ print(user.data()?['age']);
| query.limit | ✅ |
| query.limitToLast | ✅ |
| query.offset | ✅ |
| query.count() | ✅ |
| query.sum() | ❌ |
| query.average() | ❌ |
| querySnapshot.docs | ✅ |
| querySnapshot.readTime | ✅ |
| documentSnapshots.data | ✅ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,389 @@ base class Query<T> {

@override
int get hashCode => Object.hash(runtimeType, _queryOptions);

/// Returns an [AggregateQuery] that can be used to execute one or more
/// aggregation queries over the result set of this query.
///
/// ## Limitations
/// - Aggregation queries are only supported through direct server response
/// - Cannot be used with real-time listeners or offline queries
/// - Must complete within 60 seconds or returns DEADLINE_EXCEEDED error
/// - For sum() and average(), non-numeric values are ignored
/// - When combining aggregations on different fields, only documents
/// containing all those fields are included
///
/// ```dart
/// firestore.collection('cities').aggregate(
/// count(),
/// sum('population'),
/// average('population'),
/// ).get().then(
/// (res) {
/// print(res.count);
/// print(res.getSum('population'));
/// print(res.getAverage('population'));
/// },
/// onError: (e) => print('Error completing: $e'),
/// );
/// ```
AggregateQuery aggregate(
AggregateField aggregateField1, [
AggregateField? aggregateField2,
AggregateField? aggregateField3,
]) {
final fields = [
aggregateField1,
if (aggregateField2 != null) aggregateField2,
if (aggregateField3 != null) aggregateField3,
];

return AggregateQuery._(
query: this,
aggregations: fields.map((field) => field._toInternal()).toList(),
);
}

/// Returns an [AggregateQuery] that can be used to execute a count
/// aggregation.
///
/// The returned query, when executed, counts the documents in the result
/// set of this query without actually downloading the documents.
///
/// ```dart
/// firestore.collection('cities').count().get().then(
/// (res) => print(res.count),
/// onError: (e) => print('Error completing: $e'),
/// );
/// ```
AggregateQuery count() {
return aggregate(AggregateField.count());
}
}

/// Defines an aggregation that can be performed by Firestore.
@immutable
class AggregateField {
const AggregateField._({
required this.fieldPath,
required this.alias,
required this.type,
});

/// The field to aggregate on, or null for count aggregations.
final String? fieldPath;

/// The alias to use for this aggregation result.
final String alias;

/// The type of aggregation.
final _AggregateType type;

/// Creates a count aggregation.
///
/// Count aggregations provide the number of documents that match the query.
/// The result can be accessed using [AggregateQuerySnapshot.count].
factory AggregateField.count() {
return const AggregateField._(
fieldPath: null,
alias: 'count',
type: _AggregateType.count,
);
}

/// Creates a sum aggregation for the specified field.
///
/// - [field]: The field to sum across all matching documents.
///
/// The result can be accessed using [AggregateQuerySnapshot.getSum].
factory AggregateField.sum(String field) {
return AggregateField._(
fieldPath: field,
alias: 'sum_$field',
type: _AggregateType.sum,
);
}

/// Creates an average aggregation for the specified field.
///
/// - [field]: The field to average across all matching documents.
///
/// The result can be accessed using [AggregateQuerySnapshot.getAverage].
factory AggregateField.average(String field) {
return AggregateField._(
fieldPath: field,
alias: 'avg_$field',
type: _AggregateType.average,
);
}

/// Converts this public field to the internal representation.
_AggregateFieldInternal _toInternal() {
firestore1.Aggregation aggregation;
switch (type) {
case _AggregateType.count:
aggregation = firestore1.Aggregation(
count: firestore1.Count(),
);
break;
case _AggregateType.sum:
aggregation = firestore1.Aggregation(
sum: firestore1.Sum(
field: firestore1.FieldReference(fieldPath: fieldPath!),
),
);
break;
case _AggregateType.average:
aggregation = firestore1.Aggregation(
avg: firestore1.Avg(
field: firestore1.FieldReference(fieldPath: fieldPath!),
),
);
break;
}

return _AggregateFieldInternal(
alias: alias,
aggregation: aggregation,
);
}
}

/// The type of aggregation to perform.
enum _AggregateType {
count,
sum,
average,
}

/// Create a CountAggregateField object that can be used to compute
/// the count of documents in the result set of a query.
// ignore: camel_case_types
class count extends AggregateField {
/// Creates a count aggregation.
const count() : super._(
fieldPath: null,
alias: 'count',
type: _AggregateType.count,
);
}

/// Create an 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.
// ignore: camel_case_types
class sum extends AggregateField {
/// Creates a sum aggregation for the specified field.
sum(this.field) : super._(
fieldPath: field,
alias: 'sum_$field',
type: _AggregateType.sum,
);

/// The field to sum.
final String field;
}

/// Create an 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.
// ignore: camel_case_types
class average extends AggregateField {
/// Creates an average aggregation for the specified field.
average(this.field) : super._(
fieldPath: field,
alias: 'avg_$field',
type: _AggregateType.average,
);

/// The field to average.
final String field;
}

/// Internal representation of an aggregation field.
@immutable
class _AggregateFieldInternal {
const _AggregateFieldInternal({
required this.alias,
required this.aggregation,
});

final String alias;
final firestore1.Aggregation aggregation;

@override
bool operator ==(Object other) {
return other is _AggregateFieldInternal &&
alias == other.alias &&
// For count aggregations, we just check that both have count set
((aggregation.count != null && other.aggregation.count != null) ||
(aggregation.sum != null && other.aggregation.sum != null) ||
(aggregation.avg != null && other.aggregation.avg != null));
}

@override
int get hashCode => Object.hash(
alias,
aggregation.count != null || aggregation.sum != null || aggregation.avg != null,
);
}

/// Calculates aggregations over an underlying query.
@immutable
class AggregateQuery {
const AggregateQuery._({
required this.query,
required this.aggregations,
});

/// The query whose aggregations will be calculated by this object.
final Query<Object?> query;

@internal
final List<_AggregateFieldInternal> aggregations;

/// Executes the aggregate query and returns the results as an
/// [AggregateQuerySnapshot].
///
/// ```dart
/// firestore.collection('cities').count().get().then(
/// (res) => print(res.count),
/// onError: (e) => print('Error completing: $e'),
/// );
/// ```
Future<AggregateQuerySnapshot> get() async {
final firestore = query.firestore;

final aggregationQuery = firestore1.RunAggregationQueryRequest(
structuredAggregationQuery: firestore1.StructuredAggregationQuery(
structuredQuery: query._toStructuredQuery(),
aggregations: [
for (final field in aggregations)
firestore1.Aggregation(
alias: field.alias,
count: field.aggregation.count,
sum: field.aggregation.sum,
avg: field.aggregation.avg,
),
],
),
);

final response = await firestore._client.v1((client) async {
return client.projects.databases.documents.runAggregationQuery(
aggregationQuery,
query._buildProtoParentPath(),
);
});

final results = <String, Object?>{};
Timestamp? readTime;

for (final result in response) {
if (result.result != null && result.result!.aggregateFields != null) {
for (final entry in result.result!.aggregateFields!.entries) {
final value = entry.value;
if (value.integerValue != null) {
results[entry.key] = int.parse(value.integerValue!);
} else if (value.doubleValue != null) {
results[entry.key] = value.doubleValue;
} else if (value.nullValue != null) {
results[entry.key] = null;
}
}
}

if (result.readTime != null) {
readTime = Timestamp._fromString(result.readTime!);
}
}

return AggregateQuerySnapshot._(
query: this,
readTime: readTime,
data: results,
);
}

@override
bool operator ==(Object other) {
return other is AggregateQuery &&
query == other.query &&
const ListEquality<_AggregateFieldInternal>()
.equals(aggregations, other.aggregations);
}

@override
int get hashCode => Object.hash(
query,
const ListEquality<_AggregateFieldInternal>().hash(aggregations),
);
}

/// The results of executing an aggregation query.
@immutable
class AggregateQuerySnapshot {
const AggregateQuerySnapshot._({
required this.query,
required this.readTime,
required this.data,
});

/// The query that was executed to produce this result.
final AggregateQuery query;

/// The time this snapshot was obtained.
final Timestamp? readTime;

/// The raw aggregation data, keyed by alias.
final Map<String, Object?> data;

/// The count of documents that match the query. Returns `null` if the
/// count aggregation was not performed.
int? get count => data['count'] as int?;

/// Gets the sum for the specified field. Returns `null` if the
/// sum aggregation was not performed.
///
/// - [field]: The field that was summed.
num? getSum(String field) {
final alias = 'sum_$field';
final value = data[alias];
if (value == null) return null;
if (value is int || value is double) return value as num;
// Handle case where sum might be returned as a string
if (value is String) return num.tryParse(value);
return null;
}

/// Gets the average for the specified field. Returns `null` if the
/// average aggregation was not performed.
///
/// - [field]: The field that was averaged.
double? getAverage(String field) {
final alias = 'avg_$field';
final value = data[alias];
if (value == null) return null;
if (value is double) return value;
if (value is int) return value.toDouble();
// Handle case where average might be returned as a string
if (value is String) return double.tryParse(value);
return null;
}

/// Gets an aggregate field by alias.
///
/// - [alias]: The alias of the aggregate field to retrieve.
Object? getField(String alias) => data[alias];

@override
bool operator ==(Object other) {
return other is AggregateQuerySnapshot &&
query == other.query &&
readTime == other.readTime &&
const MapEquality<String, Object?>().equals(data, other.data);
}

@override
int get hashCode => Object.hash(query, readTime, data);
}

/// A QuerySnapshot contains zero or more [QueryDocumentSnapshot] objects
Expand Down
Loading