From 00972ffe4e90b48b67a3aca7e440d04f5d2d0d77 Mon Sep 17 00:00:00 2001 From: demolaf Date: Thu, 5 Feb 2026 10:27:31 +0100 Subject: [PATCH 1/4] chore: update googleapis dependency to version 16.0.0 --- packages/dart_firebase_admin/pubspec.yaml | 41 +++++++++++++++++++++ packages/googleapis_auth_utils/pubspec.yaml | 21 +++++++++++ packages/googleapis_firestore/pubspec.yaml | 22 +++++++++++ packages/googleapis_storage/pubspec.yaml | 27 ++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 packages/dart_firebase_admin/pubspec.yaml create mode 100644 packages/googleapis_auth_utils/pubspec.yaml create mode 100644 packages/googleapis_firestore/pubspec.yaml create mode 100644 packages/googleapis_storage/pubspec.yaml diff --git a/packages/dart_firebase_admin/pubspec.yaml b/packages/dart_firebase_admin/pubspec.yaml new file mode 100644 index 00000000..51df4c4a --- /dev/null +++ b/packages/dart_firebase_admin/pubspec.yaml @@ -0,0 +1,41 @@ +name: dart_firebase_admin +description: A Firebase Admin SDK implementation for Dart. +resolution: workspace +version: 0.4.1 +homepage: "https://github.com/invertase/dart_firebase_admin" +repository: "https://github.com/invertase/dart_firebase_admin" +publish_to: none + +environment: + sdk: ">=3.9.0 <4.0.0" + +dependencies: + asn1lib: ^1.6.0 + collection: ^1.18.0 + dart_jsonwebtoken: ^3.0.0 + equatable: ^2.0.7 + googleapis: ^16.0.0 + googleapis_auth: ^2.0.0 + googleapis_auth_utils: ^0.1.0 + googleapis_beta: ^9.0.0 + googleapis_firestore: ^0.1.0 + googleapis_storage: ^0.1.0 + http: ^1.0.0 + intl: ^0.20.0 + jose: ^0.3.4 + meta: ^1.9.1 + pem: ^2.0.5 + pointycastle: ^3.7.0 + +dev_dependencies: + build_runner: ^2.4.7 + file: ^7.0.0 + mocktail: ^1.0.1 + path: ^1.9.1 + test: ^1.24.4 + uuid: ^4.0.0 + +false_secrets: + - /test/auth/jwt_test.dart + - /test/client/get_id_token.js + - /test/mock_service_account.dart \ No newline at end of file diff --git a/packages/googleapis_auth_utils/pubspec.yaml b/packages/googleapis_auth_utils/pubspec.yaml new file mode 100644 index 00000000..1f374c19 --- /dev/null +++ b/packages/googleapis_auth_utils/pubspec.yaml @@ -0,0 +1,21 @@ +name: googleapis_auth_utils +description: Utilities for working with googleapis_auth package. +resolution: workspace +version: 0.1.0 +repository: "https://github.com/invertase/dart_firebase_admin" + +environment: + sdk: ">=3.9.0 <4.0.0" + +dependencies: + asn1lib: ^1.6.0 + googleapis: ^16.0.0 + googleapis_auth: ^2.0.0 + http: ^1.0.0 + meta: ^1.9.1 + pem: ^2.0.5 + pointycastle: ^3.7.0 + +dev_dependencies: + mocktail: ^1.0.1 + test: ^1.24.4 diff --git a/packages/googleapis_firestore/pubspec.yaml b/packages/googleapis_firestore/pubspec.yaml new file mode 100644 index 00000000..0417c03a --- /dev/null +++ b/packages/googleapis_firestore/pubspec.yaml @@ -0,0 +1,22 @@ +name: googleapis_firestore +description: Google Cloud Firestore client library for Dart. +resolution: workspace +version: 0.1.0 +repository: "https://github.com/invertase/dart_firebase_admin" + +environment: + sdk: ">=3.9.0 <4.0.0" + +dependencies: + collection: ^1.18.0 + googleapis: ^16.0.0 + googleapis_auth: ^2.0.0 + googleapis_auth_utils: ^0.1.0 + http: ^1.0.0 + intl: ^0.20.0 + meta: ^1.9.1 + +dev_dependencies: + build_runner: ^2.4.7 + mocktail: ^1.0.0 + test: ^1.24.4 diff --git a/packages/googleapis_storage/pubspec.yaml b/packages/googleapis_storage/pubspec.yaml new file mode 100644 index 00000000..92bc4197 --- /dev/null +++ b/packages/googleapis_storage/pubspec.yaml @@ -0,0 +1,27 @@ +name: googleapis_storage +resolution: workspace +description: Dart client for Gogole Cloud Storage - unified object storage for developers and enterprises, from live data serving to data analytics/ML to data archiving. +version: 0.1.0 +# repository: https://github.com/my_org/my_repo + +environment: + sdk: ">=3.9.0 <4.0.0" + +dependencies: + googleapis_auth: ^2.0.0 + googleapis_auth_utils: ^0.1.0 + googleapis: ^16.0.0 + http: ^1.6.0 + meta: ^1.17.0 + mime: ^2.0.0 + crypto: ^3.0.7 + xml: ^6.6.1 + freezed_annotation: ^3.1.0 + intl: ^0.20.0 + +dev_dependencies: + lints: ^6.0.0 + test: ^1.25.6 + mocktail: ^1.0.4 + build_runner: ^2.10.4 + freezed: ^3.2.3 From c9c91fcb4b45959b6ad163f1753da90766e6d5b8 Mon Sep 17 00:00:00 2001 From: demolaf Date: Thu, 5 Feb 2026 13:52:31 +0100 Subject: [PATCH 2/4] wip pipelines api --- .../lib/google_cloud_firestore.dart | 29 +- .../lib/src/firestore.dart | 36 +- .../lib/src/reference/document_reference.dart | 2 +- .../lib/src/transaction.dart | 98 +++ .../test/unit/bundle_test.dart | 2 +- .../test/unit/order_test.dart | 8 +- .../lib/src/pipelines/aggregate_function.dart | 331 +++++++ .../lib/src/pipelines/aliased.dart | 94 ++ .../lib/src/pipelines/explain_stats.dart | 35 + .../lib/src/pipelines/expression.dart | 623 ++++++++++++++ .../lib/src/pipelines/ordering.dart | 54 ++ .../lib/src/pipelines/pipeline_snapshot.dart | 141 +++ .../lib/src/pipelines/pipelines.dart | 814 ++++++++++++++++++ .../lib/src/pipelines/stage.dart | 1 + .../lib/src/pipelines/stage_options.dart | 180 ++++ .../test/pipelines_integration_test.dart | 290 +++++++ .../test/query_partition_test.dart | 405 +++++++++ 17 files changed, 3133 insertions(+), 10 deletions(-) create mode 100644 packages/googleapis_firestore/lib/src/pipelines/aggregate_function.dart create mode 100644 packages/googleapis_firestore/lib/src/pipelines/aliased.dart create mode 100644 packages/googleapis_firestore/lib/src/pipelines/explain_stats.dart create mode 100644 packages/googleapis_firestore/lib/src/pipelines/expression.dart create mode 100644 packages/googleapis_firestore/lib/src/pipelines/ordering.dart create mode 100644 packages/googleapis_firestore/lib/src/pipelines/pipeline_snapshot.dart create mode 100644 packages/googleapis_firestore/lib/src/pipelines/pipelines.dart create mode 100644 packages/googleapis_firestore/lib/src/pipelines/stage.dart create mode 100644 packages/googleapis_firestore/lib/src/pipelines/stage_options.dart create mode 100644 packages/googleapis_firestore/test/pipelines_integration_test.dart create mode 100644 packages/googleapis_firestore/test/query_partition_test.dart diff --git a/packages/google_cloud_firestore/lib/google_cloud_firestore.dart b/packages/google_cloud_firestore/lib/google_cloud_firestore.dart index dadb927e..746ebd9b 100644 --- a/packages/google_cloud_firestore/lib/google_cloud_firestore.dart +++ b/packages/google_cloud_firestore/lib/google_cloud_firestore.dart @@ -69,7 +69,34 @@ export 'src/firestore.dart' WriteResult, average, count, - sum; + sum, + // Pipeline classes + Pipeline, + PipelineSource, + PipelineSnapshot, + PipelineResult, + ExplainStats, + // Expression classes + Expression, + Field, + Constant, + FunctionExpression, + BooleanExpression, + // Aggregate classes + AggregateFunction, + CountAggregate, + CountAllAggregate, + CountDistinctAggregate, + CountIfAggregate, + SumAggregate, + AverageAggregate, + MinimumAggregate, + MaximumAggregate, + // Supporting classes + Ordering, + AliasedExpression, + AliasedAggregate, + Selectable; export 'src/firestore_exception.dart' show FirestoreClientErrorCode, FirestoreException; export 'src/status_code.dart' show StatusCode; diff --git a/packages/google_cloud_firestore/lib/src/firestore.dart b/packages/google_cloud_firestore/lib/src/firestore.dart index 7d50925d..4947db13 100644 --- a/packages/google_cloud_firestore/lib/src/firestore.dart +++ b/packages/google_cloud_firestore/lib/src/firestore.dart @@ -74,6 +74,16 @@ part 'types.dart'; part 'util.dart'; part 'validate.dart'; part 'write_batch.dart'; +part 'pipelines/expression.dart'; +part 'pipelines/aggregate_function.dart'; +part 'pipelines/ordering.dart'; +part 'pipelines/aliased.dart'; +part 'pipelines/explain_stats.dart'; +part 'pipelines/pipeline_snapshot.dart'; +part 'pipelines/pipelines.dart'; +part 'pipelines/stage_options.dart'; + +const kDefaultDatabase = '(default)'; /// Settings used to configure a Firestore instance. /// @@ -108,7 +118,7 @@ class Settings { /// Creates Firestore settings. const Settings({ this.projectId, - this.databaseId, + this.databaseId = kDefaultDatabase, this.host, this.ssl = true, this.credential, @@ -127,7 +137,7 @@ class Settings { /// The database name. If omitted, the default database will be used. /// /// Defaults to '(default)'. - final String? databaseId; + final String databaseId; /// The hostname to connect to. /// @@ -359,7 +369,7 @@ class Firestore { } /// Returns the Database ID for this Firestore instance. - String get databaseId => _settings.databaseId ?? '(default)'; + String get databaseId => _settings.databaseId; /// Returns the root path of the database. /// @@ -489,6 +499,26 @@ class Firestore { return rootDocument.listCollections(); } + /// Returns a [PipelineSource] to create and execute Firestore Pipelines. + /// + /// Pipelines provide a flexible framework for building complex data + /// transformations and queries. Note: Execution is not yet supported + /// pending googleapis package updates. + /// + /// Example: + /// ```dart + /// final pipeline = firestore + /// .pipeline() + /// .collection('books') + /// .where(greaterThan(field('rating'), constant(4.5))) + /// .select('title', 'author'); + /// + /// // execute() will throw UnimplementedError until googleapis support exists + /// ``` + PipelineSource pipeline() { + return PipelineSource._(firestore: this); + } + /// Creates a write batch, used for performing multiple writes as a single /// atomic operation. /// diff --git a/packages/google_cloud_firestore/lib/src/reference/document_reference.dart b/packages/google_cloud_firestore/lib/src/reference/document_reference.dart index 14053cc0..a577973c 100644 --- a/packages/google_cloud_firestore/lib/src/reference/document_reference.dart +++ b/packages/google_cloud_firestore/lib/src/reference/document_reference.dart @@ -71,7 +71,7 @@ interface class DocumentReference implements _Serializable { /// }); /// ``` Future>> listCollections() { - return firestore._firestoreClient.v1((a, projectId) async { + return firestore._firestoreClient.v1((api, projectId) async { final request = firestore_v1.ListCollectionIdsRequest( parent: _formattedName, // Setting `pageSize` to an arbitrarily large value lets the backend cap diff --git a/packages/google_cloud_firestore/lib/src/transaction.dart b/packages/google_cloud_firestore/lib/src/transaction.dart index 99171ec5..74809997 100644 --- a/packages/google_cloud_firestore/lib/src/transaction.dart +++ b/packages/google_cloud_firestore/lib/src/transaction.dart @@ -289,6 +289,30 @@ class Transaction { _writeBatch.delete(documentRef, precondition: precondition); } + /// Executes a pipeline within the transaction context. + /// + /// Example: + /// ```dart + /// firestore.runTransaction((transaction) async { + /// final pipeline = firestore + /// .pipeline() + /// .collection('books') + /// .where(greaterThan(field('rating'), constant(4.5))); + /// + /// final snapshot = await transaction.execute(pipeline); + /// // Process results... + /// }); + /// ``` + Future execute(Pipeline pipeline) async { + if (_writeBatch != null && _writeBatch._operations.isNotEmpty) { + throw FirestoreException( + FirestoreClientErrorCode.failedPrecondition, + readAfterWriteErrorMsg, + ); + } + return _withLazyStartedTransaction(pipeline, resultFn: _executePipelineFn); + } + Future _commit() async { if (_writeBatch == null) { throw FirestoreException( @@ -497,6 +521,7 @@ class Transaction { ); } +<<<<<<< HEAD:packages/google_cloud_firestore/lib/src/transaction.dart Future<_TransactionResult> _getAggregateQueryFn( AggregateQuery aggregateQuery, { String? transactionId, @@ -519,6 +544,79 @@ class Transaction { ); } + Future<_TransactionResult> _executePipelineFn( + Pipeline pipeline, { + String? transactionId, + Timestamp? readTime, + firestore_v1.TransactionOptions? transactionOptions, + List? fieldMask, + }) async { + final request = firestore_v1.ExecutePipelineRequest( + structuredPipeline: firestore_v1.StructuredPipeline( + pipeline: pipeline._toProto(), + ), + transaction: transactionId, + readTime: readTime != null + ? _toGoogleDateTime( + seconds: readTime.seconds, + nanoseconds: readTime.nanoseconds, + ) + : null, + newTransaction: transactionOptions, + ); + + final response = await _firestore._firestoreClient.v1((api, projectId) { + return api.projects.databases.documents.executePipeline( + request, + _firestore._formattedDatabaseName, + ); + }); + + // Parse the response + final results = []; + if (response.results != null) { + for (final resultDoc in response.results!) { + final fields = resultDoc.fields; + final data = fields != null + ? { + for (final prop in fields.entries) + prop.key: _firestore._serializer.decodeValue(prop.value), + } + : {}; + + results.add( + PipelineResult._( + ref: resultDoc.name != null + ? _firestore.doc(resultDoc.name!) + : null, + id: resultDoc.name?.split('/').last, + createTime: resultDoc.createTime != null + ? Timestamp._fromString(resultDoc.createTime!) + : null, + updateTime: resultDoc.updateTime != null + ? Timestamp._fromString(resultDoc.updateTime!) + : null, + data: data, + ), + ); + } + } + + final snapshot = PipelineSnapshot._( + pipeline: pipeline, + results: results, + executionTime: Timestamp.now(), + explainStats: response.explainStats != null + ? ExplainStats._fromProto(response.explainStats!) + : null, + ); + + return _TransactionResult( + transaction: response.transaction, + result: snapshot, + ); + } + Future _runTransaction(TransactionHandler updateFunction) async { // No backoff is set for readonly transactions (i.e. attempts == 1) if (_writeBatch == null) { diff --git a/packages/google_cloud_firestore/test/unit/bundle_test.dart b/packages/google_cloud_firestore/test/unit/bundle_test.dart index ace9cf13..ae513f61 100644 --- a/packages/google_cloud_firestore/test/unit/bundle_test.dart +++ b/packages/google_cloud_firestore/test/unit/bundle_test.dart @@ -22,7 +22,7 @@ import 'package:test/test.dart'; const testBundleId = 'test-bundle'; const testBundleVersion = 1; -const databaseRoot = 'projects/test-project/databases/(default)'; +const databaseRoot = 'projects/test-project/databases/$kDefaultDatabase'; /// Helper function to parse a length-prefixed bundle buffer into elements. List> bundleToElementArray(Uint8List buffer) { diff --git a/packages/google_cloud_firestore/test/unit/order_test.dart b/packages/google_cloud_firestore/test/unit/order_test.dart index 411fa4ac..82220961 100644 --- a/packages/google_cloud_firestore/test/unit/order_test.dart +++ b/packages/google_cloud_firestore/test/unit/order_test.dart @@ -105,11 +105,11 @@ void main() { test('compares reference values', () { final left = firestore_v1.Value( referenceValue: - 'projects/test/databases/(default)/documents/coll/doc1', + 'projects/test/databases/kDefaultDatabase/documents/coll/doc1', ); final right = firestore_v1.Value( referenceValue: - 'projects/test/databases/(default)/documents/coll/doc2', + 'projects/test/databases/kDefaultDatabase/documents/coll/doc2', ); expect(compare(left, right), lessThan(0)); @@ -285,14 +285,14 @@ void main() { test('compares nested arrays', () { final partition1 = [ firestore_v1.Value( - arrayValue: firestore_v1.ArrayValue( + arrayValue: firestore_v1.ArrayValue( values: [firestore_v1.Value(integerValue: 1)], ), ), ]; final partition2 = [ firestore_v1.Value( - arrayValue: firestore_v1.ArrayValue( + arrayValue: firestore_v1.ArrayValue( values: [firestore_v1.Value(integerValue: 2)], ), ), diff --git a/packages/googleapis_firestore/lib/src/pipelines/aggregate_function.dart b/packages/googleapis_firestore/lib/src/pipelines/aggregate_function.dart new file mode 100644 index 00000000..1adc8a8b --- /dev/null +++ b/packages/googleapis_firestore/lib/src/pipelines/aggregate_function.dart @@ -0,0 +1,331 @@ +part of '../firestore.dart'; + +/// Abstract base class for pipeline aggregate functions. +/// +/// Aggregate functions compute values across groups of documents: +/// - [CountAggregate] - counts documents +/// - [SumAggregate] - sums field values +/// - [AverageAggregate] - averages field values +/// - [MinimumAggregate] - finds minimum value +/// - [MaximumAggregate] - finds maximum value +/// +/// Create aggregates using factory constructors or top-level classes. +@immutable +abstract class AggregateFunction { + const AggregateFunction._(); + + /// Creates a count aggregation. + factory AggregateFunction.count() => const CountAggregate._(); + + /// Creates a count all aggregation (counts all documents including nulls). + factory AggregateFunction.countAll() => const CountAllAggregate._(); + + /// Creates a count distinct aggregation. + factory AggregateFunction.countDistinct(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + return CountDistinctAggregate._(field); + } + + /// Creates a conditional count aggregation. + factory AggregateFunction.countIf(BooleanExpression condition) => + CountIfAggregate._(condition); + + /// Creates a sum aggregation for the specified field. + factory AggregateFunction.sum(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + return SumAggregate._(field); + } + + /// Creates an average aggregation for the specified field. + factory AggregateFunction.average(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + return AverageAggregate._(field); + } + + /// Creates a minimum aggregation for the specified field. + factory AggregateFunction.minimum(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + return MinimumAggregate._(field); + } + + /// Creates a maximum aggregation for the specified field. + factory AggregateFunction.maximum(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + return MaximumAggregate._(field); + } + + /// Returns an aliased version of this aggregate. + AliasedAggregate as(String alias) => AliasedAggregate._(this, alias); + + /// Converts this aggregate function to googleapis proto format. + firestore_v1.Value _toProto(Firestore firestore); + + /// Helper to convert field (String or FieldPath) to proto Value. + static firestore_v1.Value _fieldOrPathValue(Object field) { + if (field is String) { + return firestore_v1.Value(stringValue: field); + } else if (field is FieldPath) { + return firestore_v1.Value(stringValue: field._formattedName); + } + throw ArgumentError('field must be String or FieldPath'); + } +} + +/// Counts the number of documents. +@immutable +final class CountAggregate extends AggregateFunction { + const CountAggregate._() : super._(); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CountAggregate && runtimeType == other.runtimeType; + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'CountAggregate()'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function_(name: 'count', args: []), + ); + } +} + +/// Counts all documents (including those with null values). +@immutable +final class CountAllAggregate extends AggregateFunction { + const CountAllAggregate._() : super._(); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CountAllAggregate && runtimeType == other.runtimeType; + + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() => 'CountAllAggregate()'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function_(name: 'count', args: []), + ); + } +} + +/// Counts distinct values of a field. +@immutable +final class CountDistinctAggregate extends AggregateFunction { + const CountDistinctAggregate._(this.field) : super._(); + + /// The field to count distinct values for. + final Object field; // String or FieldPath + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CountDistinctAggregate && + runtimeType == other.runtimeType && + field == other.field; + + @override + int get hashCode => Object.hash(runtimeType, field); + + @override + String toString() => 'CountDistinctAggregate($field)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function_( + name: 'count_distinct', + args: [AggregateFunction._fieldOrPathValue(field)], + ), + ); + } +} + +/// Counts documents matching a condition. +@immutable +final class CountIfAggregate extends AggregateFunction { + const CountIfAggregate._(this.condition) : super._(); + + /// The condition to evaluate. + final BooleanExpression condition; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CountIfAggregate && + runtimeType == other.runtimeType && + condition == other.condition; + + @override + int get hashCode => Object.hash(runtimeType, condition); + + @override + String toString() => 'CountIfAggregate($condition)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function_( + name: 'count_if', + args: [condition._toProto(firestore)], + ), + ); + } +} + +/// Sums field values across documents. +@immutable +final class SumAggregate extends AggregateFunction { + const SumAggregate._(this.field) : super._(); + + /// The field to sum. + final Object field; // String or FieldPath + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SumAggregate && + runtimeType == other.runtimeType && + field == other.field; + + @override + int get hashCode => Object.hash(runtimeType, field); + + @override + String toString() => 'SumAggregate($field)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function_( + name: 'sum', + args: [AggregateFunction._fieldOrPathValue(field)], + ), + ); + } +} + +/// Averages field values across documents. +@immutable +final class AverageAggregate extends AggregateFunction { + const AverageAggregate._(this.field) : super._(); + + /// The field to average. + final Object field; // String or FieldPath + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AverageAggregate && + runtimeType == other.runtimeType && + field == other.field; + + @override + int get hashCode => Object.hash(runtimeType, field); + + @override + String toString() => 'AverageAggregate($field)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function_( + name: 'average', + args: [AggregateFunction._fieldOrPathValue(field)], + ), + ); + } +} + +/// Finds the minimum field value across documents. +@immutable +final class MinimumAggregate extends AggregateFunction { + const MinimumAggregate._(this.field) : super._(); + + /// The field to find the minimum for. + final Object field; // String or FieldPath + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MinimumAggregate && + runtimeType == other.runtimeType && + field == other.field; + + @override + int get hashCode => Object.hash(runtimeType, field); + + @override + String toString() => 'MinimumAggregate($field)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function_( + name: 'minimum', + args: [AggregateFunction._fieldOrPathValue(field)], + ), + ); + } +} + +/// Finds the maximum field value across documents. +@immutable +final class MaximumAggregate extends AggregateFunction { + const MaximumAggregate._(this.field) : super._(); + + /// The field to find the maximum for. + final Object field; // String or FieldPath + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MaximumAggregate && + runtimeType == other.runtimeType && + field == other.field; + + @override + int get hashCode => Object.hash(runtimeType, field); + + @override + String toString() => 'MaximumAggregate($field)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function_( + name: 'maximum', + args: [AggregateFunction._fieldOrPathValue(field)], + ), + ); + } +} + +// Note: We don't create lowercase top-level classes here to avoid +// conflicts with existing aggregate classes in aggregate.dart. +// Use factory constructors or expression functions instead. diff --git a/packages/googleapis_firestore/lib/src/pipelines/aliased.dart b/packages/googleapis_firestore/lib/src/pipelines/aliased.dart new file mode 100644 index 00000000..2cc1b9fa --- /dev/null +++ b/packages/googleapis_firestore/lib/src/pipelines/aliased.dart @@ -0,0 +1,94 @@ +part of '../firestore.dart'; + +/// Marker interface for values that can be selected in a pipeline. +/// +/// Used to constrain the types accepted by the select() method. +abstract interface class Selectable {} + +/// An expression with an alias. +/// +/// Create aliased expressions using the [Expression.as] method: +/// ```dart +/// field('age').as('userAge') +/// constant(42).as('answer') +/// ``` +@immutable +final class AliasedExpression implements Selectable { + const AliasedExpression._(this.expression, this.alias); + + /// The underlying expression. + final Expression expression; + + /// The alias for this expression. + final String alias; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AliasedExpression && + runtimeType == other.runtimeType && + expression == other.expression && + alias == other.alias; + + @override + int get hashCode => Object.hash(expression, alias); + + @override + String toString() => 'AliasedExpression($expression, as: $alias)'; + + /// Converts this aliased expression to googleapis proto format. + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + 'expression': expression._toProto(firestore), + 'alias': firestore_v1.Value(stringValue: alias), + }, + ), + ); + } +} + +/// An aggregate function with an alias. +/// +/// Create aliased aggregates using the [AggregateFunction.as] method: +/// ```dart +/// AggregateFunction.count().as('totalCount') +/// AggregateFunction.sum('price').as('totalPrice') +/// ``` +@immutable +final class AliasedAggregate { + const AliasedAggregate._(this.aggregate, this.alias); + + /// The underlying aggregate function. + final AggregateFunction aggregate; + + /// The alias for this aggregate. + final String alias; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AliasedAggregate && + runtimeType == other.runtimeType && + aggregate == other.aggregate && + alias == other.alias; + + @override + int get hashCode => Object.hash(aggregate, alias); + + @override + String toString() => 'AliasedAggregate($aggregate, as: $alias)'; + + /// Converts this aliased aggregate to googleapis proto format. + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + 'aggregate': aggregate._toProto(firestore), + 'alias': firestore_v1.Value(stringValue: alias), + }, + ), + ); + } +} diff --git a/packages/googleapis_firestore/lib/src/pipelines/explain_stats.dart b/packages/googleapis_firestore/lib/src/pipelines/explain_stats.dart new file mode 100644 index 00000000..5db5cafa --- /dev/null +++ b/packages/googleapis_firestore/lib/src/pipelines/explain_stats.dart @@ -0,0 +1,35 @@ +part of '../firestore.dart'; + +/// Explain statistics for pipeline execution. +/// +/// Provides details about query planning and execution performance. +/// The format depends on the `explainOptions.outputFormat` setting in the request. +@immutable +final class ExplainStats { + const ExplainStats._(this.data); + + /// Creates ExplainStats from googleapis proto. + factory ExplainStats._fromProto(firestore_v1.ExplainStats proto) { + return ExplainStats._(proto.data ?? {}); + } + + /// The raw explain stats data from the server. + /// + /// The format depends on the `explainOptions.outputFormat` in the request: + /// - If `outputFormat: 'text'`, the data contains a string representation + /// - If `outputFormat: 'json'`, the data contains a JSON object + final Map data; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ExplainStats && + runtimeType == other.runtimeType && + const MapEquality().equals(data, other.data); + + @override + int get hashCode => const MapEquality().hash(data); + + @override + String toString() => 'ExplainStats(data: $data)'; +} diff --git a/packages/googleapis_firestore/lib/src/pipelines/expression.dart b/packages/googleapis_firestore/lib/src/pipelines/expression.dart new file mode 100644 index 00000000..4b7b4c71 --- /dev/null +++ b/packages/googleapis_firestore/lib/src/pipelines/expression.dart @@ -0,0 +1,623 @@ +part of '../firestore.dart'; + +/// Abstract base class for all pipeline expressions. +/// +/// Expressions represent values in pipeline operations and can be: +/// - Field references (via [Field]) +/// - Constant values (via [Constant]) +/// - Function calls (via [FunctionExpression]) +/// - Boolean expressions (via [BooleanExpression]) +/// +/// Expressions support method chaining and can be combined using operators. +@immutable +abstract class Expression { + const Expression._(); + + // Factory constructors for creating expressions + + /// Creates a field reference expression. + /// + /// Example: + /// ```dart + /// Expression.field('name') + /// Expression.field('address.city') + /// ``` + factory Expression.field(String fieldPath) => Field._(fieldPath); + + /// Creates a constant value expression. + /// + /// Example: + /// ```dart + /// Expression.constant(42) + /// Expression.constant('hello') + /// Expression.constant(true) + /// ``` + factory Expression.constant(Object? value) => Constant._(value); + + /// Creates a map expression from field mappings. + /// + /// Example: + /// ```dart + /// Expression.map({'name': Expression.field('fullName'), 'age': Expression.constant(25)}) + /// ``` + factory Expression.map(Map fields) => + FunctionExpression._('map', [Constant._(fields)]); + + /// Creates an array expression from elements. + /// + /// Example: + /// ```dart + /// Expression.array([Expression.field('item1'), Expression.field('item2')]) + /// ``` + factory Expression.array(List elements) => + FunctionExpression._('array', elements); + + // String functions + + /// Concatenates two string expressions. + factory Expression.stringConcat(Expression first, Expression second) => + FunctionExpression._('stringConcat', [first, second]); + + /// Extracts a substring from a string. + factory Expression.substring( + Expression str, + Expression start, [ + Expression? end, + ]) => end != null + ? FunctionExpression._('substring', [str, start, end]) + : FunctionExpression._('substring', [str, start]); + + /// Converts a string to uppercase. + factory Expression.toUpper(Expression str) => + FunctionExpression._('toUpper', [str]); + + /// Converts a string to lowercase. + factory Expression.toLower(Expression str) => + FunctionExpression._('toLower', [str]); + + /// Trims whitespace from a string. + factory Expression.trim(Expression str) => + FunctionExpression._('trim', [str]); + + /// Returns the character length of a string. + factory Expression.charLength(Expression str) => + FunctionExpression._('charLength', [str]); + + /// Reverses a string. + factory Expression.stringReverse(Expression str) => + FunctionExpression._('stringReverse', [str]); + + /// Splits a string by a delimiter. + factory Expression.split(Expression str, Expression delimiter) => + FunctionExpression._('split', [str, delimiter]); + + /// Joins array elements into a string. + factory Expression.join(Expression array, Expression delimiter) => + FunctionExpression._('join', [array, delimiter]); + + /// Concatenates multiple expressions. + factory Expression.concat( + Expression first, + Expression second, [ + Expression? third, + Expression? fourth, + ]) => third != null && fourth != null + ? FunctionExpression._('concat', [first, second, third, fourth]) + : third != null + ? FunctionExpression._('concat', [first, second, third]) + : FunctionExpression._('concat', [first, second]); + + // Array functions + + /// Concatenates two array expressions. + factory Expression.arrayConcat(Expression first, Expression second) => + FunctionExpression._('arrayConcat', [first, second]); + + /// Returns the length of an array. + factory Expression.arrayLength(Expression array) => + FunctionExpression._('arrayLength', [array]); + + /// Gets an element from an array by index. + factory Expression.arrayGet(Expression array, Expression index) => + FunctionExpression._('arrayGet', [array, index]); + + /// Reverses an array. + factory Expression.arrayReverse(Expression array) => + FunctionExpression._('arrayReverse', [array]); + + /// Returns the sum of array elements. + factory Expression.arraySum(Expression array) => + FunctionExpression._('arraySum', [array]); + + // Math functions + + /// Returns the absolute value of an expression. + factory Expression.abs(Expression value) => + FunctionExpression._('abs', [value]); + + /// Returns the ceiling of an expression. + factory Expression.ceil(Expression value) => + FunctionExpression._('ceil', [value]); + + /// Returns the floor of an expression. + factory Expression.floor(Expression value) => + FunctionExpression._('floor', [value]); + + /// Rounds an expression to the nearest integer. + factory Expression.round(Expression value) => + FunctionExpression._('round', [value]); + + /// Returns the square root of an expression. + factory Expression.sqrt(Expression value) => + FunctionExpression._('sqrt', [value]); + + /// Raises the base to the power of the exponent. + factory Expression.pow(Expression base, Expression exponent) => + FunctionExpression._('pow', [base, exponent]); + + /// Returns e raised to the power of the expression. + factory Expression.exp(Expression value) => + FunctionExpression._('exp', [value]); + + /// Returns the natural logarithm of an expression. + factory Expression.ln(Expression value) => + FunctionExpression._('ln', [value]); + + /// Returns the base-10 logarithm of an expression. + factory Expression.log10(Expression value) => + FunctionExpression._('log10', [value]); + + // Vector functions + + /// Calculates cosine distance between two vectors. + factory Expression.cosineDistance(Expression vector1, Expression vector2) => + FunctionExpression._('cosineDistance', [vector1, vector2]); + + /// Calculates dot product of two vectors. + factory Expression.dotProduct(Expression vector1, Expression vector2) => + FunctionExpression._('dotProduct', [vector1, vector2]); + + /// Calculates Euclidean distance between two vectors. + factory Expression.euclideanDistance( + Expression vector1, + Expression vector2, + ) => FunctionExpression._('euclideanDistance', [vector1, vector2]); + + /// Returns the length (magnitude) of a vector. + factory Expression.vectorLength(Expression vector) => + FunctionExpression._('vectorLength', [vector]); + + // Map functions + + /// Gets a value from a map by key. + factory Expression.mapGet(Expression map, Expression key) => + FunctionExpression._('mapGet', [map, key]); + + /// Merges two maps. + factory Expression.mapMerge(Expression first, Expression second) => + FunctionExpression._('mapMerge', [first, second]); + + /// Removes keys from a map. + factory Expression.mapRemove(Expression map, Expression keys) => + FunctionExpression._('mapRemove', [map, keys]); + + // Conditional functions + + /// Returns one value if condition is true, another if false. + factory Expression.conditional( + BooleanExpression condition, + Expression ifTrue, + Expression ifFalse, + ) => FunctionExpression._('conditional', [condition, ifTrue, ifFalse]); + + /// Returns a default value if the expression is absent. + factory Expression.ifAbsent(Expression expr, Expression defaultValue) => + FunctionExpression._('ifAbsent', [expr, defaultValue]); + + /// Returns a default value if the expression results in an error. + factory Expression.ifError(Expression expr, Expression defaultValue) => + FunctionExpression._('ifError', [expr, defaultValue]); + + // Timestamp functions + + /// Returns the current timestamp. + factory Expression.currentTimestamp() => + const FunctionExpression._('currentTimestamp', []); + + /// Adds a duration to a timestamp. + factory Expression.timestampAdd( + Expression timestamp, + Expression duration, + Expression unit, + ) => FunctionExpression._('timestampAdd', [timestamp, duration, unit]); + + /// Subtracts a duration from a timestamp. + factory Expression.timestampSubtract( + Expression timestamp, + Expression duration, + Expression unit, + ) => FunctionExpression._('timestampSubtract', [timestamp, duration, unit]); + + /// Truncates a timestamp to a unit. + factory Expression.timestampTruncate(Expression timestamp, Expression unit) => + FunctionExpression._('timestampTruncate', [timestamp, unit]); + + /// Converts a timestamp to Unix seconds. + factory Expression.timestampToUnixSeconds(Expression timestamp) => + FunctionExpression._('timestampToUnixSeconds', [timestamp]); + + /// Converts a timestamp to Unix milliseconds. + factory Expression.timestampToUnixMillis(Expression timestamp) => + FunctionExpression._('timestampToUnixMillis', [timestamp]); + + /// Converts a timestamp to Unix microseconds. + factory Expression.timestampToUnixMicros(Expression timestamp) => + FunctionExpression._('timestampToUnixMicros', [timestamp]); + + /// Converts Unix seconds to a timestamp. + factory Expression.unixSecondsToTimestamp(Expression seconds) => + FunctionExpression._('unixSecondsToTimestamp', [seconds]); + + /// Converts Unix milliseconds to a timestamp. + factory Expression.unixMillisToTimestamp(Expression millis) => + FunctionExpression._('unixMillisToTimestamp', [millis]); + + /// Converts Unix microseconds to a timestamp. + factory Expression.unixMicrosToTimestamp(Expression micros) => + FunctionExpression._('unixMicrosToTimestamp', [micros]); + + // Special functions + + /// Returns the document ID. + factory Expression.documentId() => + const FunctionExpression._('documentId', []); + + /// Returns the collection ID. + factory Expression.collectionId() => + const FunctionExpression._('collectionId', []); + + /// Returns the type of a value. + factory Expression.type(Expression value) => + FunctionExpression._('type', [value]); + + /// Returns the byte length of a value. + factory Expression.byteLength(Expression value) => + FunctionExpression._('byteLength', [value]); + + /// Returns the logical maximum of two values. + factory Expression.logicalMaximum(Expression first, Expression second) => + FunctionExpression._('logicalMaximum', [first, second]); + + /// Returns the logical minimum of two values. + factory Expression.logicalMinimum(Expression first, Expression second) => + FunctionExpression._('logicalMinimum', [first, second]); + + /// Returns the length of a value (alias for arrayLength/charLength). + factory Expression.length(Expression value) => + FunctionExpression._('length', [value]); + + /// Reverses a value (alias for arrayReverse/stringReverse). + factory Expression.reverse(Expression value) => + FunctionExpression._('reverse', [value]); + + // Instance methods + + /// Returns an aliased version of this expression. + /// + /// Example: + /// ```dart + /// field('age').as('userAge') + /// ``` + AliasedExpression as(String alias) => AliasedExpression._(this, alias); + + /// Adds two expressions. + Expression add(Expression other) => + FunctionExpression._('add', [this, other]); + + /// Subtracts another expression from this one. + Expression subtract(Expression other) => + FunctionExpression._('subtract', [this, other]); + + /// Multiplies this expression by another. + Expression multiply(Expression other) => + FunctionExpression._('multiply', [this, other]); + + /// Divides this expression by another. + Expression divide(Expression other) => + FunctionExpression._('divide', [this, other]); + + /// Returns the modulo of this expression by another. + Expression mod(Expression other) => + FunctionExpression._('mod', [this, other]); + + /// Returns true if this expression equals another. + BooleanExpression equal(Expression other) => + BooleanExpression._('equal', [this, other]); + + /// Returns true if this expression does not equal another. + BooleanExpression notEqual(Expression other) => + BooleanExpression._('notEqual', [this, other]); + + /// Returns true if this expression is greater than another. + BooleanExpression greaterThan(Expression other) => + BooleanExpression._('greaterThan', [this, other]); + + /// Returns true if this expression is less than another. + BooleanExpression lessThan(Expression other) => + BooleanExpression._('lessThan', [this, other]); + + /// Returns true if this expression is greater than or equal to another. + BooleanExpression greaterThanOrEqual(Expression other) => + BooleanExpression._('greaterThanOrEqual', [this, other]); + + /// Returns true if this expression is less than or equal to another. + BooleanExpression lessThanOrEqual(Expression other) => + BooleanExpression._('lessThanOrEqual', [this, other]); + + /// Converts this expression to googleapis proto format. + firestore_v1.Value _toProto(Firestore firestore); +} + +/// A reference to a document field in a pipeline expression. +/// +/// Create field references using [Expression.field]: +/// ```dart +/// Expression.field('name') +/// Expression.field('address.city') +/// ``` +@immutable +final class Field extends Expression implements Selectable { + const Field._(this.fieldPath) : super._(); + + /// The field path (e.g., 'name' or 'address.city'). + final String fieldPath; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Field && + runtimeType == other.runtimeType && + fieldPath == other.fieldPath; + + @override + int get hashCode => fieldPath.hashCode; + + @override + String toString() => 'Field($fieldPath)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: {'field': firestore_v1.Value(stringValue: fieldPath)}, + ), + ); + } +} + +/// A constant value in a pipeline expression. +/// +/// Create constants using [Expression.constant]: +/// ```dart +/// Expression.constant(42) +/// Expression.constant('hello') +/// Expression.constant(true) +/// Expression.constant(null) +/// ``` +@immutable +final class Constant extends Expression { + const Constant._(this.value) : super._(); + + /// The constant value. + final Object? value; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Constant && + runtimeType == other.runtimeType && + value == other.value; + + @override + int get hashCode => value.hashCode; + + @override + String toString() => 'Constant($value)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore._serializer.encodeValue(value)!; + } +} + +/// A function expression that combines other expressions. +/// +/// Function expressions are created by expression operators and factory constructors: +/// ```dart +/// Expression.field('price').add(Expression.constant(10)) +/// Expression.stringConcat(Expression.field('firstName'), Expression.field('lastName')) +/// ``` +@immutable +final class FunctionExpression extends Expression { + const FunctionExpression._(this.functionName, this.arguments) : super._(); + + /// The name of the function. + final String functionName; + + /// The arguments to the function. + final List arguments; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FunctionExpression && + runtimeType == other.runtimeType && + functionName == other.functionName && + const ListEquality().equals(arguments, other.arguments); + + @override + int get hashCode => Object.hash( + functionName, + const ListEquality().hash(arguments), + ); + + @override + String toString() => 'FunctionExpression($functionName, $arguments)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + 'function': firestore_v1.Value(stringValue: functionName), + 'args': firestore_v1.Value( + arrayValue: firestore_v1.ArrayValue( + values: arguments.map((arg) => arg._toProto(firestore)).toList(), + ), + ), + }, + ), + ); + } +} + +/// A boolean expression that evaluates to true or false. +/// +/// Boolean expressions are created by comparison operators and factory constructors: +/// ```dart +/// Expression.field('age').greaterThan(Expression.constant(18)) +/// BooleanExpression.and(condition1, condition2) +/// BooleanExpression.not(condition) +/// ``` +@immutable +final class BooleanExpression extends Expression { + const BooleanExpression._(this.functionName, this.arguments) : super._(); + + // Factory constructors for creating boolean expressions + + /// Returns the logical AND of two boolean expressions. + factory BooleanExpression.and( + BooleanExpression first, + BooleanExpression second, + ) => BooleanExpression._('and', [first, second]); + + /// Returns the logical OR of two boolean expressions. + factory BooleanExpression.or( + BooleanExpression first, + BooleanExpression second, + ) => BooleanExpression._('or', [first, second]); + + /// Returns the logical NOT of a boolean expression. + factory BooleanExpression.not(BooleanExpression expr) => + BooleanExpression._('not', [expr]); + + /// Returns the logical XOR of two boolean expressions. + factory BooleanExpression.xor( + BooleanExpression first, + BooleanExpression second, + ) => BooleanExpression._('xor', [first, second]); + + /// Returns true if the value equals any value in the list. + factory BooleanExpression.equalAny(Expression value, Expression values) => + BooleanExpression._('equalAny', [value, values]); + + /// Returns true if the value does not equal any value in the list. + factory BooleanExpression.notEqualAny(Expression value, Expression values) => + BooleanExpression._('notEqualAny', [value, values]); + + /// Returns true if a string contains a substring. + factory BooleanExpression.stringContains( + Expression str, + Expression substring, + ) => BooleanExpression._('stringContains', [str, substring]); + + /// Returns true if a string matches a pattern (LIKE operator). + factory BooleanExpression.like(Expression str, Expression pattern) => + BooleanExpression._('like', [str, pattern]); + + /// Returns true if a string contains a regex match. + factory BooleanExpression.regexContains(Expression str, Expression pattern) => + BooleanExpression._('regexContains', [str, pattern]); + + /// Returns true if a string matches a regex. + factory BooleanExpression.regexMatch(Expression str, Expression pattern) => + BooleanExpression._('regexMatch', [str, pattern]); + + /// Returns true if an array contains a value. + factory BooleanExpression.arrayContains(Expression array, Expression value) => + BooleanExpression._('arrayContains', [array, value]); + + /// Returns true if an array contains any of the values. + factory BooleanExpression.arrayContainsAny( + Expression array, + Expression values, + ) => BooleanExpression._('arrayContainsAny', [array, values]); + + /// Returns true if an array contains all of the values. + factory BooleanExpression.arrayContainsAll( + Expression array, + Expression values, + ) => BooleanExpression._('arrayContainsAll', [array, values]); + + /// Returns true if the value exists. + factory BooleanExpression.exists(Expression expr) => + BooleanExpression._('exists', [expr]); + + /// Returns true if the value is absent. + factory BooleanExpression.isAbsent(Expression expr) => + BooleanExpression._('isAbsent', [expr]); + + /// Returns true if the expression results in an error. + factory BooleanExpression.isError(Expression expr) => + BooleanExpression._('isError', [expr]); + + /// The name of the boolean function. + final String functionName; + + /// The arguments to the boolean function. + final List arguments; + + /// Returns the logical AND of this expression with another. + BooleanExpression and(BooleanExpression other) => + BooleanExpression._('and', [this, other]); + + /// Returns the logical OR of this expression with another. + BooleanExpression or(BooleanExpression other) => + BooleanExpression._('or', [this, other]); + + /// Returns the logical NOT of this expression. + BooleanExpression not() => BooleanExpression._('not', [this]); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is BooleanExpression && + runtimeType == other.runtimeType && + functionName == other.functionName && + const ListEquality().equals(arguments, other.arguments); + + @override + int get hashCode => Object.hash( + functionName, + const ListEquality().hash(arguments), + ); + + @override + String toString() => 'BooleanExpression($functionName, $arguments)'; + + @override + firestore_v1.Value _toProto(Firestore firestore) { + return firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + 'function': firestore_v1.Value(stringValue: functionName), + 'args': firestore_v1.Value( + arrayValue: firestore_v1.ArrayValue( + values: arguments.map((arg) => arg._toProto(firestore)).toList(), + ), + ), + }, + ), + ); + } +} diff --git a/packages/googleapis_firestore/lib/src/pipelines/ordering.dart b/packages/googleapis_firestore/lib/src/pipelines/ordering.dart new file mode 100644 index 00000000..1332fb6d --- /dev/null +++ b/packages/googleapis_firestore/lib/src/pipelines/ordering.dart @@ -0,0 +1,54 @@ +part of '../firestore.dart'; + +/// Specifies the sort order for a pipeline expression. +/// +/// Create ordering using factory constructors: +/// ```dart +/// Ordering.ascending(Expression.field('name')) +/// Ordering.descending(Expression.field('age')) +/// ``` +@immutable +final class Ordering { + /// Creates an ascending sort order. + factory Ordering.ascending(Expression expression) => + Ordering._(expression, 'ASCENDING'); + + /// Creates a descending sort order. + factory Ordering.descending(Expression expression) => + Ordering._(expression, 'DESCENDING'); + const Ordering._(this.expression, this.direction); + + /// The expression to sort by. + final Expression expression; + + /// The sort direction ('ASCENDING' or 'DESCENDING'). + final String direction; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Ordering && + runtimeType == other.runtimeType && + expression == other.expression && + direction == other.direction; + + @override + int get hashCode => Object.hash(expression, direction); + + @override + String toString() => 'Ordering($expression, $direction)'; + + /// Converts this ordering to googleapis proto format. + firestore_v1.Value _toProto(Firestore firestore) { + // Server expects lowercase direction names + final directionLowercase = direction.toLowerCase(); + return firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + 'expression': expression._toProto(firestore), + 'direction': firestore_v1.Value(stringValue: directionLowercase), + }, + ), + ); + } +} diff --git a/packages/googleapis_firestore/lib/src/pipelines/pipeline_snapshot.dart b/packages/googleapis_firestore/lib/src/pipelines/pipeline_snapshot.dart new file mode 100644 index 00000000..6f62815f --- /dev/null +++ b/packages/googleapis_firestore/lib/src/pipelines/pipeline_snapshot.dart @@ -0,0 +1,141 @@ +part of '../firestore.dart'; + +/// The results of executing a pipeline. +/// +/// Contains the pipeline that was executed, the results, and execution metadata. +@immutable +final class PipelineSnapshot { + const PipelineSnapshot._({ + required this.pipeline, + required this.results, + required this.executionTime, + this.explainStats, + }); + + /// The pipeline that was executed. + final Pipeline pipeline; + + /// The results of the pipeline execution. + final List results; + + /// The time this snapshot was obtained. + final Timestamp executionTime; + + /// Optional execution statistics (if explain was enabled). + final ExplainStats? explainStats; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PipelineSnapshot && + runtimeType == other.runtimeType && + pipeline == other.pipeline && + const ListEquality().equals(results, other.results) && + executionTime == other.executionTime && + explainStats == other.explainStats; + + @override + int get hashCode => Object.hash( + pipeline, + const ListEquality().hash(results), + executionTime, + explainStats, + ); + + @override + String toString() => + 'PipelineSnapshot(pipeline: $pipeline, results: ${results.length} documents)'; +} + +/// A single result from a pipeline execution. +/// +/// Contains document data and metadata. +@immutable +final class PipelineResult { + const PipelineResult._({ + required this.ref, + required this.id, + required this.createTime, + required this.updateTime, + required Map data, + }) : _data = data; + + /// Reference to the document (may be null for aggregated results). + final DocumentReference? ref; + + /// Document ID (may be null for aggregated results). + final String? id; + + /// Document creation time (may be null for aggregated results). + final Timestamp? createTime; + + /// Document update time (may be null for aggregated results). + final Timestamp? updateTime; + + final Map _data; + + /// Returns the data contained in this result. + Map data() => Map.unmodifiable(_data); + + /// Gets a specific field from the result. + /// + /// Accepts either a String field path or a [FieldPath] object. + Object? get(Object field) { + assert( + field is String || field is FieldPath, + 'field must be a String or FieldPath, got ${field.runtimeType}', + ); + + if (field is String) { + // Simple field access + if (!field.contains('.')) { + return _data[field]; + } + // Nested field access + final parts = field.split('.'); + dynamic current = _data; + for (final part in parts) { + if (current is! Map) return null; + current = current[part]; + if (current == null) return null; + } + return current; + } else { + // FieldPath access + final fieldPath = field as FieldPath; + dynamic current = _data; + for (final segment in fieldPath.segments) { + if (current is! Map) return null; + current = current[segment]; + if (current == null) return null; + } + return current; + } + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PipelineResult && + runtimeType == other.runtimeType && + ref == other.ref && + id == other.id && + createTime == other.createTime && + updateTime == other.updateTime && + const MapEquality().equals(_data, other._data); + + @override + int get hashCode => Object.hash( + ref, + id, + createTime, + updateTime, + const MapEquality().hash(_data), + ); + + @override + String toString() => 'PipelineResult(id: $id, data: $_data)'; +} + +// Note: ExecutionStats is already defined in query_profile.dart +// We'll reuse that class instead of creating a duplicate diff --git a/packages/googleapis_firestore/lib/src/pipelines/pipelines.dart b/packages/googleapis_firestore/lib/src/pipelines/pipelines.dart new file mode 100644 index 00000000..63a5f82e --- /dev/null +++ b/packages/googleapis_firestore/lib/src/pipelines/pipelines.dart @@ -0,0 +1,814 @@ +part of '../firestore.dart'; + +/// Entry point for creating Firestore pipelines. +/// +/// Obtained via [Firestore.pipeline]. +@immutable +final class PipelineSource { + const PipelineSource._({required this.firestore}); + + /// The Firestore instance. + final Firestore firestore; + + /// Creates a pipeline that operates on documents in a specific collection. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('cities') + /// ``` + Pipeline collection(String collectionId) { + return Pipeline._( + firestore: firestore, + stages: [_CollectionStage(collectionId)], + ); + } + + /// Creates a pipeline that operates on all collections with the given ID. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collectionGroup('landmarks') + /// ``` + Pipeline collectionGroup(String collectionId) { + return Pipeline._( + firestore: firestore, + stages: [_CollectionGroupStage(collectionId)], + ); + } + + /// Creates a pipeline that operates on the entire database. + /// + /// Example: + /// ```dart + /// firestore.pipeline().database() + /// ``` + Pipeline database() { + return Pipeline._( + firestore: firestore, + stages: const [_DatabaseStage(kDefaultDatabase)], + ); + } + + /// Creates a pipeline that operates on specific documents. + /// + /// Example: + /// ```dart + /// firestore.pipeline().documents([ + /// firestore.doc('cities/SF'), + /// firestore.doc('cities/LA'), + /// ]) + /// ``` + Pipeline documents(List> documents) { + return Pipeline._( + firestore: firestore, + stages: [_DocumentsStage(documents)], + ); + } + + /// Creates a pipeline from another pipeline (for composition). + Pipeline createFrom(Pipeline source) { + return Pipeline._(firestore: firestore, stages: List.from(source._stages)); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PipelineSource && + runtimeType == other.runtimeType && + firestore == other.firestore; + + @override + int get hashCode => firestore.hashCode; + + @override + String toString() => 'PipelineSource(firestore: $firestore)'; +} + +/// A Firestore pipeline for complex data transformations. +/// +/// Pipelines provide a flexible framework for building multi-stage +/// data transformations and queries. Each method returns a new Pipeline +/// instance, allowing for method chaining. +/// +/// Example: +/// ```dart +/// final pipeline = firestore +/// .pipeline() +/// .collection('books') +/// .where(greaterThan(field('rating'), constant(4.5))) +/// .select('title', 'author') +/// .sort(Ordering.descending(field('rating'))) +/// .limit(10); +/// ``` +@immutable +final class Pipeline { + const Pipeline._({required this.firestore, required List<_Stage> stages}) + : _stages = stages; + + /// The Firestore instance. + final Firestore firestore; + + /// The stages that make up this pipeline (internal). + final List<_Stage> _stages; + + /// Adds fields to documents in the pipeline. + /// + /// Example: + /// ```dart + /// pipeline.addFields({ + /// 'fullName': stringConcat(field('firstName'), field('lastName')), + /// 'discountedPrice': multiply(field('price'), constant(0.9)), + /// }) + /// ``` + Pipeline addFields(Map fields) { + return Pipeline._( + firestore: firestore, + stages: [..._stages, _AddFieldsStage(fields)], + ); + } + + /// Removes fields from documents in the pipeline. + /// + /// Example: + /// ```dart + /// pipeline.removeFields(['internalId', 'metadata']) + /// ``` + Pipeline removeFields(List fields) { + return Pipeline._( + firestore: firestore, + stages: [..._stages, _RemoveFieldsStage(fields)], + ); + } + + /// Selects specific fields to include in the results. + /// + /// Accepts field names (Strings), field paths (Lists), [Field]s, or [AliasedExpression]s. + /// + /// Example: + /// ```dart + /// pipeline.select([ + /// 'name', + /// 'age', + /// Expression.field('price').as('cost'), + /// ]) + /// ``` + Pipeline select(List fields) { + if (fields.isEmpty) { + throw ArgumentError('fields cannot be empty'); + } + + final selectables = []; + + for (final fieldArg in fields) { + if (fieldArg is String) { + selectables.add(Field._(fieldArg) as Selectable); + } else if (fieldArg is List) { + selectables.add(Field._(fieldArg.join('.')) as Selectable); + } else if (fieldArg is AliasedExpression) { + selectables.add(fieldArg); + } else if (fieldArg is Field) { + selectables.add(fieldArg); + } else { + throw ArgumentError('Invalid field type: ${fieldArg.runtimeType}'); + } + } + + return Pipeline._( + firestore: firestore, + stages: [..._stages, _SelectStage(selectables)], + ); + } + + /// Filters documents based on a condition. + /// + /// Example: + /// ```dart + /// pipeline.where(greaterThan(field('age'), constant(18))) + /// ``` + Pipeline where(BooleanExpression condition) { + return Pipeline._( + firestore: firestore, + stages: [..._stages, _WhereStage(condition)], + ); + } + + /// Sorts documents by the specified orderings. + /// + /// Example: + /// ```dart + /// pipeline.sort([ + /// Ordering.ascending(Expression.field('lastName')), + /// Ordering.descending(Expression.field('age')), + /// ]) + /// ``` + Pipeline sort(List orderings) { + if (orderings.isEmpty) { + throw ArgumentError('orderings cannot be empty'); + } + + return Pipeline._( + firestore: firestore, + stages: [..._stages, _SortStage(orderings)], + ); + } + + /// Limits the number of documents returned. + /// + /// Example: + /// ```dart + /// pipeline.limit(10) + /// ``` + Pipeline limit(int count) { + if (count <= 0) { + throw ArgumentError('limit must be positive, got $count'); + } + return Pipeline._( + firestore: firestore, + stages: [..._stages, _LimitStage(count)], + ); + } + + /// Skips the specified number of documents. + /// + /// Example: + /// ```dart + /// pipeline.offset(20) + /// ``` + Pipeline offset(int count) { + if (count < 0) { + throw ArgumentError('offset must be non-negative, got $count'); + } + return Pipeline._( + firestore: firestore, + stages: [..._stages, _OffsetStage(count)], + ); + } + + /// Returns only distinct values for the specified fields. + /// + /// Example: + /// ```dart + /// pipeline.distinct([ + /// Expression.field('category'), + /// Expression.field('brand'), + /// ]) + /// ``` + Pipeline distinct(List fields) { + if (fields.isEmpty) { + throw ArgumentError('fields cannot be empty'); + } + + return Pipeline._( + firestore: firestore, + stages: [..._stages, _DistinctStage(fields)], + ); + } + + /// Performs optionally grouped aggregation operations. + /// + /// This allows you to calculate aggregate values over a set of documents, + /// optionally grouped by one or more fields or expressions. You can specify: + /// + /// - **Accumulators:** One or more aggregation operations to perform. Each + /// aggregation calculates a value (e.g., sum, average, count) based on the + /// documents within each group. + /// - **Grouping:** Optional fields or expressions to group documents by. For + /// each distinct combination of values in these fields, a separate group is + /// created. If no grouping is specified, all documents are treated as a + /// single group. + /// + /// Example without grouping: + /// ```dart + /// pipeline.aggregate( + /// accumulators: [ + /// AggregateFunction.count().as('totalCount'), + /// AggregateFunction.average('price').as('avgPrice'), + /// ], + /// ) + /// ``` + /// + /// Example with grouping: + /// ```dart + /// pipeline.aggregate( + /// accumulators: [ + /// AggregateFunction.count().as('count'), + /// AggregateFunction.average('rating').as('avgRating'), + /// ], + /// groupBy: [Expression.field('category')], + /// ) + /// ``` + Pipeline aggregate({ + required List accumulators, + List? groupBy, + }) { + if (accumulators.isEmpty) { + throw ArgumentError('accumulators cannot be empty'); + } + + return Pipeline._( + firestore: firestore, + stages: [..._stages, _AggregateStage(accumulators, groupBy)], + ); + } + + /// Finds documents nearest to a query vector. + /// + /// Example: + /// ```dart + /// pipeline.findNearest( + /// vectorField: field('embedding'), + /// queryVector: constant([0.1, 0.2, 0.3]), + /// limit: 10, + /// distanceMeasure: 'COSINE', + /// ) + /// ``` + Pipeline findNearest({ + required Expression vectorField, + required Expression queryVector, + required int limit, + required String distanceMeasure, + String? distanceResultField, + }) { + return Pipeline._( + firestore: firestore, + stages: [ + ..._stages, + _FindNearestStage( + vectorField: vectorField, + queryVector: queryVector, + limit: limit, + distanceMeasure: distanceMeasure, + distanceResultField: distanceResultField, + ), + ], + ); + } + + /// Replaces each document with the result of an expression. + /// + /// Example: + /// ```dart + /// pipeline.replaceWith(map({'name': field('fullName'), 'age': field('age')})) + /// ``` + Pipeline replaceWith(Expression expression) { + return Pipeline._( + firestore: firestore, + stages: [..._stages, _ReplaceWithStage(expression)], + ); + } + + /// Randomly samples documents from the pipeline. + /// + /// Example: + /// ```dart + /// pipeline.sample(100) + /// ``` + Pipeline sample(int size) { + if (size <= 0) { + throw ArgumentError('sample size must be positive, got $size'); + } + return Pipeline._( + firestore: firestore, + stages: [..._stages, _SampleStage(size)], + ); + } + + /// Combines this pipeline with other pipelines. + /// + /// Example: + /// ```dart + /// pipeline1.union([pipeline2, pipeline3]) + /// ``` + Pipeline union(List pipelines) { + if (pipelines.isEmpty) { + throw ArgumentError('pipelines cannot be empty'); + } + + return Pipeline._( + firestore: firestore, + stages: [..._stages, _UnionStage(pipelines)], + ); + } + + /// Produces a document for each element in an input array. + /// + /// For each input document, this stage emits zero or more augmented documents. + /// The input array specified by [field] is evaluated, and for each array element, + /// an augmented document is emitted with the array element value set to the alias + /// field (if the field is an [AliasedExpression]). + /// + /// When [field] evaluates to a non-array value (e.g., number, null, absent), the + /// stage becomes a no-op for that document, returning it as-is with the alias field + /// absent. No documents are emitted when the field evaluates to an empty array, + /// unless [preserveNullAndEmptyArrays] is true. + /// + /// Example: + /// ```dart + /// // Input: { "title": "Book", "tags": ["comedy", "space", "adventure"] } + /// + /// pipeline.unnest( + /// field: Expression.field('tags').as('tag'), + /// indexField: 'tagIndex', + /// ) + /// + /// // Output: + /// // { "title": "Book", "tag": "comedy", "tagIndex": 0 } + /// // { "title": "Book", "tag": "space", "tagIndex": 1 } + /// // { "title": "Book", "tag": "adventure", "tagIndex": 2 } + /// ``` + Pipeline unnest({ + required Selectable field, + String? indexField, + bool preserveNullAndEmptyArrays = false, + }) { + // Extract the expression and alias from the Selectable + Expression expr; + String alias; + if (field is AliasedExpression) { + expr = field.expression; + alias = field.alias; + } else if (field is Field) { + expr = field; + alias = field.fieldPath; // Use field path as alias + } else { + throw ArgumentError('field must be a Field or AliasedExpression'); + } + + return Pipeline._( + firestore: firestore, + stages: [ + ..._stages, + _UnnestStage( + expr, + alias, + preserveNullAndEmptyArrays: preserveNullAndEmptyArrays, + indexField: indexField, + ), + ], + ); + } + + /// Adds a raw stage to the pipeline (for advanced use cases). + /// + /// Example: + /// ```dart + /// pipeline.rawStage({'customStage': {'param': 'value'}}) + /// ``` + Pipeline rawStage(Map data) { + return Pipeline._( + firestore: firestore, + stages: [..._stages, _RawStage(data)], + ); + } + + /// Executes the pipeline and returns the results. + /// + /// Example: + /// ```dart + /// final snapshot = await pipeline.execute(); + /// for (final result in snapshot.results) { + /// print(result.data()); + /// } + /// ``` + Future execute() async { + final request = firestore_v1.ExecutePipelineRequest( + structuredPipeline: firestore_v1.StructuredPipeline(pipeline: _toProto()), + ); + + final response = await firestore._firestoreClient.v1((api, projectId) { + return api.projects.databases.documents.executePipeline( + request, + firestore._formattedDatabaseName, + ); + }); + + // Parse the response + final results = []; + if (response.results != null) { + for (final resultDoc in response.results!) { + final fields = resultDoc.fields; + final data = fields != null + ? { + for (final prop in fields.entries) + prop.key: firestore._serializer.decodeValue(prop.value), + } + : {}; + + results.add( + PipelineResult._( + ref: resultDoc.name != null ? firestore.doc(resultDoc.name!) : null, + id: resultDoc.name?.split('/').last, + createTime: resultDoc.createTime != null + ? Timestamp._fromString(resultDoc.createTime!) + : null, + updateTime: resultDoc.updateTime != null + ? Timestamp._fromString(resultDoc.updateTime!) + : null, + data: data, + ), + ); + } + } + + return PipelineSnapshot._( + pipeline: this, + results: results, + executionTime: Timestamp.now(), + explainStats: response.explainStats != null + ? ExplainStats._fromProto(response.explainStats!) + : null, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Pipeline && + runtimeType == other.runtimeType && + firestore == other.firestore && + const ListEquality<_Stage>().equals(_stages, other._stages); + + @override + int get hashCode => + Object.hash(firestore, const ListEquality<_Stage>().hash(_stages)); + + @override + String toString() => 'Pipeline(${_stages.length} stages)'; + + /// Converts this pipeline to googleapis proto format. + firestore_v1.Pipeline _toProto() { + final stages = _stages.map(_stageToProto).toList(); + return firestore_v1.Pipeline(stages: stages); + } + + /// Converts a stage to googleapis proto format. + firestore_v1.Stage _stageToProto(_Stage stage) { + switch (stage) { + case _CollectionStage(:final collectionId): + return firestore_v1.Stage( + name: 'collection', + args: [_collectionReferenceValue(collectionId)], + ); + case _CollectionGroupStage(:final collectionId): + return firestore_v1.Stage( + name: 'collection_group', + args: [_collectionReferenceValue(collectionId)], + ); + case _DatabaseStage(:final database): + return firestore_v1.Stage( + name: 'database', + args: [_databaseReferenceValue(database)], + ); + case _DocumentsStage(:final documents): + return firestore_v1.Stage( + name: 'documents', + args: documents.map((doc) => _stringValue(doc.path)).toList(), + ); + case _SelectStage(:final fields): + // Server expects a single map argument: Map + final selectionsMap = {}; + for (final selectable in fields) { + if (selectable is AliasedExpression) { + selectionsMap[selectable.alias] = _expressionToValue( + selectable.expression, + ); + } else if (selectable is Field) { + // For fields without alias, use the field name as the key + selectionsMap[selectable.fieldPath] = _expressionToValue( + selectable, + ); + } + } + return firestore_v1.Stage( + name: 'select', + args: [ + firestore_v1.Value( + mapValue: firestore_v1.MapValue(fields: selectionsMap), + ), + ], + ); + case _AddFieldsStage(:final fields): + return firestore_v1.Stage( + name: 'add_fields', + options: fields.map((k, v) => MapEntry(k, _expressionToValue(v))), + ); + case _RemoveFieldsStage(:final fields): + return firestore_v1.Stage( + name: 'remove_fields', + args: fields + .map((f) => firestore_v1.Value(fieldReferenceValue: f)) + .toList(), + ); + case _WhereStage(:final condition): + return firestore_v1.Stage( + name: 'where', + args: [_expressionToValue(condition)], + ); + case _SortStage(:final orderings): + return firestore_v1.Stage( + name: 'sort', + args: orderings.map(_orderingToValue).toList(), + ); + case _LimitStage(:final limit): + return firestore_v1.Stage(name: 'limit', args: [_intValue(limit)]); + case _OffsetStage(:final offset): + return firestore_v1.Stage(name: 'offset', args: [_intValue(offset)]); + case _DistinctStage(:final fields): + // Server expects a single map argument: Map + final groupsMap = {}; + for (var i = 0; i < fields.length; i++) { + // Use field name as key if available, otherwise use index + final key = fields[i] is Field + ? (fields[i] as Field).fieldPath + : 'field_$i'; + groupsMap[key] = _expressionToValue(fields[i]); + } + return firestore_v1.Stage( + name: 'distinct', + args: [ + firestore_v1.Value( + mapValue: firestore_v1.MapValue(fields: groupsMap), + ), + ], + ); + case _AggregateStage(:final aggregates, :final groupBy): + // Server expects 2 args: (accumulators Map, groups Map) + final accumulatorsMap = {}; + for (final agg in aggregates) { + accumulatorsMap[agg.alias] = agg.aggregate._toProto(firestore); + } + + final groupsMap = {}; + if (groupBy != null) { + for (var i = 0; i < groupBy.length; i++) { + // Use field name as key if available, otherwise use index + final key = groupBy[i] is Field + ? (groupBy[i] as Field).fieldPath + : 'group_$i'; + groupsMap[key] = _expressionToValue(groupBy[i]); + } + } + + return firestore_v1.Stage( + name: 'aggregate', + args: [ + firestore_v1.Value( + mapValue: firestore_v1.MapValue(fields: accumulatorsMap), + ), + firestore_v1.Value( + mapValue: firestore_v1.MapValue(fields: groupsMap), + ), + ], + ); + case _FindNearestStage( + :final vectorField, + :final queryVector, + :final limit, + :final distanceMeasure, + :final distanceResultField, + ): + return firestore_v1.Stage( + name: 'find_nearest', + options: { + 'vector_field': _expressionToValue(vectorField), + 'query_vector': _expressionToValue(queryVector), + 'limit': _intValue(limit), + 'distance_measure': _stringValue(distanceMeasure), + if (distanceResultField != null) + 'distance_result_field': firestore_v1.Value( + fieldReferenceValue: distanceResultField, + ), + }, + ); + case _ReplaceWithStage(:final expression): + return firestore_v1.Stage( + name: 'replace_with', + args: [_expressionToValue(expression)], + ); + case _SampleStage(:final size): + return firestore_v1.Stage(name: 'sample', args: [_intValue(size)]); + case _UnionStage(:final pipelines): + return firestore_v1.Stage( + name: 'union', + args: pipelines.map((p) => _pipelineValue(p._toProto())).toList(), + ); + case _UnnestStage( + :final field, + :final alias, + :final preserveNullAndEmptyArrays, + :final indexField, + ): + // Server expects 2 args: (field Expr, alias FieldName) + // Note: preserve_null_and_empty_arrays is not supported by the server + return firestore_v1.Stage( + name: 'unnest', + args: [ + _expressionToValue(field), + firestore_v1.Value( + fieldReferenceValue: alias, + ), // Field reference for alias + ], + options: indexField != null + ? { + 'index_field': firestore_v1.Value( + fieldReferenceValue: indexField, + ), + } + : null, + ); + case _RawStage(:final data): + // For raw stages, convert the data map directly + final name = data.keys.first; + final value = data[name]; + return firestore_v1.Stage( + name: name, + args: value is List ? value.map(_anyToValue).toList() : null, + options: value is Map + ? (value as Map).map( + (k, v) => MapEntry(k, _anyToValue(v)), + ) + : null, + ); + default: + throw ArgumentError('Unknown stage type: ${stage.runtimeType}'); + } + } + + // Value conversion helpers + firestore_v1.Value _stringValue(String value) => + firestore_v1.Value(stringValue: value); + + firestore_v1.Value _intValue(int value) => + firestore_v1.Value(integerValue: value.toString()); + + firestore_v1.Value _boolValue(bool value) => + firestore_v1.Value(booleanValue: value); + + firestore_v1.Value _collectionReferenceValue(String collectionId) { + // Prepend slash if not present (matching Node.js SDK behavior) + final path = collectionId.startsWith('/') ? collectionId : '/$collectionId'; + return firestore_v1.Value(referenceValue: path); + } + + firestore_v1.Value _databaseReferenceValue(String databasePath) { + // Prepend slash if not present + final path = databasePath.startsWith('/') ? databasePath : '/$databasePath'; + return firestore_v1.Value(referenceValue: path); + } + + firestore_v1.Value _arrayValue(List values) => + firestore_v1.Value(arrayValue: firestore_v1.ArrayValue(values: values)); + + firestore_v1.Value _pipelineValue(firestore_v1.Pipeline pipeline) => + firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + 'stages': _arrayValue(pipeline.stages!.map(_stageValue).toList()), + }, + ), + ); + + firestore_v1.Value _stageValue(firestore_v1.Stage stage) => + firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + 'name': _stringValue(stage.name!), + if (stage.args != null) 'args': _arrayValue(stage.args!), + if (stage.options != null) + 'options': firestore_v1.Value( + mapValue: firestore_v1.MapValue(fields: stage.options), + ), + }, + ), + ); + + firestore_v1.Value _expressionToValue(Expression expr) { + return expr._toProto(firestore); + } + + firestore_v1.Value _orderingToValue(Ordering ordering) { + return ordering._toProto(firestore); + } + + firestore_v1.Value _anyToValue(dynamic value) { + if (value == null) { + return firestore_v1.Value(nullValue: 'NULL_VALUE'); + } else if (value is String) { + return _stringValue(value); + } else if (value is int) { + return _intValue(value); + } else if (value is bool) { + return _boolValue(value); + } else if (value is List) { + return _arrayValue(value.map(_anyToValue).toList()); + } else if (value is Map) { + return firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: value.map((k, v) => MapEntry(k.toString(), _anyToValue(v))), + ), + ); + } + // Fall back to serializer + return firestore._serializer.encodeValue(value)!; + } +} diff --git a/packages/googleapis_firestore/lib/src/pipelines/stage.dart b/packages/googleapis_firestore/lib/src/pipelines/stage.dart new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/googleapis_firestore/lib/src/pipelines/stage.dart @@ -0,0 +1 @@ + diff --git a/packages/googleapis_firestore/lib/src/pipelines/stage_options.dart b/packages/googleapis_firestore/lib/src/pipelines/stage_options.dart new file mode 100644 index 00000000..2966302f --- /dev/null +++ b/packages/googleapis_firestore/lib/src/pipelines/stage_options.dart @@ -0,0 +1,180 @@ +part of '../firestore.dart'; + +// Internal stage classes - NOT exported + +/// Base class for pipeline stages (internal use only). +@immutable +abstract class _Stage { + const _Stage(); +} + +/// Collection stage. +@immutable +class _CollectionStage extends _Stage { + const _CollectionStage(this.collectionId); + + final String collectionId; +} + +/// Collection group stage. +@immutable +class _CollectionGroupStage extends _Stage { + const _CollectionGroupStage(this.collectionId); + + final String collectionId; +} + +/// Database stage. +@immutable +class _DatabaseStage extends _Stage { + const _DatabaseStage(this.database); + + final String database; +} + +/// Documents stage. +@immutable +class _DocumentsStage extends _Stage { + const _DocumentsStage(this.documents); + + final List> documents; +} + +/// Select stage. +@immutable +class _SelectStage extends _Stage { + const _SelectStage(this.fields); + + final List fields; +} + +/// AddFields stage. +@immutable +class _AddFieldsStage extends _Stage { + const _AddFieldsStage(this.fields); + + final Map fields; +} + +/// RemoveFields stage. +@immutable +class _RemoveFieldsStage extends _Stage { + const _RemoveFieldsStage(this.fields); + + final List fields; +} + +/// Where stage. +@immutable +class _WhereStage extends _Stage { + const _WhereStage(this.condition); + + final BooleanExpression condition; +} + +/// Sort stage. +@immutable +class _SortStage extends _Stage { + const _SortStage(this.orderings); + + final List orderings; +} + +/// Limit stage. +@immutable +class _LimitStage extends _Stage { + const _LimitStage(this.limit); + + final int limit; +} + +/// Offset stage. +@immutable +class _OffsetStage extends _Stage { + const _OffsetStage(this.offset); + + final int offset; +} + +/// Distinct stage. +@immutable +class _DistinctStage extends _Stage { + const _DistinctStage(this.fields); + + final List fields; +} + +/// Aggregate stage. +@immutable +class _AggregateStage extends _Stage { + const _AggregateStage(this.aggregates, this.groupBy); + + final List aggregates; + final List? groupBy; +} + +/// FindNearest stage. +@immutable +class _FindNearestStage extends _Stage { + const _FindNearestStage({ + required this.vectorField, + required this.queryVector, + required this.limit, + required this.distanceMeasure, + this.distanceResultField, + }); + + final Expression vectorField; + final Expression queryVector; + final int limit; + final String distanceMeasure; + final String? distanceResultField; +} + +/// ReplaceWith stage. +@immutable +class _ReplaceWithStage extends _Stage { + const _ReplaceWithStage(this.expression); + + final Expression expression; +} + +/// Sample stage. +@immutable +class _SampleStage extends _Stage { + const _SampleStage(this.size); + + final int size; +} + +/// Union stage. +@immutable +class _UnionStage extends _Stage { + const _UnionStage(this.pipelines); + + final List pipelines; +} + +/// Unnest stage. +@immutable +class _UnnestStage extends _Stage { + const _UnnestStage( + this.field, + this.alias, { + required this.preserveNullAndEmptyArrays, + this.indexField, + }); + + final Expression field; + final String alias; + final bool preserveNullAndEmptyArrays; + final String? indexField; +} + +/// Raw stage (for custom stages). +@immutable +class _RawStage extends _Stage { + const _RawStage(this.data); + + final Map data; +} diff --git a/packages/googleapis_firestore/test/pipelines_integration_test.dart b/packages/googleapis_firestore/test/pipelines_integration_test.dart new file mode 100644 index 00000000..ecd10861 --- /dev/null +++ b/packages/googleapis_firestore/test/pipelines_integration_test.dart @@ -0,0 +1,290 @@ +import 'package:googleapis_firestore/googleapis_firestore.dart'; +import 'package:test/test.dart'; + +void main() { + group( + 'Firestore Pipelines Integration Tests', + () { + late Firestore firestore; + const testCollection = 'pipeline-test-books'; + + setUp(() async { + firestore = Firestore( + settings: const Settings( + projectId: 'dart-firebase-admin', + databaseId: 'dart-admin-enterprise', + ), + ); + + // Create test data + final batch = firestore.batch(); + + // Add books with tags and categories + final books = [ + { + 'title': "The Hitchhiker's Guide to the Galaxy", + 'author': 'Douglas Adams', + 'rating': 4.8, + 'price': 12.99, + 'category': 'science-fiction', + 'tags': ['comedy', 'space', 'adventure'], + }, + { + 'title': '1984', + 'author': 'George Orwell', + 'rating': 4.6, + 'price': 10.99, + 'category': 'dystopian', + 'tags': ['political', 'classic'], + }, + { + 'title': 'Dune', + 'author': 'Frank Herbert', + 'rating': 4.7, + 'price': 15.99, + 'category': 'science-fiction', + 'tags': ['space', 'politics', 'epic'], + }, + { + 'title': 'The Hobbit', + 'author': 'J.R.R. Tolkien', + 'rating': 4.9, + 'price': 11.99, + 'category': 'fantasy', + 'tags': ['adventure', 'classic', 'dragons'], + }, + { + 'title': 'Foundation', + 'author': 'Isaac Asimov', + 'rating': 4.5, + 'price': 13.99, + 'category': 'science-fiction', + 'tags': ['space', 'politics'], + }, + ]; + + for (var i = 0; i < books.length; i++) { + batch.set( + firestore.collection(testCollection).doc('book$i'), + books[i], + ); + } + + await batch.commit(); + }); + + tearDown(() async { + // Clean up test documents + final docs = await firestore.collection(testCollection).get(); + final batch = firestore.batch(); + for (final doc in docs.docs) { + batch.delete(doc.ref); + } + await batch.commit(); + await firestore.terminate(); + }); + + test('basic pipeline with collection and limit', () async { + final pipeline = firestore + .pipeline() + .collection(testCollection) + .limit(3); + + final snapshot = await pipeline.execute(); + + expect(snapshot.results.length, equals(3)); + expect(snapshot.executionTime, isNotNull); + }); + + test('pipeline with where clause and select', () async { + final pipeline = firestore + .pipeline() + .collection(testCollection) + .where( + Expression.field('rating').greaterThan(Expression.constant(4.6)), + ) + .select(['title', 'rating']); + + final snapshot = await pipeline.execute(); + + expect(snapshot.results.length, greaterThanOrEqualTo(3)); + for (final result in snapshot.results) { + final data = result.data(); + expect(data.containsKey('title'), isTrue); + expect(data.containsKey('rating'), isTrue); + expect(data['rating'], greaterThan(4.6)); + } + }); + + test('pipeline with sort and limit', () async { + final pipeline = firestore + .pipeline() + .collection(testCollection) + .sort([Ordering.descending(Expression.field('rating'))]) + .limit(2); + + final snapshot = await pipeline.execute(); + + expect(snapshot.results.length, equals(2)); + + // First result should have highest rating + final firstRating = snapshot.results[0].data()['rating']! as double; + expect(firstRating, equals(4.9)); // The Hobbit + }); + + test('pipeline with aggregate (count by category)', () async { + final pipeline = firestore + .pipeline() + .collection(testCollection) + .aggregate( + accumulators: [AggregateFunction.count().as('bookCount')], + groupBy: [Expression.field('category')], + ); + + final snapshot = await pipeline.execute(); + + // Should have 3 groups: science-fiction, dystopian, fantasy + expect(snapshot.results.length, equals(3)); + + // Find science-fiction category + final sciFiResult = snapshot.results.firstWhere( + (r) => r.data()['category'] == 'science-fiction', + ); + expect(sciFiResult.data()['bookCount'], equals(3)); + }); + + test('pipeline with aggregate (average rating by category)', () async { + final pipeline = firestore + .pipeline() + .collection(testCollection) + .aggregate( + accumulators: [ + AggregateFunction.average('rating').as('avgRating'), + AggregateFunction.count().as('count'), + ], + groupBy: [Expression.field('category')], + ) + .sort([Ordering.descending(Expression.field('avgRating'))]); + + final snapshot = await pipeline.execute(); + + expect(snapshot.results.length, equals(3)); + + // First result should have highest average rating + final firstResult = snapshot.results[0].data(); + expect(firstResult.containsKey('avgRating'), isTrue); + expect(firstResult.containsKey('count'), isTrue); + expect(firstResult.containsKey('category'), isTrue); + }); + + test('pipeline with unnest (tags)', () async { + final pipeline = firestore + .pipeline() + .collection(testCollection) + .where( + Expression.field('title').equal( + Expression.constant("The Hitchhiker's Guide to the Galaxy"), + ), + ) + .unnest( + field: Expression.field('tags').as('tag'), + indexField: 'tagIndex', + ); + + final snapshot = await pipeline.execute(); + + // Should have 3 results (one for each tag) + expect(snapshot.results.length, equals(3)); + + // Check that each result has tag and tagIndex + for (final result in snapshot.results) { + final data = result.data(); + expect(data.containsKey('tag'), isTrue); + expect(data.containsKey('tagIndex'), isTrue); + expect( + ['comedy', 'space', 'adventure'].contains(data['tag']), + isTrue, + ); + } + }); + + test( + 'complex pipeline: unnest then aggregate (tag count by category)', + () async { + final pipeline = firestore + .pipeline() + .collection(testCollection) + .unnest(field: Expression.field('tags').as('tagName')) + .aggregate( + accumulators: [AggregateFunction.countAll().as('tagCount')], + groupBy: [Expression.field('category')], + ) + .sort([Ordering.descending(Expression.field('tagCount'))]) + .limit(10); + + final snapshot = await pipeline.execute(); + + // Should have results grouped by category + expect(snapshot.results.isNotEmpty, isTrue); + + // Each result should have category and tagCount + for (final result in snapshot.results) { + final data = result.data(); + expect(data.containsKey('category'), isTrue); + expect(data.containsKey('tagCount'), isTrue); + expect(data['tagCount'], isA()); + } + + // science-fiction should have most tags (3 books * avg 2.67 tags) + final sciFiResult = snapshot.results.firstWhere( + (r) => r.data()['category'] == 'science-fiction', + ); + expect(sciFiResult.data()['tagCount'], greaterThan(5)); + }, + ); + + test('pipeline with distinct', () async { + final pipeline = firestore + .pipeline() + .collection(testCollection) + .distinct([Expression.field('category')]); + + final snapshot = await pipeline.execute(); + + // Should have 3 distinct categories + expect(snapshot.results.length, equals(3)); + + final categories = snapshot.results + .map((r) => r.data()['category']) + .toSet(); + expect( + categories, + containsAll(['science-fiction', 'dystopian', 'fantasy']), + ); + }); + + test('pipeline result get() method', () async { + final pipeline = firestore + .pipeline() + .collection(testCollection) + .limit(1); + + final snapshot = await pipeline.execute(); + final result = snapshot.results.first; + + // Test get() with string field path + final title = result.get('title'); + expect(title, isNotNull); + + // Test get() with FieldPath + final rating = result.get(FieldPath(const ['rating'])); + expect(rating, isA()); + + // Test get() with nested field path (if any) + final author = result.get('author'); + expect(author, isNotNull); + }); + }, + timeout: const Timeout(Duration(seconds: 30)), + ); +} diff --git a/packages/googleapis_firestore/test/query_partition_test.dart b/packages/googleapis_firestore/test/query_partition_test.dart new file mode 100644 index 00000000..c0aed66c --- /dev/null +++ b/packages/googleapis_firestore/test/query_partition_test.dart @@ -0,0 +1,405 @@ +import 'dart:async'; + +import 'package:googleapis/firestore/v1.dart' as firestore_v1; +import 'package:googleapis_firestore/googleapis_firestore.dart'; +import 'package:googleapis_firestore/src/firestore_http_client.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockFirestoreHttpClient extends Mock implements FirestoreHttpClient {} + +class MockFirestoreApi extends Mock implements firestore_v1.FirestoreApi {} + +class MockProjectsResource extends Mock + implements firestore_v1.ProjectsResource {} + +class MockDatabasesResource extends Mock + implements firestore_v1.ProjectsDatabasesResource {} + +class MockDocumentsResource extends Mock + implements firestore_v1.ProjectsDatabasesDocumentsResource {} + +class FakePartitionQueryRequest extends Fake + implements firestore_v1.PartitionQueryRequest {} + +void main() { + setUpAll(() { + registerFallbackValue(FakePartitionQueryRequest()); + }); + + group('QueryPartition Unit Tests', () { + late Firestore firestore; + + setUp(() { + runZoned( + () { + firestore = Firestore( + settings: const Settings(projectId: 'test-project'), + ); + }, + zoneValues: { + envSymbol: {'GOOGLE_CLOUD_PROJECT': 'test-project'}, + }, + ); + }); + + group('getPartitions validation', () { + test('validates partition count of zero', () async { + final query = firestore.collectionGroup('collectionId'); + + await expectLater( + () async { + await for (final _ in query.getPartitions(0)) { + // Should not reach here + } + }(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Value for argument "desiredPartitionCount" must be within [1, Infinity] inclusive, but was: 0', + ), + ), + ); + }); + + test('validates negative partition count', () async { + final query = firestore.collectionGroup('collectionId'); + + await expectLater( + () async { + await for (final _ in query.getPartitions(-1)) { + // Should not reach here + } + }(), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Value for argument "desiredPartitionCount" must be within [1, Infinity] inclusive, but was: -1', + ), + ), + ); + }); + }); + + group('getPartitions pagination', () { + late Firestore mockFirestore; + late MockFirestoreHttpClient mockHttpClient; + late MockFirestoreApi mockApi; + late MockProjectsResource mockProjects; + late MockDatabasesResource mockDatabases; + late MockDocumentsResource mockDocuments; + + setUp(() { + mockHttpClient = MockFirestoreHttpClient(); + mockApi = MockFirestoreApi(); + mockProjects = MockProjectsResource(); + mockDatabases = MockDatabasesResource(); + mockDocuments = MockDocumentsResource(); + + // Mock cachedProjectId + when(() => mockHttpClient.cachedProjectId).thenReturn('test-project'); + + // Set up the API resource hierarchy + when(() => mockApi.projects).thenReturn(mockProjects); + when(() => mockProjects.databases).thenReturn(mockDatabases); + when(() => mockDatabases.documents).thenReturn(mockDocuments); + + // Mock v1 to execute the callback with the mock API + when( + () => mockHttpClient.v1(any()), + ).thenAnswer((invocation) async { + final fn = + invocation.positionalArguments[0] + as Future Function( + firestore_v1.FirestoreApi, + String, + ); + return fn(mockApi, 'test-project'); + }); + + // Create Firestore instance with mock http client + mockFirestore = Firestore.internal( + settings: const Settings(projectId: 'test-project'), + client: mockHttpClient, + ); + }); + + test('handles single-page response (no pagination)', () async { + // Mock a single-page response with no nextPageToken + when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( + _, + ) async { + return firestore_v1.PartitionQueryResponse( + partitions: [ + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', + ), + ], + ), + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc2', + ), + ], + ), + ], + ); + }); + + final collectionGroup = mockFirestore.collectionGroup( + 'test-collection', + ); + final partitions = await collectionGroup.getPartitions(3).toList(); + + // Verify: + // - 3 partitions returned (2 cursors + 1 final empty partition) + // - Only 1 API call made (no pagination) + expect(partitions, hasLength(3)); + verify(() => mockDocuments.partitionQuery(any(), any())).called(1); + }); + + test('handles multi-page response with nextPageToken', () async { + var callCount = 0; + + // Mock paginated responses + when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( + invocation, + ) async { + callCount++; + + if (callCount == 1) { + // First page with nextPageToken + return firestore_v1.PartitionQueryResponse( + partitions: [ + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', + ), + ], + ), + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc2', + ), + ], + ), + ], + nextPageToken: 'page-2-token', + ); + } else if (callCount == 2) { + // Second page with nextPageToken + return firestore_v1.PartitionQueryResponse( + partitions: [ + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc3', + ), + ], + ), + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc4', + ), + ], + ), + ], + nextPageToken: 'page-3-token', + ); + } else { + // Final page without nextPageToken + return firestore_v1.PartitionQueryResponse( + partitions: [ + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc5', + ), + ], + ), + ], + ); + } + }); + + final collectionGroup = mockFirestore.collectionGroup( + 'test-collection', + ); + final partitions = await collectionGroup.getPartitions(10).toList(); + + // Verify: + // - 6 partitions returned (5 cursors from 3 pages + 1 final empty partition) + // - 3 API calls made (pagination across 3 pages) + expect(partitions, hasLength(6)); + expect(callCount, equals(3)); + verify(() => mockDocuments.partitionQuery(any(), any())).called(3); + }); + + test('handles empty string nextPageToken correctly', () async { + // Mock response with empty string nextPageToken (should stop pagination) + when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( + _, + ) async { + return firestore_v1.PartitionQueryResponse( + partitions: [ + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', + ), + ], + ), + ], + nextPageToken: '', // Empty string should stop pagination + ); + }); + + final collectionGroup = mockFirestore.collectionGroup( + 'test-collection', + ); + final partitions = await collectionGroup.getPartitions(5).toList(); + + // Verify pagination stops with empty token (1 API call only) + expect(partitions, hasLength(2)); // 1 cursor + 1 final empty partition + verify(() => mockDocuments.partitionQuery(any(), any())).called(1); + }); + + test('handles null partitions in response', () async { + when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( + _, + ) async { + return firestore_v1.PartitionQueryResponse(); + }); + + final collectionGroup = mockFirestore.collectionGroup( + 'test-collection', + ); + final partitions = await collectionGroup.getPartitions(3).toList(); + + // Should return only the final empty partition + expect(partitions, hasLength(1)); + expect(partitions[0].startAt, isNull); + expect(partitions[0].endBefore, isNull); + }); + + test('handles partitions with null values', () async { + when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( + _, + ) async { + return firestore_v1.PartitionQueryResponse( + partitions: [ + firestore_v1.Cursor(), // Null values + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', + ), + ], + ), + ], + ); + }); + + final collectionGroup = mockFirestore.collectionGroup( + 'test-collection', + ); + final partitions = await collectionGroup.getPartitions(3).toList(); + + // Should skip the cursor with null values and return 2 partitions + // (1 valid cursor + 1 final empty partition) + expect(partitions, hasLength(2)); + }); + + test('verifies partitions are sorted across multiple pages', () async { + var callCount = 0; + + // Mock paginated responses with intentionally unsorted cursors + when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( + invocation, + ) async { + callCount++; + + if (callCount == 1) { + // First page - doc3, doc1 (unsorted) + return firestore_v1.PartitionQueryResponse( + partitions: [ + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc3', + ), + ], + ), + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', + ), + ], + ), + ], + nextPageToken: 'page-2-token', + ); + } else { + // Second page - doc4, doc2 (unsorted) + return firestore_v1.PartitionQueryResponse( + partitions: [ + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc4', + ), + ], + ), + firestore_v1.Cursor( + values: [ + firestore_v1.Value( + referenceValue: + 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc2', + ), + ], + ), + ], + ); + } + }); + + final collectionGroup = mockFirestore.collectionGroup( + 'test-collection', + ); + final partitions = await collectionGroup.getPartitions(10).toList(); + + // Verify partitions are sorted: doc1, doc2, doc3, doc4, empty + expect(partitions, hasLength(5)); + + // Extract document names from reference values + final docNames = partitions.where((p) => p.startAt != null).map((p) { + final docRef = p.startAt!.first! as DocumentReference; + return docRef.path.split('/').last; + }).toList(); + + expect(docNames, equals(['doc1', 'doc2', 'doc3', 'doc4'])); + }); + }); + }); +} From 948a94c914320d19d7320e4f52da2e555ba58bed Mon Sep 17 00:00:00 2001 From: demolaf Date: Thu, 5 Feb 2026 16:48:55 +0100 Subject: [PATCH 3/4] fix ci --- scripts/firestore-coverage.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/firestore-coverage.sh b/scripts/firestore-coverage.sh index f9ef6099..c9b25dc2 100755 --- a/scripts/firestore-coverage.sh +++ b/scripts/firestore-coverage.sh @@ -3,7 +3,9 @@ # Fast fail the script on failures. set -e -# prod tests are opt-in: set GOOGLE_APPLICATION_CREDENTIALS to include them. +# Uncomment these to run prod tests locally, CI doesn't have service-account-key.json +# (service account credentials) only application default credentials and uses gcloud auth login. +# export FIRESTORE_EMULATOR_HOST=localhost:8080 # export GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json # Get the script's directory and the package directory From 878188c0792dc08b2c8ac00342f0b4abc0365eaf Mon Sep 17 00:00:00 2001 From: demolaf Date: Fri, 15 May 2026 15:46:15 +0100 Subject: [PATCH 4/4] wip --- packages/dart_firebase_admin/pubspec.yaml | 41 -- .../example/bin/example.dart | 4 +- .../example/lib/firestore_example.dart | 92 +++- .../app/firebase_app_prod_test.dart | 72 ++-- .../integration/app/firebase_app_test.dart | 54 ++- .../security_rules_prod_test.dart | 46 +- .../storage/storage_prod_test.dart | 144 ++++--- .../lib/google_cloud_firestore.dart | 56 +-- .../lib/src/pipelines/aggregate_function.dart | 20 +- .../lib/src/pipelines/aliased.dart | 0 .../lib/src/pipelines/explain_stats.dart | 2 +- .../lib/src/pipelines/expression.dart | 30 +- .../lib/src/pipelines/ordering.dart | 0 .../lib/src/pipelines/pipeline_snapshot.dart | 0 .../lib/src/pipelines/pipelines.dart | 120 +++--- .../lib/src/pipelines/stage.dart | 0 .../lib/src/pipelines/stage_options.dart | 0 .../lib/src/reference/document_reference.dart | 2 +- .../lib/src/transaction.dart | 66 +-- .../test/integration/get_all_test.dart | 1 - .../test/integration/set_options_test.dart | 32 +- .../test/unit/bundle_test.dart | 2 +- .../test/unit/document_test.dart | 1 - .../test/unit/get_all_test.dart | 1 - .../test/unit/order_test.dart | 4 +- .../test/unit/transaction_test.dart | 42 +- packages/googleapis_auth_utils/pubspec.yaml | 21 - packages/googleapis_firestore/pubspec.yaml | 22 - .../test/pipelines_integration_test.dart | 290 ------------- .../test/query_partition_test.dart | 405 ------------------ packages/googleapis_storage/pubspec.yaml | 27 -- scripts/firestore-coverage.sh | 4 +- 32 files changed, 453 insertions(+), 1148 deletions(-) delete mode 100644 packages/dart_firebase_admin/pubspec.yaml rename packages/{googleapis_firestore => google_cloud_firestore}/lib/src/pipelines/aggregate_function.dart (94%) rename packages/{googleapis_firestore => google_cloud_firestore}/lib/src/pipelines/aliased.dart (100%) rename packages/{googleapis_firestore => google_cloud_firestore}/lib/src/pipelines/explain_stats.dart (96%) rename packages/{googleapis_firestore => google_cloud_firestore}/lib/src/pipelines/expression.dart (96%) rename packages/{googleapis_firestore => google_cloud_firestore}/lib/src/pipelines/ordering.dart (100%) rename packages/{googleapis_firestore => google_cloud_firestore}/lib/src/pipelines/pipeline_snapshot.dart (100%) rename packages/{googleapis_firestore => google_cloud_firestore}/lib/src/pipelines/pipelines.dart (88%) rename packages/{googleapis_firestore => google_cloud_firestore}/lib/src/pipelines/stage.dart (100%) rename packages/{googleapis_firestore => google_cloud_firestore}/lib/src/pipelines/stage_options.dart (100%) delete mode 100644 packages/googleapis_auth_utils/pubspec.yaml delete mode 100644 packages/googleapis_firestore/pubspec.yaml delete mode 100644 packages/googleapis_firestore/test/pipelines_integration_test.dart delete mode 100644 packages/googleapis_firestore/test/query_partition_test.dart delete mode 100644 packages/googleapis_storage/pubspec.yaml diff --git a/packages/dart_firebase_admin/pubspec.yaml b/packages/dart_firebase_admin/pubspec.yaml deleted file mode 100644 index 51df4c4a..00000000 --- a/packages/dart_firebase_admin/pubspec.yaml +++ /dev/null @@ -1,41 +0,0 @@ -name: dart_firebase_admin -description: A Firebase Admin SDK implementation for Dart. -resolution: workspace -version: 0.4.1 -homepage: "https://github.com/invertase/dart_firebase_admin" -repository: "https://github.com/invertase/dart_firebase_admin" -publish_to: none - -environment: - sdk: ">=3.9.0 <4.0.0" - -dependencies: - asn1lib: ^1.6.0 - collection: ^1.18.0 - dart_jsonwebtoken: ^3.0.0 - equatable: ^2.0.7 - googleapis: ^16.0.0 - googleapis_auth: ^2.0.0 - googleapis_auth_utils: ^0.1.0 - googleapis_beta: ^9.0.0 - googleapis_firestore: ^0.1.0 - googleapis_storage: ^0.1.0 - http: ^1.0.0 - intl: ^0.20.0 - jose: ^0.3.4 - meta: ^1.9.1 - pem: ^2.0.5 - pointycastle: ^3.7.0 - -dev_dependencies: - build_runner: ^2.4.7 - file: ^7.0.0 - mocktail: ^1.0.1 - path: ^1.9.1 - test: ^1.24.4 - uuid: ^4.0.0 - -false_secrets: - - /test/auth/jwt_test.dart - - /test/client/get_id_token.js - - /test/mock_service_account.dart \ No newline at end of file diff --git a/packages/firebase_admin_sdk/example/bin/example.dart b/packages/firebase_admin_sdk/example/bin/example.dart index d22d284e..decceb80 100644 --- a/packages/firebase_admin_sdk/example/bin/example.dart +++ b/packages/firebase_admin_sdk/example/bin/example.dart @@ -30,7 +30,7 @@ Future main() async { final admin = FirebaseApp.initializeApp(); try { - await functionsExample(admin); + // await functionsExample(admin); // Uncomment to run auth example // await authExample(admin); @@ -39,7 +39,7 @@ Future main() async { // await projectConfigExample(admin); // Uncomment to run the firestore example - // await firestoreExample(admin); + await firestoreExample(admin); // Uncomment to run storage example // await storageExample(admin); diff --git a/packages/firebase_admin_sdk/example/lib/firestore_example.dart b/packages/firebase_admin_sdk/example/lib/firestore_example.dart index 6bb4aee9..1f0c7365 100644 --- a/packages/firebase_admin_sdk/example/lib/firestore_example.dart +++ b/packages/firebase_admin_sdk/example/lib/firestore_example.dart @@ -20,16 +20,17 @@ import 'package:google_cloud_firestore/google_cloud_firestore.dart'; Future firestoreExample(FirebaseApp admin) async { print('\n### Firestore Examples ###\n'); - await basicFirestoreExample(admin); - await multiDatabaseExample(admin); - await batchExample(admin); - await transactionExample(admin); - await collectionGroupExample(admin); - await getAllExample(admin); - await listCollectionsExample(admin); - await recursiveDeleteExample(admin); - await bulkWriterExamples(admin); - await bundleBuilderExample(admin); + //await basicFirestoreExample(admin); + //await multiDatabaseExample(admin); + //await batchExample(admin); + //await transactionExample(admin); + //await collectionGroupExample(admin); + //await getAllExample(admin); + //await listCollectionsExample(admin); + //await recursiveDeleteExample(admin); + //await bulkWriterExamples(admin); + //await bundleBuilderExample(admin); + await pipelineExample(admin); } /// Example 1: Basic Firestore operations with default database @@ -481,3 +482,74 @@ Future recursiveDeleteExample(FirebaseApp admin) async { print('> Error: $e'); } } + +/// Pipeline example demonstrating the Firestore Pipelines API. +/// +/// Pipelines offer a composable, stage-based alternative to structured queries. +/// Each stage transforms the stream of documents: filter, project, sort, +/// aggregate, and more — all in a single server-side execution. +Future pipelineExample(FirebaseApp admin) async { + print('### Pipeline Example ###\n'); + + final firestore = admin.firestore(databaseId: 'dart-admin-enterprise'); + final col = firestore.collection('pipeline-demo'); + + // Seed some product data. + await Future.wait([ + col.doc('p1').set({'name': 'Widget', 'category': 'hardware', 'price': 9.99, 'stock': 100}), + col.doc('p2').set({'name': 'Gadget', 'category': 'electronics', 'price': 49.99, 'stock': 25}), + col.doc('p3').set({'name': 'Doohickey', 'category': 'hardware', 'price': 4.99, 'stock': 200}), + col.doc('p4').set({'name': 'Thingamajig', 'category': 'electronics', 'price': 99.99, 'stock': 10}), + col.doc('p5').set({'name': 'Whatsit', 'category': 'hardware', 'price': 14.99, 'stock': 75}), + ]); + + try { + // --- Stage 1: filter → select → sort --- + print('> Hardware products (name + price, cheapest first):\n'); + + final filtered = await firestore + .pipeline() + .collection('pipeline-demo') + .where( + Expression.field('category').equal(Expression.constant('hardware')), + ) + .select(['name', 'price']) + .sort([Ordering.ascending(Expression.field('price'))]) + .execute(); + + for (final result in filtered.results) { + print(' - ${result.data()['name']}: \$${result.data()['price']}'); + } + print(''); + + // --- Stage 2: aggregate — count + average price per category --- + print('> Count and average price per category:\n'); + + final aggregated = await firestore + .pipeline() + .collection('pipeline-demo') + .aggregate( + accumulators: [ + AggregateFunction.countAll().as('count'), + AggregateFunction.average('price').as('avg_price'), + ], + groupBy: [Expression.field('category')], + ) + .sort([Ordering.ascending(Expression.field('category'))]) + .execute(); + + for (final result in aggregated.results) { + final d = result.data(); + final avg = (d['avg_price'] as num?)?.toStringAsFixed(2) ?? 'n/a'; + print(' - ${d['category']}: ${d['count']} product(s), avg \$$avg'); + } + print(''); + } catch (e) { + print('> Error: $e'); + } finally { + // Clean up seeded data. + await Future.wait([ + for (final id in ['p1', 'p2', 'p3', 'p4', 'p5']) col.doc(id).delete(), + ]); + } +} diff --git a/packages/firebase_admin_sdk/test/integration/app/firebase_app_prod_test.dart b/packages/firebase_admin_sdk/test/integration/app/firebase_app_prod_test.dart index 6176ae84..16d8eb97 100644 --- a/packages/firebase_admin_sdk/test/integration/app/firebase_app_prod_test.dart +++ b/packages/firebase_admin_sdk/test/integration/app/firebase_app_prod_test.dart @@ -44,19 +44,23 @@ void main() { timeout: const Timeout(Duration(seconds: 30)), ); - test('SDK-created ADC client is closed when app.close() is called', () { - return runZoned(() async { - final app = FirebaseApp.initializeApp( - name: 'adc-close-${DateTime.now().microsecondsSinceEpoch}', - options: const AppOptions(projectId: projectId), - ); - - await app.client; - await app.close(); - - expect(app.isDeleted, isTrue); - }, zoneValues: {envSymbol: prodEnv()}); - }, timeout: const Timeout(Duration(seconds: 30))); + test( + 'SDK-created ADC client is closed when app.close() is called', + () { + return runZoned(() async { + final app = FirebaseApp.initializeApp( + name: 'adc-close-${DateTime.now().microsecondsSinceEpoch}', + options: const AppOptions(projectId: projectId), + ); + + await app.client; + await app.close(); + + expect(app.isDeleted, isTrue); + }, zoneValues: {envSymbol: prodEnv()}); + }, + timeout: const Timeout(Duration(seconds: 30)), + ); }, tags: 'prod'); group('_createDefaultClient – service account path', () { @@ -90,25 +94,29 @@ void main() { final refreshTokenFile = Platform.environment['FIREBASE_REFRESH_TOKEN_CREDENTIALS']; - test('creates an authenticated client via refresh token credential', () { - return runZoned(() async { - final credential = Credential.fromRefreshToken( - File(refreshTokenFile!), - ); - - final app = FirebaseApp.initializeApp( - name: 'rt-client-${DateTime.now().microsecondsSinceEpoch}', - options: AppOptions(projectId: projectId, credential: credential), - ); - - try { - final client = await app.client; - expect(client, isNotNull); - } finally { - await app.close(); - } - }, zoneValues: {envSymbol: prodEnv()}); - }, timeout: const Timeout(Duration(seconds: 30))); + test( + 'creates an authenticated client via refresh token credential', + () { + return runZoned(() async { + final credential = Credential.fromRefreshToken( + File(refreshTokenFile!), + ); + + final app = FirebaseApp.initializeApp( + name: 'rt-client-${DateTime.now().microsecondsSinceEpoch}', + options: AppOptions(projectId: projectId, credential: credential), + ); + + try { + final client = await app.client; + expect(client, isNotNull); + } finally { + await app.close(); + } + }, zoneValues: {envSymbol: prodEnv()}); + }, + timeout: const Timeout(Duration(seconds: 30)), + ); test( 'SDK-created refresh token client is closed when app.close() is called', diff --git a/packages/firebase_admin_sdk/test/integration/app/firebase_app_test.dart b/packages/firebase_admin_sdk/test/integration/app/firebase_app_test.dart index 0b95b40e..06ec8a6f 100644 --- a/packages/firebase_admin_sdk/test/integration/app/firebase_app_test.dart +++ b/packages/firebase_admin_sdk/test/integration/app/firebase_app_test.dart @@ -89,15 +89,19 @@ void main() { timeout: const Timeout(Duration(seconds: 30)), ); - test('closes multiple services concurrently without error', () async { - app.firestore(); - app.messaging(); - app.securityRules(); + test( + 'closes multiple services concurrently without error', + () async { + app.firestore(); + app.messaging(); + app.securityRules(); - await expectLater(app.close(), completes); + await expectLater(app.close(), completes); - expect(app.isDeleted, isTrue); - }, timeout: const Timeout(Duration(seconds: 30))); + expect(app.isDeleted, isTrue); + }, + timeout: const Timeout(Duration(seconds: 30)), + ); }, skip: Environment.isFirestoreEmulatorEnabled() ? false @@ -117,26 +121,30 @@ void main() { emulatorEnv.remove(Environment.googleApplicationCredentials); }); - test('initialises Auth, creates a user, then closes cleanly', () async { - await runZoned(zoneValues: {envSymbol: emulatorEnv}, () async { - final app = FirebaseApp.initializeApp( - name: 'auth-lifecycle-${DateTime.now().millisecondsSinceEpoch}', - options: const AppOptions(projectId: projectId), - ); + test( + 'initialises Auth, creates a user, then closes cleanly', + () async { + await runZoned(zoneValues: {envSymbol: emulatorEnv}, () async { + final app = FirebaseApp.initializeApp( + name: 'auth-lifecycle-${DateTime.now().millisecondsSinceEpoch}', + options: const AppOptions(projectId: projectId), + ); - final auth = Auth.internal(app); + final auth = Auth.internal(app); - final user = await auth.createUser( - CreateRequest(email: 'lifecycle-test@example.com'), - ); - expect(user.email, 'lifecycle-test@example.com'); + final user = await auth.createUser( + CreateRequest(email: 'lifecycle-test@example.com'), + ); + expect(user.email, 'lifecycle-test@example.com'); - await auth.deleteUser(user.uid); + await auth.deleteUser(user.uid); - await app.close(); - expect(app.isDeleted, isTrue); - }); - }, timeout: const Timeout(Duration(seconds: 30))); + await app.close(); + expect(app.isDeleted, isTrue); + }); + }, + timeout: const Timeout(Duration(seconds: 30)), + ); }, skip: Environment.isAuthEmulatorEnabled() ? false diff --git a/packages/firebase_admin_sdk/test/integration/security_rules/security_rules_prod_test.dart b/packages/firebase_admin_sdk/test/integration/security_rules/security_rules_prod_test.dart index ceb366f7..1d7d74d8 100644 --- a/packages/firebase_admin_sdk/test/integration/security_rules/security_rules_prod_test.dart +++ b/packages/firebase_admin_sdk/test/integration/security_rules/security_rules_prod_test.dart @@ -123,27 +123,31 @@ void main() { expect(after.name, ruleset.name); }); - test('storage release flow', () async { - const bucket = 'dart-firebase-admin.appspot.com'; - - // Create and release a new ruleset from source - final newRuleset = await securityRules.releaseStorageRulesetFromSource( - simpleStorageContent, - bucket, - ); - createdRulesets.add(newRuleset.name); - - expect(newRuleset.name, isNotEmpty); - expect(newRuleset.source.length, 1); - expect(newRuleset.source.single.name, 'storage.rules'); - expect(newRuleset.source.single.content, simpleStorageContent); - - // Verify it was applied by getting the current ruleset - final after = await securityRules.getStorageRuleset(bucket); - expect(after.name, newRuleset.name); - expect(after.source.length, 1); - expect(after.source.single.content, simpleStorageContent); - }, skip: 'Requires Storage bucket to be configured in Firebase project'); + test( + 'storage release flow', + () async { + const bucket = 'dart-firebase-admin.appspot.com'; + + // Create and release a new ruleset from source + final newRuleset = await securityRules.releaseStorageRulesetFromSource( + simpleStorageContent, + bucket, + ); + createdRulesets.add(newRuleset.name); + + expect(newRuleset.name, isNotEmpty); + expect(newRuleset.source.length, 1); + expect(newRuleset.source.single.name, 'storage.rules'); + expect(newRuleset.source.single.content, simpleStorageContent); + + // Verify it was applied by getting the current ruleset + final after = await securityRules.getStorageRuleset(bucket); + expect(after.name, newRuleset.name); + expect(after.source.length, 1); + expect(after.source.single.content, simpleStorageContent); + }, + skip: 'Requires Storage bucket to be configured in Firebase project', + ); group('Error Handling', () { test( diff --git a/packages/firebase_admin_sdk/test/integration/storage/storage_prod_test.dart b/packages/firebase_admin_sdk/test/integration/storage/storage_prod_test.dart index 0ae2cd80..b1ec554e 100644 --- a/packages/firebase_admin_sdk/test/integration/storage/storage_prod_test.dart +++ b/packages/firebase_admin_sdk/test/integration/storage/storage_prod_test.dart @@ -31,74 +31,82 @@ void main() { group('Storage (Production)', () { group('getDownloadURL()', () { - test('returns a URL that can be used to download the file', () { - return runZoned(() async { - final app = createApp(); - final storage = app.storage(); - final bucket = storage.bucket(testBucketName); - final objectName = - 'download-url-${DateTime.now().millisecondsSinceEpoch}.txt'; - - const uploadedContent = 'Download URL test'; - - addTearDown(() async { - try { - await bucket.storage.deleteObject(bucket.name, objectName); - } catch (_) {} - }); - - await bucket.storage.uploadObject( - bucket.name, - objectName, - Uint8List.fromList(uploadedContent.codeUnits), - metadata: gcs.ObjectMetadata(contentType: 'text/plain'), - ); - - final url = await storage.getDownloadURL(bucket, objectName); - - expect(url, startsWith('$productionEndpoint/b/$testBucketName/o/')); - expect(url, contains('?alt=media&token=')); - - // Verify the URL actually serves the uploaded file content. - final response = await http.get(Uri.parse(url)); - expect(response.statusCode, 200); - expect(response.body, uploadedContent); - }, zoneValues: {envSymbol: prodEnv()}); - }, timeout: const Timeout(Duration(seconds: 30))); - - test('URL-encodes object names with special characters', () { - return runZoned(() async { - final app = createApp(); - final storage = app.storage(); - final bucket = storage.bucket(testBucketName); - final objectName = - 'folder/download url test ${DateTime.now().millisecondsSinceEpoch}.txt'; - - const uploadedContent = 'content'; - - addTearDown(() async { - try { - await bucket.storage.deleteObject(bucket.name, objectName); - } catch (_) {} - }); - - await bucket.storage.uploadObject( - bucket.name, - objectName, - Uint8List.fromList(uploadedContent.codeUnits), - metadata: gcs.ObjectMetadata(contentType: 'text/plain'), - ); - - final url = await storage.getDownloadURL(bucket, objectName); - - expect(url, contains(Uri.encodeComponent(objectName))); - - // Verify the encoded URL actually serves the uploaded file content. - final response = await http.get(Uri.parse(url)); - expect(response.statusCode, 200); - expect(response.body, uploadedContent); - }, zoneValues: {envSymbol: prodEnv()}); - }, timeout: const Timeout(Duration(seconds: 30))); + test( + 'returns a URL that can be used to download the file', + () { + return runZoned(() async { + final app = createApp(); + final storage = app.storage(); + final bucket = storage.bucket(testBucketName); + final objectName = + 'download-url-${DateTime.now().millisecondsSinceEpoch}.txt'; + + const uploadedContent = 'Download URL test'; + + addTearDown(() async { + try { + await bucket.storage.deleteObject(bucket.name, objectName); + } catch (_) {} + }); + + await bucket.storage.uploadObject( + bucket.name, + objectName, + Uint8List.fromList(uploadedContent.codeUnits), + metadata: gcs.ObjectMetadata(contentType: 'text/plain'), + ); + + final url = await storage.getDownloadURL(bucket, objectName); + + expect(url, startsWith('$productionEndpoint/b/$testBucketName/o/')); + expect(url, contains('?alt=media&token=')); + + // Verify the URL actually serves the uploaded file content. + final response = await http.get(Uri.parse(url)); + expect(response.statusCode, 200); + expect(response.body, uploadedContent); + }, zoneValues: {envSymbol: prodEnv()}); + }, + timeout: const Timeout(Duration(seconds: 30)), + ); + + test( + 'URL-encodes object names with special characters', + () { + return runZoned(() async { + final app = createApp(); + final storage = app.storage(); + final bucket = storage.bucket(testBucketName); + final objectName = + 'folder/download url test ${DateTime.now().millisecondsSinceEpoch}.txt'; + + const uploadedContent = 'content'; + + addTearDown(() async { + try { + await bucket.storage.deleteObject(bucket.name, objectName); + } catch (_) {} + }); + + await bucket.storage.uploadObject( + bucket.name, + objectName, + Uint8List.fromList(uploadedContent.codeUnits), + metadata: gcs.ObjectMetadata(contentType: 'text/plain'), + ); + + final url = await storage.getDownloadURL(bucket, objectName); + + expect(url, contains(Uri.encodeComponent(objectName))); + + // Verify the encoded URL actually serves the uploaded file content. + final response = await http.get(Uri.parse(url)); + expect(response.statusCode, 200); + expect(response.body, uploadedContent); + }, zoneValues: {envSymbol: prodEnv()}); + }, + timeout: const Timeout(Duration(seconds: 30)), + ); }); }); } diff --git a/packages/google_cloud_firestore/lib/google_cloud_firestore.dart b/packages/google_cloud_firestore/lib/google_cloud_firestore.dart index 746ebd9b..2451eb05 100644 --- a/packages/google_cloud_firestore/lib/google_cloud_firestore.dart +++ b/packages/google_cloud_firestore/lib/google_cloud_firestore.dart @@ -18,8 +18,13 @@ export 'src/credential.dart' show Credential; export 'src/firestore.dart' show AggregateField, + AggregateFunction, AggregateQuery, AggregateQuerySnapshot, + AliasedAggregate, + AliasedExpression, + AverageAggregate, + BooleanExpression, BulkWriter, BulkWriterError, BulkWriterOptions, @@ -27,6 +32,11 @@ export 'src/firestore.dart' BundleBuilder, CollectionGroup, CollectionReference, + Constant, + CountAggregate, + CountAllAggregate, + CountDistinctAggregate, + CountIfAggregate, DisabledThrottling, DistanceMeasure, DocumentChange, @@ -39,24 +49,40 @@ export 'src/firestore.dart' ExplainMetrics, ExplainOptions, ExplainResults, + ExplainStats, + Expression, + Field, FieldMask, FieldPath, FieldValue, Filter, Firestore, + FunctionExpression, GeoPoint, + MaximumAggregate, + MinimumAggregate, + Ordering, + Pipeline, + PipelineResult, + PipelineSnapshot, + PipelineSource, PlanSummary, Precondition, Query, QueryDocumentSnapshot, + // Pipeline classes QueryPartition, QuerySnapshot, ReadOnlyTransactionOptions, ReadOptions, ReadWriteTransactionOptions, + // Expression classes + Selectable, SetOptions, Settings, + SumAggregate, Timestamp, + // Aggregate classes Transaction, TransactionHandler, TransactionOptions, @@ -66,37 +92,11 @@ export 'src/firestore.dart' VectorValue, WhereFilter, WriteBatch, + // Supporting classes WriteResult, average, count, - sum, - // Pipeline classes - Pipeline, - PipelineSource, - PipelineSnapshot, - PipelineResult, - ExplainStats, - // Expression classes - Expression, - Field, - Constant, - FunctionExpression, - BooleanExpression, - // Aggregate classes - AggregateFunction, - CountAggregate, - CountAllAggregate, - CountDistinctAggregate, - CountIfAggregate, - SumAggregate, - AverageAggregate, - MinimumAggregate, - MaximumAggregate, - // Supporting classes - Ordering, - AliasedExpression, - AliasedAggregate, - Selectable; + sum; export 'src/firestore_exception.dart' show FirestoreClientErrorCode, FirestoreException; export 'src/status_code.dart' show StatusCode; diff --git a/packages/googleapis_firestore/lib/src/pipelines/aggregate_function.dart b/packages/google_cloud_firestore/lib/src/pipelines/aggregate_function.dart similarity index 94% rename from packages/googleapis_firestore/lib/src/pipelines/aggregate_function.dart rename to packages/google_cloud_firestore/lib/src/pipelines/aggregate_function.dart index 1adc8a8b..8af71d77 100644 --- a/packages/googleapis_firestore/lib/src/pipelines/aggregate_function.dart +++ b/packages/google_cloud_firestore/lib/src/pipelines/aggregate_function.dart @@ -78,9 +78,9 @@ abstract class AggregateFunction { /// Helper to convert field (String or FieldPath) to proto Value. static firestore_v1.Value _fieldOrPathValue(Object field) { if (field is String) { - return firestore_v1.Value(stringValue: field); + return firestore_v1.Value(fieldReferenceValue: field); } else if (field is FieldPath) { - return firestore_v1.Value(stringValue: field._formattedName); + return firestore_v1.Value(fieldReferenceValue: field._formattedName); } throw ArgumentError('field must be String or FieldPath'); } @@ -105,7 +105,7 @@ final class CountAggregate extends AggregateFunction { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - functionValue: firestore_v1.Function_(name: 'count', args: []), + functionValue: firestore_v1.Function$(name: 'count', args: []), ); } } @@ -129,7 +129,7 @@ final class CountAllAggregate extends AggregateFunction { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - functionValue: firestore_v1.Function_(name: 'count', args: []), + functionValue: firestore_v1.Function$(name: 'count', args: []), ); } } @@ -158,7 +158,7 @@ final class CountDistinctAggregate extends AggregateFunction { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - functionValue: firestore_v1.Function_( + functionValue: firestore_v1.Function$( name: 'count_distinct', args: [AggregateFunction._fieldOrPathValue(field)], ), @@ -190,7 +190,7 @@ final class CountIfAggregate extends AggregateFunction { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - functionValue: firestore_v1.Function_( + functionValue: firestore_v1.Function$( name: 'count_if', args: [condition._toProto(firestore)], ), @@ -222,7 +222,7 @@ final class SumAggregate extends AggregateFunction { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - functionValue: firestore_v1.Function_( + functionValue: firestore_v1.Function$( name: 'sum', args: [AggregateFunction._fieldOrPathValue(field)], ), @@ -254,7 +254,7 @@ final class AverageAggregate extends AggregateFunction { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - functionValue: firestore_v1.Function_( + functionValue: firestore_v1.Function$( name: 'average', args: [AggregateFunction._fieldOrPathValue(field)], ), @@ -286,7 +286,7 @@ final class MinimumAggregate extends AggregateFunction { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - functionValue: firestore_v1.Function_( + functionValue: firestore_v1.Function$( name: 'minimum', args: [AggregateFunction._fieldOrPathValue(field)], ), @@ -318,7 +318,7 @@ final class MaximumAggregate extends AggregateFunction { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - functionValue: firestore_v1.Function_( + functionValue: firestore_v1.Function$( name: 'maximum', args: [AggregateFunction._fieldOrPathValue(field)], ), diff --git a/packages/googleapis_firestore/lib/src/pipelines/aliased.dart b/packages/google_cloud_firestore/lib/src/pipelines/aliased.dart similarity index 100% rename from packages/googleapis_firestore/lib/src/pipelines/aliased.dart rename to packages/google_cloud_firestore/lib/src/pipelines/aliased.dart diff --git a/packages/googleapis_firestore/lib/src/pipelines/explain_stats.dart b/packages/google_cloud_firestore/lib/src/pipelines/explain_stats.dart similarity index 96% rename from packages/googleapis_firestore/lib/src/pipelines/explain_stats.dart rename to packages/google_cloud_firestore/lib/src/pipelines/explain_stats.dart index 5db5cafa..85b350c4 100644 --- a/packages/googleapis_firestore/lib/src/pipelines/explain_stats.dart +++ b/packages/google_cloud_firestore/lib/src/pipelines/explain_stats.dart @@ -10,7 +10,7 @@ final class ExplainStats { /// Creates ExplainStats from googleapis proto. factory ExplainStats._fromProto(firestore_v1.ExplainStats proto) { - return ExplainStats._(proto.data ?? {}); + return const ExplainStats._({}); } /// The raw explain stats data from the server. diff --git a/packages/googleapis_firestore/lib/src/pipelines/expression.dart b/packages/google_cloud_firestore/lib/src/pipelines/expression.dart similarity index 96% rename from packages/googleapis_firestore/lib/src/pipelines/expression.dart rename to packages/google_cloud_firestore/lib/src/pipelines/expression.dart index 4b7b4c71..bbf4df73 100644 --- a/packages/googleapis_firestore/lib/src/pipelines/expression.dart +++ b/packages/google_cloud_firestore/lib/src/pipelines/expression.dart @@ -387,11 +387,7 @@ final class Field extends Expression implements Selectable { @override firestore_v1.Value _toProto(Firestore firestore) { - return firestore_v1.Value( - mapValue: firestore_v1.MapValue( - fields: {'field': firestore_v1.Value(stringValue: fieldPath)}, - ), - ); + return firestore_v1.Value(fieldReferenceValue: fieldPath); } } @@ -467,15 +463,9 @@ final class FunctionExpression extends Expression { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - mapValue: firestore_v1.MapValue( - fields: { - 'function': firestore_v1.Value(stringValue: functionName), - 'args': firestore_v1.Value( - arrayValue: firestore_v1.ArrayValue( - values: arguments.map((arg) => arg._toProto(firestore)).toList(), - ), - ), - }, + functionValue: firestore_v1.Function$( + name: functionName, + args: arguments.map((arg) => arg._toProto(firestore)).toList(), ), ); } @@ -608,15 +598,9 @@ final class BooleanExpression extends Expression { @override firestore_v1.Value _toProto(Firestore firestore) { return firestore_v1.Value( - mapValue: firestore_v1.MapValue( - fields: { - 'function': firestore_v1.Value(stringValue: functionName), - 'args': firestore_v1.Value( - arrayValue: firestore_v1.ArrayValue( - values: arguments.map((arg) => arg._toProto(firestore)).toList(), - ), - ), - }, + functionValue: firestore_v1.Function$( + name: functionName, + args: arguments.map((arg) => arg._toProto(firestore)).toList(), ), ); } diff --git a/packages/googleapis_firestore/lib/src/pipelines/ordering.dart b/packages/google_cloud_firestore/lib/src/pipelines/ordering.dart similarity index 100% rename from packages/googleapis_firestore/lib/src/pipelines/ordering.dart rename to packages/google_cloud_firestore/lib/src/pipelines/ordering.dart diff --git a/packages/googleapis_firestore/lib/src/pipelines/pipeline_snapshot.dart b/packages/google_cloud_firestore/lib/src/pipelines/pipeline_snapshot.dart similarity index 100% rename from packages/googleapis_firestore/lib/src/pipelines/pipeline_snapshot.dart rename to packages/google_cloud_firestore/lib/src/pipelines/pipeline_snapshot.dart diff --git a/packages/googleapis_firestore/lib/src/pipelines/pipelines.dart b/packages/google_cloud_firestore/lib/src/pipelines/pipelines.dart similarity index 88% rename from packages/googleapis_firestore/lib/src/pipelines/pipelines.dart rename to packages/google_cloud_firestore/lib/src/pipelines/pipelines.dart index 63a5f82e..06594ea0 100644 --- a/packages/googleapis_firestore/lib/src/pipelines/pipelines.dart +++ b/packages/google_cloud_firestore/lib/src/pipelines/pipelines.dart @@ -472,37 +472,44 @@ final class Pipeline { /// ``` Future execute() async { final request = firestore_v1.ExecutePipelineRequest( + database: firestore._formattedDatabaseName, structuredPipeline: firestore_v1.StructuredPipeline(pipeline: _toProto()), ); - final response = await firestore._firestoreClient.v1((api, projectId) { - return api.projects.databases.documents.executePipeline( - request, - firestore._formattedDatabaseName, - ); + final stream = await firestore._firestoreClient.v1((api, projectId) async { + return api.executePipeline(request); }); - // Parse the response final results = []; - if (response.results != null) { - for (final resultDoc in response.results!) { - final fields = resultDoc.fields; - final data = fields != null - ? { - for (final prop in fields.entries) - prop.key: firestore._serializer.decodeValue(prop.value), - } - : {}; + ExplainStats? explainStats; + Timestamp? executionTime; + + await for (final response in stream) { + if (response.executionTime != null) { + executionTime = Timestamp._fromProto(response.executionTime!); + } + if (response.explainStats != null) { + explainStats = ExplainStats._fromProto(response.explainStats!); + } + for (final resultDoc in response.results) { + final data = { + for (final prop in resultDoc.fields.entries) + prop.key: firestore._serializer.decodeValue(prop.value), + }; results.add( PipelineResult._( - ref: resultDoc.name != null ? firestore.doc(resultDoc.name!) : null, - id: resultDoc.name?.split('/').last, + ref: resultDoc.name.isNotEmpty + ? firestore.doc(resultDoc.name) + : null, + id: resultDoc.name.isNotEmpty + ? resultDoc.name.split('/').last + : null, createTime: resultDoc.createTime != null - ? Timestamp._fromString(resultDoc.createTime!) + ? Timestamp._fromProto(resultDoc.createTime!) : null, updateTime: resultDoc.updateTime != null - ? Timestamp._fromString(resultDoc.updateTime!) + ? Timestamp._fromProto(resultDoc.updateTime!) : null, data: data, ), @@ -513,10 +520,8 @@ final class Pipeline { return PipelineSnapshot._( pipeline: this, results: results, - executionTime: Timestamp.now(), - explainStats: response.explainStats != null - ? ExplainStats._fromProto(response.explainStats!) - : null, + executionTime: executionTime ?? Timestamp.now(), + explainStats: explainStats, ); } @@ -542,25 +547,25 @@ final class Pipeline { } /// Converts a stage to googleapis proto format. - firestore_v1.Stage _stageToProto(_Stage stage) { + firestore_v1.Pipeline_Stage _stageToProto(_Stage stage) { switch (stage) { case _CollectionStage(:final collectionId): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'collection', args: [_collectionReferenceValue(collectionId)], ); case _CollectionGroupStage(:final collectionId): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'collection_group', args: [_collectionReferenceValue(collectionId)], ); case _DatabaseStage(:final database): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'database', args: [_databaseReferenceValue(database)], ); case _DocumentsStage(:final documents): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'documents', args: documents.map((doc) => _stringValue(doc.path)).toList(), ); @@ -579,7 +584,7 @@ final class Pipeline { ); } } - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'select', args: [ firestore_v1.Value( @@ -588,31 +593,37 @@ final class Pipeline { ], ); case _AddFieldsStage(:final fields): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'add_fields', options: fields.map((k, v) => MapEntry(k, _expressionToValue(v))), ); case _RemoveFieldsStage(:final fields): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'remove_fields', args: fields .map((f) => firestore_v1.Value(fieldReferenceValue: f)) .toList(), ); case _WhereStage(:final condition): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'where', args: [_expressionToValue(condition)], ); case _SortStage(:final orderings): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'sort', args: orderings.map(_orderingToValue).toList(), ); case _LimitStage(:final limit): - return firestore_v1.Stage(name: 'limit', args: [_intValue(limit)]); + return firestore_v1.Pipeline_Stage( + name: 'limit', + args: [_intValue(limit)], + ); case _OffsetStage(:final offset): - return firestore_v1.Stage(name: 'offset', args: [_intValue(offset)]); + return firestore_v1.Pipeline_Stage( + name: 'offset', + args: [_intValue(offset)], + ); case _DistinctStage(:final fields): // Server expects a single map argument: Map final groupsMap = {}; @@ -623,7 +634,7 @@ final class Pipeline { : 'field_$i'; groupsMap[key] = _expressionToValue(fields[i]); } - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'distinct', args: [ firestore_v1.Value( @@ -649,7 +660,7 @@ final class Pipeline { } } - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'aggregate', args: [ firestore_v1.Value( @@ -667,7 +678,7 @@ final class Pipeline { :final distanceMeasure, :final distanceResultField, ): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'find_nearest', options: { 'vector_field': _expressionToValue(vectorField), @@ -681,14 +692,17 @@ final class Pipeline { }, ); case _ReplaceWithStage(:final expression): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'replace_with', args: [_expressionToValue(expression)], ); case _SampleStage(:final size): - return firestore_v1.Stage(name: 'sample', args: [_intValue(size)]); + return firestore_v1.Pipeline_Stage( + name: 'sample', + args: [_intValue(size)], + ); case _UnionStage(:final pipelines): - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'union', args: pipelines.map((p) => _pipelineValue(p._toProto())).toList(), ); @@ -700,7 +714,7 @@ final class Pipeline { ): // Server expects 2 args: (field Expr, alias FieldName) // Note: preserve_null_and_empty_arrays is not supported by the server - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: 'unnest', args: [ _expressionToValue(field), @@ -714,20 +728,20 @@ final class Pipeline { fieldReferenceValue: indexField, ), } - : null, + : const {}, ); case _RawStage(:final data): // For raw stages, convert the data map directly final name = data.keys.first; final value = data[name]; - return firestore_v1.Stage( + return firestore_v1.Pipeline_Stage( name: name, - args: value is List ? value.map(_anyToValue).toList() : null, + args: value is List ? value.map(_anyToValue).toList() : const [], options: value is Map ? (value as Map).map( (k, v) => MapEntry(k, _anyToValue(v)), ) - : null, + : const {}, ); default: throw ArgumentError('Unknown stage type: ${stage.runtimeType}'); @@ -739,7 +753,7 @@ final class Pipeline { firestore_v1.Value(stringValue: value); firestore_v1.Value _intValue(int value) => - firestore_v1.Value(integerValue: value.toString()); + firestore_v1.Value(integerValue: value); firestore_v1.Value _boolValue(bool value) => firestore_v1.Value(booleanValue: value); @@ -763,18 +777,18 @@ final class Pipeline { firestore_v1.Value( mapValue: firestore_v1.MapValue( fields: { - 'stages': _arrayValue(pipeline.stages!.map(_stageValue).toList()), + 'stages': _arrayValue(pipeline.stages.map(_stageValue).toList()), }, ), ); - firestore_v1.Value _stageValue(firestore_v1.Stage stage) => + firestore_v1.Value _stageValue(firestore_v1.Pipeline_Stage stage) => firestore_v1.Value( mapValue: firestore_v1.MapValue( fields: { - 'name': _stringValue(stage.name!), - if (stage.args != null) 'args': _arrayValue(stage.args!), - if (stage.options != null) + 'name': _stringValue(stage.name), + if (stage.args.isNotEmpty) 'args': _arrayValue(stage.args), + if (stage.options.isNotEmpty) 'options': firestore_v1.Value( mapValue: firestore_v1.MapValue(fields: stage.options), ), @@ -792,7 +806,7 @@ final class Pipeline { firestore_v1.Value _anyToValue(dynamic value) { if (value == null) { - return firestore_v1.Value(nullValue: 'NULL_VALUE'); + return firestore_v1.Value(nullValue: protobuf_v1.NullValue.nullValue); } else if (value is String) { return _stringValue(value); } else if (value is int) { diff --git a/packages/googleapis_firestore/lib/src/pipelines/stage.dart b/packages/google_cloud_firestore/lib/src/pipelines/stage.dart similarity index 100% rename from packages/googleapis_firestore/lib/src/pipelines/stage.dart rename to packages/google_cloud_firestore/lib/src/pipelines/stage.dart diff --git a/packages/googleapis_firestore/lib/src/pipelines/stage_options.dart b/packages/google_cloud_firestore/lib/src/pipelines/stage_options.dart similarity index 100% rename from packages/googleapis_firestore/lib/src/pipelines/stage_options.dart rename to packages/google_cloud_firestore/lib/src/pipelines/stage_options.dart diff --git a/packages/google_cloud_firestore/lib/src/reference/document_reference.dart b/packages/google_cloud_firestore/lib/src/reference/document_reference.dart index a577973c..cc420a3a 100644 --- a/packages/google_cloud_firestore/lib/src/reference/document_reference.dart +++ b/packages/google_cloud_firestore/lib/src/reference/document_reference.dart @@ -80,7 +80,7 @@ interface class DocumentReference implements _Serializable { pageSize: (math.pow(2, 16) - 1).toInt(), ); - final result = await a.listCollectionIds(request); + final result = await api.listCollectionIds(request); final ids = result.collectionIds; ids.sort((a, b) => a.compareTo(b)); diff --git a/packages/google_cloud_firestore/lib/src/transaction.dart b/packages/google_cloud_firestore/lib/src/transaction.dart index 74809997..393c4d6d 100644 --- a/packages/google_cloud_firestore/lib/src/transaction.dart +++ b/packages/google_cloud_firestore/lib/src/transaction.dart @@ -521,7 +521,6 @@ class Transaction { ); } -<<<<<<< HEAD:packages/google_cloud_firestore/lib/src/transaction.dart Future<_TransactionResult> _getAggregateQueryFn( AggregateQuery aggregateQuery, { String? transactionId, @@ -552,49 +551,58 @@ class Transaction { List? fieldMask, }) async { final request = firestore_v1.ExecutePipelineRequest( + database: _firestore._formattedDatabaseName, structuredPipeline: firestore_v1.StructuredPipeline( pipeline: pipeline._toProto(), ), - transaction: transactionId, + transaction: transactionId != null ? base64Decode(transactionId) : null, readTime: readTime != null - ? _toGoogleDateTime( + ? protobuf_v1.Timestamp( seconds: readTime.seconds, - nanoseconds: readTime.nanoseconds, + nanos: readTime.nanoseconds, ) : null, newTransaction: transactionOptions, ); - final response = await _firestore._firestoreClient.v1((api, projectId) { - return api.projects.databases.documents.executePipeline( - request, - _firestore._formattedDatabaseName, - ); + final stream = await _firestore._firestoreClient.v1((api, projectId) async { + return api.executePipeline(request); }); - // Parse the response + Uint8List? responseTransaction; final results = []; - if (response.results != null) { - for (final resultDoc in response.results!) { - final fields = resultDoc.fields; - final data = fields != null - ? { - for (final prop in fields.entries) - prop.key: _firestore._serializer.decodeValue(prop.value), - } - : {}; + ExplainStats? explainStats; + Timestamp? executionTime; + + await for (final response in stream) { + if (response.transaction.isNotEmpty) { + responseTransaction = response.transaction; + } + if (response.executionTime != null) { + executionTime = Timestamp._fromProto(response.executionTime!); + } + if (response.explainStats != null) { + explainStats = ExplainStats._fromProto(response.explainStats!); + } + for (final resultDoc in response.results) { + final data = { + for (final prop in resultDoc.fields.entries) + prop.key: _firestore._serializer.decodeValue(prop.value), + }; results.add( PipelineResult._( - ref: resultDoc.name != null - ? _firestore.doc(resultDoc.name!) + ref: resultDoc.name.isNotEmpty + ? _firestore.doc(resultDoc.name) + : null, + id: resultDoc.name.isNotEmpty + ? resultDoc.name.split('/').last : null, - id: resultDoc.name?.split('/').last, createTime: resultDoc.createTime != null - ? Timestamp._fromString(resultDoc.createTime!) + ? Timestamp._fromProto(resultDoc.createTime!) : null, updateTime: resultDoc.updateTime != null - ? Timestamp._fromString(resultDoc.updateTime!) + ? Timestamp._fromProto(resultDoc.updateTime!) : null, data: data, ), @@ -605,14 +613,14 @@ class Transaction { final snapshot = PipelineSnapshot._( pipeline: pipeline, results: results, - executionTime: Timestamp.now(), - explainStats: response.explainStats != null - ? ExplainStats._fromProto(response.explainStats!) - : null, + executionTime: executionTime ?? Timestamp.now(), + explainStats: explainStats, ); return _TransactionResult( - transaction: response.transaction, + transaction: responseTransaction != null + ? base64Encode(responseTransaction) + : null, result: snapshot, ); } diff --git a/packages/google_cloud_firestore/test/integration/get_all_test.dart b/packages/google_cloud_firestore/test/integration/get_all_test.dart index 0547ecab..6f64cb91 100644 --- a/packages/google_cloud_firestore/test/integration/get_all_test.dart +++ b/packages/google_cloud_firestore/test/integration/get_all_test.dart @@ -3,7 +3,6 @@ // BSD-style license that can be found in the LICENSE file. import 'package:google_cloud_firestore/google_cloud_firestore.dart'; -import 'package:google_cloud_firestore/src/firestore.dart' show FieldMask; import 'package:test/test.dart'; import '../fixtures/helpers.dart' as helpers; diff --git a/packages/google_cloud_firestore/test/integration/set_options_test.dart b/packages/google_cloud_firestore/test/integration/set_options_test.dart index 351f142a..25cd662a 100644 --- a/packages/google_cloud_firestore/test/integration/set_options_test.dart +++ b/packages/google_cloud_firestore/test/integration/set_options_test.dart @@ -65,20 +65,24 @@ void main() { expect(data['baz'], 'qux'); }); - test('BulkWriter should merge fields', () async { - final docRef = testCollection.doc(); - await docRef.set({'foo': 'bar'}); - - final bulkWriter = firestore.bulkWriter(); - await bulkWriter.set(docRef, { - 'baz': 'qux', - }, options: const SetOptions.merge()); - await bulkWriter.close(); - - final data = (await docRef.get()).data()!; - expect(data['foo'], 'bar'); - expect(data['baz'], 'qux'); - }, skip: 'BulkWriter.close() times out - known issue'); + test( + 'BulkWriter should merge fields', + () async { + final docRef = testCollection.doc(); + await docRef.set({'foo': 'bar'}); + + final bulkWriter = firestore.bulkWriter(); + await bulkWriter.set(docRef, { + 'baz': 'qux', + }, options: const SetOptions.merge()); + await bulkWriter.close(); + + final data = (await docRef.get()).data()!; + expect(data['foo'], 'bar'); + expect(data['baz'], 'qux'); + }, + skip: 'BulkWriter.close() times out - known issue', + ); }); group('SetOptions.mergeFields()', () { diff --git a/packages/google_cloud_firestore/test/unit/bundle_test.dart b/packages/google_cloud_firestore/test/unit/bundle_test.dart index ae513f61..ace9cf13 100644 --- a/packages/google_cloud_firestore/test/unit/bundle_test.dart +++ b/packages/google_cloud_firestore/test/unit/bundle_test.dart @@ -22,7 +22,7 @@ import 'package:test/test.dart'; const testBundleId = 'test-bundle'; const testBundleVersion = 1; -const databaseRoot = 'projects/test-project/databases/$kDefaultDatabase'; +const databaseRoot = 'projects/test-project/databases/(default)'; /// Helper function to parse a length-prefixed bundle buffer into elements. List> bundleToElementArray(Uint8List buffer) { diff --git a/packages/google_cloud_firestore/test/unit/document_test.dart b/packages/google_cloud_firestore/test/unit/document_test.dart index c91c6875..9832927f 100644 --- a/packages/google_cloud_firestore/test/unit/document_test.dart +++ b/packages/google_cloud_firestore/test/unit/document_test.dart @@ -291,7 +291,6 @@ void main() { // TODO: Add reference tests. }); - group('text string', () { test('serializes unicode keys', () async { await firestore.doc('collectionId/unicode').set({'😀': '😜'}); diff --git a/packages/google_cloud_firestore/test/unit/get_all_test.dart b/packages/google_cloud_firestore/test/unit/get_all_test.dart index 340fe905..72b5fce5 100644 --- a/packages/google_cloud_firestore/test/unit/get_all_test.dart +++ b/packages/google_cloud_firestore/test/unit/get_all_test.dart @@ -13,7 +13,6 @@ // limitations under the License. import 'package:google_cloud_firestore/google_cloud_firestore.dart'; -import 'package:google_cloud_firestore/src/firestore.dart' show FieldMask; import 'package:google_cloud_firestore/src/firestore_http_client.dart'; import 'package:google_cloud_firestore_v1/firestore.dart' as firestore_v1; import 'package:google_cloud_protobuf/protobuf.dart' as protobuf_v1; diff --git a/packages/google_cloud_firestore/test/unit/order_test.dart b/packages/google_cloud_firestore/test/unit/order_test.dart index 82220961..f53e931d 100644 --- a/packages/google_cloud_firestore/test/unit/order_test.dart +++ b/packages/google_cloud_firestore/test/unit/order_test.dart @@ -285,14 +285,14 @@ void main() { test('compares nested arrays', () { final partition1 = [ firestore_v1.Value( - arrayValue: firestore_v1.ArrayValue( + arrayValue: firestore_v1.ArrayValue( values: [firestore_v1.Value(integerValue: 1)], ), ), ]; final partition2 = [ firestore_v1.Value( - arrayValue: firestore_v1.ArrayValue( + arrayValue: firestore_v1.ArrayValue( values: [firestore_v1.Value(integerValue: 2)], ), ), diff --git a/packages/google_cloud_firestore/test/unit/transaction_test.dart b/packages/google_cloud_firestore/test/unit/transaction_test.dart index 6e7b0b39..067d0a47 100644 --- a/packages/google_cloud_firestore/test/unit/transaction_test.dart +++ b/packages/google_cloud_firestore/test/unit/transaction_test.dart @@ -245,26 +245,30 @@ void main() { expect(callCount, 1); }); - test('respects maxAttempts from ReadWriteTransactionOptions', () async { - var callCount = 0; - await expectLater( - firestore.runTransaction((_) async { - callCount++; - throw FirestoreException( - FirestoreClientErrorCode.aborted, - 'test abort', - ); - }, transactionOptions: ReadWriteTransactionOptions(maxAttempts: 3)), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('max attempts exceeded'), + test( + 'respects maxAttempts from ReadWriteTransactionOptions', + () async { + var callCount = 0; + await expectLater( + firestore.runTransaction((_) async { + callCount++; + throw FirestoreException( + FirestoreClientErrorCode.aborted, + 'test abort', + ); + }, transactionOptions: ReadWriteTransactionOptions(maxAttempts: 3)), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('max attempts exceeded'), + ), ), - ), - ); - expect(callCount, 3); - }, timeout: const Timeout(Duration(seconds: 10))); + ); + expect(callCount, 3); + }, + timeout: const Timeout(Duration(seconds: 10)), + ); test('user-thrown non-FirestoreException is not retried', () async { var callCount = 0; diff --git a/packages/googleapis_auth_utils/pubspec.yaml b/packages/googleapis_auth_utils/pubspec.yaml deleted file mode 100644 index 1f374c19..00000000 --- a/packages/googleapis_auth_utils/pubspec.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: googleapis_auth_utils -description: Utilities for working with googleapis_auth package. -resolution: workspace -version: 0.1.0 -repository: "https://github.com/invertase/dart_firebase_admin" - -environment: - sdk: ">=3.9.0 <4.0.0" - -dependencies: - asn1lib: ^1.6.0 - googleapis: ^16.0.0 - googleapis_auth: ^2.0.0 - http: ^1.0.0 - meta: ^1.9.1 - pem: ^2.0.5 - pointycastle: ^3.7.0 - -dev_dependencies: - mocktail: ^1.0.1 - test: ^1.24.4 diff --git a/packages/googleapis_firestore/pubspec.yaml b/packages/googleapis_firestore/pubspec.yaml deleted file mode 100644 index 0417c03a..00000000 --- a/packages/googleapis_firestore/pubspec.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: googleapis_firestore -description: Google Cloud Firestore client library for Dart. -resolution: workspace -version: 0.1.0 -repository: "https://github.com/invertase/dart_firebase_admin" - -environment: - sdk: ">=3.9.0 <4.0.0" - -dependencies: - collection: ^1.18.0 - googleapis: ^16.0.0 - googleapis_auth: ^2.0.0 - googleapis_auth_utils: ^0.1.0 - http: ^1.0.0 - intl: ^0.20.0 - meta: ^1.9.1 - -dev_dependencies: - build_runner: ^2.4.7 - mocktail: ^1.0.0 - test: ^1.24.4 diff --git a/packages/googleapis_firestore/test/pipelines_integration_test.dart b/packages/googleapis_firestore/test/pipelines_integration_test.dart deleted file mode 100644 index ecd10861..00000000 --- a/packages/googleapis_firestore/test/pipelines_integration_test.dart +++ /dev/null @@ -1,290 +0,0 @@ -import 'package:googleapis_firestore/googleapis_firestore.dart'; -import 'package:test/test.dart'; - -void main() { - group( - 'Firestore Pipelines Integration Tests', - () { - late Firestore firestore; - const testCollection = 'pipeline-test-books'; - - setUp(() async { - firestore = Firestore( - settings: const Settings( - projectId: 'dart-firebase-admin', - databaseId: 'dart-admin-enterprise', - ), - ); - - // Create test data - final batch = firestore.batch(); - - // Add books with tags and categories - final books = [ - { - 'title': "The Hitchhiker's Guide to the Galaxy", - 'author': 'Douglas Adams', - 'rating': 4.8, - 'price': 12.99, - 'category': 'science-fiction', - 'tags': ['comedy', 'space', 'adventure'], - }, - { - 'title': '1984', - 'author': 'George Orwell', - 'rating': 4.6, - 'price': 10.99, - 'category': 'dystopian', - 'tags': ['political', 'classic'], - }, - { - 'title': 'Dune', - 'author': 'Frank Herbert', - 'rating': 4.7, - 'price': 15.99, - 'category': 'science-fiction', - 'tags': ['space', 'politics', 'epic'], - }, - { - 'title': 'The Hobbit', - 'author': 'J.R.R. Tolkien', - 'rating': 4.9, - 'price': 11.99, - 'category': 'fantasy', - 'tags': ['adventure', 'classic', 'dragons'], - }, - { - 'title': 'Foundation', - 'author': 'Isaac Asimov', - 'rating': 4.5, - 'price': 13.99, - 'category': 'science-fiction', - 'tags': ['space', 'politics'], - }, - ]; - - for (var i = 0; i < books.length; i++) { - batch.set( - firestore.collection(testCollection).doc('book$i'), - books[i], - ); - } - - await batch.commit(); - }); - - tearDown(() async { - // Clean up test documents - final docs = await firestore.collection(testCollection).get(); - final batch = firestore.batch(); - for (final doc in docs.docs) { - batch.delete(doc.ref); - } - await batch.commit(); - await firestore.terminate(); - }); - - test('basic pipeline with collection and limit', () async { - final pipeline = firestore - .pipeline() - .collection(testCollection) - .limit(3); - - final snapshot = await pipeline.execute(); - - expect(snapshot.results.length, equals(3)); - expect(snapshot.executionTime, isNotNull); - }); - - test('pipeline with where clause and select', () async { - final pipeline = firestore - .pipeline() - .collection(testCollection) - .where( - Expression.field('rating').greaterThan(Expression.constant(4.6)), - ) - .select(['title', 'rating']); - - final snapshot = await pipeline.execute(); - - expect(snapshot.results.length, greaterThanOrEqualTo(3)); - for (final result in snapshot.results) { - final data = result.data(); - expect(data.containsKey('title'), isTrue); - expect(data.containsKey('rating'), isTrue); - expect(data['rating'], greaterThan(4.6)); - } - }); - - test('pipeline with sort and limit', () async { - final pipeline = firestore - .pipeline() - .collection(testCollection) - .sort([Ordering.descending(Expression.field('rating'))]) - .limit(2); - - final snapshot = await pipeline.execute(); - - expect(snapshot.results.length, equals(2)); - - // First result should have highest rating - final firstRating = snapshot.results[0].data()['rating']! as double; - expect(firstRating, equals(4.9)); // The Hobbit - }); - - test('pipeline with aggregate (count by category)', () async { - final pipeline = firestore - .pipeline() - .collection(testCollection) - .aggregate( - accumulators: [AggregateFunction.count().as('bookCount')], - groupBy: [Expression.field('category')], - ); - - final snapshot = await pipeline.execute(); - - // Should have 3 groups: science-fiction, dystopian, fantasy - expect(snapshot.results.length, equals(3)); - - // Find science-fiction category - final sciFiResult = snapshot.results.firstWhere( - (r) => r.data()['category'] == 'science-fiction', - ); - expect(sciFiResult.data()['bookCount'], equals(3)); - }); - - test('pipeline with aggregate (average rating by category)', () async { - final pipeline = firestore - .pipeline() - .collection(testCollection) - .aggregate( - accumulators: [ - AggregateFunction.average('rating').as('avgRating'), - AggregateFunction.count().as('count'), - ], - groupBy: [Expression.field('category')], - ) - .sort([Ordering.descending(Expression.field('avgRating'))]); - - final snapshot = await pipeline.execute(); - - expect(snapshot.results.length, equals(3)); - - // First result should have highest average rating - final firstResult = snapshot.results[0].data(); - expect(firstResult.containsKey('avgRating'), isTrue); - expect(firstResult.containsKey('count'), isTrue); - expect(firstResult.containsKey('category'), isTrue); - }); - - test('pipeline with unnest (tags)', () async { - final pipeline = firestore - .pipeline() - .collection(testCollection) - .where( - Expression.field('title').equal( - Expression.constant("The Hitchhiker's Guide to the Galaxy"), - ), - ) - .unnest( - field: Expression.field('tags').as('tag'), - indexField: 'tagIndex', - ); - - final snapshot = await pipeline.execute(); - - // Should have 3 results (one for each tag) - expect(snapshot.results.length, equals(3)); - - // Check that each result has tag and tagIndex - for (final result in snapshot.results) { - final data = result.data(); - expect(data.containsKey('tag'), isTrue); - expect(data.containsKey('tagIndex'), isTrue); - expect( - ['comedy', 'space', 'adventure'].contains(data['tag']), - isTrue, - ); - } - }); - - test( - 'complex pipeline: unnest then aggregate (tag count by category)', - () async { - final pipeline = firestore - .pipeline() - .collection(testCollection) - .unnest(field: Expression.field('tags').as('tagName')) - .aggregate( - accumulators: [AggregateFunction.countAll().as('tagCount')], - groupBy: [Expression.field('category')], - ) - .sort([Ordering.descending(Expression.field('tagCount'))]) - .limit(10); - - final snapshot = await pipeline.execute(); - - // Should have results grouped by category - expect(snapshot.results.isNotEmpty, isTrue); - - // Each result should have category and tagCount - for (final result in snapshot.results) { - final data = result.data(); - expect(data.containsKey('category'), isTrue); - expect(data.containsKey('tagCount'), isTrue); - expect(data['tagCount'], isA()); - } - - // science-fiction should have most tags (3 books * avg 2.67 tags) - final sciFiResult = snapshot.results.firstWhere( - (r) => r.data()['category'] == 'science-fiction', - ); - expect(sciFiResult.data()['tagCount'], greaterThan(5)); - }, - ); - - test('pipeline with distinct', () async { - final pipeline = firestore - .pipeline() - .collection(testCollection) - .distinct([Expression.field('category')]); - - final snapshot = await pipeline.execute(); - - // Should have 3 distinct categories - expect(snapshot.results.length, equals(3)); - - final categories = snapshot.results - .map((r) => r.data()['category']) - .toSet(); - expect( - categories, - containsAll(['science-fiction', 'dystopian', 'fantasy']), - ); - }); - - test('pipeline result get() method', () async { - final pipeline = firestore - .pipeline() - .collection(testCollection) - .limit(1); - - final snapshot = await pipeline.execute(); - final result = snapshot.results.first; - - // Test get() with string field path - final title = result.get('title'); - expect(title, isNotNull); - - // Test get() with FieldPath - final rating = result.get(FieldPath(const ['rating'])); - expect(rating, isA()); - - // Test get() with nested field path (if any) - final author = result.get('author'); - expect(author, isNotNull); - }); - }, - timeout: const Timeout(Duration(seconds: 30)), - ); -} diff --git a/packages/googleapis_firestore/test/query_partition_test.dart b/packages/googleapis_firestore/test/query_partition_test.dart deleted file mode 100644 index c0aed66c..00000000 --- a/packages/googleapis_firestore/test/query_partition_test.dart +++ /dev/null @@ -1,405 +0,0 @@ -import 'dart:async'; - -import 'package:googleapis/firestore/v1.dart' as firestore_v1; -import 'package:googleapis_firestore/googleapis_firestore.dart'; -import 'package:googleapis_firestore/src/firestore_http_client.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; - -class MockFirestoreHttpClient extends Mock implements FirestoreHttpClient {} - -class MockFirestoreApi extends Mock implements firestore_v1.FirestoreApi {} - -class MockProjectsResource extends Mock - implements firestore_v1.ProjectsResource {} - -class MockDatabasesResource extends Mock - implements firestore_v1.ProjectsDatabasesResource {} - -class MockDocumentsResource extends Mock - implements firestore_v1.ProjectsDatabasesDocumentsResource {} - -class FakePartitionQueryRequest extends Fake - implements firestore_v1.PartitionQueryRequest {} - -void main() { - setUpAll(() { - registerFallbackValue(FakePartitionQueryRequest()); - }); - - group('QueryPartition Unit Tests', () { - late Firestore firestore; - - setUp(() { - runZoned( - () { - firestore = Firestore( - settings: const Settings(projectId: 'test-project'), - ); - }, - zoneValues: { - envSymbol: {'GOOGLE_CLOUD_PROJECT': 'test-project'}, - }, - ); - }); - - group('getPartitions validation', () { - test('validates partition count of zero', () async { - final query = firestore.collectionGroup('collectionId'); - - await expectLater( - () async { - await for (final _ in query.getPartitions(0)) { - // Should not reach here - } - }(), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'Value for argument "desiredPartitionCount" must be within [1, Infinity] inclusive, but was: 0', - ), - ), - ); - }); - - test('validates negative partition count', () async { - final query = firestore.collectionGroup('collectionId'); - - await expectLater( - () async { - await for (final _ in query.getPartitions(-1)) { - // Should not reach here - } - }(), - throwsA( - isA().having( - (e) => e.message, - 'message', - 'Value for argument "desiredPartitionCount" must be within [1, Infinity] inclusive, but was: -1', - ), - ), - ); - }); - }); - - group('getPartitions pagination', () { - late Firestore mockFirestore; - late MockFirestoreHttpClient mockHttpClient; - late MockFirestoreApi mockApi; - late MockProjectsResource mockProjects; - late MockDatabasesResource mockDatabases; - late MockDocumentsResource mockDocuments; - - setUp(() { - mockHttpClient = MockFirestoreHttpClient(); - mockApi = MockFirestoreApi(); - mockProjects = MockProjectsResource(); - mockDatabases = MockDatabasesResource(); - mockDocuments = MockDocumentsResource(); - - // Mock cachedProjectId - when(() => mockHttpClient.cachedProjectId).thenReturn('test-project'); - - // Set up the API resource hierarchy - when(() => mockApi.projects).thenReturn(mockProjects); - when(() => mockProjects.databases).thenReturn(mockDatabases); - when(() => mockDatabases.documents).thenReturn(mockDocuments); - - // Mock v1 to execute the callback with the mock API - when( - () => mockHttpClient.v1(any()), - ).thenAnswer((invocation) async { - final fn = - invocation.positionalArguments[0] - as Future Function( - firestore_v1.FirestoreApi, - String, - ); - return fn(mockApi, 'test-project'); - }); - - // Create Firestore instance with mock http client - mockFirestore = Firestore.internal( - settings: const Settings(projectId: 'test-project'), - client: mockHttpClient, - ); - }); - - test('handles single-page response (no pagination)', () async { - // Mock a single-page response with no nextPageToken - when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( - _, - ) async { - return firestore_v1.PartitionQueryResponse( - partitions: [ - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', - ), - ], - ), - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc2', - ), - ], - ), - ], - ); - }); - - final collectionGroup = mockFirestore.collectionGroup( - 'test-collection', - ); - final partitions = await collectionGroup.getPartitions(3).toList(); - - // Verify: - // - 3 partitions returned (2 cursors + 1 final empty partition) - // - Only 1 API call made (no pagination) - expect(partitions, hasLength(3)); - verify(() => mockDocuments.partitionQuery(any(), any())).called(1); - }); - - test('handles multi-page response with nextPageToken', () async { - var callCount = 0; - - // Mock paginated responses - when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( - invocation, - ) async { - callCount++; - - if (callCount == 1) { - // First page with nextPageToken - return firestore_v1.PartitionQueryResponse( - partitions: [ - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', - ), - ], - ), - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc2', - ), - ], - ), - ], - nextPageToken: 'page-2-token', - ); - } else if (callCount == 2) { - // Second page with nextPageToken - return firestore_v1.PartitionQueryResponse( - partitions: [ - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc3', - ), - ], - ), - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc4', - ), - ], - ), - ], - nextPageToken: 'page-3-token', - ); - } else { - // Final page without nextPageToken - return firestore_v1.PartitionQueryResponse( - partitions: [ - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc5', - ), - ], - ), - ], - ); - } - }); - - final collectionGroup = mockFirestore.collectionGroup( - 'test-collection', - ); - final partitions = await collectionGroup.getPartitions(10).toList(); - - // Verify: - // - 6 partitions returned (5 cursors from 3 pages + 1 final empty partition) - // - 3 API calls made (pagination across 3 pages) - expect(partitions, hasLength(6)); - expect(callCount, equals(3)); - verify(() => mockDocuments.partitionQuery(any(), any())).called(3); - }); - - test('handles empty string nextPageToken correctly', () async { - // Mock response with empty string nextPageToken (should stop pagination) - when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( - _, - ) async { - return firestore_v1.PartitionQueryResponse( - partitions: [ - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', - ), - ], - ), - ], - nextPageToken: '', // Empty string should stop pagination - ); - }); - - final collectionGroup = mockFirestore.collectionGroup( - 'test-collection', - ); - final partitions = await collectionGroup.getPartitions(5).toList(); - - // Verify pagination stops with empty token (1 API call only) - expect(partitions, hasLength(2)); // 1 cursor + 1 final empty partition - verify(() => mockDocuments.partitionQuery(any(), any())).called(1); - }); - - test('handles null partitions in response', () async { - when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( - _, - ) async { - return firestore_v1.PartitionQueryResponse(); - }); - - final collectionGroup = mockFirestore.collectionGroup( - 'test-collection', - ); - final partitions = await collectionGroup.getPartitions(3).toList(); - - // Should return only the final empty partition - expect(partitions, hasLength(1)); - expect(partitions[0].startAt, isNull); - expect(partitions[0].endBefore, isNull); - }); - - test('handles partitions with null values', () async { - when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( - _, - ) async { - return firestore_v1.PartitionQueryResponse( - partitions: [ - firestore_v1.Cursor(), // Null values - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', - ), - ], - ), - ], - ); - }); - - final collectionGroup = mockFirestore.collectionGroup( - 'test-collection', - ); - final partitions = await collectionGroup.getPartitions(3).toList(); - - // Should skip the cursor with null values and return 2 partitions - // (1 valid cursor + 1 final empty partition) - expect(partitions, hasLength(2)); - }); - - test('verifies partitions are sorted across multiple pages', () async { - var callCount = 0; - - // Mock paginated responses with intentionally unsorted cursors - when(() => mockDocuments.partitionQuery(any(), any())).thenAnswer(( - invocation, - ) async { - callCount++; - - if (callCount == 1) { - // First page - doc3, doc1 (unsorted) - return firestore_v1.PartitionQueryResponse( - partitions: [ - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc3', - ), - ], - ), - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc1', - ), - ], - ), - ], - nextPageToken: 'page-2-token', - ); - } else { - // Second page - doc4, doc2 (unsorted) - return firestore_v1.PartitionQueryResponse( - partitions: [ - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc4', - ), - ], - ), - firestore_v1.Cursor( - values: [ - firestore_v1.Value( - referenceValue: - 'projects/test-project/databases/kDefaultDatabase/documents/coll/doc2', - ), - ], - ), - ], - ); - } - }); - - final collectionGroup = mockFirestore.collectionGroup( - 'test-collection', - ); - final partitions = await collectionGroup.getPartitions(10).toList(); - - // Verify partitions are sorted: doc1, doc2, doc3, doc4, empty - expect(partitions, hasLength(5)); - - // Extract document names from reference values - final docNames = partitions.where((p) => p.startAt != null).map((p) { - final docRef = p.startAt!.first! as DocumentReference; - return docRef.path.split('/').last; - }).toList(); - - expect(docNames, equals(['doc1', 'doc2', 'doc3', 'doc4'])); - }); - }); - }); -} diff --git a/packages/googleapis_storage/pubspec.yaml b/packages/googleapis_storage/pubspec.yaml deleted file mode 100644 index 92bc4197..00000000 --- a/packages/googleapis_storage/pubspec.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: googleapis_storage -resolution: workspace -description: Dart client for Gogole Cloud Storage - unified object storage for developers and enterprises, from live data serving to data analytics/ML to data archiving. -version: 0.1.0 -# repository: https://github.com/my_org/my_repo - -environment: - sdk: ">=3.9.0 <4.0.0" - -dependencies: - googleapis_auth: ^2.0.0 - googleapis_auth_utils: ^0.1.0 - googleapis: ^16.0.0 - http: ^1.6.0 - meta: ^1.17.0 - mime: ^2.0.0 - crypto: ^3.0.7 - xml: ^6.6.1 - freezed_annotation: ^3.1.0 - intl: ^0.20.0 - -dev_dependencies: - lints: ^6.0.0 - test: ^1.25.6 - mocktail: ^1.0.4 - build_runner: ^2.10.4 - freezed: ^3.2.3 diff --git a/scripts/firestore-coverage.sh b/scripts/firestore-coverage.sh index c9b25dc2..93c1c80a 100755 --- a/scripts/firestore-coverage.sh +++ b/scripts/firestore-coverage.sh @@ -5,8 +5,8 @@ set -e # Uncomment these to run prod tests locally, CI doesn't have service-account-key.json # (service account credentials) only application default credentials and uses gcloud auth login. -# export FIRESTORE_EMULATOR_HOST=localhost:8080 -# export GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json +export FIRESTORE_EMULATOR_HOST=localhost:8080 +export GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json # Get the script's directory and the package directory SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"