diff --git a/packages/dart_firebase_admin/README.md b/packages/dart_firebase_admin/README.md index 05deb2d..e34e4c8 100644 --- a/packages/dart_firebase_admin/README.md +++ b/packages/dart_firebase_admin/README.md @@ -203,8 +203,8 @@ print(user.data()?['age']); | query.limitToLast | ✅ | | query.offset | ✅ | | query.count() | ✅ | -| query.sum() | ❌ | -| query.average() | ❌ | +| query.sum() | ✅ | +| query.average() | ✅ | | querySnapshot.docs | ✅ | | querySnapshot.readTime | ✅ | | documentSnapshots.data | ✅ | diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document.dart index 0729275..a7c7ec6 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document.dart @@ -179,7 +179,7 @@ class DocumentSnapshot { /// } /// }); /// ``` - bool get exists => this._fieldsProto != null; + bool get exists => _fieldsProto != null; /// Retrieves all fields in the document as an object. Returns 'undefined' if /// the document doesn't exist. @@ -196,7 +196,7 @@ class DocumentSnapshot { /// }); /// ``` T? data() { - final fieldsProto = this._fieldsProto; + final fieldsProto = _fieldsProto; final fields = fieldsProto?.fields; if (fields == null || fieldsProto == null) return null; @@ -245,7 +245,7 @@ class DocumentSnapshot { } firestore1.Value? _protoField(FieldPath field) { - final fieldsProto = this._fieldsProto?.fields; + final fieldsProto = _fieldsProto?.fields; if (fieldsProto == null) return null; var fields = fieldsProto; @@ -300,11 +300,11 @@ class _DocumentSnapshotBuilder { DocumentSnapshot build() { assert( - (this.fieldsProto != null) == (this.createTime != null), + (this.fieldsProto != null) == (createTime != null), 'Create time should be set iff document exists.', ); assert( - (this.fieldsProto != null) == (this.updateTime != null), + (this.fieldsProto != null) == (updateTime != null), 'Update time should be set iff document exists.', ); diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart index 07bffc1..8a49e1f 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart @@ -90,7 +90,7 @@ class _DocumentReader { DocumentSnapshot? documentSnapshot; if (response.transaction?.isNotEmpty ?? false) { - this._retrievedTransactionId = response.transaction; + _retrievedTransactionId = response.transaction; } final found = response.found; diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart index 5d08fc0..fcdc435 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart @@ -53,12 +53,12 @@ abstract class _Path> implements Comparable<_Path> { /// Checks whether the current path is a prefix of the specified path. bool _isPrefixOf(_Path other) { - if (other.segments.length < this.segments.length) { + if (other.segments.length < segments.length) { return false; } - for (var i = 0; i < this.segments.length; i++) { - if (this.segments[i] != other.segments[i]) { + for (var i = 0; i < segments.length; i++) { + if (segments[i] != other.segments[i]) { return false; } } @@ -74,8 +74,8 @@ abstract class _Path> implements Comparable<_Path> { if (compare != 0) return compare; } - if (this.segments.length < other.segments.length) return -1; - if (this.segments.length > other.segments.length) return 1; + if (segments.length < other.segments.length) return -1; + if (segments.length > other.segments.length) return 1; return 0; } diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart index 0d759c7..06c2ce0 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart @@ -213,7 +213,7 @@ final class DocumentReference implements _Serializable { /// }); /// ``` Future>> listCollections() { - return this.firestore._client.v1((a) async { + return firestore._client.v1((a) async { final request = firestore1.ListCollectionIdsRequest( // Setting `pageSize` to an arbitrarily large value lets the backend cap // the page size (currently to 300). Note that the backend rejects @@ -223,7 +223,7 @@ final class DocumentReference implements _Serializable { final result = await a.projects.databases.documents.listCollectionIds( request, - this._formattedName, + _formattedName, ); final ids = result.collectionIds ?? []; @@ -277,7 +277,7 @@ final class DocumentReference implements _Serializable { /// }); /// ``` Future create(T data) async { - final writeBatch = WriteBatch._(this.firestore)..create(this, data); + final writeBatch = WriteBatch._(firestore)..create(this, data); final results = await writeBatch.commit(); return results.single; @@ -288,7 +288,7 @@ final class DocumentReference implements _Serializable { /// A delete for a non-existing document is treated as a success (unless /// [precondition] is specified). Future delete([Precondition? precondition]) async { - final writeBatch = WriteBatch._(this.firestore) + final writeBatch = WriteBatch._(firestore) ..delete(this, precondition: precondition); final results = await writeBatch.commit(); @@ -298,7 +298,7 @@ final class DocumentReference implements _Serializable { /// Writes to the document referred to by this DocumentReference. If the /// document does not yet exist, it will be created. Future set(T data) async { - final writeBatch = WriteBatch._(this.firestore)..set(this, data); + final writeBatch = WriteBatch._(firestore)..set(this, data); final results = await writeBatch.commit(); return results.single; @@ -318,7 +318,7 @@ final class DocumentReference implements _Serializable { Map data, [ Precondition? precondition, ]) async { - final writeBatch = WriteBatch._(this.firestore) + final writeBatch = WriteBatch._(firestore) ..update( this, { @@ -835,7 +835,7 @@ base class Query { } _validateQueryValue('$i', fieldValue); - cursor.values.add(this.firestore._serializer.encodeValue(fieldValue)!); + cursor.values.add(firestore._serializer.encodeValue(fieldValue)!); } return cursor; @@ -1210,8 +1210,8 @@ base class Query { // For limitToLast queries, the structured query has to be translated to a version with // reversed ordered, and flipped startAt/endAt to work properly. - if (this._queryOptions.limitType == LimitType.last) { - if (!this._queryOptions.hasFieldOrders) { + if (_queryOptions.limitType == LimitType.last) { + if (!_queryOptions.hasFieldOrders) { throw ArgumentError( 'limitToLast() queries require specifying at least one orderBy() clause.', ); @@ -1270,17 +1270,17 @@ base class Query { // Kindless queries select all descendant documents, so we remove the // collectionId field. if (!_queryOptions.kindless) { - structuredQuery.from![0].collectionId = this._queryOptions.collectionId; + structuredQuery.from![0].collectionId = _queryOptions.collectionId; } if (_queryOptions.filters.isNotEmpty) { structuredQuery.where = _CompositeFilterInternal( - filters: this._queryOptions.filters, + filters: _queryOptions.filters, op: _CompositeOperator.and, ).toProto(); } - if (this._queryOptions.hasFieldOrders) { + if (_queryOptions.hasFieldOrders) { structuredQuery.orderBy = _queryOptions.fieldOrders.map((o) => o._toProto()).toList(); } @@ -1738,6 +1738,52 @@ base class Query { AggregateQuery count() { return aggregate(AggregateField.count()); } + + /// Returns an [AggregateQuery] that can be used to execute a sum + /// aggregation on the specified field. + /// + /// The returned query, when executed, calculates the sum of all values + /// for the specified field across all documents in the result set. + /// + /// - [field]: The field to sum across all matching documents. Can be a + /// String or a [FieldPath] for nested fields. + /// + /// ```dart + /// firestore.collection('products').sum('price').get().then( + /// (res) => print(res.getSum('price')), + /// onError: (e) => print('Error completing: $e'), + /// ); + /// ``` + AggregateQuery sum(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + return aggregate(AggregateField.sum(field)); + } + + /// Returns an [AggregateQuery] that can be used to execute an average + /// aggregation on the specified field. + /// + /// The returned query, when executed, calculates the average of all values + /// for the specified field across all documents in the result set. + /// + /// - [field]: The field to average across all matching documents. Can be a + /// String or a [FieldPath] for nested fields. + /// + /// ```dart + /// firestore.collection('products').average('price').get().then( + /// (res) => print(res.getAverage('price')), + /// onError: (e) => print('Error completing: $e'), + /// ); + /// ``` + AggregateQuery average(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + return aggregate(AggregateField.average(field)); + } } /// Defines an aggregation that can be performed by Firestore. @@ -1749,15 +1795,6 @@ class AggregateField { 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. @@ -1772,30 +1809,53 @@ class AggregateField { /// Creates a sum aggregation for the specified field. /// - /// - [field]: The field to sum across all matching documents. + /// - [field]: The field to sum across all matching documents. Can be a + /// String or a [FieldPath] for nested fields. /// /// The result can be accessed using [AggregateQuerySnapshot.getSum]. - factory AggregateField.sum(String field) { + factory AggregateField.sum(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + final fieldPath = FieldPath.from(field); + final fieldName = fieldPath._formattedName; return AggregateField._( - fieldPath: field, - alias: 'sum_$field', + fieldPath: fieldName, + alias: 'sum_$fieldName', type: _AggregateType.sum, ); } /// Creates an average aggregation for the specified field. /// - /// - [field]: The field to average across all matching documents. + /// - [field]: The field to average across all matching documents. Can be a + /// String or a [FieldPath] for nested fields. /// /// The result can be accessed using [AggregateQuerySnapshot.getAverage]. - factory AggregateField.average(String field) { + factory AggregateField.average(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + final fieldPath = FieldPath.from(field); + final fieldName = fieldPath._formattedName; return AggregateField._( - fieldPath: field, - alias: 'avg_$field', + fieldPath: fieldName, + alias: 'avg_$fieldName', type: _AggregateType.average, ); } + /// 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; + /// Converts this public field to the internal representation. _AggregateFieldInternal _toInternal() { firestore1.Aggregation aggregation; @@ -1804,21 +1864,18 @@ class AggregateField { aggregation = firestore1.Aggregation( count: firestore1.Count(), ); - break; case _AggregateType.sum: aggregation = firestore1.Aggregation( sum: firestore1.Sum( - field: firestore1.FieldReference(fieldPath: fieldPath!), + field: firestore1.FieldReference(fieldPath: fieldPath), ), ); - break; case _AggregateType.average: aggregation = firestore1.Aggregation( avg: firestore1.Avg( - field: firestore1.FieldReference(fieldPath: fieldPath!), + field: firestore1.FieldReference(fieldPath: fieldPath), ), ); - break; } return _AggregateFieldInternal( @@ -1840,11 +1897,12 @@ enum _AggregateType { // ignore: camel_case_types class count extends AggregateField { /// Creates a count aggregation. - const count() : super._( - fieldPath: null, - alias: 'count', - type: _AggregateType.count, - ); + 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 @@ -1852,11 +1910,12 @@ class count extends AggregateField { // 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, - ); + const sum(this.field) + : super._( + fieldPath: field, + alias: 'sum_$field', + type: _AggregateType.sum, + ); /// The field to sum. final String field; @@ -1867,11 +1926,12 @@ class sum extends AggregateField { // 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, - ); + const average(this.field) + : super._( + fieldPath: field, + alias: 'avg_$field', + type: _AggregateType.average, + ); /// The field to average. final String field; @@ -1894,15 +1954,17 @@ class _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)); + (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, - ); + alias, + aggregation.count != null || + aggregation.sum != null || + aggregation.avg != null, + ); } /// Calculates aggregations over an underlying query. diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/aggregate_query_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/aggregate_query_test.dart index 13067df..d6a494e 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/aggregate_query_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/aggregate_query_test.dart @@ -11,7 +11,8 @@ void main() { setUp(() async { firestore = await createFirestore(); collection = firestore.collection( - 'aggregate-test-${DateTime.now().millisecondsSinceEpoch}'); + 'aggregate-test-${DateTime.now().millisecondsSinceEpoch}', + ); }); test('count() on empty collection returns 0', () async { @@ -180,7 +181,9 @@ void main() { ]); final orCount = await collection.whereFilter(orFilter).count().get(); expect( - orCount.count, 3); // {a: 1, b: 'x'}, {a: 2, b: 'y'}, {a: 1, b: 'y'} + orCount.count, + 3, + ); // {a: 1, b: 'x'}, {a: 2, b: 'y'}, {a: 1, b: 'y'} }); test('getField() returns correct values', () async { @@ -196,9 +199,11 @@ void main() { await collection.add({'price': 20.0}); await collection.add({'price': 15.5}); - final snapshot = await collection.aggregate( - sum('price'), - ).get(); + final snapshot = await collection + .aggregate( + const sum('price'), + ) + .get(); expect(snapshot.getSum('price'), equals(46.0)); }); @@ -208,9 +213,11 @@ void main() { await collection.add({'score': 90}); await collection.add({'score': 100}); - final snapshot = await collection.aggregate( - average('score'), - ).get(); + final snapshot = await collection + .aggregate( + const average('score'), + ) + .get(); expect(snapshot.getAverage('score'), equals(90.0)); }); @@ -224,9 +231,9 @@ void main() { final snapshot = await collection .where('category', WhereFilter.equal, 'A') .aggregate( - count(), - sum('value'), - average('value'), + const count(), + const sum('value'), + const average('value'), ) .get(); @@ -239,11 +246,611 @@ void main() { await collection.add({'amount': 100}); await collection.add({'amount': 200}); - final snapshot = await collection.aggregate( - count(), - ).get(); + final snapshot = await collection + .aggregate( + const count(), + ) + .get(); expect(snapshot.count, equals(2)); }); + + // Tests for the convenience sum() method + group('sum() convenience method', () { + test('sum() on empty collection returns 0', () async { + final snapshot = await collection.sum('price').get(); + // Sum on empty collection returns 0, not null + expect(snapshot.getSum('price'), equals(0)); + }); + + test('sum() returns correct sum for numeric values', () async { + await collection.add({'price': 10}); + await collection.add({'price': 20}); + await collection.add({'price': 30}); + + final snapshot = await collection.sum('price').get(); + expect(snapshot.getSum('price'), equals(60)); + }); + + test('sum() works with double values', () async { + await collection.add({'amount': 10.5}); + await collection.add({'amount': 20.25}); + await collection.add({'amount': 15.75}); + + final snapshot = await collection.sum('amount').get(); + expect(snapshot.getSum('amount'), equals(46.5)); + }); + + test('sum() works with mixed int and double values', () async { + await collection.add({'value': 10}); + await collection.add({'value': 20.5}); + await collection.add({'value': 15}); + + final snapshot = await collection.sum('value').get(); + expect(snapshot.getSum('value'), equals(45.5)); + }); + + test('sum() works with filtered query', () async { + await collection.add({'price': 10, 'category': 'A'}); + await collection.add({'price': 20, 'category': 'B'}); + await collection.add({'price': 30, 'category': 'A'}); + await collection.add({'price': 40, 'category': 'B'}); + + final snapshot = await collection + .where('category', WhereFilter.equal, 'A') + .sum('price') + .get(); + + expect(snapshot.getSum('price'), equals(40)); + }); + + test('sum() works with complex queries', () async { + await collection.add({'price': 10, 'inStock': true}); + await collection.add({'price': 20, 'inStock': false}); + await collection.add({'price': 30, 'inStock': true}); + await collection.add({'price': 40, 'inStock': true}); + await collection.add({'price': 50, 'inStock': false}); + + final snapshot = await collection + .where('inStock', WhereFilter.equal, true) + .where('price', WhereFilter.greaterThanOrEqual, 20) + .sum('price') + .get(); + + expect(snapshot.getSum('price'), equals(70)); // 30 + 40 + }); + + test('sum() works with orderBy and limit', () async { + await collection.add({'value': 5, 'order': 1}); + await collection.add({'value': 10, 'order': 2}); + await collection.add({'value': 15, 'order': 3}); + await collection.add({'value': 20, 'order': 4}); + await collection.add({'value': 25, 'order': 5}); + + final snapshot = + await collection.orderBy('order').limit(3).sum('value').get(); + + expect(snapshot.getSum('value'), equals(30)); // 5 + 10 + 15 + }); + + test('sum() works with startAt and endAt', () async { + await collection.add({'value': 10, 'order': 1}); + await collection.add({'value': 20, 'order': 2}); + await collection.add({'value': 30, 'order': 3}); + await collection.add({'value': 40, 'order': 4}); + await collection.add({'value': 50, 'order': 5}); + + final snapshot = await collection + .orderBy('order') + .startAt([2]) + .endAt([4]) + .sum('value') + .get(); + + expect(snapshot.getSum('value'), equals(90)); // 20 + 30 + 40 + }); + + test('sum() works with composite filters', () async { + await collection.add({'price': 10, 'category': 'A', 'available': true}); + await collection + .add({'price': 20, 'category': 'B', 'available': false}); + await collection.add({'price': 30, 'category': 'A', 'available': true}); + await collection.add({'price': 40, 'category': 'B', 'available': true}); + + final filter = Filter.and([ + Filter.where('available', WhereFilter.equal, true), + Filter.or([ + Filter.where('category', WhereFilter.equal, 'A'), + Filter.where('price', WhereFilter.greaterThanOrEqual, 40), + ]), + ]); + + final snapshot = + await collection.whereFilter(filter).sum('price').get(); + + expect(snapshot.getSum('price'), equals(80)); // 10 + 30 + 40 + }); + + test('sum() returns null for documents without the field', () async { + await collection.add({'price': 10}); + await collection.add({'other': 20}); // missing 'price' field + await collection.add({'price': 30}); + + final snapshot = await collection.sum('price').get(); + + // Sum should only include documents with the field + expect(snapshot.getSum('price'), equals(40)); + }); + }); + + // Tests for the convenience average() method + group('average() convenience method', () { + test('average() on empty collection returns null or NaN', () async { + final snapshot = await collection.average('score').get(); + // Average on empty collection returns null (can't divide by zero) + final avg = snapshot.getAverage('score'); + expect(avg == null || avg.isNaN, isTrue); + }); + + test('average() returns correct average for integer values', () async { + await collection.add({'score': 80}); + await collection.add({'score': 90}); + await collection.add({'score': 100}); + + final snapshot = await collection.average('score').get(); + expect(snapshot.getAverage('score'), equals(90.0)); + }); + + test('average() works with double values', () async { + await collection.add({'rating': 4.5}); + await collection.add({'rating': 3.5}); + await collection.add({'rating': 5.0}); + + final snapshot = await collection.average('rating').get(); + expect(snapshot.getAverage('rating'), closeTo(4.333, 0.001)); + }); + + test('average() works with mixed int and double values', () async { + await collection.add({'value': 10}); + await collection.add({'value': 20.5}); + await collection.add({'value': 15.5}); + + final snapshot = await collection.average('value').get(); + expect(snapshot.getAverage('value'), closeTo(15.333, 0.001)); + }); + + test('average() works with filtered query', () async { + await collection.add({'score': 80, 'category': 'A'}); + await collection.add({'score': 60, 'category': 'B'}); + await collection.add({'score': 90, 'category': 'A'}); + await collection.add({'score': 70, 'category': 'B'}); + + final snapshot = await collection + .where('category', WhereFilter.equal, 'A') + .average('score') + .get(); + + expect(snapshot.getAverage('score'), equals(85.0)); + }); + + test('average() works with complex queries', () async { + await collection.add({'score': 50, 'passed': false}); + await collection.add({'score': 80, 'passed': true}); + await collection.add({'score': 90, 'passed': true}); + await collection.add({'score': 70, 'passed': true}); + await collection.add({'score': 40, 'passed': false}); + + final snapshot = await collection + .where('passed', WhereFilter.equal, true) + .where('score', WhereFilter.greaterThanOrEqual, 75) + .average('score') + .get(); + + expect(snapshot.getAverage('score'), equals(85.0)); // (80 + 90) / 2 + }); + + test('average() works with orderBy and limit', () async { + await collection.add({'value': 10, 'order': 1}); + await collection.add({'value': 20, 'order': 2}); + await collection.add({'value': 30, 'order': 3}); + await collection.add({'value': 40, 'order': 4}); + await collection.add({'value': 50, 'order': 5}); + + final snapshot = + await collection.orderBy('order').limit(3).average('value').get(); + + expect( + snapshot.getAverage('value'), + equals(20.0), + ); // (10 + 20 + 30) / 3 + }); + + test('average() works with startAt and endAt', () async { + await collection.add({'value': 10, 'order': 1}); + await collection.add({'value': 20, 'order': 2}); + await collection.add({'value': 30, 'order': 3}); + await collection.add({'value': 40, 'order': 4}); + await collection.add({'value': 50, 'order': 5}); + + final snapshot = await collection + .orderBy('order') + .startAt([2]) + .endAt([4]) + .average('value') + .get(); + + expect( + snapshot.getAverage('value'), + equals(30.0), + ); // (20 + 30 + 40) / 3 + }); + + test('average() works with composite filters', () async { + await collection.add({'price': 100, 'category': 'A', 'premium': true}); + await collection.add({'price': 50, 'category': 'B', 'premium': false}); + await collection.add({'price': 150, 'category': 'A', 'premium': true}); + await collection.add({'price': 200, 'category': 'B', 'premium': true}); + + final filter = Filter.and([ + Filter.where('premium', WhereFilter.equal, true), + Filter.or([ + Filter.where('category', WhereFilter.equal, 'A'), + Filter.where('price', WhereFilter.greaterThanOrEqual, 200), + ]), + ]); + + final snapshot = + await collection.whereFilter(filter).average('price').get(); + + expect( + snapshot.getAverage('price'), + equals(150.0), + ); // (100 + 150 + 200) / 3 + }); + + test('average() returns null for documents without the field', () async { + await collection.add({'score': 80}); + await collection.add({'other': 90}); // missing 'score' field + await collection.add({'score': 100}); + + final snapshot = await collection.average('score').get(); + + // Average should only include documents with the field + expect(snapshot.getAverage('score'), equals(90.0)); + }); + + test('average() with single document', () async { + await collection.add({'value': 42}); + + final snapshot = await collection.average('value').get(); + expect(snapshot.getAverage('value'), equals(42.0)); + }); + }); + + // Combined tests for sum(), average(), and count() + group('combined aggregations', () { + test('sum() and average() work together', () async { + await collection.add({'value': 10}); + await collection.add({'value': 20}); + await collection.add({'value': 30}); + + final snapshot = await collection + .aggregate( + const sum('value'), + const average('value'), + ) + .get(); + + expect(snapshot.getSum('value'), equals(60)); + expect(snapshot.getAverage('value'), equals(20.0)); + }); + + test('count(), sum(), and average() work together', () async { + await collection.add({'price': 10, 'category': 'A'}); + await collection.add({'price': 20, 'category': 'A'}); + await collection.add({'price': 30, 'category': 'B'}); + + final snapshot = await collection + .where('category', WhereFilter.equal, 'A') + .aggregate( + const count(), + const sum('price'), + const average('price'), + ) + .get(); + + expect(snapshot.count, equals(2)); + expect(snapshot.getSum('price'), equals(30)); + expect(snapshot.getAverage('price'), equals(15.0)); + }); + + test('multiple sum() and average() aggregations', () async { + await collection.add({'price': 10, 'quantity': 5}); + await collection.add({'price': 20, 'quantity': 3}); + await collection.add({'price': 15, 'quantity': 4}); + + // Test with up to 3 aggregations (max allowed by aggregate method) + final snapshot = await collection + .aggregate( + const sum('price'), + const average('price'), + const sum('quantity'), + ) + .get(); + + expect(snapshot.getSum('price'), equals(45)); + expect(snapshot.getSum('quantity'), equals(12)); + expect(snapshot.getAverage('price'), equals(15.0)); + }); + + test('sum() and average() on different fields', () async { + await collection.add({'price': 10, 'quantity': 5}); + await collection.add({'price': 20, 'quantity': 3}); + await collection.add({'price': 15, 'quantity': 4}); + + // Test average on quantity separately + final snapshot = await collection.average('quantity').get(); + expect(snapshot.getAverage('quantity'), equals(4.0)); + }); + + test('aggregations work with collection groups', () async { + final doc1 = collection.doc('doc1'); + final doc2 = collection.doc('doc2'); + + await doc1.set({'type': 'parent'}); + await doc2.set({'type': 'parent'}); + + await doc1.collection('items').add({'price': 10}); + await doc1.collection('items').add({'price': 20}); + await doc2.collection('items').add({'price': 30}); + + final collectionGroup = firestore.collectionGroup('items'); + final snapshot = await collectionGroup + .aggregate( + const count(), + const sum('price'), + const average('price'), + ) + .get(); + + expect(snapshot.count, equals(3)); + expect(snapshot.getSum('price'), equals(60)); + expect(snapshot.getAverage('price'), equals(20.0)); + }); + }); + + // Edge case tests + group('edge cases', () { + test('sum() with zero values', () async { + await collection.add({'value': 0}); + await collection.add({'value': 0}); + await collection.add({'value': 0}); + + final snapshot = await collection.sum('value').get(); + expect(snapshot.getSum('value'), equals(0)); + }); + + test('average() with zero values', () async { + await collection.add({'value': 0}); + await collection.add({'value': 0}); + await collection.add({'value': 0}); + + final snapshot = await collection.average('value').get(); + expect(snapshot.getAverage('value'), equals(0.0)); + }); + + test('sum() with negative values', () async { + await collection.add({'value': -10}); + await collection.add({'value': 20}); + await collection.add({'value': -5}); + + final snapshot = await collection.sum('value').get(); + expect(snapshot.getSum('value'), equals(5)); + }); + + test('average() with negative values', () async { + await collection.add({'value': -10}); + await collection.add({'value': 20}); + await collection.add({'value': -20}); + + final snapshot = await collection.average('value').get(); + expect(snapshot.getAverage('value'), closeTo(-3.333, 0.001)); + }); + + test('sum() with very large numbers', () async { + await collection.add({'value': 1000000000}); + await collection.add({'value': 2000000000}); + await collection.add({'value': 3000000000}); + + final snapshot = await collection.sum('value').get(); + expect(snapshot.getSum('value'), equals(6000000000)); + }); + + test('average() with very small numbers', () async { + await collection.add({'value': 0.001}); + await collection.add({'value': 0.002}); + await collection.add({'value': 0.003}); + + final snapshot = await collection.average('value').get(); + expect(snapshot.getAverage('value'), closeTo(0.002, 0.0001)); + }); + + test('aggregations provide consistent readTime', () async { + await collection.add({'value': 10}); + + final snapshot = await collection + .aggregate( + const count(), + const sum('value'), + const average('value'), + ) + .get(); + + expect(snapshot.readTime, isNotNull); + expect(snapshot.count, equals(1)); + expect(snapshot.getSum('value'), equals(10)); + expect(snapshot.getAverage('value'), equals(10.0)); + }); + }); + + group('FieldPath support', () { + test('sum() works with FieldPath for nested fields', () async { + await collection.add({ + 'product': {'price': 10} + }); + await collection.add({ + 'product': {'price': 20} + }); + await collection.add({ + 'product': {'price': 15} + }); + + final snapshot = + await collection.sum(FieldPath(['product', 'price'])).get(); + + expect(snapshot.getSum('product.price'), equals(45)); + }); + + test('average() works with FieldPath for nested fields', () async { + await collection.add({ + 'product': {'price': 10} + }); + await collection.add({ + 'product': {'price': 20} + }); + await collection.add({ + 'product': {'price': 15} + }); + + final snapshot = + await collection.average(FieldPath(['product', 'price'])).get(); + + expect(snapshot.getAverage('product.price'), equals(15.0)); + }); + + test('AggregateField.sum() works with FieldPath', () async { + await collection.add({ + 'nested': {'value': 100} + }); + await collection.add({ + 'nested': {'value': 200} + }); + + final snapshot = await collection + .aggregate(AggregateField.sum(FieldPath(['nested', 'value']))) + .get(); + + expect(snapshot.getSum('nested.value'), equals(300)); + }); + + test('AggregateField.average() works with FieldPath', () async { + await collection.add({ + 'nested': {'score': 85} + }); + await collection.add({ + 'nested': {'score': 90} + }); + await collection.add({ + 'nested': {'score': 95} + }); + + final snapshot = await collection + .aggregate(AggregateField.average(FieldPath(['nested', 'score']))) + .get(); + + expect(snapshot.getAverage('nested.score'), equals(90.0)); + }); + + test('combined aggregations work with FieldPath', () async { + await collection.add({ + 'data': {'price': 10, 'quantity': 5} + }); + await collection.add({ + 'data': {'price': 20, 'quantity': 3} + }); + + final snapshot = await collection + .aggregate( + AggregateField.sum(FieldPath(['data', 'price'])), + AggregateField.average(FieldPath(['data', 'quantity'])), + ) + .get(); + + expect(snapshot.getSum('data.price'), equals(30)); + expect(snapshot.getAverage('data.quantity'), equals(4.0)); + }); + + test('FieldPath works with deeply nested fields', () async { + await collection.add({ + 'level1': { + 'level2': { + 'level3': {'value': 42} + } + } + }); + await collection.add({ + 'level1': { + 'level2': { + 'level3': {'value': 58} + } + } + }); + + final snapshot = await collection + .sum(FieldPath(['level1', 'level2', 'level3', 'value'])) + .get(); + + expect(snapshot.getSum('level1.level2.level3.value'), equals(100)); + }); + + test('FieldPath and String fields can be mixed', () async { + await collection.add({ + 'price': 10, + 'nested': {'cost': 5} + }); + await collection.add({ + 'price': 20, + 'nested': {'cost': 10} + }); + + final snapshot = await collection + .aggregate( + const sum('price'), + AggregateField.sum(FieldPath(['nested', 'cost'])), + ) + .get(); + + expect(snapshot.getSum('price'), equals(30)); + expect(snapshot.getSum('nested.cost'), equals(15)); + }); + + test('AggregateField.sum() rejects invalid field types', () { + expect( + () => AggregateField.sum(123), + throwsA(isA()), + ); + }); + + test('AggregateField.average() rejects invalid field types', () { + expect( + () => AggregateField.average(123), + throwsA(isA()), + ); + }); + + test('Query.sum() rejects invalid field types', () { + expect( + () => collection.sum(123), + throwsA(isA()), + ); + }); + + test('Query.average() rejects invalid field types', () { + expect( + () => collection.average(123), + throwsA(isA()), + ); + }); + }); }); }