Skip to content

Commit 1547699

Browse files
authored
feat(firestore): Implements Aggregation queries in Firestore (#96)
* Added support for the count() aggregation query. * Added support for sum, average, and running multiple aggregate queries at once. * Fixed naming convention for the count, sum, and average classes.
1 parent 3b3af8d commit 1547699

File tree

4 files changed

+636
-0
lines changed

4 files changed

+636
-0
lines changed

packages/dart_firebase_admin/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ print(user.data()?['age']);
202202
| query.limit ||
203203
| query.limitToLast ||
204204
| query.offset ||
205+
| query.count() ||
206+
| query.sum() ||
207+
| query.average() ||
205208
| querySnapshot.docs ||
206209
| querySnapshot.readTime ||
207210
| documentSnapshots.data ||

packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart

Lines changed: 383 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1680,6 +1680,389 @@ base class Query<T> {
16801680

16811681
@override
16821682
int get hashCode => Object.hash(runtimeType, _queryOptions);
1683+
1684+
/// Returns an [AggregateQuery] that can be used to execute one or more
1685+
/// aggregation queries over the result set of this query.
1686+
///
1687+
/// ## Limitations
1688+
/// - Aggregation queries are only supported through direct server response
1689+
/// - Cannot be used with real-time listeners or offline queries
1690+
/// - Must complete within 60 seconds or returns DEADLINE_EXCEEDED error
1691+
/// - For sum() and average(), non-numeric values are ignored
1692+
/// - When combining aggregations on different fields, only documents
1693+
/// containing all those fields are included
1694+
///
1695+
/// ```dart
1696+
/// firestore.collection('cities').aggregate(
1697+
/// count(),
1698+
/// sum('population'),
1699+
/// average('population'),
1700+
/// ).get().then(
1701+
/// (res) {
1702+
/// print(res.count);
1703+
/// print(res.getSum('population'));
1704+
/// print(res.getAverage('population'));
1705+
/// },
1706+
/// onError: (e) => print('Error completing: $e'),
1707+
/// );
1708+
/// ```
1709+
AggregateQuery aggregate(
1710+
AggregateField aggregateField1, [
1711+
AggregateField? aggregateField2,
1712+
AggregateField? aggregateField3,
1713+
]) {
1714+
final fields = [
1715+
aggregateField1,
1716+
if (aggregateField2 != null) aggregateField2,
1717+
if (aggregateField3 != null) aggregateField3,
1718+
];
1719+
1720+
return AggregateQuery._(
1721+
query: this,
1722+
aggregations: fields.map((field) => field._toInternal()).toList(),
1723+
);
1724+
}
1725+
1726+
/// Returns an [AggregateQuery] that can be used to execute a count
1727+
/// aggregation.
1728+
///
1729+
/// The returned query, when executed, counts the documents in the result
1730+
/// set of this query without actually downloading the documents.
1731+
///
1732+
/// ```dart
1733+
/// firestore.collection('cities').count().get().then(
1734+
/// (res) => print(res.count),
1735+
/// onError: (e) => print('Error completing: $e'),
1736+
/// );
1737+
/// ```
1738+
AggregateQuery count() {
1739+
return aggregate(AggregateField.count());
1740+
}
1741+
}
1742+
1743+
/// Defines an aggregation that can be performed by Firestore.
1744+
@immutable
1745+
class AggregateField {
1746+
const AggregateField._({
1747+
required this.fieldPath,
1748+
required this.alias,
1749+
required this.type,
1750+
});
1751+
1752+
/// The field to aggregate on, or null for count aggregations.
1753+
final String? fieldPath;
1754+
1755+
/// The alias to use for this aggregation result.
1756+
final String alias;
1757+
1758+
/// The type of aggregation.
1759+
final _AggregateType type;
1760+
1761+
/// Creates a count aggregation.
1762+
///
1763+
/// Count aggregations provide the number of documents that match the query.
1764+
/// The result can be accessed using [AggregateQuerySnapshot.count].
1765+
factory AggregateField.count() {
1766+
return const AggregateField._(
1767+
fieldPath: null,
1768+
alias: 'count',
1769+
type: _AggregateType.count,
1770+
);
1771+
}
1772+
1773+
/// Creates a sum aggregation for the specified field.
1774+
///
1775+
/// - [field]: The field to sum across all matching documents.
1776+
///
1777+
/// The result can be accessed using [AggregateQuerySnapshot.getSum].
1778+
factory AggregateField.sum(String field) {
1779+
return AggregateField._(
1780+
fieldPath: field,
1781+
alias: 'sum_$field',
1782+
type: _AggregateType.sum,
1783+
);
1784+
}
1785+
1786+
/// Creates an average aggregation for the specified field.
1787+
///
1788+
/// - [field]: The field to average across all matching documents.
1789+
///
1790+
/// The result can be accessed using [AggregateQuerySnapshot.getAverage].
1791+
factory AggregateField.average(String field) {
1792+
return AggregateField._(
1793+
fieldPath: field,
1794+
alias: 'avg_$field',
1795+
type: _AggregateType.average,
1796+
);
1797+
}
1798+
1799+
/// Converts this public field to the internal representation.
1800+
_AggregateFieldInternal _toInternal() {
1801+
firestore1.Aggregation aggregation;
1802+
switch (type) {
1803+
case _AggregateType.count:
1804+
aggregation = firestore1.Aggregation(
1805+
count: firestore1.Count(),
1806+
);
1807+
break;
1808+
case _AggregateType.sum:
1809+
aggregation = firestore1.Aggregation(
1810+
sum: firestore1.Sum(
1811+
field: firestore1.FieldReference(fieldPath: fieldPath!),
1812+
),
1813+
);
1814+
break;
1815+
case _AggregateType.average:
1816+
aggregation = firestore1.Aggregation(
1817+
avg: firestore1.Avg(
1818+
field: firestore1.FieldReference(fieldPath: fieldPath!),
1819+
),
1820+
);
1821+
break;
1822+
}
1823+
1824+
return _AggregateFieldInternal(
1825+
alias: alias,
1826+
aggregation: aggregation,
1827+
);
1828+
}
1829+
}
1830+
1831+
/// The type of aggregation to perform.
1832+
enum _AggregateType {
1833+
count,
1834+
sum,
1835+
average,
1836+
}
1837+
1838+
/// Create a CountAggregateField object that can be used to compute
1839+
/// the count of documents in the result set of a query.
1840+
// ignore: camel_case_types
1841+
class count extends AggregateField {
1842+
/// Creates a count aggregation.
1843+
const count() : super._(
1844+
fieldPath: null,
1845+
alias: 'count',
1846+
type: _AggregateType.count,
1847+
);
1848+
}
1849+
1850+
/// Create an object that can be used to compute the sum of a specified field
1851+
/// over a range of documents in the result set of a query.
1852+
// ignore: camel_case_types
1853+
class sum extends AggregateField {
1854+
/// Creates a sum aggregation for the specified field.
1855+
sum(this.field) : super._(
1856+
fieldPath: field,
1857+
alias: 'sum_$field',
1858+
type: _AggregateType.sum,
1859+
);
1860+
1861+
/// The field to sum.
1862+
final String field;
1863+
}
1864+
1865+
/// Create an object that can be used to compute the average of a specified field
1866+
/// over a range of documents in the result set of a query.
1867+
// ignore: camel_case_types
1868+
class average extends AggregateField {
1869+
/// Creates an average aggregation for the specified field.
1870+
average(this.field) : super._(
1871+
fieldPath: field,
1872+
alias: 'avg_$field',
1873+
type: _AggregateType.average,
1874+
);
1875+
1876+
/// The field to average.
1877+
final String field;
1878+
}
1879+
1880+
/// Internal representation of an aggregation field.
1881+
@immutable
1882+
class _AggregateFieldInternal {
1883+
const _AggregateFieldInternal({
1884+
required this.alias,
1885+
required this.aggregation,
1886+
});
1887+
1888+
final String alias;
1889+
final firestore1.Aggregation aggregation;
1890+
1891+
@override
1892+
bool operator ==(Object other) {
1893+
return other is _AggregateFieldInternal &&
1894+
alias == other.alias &&
1895+
// For count aggregations, we just check that both have count set
1896+
((aggregation.count != null && other.aggregation.count != null) ||
1897+
(aggregation.sum != null && other.aggregation.sum != null) ||
1898+
(aggregation.avg != null && other.aggregation.avg != null));
1899+
}
1900+
1901+
@override
1902+
int get hashCode => Object.hash(
1903+
alias,
1904+
aggregation.count != null || aggregation.sum != null || aggregation.avg != null,
1905+
);
1906+
}
1907+
1908+
/// Calculates aggregations over an underlying query.
1909+
@immutable
1910+
class AggregateQuery {
1911+
const AggregateQuery._({
1912+
required this.query,
1913+
required this.aggregations,
1914+
});
1915+
1916+
/// The query whose aggregations will be calculated by this object.
1917+
final Query<Object?> query;
1918+
1919+
@internal
1920+
final List<_AggregateFieldInternal> aggregations;
1921+
1922+
/// Executes the aggregate query and returns the results as an
1923+
/// [AggregateQuerySnapshot].
1924+
///
1925+
/// ```dart
1926+
/// firestore.collection('cities').count().get().then(
1927+
/// (res) => print(res.count),
1928+
/// onError: (e) => print('Error completing: $e'),
1929+
/// );
1930+
/// ```
1931+
Future<AggregateQuerySnapshot> get() async {
1932+
final firestore = query.firestore;
1933+
1934+
final aggregationQuery = firestore1.RunAggregationQueryRequest(
1935+
structuredAggregationQuery: firestore1.StructuredAggregationQuery(
1936+
structuredQuery: query._toStructuredQuery(),
1937+
aggregations: [
1938+
for (final field in aggregations)
1939+
firestore1.Aggregation(
1940+
alias: field.alias,
1941+
count: field.aggregation.count,
1942+
sum: field.aggregation.sum,
1943+
avg: field.aggregation.avg,
1944+
),
1945+
],
1946+
),
1947+
);
1948+
1949+
final response = await firestore._client.v1((client) async {
1950+
return client.projects.databases.documents.runAggregationQuery(
1951+
aggregationQuery,
1952+
query._buildProtoParentPath(),
1953+
);
1954+
});
1955+
1956+
final results = <String, Object?>{};
1957+
Timestamp? readTime;
1958+
1959+
for (final result in response) {
1960+
if (result.result != null && result.result!.aggregateFields != null) {
1961+
for (final entry in result.result!.aggregateFields!.entries) {
1962+
final value = entry.value;
1963+
if (value.integerValue != null) {
1964+
results[entry.key] = int.parse(value.integerValue!);
1965+
} else if (value.doubleValue != null) {
1966+
results[entry.key] = value.doubleValue;
1967+
} else if (value.nullValue != null) {
1968+
results[entry.key] = null;
1969+
}
1970+
}
1971+
}
1972+
1973+
if (result.readTime != null) {
1974+
readTime = Timestamp._fromString(result.readTime!);
1975+
}
1976+
}
1977+
1978+
return AggregateQuerySnapshot._(
1979+
query: this,
1980+
readTime: readTime,
1981+
data: results,
1982+
);
1983+
}
1984+
1985+
@override
1986+
bool operator ==(Object other) {
1987+
return other is AggregateQuery &&
1988+
query == other.query &&
1989+
const ListEquality<_AggregateFieldInternal>()
1990+
.equals(aggregations, other.aggregations);
1991+
}
1992+
1993+
@override
1994+
int get hashCode => Object.hash(
1995+
query,
1996+
const ListEquality<_AggregateFieldInternal>().hash(aggregations),
1997+
);
1998+
}
1999+
2000+
/// The results of executing an aggregation query.
2001+
@immutable
2002+
class AggregateQuerySnapshot {
2003+
const AggregateQuerySnapshot._({
2004+
required this.query,
2005+
required this.readTime,
2006+
required this.data,
2007+
});
2008+
2009+
/// The query that was executed to produce this result.
2010+
final AggregateQuery query;
2011+
2012+
/// The time this snapshot was obtained.
2013+
final Timestamp? readTime;
2014+
2015+
/// The raw aggregation data, keyed by alias.
2016+
final Map<String, Object?> data;
2017+
2018+
/// The count of documents that match the query. Returns `null` if the
2019+
/// count aggregation was not performed.
2020+
int? get count => data['count'] as int?;
2021+
2022+
/// Gets the sum for the specified field. Returns `null` if the
2023+
/// sum aggregation was not performed.
2024+
///
2025+
/// - [field]: The field that was summed.
2026+
num? getSum(String field) {
2027+
final alias = 'sum_$field';
2028+
final value = data[alias];
2029+
if (value == null) return null;
2030+
if (value is int || value is double) return value as num;
2031+
// Handle case where sum might be returned as a string
2032+
if (value is String) return num.tryParse(value);
2033+
return null;
2034+
}
2035+
2036+
/// Gets the average for the specified field. Returns `null` if the
2037+
/// average aggregation was not performed.
2038+
///
2039+
/// - [field]: The field that was averaged.
2040+
double? getAverage(String field) {
2041+
final alias = 'avg_$field';
2042+
final value = data[alias];
2043+
if (value == null) return null;
2044+
if (value is double) return value;
2045+
if (value is int) return value.toDouble();
2046+
// Handle case where average might be returned as a string
2047+
if (value is String) return double.tryParse(value);
2048+
return null;
2049+
}
2050+
2051+
/// Gets an aggregate field by alias.
2052+
///
2053+
/// - [alias]: The alias of the aggregate field to retrieve.
2054+
Object? getField(String alias) => data[alias];
2055+
2056+
@override
2057+
bool operator ==(Object other) {
2058+
return other is AggregateQuerySnapshot &&
2059+
query == other.query &&
2060+
readTime == other.readTime &&
2061+
const MapEquality<String, Object?>().equals(data, other.data);
2062+
}
2063+
2064+
@override
2065+
int get hashCode => Object.hash(query, readTime, data);
16832066
}
16842067

16852068
/// A QuerySnapshot contains zero or more [QueryDocumentSnapshot] objects

0 commit comments

Comments
 (0)