diff --git a/packages/firebase_admin_sdk/example/lib/firestore_example.dart b/packages/firebase_admin_sdk/example/lib/firestore_example.dart index 04340c1e..fe5dc4ae 100644 --- a/packages/firebase_admin_sdk/example/lib/firestore_example.dart +++ b/packages/firebase_admin_sdk/example/lib/firestore_example.dart @@ -33,6 +33,7 @@ Future firestoreExample(FirebaseApp admin) async { await recursiveDeleteExample(admin); await bulkWriterExamples(admin); await bundleBuilderExample(admin); + await pipelineExample(admin); } /// Example 1: Basic Firestore operations with default database @@ -507,3 +508,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 70566bd2..7708aedf 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 @@ -118,26 +122,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 dadb927e..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,6 +92,7 @@ export 'src/firestore.dart' VectorValue, WhereFilter, WriteBatch, + // Supporting classes WriteResult, average, count, 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/pipelines/aggregate_function.dart b/packages/google_cloud_firestore/lib/src/pipelines/aggregate_function.dart new file mode 100644 index 00000000..8af71d77 --- /dev/null +++ b/packages/google_cloud_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(fieldReferenceValue: field); + } else if (field is FieldPath) { + return firestore_v1.Value(fieldReferenceValue: 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/google_cloud_firestore/lib/src/pipelines/aliased.dart b/packages/google_cloud_firestore/lib/src/pipelines/aliased.dart new file mode 100644 index 00000000..2cc1b9fa --- /dev/null +++ b/packages/google_cloud_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/google_cloud_firestore/lib/src/pipelines/explain_stats.dart b/packages/google_cloud_firestore/lib/src/pipelines/explain_stats.dart new file mode 100644 index 00000000..85b350c4 --- /dev/null +++ b/packages/google_cloud_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 const ExplainStats._({}); + } + + /// 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/google_cloud_firestore/lib/src/pipelines/expression.dart b/packages/google_cloud_firestore/lib/src/pipelines/expression.dart new file mode 100644 index 00000000..bbf4df73 --- /dev/null +++ b/packages/google_cloud_firestore/lib/src/pipelines/expression.dart @@ -0,0 +1,607 @@ +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(fieldReferenceValue: 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( + functionValue: firestore_v1.Function$( + name: functionName, + args: 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( + functionValue: firestore_v1.Function$( + name: functionName, + args: arguments.map((arg) => arg._toProto(firestore)).toList(), + ), + ); + } +} diff --git a/packages/google_cloud_firestore/lib/src/pipelines/ordering.dart b/packages/google_cloud_firestore/lib/src/pipelines/ordering.dart new file mode 100644 index 00000000..1332fb6d --- /dev/null +++ b/packages/google_cloud_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/google_cloud_firestore/lib/src/pipelines/pipeline_snapshot.dart b/packages/google_cloud_firestore/lib/src/pipelines/pipeline_snapshot.dart new file mode 100644 index 00000000..6f62815f --- /dev/null +++ b/packages/google_cloud_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/google_cloud_firestore/lib/src/pipelines/pipelines.dart b/packages/google_cloud_firestore/lib/src/pipelines/pipelines.dart new file mode 100644 index 00000000..06594ea0 --- /dev/null +++ b/packages/google_cloud_firestore/lib/src/pipelines/pipelines.dart @@ -0,0 +1,828 @@ +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( + database: firestore._formattedDatabaseName, + structuredPipeline: firestore_v1.StructuredPipeline(pipeline: _toProto()), + ); + + final stream = await firestore._firestoreClient.v1((api, projectId) async { + return api.executePipeline(request); + }); + + final results = []; + 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.isNotEmpty + ? firestore.doc(resultDoc.name) + : null, + id: resultDoc.name.isNotEmpty + ? resultDoc.name.split('/').last + : null, + createTime: resultDoc.createTime != null + ? Timestamp._fromProto(resultDoc.createTime!) + : null, + updateTime: resultDoc.updateTime != null + ? Timestamp._fromProto(resultDoc.updateTime!) + : null, + data: data, + ), + ); + } + } + + return PipelineSnapshot._( + pipeline: this, + results: results, + executionTime: executionTime ?? Timestamp.now(), + explainStats: explainStats, + ); + } + + @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.Pipeline_Stage _stageToProto(_Stage stage) { + switch (stage) { + case _CollectionStage(:final collectionId): + return firestore_v1.Pipeline_Stage( + name: 'collection', + args: [_collectionReferenceValue(collectionId)], + ); + case _CollectionGroupStage(:final collectionId): + return firestore_v1.Pipeline_Stage( + name: 'collection_group', + args: [_collectionReferenceValue(collectionId)], + ); + case _DatabaseStage(:final database): + return firestore_v1.Pipeline_Stage( + name: 'database', + args: [_databaseReferenceValue(database)], + ); + case _DocumentsStage(:final documents): + return firestore_v1.Pipeline_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.Pipeline_Stage( + name: 'select', + args: [ + firestore_v1.Value( + mapValue: firestore_v1.MapValue(fields: selectionsMap), + ), + ], + ); + case _AddFieldsStage(:final fields): + return firestore_v1.Pipeline_Stage( + name: 'add_fields', + options: fields.map((k, v) => MapEntry(k, _expressionToValue(v))), + ); + case _RemoveFieldsStage(:final fields): + 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.Pipeline_Stage( + name: 'where', + args: [_expressionToValue(condition)], + ); + case _SortStage(:final orderings): + return firestore_v1.Pipeline_Stage( + name: 'sort', + args: orderings.map(_orderingToValue).toList(), + ); + case _LimitStage(:final limit): + return firestore_v1.Pipeline_Stage( + name: 'limit', + args: [_intValue(limit)], + ); + case _OffsetStage(:final offset): + return firestore_v1.Pipeline_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.Pipeline_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.Pipeline_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.Pipeline_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.Pipeline_Stage( + name: 'replace_with', + args: [_expressionToValue(expression)], + ); + case _SampleStage(:final size): + return firestore_v1.Pipeline_Stage( + name: 'sample', + args: [_intValue(size)], + ); + case _UnionStage(:final pipelines): + return firestore_v1.Pipeline_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.Pipeline_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, + ), + } + : 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.Pipeline_Stage( + name: name, + args: value is List ? value.map(_anyToValue).toList() : const [], + options: value is Map + ? (value as Map).map( + (k, v) => MapEntry(k, _anyToValue(v)), + ) + : const {}, + ); + 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); + + 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.Pipeline_Stage stage) => + firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + '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), + ), + }, + ), + ); + + 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: protobuf_v1.NullValue.nullValue); + } 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/google_cloud_firestore/lib/src/pipelines/stage.dart b/packages/google_cloud_firestore/lib/src/pipelines/stage.dart new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/packages/google_cloud_firestore/lib/src/pipelines/stage.dart @@ -0,0 +1 @@ + diff --git a/packages/google_cloud_firestore/lib/src/pipelines/stage_options.dart b/packages/google_cloud_firestore/lib/src/pipelines/stage_options.dart new file mode 100644 index 00000000..2966302f --- /dev/null +++ b/packages/google_cloud_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/google_cloud_firestore/lib/src/reference/document_reference.dart b/packages/google_cloud_firestore/lib/src/reference/document_reference.dart index 14053cc0..cc420a3a 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 @@ -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 f2ebe31b..922420fe 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( @@ -519,6 +543,88 @@ class Transaction { ); } + Future<_TransactionResult> _executePipelineFn( + Pipeline pipeline, { + String? transactionId, + Timestamp? readTime, + firestore_v1.TransactionOptions? transactionOptions, + List? fieldMask, + }) async { + final request = firestore_v1.ExecutePipelineRequest( + database: _firestore._formattedDatabaseName, + structuredPipeline: firestore_v1.StructuredPipeline( + pipeline: pipeline._toProto(), + ), + transaction: transactionId != null ? base64Decode(transactionId) : null, + readTime: readTime != null + ? protobuf_v1.Timestamp( + seconds: readTime.seconds, + nanos: readTime.nanoseconds, + ) + : null, + newTransaction: transactionOptions, + ); + + final stream = await _firestore._firestoreClient.v1((api, projectId) async { + return api.executePipeline(request); + }); + + Uint8List? responseTransaction; + final results = []; + 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.isNotEmpty + ? _firestore.doc(resultDoc.name) + : null, + id: resultDoc.name.isNotEmpty + ? resultDoc.name.split('/').last + : null, + createTime: resultDoc.createTime != null + ? Timestamp._fromProto(resultDoc.createTime!) + : null, + updateTime: resultDoc.updateTime != null + ? Timestamp._fromProto(resultDoc.updateTime!) + : null, + data: data, + ), + ); + } + } + + final snapshot = PipelineSnapshot._( + pipeline: pipeline, + results: results, + executionTime: executionTime ?? Timestamp.now(), + explainStats: explainStats, + ); + + return _TransactionResult( + transaction: responseTransaction != null + ? base64Encode(responseTransaction) + : null, + 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/integration/get_all_test.dart b/packages/google_cloud_firestore/test/integration/get_all_test.dart new file mode 100644 index 00000000..e69de29b diff --git a/packages/google_cloud_firestore/test/order_test.dart b/packages/google_cloud_firestore/test/order_test.dart index 411fa4ac..f53e931d 100644 --- a/packages/google_cloud_firestore/test/order_test.dart +++ b/packages/google_cloud_firestore/test/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)); diff --git a/packages/google_cloud_firestore/test/set_options_test.dart b/packages/google_cloud_firestore/test/set_options_test.dart index 386b1c81..b04f546c 100644 --- a/packages/google_cloud_firestore/test/set_options_test.dart +++ b/packages/google_cloud_firestore/test/set_options_test.dart @@ -68,20 +68,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/transaction_test.dart b/packages/google_cloud_firestore/test/unit/transaction_test.dart new file mode 100644 index 00000000..e69de29b diff --git a/scripts/firestore-coverage.sh b/scripts/firestore-coverage.sh index 9fda6d25..9d73c0b3 100755 --- a/scripts/firestore-coverage.sh +++ b/scripts/firestore-coverage.sh @@ -3,8 +3,10 @@ # Fast fail the script on failures. set -e -# prod tests are opt-in: set GOOGLE_APPLICATION_CREDENTIALS to include them. -# export GOOGLE_APPLICATION_CREDENTIALS=service-account-key.json +# 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 SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"