From c9bd448888779a5a0302d7a470c97f883dc79e95 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 29 Jun 2026 16:01:55 +0200 Subject: [PATCH 1/3] feat: add support for Firestore Pipeline --- packages/google_cloud_firestore/README.md | 86 + .../lib/google_cloud_firestore.dart | 28 + .../lib/src/firestore.dart | 8 + .../lib/src/pipeline.dart | 2161 +++++++++++++++++ .../src/reference/vector_query_options.dart | 6 +- .../google_cloud_firestore/test/e2e/README.md | 44 + .../test/e2e/pipeline_e2e_test.dart | 895 +++++++ .../test/pipeline_test.dart | 486 ++++ 8 files changed, 3711 insertions(+), 3 deletions(-) create mode 100644 packages/google_cloud_firestore/lib/src/pipeline.dart create mode 100644 packages/google_cloud_firestore/test/e2e/README.md create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart create mode 100644 packages/google_cloud_firestore/test/pipeline_test.dart diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index a5a3fda1..5e64e104 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -160,6 +160,92 @@ for (final doc in querySnapshot.docs) { } ``` +### Firestore Pipeline Operations + +Firestore Pipeline operations are available for Firestore Enterprise edition +databases. They are server-side queries for projections, expressions, +aggregates, and vector search. + +```dart +final snapshot = await firestore + .pipeline() + .collection('books') + .where(Expression.field('active').equalValue(true)) + .sort([Expression.field('price').ascending()]) + .select([ + Expression.field('title'), + Expression.field('price'), + Expression.field('title').toUpperCase().as('upperTitle'), + Expression.field('tags').arrayLength().as('tagCount'), + ]) + .limit(10) + .execute(); + +for (final result in snapshot.results) { + print(result.data()); +} +``` + +#### Aggregates + +Aggregate stages use aliased aggregate expressions: + +```dart +final snapshot = await firestore + .pipeline() + .collection('books') + .where(Expression.field('active').equalValue(true)) + .aggregate([ + Expression.field('price').sum().as('totalPrice'), + Expression.field('rating').average().as('averageRating'), + PipelineFunctions.count().as('bookCount'), + ]) + .execute(); + +final data = snapshot.results.single.data(); +print(data); +``` + +#### Expressions + +Use `Expression.field`, `Expression.constant`, and `Expression.variable` to +build expressions. Most helpers are also available as fluent methods: + +```dart +final expression = Expression.field('createdAt') + .timestampSubtract('day', 7) + .timestampToUnixSeconds() + .as('createdSeconds'); +``` + +Expressions can be selected with an alias, used in filters, passed to aggregate +stages, or composed with other expression helpers. + +#### E2E Testing + +Real-project Pipeline E2E tests live in `test/e2e/pipeline_e2e_test.dart`. +They are skipped unless you provide a project and credentials: + +```bash +export FIRESTORE_PIPELINE_E2E_PROJECT_ID="your-project-id" +export FIRESTORE_PIPELINE_E2E_DATABASE_ID="your-enterprise-database-id" +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" +dart test test/e2e/pipeline_e2e_test.dart +``` + +The E2E suite includes vector nearest-neighbor coverage. Create the vector index +once for the test collection group before running the suite in CI: + +```bash +gcloud firestore indexes composite create \ + --project="your-project-id" \ + --database="your-enterprise-database-id" \ + --collection-group="pipeline_e2e_books" \ + --query-scope=COLLECTION \ + --field-config=field-path="runId",order=ASCENDING \ + --field-config=field-path="embedding",vector-config='{"dimension":"3","flat":"{}"}' +``` + #### Get All ```dart diff --git a/packages/google_cloud_firestore/lib/google_cloud_firestore.dart b/packages/google_cloud_firestore/lib/google_cloud_firestore.dart index dadb927e..dd08c0f1 100644 --- a/packages/google_cloud_firestore/lib/google_cloud_firestore.dart +++ b/packages/google_cloud_firestore/lib/google_cloud_firestore.dart @@ -20,6 +20,8 @@ export 'src/firestore.dart' AggregateField, AggregateQuery, AggregateQuerySnapshot, + AliasedExpression, + BooleanExpression, BulkWriter, BulkWriterError, BulkWriterOptions, @@ -39,12 +41,26 @@ export 'src/firestore.dart' ExplainMetrics, ExplainOptions, ExplainResults, + Expression, FieldMask, FieldPath, FieldValue, Filter, Firestore, GeoPoint, + Ordering, + Pipeline, + PipelineAggregateFunction, + PipelineAliasedExpression, + PipelineBooleanExpression, + PipelineExpression, + PipelineField, + PipelineFunctions, + PipelineOrdering, + PipelineResult, + PipelineSnapshot, + PipelineSource, + PipelineValueType, PlanSummary, Precondition, Query, @@ -54,6 +70,7 @@ export 'src/firestore.dart' ReadOnlyTransactionOptions, ReadOptions, ReadWriteTransactionOptions, + Selectable, SetOptions, Settings, Timestamp, @@ -67,8 +84,19 @@ export 'src/firestore.dart' WhereFilter, WriteBatch, WriteResult, + and, + ascending, average, + constant, count, + currentDocument, + descending, + equal, + field, + not, + notEqual, + or, + pipelineFunction, sum; export 'src/firestore_exception.dart' show FirestoreClientErrorCode, FirestoreException; diff --git a/packages/google_cloud_firestore/lib/src/firestore.dart b/packages/google_cloud_firestore/lib/src/firestore.dart index 7d50925d..bb968966 100644 --- a/packages/google_cloud_firestore/lib/src/firestore.dart +++ b/packages/google_cloud_firestore/lib/src/firestore.dart @@ -45,6 +45,7 @@ part 'filter.dart'; part 'geo_point.dart'; part 'order.dart'; part 'path.dart'; +part 'pipeline.dart'; part 'query_partition.dart'; part 'query_profile.dart'; part 'rate_limiter.dart'; @@ -467,6 +468,13 @@ class Firestore { ); } + /// Creates a [PipelineSource], which defines a Firestore Pipeline operation. + /// + /// Pipeline operations are available for Firestore Enterprise edition + /// databases and support server-side projections, expressions, aggregates, + /// and vector search. + PipelineSource pipeline() => PipelineSource._(this); + /// Fetches the root collections that are associated with this Firestore /// database. /// diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart new file mode 100644 index 00000000..667a581a --- /dev/null +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -0,0 +1,2161 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +part of 'firestore.dart'; + +/// Creates a field reference expression for Firestore Pipeline operations. +PipelineField field(String fieldPath) => PipelineField._(fieldPath); + +/// Creates a constant expression for Firestore Pipeline operations. +PipelineExpression constant(Object? value) => _PipelineConstant(value); + +/// Creates a variable reference expression for Firestore Pipeline operations. +PipelineExpression variable(String name) => _PipelineVariable(name); + +/// FlutterFire-style entry points for building Pipeline expressions. +abstract final class Expression { + /// Creates a field reference expression. + static PipelineField field(String fieldPath) => PipelineField._(fieldPath); + + /// Creates a constant expression. + static PipelineExpression constant(Object? value) => _PipelineConstant(value); + + /// Creates a variable reference expression. + static PipelineExpression variable(String name) => _PipelineVariable(name); + + /// Creates an array expression. + static PipelineExpression array(Iterable values) { + return PipelineFunctions.array(values); + } + + /// Creates a vector value expression. + static PipelineExpression vector(List values) { + return _PipelineConstant( + FieldValue.vector([for (final value in values) value.toDouble()]), + ); + } + + /// Creates a raw backend function expression. + static PipelineExpression raw( + String name, + Iterable args, { + Map options = const {}, + }) { + return pipelineFunction(name, args, options: options); + } +} + +/// FlutterFire-style alias for [PipelineBooleanExpression]. +typedef BooleanExpression = PipelineBooleanExpression; + +/// FlutterFire-style alias for [PipelineOrdering]. +typedef Ordering = PipelineOrdering; + +/// FlutterFire-style alias for [PipelineAliasedExpression]. +typedef AliasedExpression = PipelineAliasedExpression; + +/// FlutterFire-style alias for expressions that can be selected. +typedef Selectable = PipelineExpression; + +/// FlutterFire-style alias for aggregate function expressions. +typedef PipelineAggregateFunction = PipelineExpression; + +/// Firestore Pipeline backend value types used with [PipelineExpression.isType]. +enum PipelineValueType { + /// Null values. + nullValue('null'), + + /// Boolean values. + boolean('boolean'), + + /// Any numeric value. + number('number'), + + /// Integer numeric values. + int64('int64'), + + /// Double numeric values. + double('double'), + + /// Timestamp values. + timestamp('timestamp'), + + /// String values. + string('string'), + + /// Bytes values. + bytes('bytes'), + + /// Document reference values. + reference('reference'), + + /// Geo point values. + geoPoint('geo_point'), + + /// Array values. + array('array'), + + /// Map values. + map('map'), + + /// Vector values. + vector('vector'); + + const PipelineValueType(this.value); + + /// The backend type name. + final String value; +} + +/// Creates a raw Pipeline function expression. +PipelineExpression pipelineFunction( + String name, + Iterable args, { + Map options = const {}, +}) { + return _PipelineFunctionExpression(name, args.toList(), options); +} + +/// Creates an equality expression. +PipelineBooleanExpression equal(Object? left, Object? right) { + return _comparison('equal', left, right); +} + +/// Creates a not-equal expression. +PipelineBooleanExpression notEqual(Object? left, Object? right) { + return _comparison('not_equal', left, right); +} + +/// Creates a less-than-or-equal expression. +PipelineBooleanExpression lessThanOrEqual(Object? left, Object? right) { + return _comparison('less_than_or_equal', left, right); +} + +/// Creates a greater-than-or-equal expression. +PipelineBooleanExpression greaterThanOrEqual(Object? left, Object? right) { + return _comparison('greater_than_or_equal', left, right); +} + +PipelineBooleanExpression _comparison( + String name, + Object? left, + Object? right, +) { + return _PipelineBooleanExpression(name, [ + if (left is String) field(left) else left, + right, + ]); +} + +/// Creates a logical AND expression. +PipelineBooleanExpression and(Iterable expressions) { + return _PipelineBooleanExpression('and', expressions.toList()); +} + +/// Creates a logical OR expression. +PipelineBooleanExpression or(Iterable expressions) { + return _PipelineBooleanExpression('or', expressions.toList()); +} + +/// Creates a logical NOT expression. +PipelineBooleanExpression not(PipelineBooleanExpression expression) { + return _PipelineBooleanExpression('not', [expression]); +} + +/// Returns the current document as a Pipeline expression. +PipelineExpression currentDocument() => PipelineFunctions.currentDocument(); + +/// Convenience wrappers for the Firestore Pipeline function catalog. +/// +/// These helpers encode to the backend function names documented in the +/// Firestore Pipeline functions reference. String arguments are encoded as +/// string literals; use [field] when you want to reference a document field. +abstract final class PipelineFunctions { + static PipelineExpression _expr(String name, Iterable args) { + return pipelineFunction(name, args); + } + + static PipelineBooleanExpression _bool(String name, Iterable args) { + return _PipelineBooleanExpression(name, args.toList()); + } + + /// Creates a raw Pipeline function expression. + static PipelineExpression raw(String name, Iterable args) { + return _expr(name, args); + } + + /// COUNT aggregate function. + static PipelineExpression count([Object? expression]) { + return _expr('count', _optionalArg(expression)); + } + + /// COUNT_IF aggregate function. + static PipelineExpression countIf(Object? expression) { + return _expr('count_if', [expression]); + } + + /// COUNT_DISTINCT aggregate function. + static PipelineExpression countDistinct(Object? expression) { + return _expr('count_distinct', [expression]); + } + + /// SUM function. + static PipelineExpression sum(Object? expression) { + return _expr('sum', [expression]); + } + + /// AVERAGE aggregate function. + static PipelineExpression average(Object? expression) { + return _expr('average', [expression]); + } + + /// MINIMUM function. + static PipelineExpression minimum(Object? first, [Object? second]) { + return _expr('minimum', [first, ..._optionalArg(second)]); + } + + /// MAXIMUM function. + static PipelineExpression maximum(Object? first, [Object? second]) { + return _expr('maximum', [first, ..._optionalArg(second)]); + } + + /// FIRST aggregate function. + static PipelineExpression first(Object? expression) { + return _expr('first', [expression]); + } + + /// LAST aggregate function. + static PipelineExpression last(Object? expression) { + return _expr('last', [expression]); + } + + /// ARRAY_AGG aggregate function. + static PipelineExpression arrayAgg(Object? expression) { + return _expr('array_agg', [expression]); + } + + /// ARRAY_AGG_DISTINCT aggregate function. + static PipelineExpression arrayAggDistinct(Object? expression) { + return _expr('array_agg_distinct', [expression]); + } + + /// ABS arithmetic function. + static PipelineExpression abs(Object? value) => _expr('abs', [value]); + + /// ADD arithmetic function. + static PipelineExpression add(Object? left, Object? right) { + return _expr('add', [left, right]); + } + + /// SUBTRACT arithmetic function. + static PipelineExpression subtract(Object? left, Object? right) { + return _expr('subtract', [left, right]); + } + + /// MULTIPLY arithmetic function. + static PipelineExpression multiply(Object? left, Object? right) { + return _expr('multiply', [left, right]); + } + + /// DIVIDE arithmetic function. + static PipelineExpression divide(Object? left, Object? right) { + return _expr('divide', [left, right]); + } + + /// MOD arithmetic function. + static PipelineExpression mod(Object? left, Object? right) { + return _expr('mod', [left, right]); + } + + /// CEIL arithmetic function. + static PipelineExpression ceil(Object? value) => _expr('ceil', [value]); + + /// FLOOR arithmetic function. + static PipelineExpression floor(Object? value) => _expr('floor', [value]); + + /// ROUND arithmetic function. + static PipelineExpression round(Object? value) => _expr('round', [value]); + + /// TRUNC arithmetic function. + static PipelineExpression trunc(Object? value) => _expr('trunc', [value]); + + /// POW arithmetic function. + static PipelineExpression pow(Object? base, Object? exponent) { + return _expr('pow', [base, exponent]); + } + + /// SQRT arithmetic function. + static PipelineExpression sqrt(Object? value) => _expr('sqrt', [value]); + + /// EXP arithmetic function. + static PipelineExpression exp(Object? exponent) => _expr('exp', [exponent]); + + /// LN arithmetic function. + static PipelineExpression ln(Object? value) => _expr('ln', [value]); + + /// LOG arithmetic function. + static PipelineExpression log(Object? number, [Object? base]) { + return _expr('log', [number, ..._optionalArg(base)]); + } + + /// LOG10 arithmetic function. + static PipelineExpression log10(Object? value) => _expr('log10', [value]); + + /// RAND arithmetic function. + static PipelineExpression rand() => _expr('rand', const []); + + /// ARRAY construction function. + static PipelineExpression array(Iterable values) { + return _expr('array', values); + } + + /// ARRAY_CONCAT function. + static PipelineExpression arrayConcat(Iterable arrays) { + return _expr('array_concat', arrays); + } + + /// ARRAY_CONTAINS function. + static PipelineBooleanExpression arrayContains(Object? array, Object? value) { + return _bool('array_contains', [array, value]); + } + + /// ARRAY_CONTAINS_ALL function. + static PipelineBooleanExpression arrayContainsAll( + Object? array, + Object? searchValues, + ) { + return _bool('array_contains_all', [array, searchValues]); + } + + /// ARRAY_CONTAINS_ANY function. + static PipelineBooleanExpression arrayContainsAny( + Object? array, + Object? searchValues, + ) { + return _bool('array_contains_any', [array, searchValues]); + } + + /// ARRAY_FILTER function. + static PipelineExpression arrayFilter( + Object? array, + String variableName, + Object? predicate, + ) { + return _expr('array_filter', [array, variableName, predicate]); + } + + /// ARRAY_GET function. + static PipelineExpression arrayGet(Object? array, Object? index) { + return _expr('array_get', [array, index]); + } + + /// ARRAY_LENGTH function. + static PipelineExpression arrayLength(Object? array) { + return _expr('array_length', [array]); + } + + /// ARRAY_REVERSE function. + static PipelineExpression arrayReverse(Object? array) { + return _expr('array_reverse', [array]); + } + + /// ARRAY_FIRST function. + static PipelineExpression arrayFirst(Object? array) { + return _expr('array_first', [array]); + } + + /// ARRAY_FIRST_N function. + static PipelineExpression arrayFirstN(Object? array, Object? n) { + return _expr('array_first_n', [array, n]); + } + + /// ARRAY_INDEX_OF function. + static PipelineExpression arrayIndexOf(Object? array, Object? value) { + return _expr('array_index_of', [array, value, 'first']); + } + + /// ARRAY_INDEX_OF_ALL function. + static PipelineExpression arrayIndexOfAll(Object? array, Object? value) { + return _expr('array_index_of_all', [array, value]); + } + + /// ARRAY_LAST function. + static PipelineExpression arrayLast(Object? array) { + return _expr('array_last', [array]); + } + + /// ARRAY_LAST_N function. + static PipelineExpression arrayLastN(Object? array, Object? n) { + return _expr('array_last_n', [array, n]); + } + + /// ARRAY_LAST_INDEX_OF function. + static PipelineExpression arrayLastIndexOf(Object? array, Object? value) { + return _expr('array_index_of', [array, value, 'last']); + } + + /// ARRAY_SLICE function. + static PipelineExpression arraySlice( + Object? array, + Object? offset, + Object? length, + ) { + return _expr('array_slice', [array, offset, length]); + } + + /// ARRAY_TRANSFORM function. + static PipelineExpression arrayTransform( + Object? array, + String variableName, + Object? expression, [ + String? indexVariableName, + ]) { + return indexVariableName == null + ? _expr('array_transform', [array, variableName, expression]) + : _expr('array_transform', [ + array, + variableName, + indexVariableName, + expression, + ]); + } + + /// MAXIMUM_N array function. + static PipelineExpression maximumN(Object? array, Object? n) { + return _expr('maximum_n', [array, n]); + } + + /// MINIMUM_N array function. + static PipelineExpression minimumN(Object? array, Object? n) { + return _expr('minimum_n', [array, n]); + } + + /// JOIN function. + static PipelineExpression join(Object? array, [Object? separator]) { + return _expr('join', [array, ..._optionalArg(separator)]); + } + + /// EQUAL comparison function. + static PipelineBooleanExpression equal(Object? left, Object? right) { + return _bool('equal', [left, right]); + } + + /// GREATER_THAN comparison function. + static PipelineBooleanExpression greaterThan(Object? left, Object? right) { + return _bool('greater_than', [left, right]); + } + + /// GREATER_THAN_OR_EQUAL comparison function. + static PipelineBooleanExpression greaterThanOrEqual( + Object? left, + Object? right, + ) { + return _bool('greater_than_or_equal', [left, right]); + } + + /// LESS_THAN comparison function. + static PipelineBooleanExpression lessThan(Object? left, Object? right) { + return _bool('less_than', [left, right]); + } + + /// LESS_THAN_OR_EQUAL comparison function. + static PipelineBooleanExpression lessThanOrEqual( + Object? left, + Object? right, + ) { + return _bool('less_than_or_equal', [left, right]); + } + + /// NOT_EQUAL comparison function. + static PipelineBooleanExpression notEqual(Object? left, Object? right) { + return _bool('not_equal', [left, right]); + } + + /// CMP comparison function. + static PipelineExpression cmp(Object? left, Object? right) { + return _expr('cmp', [left, right]); + } + + /// EXISTS debugging function. + static PipelineBooleanExpression exists(Object? value) { + return _bool('exists', [value]); + } + + /// IS_ABSENT debugging function. + static PipelineBooleanExpression isAbsent(Object? value) { + return _bool('is_absent', [value]); + } + + /// IF_ABSENT debugging function. + static PipelineExpression ifAbsent(Object? value, Object? replacement) { + return _expr('if_absent', [value, replacement]); + } + + /// IS_ERROR debugging function. + static PipelineBooleanExpression isError(Object? value) { + return _bool('is_error', [value]); + } + + /// IF_ERROR debugging function. + static PipelineExpression ifError(Object? value, Object? catchValue) { + return _expr('if_error', [value, catchValue]); + } + + /// COLLECTION_ID reference function. + static PipelineExpression collectionId(Object? reference) { + return _expr('collection_id', [reference]); + } + + /// DOCUMENT_ID reference function. + static PipelineExpression documentId(Object? reference) { + return _expr('document_id', [reference]); + } + + /// PARENT reference function. + static PipelineExpression parent(Object? reference) { + return _expr('parent', [reference]); + } + + /// REFERENCE_SLICE reference function. + static PipelineExpression referenceSlice( + Object? reference, + Object? offset, + Object? length, + ) { + return _expr('reference_slice', [reference, offset, length]); + } + + /// AND logical function. + static PipelineBooleanExpression and(Iterable expressions) { + return _bool('and', expressions); + } + + /// OR logical function. + static PipelineBooleanExpression or(Iterable expressions) { + return _bool('or', expressions); + } + + /// XOR logical function. + static PipelineBooleanExpression xor(Iterable expressions) { + return _bool('xor', expressions); + } + + /// NOR logical function. + static PipelineBooleanExpression nor(Iterable expressions) { + return _bool('nor', expressions); + } + + /// NOT logical function. + static PipelineBooleanExpression not(Object? expression) { + return _bool('not', [expression]); + } + + /// CONDITIONAL logical function. + static PipelineExpression conditional( + Object? condition, + Object? trueCase, + Object? falseCase, + ) { + return _expr('conditional', [condition, trueCase, falseCase]); + } + + /// IF_NULL logical function. + static PipelineExpression ifNull(Object? expression, Object? replacement) { + return _expr('if_null', [expression, replacement]); + } + + /// SWITCH_ON logical function. + static PipelineExpression switchOn(Iterable cases) { + return _expr('switch_on', cases); + } + + /// EQUAL_ANY logical function. + static PipelineBooleanExpression equalAny( + Object? value, + Object? searchSpace, + ) { + return _bool('equal_any', [value, searchSpace]); + } + + /// NOT_EQUAL_ANY logical function. + static PipelineBooleanExpression notEqualAny( + Object? value, + Object? searchSpace, + ) { + return _bool('not_equal_any', [value, searchSpace]); + } + + /// MAP construction function. + static PipelineExpression map(Iterable keyValues) { + return _expr('map', keyValues); + } + + /// MAP_GET function. + static PipelineExpression mapGet(Object? map, Object? key) { + return _expr('map_get', [map, key]); + } + + /// MAP_SET function. + static PipelineExpression mapSet(Object? map, Iterable keyValues) { + return _expr('map_set', [map, ...keyValues]); + } + + /// MAP_REMOVE function. + static PipelineExpression mapRemove(Object? map, Iterable keys) { + return _expr('map_remove', [map, ...keys]); + } + + /// MAP_MERGE function. + static PipelineExpression mapMerge(Iterable maps) { + return _expr('map_merge', maps); + } + + /// CURRENT_DOCUMENT function. + static PipelineExpression currentDocument() { + return _expr('current_document', const []); + } + + /// MAP_KEYS function. + static PipelineExpression mapKeys(Object? map) => _expr('map_keys', [map]); + + /// MAP_VALUES function. + static PipelineExpression mapValues(Object? map) { + return _expr('map_values', [map]); + } + + /// MAP_ENTRIES function. + static PipelineExpression mapEntries(Object? map) { + return _expr('map_entries', [map]); + } + + /// BYTE_LENGTH string function. + static PipelineExpression byteLength(Object? value) { + return _expr('byte_length', [value]); + } + + /// CHAR_LENGTH string function. + static PipelineExpression charLength(Object? value) { + return _expr('char_length', [value]); + } + + /// STARTS_WITH string function. + static PipelineBooleanExpression startsWith(Object? value, Object? prefix) { + return _bool('starts_with', [value, prefix]); + } + + /// ENDS_WITH string function. + static PipelineBooleanExpression endsWith(Object? value, Object? postfix) { + return _bool('ends_with', [value, postfix]); + } + + /// LIKE string function. + static PipelineBooleanExpression like(Object? value, Object? pattern) { + return _bool('like', [value, pattern]); + } + + /// REGEX_CONTAINS string function. + static PipelineBooleanExpression regexContains( + Object? value, + Object? pattern, + ) { + return _bool('regex_contains', [value, pattern]); + } + + /// REGEX_MATCH string function. + static PipelineBooleanExpression regexMatch(Object? value, Object? pattern) { + return _bool('regex_match', [value, pattern]); + } + + /// REGEX_FIND string function. + static PipelineExpression regexFind(Object? value, Object? pattern) { + return _expr('regex_find', [value, pattern]); + } + + /// REGEX_FIND_ALL string function. + static PipelineExpression regexFindAll(Object? value, Object? pattern) { + return _expr('regex_find_all', [value, pattern]); + } + + /// STRING_CONCAT string function. + static PipelineExpression stringConcat(Iterable values) { + return _expr('string_concat', values); + } + + /// STRING_CONTAINS string function. + static PipelineBooleanExpression stringContains( + Object? value, + Object? substring, + ) { + return _bool('string_contains', [value, substring]); + } + + /// STRING_INDEX_OF string function. + static PipelineExpression stringIndexOf(Object? value, Object? substring) { + return _expr('string_index_of', [value, substring]); + } + + /// TO_UPPER string function. + static PipelineExpression toUpper(Object? value) { + return _expr('to_upper', [value]); + } + + /// TO_LOWER string function. + static PipelineExpression toLower(Object? value) { + return _expr('to_lower', [value]); + } + + /// SUBSTRING string function. + static PipelineExpression substring( + Object? value, + Object? offset, [ + Object? length, + ]) { + return _expr('substring', [value, offset, ..._optionalArg(length)]); + } + + /// STRING_REVERSE string function. + static PipelineExpression stringReverse(Object? value) { + return _expr('string_reverse', [value]); + } + + /// STRING_REPEAT string function. + static PipelineExpression stringRepeat(Object? value, Object? count) { + return _expr('string_repeat', [value, count]); + } + + /// STRING_REPLACE_ALL string function. + static PipelineExpression stringReplaceAll( + Object? value, + Object? from, + Object? to, + ) { + return _expr('string_replace_all', [value, from, to]); + } + + /// STRING_REPLACE_ONE string function. + static PipelineExpression stringReplaceOne( + Object? value, + Object? from, + Object? to, + ) { + return _expr('string_replace_one', [value, from, to]); + } + + /// TRIM string function. + static PipelineExpression trim(Object? value, [Object? characters]) { + return _expr('trim', [value, ..._optionalArg(characters)]); + } + + /// LTRIM string function. + static PipelineExpression ltrim(Object? value, [Object? characters]) { + return _expr('ltrim', [value, ..._optionalArg(characters)]); + } + + /// RTRIM string function. + static PipelineExpression rtrim(Object? value, [Object? characters]) { + return _expr('rtrim', [value, ..._optionalArg(characters)]); + } + + /// SPLIT string function. + static PipelineExpression split(Object? input, [Object? delimiter]) { + return _expr('split', [input, ..._optionalArg(delimiter)]); + } + + /// CURRENT_TIMESTAMP function. + static PipelineExpression currentTimestamp() { + return _expr('current_timestamp', const []); + } + + /// TIMESTAMP_TRUNC function. + static PipelineExpression timestampTrunc( + Object? timestamp, + Object? granularity, [ + Object? timezone, + ]) { + return _expr('timestamp_trunc', [ + timestamp, + granularity, + ..._optionalArg(timezone), + ]); + } + + /// UNIX_MICROS_TO_TIMESTAMP function. + static PipelineExpression unixMicrosToTimestamp(Object? input) { + return _expr('unix_micros_to_timestamp', [input]); + } + + /// UNIX_MILLIS_TO_TIMESTAMP function. + static PipelineExpression unixMillisToTimestamp(Object? input) { + return _expr('unix_millis_to_timestamp', [input]); + } + + /// UNIX_SECONDS_TO_TIMESTAMP function. + static PipelineExpression unixSecondsToTimestamp(Object? input) { + return _expr('unix_seconds_to_timestamp', [input]); + } + + /// TIMESTAMP_ADD function. + static PipelineExpression timestampAdd( + Object? timestamp, + Object? unit, + Object? amount, + ) { + return _expr('timestamp_add', [timestamp, unit, amount]); + } + + /// TIMESTAMP_SUBTRACT function. + static PipelineExpression timestampSubtract( + Object? timestamp, + Object? unit, + Object? amount, + ) { + return _expr('timestamp_subtract', [timestamp, unit, amount]); + } + + /// TIMESTAMP_TO_UNIX_MICROS function. + static PipelineExpression timestampToUnixMicros(Object? input) { + return _expr('timestamp_to_unix_micros', [input]); + } + + /// TIMESTAMP_TO_UNIX_MILLIS function. + static PipelineExpression timestampToUnixMillis(Object? input) { + return _expr('timestamp_to_unix_millis', [input]); + } + + /// TIMESTAMP_TO_UNIX_SECONDS function. + static PipelineExpression timestampToUnixSeconds(Object? input) { + return _expr('timestamp_to_unix_seconds', [input]); + } + + /// TIMESTAMP_DIFF function. + static PipelineExpression timestampDiff( + Object? end, + Object? start, + Object? unit, + ) { + return _expr('timestamp_diff', [end, start, unit]); + } + + /// TIMESTAMP_EXTRACT function. + static PipelineExpression timestampExtract( + Object? timestamp, + Object? part, [ + Object? timezone, + ]) { + return _expr('timestamp_extract', [ + timestamp, + part, + ..._optionalArg(timezone), + ]); + } + + /// TYPE function. + static PipelineExpression type(Object? input) => _expr('type', [input]); + + /// IS_TYPE function. + static PipelineBooleanExpression isType(Object? input, Object? type) { + return _bool('is_type', [input, type]); + } + + /// COSINE_DISTANCE vector function. + static PipelineExpression cosineDistance(Object? left, Object? right) { + return _expr('cosine_distance', [left, right]); + } + + /// DOT_PRODUCT vector function. + static PipelineExpression dotProduct(Object? left, Object? right) { + return _expr('dot_product', [left, right]); + } + + /// EUCLIDEAN_DISTANCE vector function. + static PipelineExpression euclideanDistance(Object? left, Object? right) { + return _expr('euclidean_distance', [left, right]); + } + + /// VECTOR_LENGTH vector function. + static PipelineExpression vectorLength(Object? vector) { + return _expr('vector_length', [vector]); + } +} + +/// Creates an ascending Pipeline ordering. +PipelineOrdering ascending(Object expression) { + return PipelineOrdering._('ascending', expression); +} + +/// Creates a descending Pipeline ordering. +PipelineOrdering descending(Object expression) { + return PipelineOrdering._('descending', expression); +} + +/// The starting point for constructing Firestore Pipeline operations. +@immutable +final class PipelineSource { + const PipelineSource._(this._firestore); + + final Firestore _firestore; + + /// Starts a Pipeline over documents in the collection at [collectionPath]. + Pipeline collection(String collectionPath) { + _validateResourcePath('collectionPath', collectionPath); + return collectionReference(_firestore.collection(collectionPath)); + } + + /// Starts a Pipeline over every collection with [collectionId]. + Pipeline collectionGroup(String collectionId) { + if (collectionId.contains('/')) { + throw ArgumentError( + 'Invalid collectionId "$collectionId". Collection IDs must not contain "/".', + ); + } + return _start('collection_group', [collectionId]); + } + + /// Starts a Pipeline over every document in the database. + Pipeline database() => _start('database', const []); + + /// Starts a Pipeline over the provided collection reference. + Pipeline collectionReference( + CollectionReference collectionReference, + ) { + return _start('collection', [collectionReference]); + } + + /// Starts a Pipeline over the provided document references. + Pipeline documents(Iterable> documents) { + final refs = documents.toList(); + if (refs.isEmpty) { + throw ArgumentError.value(documents, 'documents', 'Must not be empty.'); + } + return _start('documents', refs); + } + + Pipeline _start(String name, List args) { + return Pipeline._( + firestore: _firestore, + stages: [_PipelineStage(name, args)], + ); + } +} + +/// A Firestore Pipeline operation. +@immutable +final class Pipeline { + const Pipeline._({ + required this.firestore, + required List<_PipelineStage> stages, + Map options = const {}, + }) : _stages = stages, + _options = options; + + /// The Firestore instance used to execute this Pipeline. + final Firestore firestore; + final List<_PipelineStage> _stages; + final Map _options; + + /// Adds a raw backend Pipeline stage. + /// + /// Use this for preview stages or options not yet wrapped by this SDK. + Pipeline rawStage( + String name, + Iterable args, { + Map options = const {}, + }) { + return _append(_PipelineStage(name, args.toList(), options)); + } + + /// Filters inputs using [condition]. + Pipeline where(PipelineBooleanExpression condition) { + return rawStage('where', [condition]); + } + + /// Selects or computes fields from the inputs. + /// + /// Entries may be [String] field names, [PipelineField] references, + /// [PipelineExpression] instances, or [PipelineAliasedExpression] values. + Pipeline select(Iterable selections) { + return rawStage('select', [_projectionMap(selections)]); + } + + /// Adds or overwrites fields on the inputs. + Pipeline addFields(Iterable fields) { + return rawStage('add_fields', fields); + } + + /// Aggregates inputs using aliased aggregate expressions. + Pipeline aggregate( + Iterable accumulators, { + Iterable groups = const [], + }) { + final values = accumulators.toList(); + if (values.isEmpty) { + throw ArgumentError.value( + accumulators, + 'accumulators', + 'Must not be empty.', + ); + } + return rawStage('aggregate', [ + _projectionMap(values), + _projectionMap(groups), + ]); + } + + /// Returns unique combinations of the provided grouping expressions. + Pipeline distinct(Iterable groups) { + final values = groups.toList(); + if (values.isEmpty) { + throw ArgumentError.value(groups, 'groups', 'Must not be empty.'); + } + return rawStage('distinct', [ + for (final group in values) + if (group is String) field(group) else group, + ]); + } + + /// Removes fields from the inputs. + Pipeline removeFields(Iterable fields) { + return rawStage('remove_fields', [ + for (final value in fields) + if (value is String) field(value) else value, + ]); + } + + /// Sorts inputs according to [orderings]. + Pipeline sort(Iterable orderings) { + final values = orderings.toList(); + if (values.isEmpty) { + throw ArgumentError.value(orderings, 'orderings', 'Must not be empty.'); + } + return rawStage('sort', values); + } + + /// Skips the first [offset] inputs. + Pipeline offset(int offset) { + if (offset < 0) { + throw ArgumentError.value(offset, 'offset', 'Must be non-negative.'); + } + return rawStage('offset', [offset]); + } + + /// Limits the number of returned inputs to [limit]. + Pipeline limit(int limit) { + if (limit < 0) { + throw ArgumentError.value(limit, 'limit', 'Must be non-negative.'); + } + return rawStage('limit', [limit]); + } + + /// Emits a document for each element in [expression]. + Pipeline unnest(Object expression, {String? indexField}) { + return rawStage('unnest', [ + if (expression is String) field(expression) else expression, + ], options: _compactOptions({'index_field': indexField})); + } + + /// Replaces each input document with [expression]. + Pipeline replaceWith(Object expression) { + return rawStage('replace_with', [ + if (expression is String) field(expression) else expression, + ]); + } + + /// Performs a union with [pipeline], including duplicates. + Pipeline union(Pipeline pipeline) { + return rawStage('union', [pipeline]); + } + + /// Samples a fixed number or percentage of documents from the input. + Pipeline sample({int? documents, double? percentage}) { + if ((documents == null) == (percentage == null)) { + throw ArgumentError( + 'Exactly one of documents or percentage must be provided.', + ); + } + if (documents != null && documents < 0) { + throw ArgumentError.value( + documents, + 'documents', + 'Must be non-negative.', + ); + } + if (percentage != null && (percentage < 0 || percentage > 1)) { + throw ArgumentError.value( + percentage, + 'percentage', + 'Must be between 0 and 1.', + ); + } + return rawStage( + 'sample', + const [], + options: _compactOptions({ + 'documents': documents, + 'percentage': percentage, + }), + ); + } + + /// Performs vector nearest-neighbor search. + Pipeline findNearest({ + required Object vectorField, + required Object queryVector, + required DistanceMeasure distanceMeasure, + int? limit, + String? distanceResultField, + double? distanceThreshold, + }) { + return rawStage( + 'find_nearest', + [ + if (vectorField is String) field(vectorField) else vectorField, + queryVector, + distanceMeasure.value, + ], + options: _compactOptions({ + 'limit': limit, + 'distance_field': distanceResultField == null + ? null + : field(distanceResultField), + 'distance_threshold': distanceThreshold, + }), + ); + } + + /// Adds a search stage. + /// + /// The search API is still evolving; [options] is passed through to the + /// backend stage as encoded Pipeline values. + Pipeline search(Map options) { + return rawStage('search', const [], options: options); + } + + /// Returns a copy of this Pipeline with query-level [options]. + Pipeline withOptions(Map options) { + return Pipeline._( + firestore: firestore, + stages: _stages, + options: {..._options, ...options}, + ); + } + + /// Executes this Pipeline and returns the results. + Future execute({ + String? transaction, + Timestamp? readTime, + }) async { + if (transaction != null && readTime != null) { + throw ArgumentError( + 'Only one of transaction or readTime can be provided.', + ); + } + + final response = await firestore._firestoreClient.v1(( + api, + projectId, + ) async { + final request = firestore_v1.ExecutePipelineRequest( + database: 'projects/$projectId/databases/${firestore.databaseId}', + structuredPipeline: firestore_v1.StructuredPipeline( + pipeline: _toProto(), + options: _encodeOptions(_options, firestore), + ), + transaction: transaction.let(base64Decode), + readTime: readTime?._toProto().timestampValue, + ); + return api.executePipeline(request); + }); + + final results = []; + Timestamp? executionTime; + String? newTransaction; + firestore_v1.ExplainStats? explainStats; + + await for (final chunk in response) { + if (chunk.transaction.isNotEmpty) { + newTransaction = base64Encode(chunk.transaction); + } + if (chunk.executionTime != null) { + executionTime = Timestamp._fromProto(chunk.executionTime!); + } + if (chunk.explainStats != null) { + explainStats = chunk.explainStats; + } + + for (final document in chunk.results) { + results.add(PipelineResult._fromDocument(document, firestore)); + } + } + + return PipelineSnapshot._( + results: results, + executionTime: executionTime, + transaction: newTransaction, + explainStats: explainStats, + ); + } + + Pipeline _append(_PipelineStage stage) { + return Pipeline._( + firestore: firestore, + stages: [..._stages, stage], + options: _options, + ); + } + + firestore_v1.Pipeline _toProto() { + return firestore_v1.Pipeline( + stages: [for (final stage in _stages) stage._toProto(firestore)], + ); + } +} + +/// A Pipeline execution result. +@immutable +final class PipelineResult { + const PipelineResult._({ + required DocumentData data, + required this.name, + required this.document, + required this.createTime, + required this.updateTime, + }) : _data = data; + + factory PipelineResult._fromDocument( + firestore_v1.Document document, + Firestore firestore, + ) { + final name = document.name.isEmpty ? null : document.name; + final ref = name == null + ? null + : DocumentReference._( + firestore: firestore, + path: _QualifiedResourcePath.fromSlashSeparatedString(name), + converter: _jsonConverter, + ); + + return PipelineResult._( + data: { + for (final entry in document.fields.entries) + entry.key: _decodePipelineResultValue(entry.value, firestore), + }, + name: name, + document: ref, + createTime: document.createTime.let(Timestamp._fromProto), + updateTime: document.updateTime.let(Timestamp._fromProto), + ); + } + + final DocumentData _data; + + /// The document name when returned by the backend. + /// + /// Projection stages may omit document metadata, in which case this is `null`. + final String? name; + + /// The document reference when returned by the backend. + final DocumentReference? document; + + /// The time the document was created. + final Timestamp? createTime; + + /// The time the document was last updated. + final Timestamp? updateTime; + + /// Returns the decoded result fields. + DocumentData? data() => Map.unmodifiable(_data); + + /// Returns the decoded value at [fieldName], or `null` when absent. + Object? get(String fieldName) => _data[fieldName]; +} + +Object? _decodePipelineResultValue( + firestore_v1.Value value, + Firestore firestore, +) { + final referenceValue = value.referenceValue; + if (referenceValue != null && + referenceValue.isNotEmpty && + !_isDocumentReferenceValue(referenceValue)) { + return referenceValue; + } + return firestore._serializer.decodeValue(value); +} + +bool _isDocumentReferenceValue(String referenceValue) { + final value = referenceValue.startsWith('/') + ? referenceValue.substring(1) + : referenceValue; + final match = RegExp( + r'^projects/[^/]+/databases/[^/]+(?:/documents(?:/(.*))?)?$', + ).firstMatch(value); + if (match == null) { + return false; + } + final path = match.group(1); + if (path == null || path.isEmpty) { + return false; + } + return path.split('/').where((segment) => segment.isNotEmpty).length.isEven; +} + +/// A snapshot returned by executing a Firestore Pipeline operation. +@immutable +final class PipelineSnapshot { + const PipelineSnapshot._({ + required this.results, + required this.executionTime, + required this.transaction, + required this.explainStats, + }); + + /// The Pipeline results returned by the backend. + final List results; + + /// FlutterFire-style alias for [results]. + List get result => results; + + /// The time at which the results are valid. + final Timestamp? executionTime; + + /// A newly-created transaction ID, when requested by the backend. + final String? transaction; + + /// Raw explain stats returned by the generated Firestore API model. + final firestore_v1.ExplainStats? explainStats; + + /// The number of results in this snapshot. + int get size => results.length; + + /// Whether this snapshot contains no results. + bool get empty => results.isEmpty; +} + +/// Base class for Firestore Pipeline expressions. +@immutable +sealed class PipelineExpression { + const PipelineExpression(); + + firestore_v1.Value _toValue(Firestore firestore); + + /// Assigns [alias] to this expression for projection-style stages. + PipelineAliasedExpression alias(String alias) { + return PipelineAliasedExpression._(this, alias); + } + + /// Assigns [alias] to this expression for projection-style stages. + PipelineAliasedExpression as(String alias) => this.alias(alias); + + /// Treats this expression as a boolean expression. + PipelineBooleanExpression asBoolean() => _PipelineBooleanCastExpression(this); + + /// Creates an equality expression. + PipelineBooleanExpression equal(Object? other) { + return _PipelineBooleanExpression('equal', [this, other]); + } + + /// Creates an equality expression against a literal value. + PipelineBooleanExpression equalValue(Object? value) => equal(value); + + /// Creates a not-equal expression. + PipelineBooleanExpression notEqual(Object? other) { + return _PipelineBooleanExpression('not_equal', [this, other]); + } + + /// Creates a not-equal expression against a literal value. + PipelineBooleanExpression notEqualValue(Object? value) => notEqual(value); + + /// Creates a less-than expression. + PipelineBooleanExpression lessThan(Object? other) { + return _PipelineBooleanExpression('less_than', [this, other]); + } + + /// Creates a less-than expression against a literal value. + PipelineBooleanExpression lessThanValue(Object? value) => lessThan(value); + + /// Creates a less-than-or-equal expression. + PipelineBooleanExpression lessThanOrEqual(Object? other) { + return _PipelineBooleanExpression('less_than_or_equal', [this, other]); + } + + /// Creates a less-than-or-equal expression against a literal value. + PipelineBooleanExpression lessThanOrEqualValue(Object? value) { + return lessThanOrEqual(value); + } + + /// Creates a greater-than expression. + PipelineBooleanExpression greaterThan(Object? other) { + return _PipelineBooleanExpression('greater_than', [this, other]); + } + + /// Creates a greater-than expression against a literal value. + PipelineBooleanExpression greaterThanValue(Object? value) => + greaterThan(value); + + /// Creates a greater-than-or-equal expression. + PipelineBooleanExpression greaterThanOrEqual(Object? other) { + return _PipelineBooleanExpression('greater_than_or_equal', [this, other]); + } + + /// Creates a greater-than-or-equal expression against a literal value. + PipelineBooleanExpression greaterThanOrEqualValue(Object? value) { + return greaterThanOrEqual(value); + } + + /// Creates an addition expression. + PipelineExpression add(Object? other) { + return _PipelineFunctionExpression('add', [this, other], const {}); + } + + /// Adds a numeric literal to this expression. + PipelineExpression addNumber(num other) => add(other); + + /// Creates a subtraction expression. + PipelineExpression subtract(Object? other) { + return _PipelineFunctionExpression('subtract', [this, other], const {}); + } + + /// Subtracts a numeric literal from this expression. + PipelineExpression subtractNumber(num other) => subtract(other); + + /// Creates a multiplication expression. + PipelineExpression multiply(Object? other) { + return _PipelineFunctionExpression('multiply', [this, other], const {}); + } + + /// Multiplies this expression by a numeric literal. + PipelineExpression multiplyNumber(num other) => multiply(other); + + /// Creates a division expression. + PipelineExpression divide(Object? other) { + return _PipelineFunctionExpression('divide', [this, other], const {}); + } + + /// Divides this expression by a numeric literal. + PipelineExpression divideNumber(num other) => divide(other); + + /// Returns the absolute value of this expression. + PipelineExpression abs() => PipelineFunctions.abs(this); + + /// Returns the modulo of this expression and [other]. + PipelineExpression modulo(Object? other) => + PipelineFunctions.mod(this, other); + + /// Returns the modulo of this expression and a numeric literal. + PipelineExpression moduloNumber(num other) => modulo(other); + + /// Returns the ceiling of this expression. + PipelineExpression ceil() => PipelineFunctions.ceil(this); + + /// Returns the floor of this expression. + PipelineExpression floor() => PipelineFunctions.floor(this); + + /// Returns the rounded value of this expression. + PipelineExpression round() => PipelineFunctions.round(this); + + /// Truncates this expression. + PipelineExpression trunc([Object? decimals]) { + return decimals == null + ? PipelineFunctions.trunc(this) + : PipelineFunctions.raw('trunc', [this, decimals]); + } + + /// Returns the square root of this expression. + PipelineExpression sqrt() => PipelineFunctions.sqrt(this); + + /// Creates a count aggregate from this expression. + PipelineExpression count() => PipelineFunctions.count(this); + + /// Creates a count distinct aggregate from this expression. + PipelineExpression countDistinct() => PipelineFunctions.countDistinct(this); + + /// Creates a sum aggregate from this expression. + PipelineExpression sum() => PipelineFunctions.sum(this); + + /// Creates an average aggregate from this expression. + PipelineExpression average() => PipelineFunctions.average(this); + + /// Creates a minimum aggregate from this expression. + PipelineExpression minimum() => PipelineFunctions.minimum(this); + + /// Creates a maximum aggregate from this expression. + PipelineExpression maximum() => PipelineFunctions.maximum(this); + + /// Creates a first aggregate from this expression. + PipelineExpression first() => PipelineFunctions.first(this); + + /// Creates a last aggregate from this expression. + PipelineExpression last() => PipelineFunctions.last(this); + + /// Creates an array aggregation from this expression. + PipelineExpression arrayAgg() => PipelineFunctions.arrayAgg(this); + + /// Creates a distinct array aggregation from this expression. + PipelineExpression arrayAggDistinct() { + return PipelineFunctions.arrayAggDistinct(this); + } + + /// Concatenates this array expression with [secondArray]. + PipelineExpression arrayConcat(Object? secondArray) { + return PipelineFunctions.arrayConcat([this, secondArray]); + } + + /// Concatenates this array expression with [otherArrays]. + PipelineExpression arrayConcatMultiple(Iterable otherArrays) { + return PipelineFunctions.arrayConcat([this, ...otherArrays]); + } + + /// Checks if this array contains [element]. + PipelineBooleanExpression arrayContainsValue(Object? element) { + return PipelineFunctions.arrayContains(this, element); + } + + /// Checks if this array contains [element]. + PipelineBooleanExpression arrayContainsElement(Object? element) { + return PipelineFunctions.arrayContains(this, element); + } + + /// Checks if this array contains all [values]. + PipelineBooleanExpression arrayContainsAll(Iterable values) { + return PipelineFunctions.arrayContainsAll( + this, + PipelineFunctions.array(values), + ); + } + + /// Checks if this array contains all values from [arrayExpression]. + PipelineBooleanExpression arrayContainsAllFrom(Object? arrayExpression) { + return PipelineFunctions.arrayContainsAll(this, arrayExpression); + } + + /// Checks if this array contains any [values]. + PipelineBooleanExpression arrayContainsAny(Iterable values) { + return PipelineFunctions.arrayContainsAny( + this, + PipelineFunctions.array(values), + ); + } + + /// Filters this array expression. + PipelineExpression arrayFilter( + String alias, + PipelineBooleanExpression filter, + ) { + return PipelineFunctions.arrayFilter(this, alias, filter); + } + + /// Returns the first element of this array expression. + PipelineExpression arrayFirst() => PipelineFunctions.arrayFirst(this); + + /// Returns the first [n] elements of this array expression. + PipelineExpression arrayFirstN(Object? n) => + PipelineFunctions.arrayFirstN(this, n); + + /// Returns the index of [element]. + PipelineExpression arrayIndexOf(Object? element) { + return PipelineFunctions.arrayIndexOf(this, element); + } + + /// Returns all indexes of [element]. + PipelineExpression arrayIndexOfAll(Object? element) { + return PipelineFunctions.arrayIndexOfAll(this, element); + } + + /// Returns the last element of this array expression. + PipelineExpression arrayLast() => PipelineFunctions.arrayLast(this); + + /// Returns the last [n] elements of this array expression. + PipelineExpression arrayLastN(Object? n) => + PipelineFunctions.arrayLastN(this, n); + + /// Returns the last index of [element]. + PipelineExpression arrayLastIndexOf(Object? element) { + return PipelineFunctions.arrayLastIndexOf(this, element); + } + + /// Returns the length of this array expression. + PipelineExpression arrayLength() => PipelineFunctions.arrayLength(this); + + /// Returns the maximum element of this array expression. + PipelineExpression arrayMaximum() => PipelineFunctions.maximum(this); + + /// Returns the largest [n] elements of this array expression. + PipelineExpression arrayMaximumN(Object? n) => + PipelineFunctions.maximumN(this, n); + + /// Returns the minimum element of this array expression. + PipelineExpression arrayMinimum() => PipelineFunctions.minimum(this); + + /// Returns the smallest [n] elements of this array expression. + PipelineExpression arrayMinimumN(Object? n) => + PipelineFunctions.minimumN(this, n); + + /// Reverses this array expression. + PipelineExpression arrayReverse() => PipelineFunctions.arrayReverse(this); + + /// Returns a slice of this array expression. + PipelineExpression arraySlice(Object? offset, [Object? length]) { + return length == null + ? PipelineFunctions.raw('array_slice', [this, offset]) + : PipelineFunctions.arraySlice(this, offset, length); + } + + /// Returns the sum of numeric elements in this array expression. + PipelineExpression arraySum() => PipelineFunctions.sum(this); + + /// Transforms this array expression. + PipelineExpression arrayTransform(String elementAlias, Object? transform) { + return PipelineFunctions.arrayTransform(this, elementAlias, transform); + } + + /// Transforms this array expression with element and index aliases. + PipelineExpression arrayTransformWithIndex( + String elementAlias, + String indexAlias, + Object? transform, + ) { + return PipelineFunctions.arrayTransform( + this, + elementAlias, + transform, + indexAlias, + ); + } + + /// Checks if this expression exists. + PipelineBooleanExpression exists() => PipelineFunctions.exists(this); + + /// Checks if this expression is absent. + PipelineBooleanExpression isAbsent() => PipelineFunctions.isAbsent(this); + + /// Replaces absent values with [elseValue]. + PipelineExpression ifAbsentValue(Object? elseValue) { + return PipelineFunctions.ifAbsent(this, elseValue); + } + + /// Replaces absent values with [elseExpr]. + PipelineExpression ifAbsent(Object? elseExpr) { + return PipelineFunctions.ifAbsent(this, elseExpr); + } + + /// Checks if this expression errors. + PipelineBooleanExpression isError() => PipelineFunctions.isError(this); + + /// Replaces errors with [catchValue]. + PipelineExpression ifErrorValue(Object? catchValue) { + return PipelineFunctions.ifError(this, catchValue); + } + + /// Replaces errors with [catchExpr]. + PipelineExpression ifError(Object? catchExpr) { + return PipelineFunctions.ifError(this, catchExpr); + } + + /// Returns the collection ID from this reference expression. + PipelineExpression collectionId() => PipelineFunctions.collectionId(this); + + /// Returns the document ID from this reference expression. + PipelineExpression documentId() => PipelineFunctions.documentId(this); + + /// Returns the parent reference from this reference expression. + PipelineExpression parent() => PipelineFunctions.parent(this); + + /// Returns a reference slice from this reference expression. + PipelineExpression referenceSlice(Object? offset, Object? length) { + return PipelineFunctions.referenceSlice(this, offset, length); + } + + /// Gets a map value by [key]. + PipelineExpression mapGet(Object? key) => PipelineFunctions.mapGet(this, key); + + /// Gets a map value by literal [key]. + PipelineExpression mapGetLiteral(String key) => mapGet(key); + + /// Sets key/value pairs on this map expression. + PipelineExpression mapSet( + Object? key, + Object? value, [ + Iterable moreKeyValues = const [], + ]) { + return PipelineFunctions.mapSet(this, [key, value, ...moreKeyValues]); + } + + /// Returns this map expression's entries. + PipelineExpression mapEntries() => PipelineFunctions.mapEntries(this); + + /// Removes [keys] from this map expression. + PipelineExpression mapRemove(Iterable keys) { + return PipelineFunctions.mapRemove(this, keys); + } + + /// Merges this map expression with [maps]. + PipelineExpression mapMerge(Iterable maps) { + return PipelineFunctions.mapMerge([this, ...maps]); + } + + /// Returns this map expression's keys. + PipelineExpression mapKeys() => PipelineFunctions.mapKeys(this); + + /// Returns this map expression's values. + PipelineExpression mapValues() => PipelineFunctions.mapValues(this); + + /// Joins this array expression with [delimiter]. + PipelineExpression join(Object? delimiter) => + PipelineFunctions.join(this, delimiter); + + /// Joins this array expression with literal [delimiter]. + PipelineExpression joinLiteral(String delimiter) => join(delimiter); + + /// Returns the byte length of this string/bytes expression. + PipelineExpression byteLength() => PipelineFunctions.byteLength(this); + + /// Returns the character length of this string expression. + PipelineExpression length() => PipelineFunctions.charLength(this); + + /// Concatenates this string expression with [others]. + PipelineExpression concat(Iterable others) { + return PipelineFunctions.stringConcat([this, ...others]); + } + + /// Converts this string expression to lowercase. + PipelineExpression toLowerCase() => PipelineFunctions.toLower(this); + + /// Converts this string expression to uppercase. + PipelineExpression toUpperCase() => PipelineFunctions.toUpper(this); + + /// Trims this string expression. + PipelineExpression trim([Object? valueToTrim]) { + return PipelineFunctions.trim(this, valueToTrim); + } + + /// Trims leading characters from this string expression. + PipelineExpression ltrim([Object? valueToTrim]) { + return PipelineFunctions.ltrim(this, valueToTrim); + } + + /// Trims trailing characters from this string expression. + PipelineExpression rtrim([Object? valueToTrim]) { + return PipelineFunctions.rtrim(this, valueToTrim); + } + + /// Splits this string expression with [delimiter]. + PipelineExpression split(Object? delimiter) => + PipelineFunctions.split(this, delimiter); + + /// Splits this string expression with literal [delimiter]. + PipelineExpression splitLiteral(String delimiter) => split(delimiter); + + /// Returns the index of [search] in this string expression. + PipelineExpression stringIndexOf(Object? search) { + return PipelineFunctions.stringIndexOf(this, search); + } + + /// Repeats this string expression [repetitions] times. + PipelineExpression stringRepeat(Object? repetitions) { + return PipelineFunctions.stringRepeat(this, repetitions); + } + + /// Replaces all occurrences of [find] with [replacement]. + PipelineExpression stringReplaceAll(Object? find, Object? replacement) { + return PipelineFunctions.stringReplaceAll(this, find, replacement); + } + + /// Replaces all occurrences of literal [find] with [replacement]. + PipelineExpression stringReplaceAllLiteral(String find, String replacement) { + return stringReplaceAll(find, replacement); + } + + /// Replaces one occurrence of [find] with [replacement]. + PipelineExpression stringReplaceOne(Object? find, Object? replacement) { + return PipelineFunctions.stringReplaceOne(this, find, replacement); + } + + /// Replaces one occurrence of literal [find] with [replacement]. + PipelineExpression stringReplaceOneLiteral(String find, String replacement) { + return stringReplaceOne(find, replacement); + } + + /// Extracts a substring from this string expression. + PipelineExpression substring(Object? start, Object? end) { + return PipelineFunctions.substring(this, start, end); + } + + /// Extracts a substring from this string expression. + PipelineExpression substringLiteral(int start, int end) { + return substring(start, end); + } + + /// Checks if this string expression starts with [prefix]. + PipelineBooleanExpression startsWith(Object? prefix) { + return PipelineFunctions.startsWith(this, prefix); + } + + /// Checks if this string expression ends with [postfix]. + PipelineBooleanExpression endsWith(Object? postfix) { + return PipelineFunctions.endsWith(this, postfix); + } + + /// Performs a wildcard match. + PipelineBooleanExpression like(Object? pattern) => + PipelineFunctions.like(this, pattern); + + /// Performs a regex contains check. + PipelineBooleanExpression regexContains(Object? pattern) { + return PipelineFunctions.regexContains(this, pattern); + } + + /// Performs a regex match check. + PipelineBooleanExpression regexMatch(Object? pattern) { + return PipelineFunctions.regexMatch(this, pattern); + } + + /// Returns the first regex match of [pattern]. + PipelineExpression regexFind(Object? pattern) { + return PipelineFunctions.regexFind(this, pattern); + } + + /// Returns all regex matches of [pattern]. + PipelineExpression regexFindAll(Object? pattern) { + return PipelineFunctions.regexFindAll(this, pattern); + } + + /// Checks if this string expression contains [substring]. + PipelineBooleanExpression stringContains(Object? substring) { + return PipelineFunctions.stringContains(this, substring); + } + + /// Truncates this timestamp expression. + PipelineExpression timestampTrunc(Object? granularity, [Object? timezone]) { + return PipelineFunctions.timestampTrunc(this, granularity, timezone); + } + + /// Adds a timestamp duration to this expression. + PipelineExpression timestampAdd(Object? unit, Object? amount) { + return PipelineFunctions.timestampAdd(this, unit, amount); + } + + /// Subtracts a timestamp duration from this expression. + PipelineExpression timestampSubtract(Object? unit, Object? amount) { + return PipelineFunctions.timestampSubtract(this, unit, amount); + } + + /// Converts this timestamp expression to Unix micros. + PipelineExpression timestampToUnixMicros() { + return PipelineFunctions.timestampToUnixMicros(this); + } + + /// Converts this timestamp expression to Unix millis. + PipelineExpression timestampToUnixMillis() { + return PipelineFunctions.timestampToUnixMillis(this); + } + + /// Converts this timestamp expression to Unix seconds. + PipelineExpression timestampToUnixSeconds() { + return PipelineFunctions.timestampToUnixSeconds(this); + } + + /// Returns the timestamp difference between this expression and [start]. + PipelineExpression timestampDiff(Object? start, Object? unit) { + return PipelineFunctions.timestampDiff(this, start, unit); + } + + /// Extracts [part] from this timestamp expression. + PipelineExpression timestampExtract(Object? part, [Object? timezone]) { + return PipelineFunctions.timestampExtract(this, part, timezone); + } + + /// Returns the type of this expression. + PipelineExpression type() => PipelineFunctions.type(this); + + /// Checks the backend type of this expression. + PipelineBooleanExpression isType(Object? valueType) { + return PipelineFunctions.isType(this, valueType); + } + + /// Computes cosine distance between this vector and [other]. + PipelineExpression cosineDistance(Object? other) { + return PipelineFunctions.cosineDistance(this, other); + } + + /// Computes dot product between this vector and [other]. + PipelineExpression dotProduct(Object? other) { + return PipelineFunctions.dotProduct(this, other); + } + + /// Computes Euclidean distance between this vector and [other]. + PipelineExpression euclideanDistance(Object? other) { + return PipelineFunctions.euclideanDistance(this, other); + } + + /// Returns this vector expression's length. + PipelineExpression vectorLength() => PipelineFunctions.vectorLength(this); + + /// Creates an ascending ordering for this expression. + PipelineOrdering ascending() => PipelineOrdering._('ascending', this); + + /// Creates a descending ordering for this expression. + PipelineOrdering descending() => PipelineOrdering._('descending', this); +} + +/// A Pipeline boolean expression. +@immutable +sealed class PipelineBooleanExpression extends PipelineExpression { + const PipelineBooleanExpression(); +} + +/// A Pipeline field reference. +@immutable +final class PipelineField extends PipelineExpression { + const PipelineField._(this.path); + + /// The field path referenced by this expression. + final String path; + + /// Creates an ascending ordering for this field. + @override + PipelineOrdering ascending() => PipelineOrdering._('ascending', this); + + /// Creates a descending ordering for this field. + @override + PipelineOrdering descending() => PipelineOrdering._('descending', this); + + @override + firestore_v1.Value _toValue(Firestore firestore) { + return firestore_v1.Value(fieldReferenceValue: path); + } +} + +/// A Pipeline expression with an alias. +@immutable +final class PipelineAliasedExpression extends PipelineExpression { + const PipelineAliasedExpression._(this.expression, this.name); + + /// The expression being aliased. + final PipelineExpression expression; + + /// The alias name. + final String name; + + @override + firestore_v1.Value _toValue(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function$( + name: 'alias', + args: [ + expression._toValue(firestore), + firestore_v1.Value(stringValue: name), + ], + ), + ); + } +} + +/// A Pipeline ordering. +@immutable +final class PipelineOrdering { + const PipelineOrdering._(this._name, this._expression); + + final String _name; + final Object _expression; + + firestore_v1.Value _toValue(Firestore firestore) { + return firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + 'expression': _encodePipelineValue(_expression, firestore), + 'direction': firestore_v1.Value(stringValue: _name), + }, + ), + ); + } +} + +final class _PipelineConstant extends PipelineExpression { + const _PipelineConstant(this.value); + + final Object? value; + + @override + firestore_v1.Value _toValue(Firestore firestore) { + return _encodeLiteralValue(value, firestore); + } +} + +final class _PipelineVariable extends PipelineExpression { + const _PipelineVariable(this.name); + + final String name; + + @override + firestore_v1.Value _toValue(Firestore firestore) { + return firestore_v1.Value(variableReferenceValue: name); + } +} + +final class _PipelineFunctionExpression extends PipelineExpression { + const _PipelineFunctionExpression(this.name, this.args, this.options); + + final String name; + final List args; + final Map options; + + @override + firestore_v1.Value _toValue(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function$( + name: name, + args: [for (final arg in args) _encodePipelineValue(arg, firestore)], + options: _encodeOptions(options, firestore), + ), + ); + } +} + +final class _PipelineBooleanExpression extends PipelineBooleanExpression { + const _PipelineBooleanExpression(this.name, this.args); + + final String name; + final List args; + + @override + firestore_v1.Value _toValue(Firestore firestore) { + return firestore_v1.Value( + functionValue: firestore_v1.Function$( + name: name, + args: [for (final arg in args) _encodePipelineValue(arg, firestore)], + ), + ); + } +} + +final class _PipelineBooleanCastExpression extends PipelineBooleanExpression { + const _PipelineBooleanCastExpression(this.expression); + + final PipelineExpression expression; + + @override + firestore_v1.Value _toValue(Firestore firestore) { + return expression._toValue(firestore); + } +} + +final class _PipelineStage { + const _PipelineStage(this.name, this.args, [this.options = const {}]); + + final String name; + final List args; + final Map options; + + firestore_v1.Pipeline_Stage _toProto(Firestore firestore) { + return firestore_v1.Pipeline_Stage( + name: name, + args: [for (final arg in args) _encodePipelineValue(arg, firestore)], + options: _encodeOptions(options, firestore), + ); + } +} + +Map _encodeOptions( + Map options, + Firestore firestore, +) { + return { + for (final entry in options.entries) + entry.key: _encodePipelineValue(entry.value, firestore), + }; +} + +firestore_v1.Value _encodePipelineValue(Object? value, Firestore firestore) { + switch (value) { + case PipelineOrdering(): + return value._toValue(firestore); + case PipelineExpression(): + return value._toValue(firestore); + case Pipeline(): + return firestore_v1.Value(pipelineValue: value._toProto()); + case PipelineValueType(): + return firestore_v1.Value(stringValue: value.value); + case Uint8List(): + return _encodeLiteralValue(value, firestore); + case Iterable(): + return firestore_v1.Value( + arrayValue: firestore_v1.ArrayValue( + values: [ + for (final item in value) _encodePipelineValue(item, firestore), + ], + ), + ); + case Map(): + return firestore_v1.Value( + mapValue: firestore_v1.MapValue( + fields: { + for (final entry in value.entries) + entry.key.toString(): _encodePipelineValue( + entry.value, + firestore, + ), + }, + ), + ); + case String(): + return firestore_v1.Value(stringValue: value); + case DocumentReference(): + return firestore_v1.Value(referenceValue: value._formattedName); + case CollectionReference(): + return firestore_v1.Value(referenceValue: '/${value.path}'); + default: + return _encodeLiteralValue(value, firestore); + } +} + +firestore_v1.Value _encodeLiteralValue(Object? value, Firestore firestore) { + final encoded = firestore._serializer.encodeValue(value); + if (encoded == null) { + throw ArgumentError.value(value, 'value', 'Unsupported Pipeline value.'); + } + return encoded; +} + +List _optionalArg(Object? value) { + return value == null ? const [] : [value]; +} + +Map _compactOptions(Map options) { + return Map.fromEntries(options.entries.where((entry) => entry.value != null)); +} + +Map _projectionMap(Iterable selections) { + return Map.fromEntries(selections.map(_projectionEntry)); +} + +MapEntry _projectionEntry(Object selection) { + return switch (selection) { + String() => MapEntry(selection, field(selection)), + PipelineField() => MapEntry(selection.path, selection), + PipelineAliasedExpression() => MapEntry( + selection.name, + selection.expression, + ), + PipelineExpression() => throw ArgumentError.value( + selection, + 'selections', + 'Computed Pipeline expressions must be aliased before select().', + ), + _ => throw ArgumentError.value( + selection, + 'selections', + 'Expected a String, PipelineField, or PipelineAliasedExpression.', + ), + }; +} diff --git a/packages/google_cloud_firestore/lib/src/reference/vector_query_options.dart b/packages/google_cloud_firestore/lib/src/reference/vector_query_options.dart index 5ccbb370..a9088714 100644 --- a/packages/google_cloud_firestore/lib/src/reference/vector_query_options.dart +++ b/packages/google_cloud_firestore/lib/src/reference/vector_query_options.dart @@ -18,15 +18,15 @@ part of '../firestore.dart'; enum DistanceMeasure { /// Euclidean distance - straight-line distance between vectors. /// Good for spatial data. - euclidean('EUCLIDEAN'), + euclidean('euclidean'), /// Cosine distance - measures the angle between vectors. /// Good for text embeddings where magnitude doesn't matter. - cosine('COSINE'), + cosine('cosine'), /// Dot product distance - inner product of vectors. /// Good for normalized vectors. - dotProduct('DOT_PRODUCT'); + dotProduct('dot_product'); const DistanceMeasure(this.value); diff --git a/packages/google_cloud_firestore/test/e2e/README.md b/packages/google_cloud_firestore/test/e2e/README.md new file mode 100644 index 00000000..2b688c06 --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/README.md @@ -0,0 +1,44 @@ +# Firestore Pipeline E2E Tests + +These tests run against a real Firebase/Google Cloud project and are skipped by +default. + +Required environment: + +```sh +export FIRESTORE_PIPELINE_E2E_PROJECT_ID="your-project-id" +export FIRESTORE_PIPELINE_E2E_DATABASE_ID="your-enterprise-database-id" +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" +dart test test/e2e/pipeline_e2e_test.dart +``` + +The target database must support Firestore Pipelines. The service account needs +permission to create, read, query, and delete Firestore documents. + +The suite writes temporary documents, executes Pipeline queries against them, +and deletes the documents in teardown. It covers source stages, projection, +sorting, aggregates, result metadata, vector search, and the supported +expression helpers. + +Vector nearest-neighbor coverage requires this vector index: + +```sh +gcloud firestore indexes composite create \ + --project="your-project-id" \ + --database="your-enterprise-database-id" \ + --collection-group="pipeline_e2e_books" \ + --query-scope=COLLECTION \ + --field-config=field-path="runId",order=ASCENDING \ + --field-config=field-path="embedding",vector-config='{"dimension":"3","flat":"{}"}' +``` + +The vector index is part of the expected CI setup. The E2E suite uses the stable +`pipeline_e2e_books` collection group and unique per-run document IDs so the +index can be created once and reused. + +For CI, store the service account JSON as a secret, write it to a temporary file, +set `GOOGLE_APPLICATION_CREDENTIALS` to that file path, and run: + +```sh +dart test test/e2e/pipeline_e2e_test.dart +``` diff --git a/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart new file mode 100644 index 00000000..04dcf13d --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart @@ -0,0 +1,895 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +const _projectIdEnv = 'FIRESTORE_PIPELINE_E2E_PROJECT_ID'; +const _databaseIdEnv = 'FIRESTORE_PIPELINE_E2E_DATABASE_ID'; +const _collectionPath = 'pipeline_e2e_books'; + +void main() { + final projectId = + Platform.environment[_projectIdEnv] ?? + Platform.environment['GOOGLE_CLOUD_PROJECT'] ?? + Platform.environment['GCLOUD_PROJECT']; + final databaseId = Platform.environment[_databaseIdEnv] ?? '(default)'; + final shouldRun = projectId != null && projectId.isNotEmpty; + + group('Firestore Pipeline E2E', skip: shouldRun ? null : _skipReason, () { + late Firestore firestore; + late String runId; + late List> docs; + + setUp(() async { + firestore = Firestore( + settings: Settings(projectId: projectId, databaseId: databaseId), + ); + runId = 'run_${DateTime.now().microsecondsSinceEpoch}'; + docs = [ + firestore.doc('$_collectionPath/${runId}_book_1'), + firestore.doc('$_collectionPath/${runId}_book_2'), + firestore.doc('$_collectionPath/${runId}_book_3'), + ]; + + await Future.wait([ + docs[0].set({ + 'runId': runId, + 'title': 'Dart Pipelines', + 'active': true, + 'price': 10, + 'rating': 5, + 'discount': 2, + 'score': -12.7, + 'flags': 6, + 'bytes': Uint8List.fromList([0x0f, 0xf0]), + 'tags': ['dart', 'firebase'], + 'numbers': [3, 1, 2, 3], + 'words': ['dart', 'firebase'], + 'metadata': {'lang': 'dart', 'category': 'sdk'}, + 'pathRef': docs[1], + 'nullable': null, + 'spaced': ' Dart ', + 'csv': 'dart,firebase,admin', + 'createdAt': Timestamp(seconds: 1700000000, nanoseconds: 0), + 'embedding': FieldValue.vector([1, 0, 0]), + }), + docs[1].set({ + 'runId': runId, + 'title': 'Firestore Admin', + 'active': true, + 'price': 20, + 'rating': 4, + 'discount': 3, + 'score': 3.2, + 'flags': 3, + 'bytes': Uint8List.fromList([0xaa, 0x55]), + 'tags': ['firebase'], + 'numbers': [4, 5], + 'words': ['firebase'], + 'metadata': {'lang': 'dart', 'category': 'admin'}, + 'pathRef': docs[0], + 'nullable': null, + 'spaced': ' Admin ', + 'csv': 'firestore,admin', + 'createdAt': Timestamp(seconds: 1700003600, nanoseconds: 0), + 'embedding': FieldValue.vector([0, 1, 0]), + }), + docs[2].set({ + 'runId': runId, + 'title': 'Inactive Draft', + 'active': false, + 'price': 30, + 'rating': 2, + 'discount': 4, + 'score': 8.9, + 'flags': 5, + 'bytes': Uint8List.fromList([0xff, 0x00]), + 'tags': ['draft'], + 'numbers': [9], + 'words': ['draft'], + 'metadata': {'lang': 'dart', 'category': 'draft'}, + 'pathRef': docs[0], + 'nullable': null, + 'spaced': ' Draft ', + 'csv': 'inactive,draft', + 'createdAt': Timestamp(seconds: 1700007200, nanoseconds: 0), + 'embedding': FieldValue.vector([0, 0, 1]), + }), + ]); + }); + + tearDown(() async { + await Future.wait([for (final doc in docs) doc.delete()]); + }); + + test('executes a real pipeline with expressions and metadata', () async { + final snapshot = await firestore + .pipeline() + .collection(_collectionPath) + .where(_runFilter(runId, Expression.field('active').asBoolean())) + .sort([Expression.field('price').ascending()]) + .select([ + Expression.field('title'), + Expression.field('price'), + Expression.field('title').toUpperCase().as('upperTitle'), + Expression.field('tags').arrayLength().as('tagCount'), + Expression.field( + 'metadata', + ).mapGetLiteral('category').as('category'), + Expression.field( + 'createdAt', + ).timestampToUnixSeconds().as('createdSeconds'), + ]) + .limit(2) + .execute(); + + expect(snapshot.results, hasLength(2)); + expect(snapshot.executionTime, isNotNull); + expect(snapshot.results.first.get('title'), 'Dart Pipelines'); + expect(snapshot.results.first.get('price'), 10); + expect(snapshot.results.first.get('upperTitle'), 'DART PIPELINES'); + expect(snapshot.results.first.get('tagCount'), 2); + expect(snapshot.results.first.get('category'), 'sdk'); + + final metadataSnapshot = await firestore + .pipeline() + .collection(_collectionPath) + .where( + _runFilter( + runId, + Expression.field('title').equalValue('Dart Pipelines'), + ), + ) + .limit(1) + .execute(); + + expect(metadataSnapshot.results.single.createTime, isNotNull); + expect(metadataSnapshot.results.single.updateTime, isNotNull); + expect(metadataSnapshot.results.single.document, isNotNull); + }); + + test('executes aggregate pipeline stages', () async { + final aggregateSnapshot = await firestore + .pipeline() + .collection(_collectionPath) + .where(_runFilter(runId, Expression.field('active').equalValue(true))) + .aggregate([ + Expression.field('price').sum().as('totalPrice'), + Expression.field('rating').average().as('averageRating'), + Expression.field('title').count().as('bookCount'), + ]) + .execute(); + + expect(aggregateSnapshot.results, hasLength(1)); + expect(aggregateSnapshot.results.single.get('totalPrice'), 30); + expect(aggregateSnapshot.results.single.get('averageRating'), 4.5); + expect(aggregateSnapshot.results.single.get('bookCount'), 2); + }); + + group('function catalog', () { + for (final scenario in _functionScenarios) { + test(scenario.name, () async { + final snapshot = await firestore + .pipeline() + .collection(_collectionPath) + .where( + _runFilter( + runId, + Expression.field('title').equalValue('Dart Pipelines'), + ), + ) + .select([ + for (final expectation in scenario.expectations) + expectation.expression.as(expectation.alias), + ]) + .limit(1) + .execute(); + + expect(snapshot.results, hasLength(1)); + final result = snapshot.results.single; + for (final expectation in scenario.expectations) { + _expectPipelineValue( + result.get(expectation.alias), + expectation.expected, + reason: '${scenario.name}.${expectation.alias}', + ); + } + }); + } + }); + + group('aggregate function catalog', () { + for (final scenario in _aggregateScenarios) { + test(scenario.name, () async { + final snapshot = await firestore + .pipeline() + .collection(_collectionPath) + .where(Expression.field('runId').equalValue(runId)) + .aggregate([ + for (final expectation in scenario.expectations) + expectation.expression.as(expectation.alias), + ]) + .execute(); + + expect(snapshot.results, hasLength(1)); + final result = snapshot.results.single; + for (final expectation in scenario.expectations) { + _expectPipelineValue( + result.get(expectation.alias), + expectation.expected, + reason: '${scenario.name}.${expectation.alias}', + ); + } + }); + } + }); + + test('executes vector nearest-neighbor stage', () async { + final vectorSnapshot = await firestore + .pipeline() + .collection(_collectionPath) + .where(Expression.field('runId').equalValue(runId)) + .findNearest( + vectorField: 'embedding', + queryVector: Expression.vector([1, 0, 0]), + distanceMeasure: DistanceMeasure.cosine, + limit: 1, + distanceResultField: 'distance', + ) + .execute(); + + expect(vectorSnapshot.results, hasLength(1)); + expect(vectorSnapshot.results.single.get('title'), 'Dart Pipelines'); + expect(vectorSnapshot.results.single.get('distance'), isNotNull); + }); + }); +} + +const _skipReason = + 'Set FIRESTORE_PIPELINE_E2E_PROJECT_ID and optionally ' + 'FIRESTORE_PIPELINE_E2E_DATABASE_ID to run against a real Firebase project. ' + 'CI should authenticate with Application Default Credentials, for example ' + 'by setting GOOGLE_APPLICATION_CREDENTIALS to a service account JSON file.'; + +PipelineBooleanExpression _runFilter( + String runId, [ + PipelineBooleanExpression? condition, +]) { + return PipelineFunctions.and([ + Expression.field('runId').equalValue(runId), + ?condition, + ]); +} + +final _aggregateScenarios = <_FunctionScenario>[ + _FunctionScenario('aggregate functions', [ + _FunctionExpectation('count', PipelineFunctions.count(), 3), + _FunctionExpectation( + 'countIf', + PipelineFunctions.countIf(Expression.field('active')), + 2, + ), + _FunctionExpectation( + 'countDistinct', + Expression.field('metadata').mapGetLiteral('lang').countDistinct(), + 1, + ), + _FunctionExpectation('sum', Expression.field('price').sum(), 60), + _FunctionExpectation( + 'average', + Expression.field('rating').average(), + closeTo(11 / 3, 0.0001), + ), + _FunctionExpectation('minimum', Expression.field('price').minimum(), 10), + _FunctionExpectation('maximum', Expression.field('price').maximum(), 30), + _FunctionExpectation( + 'first', + Expression.field('title').first(), + isA(), + ), + _FunctionExpectation( + 'last', + Expression.field('title').last(), + isA(), + ), + _FunctionExpectation( + 'arrayAgg', + Expression.field('title').arrayAgg(), + containsAll(['Dart Pipelines', 'Firestore Admin', 'Inactive Draft']), + ), + _FunctionExpectation( + 'arrayAggDistinct', + Expression.field('metadata').mapGetLiteral('lang').arrayAggDistinct(), + ['dart'], + ), + ]), +]; + +final _functionScenarios = <_FunctionScenario>[ + _FunctionScenario('arithmetic functions', [ + _FunctionExpectation( + 'abs', + Expression.field('score').abs(), + closeTo(12.7, 0.0001), + ), + _FunctionExpectation('add', Expression.field('price').addNumber(2), 12), + _FunctionExpectation( + 'subtract', + Expression.field('price').subtractNumber(3), + 7, + ), + _FunctionExpectation( + 'multiply', + Expression.field('price').multiplyNumber(2), + 20, + ), + _FunctionExpectation( + 'divide', + Expression.field('price').divideNumber(2), + 5, + ), + _FunctionExpectation('mod', Expression.field('price').moduloNumber(4), 2), + _FunctionExpectation('ceil', Expression.constant(12.2).ceil(), 13), + _FunctionExpectation('floor', Expression.constant(12.8).floor(), 12), + _FunctionExpectation('round', Expression.constant(12.6).round(), 13), + _FunctionExpectation('trunc', Expression.constant(12.8).trunc(), 12), + _FunctionExpectation('pow', PipelineFunctions.pow(2, 3), 8), + _FunctionExpectation('sqrt', Expression.constant(9).sqrt(), 3), + _FunctionExpectation( + 'exp', + PipelineFunctions.exp(1), + closeTo(2.71828, 0.001), + ), + _FunctionExpectation( + 'ln', + PipelineFunctions.ln(2.718281828), + closeTo(1, 0.001), + ), + _FunctionExpectation('log', PipelineFunctions.log(8, 2), closeTo(3, 0.001)), + _FunctionExpectation( + 'log10', + PipelineFunctions.log10(100), + closeTo(2, 0.001), + ), + _FunctionExpectation('rand', PipelineFunctions.rand(), isA()), + ]), + _FunctionScenario('array functions', [ + _FunctionExpectation('array', Expression.array([1, 2, 3]), [1, 2, 3]), + _FunctionExpectation( + 'arrayConcat', + Expression.field('tags').arrayConcat(['admin']), + ['dart', 'firebase', 'admin'], + ), + _FunctionExpectation( + 'arrayConcatMultiple', + Expression.field('tags').arrayConcatMultiple([ + ['admin'], + ['sdk'], + ]), + ['dart', 'firebase', 'admin', 'sdk'], + ), + _FunctionExpectation( + 'arrayContainsValue', + Expression.field('tags').arrayContainsValue('dart'), + true, + ), + _FunctionExpectation( + 'arrayContainsElement', + Expression.field( + 'tags', + ).arrayContainsElement(Expression.constant('firebase')), + true, + ), + _FunctionExpectation( + 'arrayContainsAll', + Expression.field('tags').arrayContainsAll(['dart', 'firebase']), + true, + ), + _FunctionExpectation( + 'arrayContainsAllFrom', + Expression.field('tags').arrayContainsAllFrom(Expression.array(['dart'])), + true, + ), + _FunctionExpectation( + 'arrayContainsAny', + Expression.field('tags').arrayContainsAny(['missing', 'dart']), + true, + ), + _FunctionExpectation( + 'arrayFilter', + Expression.field('tags').arrayFilter( + 'tag', + Expression.variable('tag').notEqualValue('firebase'), + ), + ['dart'], + ), + _FunctionExpectation( + 'arrayGet', + PipelineFunctions.arrayGet(Expression.field('numbers'), 1), + 1, + ), + _FunctionExpectation( + 'arrayLength', + Expression.field('numbers').arrayLength(), + 4, + ), + _FunctionExpectation( + 'arrayReverse', + Expression.field('numbers').arrayReverse(), + [3, 2, 1, 3], + ), + _FunctionExpectation( + 'arrayFirst', + Expression.field('numbers').arrayFirst(), + 3, + ), + _FunctionExpectation( + 'arrayFirstN', + Expression.field('numbers').arrayFirstN(2), + [3, 1], + ), + _FunctionExpectation( + 'arrayIndexOf', + Expression.field('numbers').arrayIndexOf(1), + 1, + ), + _FunctionExpectation( + 'arrayIndexOfAll', + Expression.field('numbers').arrayIndexOfAll(3), + [0, 3], + ), + _FunctionExpectation( + 'arrayLast', + Expression.field('numbers').arrayLast(), + 3, + ), + _FunctionExpectation( + 'arrayLastN', + Expression.field('numbers').arrayLastN(2), + [2, 3], + ), + _FunctionExpectation( + 'arrayLastIndexOf', + Expression.field('numbers').arrayLastIndexOf(3), + 3, + ), + _FunctionExpectation( + 'arraySlice', + Expression.field('numbers').arraySlice(1, 2), + [1, 2], + ), + _FunctionExpectation( + 'arrayTransform', + Expression.field( + 'numbers', + ).arrayTransform('n', Expression.variable('n').addNumber(1)), + [4, 2, 3, 4], + ), + _FunctionExpectation( + 'arrayTransformWithIndex', + Expression.field('numbers').arrayTransformWithIndex( + 'n', + 'i', + Expression.variable('n').add(Expression.variable('i')), + ), + [3, 2, 4, 6], + ), + _FunctionExpectation( + 'maximumN', + Expression.field('numbers').arrayMaximumN(2), + [3, 3], + ), + _FunctionExpectation( + 'minimumN', + Expression.field('numbers').arrayMinimumN(2), + [1, 2], + ), + _FunctionExpectation( + 'join', + Expression.field('words').joinLiteral('-'), + 'dart-firebase', + ), + ]), + _FunctionScenario('comparison functions', [ + _FunctionExpectation( + 'equal', + Expression.field('price').equalValue(10), + true, + ), + _FunctionExpectation( + 'notEqual', + Expression.field('price').notEqualValue(11), + true, + ), + _FunctionExpectation( + 'greaterThan', + Expression.field('price').greaterThanValue(9), + true, + ), + _FunctionExpectation( + 'greaterThanOrEqual', + Expression.field('price').greaterThanOrEqualValue(10), + true, + ), + _FunctionExpectation( + 'lessThan', + Expression.field('price').lessThanValue(11), + true, + ), + _FunctionExpectation( + 'lessThanOrEqual', + Expression.field('price').lessThanOrEqualValue(10), + true, + ), + _FunctionExpectation( + 'cmp', + PipelineFunctions.cmp(Expression.field('price'), 10), + 0, + ), + ]), + _FunctionScenario('debugging functions', [ + _FunctionExpectation('exists', Expression.field('title').exists(), true), + _FunctionExpectation( + 'isAbsent', + Expression.field('missing').isAbsent(), + true, + ), + _FunctionExpectation( + 'ifAbsent', + Expression.field('missing').ifAbsentValue('fallback'), + 'fallback', + ), + _FunctionExpectation('isError', Expression.field('title').isError(), false), + _FunctionExpectation( + 'ifError', + Expression.field('title').ifErrorValue('caught'), + 'Dart Pipelines', + ), + ]), + _FunctionScenario('reference functions', [ + _FunctionExpectation( + 'collectionId', + Expression.field('pathRef').collectionId(), + isA(), + ), + _FunctionExpectation( + 'documentId', + Expression.field('pathRef').documentId(), + allOf(startsWith('run_'), endsWith('_book_2')), + ), + _FunctionExpectation( + 'parent', + Expression.field('pathRef').parent(), + isNotNull, + ), + _FunctionExpectation( + 'referenceSlice', + Expression.field('pathRef').referenceSlice(0, 2), + isNotNull, + ), + ]), + _FunctionScenario('logical functions', [ + _FunctionExpectation( + 'and', + PipelineFunctions.and([true, Expression.field('active')]), + true, + ), + _FunctionExpectation( + 'or', + PipelineFunctions.or([false, Expression.field('active')]), + true, + ), + _FunctionExpectation('xor', PipelineFunctions.xor([true, false]), true), + _FunctionExpectation('nor', PipelineFunctions.nor([false, false]), true), + _FunctionExpectation('not', PipelineFunctions.not(false), true), + _FunctionExpectation( + 'conditional', + PipelineFunctions.conditional(Expression.field('active'), 'yes', 'no'), + 'yes', + ), + _FunctionExpectation( + 'ifNull', + PipelineFunctions.ifNull(Expression.field('nullable'), 'fallback'), + 'fallback', + ), + _FunctionExpectation( + 'equalAny', + PipelineFunctions.equalAny('dart', Expression.field('tags')), + true, + ), + _FunctionExpectation( + 'notEqualAny', + PipelineFunctions.notEqualAny('dart', Expression.array(['firebase'])), + true, + ), + ]), + _FunctionScenario('map functions', [ + _FunctionExpectation('map', PipelineFunctions.map(['a', 1, 'b', 2]), { + 'a': 1, + 'b': 2, + }), + _FunctionExpectation( + 'mapGet', + Expression.field('metadata').mapGetLiteral('category'), + 'sdk', + ), + _FunctionExpectation( + 'mapSet', + Expression.field('metadata').mapSet('edition', 'enterprise'), + containsPair('edition', 'enterprise'), + ), + _FunctionExpectation( + 'mapRemove', + Expression.field('metadata').mapRemove(['lang']), + isNot(contains('lang')), + ), + _FunctionExpectation( + 'mapMerge', + Expression.field('metadata').mapMerge([ + {'edition': 'enterprise'}, + ]), + containsPair('edition', 'enterprise'), + ), + _FunctionExpectation( + 'currentDocument', + currentDocument(), + isA>(), + ), + _FunctionExpectation( + 'mapKeys', + Expression.field('metadata').mapKeys(), + containsAll(['lang', 'category']), + ), + _FunctionExpectation( + 'mapValues', + Expression.field('metadata').mapValues(), + containsAll(['dart', 'sdk']), + ), + _FunctionExpectation( + 'mapEntries', + Expression.field('metadata').mapEntries(), + isA>(), + ), + ]), + _FunctionScenario('string functions', [ + _FunctionExpectation( + 'byteLength', + Expression.field('title').byteLength(), + 14, + ), + _FunctionExpectation('charLength', Expression.field('title').length(), 14), + _FunctionExpectation( + 'startsWith', + Expression.field('title').startsWith('Dart'), + true, + ), + _FunctionExpectation( + 'endsWith', + Expression.field('title').endsWith('lines'), + true, + ), + _FunctionExpectation('like', Expression.field('title').like('Dart%'), true), + _FunctionExpectation( + 'regexContains', + Expression.field('title').regexContains('Pipe'), + true, + ), + _FunctionExpectation( + 'regexMatch', + Expression.field('title').regexMatch(r'^Dart.*'), + true, + ), + _FunctionExpectation( + 'regexFind', + Expression.field('title').regexFind('Pipe'), + isNotNull, + ), + _FunctionExpectation( + 'regexFindAll', + Expression.field('title').regexFindAll('[aei]'), + isA>(), + ), + _FunctionExpectation( + 'stringConcat', + Expression.field('title').concat([' v2']), + 'Dart Pipelines v2', + ), + _FunctionExpectation( + 'stringContains', + Expression.field('title').stringContains('Pipeline'), + true, + ), + _FunctionExpectation( + 'stringIndexOf', + Expression.field('title').stringIndexOf('Pipeline'), + 5, + ), + _FunctionExpectation( + 'toUpper', + Expression.field('title').toUpperCase(), + 'DART PIPELINES', + ), + _FunctionExpectation( + 'toLower', + Expression.field('title').toLowerCase(), + 'dart pipelines', + ), + _FunctionExpectation( + 'substring', + Expression.field('title').substringLiteral(0, 4), + 'Dart', + ), + _FunctionExpectation( + 'stringReverse', + PipelineFunctions.stringReverse(Expression.constant('Dart')), + 'traD', + ), + _FunctionExpectation( + 'stringRepeat', + Expression.constant('ha').stringRepeat(3), + 'hahaha', + ), + _FunctionExpectation( + 'stringReplaceAll', + Expression.field('title').stringReplaceAllLiteral('i', 'I'), + 'Dart PIpelInes', + ), + _FunctionExpectation( + 'stringReplaceOne', + Expression.field('title').stringReplaceOneLiteral('i', 'I'), + 'Dart PIpelines', + ), + _FunctionExpectation('trim', Expression.field('spaced').trim(), 'Dart'), + _FunctionExpectation('ltrim', Expression.field('spaced').ltrim(), 'Dart '), + _FunctionExpectation('rtrim', Expression.field('spaced').rtrim(), ' Dart'), + _FunctionExpectation('split', Expression.field('csv').splitLiteral(','), [ + 'dart', + 'firebase', + 'admin', + ]), + ]), + _FunctionScenario('timestamp and type functions', [ + _FunctionExpectation( + 'currentTimestamp', + PipelineFunctions.currentTimestamp(), + isA(), + ), + _FunctionExpectation( + 'timestampTrunc', + Expression.field('createdAt').timestampTrunc('day', 'UTC'), + isA(), + ), + _FunctionExpectation( + 'unixMicrosToTimestamp', + PipelineFunctions.unixMicrosToTimestamp(1700000000000000), + isA(), + ), + _FunctionExpectation( + 'unixMillisToTimestamp', + PipelineFunctions.unixMillisToTimestamp(1700000000000), + isA(), + ), + _FunctionExpectation( + 'unixSecondsToTimestamp', + PipelineFunctions.unixSecondsToTimestamp(1700000000), + isA(), + ), + _FunctionExpectation( + 'timestampAdd', + Expression.field('createdAt').timestampAdd('second', 60), + isA(), + ), + _FunctionExpectation( + 'timestampSubtract', + Expression.field('createdAt').timestampSubtract('second', 60), + isA(), + ), + _FunctionExpectation( + 'timestampToUnixMicros', + Expression.field('createdAt').timestampToUnixMicros(), + 1700000000000000, + ), + _FunctionExpectation( + 'timestampToUnixMillis', + Expression.field('createdAt').timestampToUnixMillis(), + 1700000000000, + ), + _FunctionExpectation( + 'timestampToUnixSeconds', + Expression.field('createdAt').timestampToUnixSeconds(), + 1700000000, + ), + _FunctionExpectation( + 'timestampDiff', + PipelineFunctions.timestampDiff( + Expression.field('createdAt'), + PipelineFunctions.unixSecondsToTimestamp(1699999940), + 'second', + ), + 60, + ), + _FunctionExpectation( + 'timestampExtract', + Expression.field('createdAt').timestampExtract('year', 'UTC'), + 2023, + ), + _FunctionExpectation( + 'type', + Expression.field('price').type(), + isA(), + ), + _FunctionExpectation( + 'isType', + Expression.field('price').isType(PipelineValueType.number), + true, + ), + ]), + _FunctionScenario('vector functions', [ + _FunctionExpectation( + 'cosineDistance', + Expression.field( + 'embedding', + ).cosineDistance(Expression.vector([1, 0, 0])), + closeTo(0, 0.0001), + ), + _FunctionExpectation( + 'dotProduct', + Expression.field('embedding').dotProduct(Expression.vector([1, 0, 0])), + closeTo(1, 0.0001), + ), + _FunctionExpectation( + 'euclideanDistance', + Expression.field( + 'embedding', + ).euclideanDistance(Expression.vector([1, 0, 0])), + closeTo(0, 0.0001), + ), + _FunctionExpectation( + 'vectorLength', + Expression.field('embedding').vectorLength(), + 3, + ), + ]), +]; + +final class _FunctionScenario { + const _FunctionScenario(this.name, this.expectations); + + final String name; + final List<_FunctionExpectation> expectations; +} + +final class _FunctionExpectation { + const _FunctionExpectation(this.alias, this.expression, this.expected); + + final String alias; + final PipelineExpression expression; + final Object? expected; +} + +void _expectPipelineValue( + Object? actual, + Object? expected, { + required String reason, +}) { + if (expected is Matcher) { + expect(actual, expected, reason: reason); + return; + } + expect(actual, expected, reason: reason); +} diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart new file mode 100644 index 00000000..77d06b28 --- /dev/null +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -0,0 +1,486 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:google_cloud_firestore/src/firestore_http_client.dart'; +import 'package:google_cloud_firestore_v1/firestore.dart' as firestore_v1; +import 'package:google_cloud_firestore_v1/testing.dart'; +import 'package:google_cloud_protobuf/protobuf.dart' as protobuf_v1; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +const _projectId = 'test-project'; + +class MockFirestoreHttpClient extends Mock implements FirestoreHttpClient {} + +void main() { + group('Firestore Pipeline', () { + late MockFirestoreHttpClient mockClient; + late Firestore firestore; + + setUp(() { + mockClient = MockFirestoreHttpClient(); + firestore = Firestore.internal( + settings: const Settings( + projectId: _projectId, + databaseId: 'enterprise', + ), + client: mockClient, + ); + + when(() => mockClient.cachedProjectId).thenReturn(_projectId); + }); + + test('serializes common stages and expressions', () async { + firestore_v1.ExecutePipelineRequest? capturedRequest; + + when( + () => + mockClient.v1>(any()), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> + Function(firestore_v1.Firestore api, String projectId); + + final api = FakeFirestore( + executePipeline: (firestore_v1.ExecutePipelineRequest request) { + capturedRequest = request; + return const Stream.empty(); + }, + ); + + return callback(api, _projectId); + }); + + await firestore + .pipeline() + .collection('cities') + .where(Expression.field('population').greaterThanValue(100000)) + .sort([field('name').ascending()]) + .select(['name', field('population')]) + .limit(10) + .execute(); + + final request = capturedRequest!; + expect(request.database, 'projects/$_projectId/databases/enterprise'); + + final stages = request.structuredPipeline!.pipeline!.stages; + expect(stages.map((stage) => stage.name), [ + 'collection', + 'where', + 'sort', + 'select', + 'limit', + ]); + + expect(stages[0].args.single.referenceValue, '/cities'); + + final whereFunction = stages[1].args.single.functionValue!; + expect(whereFunction.name, 'greater_than'); + expect(whereFunction.args[0].fieldReferenceValue, 'population'); + expect(whereFunction.args[1].integerValue, 100000); + + final sortValue = stages[2].args.single.mapValue!; + expect(sortValue.fields['direction']!.stringValue, 'ascending'); + expect(sortValue.fields['expression']!.fieldReferenceValue, 'name'); + + final selectFields = stages[3].args.single.mapValue!.fields; + expect(selectFields['name']!.fieldReferenceValue, 'name'); + expect(selectFields['population']!.fieldReferenceValue, 'population'); + expect(stages[4].args.single.integerValue, 10); + }); + + test('executes and decodes results', () async { + final executionTime = protobuf_v1.Timestamp(seconds: 42); + + when( + () => + mockClient.v1>(any()), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> + Function(firestore_v1.Firestore api, String projectId); + + final api = FakeFirestore( + executePipeline: (_) { + return Stream.fromIterable([ + firestore_v1.ExecutePipelineResponse( + executionTime: executionTime, + results: [ + firestore_v1.Document( + name: + 'projects/$_projectId/databases/enterprise/documents/books/book-1', + fields: { + 'title': firestore.serializer.encodeValue('Dart')!, + 'price': firestore.serializer.encodeValue(12.5)!, + }, + createTime: executionTime, + updateTime: executionTime, + ), + ], + ), + ]); + }, + ); + + return callback(api, _projectId); + }); + + final snapshot = await firestore.pipeline().collection('books').execute(); + + expect(snapshot.size, 1); + expect(snapshot.empty, isFalse); + expect(snapshot.executionTime, Timestamp(seconds: 42, nanoseconds: 0)); + expect(snapshot.results.single.name, contains('/books/book-1')); + expect(snapshot.results.single.document, firestore.doc('books/book-1')); + expect( + snapshot.results.single.createTime, + Timestamp(seconds: 42, nanoseconds: 0), + ); + expect( + snapshot.results.single.updateTime, + Timestamp(seconds: 42, nanoseconds: 0), + ); + expect(snapshot.results.single.data(), {'title': 'Dart', 'price': 12.5}); + expect(snapshot.results.single.get('title'), 'Dart'); + expect(snapshot.result, snapshot.results); + }); + + test('supports documents source and raw stages', () async { + firestore_v1.ExecutePipelineRequest? capturedRequest; + + when( + () => + mockClient.v1>(any()), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> + Function(firestore_v1.Firestore api, String projectId); + + final api = FakeFirestore( + executePipeline: (firestore_v1.ExecutePipelineRequest request) { + capturedRequest = request; + return const Stream.empty(); + }, + ); + + return callback(api, _projectId); + }); + + await firestore + .pipeline() + .documents([firestore.doc('books/book-1')]) + .rawStage('sample', [1], options: {'stable': true}) + .execute(); + + final stages = capturedRequest!.structuredPipeline!.pipeline!.stages; + expect(stages.first.name, 'documents'); + expect( + stages.first.args.single.referenceValue, + 'projects/$_projectId/databases/enterprise/documents/books/book-1', + ); + expect(stages.last.name, 'sample'); + expect(stages.last.args.single.integerValue, 1); + expect(stages.last.options['stable']!.booleanValue, isTrue); + }); + + test('serializes catalog function helpers', () async { + firestore_v1.ExecutePipelineRequest? capturedRequest; + + when( + () => + mockClient.v1>(any()), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> + Function(firestore_v1.Firestore api, String projectId); + + final api = FakeFirestore( + executePipeline: (firestore_v1.ExecutePipelineRequest request) { + capturedRequest = request; + return const Stream.empty(); + }, + ); + + return callback(api, _projectId); + }); + + await firestore + .pipeline() + .collection('books') + .select([ + PipelineFunctions.regexMatch( + field('title'), + r'^Dart', + ).alias('isDart'), + PipelineFunctions.timestampToUnixMillis( + field('publishedAt'), + ).alias('publishedMillis'), + PipelineFunctions.cosineDistance( + field('embedding'), + FieldValue.vector([1, 2, 3]), + ).alias('distance'), + ]) + .where( + PipelineFunctions.and([ + PipelineFunctions.arrayContains(field('tags'), 'programming'), + PipelineFunctions.isType(field('price'), 'number'), + ]), + ) + .execute(); + + final stages = capturedRequest!.structuredPipeline!.pipeline!.stages; + final selectedFunctions = stages[1].args.single.mapValue!.fields; + expect(selectedFunctions['isDart']!.functionValue!.name, 'regex_match'); + expect( + selectedFunctions['publishedMillis']!.functionValue!.name, + 'timestamp_to_unix_millis', + ); + expect( + selectedFunctions['distance']!.functionValue!.name, + 'cosine_distance', + ); + + final whereFunction = stages[2].args.single.functionValue!; + expect(whereFunction.name, 'and'); + expect(whereFunction.args[0].functionValue!.name, 'array_contains'); + expect(whereFunction.args[1].functionValue!.name, 'is_type'); + }); + + test('serializes FlutterFire-style expression and stage APIs', () async { + firestore_v1.ExecutePipelineRequest? capturedRequest; + + when( + () => + mockClient.v1>(any()), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> + Function(firestore_v1.Firestore api, String projectId); + + final api = FakeFirestore( + executePipeline: (firestore_v1.ExecutePipelineRequest request) { + capturedRequest = request; + return const Stream.empty(); + }, + ); + + return callback(api, _projectId); + }); + + final secondary = firestore.pipeline().collection('archivedBooks').select( + ['title'], + ); + + await firestore + .pipeline() + .collectionReference(firestore.collection('books')) + .where(Expression.field('rating').greaterThanOrEqualValue(4)) + .aggregate( + [Expression.field('price').average().as('avgPrice')], + groups: ['genre'], + ) + .distinct(['genre']) + .unnest(Expression.field('tags'), indexField: 'tagIndex') + .replaceWith(Expression.field('summary')) + .union(secondary) + .sample(documents: 5) + .findNearest( + vectorField: 'embedding', + queryVector: FieldValue.vector([1, 2, 3]), + distanceMeasure: DistanceMeasure.cosine, + limit: 3, + distanceResultField: 'distance', + ) + .search({'query': Expression.constant('dart')}) + .execute(); + + final stages = capturedRequest!.structuredPipeline!.pipeline!.stages; + expect(stages.map((stage) => stage.name), [ + 'collection', + 'where', + 'aggregate', + 'distinct', + 'unnest', + 'replace_with', + 'union', + 'sample', + 'find_nearest', + 'search', + ]); + + expect(stages[0].args.single.referenceValue, '/books'); + expect( + stages[1].args.single.functionValue!.name, + 'greater_than_or_equal', + ); + + expect( + stages[2].args.first.mapValue!.fields['avgPrice']!.functionValue!.name, + 'average', + ); + expect( + stages[2].args[1].mapValue!.fields['genre']!.fieldReferenceValue, + 'genre', + ); + + expect(stages[3].args.single.fieldReferenceValue, 'genre'); + expect(stages[4].args.single.fieldReferenceValue, 'tags'); + expect(stages[4].options['index_field']!.stringValue, 'tagIndex'); + expect(stages[5].args.single.fieldReferenceValue, 'summary'); + expect( + stages[6].args.single.pipelineValue!.stages.first.name, + 'collection', + ); + expect(stages[7].options['documents']!.integerValue, 5); + expect(stages[8].args.first.fieldReferenceValue, 'embedding'); + expect(stages[8].args[2].stringValue, 'cosine'); + expect(stages[8].options['limit']!.integerValue, 3); + expect( + stages[8].options['distance_field']!.fieldReferenceValue, + 'distance', + ); + expect(stages[9].options['query']!.stringValue, 'dart'); + }); + + test( + 'serializes complete FlutterFire-style fluent expression surface', + () async { + firestore_v1.ExecutePipelineRequest? capturedRequest; + + when( + () => mockClient.v1>( + any(), + ), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> + Function(firestore_v1.Firestore api, String projectId); + + final api = FakeFirestore( + executePipeline: (firestore_v1.ExecutePipelineRequest request) { + capturedRequest = request; + return const Stream.empty(); + }, + ); + + return callback(api, _projectId); + }); + + await firestore + .pipeline() + .collection('books') + .where(Expression.field('published').asBoolean()) + .select([ + Expression.field('tags').arrayLastIndexOf('dart').as('lastIndex'), + Expression.field('path').parent().as('parent'), + Expression.field('path').referenceSlice(0, 2).as('slice'), + Expression.field('metadata').mapKeys().as('keys'), + Expression.field('metadata').mapValues().as('values'), + Expression.field('metadata').mapRemove(['draft']).as('removed'), + Expression.field('metadata') + .mapMerge([ + {'lang': 'dart'}, + ]) + .as('merged'), + Expression.field('title').regexFind(r'Dart').as('regexFind'), + Expression.field( + 'title', + ).regexFindAll(r'Dart').as('regexFindAll'), + Expression.field('title').stringContains('art').as('contains'), + Expression.field( + 'createdAt', + ).timestampTrunc('day', 'UTC').as('createdDay'), + Expression.field( + 'createdAt', + ).timestampAdd('day', 1).as('createdPlusOne'), + Expression.field( + 'createdAt', + ).timestampSubtract('hour', 2).as('createdMinusTwo'), + Expression.field( + 'createdAt', + ).timestampToUnixMicros().as('createdMicros'), + Expression.field( + 'createdAt', + ).timestampToUnixMillis().as('createdMillis'), + Expression.field( + 'createdAt', + ).timestampToUnixSeconds().as('createdSeconds'), + Expression.field('updatedAt') + .timestampDiff(Expression.field('createdAt'), 'second') + .as('ageSeconds'), + Expression.field('createdAt').timestampExtract('year').as('year'), + Expression.field( + 'embedding', + ).cosineDistance(Expression.vector([1, 2, 3])).as('cosine'), + Expression.field( + 'embedding', + ).dotProduct(Expression.vector([1, 2, 3])).as('dot'), + Expression.field( + 'embedding', + ).euclideanDistance(Expression.vector([1, 2, 3])).as('euclidean'), + Expression.field('embedding').vectorLength().as('vectorLength'), + Expression.field( + 'price', + ).isType(PipelineValueType.number).as('isNumber'), + ]) + .execute(); + + final stages = capturedRequest!.structuredPipeline!.pipeline!.stages; + expect(stages[1].args.single.fieldReferenceValue, 'published'); + + final functionNames = [ + for (final arg in stages[2].args.single.mapValue!.fields.values) + arg.functionValue!.name, + ]; + + expect(functionNames, [ + 'array_index_of', + 'parent', + 'reference_slice', + 'map_keys', + 'map_values', + 'map_remove', + 'map_merge', + 'regex_find', + 'regex_find_all', + 'string_contains', + 'timestamp_trunc', + 'timestamp_add', + 'timestamp_subtract', + 'timestamp_to_unix_micros', + 'timestamp_to_unix_millis', + 'timestamp_to_unix_seconds', + 'timestamp_diff', + 'timestamp_extract', + 'cosine_distance', + 'dot_product', + 'euclidean_distance', + 'vector_length', + 'is_type', + ]); + + final isTypeFunction = + stages[2].args.single.mapValue!.fields['isNumber']!.functionValue!; + expect(isTypeFunction.args.last.stringValue, 'number'); + }, + ); + }); +} From 3616d1654ae47d73213a11be589f822e3aae90fe Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 29 Jun 2026 16:12:30 +0200 Subject: [PATCH 2/3] fix feedback --- .../lib/google_cloud_firestore.dart | 4 +++ .../lib/src/pipeline.dart | 20 ++++++++++--- .../src/reference/vector_query_options.dart | 6 ++-- .../test/bulk_writer_test.dart | 3 +- .../test/bundle_test.dart | 3 +- .../test/document_test.dart | 3 +- .../test/explain_test.dart | 3 +- .../test/order_test.dart | 3 +- .../test/pipeline_test.dart | 28 +++++++++++++++---- .../test/query_partition_test.dart | 3 +- .../test/write_batch_test.dart | 3 +- 11 files changed, 60 insertions(+), 19 deletions(-) diff --git a/packages/google_cloud_firestore/lib/google_cloud_firestore.dart b/packages/google_cloud_firestore/lib/google_cloud_firestore.dart index dd08c0f1..4df3aa61 100644 --- a/packages/google_cloud_firestore/lib/google_cloud_firestore.dart +++ b/packages/google_cloud_firestore/lib/google_cloud_firestore.dart @@ -93,6 +93,10 @@ export 'src/firestore.dart' descending, equal, field, + greaterThan, + greaterThanOrEqual, + lessThan, + lessThanOrEqual, not, notEqual, or, diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 667a581a..ce7e83eb 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -137,11 +137,21 @@ PipelineBooleanExpression notEqual(Object? left, Object? right) { return _comparison('not_equal', left, right); } +/// Creates a less-than expression. +PipelineBooleanExpression lessThan(Object? left, Object? right) { + return _comparison('less_than', left, right); +} + /// Creates a less-than-or-equal expression. PipelineBooleanExpression lessThanOrEqual(Object? left, Object? right) { return _comparison('less_than_or_equal', left, right); } +/// Creates a greater-than expression. +PipelineBooleanExpression greaterThan(Object? left, Object? right) { + return _comparison('greater_than', left, right); +} + /// Creates a greater-than-or-equal expression. PipelineBooleanExpression greaterThanOrEqual(Object? left, Object? right) { return _comparison('greater_than_or_equal', left, right); @@ -1121,7 +1131,7 @@ final class Pipeline { [ if (vectorField is String) field(vectorField) else vectorField, queryVector, - distanceMeasure.value, + distanceMeasure.value.toLowerCase(), ], options: _compactOptions({ 'limit': limit, @@ -1293,13 +1303,15 @@ Object? _decodePipelineResultValue( return firestore._serializer.decodeValue(value); } +final _documentReferenceRegExp = RegExp( + r'^projects/[^/]+/databases/[^/]+(?:/documents(?:/(.*))?)?$', +); + bool _isDocumentReferenceValue(String referenceValue) { final value = referenceValue.startsWith('/') ? referenceValue.substring(1) : referenceValue; - final match = RegExp( - r'^projects/[^/]+/databases/[^/]+(?:/documents(?:/(.*))?)?$', - ).firstMatch(value); + final match = _documentReferenceRegExp.firstMatch(value); if (match == null) { return false; } diff --git a/packages/google_cloud_firestore/lib/src/reference/vector_query_options.dart b/packages/google_cloud_firestore/lib/src/reference/vector_query_options.dart index a9088714..5ccbb370 100644 --- a/packages/google_cloud_firestore/lib/src/reference/vector_query_options.dart +++ b/packages/google_cloud_firestore/lib/src/reference/vector_query_options.dart @@ -18,15 +18,15 @@ part of '../firestore.dart'; enum DistanceMeasure { /// Euclidean distance - straight-line distance between vectors. /// Good for spatial data. - euclidean('euclidean'), + euclidean('EUCLIDEAN'), /// Cosine distance - measures the angle between vectors. /// Good for text embeddings where magnitude doesn't matter. - cosine('cosine'), + cosine('COSINE'), /// Dot product distance - inner product of vectors. /// Good for normalized vectors. - dotProduct('dot_product'); + dotProduct('DOT_PRODUCT'); const DistanceMeasure(this.value); diff --git a/packages/google_cloud_firestore/test/bulk_writer_test.dart b/packages/google_cloud_firestore/test/bulk_writer_test.dart index c23e6a61..7c16ab2a 100644 --- a/packages/google_cloud_firestore/test/bulk_writer_test.dart +++ b/packages/google_cloud_firestore/test/bulk_writer_test.dart @@ -14,7 +14,8 @@ import 'dart:async'; -import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:google_cloud_firestore/google_cloud_firestore.dart' + hide greaterThan, lessThan; import 'package:test/test.dart'; import 'fixtures/helpers.dart'; diff --git a/packages/google_cloud_firestore/test/bundle_test.dart b/packages/google_cloud_firestore/test/bundle_test.dart index cb93413c..256fe1fb 100644 --- a/packages/google_cloud_firestore/test/bundle_test.dart +++ b/packages/google_cloud_firestore/test/bundle_test.dart @@ -15,7 +15,8 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:google_cloud_firestore/google_cloud_firestore.dart' + hide greaterThan, lessThan; import 'package:google_cloud_firestore_v1/firestore.dart' as firestore_v1; import 'package:google_cloud_protobuf/protobuf.dart' as protobuf_v1; import 'package:test/test.dart'; diff --git a/packages/google_cloud_firestore/test/document_test.dart b/packages/google_cloud_firestore/test/document_test.dart index f7c64ee0..14d5b3e8 100644 --- a/packages/google_cloud_firestore/test/document_test.dart +++ b/packages/google_cloud_firestore/test/document_test.dart @@ -17,7 +17,8 @@ library; import 'dart:typed_data'; -import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:google_cloud_firestore/google_cloud_firestore.dart' + hide greaterThan, lessThan; import 'package:test/test.dart' hide throwsArgumentError; import 'fixtures/helpers.dart'; diff --git a/packages/google_cloud_firestore/test/explain_test.dart b/packages/google_cloud_firestore/test/explain_test.dart index 9cf13b54..8aa29b88 100644 --- a/packages/google_cloud_firestore/test/explain_test.dart +++ b/packages/google_cloud_firestore/test/explain_test.dart @@ -16,7 +16,8 @@ library; import 'dart:async'; -import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:google_cloud_firestore/google_cloud_firestore.dart' + hide greaterThan, lessThan; import 'package:test/test.dart'; /// Production-only tests for Query explain() API. diff --git a/packages/google_cloud_firestore/test/order_test.dart b/packages/google_cloud_firestore/test/order_test.dart index 411fa4ac..c6fc4f79 100644 --- a/packages/google_cloud_firestore/test/order_test.dart +++ b/packages/google_cloud_firestore/test/order_test.dart @@ -14,7 +14,8 @@ import 'dart:typed_data'; -import 'package:google_cloud_firestore/src/firestore.dart'; +import 'package:google_cloud_firestore/src/firestore.dart' + hide greaterThan, lessThan; import 'package:google_cloud_firestore_v1/firestore.dart' as firestore_v1; import 'package:google_cloud_protobuf/protobuf.dart' as protobuf_v1; import 'package:google_cloud_type/type.dart' as type_v1; diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 77d06b28..aeb6fdbc 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -18,7 +18,7 @@ import 'package:google_cloud_firestore_v1/firestore.dart' as firestore_v1; import 'package:google_cloud_firestore_v1/testing.dart'; import 'package:google_cloud_protobuf/protobuf.dart' as protobuf_v1; import 'package:mocktail/mocktail.dart'; -import 'package:test/test.dart'; +import 'package:test/test.dart' hide greaterThan, lessThan; const _projectId = 'test-project'; @@ -67,7 +67,12 @@ void main() { await firestore .pipeline() .collection('cities') - .where(Expression.field('population').greaterThanValue(100000)) + .where( + and([ + greaterThan('population', 100000), + lessThan('population', 1000000), + ]), + ) .sort([field('name').ascending()]) .select(['name', field('population')]) .limit(10) @@ -88,9 +93,22 @@ void main() { expect(stages[0].args.single.referenceValue, '/cities'); final whereFunction = stages[1].args.single.functionValue!; - expect(whereFunction.name, 'greater_than'); - expect(whereFunction.args[0].fieldReferenceValue, 'population'); - expect(whereFunction.args[1].integerValue, 100000); + expect(whereFunction.name, 'and'); + expect(whereFunction.args[0].functionValue!.name, 'greater_than'); + expect( + whereFunction.args[0].functionValue!.args[0].fieldReferenceValue, + 'population', + ); + expect(whereFunction.args[0].functionValue!.args[1].integerValue, 100000); + expect(whereFunction.args[1].functionValue!.name, 'less_than'); + expect( + whereFunction.args[1].functionValue!.args[0].fieldReferenceValue, + 'population', + ); + expect( + whereFunction.args[1].functionValue!.args[1].integerValue, + 1000000, + ); final sortValue = stages[2].args.single.mapValue!; expect(sortValue.fields['direction']!.stringValue, 'ascending'); diff --git a/packages/google_cloud_firestore/test/query_partition_test.dart b/packages/google_cloud_firestore/test/query_partition_test.dart index a048515d..127fe226 100644 --- a/packages/google_cloud_firestore/test/query_partition_test.dart +++ b/packages/google_cloud_firestore/test/query_partition_test.dart @@ -14,7 +14,8 @@ import 'dart:async'; -import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:google_cloud_firestore/google_cloud_firestore.dart' + hide greaterThan, lessThan; import 'package:google_cloud_firestore/src/firestore_http_client.dart'; import 'package:google_cloud_firestore_v1/firestore.dart' as firestore_v1; import 'package:google_cloud_firestore_v1/testing.dart'; diff --git a/packages/google_cloud_firestore/test/write_batch_test.dart b/packages/google_cloud_firestore/test/write_batch_test.dart index d4624d40..a9c59cdc 100644 --- a/packages/google_cloud_firestore/test/write_batch_test.dart +++ b/packages/google_cloud_firestore/test/write_batch_test.dart @@ -16,7 +16,8 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:google_cloud_firestore/google_cloud_firestore.dart' + hide greaterThan, lessThan; import 'package:test/test.dart' hide throwsArgumentError; import 'fixtures/helpers.dart'; From 230b2d3577c92b6e815e789b0583bfc02a9dc044 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Mon, 29 Jun 2026 16:21:34 +0200 Subject: [PATCH 3/3] skip --- packages/google_cloud_firestore/README.md | 2 +- packages/google_cloud_firestore/test/e2e/README.md | 4 ++-- .../google_cloud_firestore/test/e2e/pipeline_e2e_test.dart | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 5e64e104..796c1867 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -230,7 +230,7 @@ They are skipped unless you provide a project and credentials: export FIRESTORE_PIPELINE_E2E_PROJECT_ID="your-project-id" export FIRESTORE_PIPELINE_E2E_DATABASE_ID="your-enterprise-database-id" export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" -dart test test/e2e/pipeline_e2e_test.dart +dart test -P prod test/e2e/pipeline_e2e_test.dart ``` The E2E suite includes vector nearest-neighbor coverage. Create the vector index diff --git a/packages/google_cloud_firestore/test/e2e/README.md b/packages/google_cloud_firestore/test/e2e/README.md index 2b688c06..6f99fb65 100644 --- a/packages/google_cloud_firestore/test/e2e/README.md +++ b/packages/google_cloud_firestore/test/e2e/README.md @@ -9,7 +9,7 @@ Required environment: export FIRESTORE_PIPELINE_E2E_PROJECT_ID="your-project-id" export FIRESTORE_PIPELINE_E2E_DATABASE_ID="your-enterprise-database-id" export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" -dart test test/e2e/pipeline_e2e_test.dart +dart test -P prod test/e2e/pipeline_e2e_test.dart ``` The target database must support Firestore Pipelines. The service account needs @@ -40,5 +40,5 @@ For CI, store the service account JSON as a secret, write it to a temporary file set `GOOGLE_APPLICATION_CREDENTIALS` to that file path, and run: ```sh -dart test test/e2e/pipeline_e2e_test.dart +dart test -P prod test/e2e/pipeline_e2e_test.dart ``` diff --git a/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart index 04dcf13d..b20ba9a1 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart @@ -12,6 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +@Tags(['prod']) +library; + import 'dart:io'; import 'dart:typed_data';