From 1aff483c7dd31d9bf39ee64168d00832b3278727 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:19:31 +0200 Subject: [PATCH 01/29] test(firestore): restructure the Pipeline E2E suite and guard its coverage Split the live suite into test/e2e/pipeline/ by area, with a shared harness that seeds one run per file, documents the seed data and runs one test per case. Add test/pipeline_e2e_coverage_test.dart, a credential-free guard that resolves the E2E sources with package:analyzer and fails unless every public Pipeline member, optional parameter, value position, required argument shape and enum value is exercised live. --- .github/workflows/e2e_pipeline.yml | 10 +- CONTRIBUTING.md | 2 +- packages/google_cloud_firestore/README.md | 14 +- packages/google_cloud_firestore/pubspec.yaml | 1 + .../google_cloud_firestore/test/e2e/README.md | 63 +- .../test/e2e/pipeline/aggregates_test.dart | 78 + .../test/e2e/pipeline/execute_test.dart | 62 + .../pipeline/functions_arithmetic_test.dart | 60 + .../e2e/pipeline/functions_array_test.dart | 223 +++ .../functions_comparison_logical_test.dart | 140 ++ .../functions_map_reference_test.dart | 126 ++ .../e2e/pipeline/functions_string_test.dart | 137 ++ .../functions_timestamp_vector_test.dart | 139 ++ .../pipeline/functions_type_debug_test.dart | 68 + .../test/e2e/pipeline/harness.dart | 585 ++++++++ .../test/e2e/pipeline/results_test.dart | 44 + .../test/e2e/pipeline/sources_test.dart | 73 + .../test/e2e/pipeline/stages_test.dart | 110 ++ .../test/e2e/pipeline_e2e_test.dart | 1264 ---------------- .../test/pipeline_e2e_coverage_test.dart | 1294 +++++++++++++++++ 20 files changed, 3214 insertions(+), 1279 deletions(-) create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/functions_array_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/functions_comparison_logical_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/functions_string_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/functions_timestamp_vector_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/harness.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart create mode 100644 packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart delete mode 100644 packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart create mode 100644 packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart diff --git a/.github/workflows/e2e_pipeline.yml b/.github/workflows/e2e_pipeline.yml index ad449f9d..59a473f8 100644 --- a/.github/workflows/e2e_pipeline.yml +++ b/.github/workflows/e2e_pipeline.yml @@ -97,13 +97,13 @@ jobs: # Same Enterprise-edition database FlutterFire's pipeline suite # targets; not a secret, it is hardcoded in FlutterFire's own tests. FIRESTORE_PIPELINE_E2E_DATABASE_ID: firestore-pipeline-test - # --concurrency=1: every test seeds and deletes its own documents in a - # shared live database, matching how scripts/firestore-coverage.sh runs - # the other prod-tagged suites. + # --concurrency=1: every file under test/e2e/pipeline/ seeds and + # deletes its own documents in a shared live database, matching how + # scripts/firestore-coverage.sh runs the other prod-tagged suites. # # Prerequisite: the target database needs the `pipeline_e2e_books` # vector index described in test/e2e/README.md, otherwise the - # findNearest test fails. It is created once, out of band. + # findNearest tests fail. It is created once, out of band. run: | set -o pipefail if [ -z "${FIRESTORE_PIPELINE_E2E_PROJECT_ID:-}" ]; then @@ -111,7 +111,7 @@ jobs: exit 1 fi log="$RUNNER_TEMP/pipeline-e2e.log" - dart test -P prod test/e2e/pipeline_e2e_test.dart \ + dart test -P prod test/e2e/pipeline/ \ --concurrency=1 --reporter expanded 2>&1 | tee "$log" # `dart test` exits 0 when every test is skipped, which is exactly # what a missing project or database ID produces. Treat that as a diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index be18518c..417c4655 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -307,7 +307,7 @@ in that project: | Job | Trigger | What it does | |-----|---------|--------------| -| `pipeline-e2e` | PRs (non-fork) touching `packages/google_cloud_firestore/**`, pushes to `main`, schedule & manual | Runs `test/e2e/pipeline_e2e_test.dart` against live Firestore | +| `pipeline-e2e` | PRs (non-fork) touching `packages/google_cloud_firestore/**`, pushes to `main`, schedule & manual | Runs `test/e2e/pipeline/` against live Firestore | It is a separate workflow so the live-quota cost is only paid for changes that can affect it. See diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 12182e5a..9c1e636e 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -524,16 +524,24 @@ Two behavioural notes: #### 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: +Real-project Pipeline E2E tests live in `test/e2e/pipeline/`, one file per +area (sources, stages, results, execution, aggregates, and one per function +family). 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 -P prod test/e2e/pipeline_e2e_test.dart +dart test -P prod test/e2e/pipeline/ --concurrency=1 ``` +`test/pipeline_e2e_coverage_test.dart` runs with the ordinary unit tests and +needs no credentials. It statically checks that the E2E suite references every +public Pipeline API member, supplies and omits every optional parameter, and +passes both an expression and a plain value to every value position, so a new +Pipeline function needs a live case before the build goes green. See +[`test/e2e/README.md`](test/e2e/README.md) for the layout and helpers. + The E2E suite includes vector nearest-neighbor coverage. Create the vector index once for the test collection group before running the suite in CI: diff --git a/packages/google_cloud_firestore/pubspec.yaml b/packages/google_cloud_firestore/pubspec.yaml index 02c4a9b5..205b6826 100644 --- a/packages/google_cloud_firestore/pubspec.yaml +++ b/packages/google_cloud_firestore/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: retry: ^3.1.2 dev_dependencies: + analyzer: '>=10.0.0 <15.0.0' build_runner: ^2.4.7 mocktail: ^1.0.2 test: ^1.26.3 diff --git a/packages/google_cloud_firestore/test/e2e/README.md b/packages/google_cloud_firestore/test/e2e/README.md index cfc1f80f..ea30f30c 100644 --- a/packages/google_cloud_firestore/test/e2e/README.md +++ b/packages/google_cloud_firestore/test/e2e/README.md @@ -13,16 +13,67 @@ suite somewhere it cannot pass. export FIRESTORE_PIPELINE_E2E_PROJECT_ID="" export FIRESTORE_PIPELINE_E2E_DATABASE_ID="firestore-pipeline-test" export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account.json" -dart test -P prod test/e2e/pipeline_e2e_test.dart +dart test -P prod test/e2e/pipeline/ --concurrency=1 ``` The credential needs permission to create, read, query, and delete documents in that database — `roles/datastore.user` covers it. -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. +## Layout + +The suite lives in `test/e2e/pipeline/`, one file per area: + +| File | Covers | +| --- | --- | +| `harness.dart` | Shared setup, seed data and case helpers (not a test file). | +| `sources_test.dart` | `Firestore.pipeline` and the `PipelineSource` stages. | +| `stages_test.dart` | `Pipeline` stages, orderings and aliases. | +| `results_test.dart` | `PipelineSnapshot` / `PipelineResult` accessors and value decoding. | +| `execute_test.dart` | `execute` options, explain stats, `Transaction.executePipeline`. | +| `aggregates_test.dart` | Aggregate functions and the `aggregate` stage. | +| `functions__test.dart` | One file per function family: `comparison_logical`, `arithmetic`, `string`, `array`, `map_reference`, `timestamp_vector`, `type_debug`. | + +Each file wraps its tests in a single `pipelineE2E(...)` call, which writes the +documents described by `buildSeed` in `harness.dart` once per file — under a +run ID unique to that file — and deletes them in `tearDownAll`. `buildSeed`'s +doc comment lists every seeded field and value; compute expectations from it. + +Prefer the table-driven helpers, which declare one test per case so a single +backend rejection never hides another: + +```dart +pipelineE2E('Pipeline arithmetic functions', (ctx) { + ctx.functionCases('round', [ + // Evaluated on book 1 as select([expression.as('v')]). + FunctionCase('static, omitted decimals', PipelineFunctions.round('half'), isNumber(3)), + FunctionCase('fluent, with decimals', field('precise').round(2), isNumber(4.12)), + ]); +}); +``` + +`ctx.aggregateCases` evaluates `aggregate([...])` over the run's three +documents, and `ctx.filterCases` checks which books a `where` condition keeps. +`isInt`, `isDouble` and `isNumber` match numeric results by wire type. + +Test files import only `package:google_cloud_firestore/google_cloud_firestore.dart`, +`package:test/test.dart` and the harness (plus `dart:` libraries), so a public +API missing from the barrel fails to compile here. + +## Coverage guard + +`test/pipeline_e2e_coverage_test.dart` is an ordinary, credential-free unit +test. It resolves these files with `package:analyzer` and fails unless every +public Pipeline API member — static and fluent forms counted separately — is +referenced here, every optional parameter is both supplied and omitted, every +`Object`-typed value position receives both an expression and a plain value, +and a few listed parameters receive specific shapes (collections holding +expressions, numeric vector lists, dotted and `FieldPath` result paths). Its +report groups gaps by family, naming the file that owns each one. The rules, +the family tables and the few exemptions are documented at the top of the +guard. + +Adding a public Pipeline function therefore means adding a live case for it +here; the guard fails until you do. Vector nearest-neighbor coverage requires this vector index: @@ -89,7 +140,7 @@ IDs, so the two can run concurrently without interfering. a misconfigured job would report a green build for tests that never ran. - `concurrency` is scoped per ref with `cancel-in-progress`, so two runs never write to the database on the same ref at once. A cancelled run does skip its - `tearDown`, which leaves that run's `run__book_*` documents behind. + `tearDownAll`, which leaves that run's `run__book_*` documents behind. They are inert — every assertion filters on `runId` — but `pipeline_e2e_books` is worth sweeping occasionally. diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart new file mode 100644 index 00000000..ae89f7f0 --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart @@ -0,0 +1,78 @@ +// 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. + +/// Live coverage of the aggregate functions and the `aggregate` stage. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline aggregates', (ctx) { + test('executes aggregate pipeline stages', () async { + final aggregateSnapshot = await ctx.firestore + .pipeline() + .collection(ctx.collectionPath) + .where(ctx.runFilter(Expression.field('active').equal(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); + }); + + ctx.aggregateCases('aggregate functions', [ + AggregateCase('count', PipelineFunctions.count(), 3), + AggregateCase( + 'countIf', + PipelineFunctions.countIf(Expression.field('active')), + 2, + ), + AggregateCase( + 'countDistinct', + Expression.field('metadata').mapGetLiteral('lang').countDistinct(), + 1, + ), + AggregateCase('sum', Expression.field('price').sum(), 60), + AggregateCase( + 'average', + Expression.field('rating').average(), + closeTo(11 / 3, 0.0001), + ), + AggregateCase('minimum', Expression.field('price').minimum(), 10), + AggregateCase('maximum', Expression.field('price').maximum(), 30), + AggregateCase('first', Expression.field('title').first(), isA()), + AggregateCase('last', Expression.field('title').last(), isA()), + AggregateCase( + 'arrayAgg', + Expression.field('title').arrayAgg(), + containsAll(['Dart Pipelines', 'Firestore Admin', 'Inactive Draft']), + ), + AggregateCase( + 'arrayAggDistinct', + Expression.field('metadata').mapGetLiteral('lang').arrayAggDistinct(), + ['dart'], + ), + ]); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart new file mode 100644 index 00000000..1c072bff --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart @@ -0,0 +1,62 @@ +// 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. + +/// Live coverage of `Pipeline.execute` options, explain stats and +/// `Transaction.executePipeline`. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline execution', (ctx) { + test('requests explain stats via typed options', () async { + final snapshot = await ctx.firestore + .pipeline() + .collection(ctx.collectionPath) + .where(ctx.runFilter(Expression.field('active').equal(true))) + .execute( + explain: const PipelineExplainOptions( + mode: PipelineExplainMode.analyze, + outputFormat: PipelineExplainOutputFormat.text, + ), + ); + + // `analyze` runs the pipeline and returns planning stats alongside the + // results, which is what proves the option names reached the backend. + expect(snapshot.results, isNotEmpty); + expect(snapshot.explainStats, isNotNull); + expect(snapshot.explainStats!.text, isNotEmpty); + }); + + test('executes a pipeline inside a transaction', () async { + final titles = await ctx.firestore.runTransaction((transaction) async { + final snapshot = await transaction.executePipeline( + ctx.firestore + .pipeline() + .collection(ctx.collectionPath) + .where(ctx.runFilter(Expression.field('active').equal(true))) + .sort([Expression.field('price').ascending()]) + .select([Expression.field('title')]), + ); + return [for (final result in snapshot.results) result.get('title')]; + }, transactionOptions: ReadOnlyTransactionOptions()); + + expect(titles, ['Dart Pipelines', 'Firestore Admin']); + }); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart new file mode 100644 index 00000000..fd0b8afe --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart @@ -0,0 +1,60 @@ +// 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. + +/// Live coverage of the arithmetic Pipeline functions. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline arithmetic functions', (ctx) { + ctx.functionCases('arithmetic functions', [ + FunctionCase( + 'abs', + Expression.field('score').abs(), + closeTo(12.7, 0.0001), + ), + FunctionCase('add', Expression.field('price').add(2), 12), + FunctionCase('subtract', Expression.field('price').subtract(3), 7), + FunctionCase('multiply', Expression.field('price').multiply(2), 20), + FunctionCase('divide', Expression.field('price').divide(2), 5), + FunctionCase('mod', Expression.field('price').mod(4), 2), + FunctionCase('ceil', Expression.constant(12.2).ceil(), 13), + FunctionCase('floor', Expression.constant(12.8).floor(), 12), + FunctionCase('round', Expression.constant(12.6).round(), 13), + FunctionCase('trunc', Expression.constant(12.8).trunc(), 12), + FunctionCase( + 'roundDecimalPlaces', + Expression.constant(12.68).round(1), + closeTo(12.7, 0.0001), + ), + FunctionCase( + 'truncDecimalPlaces', + Expression.constant(12.89).trunc(1), + closeTo(12.8, 0.0001), + ), + FunctionCase('pow', PipelineFunctions.pow(2, 3), 8), + FunctionCase('sqrt', Expression.constant(9).sqrt(), 3), + FunctionCase('exp', PipelineFunctions.exp(1), closeTo(2.71828, 0.001)), + FunctionCase('ln', PipelineFunctions.ln(2.718281828), closeTo(1, 0.001)), + FunctionCase('log', PipelineFunctions.log(8, 2), closeTo(3, 0.001)), + FunctionCase('log10', PipelineFunctions.log10(100), closeTo(2, 0.001)), + FunctionCase('rand', PipelineFunctions.rand(), isA()), + ]); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_array_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_array_test.dart new file mode 100644 index 00000000..d5b1d4e1 --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_array_test.dart @@ -0,0 +1,223 @@ +// 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. + +/// Live coverage of the array Pipeline functions. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline array functions', (ctx) { + ctx.functionCases('array functions', [ + FunctionCase('array', Expression.array([1, 2, 3]), [1, 2, 3]), + FunctionCase( + 'arrayConcat', + Expression.field('tags').arrayConcat(['admin']), + ['dart', 'firebase', 'admin'], + ), + FunctionCase( + 'arrayConcatMultiple', + Expression.field('tags').arrayConcatMultiple([ + ['admin'], + ['sdk'], + ]), + ['dart', 'firebase', 'admin', 'sdk'], + ), + FunctionCase( + 'arrayContainsValue', + Expression.field('tags').arrayContains('dart'), + true, + ), + FunctionCase( + 'arrayContainsElement', + Expression.field('tags').arrayContains(Expression.constant('firebase')), + true, + ), + FunctionCase( + 'arrayContainsAll', + Expression.field('tags').arrayContainsAll(['dart', 'firebase']), + true, + ), + FunctionCase( + 'arrayContainsAllFrom', + Expression.field( + 'tags', + ).arrayContainsAllFrom(Expression.array(['dart'])), + true, + ), + FunctionCase( + 'arrayContainsAny', + Expression.field('tags').arrayContainsAny(['missing', 'dart']), + true, + ), + FunctionCase( + 'arrayFilter', + Expression.field( + 'tags', + ).arrayFilter('tag', Expression.variable('tag').notEqual('firebase')), + ['dart'], + ), + FunctionCase( + 'arrayGet', + PipelineFunctions.arrayGet(Expression.field('numbers'), 1), + 1, + ), + FunctionCase('arrayLength', Expression.field('numbers').arrayLength(), 4), + FunctionCase('arrayReverse', Expression.field('numbers').arrayReverse(), [ + 3, + 2, + 1, + 3, + ]), + FunctionCase('arrayFirst', Expression.field('numbers').arrayFirst(), 3), + FunctionCase('arrayFirstN', Expression.field('numbers').arrayFirstN(2), [ + 3, + 1, + ]), + FunctionCase( + 'arrayIndexOf', + Expression.field('numbers').arrayIndexOf(1), + 1, + ), + FunctionCase( + 'arrayIndexOfAll', + Expression.field('numbers').arrayIndexOfAll(3), + [0, 3], + ), + FunctionCase('arrayLast', Expression.field('numbers').arrayLast(), 3), + FunctionCase('arrayLastN', Expression.field('numbers').arrayLastN(2), [ + 2, + 3, + ]), + FunctionCase( + 'arrayLastIndexOf', + Expression.field('numbers').arrayLastIndexOf(3), + 3, + ), + FunctionCase('arraySlice', Expression.field('numbers').arraySlice(1, 2), [ + 1, + 2, + ]), + FunctionCase( + 'arrayTransform', + Expression.field( + 'numbers', + ).arrayTransform('n', Expression.variable('n').add(1)), + [4, 2, 3, 4], + ), + FunctionCase( + 'arrayTransformWithIndex', + Expression.field('numbers').arrayTransformWithIndex( + 'n', + 'i', + Expression.variable('n').add(Expression.variable('i')), + ), + [3, 2, 4, 6], + ), + FunctionCase( + 'arrayMaximum', + Expression.field('numbers').arrayMaximum(), + 3, + ), + FunctionCase('maximumN', Expression.field('numbers').arrayMaximumN(2), [ + 3, + 3, + ]), + FunctionCase( + 'arrayMinimum', + Expression.field('numbers').arrayMinimum(), + 1, + ), + FunctionCase('minimumN', Expression.field('numbers').arrayMinimumN(2), [ + 1, + 2, + ]), + FunctionCase('arraySum', Expression.field('numbers').arraySum(), 9), + // The static helpers must emit the same backend names as the fluent + // forms (`maximum`, not `array_maximum`). + FunctionCase( + 'staticArrayMaximum', + PipelineFunctions.arrayMaximum('numbers'), + 3, + ), + FunctionCase( + 'staticArrayMaximumN', + PipelineFunctions.arrayMaximumN('numbers', 2), + [3, 3], + ), + FunctionCase( + 'staticArrayMinimum', + PipelineFunctions.arrayMinimum('numbers'), + 1, + ), + FunctionCase( + 'staticArrayMinimumN', + PipelineFunctions.arrayMinimumN('numbers', 2), + [1, 2], + ), + FunctionCase('staticArraySum', PipelineFunctions.arraySum('numbers'), 9), + FunctionCase( + 'join', + Expression.field('words').joinLiteral('-'), + 'dart-firebase', + ), + FunctionCase( + 'joinStatic', + PipelineFunctions.join('words', ', '), + 'dart, firebase', + ), + ]); + + // Collections that hold expressions must be built with array(...) / + // map(...); the backend rejects them inside a literal array or map value. + ctx.functionCases('collections holding expressions', [ + FunctionCase( + 'arrayContainsAll', + PipelineFunctions.arrayContainsAll('tags', [ + Expression.field('metadata').mapGetLiteral('lang'), + 'firebase', + ]), + true, + ), + FunctionCase( + 'arrayContainsAny', + Expression.field('tags').arrayContainsAny([ + Expression.field('metadata').mapGetLiteral('lang'), + 'missing', + ]), + true, + ), + FunctionCase( + 'arrayConcat', + Expression.field('tags').arrayConcat([Expression.field('title')]), + ['dart', 'firebase', 'Dart Pipelines'], + ), + FunctionCase( + 'nestedArray', + Expression.array([ + 1, + [Expression.field('price')], + ]), + [ + 1, + [10], + ], + ), + ]); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_comparison_logical_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_comparison_logical_test.dart new file mode 100644 index 00000000..79372535 --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_comparison_logical_test.dart @@ -0,0 +1,140 @@ +// 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. + +/// Live coverage of the comparison and logical Pipeline functions. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline comparison and logical functions', (ctx) { + ctx.functionCases('comparison functions', [ + FunctionCase('equal', Expression.field('price').equal(10), true), + FunctionCase('notEqual', Expression.field('price').notEqual(11), true), + FunctionCase( + 'greaterThan', + Expression.field('price').greaterThan(9), + true, + ), + FunctionCase( + 'greaterThanOrEqual', + Expression.field('price').greaterThanOrEqual(10), + true, + ), + FunctionCase('lessThan', Expression.field('price').lessThan(11), true), + FunctionCase( + 'lessThanOrEqual', + Expression.field('price').lessThanOrEqual(10), + true, + ), + FunctionCase( + 'cmp', + PipelineFunctions.cmp(Expression.field('price'), 10), + 0, + ), + ]); + + ctx.functionCases('logical functions', [ + FunctionCase( + 'and', + PipelineFunctions.and([true, Expression.field('active')]), + true, + ), + FunctionCase( + 'or', + PipelineFunctions.or([false, Expression.field('active')]), + true, + ), + FunctionCase('xor', PipelineFunctions.xor([true, false]), true), + FunctionCase('nor', PipelineFunctions.nor([false, false]), true), + FunctionCase('not', PipelineFunctions.not(false), true), + FunctionCase( + 'conditional', + PipelineFunctions.conditional(Expression.field('active'), 'yes', 'no'), + 'yes', + ), + FunctionCase( + 'ifNull', + PipelineFunctions.ifNull(Expression.field('nullable'), 'fallback'), + 'fallback', + ), + // The first argument is a field position: a bare String means a field + // reference, not a string literal. Passing 'dart' here asked about a + // non-existent field named `dart`, which made equalAny false and made + // notEqualAny true for the wrong reason. + FunctionCase( + 'equalAny', + PipelineFunctions.equalAny( + 'title', + Expression.array(['Dart Pipelines', 'Unrelated Title']), + ), + true, + ), + FunctionCase( + 'notEqualAny', + PipelineFunctions.notEqualAny( + 'title', + Expression.array(['Unrelated Title', 'Another Title']), + ), + true, + ), + ]); + + // Collections that hold expressions must be built with array(...) / + // map(...); the backend rejects them inside a literal array or map value. + ctx.functionCases('collections holding expressions', [ + FunctionCase( + 'equalAny', + PipelineFunctions.equalAny('rating', [Expression.field('score'), 5]), + true, + ), + FunctionCase( + 'equalAnyMatchesExpression', + Expression.field( + 'price', + ).equalAny([Expression.field('rating').multiply(2), 3]), + true, + ), + FunctionCase( + 'notEqualAny', + PipelineFunctions.notEqualAny('rating', [Expression.field('price'), 4]), + true, + ), + FunctionCase( + 'equal', + Expression.field('tags').equal([ + Expression.field('metadata').mapGetLiteral('lang'), + 'firebase', + ]), + true, + ), + ]); + + ctx.filterCases('filters', [ + // Regression: the list was sent as a literal array value, which the + // backend rejected with "Value type is not supported: + // FIELD_REFERENCE_VALUE". Book 1 matches the literal 2, book 2 its own + // `flags` (3). + FilterCase( + 'filters with a search space holding expressions', + PipelineFunctions.equalAny('discount', [Expression.field('flags'), 2]), + ['Dart Pipelines', 'Firestore Admin'], + ), + ]); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart new file mode 100644 index 00000000..6bf83943 --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart @@ -0,0 +1,126 @@ +// 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. + +/// Live coverage of the map and reference Pipeline functions. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline map and reference functions', (ctx) { + ctx.functionCases('map functions', [ + FunctionCase('map', PipelineFunctions.map(['a', 1, 'b', 2]), { + 'a': 1, + 'b': 2, + }), + FunctionCase( + 'mapGet', + Expression.field('metadata').mapGetLiteral('category'), + 'sdk', + ), + FunctionCase( + 'mapSet', + Expression.field('metadata').mapSet('edition', 'enterprise'), + containsPair('edition', 'enterprise'), + ), + FunctionCase( + 'mapRemove', + Expression.field('metadata').mapRemove('lang'), + isNot(contains('lang')), + ), + FunctionCase( + 'mapRemove with an expression key', + Expression.field('metadata').mapRemove(Expression.constant('lang')), + isNot(contains('lang')), + ), + FunctionCase( + 'chained mapRemove', + Expression.field('metadata').mapRemove('lang').mapRemove('category'), + isEmpty, + ), + FunctionCase( + 'mapMerge', + Expression.field('metadata').mapMerge([ + {'edition': 'enterprise'}, + ]), + containsPair('edition', 'enterprise'), + ), + FunctionCase( + 'currentDocument', + currentDocument(), + isA>(), + ), + FunctionCase( + 'mapKeys', + Expression.field('metadata').mapKeys(), + containsAll(['lang', 'category']), + ), + FunctionCase( + 'mapValues', + Expression.field('metadata').mapValues(), + containsAll(['dart', 'sdk']), + ), + FunctionCase( + 'mapEntries', + Expression.field('metadata').mapEntries(), + isA>(), + ), + ]); + + ctx.functionCases('reference functions', [ + FunctionCase( + 'collectionId', + Expression.field('pathRef').collectionId(), + isA(), + ), + FunctionCase( + 'documentId', + Expression.field('pathRef').documentId(), + allOf(startsWith('run_'), endsWith('_book_2')), + ), + FunctionCase('parent', Expression.field('pathRef').parent(), isNotNull), + FunctionCase( + 'referenceSlice', + Expression.field('pathRef').referenceSlice(0, 2), + isNotNull, + ), + ]); + + // Collections that hold expressions must be built with array(...) / + // map(...); the backend rejects them inside a literal array or map value. + ctx.functionCases('collections holding expressions', [ + FunctionCase( + 'mapMerge', + Expression.field('metadata').mapMerge([ + {'title': Expression.field('title')}, + ]), + containsPair('title', 'Dart Pipelines'), + ), + FunctionCase( + 'nestedMap', + PipelineFunctions.map([ + 'nested', + {'price': Expression.field('price')}, + ]), + { + 'nested': {'price': 10}, + }, + ), + ]); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_string_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_string_test.dart new file mode 100644 index 00000000..6634bcc8 --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_string_test.dart @@ -0,0 +1,137 @@ +// 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. + +/// Live coverage of the string Pipeline functions and the generic `length`, +/// `reverse` and `concat` functions. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline string functions', (ctx) { + ctx.functionCases('string functions', [ + FunctionCase('byteLength', Expression.field('title').byteLength(), 14), + FunctionCase('charLength', Expression.field('title').length(), 14), + FunctionCase( + 'startsWith', + Expression.field('title').startsWith('Dart'), + true, + ), + FunctionCase( + 'endsWith', + Expression.field('title').endsWith('lines'), + true, + ), + FunctionCase('like', Expression.field('title').like('Dart%'), true), + FunctionCase( + 'regexContains', + Expression.field('title').regexContains('Pipe'), + true, + ), + FunctionCase( + 'regexMatch', + Expression.field('title').regexMatch(r'^Dart.*'), + true, + ), + FunctionCase( + 'regexFind', + Expression.field('title').regexFind('Pipe'), + isNotNull, + ), + FunctionCase( + 'regexFindAll', + Expression.field('title').regexFindAll('[aei]'), + isA>(), + ), + FunctionCase( + 'stringConcat', + Expression.field('title').concat([' v2']), + 'Dart Pipelines v2', + ), + FunctionCase( + 'stringContains', + Expression.field('title').stringContains('Pipeline'), + true, + ), + FunctionCase( + 'stringIndexOf', + Expression.field('title').stringIndexOf('Pipeline'), + 5, + ), + FunctionCase( + 'toUpper', + Expression.field('title').toUpperCase(), + 'DART PIPELINES', + ), + FunctionCase( + 'toLower', + Expression.field('title').toLowerCase(), + 'dart pipelines', + ), + FunctionCase( + 'substring', + Expression.field('title').substringLiteral(0, 4), + 'Dart', + ), + // The second argument is a length, not an end index. + FunctionCase( + 'substringLength', + Expression.field('title').substring(5, 3), + 'Pip', + ), + FunctionCase( + 'substringToEnd', + Expression.field('title').substring(5), + 'Pipelines', + ), + FunctionCase( + 'stringReverse', + PipelineFunctions.stringReverse(Expression.constant('Dart')), + 'traD', + ), + FunctionCase( + 'stringRepeat', + Expression.constant('ha').stringRepeat(3), + 'hahaha', + ), + FunctionCase( + 'stringReplaceAll', + Expression.field('title').stringReplaceAllLiteral('i', 'I'), + 'Dart PIpelInes', + ), + FunctionCase( + 'stringReplaceOne', + Expression.field('title').stringReplaceOneLiteral('i', 'I'), + 'Dart PIpelines', + ), + FunctionCase('trim', Expression.field('spaced').trim(), 'Dart'), + FunctionCase('ltrim', Expression.field('spaced').ltrim(), 'Dart '), + FunctionCase('rtrim', Expression.field('spaced').rtrim(), ' Dart'), + FunctionCase('split', Expression.field('csv').splitLiteral(','), [ + 'dart', + 'firebase', + 'admin', + ]), + FunctionCase('splitStatic', PipelineFunctions.split('csv', ','), [ + 'dart', + 'firebase', + 'admin', + ]), + ]); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_timestamp_vector_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_timestamp_vector_test.dart new file mode 100644 index 00000000..3042f55b --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_timestamp_vector_test.dart @@ -0,0 +1,139 @@ +// 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. + +/// Live coverage of the timestamp and vector Pipeline functions. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline timestamp and vector functions', (ctx) { + ctx.functionCases('timestamp functions', [ + FunctionCase( + 'currentTimestamp', + PipelineFunctions.currentTimestamp(), + isA(), + ), + FunctionCase( + 'timestampTrunc', + Expression.field('createdAt').timestampTruncate('day', 'UTC'), + isA(), + ), + FunctionCase( + 'unixMicrosToTimestamp', + PipelineFunctions.unixMicrosToTimestamp(1700000000000000), + isA(), + ), + FunctionCase( + 'unixMillisToTimestamp', + PipelineFunctions.unixMillisToTimestamp(1700000000000), + isA(), + ), + FunctionCase( + 'unixSecondsToTimestamp', + PipelineFunctions.unixSecondsToTimestamp(1700000000), + isA(), + ), + FunctionCase( + 'timestampAdd', + Expression.field('createdAt').timestampAdd('second', 60), + isA(), + ), + FunctionCase( + 'timestampSubtract', + Expression.field('createdAt').timestampSubtract('second', 60), + isA(), + ), + FunctionCase( + 'timestampToUnixMicros', + Expression.field('createdAt').timestampToUnixMicros(), + 1700000000000000, + ), + FunctionCase( + 'timestampToUnixMillis', + Expression.field('createdAt').timestampToUnixMillis(), + 1700000000000, + ), + FunctionCase( + 'timestampToUnixSeconds', + Expression.field('createdAt').timestampToUnixSeconds(), + 1700000000, + ), + FunctionCase( + 'timestampDiff', + PipelineFunctions.timestampDiff( + Expression.field('createdAt'), + PipelineFunctions.unixSecondsToTimestamp(1699999940), + 'second', + ), + 60, + ), + FunctionCase( + 'timestampExtract', + Expression.field('createdAt').timestampExtract('year', 'UTC'), + 2023, + ), + ]); + + ctx.functionCases('vector functions', [ + FunctionCase( + 'cosineDistance', + Expression.field( + 'embedding', + ).cosineDistance(Expression.vector([1, 0, 0])), + closeTo(0, 0.0001), + ), + FunctionCase( + 'dotProduct', + Expression.field('embedding').dotProduct(Expression.vector([1, 0, 0])), + closeTo(1, 0.0001), + ), + FunctionCase( + 'euclideanDistance', + Expression.field( + 'embedding', + ).euclideanDistance(Expression.vector([1, 0, 0])), + closeTo(0, 0.0001), + ), + FunctionCase( + 'vectorLength', + Expression.field('embedding').vectorLength(), + 3, + ), + ]); + + // A plain list of numbers must reach the backend as a vector, not an array. + ctx.functionCases('vector functions with list arguments', [ + FunctionCase( + 'cosineDistance', + Expression.field('embedding').cosineDistance(const [1.0, 0.0, 0.0]), + closeTo(0, 0.0001), + ), + FunctionCase( + 'dotProduct', + PipelineFunctions.dotProduct('embedding', const [1, 0, 0]), + closeTo(1, 0.0001), + ), + FunctionCase( + 'euclideanDistance', + Expression.field('embedding').euclideanDistance(const [1.0, 0.0, 0.0]), + closeTo(0, 0.0001), + ), + ]); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart new file mode 100644 index 00000000..da4d2835 --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart @@ -0,0 +1,68 @@ +// 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. + +/// Live coverage of the type, debugging and search Pipeline functions, every +/// `PipelineValueType`, and the expression primitives (`field`, `constant`, +/// raw functions). +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline type and debugging functions', (ctx) { + ctx.functionCases('debugging functions', [ + FunctionCase('exists', Expression.field('title').exists(), true), + FunctionCase('isAbsent', Expression.field('missing').isAbsent(), true), + FunctionCase( + 'ifAbsent', + Expression.field('missing').ifAbsent('fallback'), + 'fallback', + ), + FunctionCase('isError', Expression.field('title').isError(), false), + FunctionCase( + 'ifError', + Expression.field('title').ifError('caught'), + 'Dart Pipelines', + ), + ]); + + ctx.functionCases('type functions', [ + FunctionCase('type', Expression.field('price').type(), isA()), + FunctionCase( + 'isType', + Expression.field('price').isType(PipelineValueType.number), + true, + ), + FunctionCase( + 'isTypeInt64', + Expression.field('price').isType(PipelineValueType.int64), + true, + ), + FunctionCase( + 'isTypeFloat64', + Expression.field('score').isType(PipelineValueType.double), + true, + ), + FunctionCase( + 'isTypeNotFloat64', + Expression.field('price').isType(PipelineValueType.double), + false, + ), + ]); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/harness.dart b/packages/google_cloud_firestore/test/e2e/pipeline/harness.dart new file mode 100644 index 00000000..6aba1944 --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/harness.dart @@ -0,0 +1,585 @@ +// 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. + +/// Shared harness for the live Firestore Pipelines E2E suite. +/// +/// Every `*_test.dart` file in this directory: +/// +/// - is tagged `@Tags(['prod'])`; +/// - imports only `package:google_cloud_firestore/google_cloud_firestore.dart`, +/// `package:test/test.dart` and this harness (plus `dart:` libraries), so a +/// public API that is missing from the barrel fails to compile here; +/// - wraps its tests in a single [pipelineE2E] call, which seeds the +/// documents described in [buildSeed] once per file and deletes them +/// afterwards. +/// +/// Prefer the table-driven helpers ([PipelineE2EContext.functionCases], +/// [PipelineE2EContext.aggregateCases], [PipelineE2EContext.filterCases]): +/// each case is its own test, so one `INVALID_ARGUMENT` never hides another. +/// +/// `test/pipeline_e2e_coverage_test.dart` statically checks that these files +/// exercise every public Pipeline API member, every optional parameter (both +/// supplied and omitted) and every value position (with an expression and +/// with a plain value). Adding a public Pipeline function without an E2E case +/// here makes that guard fail. +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +/// Environment variable naming the project that holds the Pipelines database. +const projectIdEnv = 'FIRESTORE_PIPELINE_E2E_PROJECT_ID'; + +/// Environment variable naming the Enterprise-edition database to run against. +const databaseIdEnv = 'FIRESTORE_PIPELINE_E2E_DATABASE_ID'; + +/// The collection every E2E file seeds into. +/// +/// Kept stable on purpose: the vector index the `findNearest` tests need is +/// declared on this collection group (see `test/e2e/README.md`), and per-run +/// document IDs keep concurrent runs apart. +const pipelineE2ECollection = 'pipeline_e2e_books'; + +/// Why the suite is skipped when it is not configured. +const pipelineE2ESkipReason = + 'Set both FIRESTORE_PIPELINE_E2E_PROJECT_ID and ' + 'FIRESTORE_PIPELINE_E2E_DATABASE_ID to run against a real Firebase ' + 'project. The database must be Enterprise edition; Pipelines are not ' + 'available on Standard. CI authenticates with Application Default ' + 'Credentials, for example via Workload Identity Federation.'; + +/// The alias [PipelineE2EContext.functionCases] and +/// [PipelineE2EContext.aggregateCases] store each evaluated expression under. +const valueAlias = 'v'; + +/// Declares a group of live Pipeline tests sharing one seeded data set. +/// +/// The group is skipped unless both [projectIdEnv] and [databaseIdEnv] are +/// set. Otherwise the documents described in [buildSeed] are written once in +/// `setUpAll`, under a run ID unique to this call, and deleted in +/// `tearDownAll`. +/// +/// [body] runs at declaration time, like any group body. Everything on the +/// context ([PipelineE2EContext.firestore], [PipelineE2EContext.runId], the +/// document references and the Pipeline builders) is already usable there, +/// so cases may embed `ctx.book2Ref` and similar; only executing a Pipeline +/// has to wait for a test body. +void pipelineE2E( + String description, + void Function(PipelineE2EContext ctx) body, +) { + final target = _E2ETarget.fromEnvironment(); + final ctx = PipelineE2EContext._(target); + + group(description, skip: target.enabled ? null : pipelineE2ESkipReason, () { + setUpAll(ctx._seed); + tearDownAll(ctx._deleteSeed); + body(ctx); + }); +} + +final class _E2ETarget { + const _E2ETarget(this.projectId, this.databaseId); + + factory _E2ETarget.fromEnvironment() { + final projectId = + Platform.environment[projectIdEnv] ?? + Platform.environment['GOOGLE_CLOUD_PROJECT'] ?? + Platform.environment['GCLOUD_PROJECT']; + // No '(default)' fallback on purpose. CI credential helpers such as + // google-github-actions/auth export GOOGLE_CLOUD_PROJECT, so a project-only + // guard would silently arm this suite against whatever project happened to + // be authenticated, using its default database — which may be Standard + // edition (no Pipelines at all) or simply lack the vector index this suite + // needs. Both variables must be set deliberately. + final databaseId = Platform.environment[databaseIdEnv]; + return _E2ETarget(projectId, databaseId); + } + + final String? projectId; + final String? databaseId; + + bool get enabled => + projectId != null && + projectId!.isNotEmpty && + databaseId != null && + databaseId!.isNotEmpty; +} + +var _lastRunMicros = 0; + +/// A run ID unique within this isolate, in the `run_` format the +/// README's cleanup advice refers to. +String _nextRunId() { + var micros = DateTime.now().microsecondsSinceEpoch; + if (micros <= _lastRunMicros) micros = _lastRunMicros + 1; + _lastRunMicros = micros; + return 'run_$micros'; +} + +/// What a [pipelineE2E] body builds its tests from. +final class PipelineE2EContext { + PipelineE2EContext._(this._target) : runId = _nextRunId(); + + final _E2ETarget _target; + + /// Identifies this file's seeded documents; every seeded document carries + /// it in its `runId` field, and their IDs start with it. + final String runId; + + /// The Firestore instance under test. + /// + /// Creating it does no I/O. When the suite is skipped it points at a + /// placeholder project, so declaration-time code can still build document + /// references. + late final Firestore firestore = Firestore( + settings: _target.enabled + ? Settings(projectId: _target.projectId, databaseId: _target.databaseId) + : const Settings( + projectId: 'pipeline-e2e-skipped', + databaseId: 'pipeline-e2e-skipped', + ), + ); + + /// The seeded collection, [pipelineE2ECollection]. + String get collectionPath => pipelineE2ECollection; + + /// The seeded documents: `[book1Ref, book2Ref, book3Ref]`. + late final List> refs = [ + for (var i = 1; i <= 3; i++) + firestore.doc('$pipelineE2ECollection/${runId}_book_$i'), + ]; + + /// Book 1, `'Dart Pipelines'`. Function cases are evaluated against it. + DocumentReference get book1Ref => refs[0]; + + /// Book 2, `'Firestore Admin'`. + DocumentReference get book2Ref => refs[1]; + + /// Book 3, `'Inactive Draft'`. + DocumentReference get book3Ref => refs[2]; + + /// The exact data written for each of [refs], as built by [buildSeed]. + late final List seed = buildSeed(runId, refs); + + /// Restricts a Pipeline to this run's documents, optionally also requiring + /// [condition]. + /// + /// Without [condition] this is a bare equality rather than a one-operand + /// `and`, a shape the Node SDK's `and(first, second, ...more)` never sends. + PipelineBooleanExpression runFilter([PipelineBooleanExpression? condition]) { + final inRun = Expression.field('runId').equal(runId); + return condition == null + ? inRun + : PipelineFunctions.and([inRun, condition]); + } + + /// A Pipeline over this run's three seeded documents. + Pipeline runPipeline() { + return firestore.pipeline().collection(collectionPath).where(runFilter()); + } + + /// A Pipeline over book 1 only. + Pipeline book1Pipeline() { + return firestore + .pipeline() + .collection(collectionPath) + .where(runFilter(Expression.field('title').equal('Dart Pipelines'))); + } + + /// Evaluates [expression] against book 1 and returns the result. + /// + /// Selects `expression.as(valueAlias)` from [book1Pipeline] and reads it + /// back, failing unless exactly one result comes back. + Future evaluate(PipelineExpression expression) async { + final snapshot = await book1Pipeline() + .select([expression.as(valueAlias)]) + .limit(1) + .execute(); + expect(snapshot.results, hasLength(1)); + return snapshot.results.single.get(valueAlias); + } + + /// Aggregates [accumulator] over this run's three documents and returns the + /// result. + Future evaluateAggregate(PipelineExpression accumulator) async { + final snapshot = await runPipeline().aggregate([ + accumulator.as(valueAlias), + ]).execute(); + expect(snapshot.results, hasLength(1)); + return snapshot.results.single.get(valueAlias); + } + + /// Returns the titles [pipeline] produces, in order. + Future> titlesOf(Pipeline pipeline) async { + final snapshot = await pipeline.execute(); + return [for (final result in snapshot.results) result.get('title')]; + } + + /// Declares one test per case, evaluating each expression on book 1. + /// + /// See [FunctionCase] for how [FunctionCase.expected] is compared. + void functionCases(String description, List cases) { + group(description, () { + for (final functionCase in cases) { + test(functionCase.name, () async { + expect( + await evaluate(functionCase.expression), + functionCase.expected, + reason: '$description: ${functionCase.name}', + ); + }); + } + }); + } + + /// Declares one test per case, aggregating each accumulator over the run's + /// three documents. + void aggregateCases(String description, List cases) { + group(description, () { + for (final aggregateCase in cases) { + test(aggregateCase.name, () async { + expect( + await evaluateAggregate(aggregateCase.accumulator), + aggregateCase.expected, + reason: '$description: ${aggregateCase.name}', + ); + }); + } + }); + } + + /// Declares one test per case, filtering the run's documents with each + /// condition and comparing the matching titles, sorted by ascending price + /// (so always in book order). + void filterCases(String description, List cases) { + group(description, () { + for (final filterCase in cases) { + test(filterCase.name, () async { + expect( + await titlesOf( + firestore + .pipeline() + .collection(collectionPath) + .where(runFilter(filterCase.condition)) + .sort([Expression.field('price').ascending()]) + .select([Expression.field('title')]), + ), + filterCase.expectedTitles, + reason: '$description: ${filterCase.name}', + ); + }); + } + }); + } + + Future _seed() async { + await Future.wait([ + for (var i = 0; i < refs.length; i++) refs[i].set(seed[i]), + ]); + } + + Future _deleteSeed() async { + try { + await Future.wait([for (final ref in refs) ref.delete()]); + } finally { + await firestore.terminate(); + } + } +} + +/// An expression evaluated on book 1 by [PipelineE2EContext.functionCases]. +final class FunctionCase { + /// Creates a case named [name]. + const FunctionCase(this.name, this.expression, this.expected); + + /// The test name. + final String name; + + /// The expression under test. Do not alias it; the harness does. + final PipelineExpression expression; + + /// The expected value: a [Matcher], or a value compared with `equals`. + /// + /// `equals` treats `13` and `13.0` as equal; use [isInt] or [isDouble] + /// when the numeric type is part of what is being tested. + final Object? expected; +} + +/// An accumulator evaluated over the run's three documents by +/// [PipelineE2EContext.aggregateCases]. +final class AggregateCase { + /// Creates a case named [name]. + const AggregateCase(this.name, this.accumulator, this.expected); + + /// The test name. + final String name; + + /// The aggregate expression under test. Do not alias it; the harness does. + final PipelineExpression accumulator; + + /// The expected value: a [Matcher], or a value compared with `equals`. + final Object? expected; +} + +/// A condition applied to the run's three documents by +/// [PipelineE2EContext.filterCases]. +final class FilterCase { + /// Creates a case named [name]. + const FilterCase(this.name, this.condition, this.expectedTitles); + + /// The test name. + final String name; + + /// The `where` condition under test; the run filter is added for you. + final PipelineBooleanExpression condition; + + /// The titles of the matching books, in book order: any subset of + /// `'Dart Pipelines'`, `'Firestore Admin'`, `'Inactive Draft'`. + final List expectedTitles; +} + +/// Matches an [int] equal to [value]. +/// +/// Results decode by wire type: an `integerValue` becomes an [int] and a +/// `doubleValue` a [double], so the backend's choice of result type is +/// observable here (unlike in the Node SDK, where both are a JS `number`). +/// For example `add` on two int64 fields returns int64, while `average` returns +/// float64. Use [isInt] / [isDouble] when the result type is part of what a +/// case checks, and [isNumber], which accepts either, when it is not. +Matcher isInt(int value) => allOf(isA(), equals(value)); + +/// Matches a [double] within [tolerance] of [value]. See [isInt]. +Matcher isDouble(num value, {double tolerance = 1e-9}) { + return allOf(isA(), closeTo(value, tolerance)); +} + +/// Matches an [int] or a [double] within [tolerance] of [value]. See [isInt]. +Matcher isNumber(num value, {double tolerance = 1e-9}) { + return allOf(isA(), closeTo(value, tolerance)); +} + +/// Builds the data written for each of [refs] (books 1, 2 and 3). +/// +/// This is the single source of truth for expectations: compute expected +/// values from the table below, never by guessing. Every field exists on all +/// three books unless noted. No document has a field named `missing`, so use +/// it for absent-field cases. +/// +/// | Field | Book 1 | Book 2 | Book 3 | +/// | --- | --- | --- | --- | +/// | `runId` | [runId] | [runId] | [runId] | +/// | `title` | `'Dart Pipelines'` (14 chars) | `'Firestore Admin'` | `'Inactive Draft'` | +/// | `active` | `true` | `true` | `false` | +/// | `archived` | `false` | `false` | `true` | +/// | `price` (int) | `10` | `20` | `30` | +/// | `rating` (int) | `5` | `4` | `2` | +/// | `discount` (int) | `2` | `3` | `4` | +/// | `flags` (int, bit flags) | `6` | `3` | `5` | +/// | `quantity` (int) | `7` | `0` | `-3` | +/// | `zero` (int) | `0` | `0` | `0` | +/// | `largeInt` (int, 2^53 + 1) | `9007199254740993` | same | same | +/// | `score` (double) | `-12.7` | `3.2` | `8.9` | +/// | `ratio` (double) | `0.5` | `1.25` | `2.0` | +/// | `half` (double) | `2.5` | `2.5` | `2.5` | +/// | `negativeHalf` (double) | `-2.5` | `-2.5` | `-2.5` | +/// | `precise` (double) | `4.123456` | same | same | +/// | `nullable` | `null` | `null` | `null` | +/// | `bytes` | `[0x0f, 0xf0]` | `[0xaa, 0x55]` | `[0xff, 0x00]` | +/// | `spaced` | `' Dart '` | `' Admin '` | `' Draft '` | +/// | `padded` | `'__Dart__'` | same | same | +/// | `empty` | `''` | `''` | `''` | +/// | `greeting` | `'Hello, World!'` | `'Hello, Admin!'` | `'Goodbye, Draft!'` | +/// | `prefix` | `'Dart'` | `'Fire'` | `'Draft'` | +/// | `unicode` | `'café ☕ 日本'` (9 code points, 16 UTF-8 bytes) | same | same | +/// | `email` | `'dev.team@example.com'` | `'admin@example.org'` | `'draft@example.net'` | +/// | `sentence` | `'Dart is fun and Dart is fast'` | same | same | +/// | `csv` | `'dart,firebase,admin'` | `'firestore,admin'` | `'inactive,draft'` | +/// | `semicolons` | `'a;b;c'` | same | same | +/// | `delimiter` | `';'` | same | same | +/// | `tags` | `['dart', 'firebase']` | `['firebase']` | `['draft']` | +/// | `words` | `['dart', 'firebase']` | `['firebase']` | `['draft']` | +/// | `numbers` (ints) | `[3, 1, 2, 3]` | `[4, 5]` | `[9]` | +/// | `scores` (doubles) | `[1.5, 2.5, -0.5]` | `[4.0]` | `[0.25, 0.75]` | +/// | `mixedNumbers` | `[1, 2.5, -3, 0]` | same | same | +/// | `booleans` | `[true, false, true]` | same | same | +/// | `withNulls` | `[1, null, 3]` | same | same | +/// | `emptyArray` | `[]` | `[]` | `[]` | +/// | `items` (maps) | `[{name: 'pen', qty: 2, price: 1.5}, {name: 'ink', qty: 1, price: 4.0}]` | `[{name: 'cap', qty: 5, price: 0.5}]` | `[]` | +/// | `metadata` | `{lang: 'dart', category: 'sdk'}` | `{lang: 'dart', category: 'admin'}` | `{lang: 'dart', category: 'draft'}` | +/// | `nested` | `{level1: {level2: {value: 42}}, 'dotted.key': 'dot', list: [1, 2]}` | same | same | +/// | `stats` | `{views: 100, likes: 7}` | `{views: 50, likes: 3}` | `{views: 0, likes: 0}` | +/// | `emptyMap` | `{}` | `{}` | `{}` | +/// | `createdAt` | `Timestamp(1700000000, 0)` (2023-11-14T22:13:20Z) | `Timestamp(1700003600, 0)` | `Timestamp(1700007200, 0)` | +/// | `updatedAt` (createdAt + 1 day + 0.123456 s) | `Timestamp(1700086400, 123456000)` | `Timestamp(1700090000, 123456000)` | `Timestamp(1700093600, 123456000)` | +/// | `unixSeconds` (= createdAt) | `1700000000` | `1700003600` | `1700007200` | +/// | `unixMillis` (= createdAt) | `1700000000000` | `1700003600000` | `1700007200000` | +/// | `unixMicros` (= createdAt) | `1700000000000000` | `1700003600000000` | `1700007200000000` | +/// | `location` (GeoPoint) | `(37.7749, -122.4194)` | `(40.7128, -74.006)` | `(51.5074, -0.1278)` | +/// | `pathRef` (reference) | book 2 | book 1 | book 1 | +/// | `embedding` (vector) | `[1, 0, 0]` | `[0, 1, 0]` | `[0, 0, 1]` | +/// | `sparse` | `'book 1 only'` | absent | absent | +/// +/// Document IDs are `_book_1`, `_book_2` and `_book_3`. +List buildSeed( + String runId, + List> refs, +) { + Timestamp at(int seconds, [int nanoseconds = 0]) { + return Timestamp(seconds: seconds, nanoseconds: nanoseconds); + } + + // Fields whose values do not vary between books. + final shared = { + 'runId': runId, + 'zero': 0, + 'largeInt': 9007199254740993, + 'half': 2.5, + 'negativeHalf': -2.5, + 'precise': 4.123456, + 'nullable': null, + 'padded': '__Dart__', + 'empty': '', + 'unicode': 'café ☕ 日本', + 'sentence': 'Dart is fun and Dart is fast', + 'semicolons': 'a;b;c', + 'delimiter': ';', + 'mixedNumbers': [1, 2.5, -3, 0], + 'booleans': [true, false, true], + 'withNulls': [1, null, 3], + 'emptyArray': [], + 'nested': { + 'level1': { + 'level2': {'value': 42}, + }, + 'dotted.key': 'dot', + 'list': [1, 2], + }, + 'emptyMap': {}, + }; + + return [ + { + ...shared, + 'title': 'Dart Pipelines', + 'active': true, + 'archived': false, + 'price': 10, + 'rating': 5, + 'discount': 2, + 'flags': 6, + 'quantity': 7, + 'score': -12.7, + 'ratio': 0.5, + 'bytes': Uint8List.fromList([0x0f, 0xf0]), + 'spaced': ' Dart ', + 'greeting': 'Hello, World!', + 'prefix': 'Dart', + 'email': 'dev.team@example.com', + 'csv': 'dart,firebase,admin', + 'tags': ['dart', 'firebase'], + 'words': ['dart', 'firebase'], + 'numbers': [3, 1, 2, 3], + 'scores': [1.5, 2.5, -0.5], + 'items': [ + {'name': 'pen', 'qty': 2, 'price': 1.5}, + {'name': 'ink', 'qty': 1, 'price': 4.0}, + ], + 'metadata': {'lang': 'dart', 'category': 'sdk'}, + 'stats': {'views': 100, 'likes': 7}, + 'createdAt': at(1700000000), + 'updatedAt': at(1700086400, 123456000), + 'unixSeconds': 1700000000, + 'unixMillis': 1700000000000, + 'unixMicros': 1700000000000000, + 'location': GeoPoint(latitude: 37.7749, longitude: -122.4194), + 'pathRef': refs[1], + 'embedding': FieldValue.vector([1, 0, 0]), + 'sparse': 'book 1 only', + }, + { + ...shared, + 'title': 'Firestore Admin', + 'active': true, + 'archived': false, + 'price': 20, + 'rating': 4, + 'discount': 3, + 'flags': 3, + 'quantity': 0, + 'score': 3.2, + 'ratio': 1.25, + 'bytes': Uint8List.fromList([0xaa, 0x55]), + 'spaced': ' Admin ', + 'greeting': 'Hello, Admin!', + 'prefix': 'Fire', + 'email': 'admin@example.org', + 'csv': 'firestore,admin', + 'tags': ['firebase'], + 'words': ['firebase'], + 'numbers': [4, 5], + 'scores': [4.0], + 'items': [ + {'name': 'cap', 'qty': 5, 'price': 0.5}, + ], + 'metadata': {'lang': 'dart', 'category': 'admin'}, + 'stats': {'views': 50, 'likes': 3}, + 'createdAt': at(1700003600), + 'updatedAt': at(1700090000, 123456000), + 'unixSeconds': 1700003600, + 'unixMillis': 1700003600000, + 'unixMicros': 1700003600000000, + 'location': GeoPoint(latitude: 40.7128, longitude: -74.006), + 'pathRef': refs[0], + 'embedding': FieldValue.vector([0, 1, 0]), + }, + { + ...shared, + 'title': 'Inactive Draft', + 'active': false, + 'archived': true, + 'price': 30, + 'rating': 2, + 'discount': 4, + 'flags': 5, + 'quantity': -3, + 'score': 8.9, + 'ratio': 2.0, + 'bytes': Uint8List.fromList([0xff, 0x00]), + 'spaced': ' Draft ', + 'greeting': 'Goodbye, Draft!', + 'prefix': 'Draft', + 'email': 'draft@example.net', + 'csv': 'inactive,draft', + 'tags': ['draft'], + 'words': ['draft'], + 'numbers': [9], + 'scores': [0.25, 0.75], + 'items': [], + 'metadata': {'lang': 'dart', 'category': 'draft'}, + 'stats': {'views': 0, 'likes': 0}, + 'createdAt': at(1700007200), + 'updatedAt': at(1700093600, 123456000), + 'unixSeconds': 1700007200, + 'unixMillis': 1700007200000, + 'unixMicros': 1700007200000000, + 'location': GeoPoint(latitude: 51.5074, longitude: -0.1278), + 'pathRef': refs[0], + 'embedding': FieldValue.vector([0, 0, 1]), + }, + ]; +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart new file mode 100644 index 00000000..e6db8b3d --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart @@ -0,0 +1,44 @@ +// 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. + +/// Live coverage of `PipelineSnapshot` and `PipelineResult`: accessors, +/// document metadata and value decoding. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline results', (ctx) { + test('exposes document metadata and nested fields', () async { + final metadataSnapshot = await ctx.book1Pipeline().limit(1).execute(); + + expect(metadataSnapshot.results.single.createTime, isNotNull); + expect(metadataSnapshot.results.single.updateTime, isNotNull); + expect(metadataSnapshot.results.single.ref, isNotNull); + // get resolves nested dot-paths and FieldPaths, as in Node. + expect(metadataSnapshot.results.single.get('metadata.lang'), 'dart'); + expect( + metadataSnapshot.results.single.get( + FieldPath(const ['metadata', 'category']), + ), + 'sdk', + ); + expect(metadataSnapshot.results.single.get('metadata.missing'), isNull); + }); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart new file mode 100644 index 00000000..8d0b4e98 --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart @@ -0,0 +1,73 @@ +// 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. + +/// Live coverage of `Firestore.pipeline` and the `PipelineSource` stages. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline sources', (ctx) { + test('executes a documents source stage', () async { + final snapshot = await ctx.firestore + .pipeline() + .documents([ctx.book1Ref, ctx.book3Ref]) + .sort([Expression.field('price').ascending()]) + .select([Expression.field('title')]) + .execute(); + + expect(snapshot.results.map((result) => result.get('title')), [ + 'Dart Pipelines', + 'Inactive Draft', + ]); + }); + + test('executes a collection group source stage', () async { + final snapshot = await ctx.firestore + .pipeline() + .collectionGroup(ctx.collectionPath) + .where(ctx.runFilter(Expression.field('active').equal(true))) + .sort([Expression.field('price').ascending()]) + .select([Expression.field('title')]) + .execute(); + + expect(snapshot.results.map((result) => result.get('title')), [ + 'Dart Pipelines', + 'Firestore Admin', + ]); + }); + + test('executes a pipeline created from a collection group query', () async { + final snapshot = await ctx.firestore + .pipeline() + .createFrom( + ctx.firestore + .collectionGroup(ctx.collectionPath) + .where('runId', WhereFilter.equal, ctx.runId) + .where('active', WhereFilter.equal, true) + .orderBy('price'), + ) + .execute(); + + expect(snapshot.results.map((result) => result.get('title')), [ + 'Dart Pipelines', + 'Firestore Admin', + ]); + }); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart new file mode 100644 index 00000000..022b58cb --- /dev/null +++ b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart @@ -0,0 +1,110 @@ +// 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. + +/// Live coverage of the `Pipeline` stage methods, orderings and aliases. +@Tags(['prod']) +library; + +import 'package:google_cloud_firestore/google_cloud_firestore.dart'; +import 'package:test/test.dart'; + +import 'harness.dart'; + +void main() { + pipelineE2E('Pipeline stages', (ctx) { + test('where, sort, select and limit', () async { + final snapshot = await ctx.firestore + .pipeline() + .collection(ctx.collectionPath) + .where(ctx.runFilter(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'); + }); + + test('executes an addFields stage', () async { + final snapshot = await ctx.book1Pipeline().addFields([ + Expression.field('rating').as('copiedRating'), + Expression.constant(true).as('annotated'), + ]).execute(); + + expect(snapshot.results, hasLength(1)); + final result = snapshot.results.single; + expect(result.get('copiedRating'), 5); + expect(result.get('annotated'), true); + // Existing fields are kept alongside the added ones. + expect(result.get('title'), 'Dart Pipelines'); + + // A single field must still be sent as a map, not an alias function. + final single = await ctx.book1Pipeline().addFields([ + Expression.field('title').toUpperCase().as('upper'), + ]).execute(); + + expect(single.results.single.get('upper'), 'DART PIPELINES'); + }); + + test('executes vector nearest-neighbor stage', () async { + final vectorSnapshot = await ctx + .runPipeline() + .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); + }); + + test('executes vector nearest-neighbor stage with a list', () async { + final vectorSnapshot = await ctx + .runPipeline() + .findNearest( + vectorField: 'embedding', + queryVector: const [1.0, 0.0, 0.0], + distanceMeasure: DistanceMeasure.cosine, + limit: 1, + distanceResultField: 'distance', + ) + .execute(); + + expect(vectorSnapshot.results, hasLength(1)); + expect(vectorSnapshot.results.single.get('title'), 'Dart Pipelines'); + }); + }); +} diff --git a/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart deleted file mode 100644 index ce052acb..00000000 --- a/packages/google_cloud_firestore/test/e2e/pipeline_e2e_test.dart +++ /dev/null @@ -1,1264 +0,0 @@ -// 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. - -@Tags(['prod']) -library; - -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']; - // No '(default)' fallback on purpose. CI credential helpers such as - // google-github-actions/auth export GOOGLE_CLOUD_PROJECT, so a project-only - // guard would silently arm this suite against whatever project happened to - // be authenticated, using its default database — which may be Standard - // edition (no Pipelines at all) or simply lack the vector index this suite - // needs. Both variables must be set deliberately. - final databaseId = Platform.environment[_databaseIdEnv]; - final shouldRun = - projectId != null && - projectId.isNotEmpty && - databaseId != null && - databaseId.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').equal('Dart Pipelines'), - ), - ) - .limit(1) - .execute(); - - expect(metadataSnapshot.results.single.createTime, isNotNull); - expect(metadataSnapshot.results.single.updateTime, isNotNull); - expect(metadataSnapshot.results.single.ref, isNotNull); - // get resolves nested dot-paths and FieldPaths, as in Node. - expect(metadataSnapshot.results.single.get('metadata.lang'), 'dart'); - expect( - metadataSnapshot.results.single.get( - FieldPath(const ['metadata', 'category']), - ), - 'sdk', - ); - expect(metadataSnapshot.results.single.get('metadata.missing'), isNull); - }); - - test('executes aggregate pipeline stages', () async { - final aggregateSnapshot = await firestore - .pipeline() - .collection(_collectionPath) - .where(_runFilter(runId, Expression.field('active').equal(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); - }); - - test('executes an addFields stage', () async { - final snapshot = await firestore - .pipeline() - .collection(_collectionPath) - .where( - _runFilter( - runId, - Expression.field('title').equal('Dart Pipelines'), - ), - ) - .addFields([ - Expression.field('rating').as('copiedRating'), - Expression.constant(true).as('annotated'), - ]) - .execute(); - - expect(snapshot.results, hasLength(1)); - final result = snapshot.results.single; - expect(result.get('copiedRating'), 5); - expect(result.get('annotated'), true); - // Existing fields are kept alongside the added ones. - expect(result.get('title'), 'Dart Pipelines'); - - // A single field must still be sent as a map, not an alias function. - final single = await firestore - .pipeline() - .collection(_collectionPath) - .where( - _runFilter( - runId, - Expression.field('title').equal('Dart Pipelines'), - ), - ) - .addFields([Expression.field('title').toUpperCase().as('upper')]) - .execute(); - - expect(single.results.single.get('upper'), 'DART PIPELINES'); - }); - - test('requests explain stats via typed options', () async { - final snapshot = await firestore - .pipeline() - .collection(_collectionPath) - .where(_runFilter(runId, Expression.field('active').equal(true))) - .execute( - explain: const PipelineExplainOptions( - mode: PipelineExplainMode.analyze, - outputFormat: PipelineExplainOutputFormat.text, - ), - ); - - // `analyze` runs the pipeline and returns planning stats alongside the - // results, which is what proves the option names reached the backend. - expect(snapshot.results, isNotEmpty); - expect(snapshot.explainStats, isNotNull); - expect(snapshot.explainStats!.text, isNotEmpty); - }); - - test('executes a pipeline inside a transaction', () async { - final titles = await firestore.runTransaction((transaction) async { - final snapshot = await transaction.executePipeline( - firestore - .pipeline() - .collection(_collectionPath) - .where(_runFilter(runId, Expression.field('active').equal(true))) - .sort([Expression.field('price').ascending()]) - .select([Expression.field('title')]), - ); - return [for (final result in snapshot.results) result.get('title')]; - }, transactionOptions: ReadOnlyTransactionOptions()); - - expect(titles, ['Dart Pipelines', 'Firestore Admin']); - }); - - test('executes a documents source stage', () async { - final snapshot = await firestore - .pipeline() - .documents([docs[0], docs[2]]) - .sort([Expression.field('price').ascending()]) - .select([Expression.field('title')]) - .execute(); - - expect(snapshot.results.map((result) => result.get('title')), [ - 'Dart Pipelines', - 'Inactive Draft', - ]); - }); - - test('filters with a search space holding expressions', () async { - // Regression: the list was sent as a literal array value, which the - // backend rejected with "Value type is not supported: - // FIELD_REFERENCE_VALUE". - final snapshot = await firestore - .pipeline() - .collection(_collectionPath) - .where( - _runFilter( - runId, - PipelineFunctions.equalAny('discount', [ - Expression.field('flags'), - 2, - ]), - ), - ) - .sort([Expression.field('price').ascending()]) - .select([Expression.field('title')]) - .execute(); - - // Book 1 matches the literal 2, book 2 its own `flags` (3). - expect(snapshot.results.map((result) => result.get('title')), [ - 'Dart Pipelines', - 'Firestore Admin', - ]); - }); - - test('executes a collection group source stage', () async { - final snapshot = await firestore - .pipeline() - .collectionGroup(_collectionPath) - .where(_runFilter(runId, Expression.field('active').equal(true))) - .sort([Expression.field('price').ascending()]) - .select([Expression.field('title')]) - .execute(); - - expect(snapshot.results.map((result) => result.get('title')), [ - 'Dart Pipelines', - 'Firestore Admin', - ]); - }); - - test('executes a pipeline created from a collection group query', () async { - final snapshot = await firestore - .pipeline() - .createFrom( - firestore - .collectionGroup(_collectionPath) - .where('runId', WhereFilter.equal, runId) - .where('active', WhereFilter.equal, true) - .orderBy('price'), - ) - .execute(); - - expect(snapshot.results.map((result) => result.get('title')), [ - 'Dart Pipelines', - 'Firestore Admin', - ]); - }); - - 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').equal('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').equal(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').equal(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); - }); - - test('executes vector nearest-neighbor stage with a list', () async { - final vectorSnapshot = await firestore - .pipeline() - .collection(_collectionPath) - .where(Expression.field('runId').equal(runId)) - .findNearest( - vectorField: 'embedding', - queryVector: const [1.0, 0.0, 0.0], - distanceMeasure: DistanceMeasure.cosine, - limit: 1, - distanceResultField: 'distance', - ) - .execute(); - - expect(vectorSnapshot.results, hasLength(1)); - expect(vectorSnapshot.results.single.get('title'), 'Dart Pipelines'); - }); - }); -} - -const _skipReason = - 'Set both FIRESTORE_PIPELINE_E2E_PROJECT_ID and ' - 'FIRESTORE_PIPELINE_E2E_DATABASE_ID to run against a real Firebase ' - 'project. The database must be Enterprise edition; Pipelines are not ' - 'available on Standard. CI authenticates with Application Default ' - 'Credentials, for example via Workload Identity Federation.'; - -PipelineBooleanExpression _runFilter( - String runId, [ - PipelineBooleanExpression? condition, -]) { - return PipelineFunctions.and([ - Expression.field('runId').equal(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').add(2), 12), - _FunctionExpectation('subtract', Expression.field('price').subtract(3), 7), - _FunctionExpectation('multiply', Expression.field('price').multiply(2), 20), - _FunctionExpectation('divide', Expression.field('price').divide(2), 5), - _FunctionExpectation('mod', Expression.field('price').mod(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( - 'roundDecimalPlaces', - Expression.constant(12.68).round(1), - closeTo(12.7, 0.0001), - ), - _FunctionExpectation( - 'truncDecimalPlaces', - Expression.constant(12.89).trunc(1), - closeTo(12.8, 0.0001), - ), - _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').arrayContains('dart'), - true, - ), - _FunctionExpectation( - 'arrayContainsElement', - Expression.field('tags').arrayContains(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').notEqual('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').add(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( - 'arrayMaximum', - Expression.field('numbers').arrayMaximum(), - 3, - ), - _FunctionExpectation( - 'maximumN', - Expression.field('numbers').arrayMaximumN(2), - [3, 3], - ), - _FunctionExpectation( - 'arrayMinimum', - Expression.field('numbers').arrayMinimum(), - 1, - ), - _FunctionExpectation( - 'minimumN', - Expression.field('numbers').arrayMinimumN(2), - [1, 2], - ), - _FunctionExpectation('arraySum', Expression.field('numbers').arraySum(), 9), - // The static helpers must emit the same backend names as the fluent - // forms (`maximum`, not `array_maximum`). - _FunctionExpectation( - 'staticArrayMaximum', - PipelineFunctions.arrayMaximum('numbers'), - 3, - ), - _FunctionExpectation( - 'staticArrayMaximumN', - PipelineFunctions.arrayMaximumN('numbers', 2), - [3, 3], - ), - _FunctionExpectation( - 'staticArrayMinimum', - PipelineFunctions.arrayMinimum('numbers'), - 1, - ), - _FunctionExpectation( - 'staticArrayMinimumN', - PipelineFunctions.arrayMinimumN('numbers', 2), - [1, 2], - ), - _FunctionExpectation( - 'staticArraySum', - PipelineFunctions.arraySum('numbers'), - 9, - ), - _FunctionExpectation( - 'join', - Expression.field('words').joinLiteral('-'), - 'dart-firebase', - ), - _FunctionExpectation( - 'joinStatic', - PipelineFunctions.join('words', ', '), - 'dart, firebase', - ), - ]), - _FunctionScenario('comparison functions', [ - _FunctionExpectation('equal', Expression.field('price').equal(10), true), - _FunctionExpectation( - 'notEqual', - Expression.field('price').notEqual(11), - true, - ), - _FunctionExpectation( - 'greaterThan', - Expression.field('price').greaterThan(9), - true, - ), - _FunctionExpectation( - 'greaterThanOrEqual', - Expression.field('price').greaterThanOrEqual(10), - true, - ), - _FunctionExpectation( - 'lessThan', - Expression.field('price').lessThan(11), - true, - ), - _FunctionExpectation( - 'lessThanOrEqual', - Expression.field('price').lessThanOrEqual(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').ifAbsent('fallback'), - 'fallback', - ), - _FunctionExpectation('isError', Expression.field('title').isError(), false), - _FunctionExpectation( - 'ifError', - Expression.field('title').ifError('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', - ), - // The first argument is a field position: a bare String means a field - // reference, not a string literal. Passing 'dart' here asked about a - // non-existent field named `dart`, which made equalAny false and made - // notEqualAny true for the wrong reason. - _FunctionExpectation( - 'equalAny', - PipelineFunctions.equalAny( - 'title', - Expression.array(['Dart Pipelines', 'Unrelated Title']), - ), - true, - ), - _FunctionExpectation( - 'notEqualAny', - PipelineFunctions.notEqualAny( - 'title', - Expression.array(['Unrelated Title', 'Another Title']), - ), - 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( - 'mapRemoveExpressionKey', - Expression.field('metadata').mapRemove(Expression.constant('lang')), - isNot(contains('lang')), - ), - _FunctionExpectation( - 'mapRemoveChained', - Expression.field('metadata').mapRemove('lang').mapRemove('category'), - isEmpty, - ), - _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>(), - ), - ]), - // Collections that hold expressions must be built with array(...) / - // map(...); the backend rejects them inside a literal array or map value. - _FunctionScenario('collections holding expressions', [ - _FunctionExpectation( - 'equalAny', - PipelineFunctions.equalAny('rating', [Expression.field('score'), 5]), - true, - ), - _FunctionExpectation( - 'equalAnyMatchesExpression', - Expression.field( - 'price', - ).equalAny([Expression.field('rating').multiply(2), 3]), - true, - ), - _FunctionExpectation( - 'notEqualAny', - PipelineFunctions.notEqualAny('rating', [Expression.field('price'), 4]), - true, - ), - _FunctionExpectation( - 'arrayContainsAll', - PipelineFunctions.arrayContainsAll('tags', [ - Expression.field('metadata').mapGetLiteral('lang'), - 'firebase', - ]), - true, - ), - _FunctionExpectation( - 'arrayContainsAny', - Expression.field('tags').arrayContainsAny([ - Expression.field('metadata').mapGetLiteral('lang'), - 'missing', - ]), - true, - ), - _FunctionExpectation( - 'arrayConcat', - Expression.field('tags').arrayConcat([Expression.field('title')]), - ['dart', 'firebase', 'Dart Pipelines'], - ), - _FunctionExpectation( - 'mapMerge', - Expression.field('metadata').mapMerge([ - {'title': Expression.field('title')}, - ]), - containsPair('title', 'Dart Pipelines'), - ), - _FunctionExpectation( - 'equal', - Expression.field( - 'tags', - ).equal([Expression.field('metadata').mapGetLiteral('lang'), 'firebase']), - true, - ), - _FunctionExpectation( - 'nestedArray', - Expression.array([ - 1, - [Expression.field('price')], - ]), - [ - 1, - [10], - ], - ), - _FunctionExpectation( - 'nestedMap', - PipelineFunctions.map([ - 'nested', - {'price': Expression.field('price')}, - ]), - { - 'nested': {'price': 10}, - }, - ), - ]), - _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', - ), - // The second argument is a length, not an end index. - _FunctionExpectation( - 'substringLength', - Expression.field('title').substring(5, 3), - 'Pip', - ), - _FunctionExpectation( - 'substringToEnd', - Expression.field('title').substring(5), - 'Pipelines', - ), - _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', - ]), - _FunctionExpectation('splitStatic', PipelineFunctions.split('csv', ','), [ - 'dart', - 'firebase', - 'admin', - ]), - ]), - _FunctionScenario('timestamp and type functions', [ - _FunctionExpectation( - 'currentTimestamp', - PipelineFunctions.currentTimestamp(), - isA(), - ), - _FunctionExpectation( - 'timestampTrunc', - Expression.field('createdAt').timestampTruncate('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, - ), - _FunctionExpectation( - 'isTypeInt64', - Expression.field('price').isType(PipelineValueType.int64), - true, - ), - _FunctionExpectation( - 'isTypeFloat64', - Expression.field('score').isType(PipelineValueType.double), - true, - ), - _FunctionExpectation( - 'isTypeNotFloat64', - Expression.field('price').isType(PipelineValueType.double), - false, - ), - ]), - _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, - ), - ]), - // A plain list of numbers must reach the backend as a vector, not an array. - _FunctionScenario('vector functions with list arguments', [ - _FunctionExpectation( - 'cosineDistance', - Expression.field('embedding').cosineDistance(const [1.0, 0.0, 0.0]), - closeTo(0, 0.0001), - ), - _FunctionExpectation( - 'dotProduct', - PipelineFunctions.dotProduct('embedding', const [1, 0, 0]), - closeTo(1, 0.0001), - ), - _FunctionExpectation( - 'euclideanDistance', - Expression.field('embedding').euclideanDistance(const [1.0, 0.0, 0.0]), - closeTo(0, 0.0001), - ), - ]), -]; - -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_e2e_coverage_test.dart b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart new file mode 100644 index 00000000..a4662a9f --- /dev/null +++ b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart @@ -0,0 +1,1294 @@ +// 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. + +/// Static guard over the live Pipeline E2E suite in `test/e2e/pipeline/`. +/// +/// The live suite needs credentials, so it only runs in its own workflow; this +/// guard runs in the ordinary credential-free `dart test`. It resolves the E2E +/// sources with `package:analyzer` and checks them against the Pipeline API +/// derived from the resolved library, so a newly added public function fails +/// here until it gets a live case: +/// +/// 1. **Member coverage.** Every public member declared in +/// `lib/src/pipeline.dart` — top-level functions, static and instance +/// methods, getters and constructors of its classes, and every enum value — +/// plus `Firestore.pipeline` and `Transaction.executePipeline`, is +/// referenced somewhere under `test/e2e/pipeline/`. References match by +/// resolved element, so the static `PipelineFunctions.round` and the fluent +/// `PipelineExpression.round` are distinct members, as are an override such +/// as `PipelineField.ascending` and `PipelineExpression.ascending`. Public +/// declarations of `pipeline.dart` missing from the barrel are reported +/// too. +/// 2. **Optional parameters.** Each optional parameter of those members is +/// supplied by at least one invocation and omitted by another. An explicit +/// `null` argument counts as omitted. +/// 3. **Value positions.** A parameter typed `Object`, `Object?` or `dynamic` +/// receives an argument whose static type is a `PipelineExpression` in at +/// least one invocation, and a plain value (a literal, a field-name +/// `String`, a number, ...) in another. For a parameter typed +/// `Iterable` or `Iterable` the same applies to the +/// elements of list literals passed to it. +/// 4. **Required shapes.** Each parameter listed in [_requiredShapes] +/// receives every listed argument shape at least once, and every +/// enum-typed parameter (such as `findNearest`'s `distanceMeasure`) +/// receives every value of its enum. +/// +/// [_notExecutable] and [_notValuePositions] exempt the few members and +/// parameters these rules cannot apply to. Entries that name nothing, or that +/// no longer prevent a gap, fail the guard, as do stale entries in the shape +/// and family tables. +/// +/// The E2E files must also import only the public barrel, `package:test`, +/// the harness and `dart:` libraries, be tagged `prod`, and declare their tests +/// through `pipelineE2E`. +/// +/// **Adding a Pipeline API member:** add a live case for it — and for each of +/// its optional and value-position parameters — to the file of its family +/// (see [_families] and [_familyOf]), or this guard fails. A member whose name +/// matches no family table fails the guard until it is assigned one. +@Timeout(Duration(minutes: 5)) +library; + +import 'dart:io'; +import 'dart:isolate'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/ast/visitor.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:analyzer/dart/element/type_system.dart'; +import 'package:analyzer/diagnostic/diagnostic.dart'; +import 'package:test/test.dart'; + +/// The E2E sources this guard scans, relative to the package root. +const _e2eDirectory = 'test/e2e/pipeline'; + +const _barrelUri = 'package:google_cloud_firestore/google_cloud_firestore.dart'; + +/// The library whose `pipeline.dart` part declares the Pipeline API. +const _implementationUri = 'package:google_cloud_firestore/src/firestore.dart'; + +const _pipelinePart = 'package:google_cloud_firestore/src/pipeline.dart'; + +/// Pipeline API members declared outside `pipeline.dart`. +const _entryPoints = { + 'Firestore': ['pipeline'], + 'Transaction': ['executePipeline'], +}; + +/// Members of `Object`, which are never part of the universe. +const _objectMembers = { + '==', + 'hashCode', + 'noSuchMethod', + 'runtimeType', + 'toString', +}; + +/// The families the API is split into, with the E2E file owning each. +/// +/// The function families follow the README's "Function reference" table: +/// Generic (`length`, `reverse`, `concat`) belongs to `string`, Reference to +/// `map_reference`, and Type, Debug and Search to `type_debug`. +const _families = { + 'sources': 'sources_test.dart', + 'stages': 'stages_test.dart', + 'results': 'results_test.dart', + 'execute': 'execute_test.dart', + 'aggregates': 'aggregates_test.dart', + 'comparison_logical': 'functions_comparison_logical_test.dart', + 'arithmetic': 'functions_arithmetic_test.dart', + 'string': 'functions_string_test.dart', + 'array': 'functions_array_test.dart', + 'map_reference': 'functions_map_reference_test.dart', + 'timestamp_vector': 'functions_timestamp_vector_test.dart', + 'type_debug': 'functions_type_debug_test.dart', +}; + +/// Family overrides for single members, checked first by [_familyOf]. +const _familyByMember = { + 'Pipeline.aggregate': 'aggregates', + 'Pipeline.execute': 'execute', + 'Pipeline.firestore': 'execute', + 'PipelineField.path': 'type_debug', +}; + +/// Families by declaring class, checked second by [_familyOf]. +const _familyByClass = { + 'Firestore': 'sources', + 'Pipeline': 'stages', + 'PipelineAliasedExpression': 'stages', + 'PipelineExplainMode': 'execute', + 'PipelineExplainOptions': 'execute', + 'PipelineExplainOutputFormat': 'execute', + 'PipelineIndexMode': 'execute', + 'PipelineResult': 'results', + 'PipelineSnapshot': 'results', + 'PipelineSource': 'sources', + 'PipelineValueType': 'type_debug', + 'Transaction': 'execute', +}; + +/// Families by member name, checked last by [_familyOf]. +/// +/// Shared by every form of a function: static (`PipelineFunctions`, +/// `Expression`), fluent (`PipelineExpression`, `PipelineBooleanExpression`, +/// `PipelineField`) and top-level. +const _familyByName = { + // Comparison and logical. + 'and': 'comparison_logical', + 'cmp': 'comparison_logical', + 'coalesce': 'comparison_logical', + 'conditional': 'comparison_logical', + 'equal': 'comparison_logical', + 'equalAny': 'comparison_logical', + 'greaterThan': 'comparison_logical', + 'greaterThanOrEqual': 'comparison_logical', + 'ifNull': 'comparison_logical', + 'lessThan': 'comparison_logical', + 'lessThanOrEqual': 'comparison_logical', + 'nor': 'comparison_logical', + 'not': 'comparison_logical', + 'notEqual': 'comparison_logical', + 'notEqualAny': 'comparison_logical', + 'or': 'comparison_logical', + 'switchOn': 'comparison_logical', + 'xor': 'comparison_logical', + // Aggregates. + 'arrayAgg': 'aggregates', + 'arrayAggDistinct': 'aggregates', + 'average': 'aggregates', + 'count': 'aggregates', + 'countAll': 'aggregates', + 'countDistinct': 'aggregates', + 'countIf': 'aggregates', + 'first': 'aggregates', + 'last': 'aggregates', + 'maximum': 'aggregates', + 'minimum': 'aggregates', + 'sum': 'aggregates', + // Arithmetic. + 'abs': 'arithmetic', + 'add': 'arithmetic', + 'ceil': 'arithmetic', + 'divide': 'arithmetic', + 'exp': 'arithmetic', + 'floor': 'arithmetic', + 'ln': 'arithmetic', + 'log': 'arithmetic', + 'log10': 'arithmetic', + 'logicalMaximum': 'arithmetic', + 'logicalMinimum': 'arithmetic', + 'mod': 'arithmetic', + 'multiply': 'arithmetic', + 'pow': 'arithmetic', + 'rand': 'arithmetic', + 'round': 'arithmetic', + 'sqrt': 'arithmetic', + 'subtract': 'arithmetic', + 'trunc': 'arithmetic', + // Array. + 'array': 'array', + 'arrayConcat': 'array', + 'arrayConcatMultiple': 'array', + 'arrayContains': 'array', + 'arrayContainsAll': 'array', + 'arrayContainsAllFrom': 'array', + 'arrayContainsAny': 'array', + 'arrayFilter': 'array', + 'arrayFirst': 'array', + 'arrayFirstN': 'array', + 'arrayGet': 'array', + 'arrayIndexOf': 'array', + 'arrayIndexOfAll': 'array', + 'arrayLast': 'array', + 'arrayLastIndexOf': 'array', + 'arrayLastN': 'array', + 'arrayLength': 'array', + 'arrayMaximum': 'array', + 'arrayMaximumN': 'array', + 'arrayMinimum': 'array', + 'arrayMinimumN': 'array', + 'arrayReverse': 'array', + 'arraySlice': 'array', + 'arraySum': 'array', + 'arrayTransform': 'array', + 'arrayTransformWithIndex': 'array', + 'join': 'array', + 'joinLiteral': 'array', + 'maximumN': 'array', + 'minimumN': 'array', + 'variable': 'array', + // String, including the generic length / reverse / concat. + 'byteLength': 'string', + 'charLength': 'string', + 'concat': 'string', + 'endsWith': 'string', + 'length': 'string', + 'like': 'string', + 'ltrim': 'string', + 'regexContains': 'string', + 'regexFind': 'string', + 'regexFindAll': 'string', + 'regexMatch': 'string', + 'reverse': 'string', + 'rtrim': 'string', + 'split': 'string', + 'splitLiteral': 'string', + 'startsWith': 'string', + 'stringConcat': 'string', + 'stringContains': 'string', + 'stringIndexOf': 'string', + 'stringRepeat': 'string', + 'stringReplaceAll': 'string', + 'stringReplaceAllLiteral': 'string', + 'stringReplaceOne': 'string', + 'stringReplaceOneLiteral': 'string', + 'stringReverse': 'string', + 'substring': 'string', + 'substringLiteral': 'string', + 'toLower': 'string', + 'toLowerCase': 'string', + 'toUpper': 'string', + 'toUpperCase': 'string', + 'trim': 'string', + // Map and reference. + 'collectionId': 'map_reference', + 'currentDocument': 'map_reference', + 'documentId': 'map_reference', + 'getField': 'map_reference', + 'map': 'map_reference', + 'mapEntries': 'map_reference', + 'mapGet': 'map_reference', + 'mapGetLiteral': 'map_reference', + 'mapKeys': 'map_reference', + 'mapMerge': 'map_reference', + 'mapRemove': 'map_reference', + 'mapSet': 'map_reference', + 'mapValues': 'map_reference', + 'parent': 'map_reference', + 'referenceSlice': 'map_reference', + // Timestamp and vector. + 'cosineDistance': 'timestamp_vector', + 'currentTimestamp': 'timestamp_vector', + 'dotProduct': 'timestamp_vector', + 'euclideanDistance': 'timestamp_vector', + 'geoDistance': 'timestamp_vector', + 'timestampAdd': 'timestamp_vector', + 'timestampDiff': 'timestamp_vector', + 'timestampExtract': 'timestamp_vector', + 'timestampSubtract': 'timestamp_vector', + 'timestampToUnixMicros': 'timestamp_vector', + 'timestampToUnixMillis': 'timestamp_vector', + 'timestampToUnixSeconds': 'timestamp_vector', + 'timestampTruncate': 'timestamp_vector', + 'unixMicrosToTimestamp': 'timestamp_vector', + 'unixMillisToTimestamp': 'timestamp_vector', + 'unixSecondsToTimestamp': 'timestamp_vector', + 'vector': 'timestamp_vector', + 'vectorLength': 'timestamp_vector', + // Type, debugging, search and expression primitives. + 'asBoolean': 'type_debug', + 'constant': 'type_debug', + 'documentMatches': 'type_debug', + 'exists': 'type_debug', + 'field': 'type_debug', + 'ifAbsent': 'type_debug', + 'ifError': 'type_debug', + 'isAbsent': 'type_debug', + 'isError': 'type_debug', + 'isType': 'type_debug', + 'pipelineFunction': 'type_debug', + 'raw': 'type_debug', + 'score': 'type_debug', + 'type': 'type_debug', + // Orderings and aliases, used by the sort and projection stages. + 'as': 'stages', + 'ascending': 'stages', + 'descending': 'stages', +}; + +/// The argument shapes [_requiredShapes] can demand. +enum _Shape { + /// The argument is a list, set or map literal holding a `PipelineExpression` + /// at any depth. + collectionWithExpression( + 'a list/set/map literal holding a PipelineExpression', + ), + + /// The argument is a list literal with an element that is itself a list, + /// set or map literal holding a `PipelineExpression`. + nestedCollectionWithExpression( + 'a list literal whose element is a list/set/map literal holding a ' + 'PipelineExpression', + ), + + /// The argument is a non-empty list literal of numbers. + numericList('a list literal of numbers'), + + /// The argument is a `String` literal containing a dot. + dottedPath("a dotted String literal such as 'a.b'"), + + /// The argument's static type is `FieldPath`. + fieldPath('a FieldPath'); + + const _Shape(this.description); + + final String description; +} + +/// Argument shapes that must each be exercised at least once (rule 4). +/// +/// Collections holding expressions: wherever the Node SDK types a parameter +/// `Array`, `unknown[]` or `Record` — +/// and wherever it takes `unknown`, which it converts with +/// `valueToDefaultExpr` — a Dart collection literal may hold expressions and +/// must reach the backend as an `array(...)` / `map(...)` function rather +/// than a literal value. Each distinct encoding path appears here at least +/// once: the search-space functions, the generic value path, the variadic +/// first-argument path and nested construction. +/// +/// Numeric lists: vector positions accept a plain list of numbers, which must +/// be sent as a vector rather than an array. +const _requiredShapes = >{ + // Search spaces. + 'PipelineFunctions.equalAny(searchSpace)': [_Shape.collectionWithExpression], + 'PipelineFunctions.notEqualAny(searchSpace)': [ + _Shape.collectionWithExpression, + ], + 'PipelineFunctions.arrayContainsAll(searchValues)': [ + _Shape.collectionWithExpression, + ], + 'PipelineFunctions.arrayContainsAny(searchValues)': [ + _Shape.collectionWithExpression, + ], + 'PipelineExpression.equalAny(searchSpace)': [_Shape.collectionWithExpression], + 'PipelineExpression.notEqualAny(searchSpace)': [ + _Shape.collectionWithExpression, + ], + 'PipelineExpression.arrayContainsAll(values)': [ + _Shape.collectionWithExpression, + ], + 'PipelineExpression.arrayContainsAny(values)': [ + _Shape.collectionWithExpression, + ], + // Generic value positions. + 'equal(right)': [_Shape.collectionWithExpression], + 'PipelineFunctions.equal(right)': [_Shape.collectionWithExpression], + 'PipelineExpression.equal(other)': [_Shape.collectionWithExpression], + 'PipelineFunctions.conditional(trueCase)': [_Shape.collectionWithExpression], + 'PipelineBooleanExpression.conditional(thenExpression)': [ + _Shape.collectionWithExpression, + ], + 'PipelineExpression.mapSet(value)': [_Shape.collectionWithExpression], + 'PipelineExpression.arrayConcat(secondArray)': [ + _Shape.collectionWithExpression, + ], + // Variadic arguments and nested construction. + 'PipelineFunctions.arrayConcat(arrays)': [ + _Shape.nestedCollectionWithExpression, + ], + 'PipelineExpression.arrayConcatMultiple(otherArrays)': [ + _Shape.nestedCollectionWithExpression, + ], + 'PipelineFunctions.mapMerge(maps)': [_Shape.nestedCollectionWithExpression], + 'PipelineExpression.mapMerge(maps)': [_Shape.nestedCollectionWithExpression], + 'PipelineFunctions.mapSet(keyValues)': [ + _Shape.nestedCollectionWithExpression, + ], + 'PipelineFunctions.array(values)': [_Shape.nestedCollectionWithExpression], + 'Expression.array(values)': [_Shape.nestedCollectionWithExpression], + 'PipelineFunctions.map(keyValues)': [_Shape.nestedCollectionWithExpression], + // Vector positions. + 'PipelineFunctions.cosineDistance(right)': [_Shape.numericList], + 'PipelineFunctions.dotProduct(right)': [_Shape.numericList], + 'PipelineFunctions.euclideanDistance(right)': [_Shape.numericList], + 'PipelineExpression.cosineDistance(other)': [_Shape.numericList], + 'PipelineExpression.dotProduct(other)': [_Shape.numericList], + 'PipelineExpression.euclideanDistance(other)': [_Shape.numericList], + 'Pipeline.findNearest(queryVector)': [_Shape.numericList], + // Result paths. + 'PipelineResult.get(field)': [_Shape.dottedPath, _Shape.fieldPath], +}; + +/// Members (`Member`) and parameters (`Member(param)`) that cannot run +/// against the E2E database, exempt from every rule. +/// +/// Keep this minimal: anything the backend can execute gets a live case. +const _notExecutable = { + 'Pipeline.search': + 'needs a full-text search index, which pipeline_e2e_books does not ' + 'have; the Node SDK system tests do not cover search either ' + '(TODO(search))', + 'PipelineExpression.geoDistance': 'only valid inside a search stage', + 'PipelineFunctions.documentMatches': 'only valid inside a search stage', + 'PipelineFunctions.geoDistance': 'only valid inside a search stage', + 'PipelineFunctions.score': 'only valid inside a search stage', + 'documentMatches': 'only valid inside a search stage', + 'score': 'only valid inside a search stage', + 'Expression.raw(options)': + 'no backend function takes options yet (the Node SDK only uses them for ' + 'the disabled snippet function); an empty map equals omitting it', + 'pipelineFunction(options)': + 'no backend function takes options yet (the Node SDK only uses them for ' + 'the disabled snippet function); an empty map equals omitting it', +}; + +/// `Object`-typed parameters (`Member(param)`) that are not Pipeline value +/// positions, exempt from rule 3. +const _notValuePositions = { + 'Expression.constant(value)': + 'wraps a literal value; an expression is not a constant', + 'PipelineResult.get(field)': + 'takes a String or a FieldPath; see the dottedPath and fieldPath shapes', + 'PipelineSource.createFrom(query)': 'takes a Query or a VectorQuery', + 'constant(value)': 'wraps a literal value; an expression is not a constant', +}; + +/// The imports an E2E file may use besides `dart:` libraries. +const _allowedImports = {_barrelUri, 'package:test/test.dart', 'harness.dart'}; + +void main() { + late final _Analysis analysis; + + setUpAll(() async { + analysis = await _Analysis.run(); + }); + + test('the E2E sources resolve without errors', () { + expect(analysis.diagnostics, isEmpty); + }); + + test('the E2E sources follow the suite conventions', () { + expect(analysis.conventionErrors, isEmpty); + }); + + test('the guard tables match the Pipeline API', () { + expect(analysis.configurationErrors, isEmpty); + }); + + test('every Pipeline API member has live E2E coverage', () { + final report = analysis.coverageReport(); + if (report != null) fail(report); + }); +} + +/// A member of the Pipeline API universe. +final class _Member { + _Member(this.key, this.element); + + /// `Class.member`, `Class.new` for an unnamed constructor, or the bare name + /// of a top-level function. + final String key; + final Element element; + + late final String? family = _familyOf(this); + + late final List parameters = switch (element) { + final ExecutableElement executable => executable.formalParameters, + _ => const [], + }; + + String get name => element.name!; + + String? get className => switch (element.enclosingElement) { + final InterfaceElement owner => owner.name, + _ => null, + }; +} + +/// Returns the family that owns [member], or `null` when no table assigns +/// one. +String? _familyOf(_Member member) { + return _familyByMember[member.key] ?? + _familyByClass[member.className] ?? + _familyByName[member.name]; +} + +/// How rule 3 treats a parameter. +enum _ValueKind { + /// Not a value position. + none, + + /// Typed `Object`, `Object?` or `dynamic`. + value, + + /// Typed `Iterable` (or `List`) of `Object`, `Object?` or `dynamic`. + iterable, +} + +/// What the E2E sources do with one parameter of one member. +final class _ParameterUsage { + _ParameterUsage(this.parameter, this.kind); + + final FormalParameterElement parameter; + final _ValueKind kind; + + bool supplied = false; + bool omitted = false; + bool expression = false; + bool plain = false; + final shapes = <_Shape>{}; + + /// The enum whose values this parameter takes, if it is enum-typed. + late final EnumElement? enumElement = switch (parameter.type) { + InterfaceType(element: final EnumElement element) => element, + _ => null, + }; + + /// The names of the [enumElement] values passed to this parameter. + final enumValues = {}; +} + +/// A coverage gap, attributed to a member's family. +final class _Gap { + _Gap( + this.member, + this.rule, + this.subject, + this.message, { + this.waivable = true, + }); + + final _Member member; + final int rule; + + /// Whether an exemption can waive this gap. A missing export never is. + final bool waivable; + + /// The exemption key this gap would be waived by: the member key, or + /// `Member(param)`. + final String subject; + final String message; + + String get line => '$subject: $message'; +} + +const _ruleTitles = { + 1: 'Rule 1: member coverage', + 2: 'Rule 2: optional parameters', + 3: 'Rule 3: value positions', + 4: 'Rule 4: required shapes', +}; + +final class _Analysis { + _Analysis._(this._packageRoot); + + final String _packageRoot; + + final diagnostics = []; + final conventionErrors = []; + final configurationErrors = []; + + /// The universe, keyed by [_Member.key]. + final members = {}; + + /// Universe members that are not exported from the barrel. + final unexported = <_Member>[]; + + final referenced = {}; + + /// `Member(param)` to its usage. + final usages = {}; + + late final TypeSystem _typeSystem; + late final InterfaceType _expressionType; + late final InterfaceType _fieldPathType; + late final InterfaceType _numType; + + static Future<_Analysis> run() async { + final barrel = await Isolate.resolvePackageUri(Uri.parse(_barrelUri)); + final packageRoot = File.fromUri(barrel!).parent.parent.path; + final analysis = _Analysis._(packageRoot); + await analysis._analyze(barrel.toFilePath()); + return analysis; + } + + String get _e2ePath => '$_packageRoot/$_e2eDirectory'; + + Future _analyze(String barrelPath) async { + final sources = + Directory(_e2ePath) + .listSync(recursive: true) + .whereType() + .map((file) => file.absolute.path) + .where((path) => path.endsWith('.dart')) + .toList() + ..sort(); + + final collection = AnalysisContextCollection( + includedPaths: [barrelPath, ...sources], + ); + final session = collection.contextFor(barrelPath).currentSession; + + final barrel = + await session.getLibraryByUri(_barrelUri) as LibraryElementResult; + final implementation = + await session.getLibraryByUri(_implementationUri) + as LibraryElementResult; + _buildUniverse(barrel.element, implementation.element); + + for (final path in sources) { + final result = await session.getResolvedUnit(path); + if (result is! ResolvedUnitResult) { + diagnostics.add('${_relative(path)}: could not be resolved ($result)'); + continue; + } + _checkUnit(result); + } + + _checkConfiguration(); + } + + // --------------------------------------------------------------------------- + // Universe. + + void _buildUniverse(LibraryElement barrel, LibraryElement implementation) { + _typeSystem = implementation.typeSystem; + _expressionType = implementation.getClass('PipelineExpression')!.thisType; + _fieldPathType = implementation.getClass('FieldPath')!.thisType; + _numType = implementation.typeProvider.numType; + + final exported = barrel.exportNamespace.definedNames2.values.toSet(); + bool isDeclaredInPipeline(Element element) { + return element.firstFragment.libraryFragment?.source.uri.toString() == + _pipelinePart; + } + + void add(Element element, {required bool exportedOwner}) { + final key = _keyOf(element)!; + final member = _Member(key, element); + members[key] = member; + if (!exportedOwner) unexported.add(member); + for (final parameter in member.parameters) { + usages['$key(${parameter.name})'] = _ParameterUsage( + parameter, + _valueKind(parameter.type), + ); + } + } + + for (final function in implementation.topLevelFunctions) { + if (!function.isPublic || !isDeclaredInPipeline(function)) continue; + add(function, exportedOwner: exported.contains(function)); + } + for (final alias in implementation.typeAliases) { + if (alias.isPublic && + isDeclaredInPipeline(alias) && + !exported.contains(alias)) { + unexported.add(_Member(alias.name!, alias)); + } + } + for (final owner in [ + ...implementation.classes, + ...implementation.enums, + ]) { + if (!owner.isPublic || !isDeclaredInPipeline(owner)) continue; + final isExported = exported.contains(owner); + for (final element in _publicMembers(owner)) { + add(element, exportedOwner: isExported); + } + } + for (final MapEntry(key: className, value: names) in _entryPoints.entries) { + final owner = implementation.getClass(className)!; + for (final name in names) { + add(owner.getMethod(name)!, exportedOwner: exported.contains(owner)); + } + } + } + + /// The public members [owner] declares: constructors of instantiable + /// classes, methods and getters (fields included) — or, for an enum, only + /// its values. + Iterable _publicMembers(InterfaceElement owner) sync* { + if (owner is EnumElement) { + yield* owner.fields.where((field) => field.isEnumConstant); + return; + } + if (owner is ClassElement && !owner.isAbstract && !owner.isSealed) { + yield* owner.constructors.where( + (constructor) => + constructor.isPublic && constructor.isOriginDeclaration, + ); + } + yield* owner.methods.where( + (method) => method.isPublic && !_objectMembers.contains(method.name), + ); + yield* owner.fields.where( + (field) => field.isPublic && !_objectMembers.contains(field.name), + ); + } + + _ValueKind _valueKind(DartType type) { + bool isTop(DartType type) => type is DynamicType || type.isDartCoreObject; + if (isTop(type)) return _ValueKind.value; + if (type is InterfaceType && + (type.isDartCoreIterable || type.isDartCoreList) && + isTop(type.typeArguments.single)) { + return _ValueKind.iterable; + } + return _ValueKind.none; + } + + /// The universe key of [element], or `null` when it is not declared in the + /// implementation library. + String? _keyOf(Element element) { + var base = element.baseElement; + if (base is PropertyAccessorElement) base = base.variable; + if (base.library?.uri.toString() != _implementationUri) return null; + return switch (base.enclosingElement) { + final InterfaceElement owner => '${owner.name}.${base.name}', + _ => base.name, + }; + } + + // --------------------------------------------------------------------------- + // E2E sources. + + String _relative(String path) => path.substring(_packageRoot.length + 1); + + void _checkUnit(ResolvedUnitResult result) { + final file = _relative(result.path); + for (final diagnostic in result.diagnostics) { + if (diagnostic.severity != Severity.error) continue; + final location = result.lineInfo.getLocation(diagnostic.offset); + diagnostics.add('$file:${location.lineNumber}: ${diagnostic.message}'); + } + + final unit = result.unit; + final isTest = file.endsWith('_test.dart'); + for (final directive in unit.directives) { + if (directive is! ImportDirective) continue; + final uri = directive.uri.stringValue ?? ''; + if (!uri.startsWith('dart:') && !_allowedImports.contains(uri)) { + conventionErrors.add( + '$file: imports $uri; E2E files may import only the barrel, ' + 'package:test, harness.dart and dart: libraries', + ); + } + } + if (isTest) { + final tagged = unit.directives.whereType().any( + (library) => library.metadata.any( + (annotation) => + annotation.name.name == 'Tags' && + (annotation.arguments?.toSource().contains("'prod'") ?? false), + ), + ); + if (!tagged) { + conventionErrors.add("$file: missing @Tags(['prod']) on `library;`"); + } + } + + final visitor = _ReferenceVisitor(this); + unit.accept(visitor); + if (isTest && !visitor.declaresSuite) { + conventionErrors.add('$file: declares no tests through pipelineE2E'); + } + } + + void _recordReference(Element? element) { + if (element == null) return; + final key = _keyOf(element); + if (key != null && members.containsKey(key)) referenced.add(key); + } + + void _recordInvocation(Element? element, ArgumentList arguments) { + if (element == null) return; + final key = _keyOf(element); + final member = key == null ? null : members[key]; + if (member == null) return; + + final supplied = {}; + for (final argument in arguments.arguments) { + final parameter = argument.correspondingParameter; + final value = _argumentValue(argument, parameter); + final name = parameter?.name; + if (name == null || value == null || value is NullLiteral) continue; + supplied.add(name); + + final subject = '$key($name)'; + final usage = usages[subject]; + if (usage == null) continue; + switch (usage.kind) { + case _ValueKind.none: + break; + case _ValueKind.value: + _classify(value, usage); + case _ValueKind.iterable: + if (_isListOrSetLiteral(value)) { + for (final element in _leaves(value)) { + _classify(element, usage); + } + } + } + for (final shape in _requiredShapes[subject] ?? const <_Shape>[]) { + if (_hasShape(value, shape)) usage.shapes.add(shape); + } + if (usage.enumElement != null) { + if (_enumConstantName(value) case final constant?) { + usage.enumValues.add(constant); + } + } + } + + for (final parameter in member.parameters) { + if (!parameter.isOptional) continue; + final usage = usages['$key(${parameter.name})']!; + if (supplied.contains(parameter.name)) { + usage.supplied = true; + } else { + usage.omitted = true; + } + } + } + + /// The name of the enum value [value] reads, as in `DistanceMeasure.cosine`. + String? _enumConstantName(Expression value) { + var element = switch (value) { + PrefixedIdentifier() => value.identifier.element, + PropertyAccess() => value.propertyName.element, + SimpleIdentifier() => value.element, + _ => null, + }; + if (element is PropertyAccessorElement) element = element.variable; + return element is FieldElement && element.isEnumConstant + ? element.name + : null; + } + + /// The expression passed by [argument]. + /// + /// A named argument is a `NamedExpression` up to analyzer 12 and a + /// `NamedArgument` from 13; neither type is named here so that the guard + /// builds against both. In both, the value is the last expression child. + Expression? _argumentValue( + AstNode argument, + FormalParameterElement? parameter, + ) { + if (parameter != null && parameter.isNamed) { + return argument.childEntities.whereType().lastOrNull; + } + return argument is Expression ? argument : null; + } + + /// Records whether [value] is an expression or a plain value. Arguments + /// whose static type could be either, such as `Object`, count as neither. + void _classify(Expression value, _ParameterUsage usage) { + switch (_expressionness(value)) { + case true: + usage.expression = true; + case false: + usage.plain = true; + case null: + break; + } + } + + /// Whether [value]'s static type is a `PipelineExpression` (`true`), cannot + /// be one (`false`), or might be (`null`). + bool? _expressionness(Expression value) { + final type = value.staticType; + if (type == null || type is InvalidType || type is NeverType) return null; + if (type.isDartCoreNull) return false; + final nonNull = _typeSystem.promoteToNonNull(type); + if (_typeSystem.isSubtypeOf(nonNull, _expressionType)) return true; + if (_typeSystem.isSubtypeOf(_expressionType, nonNull)) return null; + return false; + } + + bool _isSubtypeOf(Expression value, InterfaceType type) { + final staticType = value.staticType; + if (staticType == null || staticType is InvalidType) return false; + if (staticType is NeverType || staticType.isDartCoreNull) return false; + return _typeSystem.isSubtypeOf( + _typeSystem.promoteToNonNull(staticType), + type, + ); + } + + bool _isListOrSetLiteral(Expression value) { + return value is ListLiteral || (value is SetOrMapLiteral && value.isSet); + } + + /// The element and value expressions of a collection literal, looking + /// through `if`, `for`, null-aware elements and spreads of literals. + Iterable _leaves(Expression literal) { + final elements = switch (literal) { + ListLiteral() => literal.elements, + SetOrMapLiteral() => literal.elements, + _ => const [], + }; + return _collectionLeaves(elements); + } + + Iterable _collectionLeaves( + Iterable elements, + ) sync* { + // An if-chain rather than a switch: the set of CollectionElement + // subtypes differs between analyzer versions. + for (final element in elements) { + if (element is Expression) { + yield element; + } else if (element is NullAwareElement) { + yield element.value; + } else if (element is MapLiteralEntry) { + yield element.value; + } else if (element is IfElement) { + yield* _collectionLeaves([element.thenElement, ?element.elseElement]); + } else if (element is ForElement) { + yield* _collectionLeaves([element.body]); + } else if (element is SpreadElement) { + yield* _leaves(element.expression); + } + } + } + + /// Whether [value] is a collection literal holding an expression at any + /// depth. + bool _holdsExpression(Expression value) { + if (value is! ListLiteral && value is! SetOrMapLiteral) return false; + return _leaves( + value, + ).any((leaf) => (_expressionness(leaf) ?? false) || _holdsExpression(leaf)); + } + + bool _hasShape(Expression value, _Shape shape) { + switch (shape) { + case _Shape.collectionWithExpression: + return _holdsExpression(value); + case _Shape.nestedCollectionWithExpression: + return _isListOrSetLiteral(value) && + _leaves(value).any(_holdsExpression); + case _Shape.numericList: + final leaves = value is ListLiteral ? _leaves(value).toList() : null; + return leaves != null && + leaves.isNotEmpty && + leaves.every((leaf) => _isSubtypeOf(leaf, _numType)); + case _Shape.dottedPath: + return value is StringLiteral && + (value.stringValue?.contains('.') ?? false); + case _Shape.fieldPath: + return _isSubtypeOf(value, _fieldPathType); + } + } + + // --------------------------------------------------------------------------- + // Configuration. + + void _checkConfiguration() { + for (final MapEntry(:key, :value) in _families.entries) { + if (!File('$_e2ePath/$value').existsSync()) { + configurationErrors.add( + 'family $key: $_e2eDirectory/$value does not exist', + ); + } + } + for (final member in members.values) { + if (member.family == null) { + configurationErrors.add( + '${member.key}: no family; add it to _familyByName, ' + '_familyByClass or _familyByMember', + ); + } + } + for (final MapEntry(:key, :value) in [ + ..._familyByMember.entries, + ..._familyByClass.entries, + ..._familyByName.entries, + ]) { + if (!_families.containsKey(value)) { + configurationErrors.add( + 'family table entry $key: unknown family $value', + ); + } + } + for (final key in _familyByMember.keys) { + if (!members.containsKey(key)) { + configurationErrors.add('_familyByMember: $key is not an API member'); + } + } + final classes = {for (final member in members.values) member.className}; + for (final key in _familyByClass.keys) { + if (!classes.contains(key)) { + configurationErrors.add('_familyByClass: $key declares no API member'); + } + } + final namesResolvedByName = { + for (final member in members.values) + if (_familyByMember[member.key] == null && + _familyByClass[member.className] == null) + member.name, + }; + for (final key in _familyByName.keys) { + if (!namesResolvedByName.contains(key)) { + configurationErrors.add('_familyByName: no API member named $key'); + } + } + + for (final MapEntry(:key, :value) in _requiredShapes.entries) { + final usage = usages[key]; + if (usage == null) { + configurationErrors.add('_requiredShapes: $key is not a parameter'); + } else if (value.isEmpty) { + configurationErrors.add('_requiredShapes: $key lists no shape'); + } else if (_notExecutable.containsKey(key) || + _notExecutable.containsKey(key.substring(0, key.indexOf('(')))) { + configurationErrors.add( + '_requiredShapes: $key is exempted by _notExecutable', + ); + } + } + + // An exemption is stale when it names nothing, or when the E2E sources + // already cover what it waives. + final unexempted = _gaps( + applyExemptions: false, + ).where((gap) => gap.waivable).toList(); + final waived = {for (final gap in unexempted) gap.subject}; + final waivedMembers = {for (final gap in unexempted) gap.member.key}; + for (final key in _notExecutable.keys) { + if (!members.containsKey(key) && !usages.containsKey(key)) { + configurationErrors.add( + '_notExecutable: $key is not an API member or parameter', + ); + } else if (!waived.contains(key) && + !(members.containsKey(key) && waivedMembers.contains(key))) { + configurationErrors.add( + '_notExecutable: $key has full coverage; remove the exemption', + ); + } + } + final valueGaps = { + for (final gap in unexempted) + if (gap.rule == 3) gap.subject, + }; + for (final key in _notValuePositions.keys) { + final usage = usages[key]; + if (usage == null || usage.kind == _ValueKind.none) { + configurationErrors.add( + '_notValuePositions: $key is not an Object-typed parameter', + ); + } else if (!valueGaps.contains(key)) { + configurationErrors.add( + '_notValuePositions: $key is covered; remove the exemption', + ); + } + } + } + + // --------------------------------------------------------------------------- + // Gaps. + + List<_Gap> _gaps({required bool applyExemptions}) { + final gaps = <_Gap>[]; + bool exempt(String subject, {bool valuePosition = false}) { + if (!applyExemptions) return false; + return _notExecutable.containsKey(subject) || + (valuePosition && _notValuePositions.containsKey(subject)); + } + + for (final member in unexported) { + gaps.add( + _Gap( + member, + 1, + member.key, + 'public in lib/src/pipeline.dart but not exported from ' + 'lib/google_cloud_firestore.dart', + waivable: false, + ), + ); + } + + for (final member in members.values) { + final key = member.key; + if (exempt(key)) continue; + if (!referenced.contains(key)) { + gaps.add(_Gap(member, 1, key, 'not referenced')); + } + for (final parameter in member.parameters) { + final subject = '$key(${parameter.name})'; + final usage = usages[subject]!; + if (exempt(subject)) continue; + if (parameter.isOptional) { + if (!usage.supplied) { + gaps.add(_Gap(member, 2, subject, 'never supplied')); + } + if (!usage.omitted) { + gaps.add(_Gap(member, 2, subject, 'never omitted')); + } + } + if (!exempt(subject, valuePosition: true)) { + switch (usage.kind) { + case _ValueKind.none: + break; + case _ValueKind.value: + if (!usage.expression) { + gaps.add( + _Gap(member, 3, subject, 'never passed a PipelineExpression'), + ); + } + if (!usage.plain) { + gaps.add( + _Gap( + member, + 3, + subject, + 'never passed a plain value (literal, field name, ...)', + ), + ); + } + case _ValueKind.iterable: + if (!usage.expression) { + gaps.add( + _Gap( + member, + 3, + subject, + 'no list-literal element is a PipelineExpression', + ), + ); + } + if (!usage.plain) { + gaps.add( + _Gap( + member, + 3, + subject, + 'no list-literal element is a plain value', + ), + ); + } + } + } + if (usage.enumElement case final enumElement?) { + for (final constant in enumElement.fields) { + if (constant.isEnumConstant && + !usage.enumValues.contains(constant.name)) { + gaps.add( + _Gap( + member, + 4, + subject, + 'never passed ${enumElement.name}.${constant.name}', + ), + ); + } + } + } + for (final shape in _requiredShapes[subject] ?? const <_Shape>[]) { + if (!usage.shapes.contains(shape)) { + gaps.add( + _Gap( + member, + 4, + subject, + 'never passed ${shape.description} (${shape.name})', + ), + ); + } + } + } + } + return gaps; + } + + /// The grouped gap report, or `null` when there are no gaps. + String? coverageReport() { + final gaps = _gaps(applyExemptions: true); + if (gaps.isEmpty) return null; + + final byFamily = >{}; + for (final gap in gaps) { + (byFamily[gap.member.family ?? '(no family)'] ??= []).add(gap); + } + final families = [ + ..._families.keys.where(byFamily.containsKey), + ...byFamily.keys.where((family) => !_families.containsKey(family)), + ]; + + final buffer = StringBuffer() + ..writeln( + 'Pipeline E2E coverage: ${gaps.length} gaps. Add live cases under ' + '$_e2eDirectory/ to the file named for each family; the rules are ' + 'documented at the top of test/pipeline_e2e_coverage_test.dart.', + ) + ..writeln() + ..writeln('Gaps by family:'); + for (final family in families) { + buffer.writeln( + ' ${family.padRight(20)} ${'${byFamily[family]!.length}'.padLeft(4)}' + ' $_e2eDirectory/${_families[family] ?? '?'}', + ); + } + + for (final family in families) { + final familyGaps = byFamily[family]!; + buffer + ..writeln() + ..writeln( + '== $family: $_e2eDirectory/${_families[family] ?? '?'} ' + '(${familyGaps.length}) ==', + ); + for (final rule in _ruleTitles.keys) { + final lines = [ + for (final gap in familyGaps) + if (gap.rule == rule) gap.line, + ]; + if (lines.isEmpty) continue; + buffer.writeln('-- ${_ruleTitles[rule]} --'); + lines.forEach(buffer.writeln); + } + } + return buffer.toString(); + } +} + +/// Records every API reference and invocation in an E2E unit. +final class _ReferenceVisitor extends RecursiveAstVisitor { + _ReferenceVisitor(this._analysis); + + final _Analysis _analysis; + + /// Whether the unit invokes the harness's `pipelineE2E`. + bool declaresSuite = false; + + @override + void visitSimpleIdentifier(SimpleIdentifier node) { + _analysis._recordReference(node.element); + super.visitSimpleIdentifier(node); + } + + @override + void visitMethodInvocation(MethodInvocation node) { + final element = node.methodName.element; + if (element is TopLevelFunctionElement && element.name == 'pipelineE2E') { + declaresSuite = true; + } + _analysis._recordInvocation(element, node.argumentList); + super.visitMethodInvocation(node); + } + + @override + void visitInstanceCreationExpression(InstanceCreationExpression node) { + final element = node.constructorName.element; + _analysis + .._recordReference(element) + .._recordInvocation(element, node.argumentList); + super.visitInstanceCreationExpression(node); + } +} From eaca9db114fe9863b9c26c4450f437c39feff36e Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:12:59 +0200 Subject: [PATCH 02/29] refactor(firestore): share one implementation between static and fluent Pipeline functions Several Pipeline functions were built separately by each of their forms, which is how the static and fluent forms drifted apart before (different backend names, argument conversions and optional arguments). Make every form of a function forward, in one call and with its parameters in order, to a single implementation: - the top-level comparisons, and, or and not now call their PipelineFunctions counterparts instead of building the function themselves; the private _comparison helper is gone; - the top-level field, constant and variable forward to Expression, which holds them because a bare name inside Expression resolves to its own member; - Expression.raw forwards to PipelineFunctions.raw, which gains the options parameter it lacked; - PipelineExpression.arraySlice always calls PipelineFunctions.arraySlice, whose length is now optional like the fluent form and the Node SDK, instead of building a raw array_slice when length was omitted; - PipelineExpression.arrayContainsAll and arrayContainsAny take an Object? like their static forms and the Node SDK, so they also accept an array expression; - PipelineField no longer overrides ascending and descending with copies of the inherited methods. The encoded requests are unchanged for every valid input. --- packages/google_cloud_firestore/CHANGELOG.md | 4 + .../lib/src/pipeline.dart | 122 +++++++++--------- 2 files changed, 68 insertions(+), 58 deletions(-) diff --git a/packages/google_cloud_firestore/CHANGELOG.md b/packages/google_cloud_firestore/CHANGELOG.md index 5743d79e..ca0cad1a 100644 --- a/packages/google_cloud_firestore/CHANGELOG.md +++ b/packages/google_cloud_firestore/CHANGELOG.md @@ -16,6 +16,10 @@ - `PipelineExpression.substring` and `substringLiteral` now take `(position, [length])` like `PipelineFunctions.substring`: the second argument is a length, not an end index, and can be omitted. - `PipelineExpression.round` now accepts the optional `decimalPlaces` argument. - Exported the top-level `variable()` Pipeline helper. +- Every Pipeline function now has a single implementation shared by all of its forms. The fluent `PipelineExpression` method, the top-level helper (`equal`, `and`, `not`, ...) and the `Expression` alias forward to the `PipelineFunctions` function (and `field`, `constant` and `variable` to `Expression`), so every form sends the same request. +- `PipelineFunctions.arraySlice` now takes an optional `length`, like `PipelineExpression.arraySlice` and the Node Admin SDK. +- `PipelineFunctions.raw` now accepts `options`, like `Expression.raw`. +- `PipelineExpression.arrayContainsAll` and `arrayContainsAny` now also accept an array expression, like their `PipelineFunctions` forms and the Node Admin SDK. ## 0.5.5 diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 24e71f1c..951f5ec8 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -15,16 +15,20 @@ part of 'firestore.dart'; /// Creates a field reference expression for Firestore Pipeline operations. -PipelineField field(String fieldPath) => PipelineField._(fieldPath); +PipelineField field(String fieldPath) => Expression.field(fieldPath); /// Creates a constant expression for Firestore Pipeline operations. -PipelineExpression constant(Object? value) => _PipelineConstant(value); +PipelineExpression constant(Object? value) => Expression.constant(value); /// Creates a variable reference expression for Firestore Pipeline operations. -PipelineExpression variable(String name) => _PipelineVariable(name); +PipelineExpression variable(String name) => Expression.variable(name); /// FlutterFire-style entry points for building Pipeline expressions. abstract final class Expression { + // The top-level field, constant and variable helpers forward to these. The + // forwarding cannot go the other way: in here, a bare `field` names + // Expression.field itself. + /// Creates a field reference expression. static PipelineField field(String fieldPath) => PipelineField._(fieldPath); @@ -47,12 +51,14 @@ abstract final class Expression { } /// Creates a raw backend function expression. + /// + /// See [PipelineFunctions.raw]. static PipelineExpression raw( String name, Iterable args, { Map options = const {}, }) { - return pipelineFunction(name, args, options: options); + return PipelineFunctions.raw(name, args, options: options); } } @@ -213,42 +219,36 @@ PipelineExpression pipelineFunction( return _PipelineFunctionExpression(name, args.toList(), options); } -/// Creates an equality expression. +/// Creates an equality expression; same as [PipelineFunctions.equal]. PipelineBooleanExpression equal(Object? left, Object? right) { - return _comparison('equal', left, right); + return PipelineFunctions.equal(left, right); } -/// Creates a not-equal expression. +/// Creates a not-equal expression; same as [PipelineFunctions.notEqual]. PipelineBooleanExpression notEqual(Object? left, Object? right) { - return _comparison('not_equal', left, right); + return PipelineFunctions.notEqual(left, right); } -/// Creates a less-than expression. +/// Creates a less-than expression; same as [PipelineFunctions.lessThan]. PipelineBooleanExpression lessThan(Object? left, Object? right) { - return _comparison('less_than', left, right); + return PipelineFunctions.lessThan(left, right); } -/// Creates a less-than-or-equal expression. +/// Creates a less-than-or-equal expression; same as +/// [PipelineFunctions.lessThanOrEqual]. PipelineBooleanExpression lessThanOrEqual(Object? left, Object? right) { - return _comparison('less_than_or_equal', left, right); + return PipelineFunctions.lessThanOrEqual(left, right); } -/// Creates a greater-than expression. +/// Creates a greater-than expression; same as [PipelineFunctions.greaterThan]. PipelineBooleanExpression greaterThan(Object? left, Object? right) { - return _comparison('greater_than', left, right); + return PipelineFunctions.greaterThan(left, right); } -/// Creates a greater-than-or-equal expression. +/// Creates a greater-than-or-equal expression; same as +/// [PipelineFunctions.greaterThanOrEqual]. PipelineBooleanExpression greaterThanOrEqual(Object? left, Object? right) { - return _comparison('greater_than_or_equal', left, right); -} - -PipelineBooleanExpression _comparison( - String name, - Object? left, - Object? right, -) { - return PipelineFunctions._bool(name, [_fieldOrExpression(left), right]); + return PipelineFunctions.greaterThanOrEqual(left, right); } /// Interprets a [String] in a field position as a field reference. @@ -327,19 +327,19 @@ bool _containsExpression(Object? value) { }; } -/// Creates a logical AND expression. +/// Creates a logical AND expression; same as [PipelineFunctions.and]. PipelineBooleanExpression and(Iterable expressions) { - return _PipelineBooleanExpression('and', expressions.toList()); + return PipelineFunctions.and(expressions); } -/// Creates a logical OR expression. +/// Creates a logical OR expression; same as [PipelineFunctions.or]. PipelineBooleanExpression or(Iterable expressions) { - return _PipelineBooleanExpression('or', expressions.toList()); + return PipelineFunctions.or(expressions); } -/// Creates a logical NOT expression. +/// Creates a logical NOT expression; same as [PipelineFunctions.not]. PipelineBooleanExpression not(PipelineBooleanExpression expression) { - return _PipelineBooleanExpression('not', [expression]); + return PipelineFunctions.not(expression); } /// Returns the current document as a Pipeline expression. @@ -362,6 +362,10 @@ PipelineBooleanExpression documentMatches(Object? rquery) { /// A [List] or [Map] argument may hold expressions, as in `[field('a'), 1]`: /// it is sent as an [array] or [map] function so the backend evaluates them. /// Wrap a collection in [constant] to send it as a literal value instead. +/// +/// The fluent [PipelineExpression] method of the same name, and any top-level +/// or [Expression] helper, forward to the function here, so every form of a +/// function builds the same expression. abstract final class PipelineFunctions { static PipelineExpression _expr(String name, Iterable args) { return pipelineFunction(name, args.map(_valueToDefaultExpr)); @@ -395,9 +399,13 @@ abstract final class PipelineFunctions { /// /// Unlike the other helpers, [args] are sent as-is: a [List] or [Map] is a /// literal value, which cannot hold expressions. Build those with [array] - /// or [map]. - static PipelineExpression raw(String name, Iterable args) { - return pipelineFunction(name, args); + /// or [map]. [options] are sent as the function's options. + static PipelineExpression raw( + String name, + Iterable args, { + Map options = const {}, + }) { + return pipelineFunction(name, args, options: options); } /// COUNT aggregate function. @@ -707,12 +715,19 @@ abstract final class PipelineFunctions { } /// ARRAY_SLICE function. + /// + /// Returns [length] elements of [array] starting at index [offset]; when + /// [length] is omitted the slice runs to the end of the array. static PipelineExpression arraySlice( Object? array, - Object? offset, + Object? offset, [ Object? length, - ) { - return _expr('array_slice', [_fieldOrExpression(array), offset, length]); + ]) { + return _expr('array_slice', [ + _fieldOrExpression(array), + offset, + ..._optionalArg(length), + ]); } /// ARRAY_TRANSFORM function. @@ -2058,12 +2073,8 @@ PipelineBooleanExpression _cursorCondition( PipelineBooleanExpression compare(Object? expression, Object? value) { return before - ? _comparison('less_than', expression, value) - : _comparison('greater_than', expression, value); - } - - PipelineBooleanExpression equals(Object? expression, Object? value) { - return _comparison('equal', expression, value); + ? lessThan(expression, value) + : greaterThan(expression, value); } var expression = orderings[size - 1]._expression; @@ -2072,7 +2083,7 @@ PipelineBooleanExpression _cursorCondition( var condition = compare(expression, value); // An inclusive bound also matches the cursor value itself. if (before != cursor.before) { - condition = or([condition, equals(expression, value)]); + condition = or([condition, equal(expression, value)]); } for (var i = size - 2; i >= 0; i--) { @@ -2080,7 +2091,7 @@ PipelineBooleanExpression _cursorCondition( value = _PipelineProtoValue(cursor.values[i]); condition = or([ compare(expression, value), - and([equals(expression, value), condition]), + and([equal(expression, value), condition]), ]); } @@ -2456,7 +2467,9 @@ sealed class PipelineExpression { } /// Checks if this array contains all [values]. - PipelineBooleanExpression arrayContainsAll(Iterable values) { + /// + /// [values] is a list of values or an array expression. + PipelineBooleanExpression arrayContainsAll(Object? values) { return PipelineFunctions.arrayContainsAll(this, values); } @@ -2466,7 +2479,9 @@ sealed class PipelineExpression { } /// Checks if this array contains any [values]. - PipelineBooleanExpression arrayContainsAny(Iterable values) { + /// + /// [values] is a list of values or an array expression. + PipelineBooleanExpression arrayContainsAny(Object? values) { return PipelineFunctions.arrayContainsAny(this, values); } @@ -2532,11 +2547,10 @@ sealed class PipelineExpression { /// Reverses this array expression. PipelineExpression arrayReverse() => PipelineFunctions.arrayReverse(this); - /// Returns a slice of this array expression. + /// Returns [length] elements of this array expression starting at index + /// [offset]; when [length] is omitted the slice runs to the end. PipelineExpression arraySlice(Object? offset, [Object? length]) { - return length == null - ? PipelineFunctions.raw('array_slice', [this, offset]) - : PipelineFunctions.arraySlice(this, offset, length); + return PipelineFunctions.arraySlice(this, offset, length); } /// Returns the sum of numeric elements in this array expression. @@ -2954,14 +2968,6 @@ final class PipelineField extends PipelineExpression { /// 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); From 145aff3b1db4545c52337e4fd7c7efe0f89ad08e Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:13:12 +0200 Subject: [PATCH 03/29] refactor(firestore)!: replace arrayTransform's index alias with arrayTransformWithIndex PipelineFunctions.arrayTransform took an optional trailing index alias that its fluent form did not, so the fluent arrayTransformWithIndex had to call it with its arguments reordered. Follow the Node SDK instead: arrayTransform takes (array, elementAlias, transform) and the new PipelineFunctions.arrayTransformWithIndex takes (array, elementAlias, indexAlias, transform), which PipelineExpression.arrayTransformWithIndex now forwards to. Both send the same array_transform function as before. BREAKING CHANGE: PipelineFunctions.arrayTransform no longer accepts a fourth index-alias argument. Call PipelineFunctions.arrayTransformWithIndex(array, elementAlias, indexAlias, transform) instead. --- packages/google_cloud_firestore/CHANGELOG.md | 1 + packages/google_cloud_firestore/README.md | 2 +- .../lib/src/pipeline.dart | 47 +++++++++++++------ 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/packages/google_cloud_firestore/CHANGELOG.md b/packages/google_cloud_firestore/CHANGELOG.md index ca0cad1a..08c2eb05 100644 --- a/packages/google_cloud_firestore/CHANGELOG.md +++ b/packages/google_cloud_firestore/CHANGELOG.md @@ -6,6 +6,7 @@ - **Breaking:** `PipelineFunctions.join` and `PipelineFunctions.split` now require their delimiter, matching `PipelineExpression` and the Node Admin SDK. A one-argument call was always rejected by the backend with `INVALID_ARGUMENT`. - **Breaking:** `PipelineFunctions.mapRemove` and `PipelineExpression.mapRemove` take a single key (a `String` or an expression) and send `map_remove(map, key)`, like the Node Admin SDK, instead of an `Iterable` of keys. Chain `.mapRemove('a').mapRemove('b')` to remove several keys. - **Breaking:** `PipelineResult.get` now resolves dot-separated paths such as `'metadata.lang'` as nested fields, like `DocumentSnapshot.get`, and also accepts a `FieldPath`. A top-level field whose name contains a dot, which `get('a.b')` used to return, is now read with `get(FieldPath(['a.b']))`. A string that is empty, starts or ends with `.`, or contains `..` or any of `*~/[]` now throws an `ArgumentError` instead of returning `null`. +- **Breaking:** `PipelineFunctions.arrayTransform` no longer takes a trailing index alias. Use the new `PipelineFunctions.arrayTransformWithIndex(array, elementAlias, indexAlias, transform)`, which matches `PipelineExpression.arrayTransformWithIndex` and the Node Admin SDK and sends the same `array_transform` function. - Fixed `PipelineFunctions.arrayMaximum`, `arrayMaximumN`, `arrayMinimum`, `arrayMinimumN` and `arraySum` sending function names (`array_maximum`, ...) that the backend rejects. They now send `maximum`, `maximum_n`, `minimum`, `minimum_n` and `sum`, like their `PipelineExpression` counterparts. - Fixed `Pipeline.addFields` sending one `alias` function per field instead of a single map keyed by alias, which the backend rejected. - Fixed `PipelineSource.collectionGroup()`, and `createFrom()` on a collection group query, omitting the leading root ancestor argument of the `collection_group` stage. diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 9c1e636e..886b5e53 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -396,7 +396,7 @@ available on `PipelineFunctions`; most also exist as a fluent method. | Logical | `and`, `or`, `xor`, `nor`, `not`, `conditional`, `ifNull`, `coalesce`, `switchOn`, `equalAny`, `notEqualAny` | `and`, `or`, `xor`, `nor`, `not`, `conditional`, `if_null`, `coalesce`, `switch_on`, `equal_any`, `not_equal_any` | | Aggregate | `count`, `countAll`, `countIf`, `countDistinct`, `sum`, `average`, `minimum`, `maximum`, `first`, `last`, `arrayAgg`, `arrayAggDistinct` | `count`, `count_if`, `count_distinct`, `sum`, `average`, `minimum`, `maximum`, `first`, `last`, `array_agg`, `array_agg_distinct` | | Arithmetic | `add`, `subtract`, `multiply`, `divide`, `mod`, `abs`, `ceil`, `floor`, `round`, `trunc`, `sqrt`, `pow`, `exp`, `ln`, `log`, `log10`, `rand`, `logicalMinimum`, `logicalMaximum` | `add`, `subtract`, `multiply`, `divide`, `mod`, `abs`, `ceil`, `floor`, `round`, `trunc`, `sqrt`, `pow`, `exp`, `ln`, `log`, `log10`, `rand`, `minimum`, `maximum` | -| Array | `array`, `arrayConcat`, `arrayContains`, `arrayContainsAll`, `arrayContainsAny`, `arrayFilter`, `arrayGet`, `arrayLength`, `arrayReverse`, `arrayFirst`, `arrayFirstN`, `arrayLast`, `arrayLastN`, `arrayIndexOf`, `arrayIndexOfAll`, `arrayLastIndexOf`, `arraySlice`, `arrayTransform`, `arrayMaximum`, `arrayMaximumN`, `arrayMinimum`, `arrayMinimumN`, `arraySum`, `maximumN`, `minimumN`, `join` | `array`, `array_concat`, `array_contains`, `array_contains_all`, `array_contains_any`, `array_filter`, `array_get`, `array_length`, `array_reverse`, `array_first`, `array_first_n`, `array_last`, `array_last_n`, `array_index_of`, `array_index_of_all`, `array_index_of`, `array_slice`, `array_transform`, `maximum`, `maximum_n`, `minimum`, `minimum_n`, `sum`, `maximum_n`, `minimum_n`, `join` | +| Array | `array`, `arrayConcat`, `arrayContains`, `arrayContainsAll`, `arrayContainsAny`, `arrayFilter`, `arrayGet`, `arrayLength`, `arrayReverse`, `arrayFirst`, `arrayFirstN`, `arrayLast`, `arrayLastN`, `arrayIndexOf`, `arrayIndexOfAll`, `arrayLastIndexOf`, `arraySlice`, `arrayTransform`, `arrayTransformWithIndex`, `arrayMaximum`, `arrayMaximumN`, `arrayMinimum`, `arrayMinimumN`, `arraySum`, `maximumN`, `minimumN`, `join` | `array`, `array_concat`, `array_contains`, `array_contains_all`, `array_contains_any`, `array_filter`, `array_get`, `array_length`, `array_reverse`, `array_first`, `array_first_n`, `array_last`, `array_last_n`, `array_index_of`, `array_index_of_all`, `array_index_of`, `array_slice`, `array_transform`, `array_transform`, `maximum`, `maximum_n`, `minimum`, `minimum_n`, `sum`, `maximum_n`, `minimum_n`, `join` | | String | `byteLength`, `charLength`, `startsWith`, `endsWith`, `like`, `regexContains`, `regexMatch`, `regexFind`, `regexFindAll`, `stringConcat`, `stringContains`, `stringIndexOf`, `toUpper`, `toLower`, `substring`, `stringReverse`, `stringRepeat`, `stringReplaceAll`, `stringReplaceOne`, `trim`, `ltrim`, `rtrim`, `split` | `byte_length`, `char_length`, `starts_with`, `ends_with`, `like`, `regex_contains`, `regex_match`, `regex_find`, `regex_find_all`, `string_concat`, `string_contains`, `string_index_of`, `to_upper`, `to_lower`, `substring`, `string_reverse`, `string_repeat`, `string_replace_all`, `string_replace_one`, `trim`, `ltrim`, `rtrim`, `split` | | Generic | `length`, `reverse`, `concat` | `length`, `reverse`, `concat` | | Map | `map`, `mapGet`, `getField`, `mapSet`, `mapRemove`, `mapMerge`, `mapKeys`, `mapValues`, `mapEntries` | `map`, `map_get`, `get_field`, `map_set`, `map_remove`, `map_merge`, `map_keys`, `map_values`, `map_entries` | diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 951f5ec8..12f9c496 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -731,21 +731,38 @@ abstract final class PipelineFunctions { } /// ARRAY_TRANSFORM function. + /// + /// Evaluates [transform] for each element of [array], with the element + /// bound to the variable [elementAlias]. To also bind the element's index, + /// use [arrayTransformWithIndex]. static PipelineExpression arrayTransform( Object? array, - String variableName, - Object? expression, [ - String? indexVariableName, - ]) { - final target = _fieldOrExpression(array); - return indexVariableName == null - ? _expr('array_transform', [target, variableName, expression]) - : _expr('array_transform', [ - target, - variableName, - indexVariableName, - expression, - ]); + String elementAlias, + Object? transform, + ) { + return _expr('array_transform', [ + _fieldOrExpression(array), + elementAlias, + transform, + ]); + } + + /// ARRAY_TRANSFORM function, binding each element's index too. + /// + /// Like [arrayTransform], with the element's zero-based index bound to the + /// variable [indexAlias]. + static PipelineExpression arrayTransformWithIndex( + Object? array, + String elementAlias, + String indexAlias, + Object? transform, + ) { + return _expr('array_transform', [ + _fieldOrExpression(array), + elementAlias, + indexAlias, + transform, + ]); } /// MAXIMUM_N array function. @@ -2567,11 +2584,11 @@ sealed class PipelineExpression { String indexAlias, Object? transform, ) { - return PipelineFunctions.arrayTransform( + return PipelineFunctions.arrayTransformWithIndex( this, elementAlias, - transform, indexAlias, + transform, ); } From 2a8afe1f16850680020c83a36191087383384231 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:13:25 +0200 Subject: [PATCH 04/29] fix(firestore): read a String passed to top-level ascending/descending as a field ascending('rating') sent the string constant 'rating' as the sort expression instead of the field, unlike field('rating').ascending(), every other Pipeline helper that takes a field name, and the Node SDK's ascending(fieldName). A String now names a field. --- packages/google_cloud_firestore/CHANGELOG.md | 1 + .../google_cloud_firestore/lib/src/pipeline.dart | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/google_cloud_firestore/CHANGELOG.md b/packages/google_cloud_firestore/CHANGELOG.md index 08c2eb05..3a89584f 100644 --- a/packages/google_cloud_firestore/CHANGELOG.md +++ b/packages/google_cloud_firestore/CHANGELOG.md @@ -13,6 +13,7 @@ - Fixed `PipelineValueType.double` sending `'double'` instead of `'float64'`, which made `isType` fail. Added the `int32`, `decimal128`, `maxKey`, `minKey`, `objectId` and `regex` value types. - Fixed `cosineDistance`, `dotProduct`, `euclideanDistance` and `Pipeline.findNearest` sending a plain list of numbers as an array instead of a vector. - Fixed Pipeline functions such as `equalAny`, `notEqualAny`, `arrayContainsAll`, `arrayContainsAny` and `mapMerge` failing when a `List` or `Map` argument holds expressions. Collection arguments are now sent as `array(...)` / `map(...)` functions, like the Node Admin SDK, and the static and fluent forms encode identically. +- Fixed the top-level `ascending()` and `descending()` sorting by a string constant when given a field name. A `String` now names a field, like everywhere else in the Pipeline API and in the Node Admin SDK. - `Pipeline.select`, `addFields`, `aggregate` and `distinct` now throw an `ArgumentError` on a duplicate field name or alias instead of silently keeping the last one, like the Node Admin SDK. - `PipelineExpression.substring` and `substringLiteral` now take `(position, [length])` like `PipelineFunctions.substring`: the second argument is a length, not an end index, and can be omitted. - `PipelineExpression.round` now accepts the optional `decimalPlaces` argument. diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 12f9c496..ae4e14b4 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -1389,13 +1389,25 @@ abstract final class PipelineFunctions { } /// Creates an ascending Pipeline ordering. +/// +/// [expression] is a field name or an expression, like +/// [PipelineExpression.ascending] on that expression. PipelineOrdering ascending(Object expression) { - return PipelineOrdering._('ascending', expression); + return PipelineOrdering._( + 'ascending', + expression is String ? field(expression) : expression, + ); } /// Creates a descending Pipeline ordering. +/// +/// [expression] is a field name or an expression, like +/// [PipelineExpression.descending] on that expression. PipelineOrdering descending(Object expression) { - return PipelineOrdering._('descending', expression); + return PipelineOrdering._( + 'descending', + expression is String ? field(expression) : expression, + ); } /// The starting point for constructing Firestore Pipeline operations. From fca1378fa80ee12a035c78bd3cf5e78ab37b1e6c Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:16:21 +0200 Subject: [PATCH 05/29] test(firestore): enforce a single implementation per Pipeline function Add pipeline_single_implementation_test.dart, which resolves lib/src/pipeline.dart with package:analyzer and groups its public members by name across the forms a Pipeline function can take (PipelineFunctions statics, Expression statics, top-level functions and PipelineExpression methods). For every name written in more than one form, every form but the first must be a single call forwarding its parameters, in order and with the same optionality and defaults, to that first form; fluent members with no same-named counterpart, such as toLowerCase, must forward to the function they alias. A documented exceptions map covers concat, ascending and descending, and stale entries fail. The same file encodes every pair through each of its forms, with field names, expressions, literals and optional arguments, and checks the captured ExecutePipelineRequest JSON is identical. A pair without a behavioural entry, or an entry that skips one of its forms, fails. The analyzer dev dependency allows 10.x through 14.x; the checker avoids the argument AST types that changed in analyzer 13. --- .../pipeline_single_implementation_test.dart | 1453 +++++++++++++++++ 1 file changed, 1453 insertions(+) create mode 100644 packages/google_cloud_firestore/test/pipeline_single_implementation_test.dart diff --git a/packages/google_cloud_firestore/test/pipeline_single_implementation_test.dart b/packages/google_cloud_firestore/test/pipeline_single_implementation_test.dart new file mode 100644 index 00000000..9e319294 --- /dev/null +++ b/packages/google_cloud_firestore/test/pipeline_single_implementation_test.dart @@ -0,0 +1,1453 @@ +// 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. + +/// Enforces that every Pipeline function has exactly one implementation. +/// +/// A Pipeline function can be written in several forms: the static +/// `PipelineFunctions.equal('price', 1)`, the fluent `field('price').equal(1)`, +/// a top-level `equal('price', 1)`, and FlutterFire-style `Expression` aliases +/// such as `Expression.field`. When forms were implemented separately they +/// drifted apart (different backend names, argument conversions and optional +/// arguments). So this test resolves `lib/src/pipeline.dart` with +/// `package:analyzer`, groups its public members by name, and requires every +/// form but one to be a single call forwarding its parameters, in order, to +/// that one implementation. It then encodes every pair through each of its +/// forms and checks they send identical requests. +@TestOn('vm') +library; + +import 'dart:isolate'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/ast/ast.dart' as ast; +import 'package:analyzer/dart/ast/visitor.dart' as ast; +import 'package:analyzer/dart/element/element.dart'; +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:mocktail/mocktail.dart'; +import 'package:test/test.dart' hide greaterThan, lessThan; + +/// Pairs that cannot forward to their counterpart, with the reason. +/// +/// Every entry must name a pair that fails the forwarding check; an entry +/// that no longer fails is stale and fails the test. +const _exceptions = { + 'concat': + 'The fluent form keeps the FlutterFire-style `concat(List others)`, ' + "while PipelineFunctions.concat takes Node's `(first, second, [others])`. " + 'It still builds the function only through PipelineFunctions.concat, but ' + 'has to split `others` into `second` and the rest to call it. Making it ' + '`concat(second, [others])` would silently turn existing `concat([x])` ' + 'calls into concatenating the array `[x]`.', + 'ascending': _orderingException, + 'descending': _orderingException, +}; + +const _orderingException = + 'Inside PipelineExpression the name resolves to the fluent method itself, ' + 'so it cannot call the top-level function, and PipelineFunctions has no ' + 'ordering helpers to share. Both forms are a single PipelineOrdering ' + 'constructor call, and _behaviour checks they encode identically.'; + +/// Public fluent members that are not a form of any Pipeline function, so +/// they have nothing to forward to. +const _notFunctions = { + 'as': 'Wraps the expression in a PipelineAliasedExpression.', + 'asBoolean': 'Casts the expression to a PipelineBooleanExpression.', +}; + +void main() { + group( + 'Pipeline functions have a single implementation', + timeout: const Timeout(Duration(minutes: 3)), + () { + late _Inventory inventory; + + setUpAll(() async { + inventory = await _Inventory.load(); + }); + + test('derives the pairs from the library', () { + // Guards against a derivation that silently finds nothing to check. + expect(inventory.pairs.length, greaterThanOrEqualTo(100)); + expect(inventory.pairs['equal']?.map((member) => member.label), [ + 'PipelineFunctions.equal', + 'equal', + 'PipelineExpression.equal', + ]); + expect( + inventory.pairs['not']?.map((member) => member.label), + containsAll([ + 'PipelineFunctions.not', + 'PipelineBooleanExpression.not', + ]), + ); + }); + + test('every form forwards to the one implementation', () { + final problems = []; + for (final MapEntry(key: name, value: members) + in inventory.pairs.entries) { + if (_exceptions.containsKey(name)) continue; + final implementation = members.first; + for (final member in members.skip(1)) { + final problem = _forwardingProblem(member, implementation); + if (problem != null) { + problems.add( + '${member.label} must forward to ${implementation.label}: ' + '$problem.', + ); + } + } + } + expect(problems, isEmpty, reason: problems.join('\n')); + }); + + test('fluent members without a same-named counterpart forward', () { + final problems = []; + for (final member in inventory.unpairedFluent) { + if (_notFunctions.containsKey(member.name)) continue; + final target = inventory.calledMember(member); + final problem = target == null + ? 'its body must be a single call forwarding to a ' + 'PipelineFunctions function or another fluent method, ' + 'but it is `${_source(member.body)}`' + : _forwardingProblem(member, target); + if (problem != null) problems.add('${member.label}: $problem.'); + } + expect(problems, isEmpty, reason: problems.join('\n')); + }); + + test('exceptions are still needed', () { + final stale = []; + for (final name in _exceptions.keys) { + final members = inventory.pairs[name]; + if (members == null) { + stale.add('$name is no longer a pair.'); + continue; + } + final forwards = members + .skip(1) + .every( + (member) => _forwardingProblem(member, members.first) == null, + ); + if (forwards) { + stale.add('$name now forwards; remove it from _exceptions.'); + } + } + for (final name in _notFunctions.keys) { + if (!inventory.unpairedFluent.any((member) => member.name == name)) { + stale.add( + '$name is no longer an unpaired fluent member; remove it from ' + '_notFunctions.', + ); + } + } + expect(stale, isEmpty, reason: stale.join('\n')); + }); + + test('every pair has a behavioural check covering each form', () { + final expected = >{ + for (final MapEntry(:key, :value) in inventory.pairs.entries) + key: {for (final member in value) member.form.name}, + for (final alias in inventory.unpairedFluent) + if (!_notFunctions.containsKey(alias.name)) + alias.name: { + 'alias', + if (inventory.calledMember(alias) case final target?) + target.form.name, + }, + }; + final problems = []; + for (final MapEntry(key: name, value: forms) in expected.entries) { + final calls = _behaviour[name]; + if (calls == null) { + problems.add('$name has no entry in _behaviour.'); + continue; + } + final covered = {for (final call in calls) ...call.forms.keys}; + if (covered.difference(forms) case final extra + when extra.isNotEmpty) { + problems.add('$name has no ${extra.join(', ')} form.'); + } + if (forms.difference(covered) case final missing + when missing.isNotEmpty) { + problems.add( + '_behaviour never calls $name in ${missing.join(', ')} form.', + ); + } + for (final call in calls) { + if (call.forms.length < 2) { + problems.add('$name: "${call.description}" compares one form.'); + } + } + } + for (final name in _behaviour.keys) { + if (!expected.containsKey(name)) { + problems.add('_behaviour.$name is not a pair; remove it.'); + } + } + expect(problems, isEmpty, reason: problems.join('\n')); + }); + }, + ); + + group('every form of a Pipeline function encodes identically', () { + late Firestore firestore; + firestore_v1.ExecutePipelineRequest? captured; + + setUp(() { + final client = _MockFirestoreHttpClient(); + firestore = Firestore.internal( + settings: const Settings(projectId: _projectId, databaseId: 'db'), + client: client, + ); + when(() => client.cachedProjectId).thenReturn(_projectId); + when( + () => client.v1>(any()), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> + Function(firestore_v1.Firestore api, String projectId); + final api = FakeFirestore( + executePipeline: (request) { + captured = request; + return const Stream.empty(); + }, + ); + return callback(api, _projectId); + }); + }); + + /// The JSON of the request that executes a Pipeline using [built]. + Future encode(Object built) async { + final pipeline = firestore.pipeline().collection('books'); + captured = null; + await switch (built) { + PipelineOrdering() => pipeline.sort([built]), + PipelineExpression() => pipeline.select([built.as('result')]), + _ => throw ArgumentError.value(built, 'built'), + }.execute(); + return captured!.toJson(); + } + + for (final MapEntry(key: name, value: calls) in _behaviour.entries) { + test(name, () async { + for (final call in calls) { + final [(firstForm, firstBuild), ...others] = [ + for (final MapEntry(:key, :value) in call.forms.entries) + (key, value), + ]; + final expected = await encode(firstBuild()); + for (final (form, build) in others) { + expect( + await encode(build()), + expected, + reason: + '$name ${call.description}: the $form form encodes ' + 'differently from the $firstForm form', + ); + } + } + }); + } + }); +} + +/// The forms a Pipeline function is written in. +/// +/// The first form a function has holds its implementation, and the later +/// ones forward to it. The order is the only one that always compiles: a +/// later form can name an earlier one unambiguously, but inside a class a +/// bare name resolves to that class's own member, so `PipelineExpression.not` +/// or `Expression.field` cannot call the top-level `not` or `field`. +enum _Form { + /// `PipelineFunctions.x(...)`. + static, + + /// The FlutterFire-style `Expression.x(...)`. + expression, + + /// A top-level `x(...)`. + topLevel, + + /// `expression.x(...)`, on `PipelineExpression` or one of its subclasses. + fluent, +} + +/// A public member of `lib/src/pipeline.dart`. +final class _Member { + _Member(this.form, this.element, this.body); + + final _Form form; + final ExecutableElement element; + final ast.FunctionBody body; + + String get name => element.name!; + + String get label { + final owner = element.enclosingElement; + return owner is InterfaceElement ? '${owner.name}.$name' : name; + } + + bool isElement(Element? other) { + return other is ExecutableElement && + other.baseElement == element.baseElement; + } +} + +/// The public Pipeline members, grouped by name. +final class _Inventory { + _Inventory(List<_Member> members) { + for (final member in members) { + _byName.putIfAbsent(member.name, () => []).add(member); + } + for (final group in _byName.values) { + group.sort((a, b) => a.form.index.compareTo(b.form.index)); + } + } + + final _byName = >{}; + + /// The names written in more than one form, each with its members, + /// implementation first. + late final Map> pairs = { + for (final MapEntry(:key, :value) in _byName.entries) + if (value.map((member) => member.form).toSet().length > 1) key: value, + }; + + /// Fluent members whose name no other form uses. + late final List<_Member> unpairedFluent = [ + for (final MapEntry(:key, :value) in _byName.entries) + if (!pairs.containsKey(key)) + ...value.where((member) => member.form == _Form.fluent), + ]; + + /// The `PipelineFunctions` or fluent member that [member]'s body calls, if + /// its body is a single call. + _Member? calledMember(_Member member) { + final call = _singleExpression(member.body); + if (call is! ast.MethodInvocation) return null; + final callee = call.methodName.element; + for (final group in _byName.values) { + for (final candidate in group) { + if ((candidate.form == _Form.static || + candidate.form == _Form.fluent) && + candidate.isElement(callee)) { + return candidate; + } + } + } + return null; + } + + static Future<_Inventory> load() async { + final uri = await Isolate.resolvePackageUri( + Uri.parse('package:google_cloud_firestore/src/firestore.dart'), + ); + final libraryPath = uri!.toFilePath(); + final collection = AnalysisContextCollection(includedPaths: [libraryPath]); + try { + final result = await collection + .contextFor(libraryPath) + .currentSession + .getResolvedLibrary(libraryPath); + if (result is! ResolvedLibraryResult) { + throw StateError('Could not resolve $libraryPath: $result'); + } + final pipelinePath = uri.resolve('pipeline.dart').toFilePath(); + final unit = result.units + .singleWhere((unit) => unit.path == pipelinePath) + .unit; + final members = <_Member>[]; + unit.accept(_MemberCollector(members)); + return _Inventory(members); + } finally { + await collection.dispose(); + } + } +} + +/// Collects the public functions and methods that are forms of a Pipeline +/// function. +final class _MemberCollector extends ast.RecursiveAstVisitor { + _MemberCollector(this._members); + + final List<_Member> _members; + + @override + void visitFunctionDeclaration(ast.FunctionDeclaration node) { + final element = node.declaredFragment?.element; + if (node.parent is ast.CompilationUnit && + element is TopLevelFunctionElement && + element.isPublic) { + _members.add( + _Member(_Form.topLevel, element, node.functionExpression.body), + ); + } + } + + @override + void visitMethodDeclaration(ast.MethodDeclaration node) { + final element = node.declaredFragment?.element; + if (element is! MethodElement || !element.isPublic) return; + final owner = element.enclosingElement; + if (owner is! ClassElement || !owner.isPublic) return; + + final form = switch (owner.name) { + 'PipelineFunctions' when element.isStatic => _Form.static, + 'Expression' when element.isStatic => _Form.expression, + _ when !element.isStatic && _isPipelineExpression(owner) => _Form.fluent, + _ => null, + }; + if (form != null) _members.add(_Member(form, element, node.body)); + } + + static bool _isPipelineExpression(ClassElement element) { + return element.name == 'PipelineExpression' || + element.allSupertypes.any( + (type) => type.element.name == 'PipelineExpression', + ); + } +} + +/// Explains why [member] is not a single call forwarding its parameters to +/// [target], or returns null when it is. +/// +/// The call must pass `this` first when a fluent member forwards to a static +/// or top-level one, followed by each of [member]'s parameters, unchanged and +/// in order. A counterpart that takes its operands as one `Iterable` may +/// receive them packed, in the same order, into a list literal such as +/// `[this, second, ...others]`. Each parameter passed straight through must +/// match its counterpart's optionality and default value, and the counterpart +/// must not take parameters that [member] leaves out. +String? _forwardingProblem(_Member member, _Member target) { + final call = _singleExpression(member.body); + if (call is! ast.MethodInvocation || + !target.isElement(call.methodName.element)) { + return 'its body must be a single call to ${target.label}, but it is ' + '`${_source(member.body)}`'; + } + if (target.form == _Form.fluent && + call.target != null && + call.target is! ast.ThisExpression) { + return 'it must call ${target.label} on `this`'; + } + + final parameters = member.element.formalParameters; + FormalParameterElement? parameterOf(ast.AstNode node) { + if (node is! ast.SimpleIdentifier) return null; + final element = node.element; + return element is FormalParameterElement + ? _matching(element, parameters) + : null; + } + + final targetParameters = target.element.formalParameters; + final bound = {}; + final passed = []; + + for (final argument in call.argumentList.arguments) { + final targetParameter = _matching( + argument.correspondingParameter, + targetParameters, + ); + if (targetParameter == null) { + return 'argument `${argument.toSource()}` does not bind to a parameter ' + 'of ${target.label}'; + } + bound.add(targetParameter); + + // A named argument is `name: value`, whose value is its last child. This + // holds across analyzer versions, which model named arguments as + // different node types. + final value = targetParameter.isNamed + ? argument.childEntities.last + : argument; + + if (value is ast.ThisExpression) { + passed.add('this'); + } else if (value is ast.ListLiteral) { + for (final element in value.elements) { + final packed = switch (element) { + ast.ThisExpression() => 'this', + ast.SpreadElement(isNullAware: false, :final expression) => + parameterOf(expression)?.name, + _ => switch (parameterOf(element)) { + final parameter? when parameter.isRequiredPositional => + parameter.name, + _ => null, + }, + }; + if (packed == null) { + return 'list argument element `${element.toSource()}` must be ' + '`this`, a required parameter, or a spread parameter'; + } + passed.add(packed); + } + } else { + final parameter = value is ast.AstNode ? parameterOf(value) : null; + if (parameter == null || + (targetParameter.isNamed && parameter.name != targetParameter.name)) { + return 'argument `${argument.toSource()}` must pass one of its ' + 'parameters through unchanged'; + } + final mismatch = _parameterMismatch(parameter, targetParameter, target); + if (mismatch != null) return mismatch; + passed.add(parameter.name!); + } + } + + final dropped = [ + for (final parameter in targetParameters) + if (!bound.contains(parameter)) parameter.name, + ]; + if (dropped.isNotEmpty) { + return '${target.label} also takes ${dropped.join(', ')}, which it does ' + 'not expose'; + } + + final expected = [ + if (member.form == _Form.fluent && target.form != _Form.fluent) 'this', + for (final parameter in parameters) parameter.name!, + ]; + if (passed.join(', ') != expected.join(', ')) { + return 'it passes (${passed.join(', ')}) but must pass ' + '(${expected.join(', ')}), in that order'; + } + return null; +} + +/// The parameter of [parameters] that [parameter] refers to. +FormalParameterElement? _matching( + FormalParameterElement? parameter, + List parameters, +) { + for (final candidate in parameters) { + if (candidate.baseElement == parameter?.baseElement) return candidate; + } + return null; +} + +/// Explains how [parameter] differs from the [targetParameter] of [target] it +/// is passed to, or returns null when they match. +String? _parameterMismatch( + FormalParameterElement parameter, + FormalParameterElement targetParameter, + _Member target, +) { + String kind(FormalParameterElement parameter) { + if (parameter.isRequiredPositional) return 'required'; + if (parameter.isOptionalPositional) return 'optional'; + return parameter.isRequiredNamed ? 'required named' : 'optional named'; + } + + if (kind(parameter) != kind(targetParameter)) { + return '`${parameter.name}` is ${kind(parameter)} but ' + '${target.label}\'s `${targetParameter.name}` is ' + '${kind(targetParameter)}'; + } + if (parameter.defaultValueCode != targetParameter.defaultValueCode) { + return '`${parameter.name}` defaults to `${parameter.defaultValueCode}` ' + 'but ${target.label}\'s `${targetParameter.name}` defaults to ' + '`${targetParameter.defaultValueCode}`'; + } + return null; +} + +/// The expression [body] evaluates, when it is `=> e` or `{ return e; }`. +ast.Expression? _singleExpression(ast.FunctionBody body) { + return switch (body) { + ast.ExpressionFunctionBody(:final expression) => expression, + ast.BlockFunctionBody( + block: ast.Block(statements: [ast.ReturnStatement(:final expression)]), + ) => + expression, + _ => null, + }; +} + +String _source(ast.AstNode node) { + return node.toSource().replaceAll(RegExp(r'\s+'), ' '); +} + +const _projectId = 'test-project'; + +class _MockFirestoreHttpClient extends Mock implements FirestoreHttpClient {} + +/// One call to a Pipeline function, written in several of its forms. +/// +/// [forms] maps each form's name (a [_Form] name, or `alias` for a fluent +/// member forwarding to a differently named one) to a builder returning the +/// [PipelineExpression] or [PipelineOrdering] that form produces. +final class _Call { + _Call(this.description, this.forms); + + final String description; + final Map forms; +} + +/// A receiver that is an expression rather than a plain field. +final _expression = field('score').ifAbsent(0); + +/// A boolean receiver. +final _condition = field('score').greaterThan(10); + +/// [description] called on a field name and on an expression. +/// +/// The static and top-level forms take the receiver as their first argument; +/// the fluent and alias forms are called on it. Pass [fieldName] false for +/// functions whose first argument is a value rather than a field. +List<_Call> _on( + String description, { + Object Function(Object? receiver)? static, + Object Function(Object? receiver)? topLevel, + Object Function(PipelineExpression receiver)? fluent, + Object Function(PipelineExpression receiver)? alias, + bool fieldName = true, +}) { + _Call call(String on, Object? argument, PipelineExpression receiver) { + return _Call('$description on $on', { + if (static case final build?) 'static': () => build(argument), + if (topLevel case final build?) 'topLevel': () => build(argument), + if (fluent case final build?) 'fluent': () => build(receiver), + if (alias case final build?) 'alias': () => build(receiver), + }); + } + + return [ + if (fieldName) call('a field name', 'score', field('score')), + call('an expression', _expression, _expression), + ]; +} + +/// A function of its receiver alone. +List<_Call> _unary( + Object Function(Object? receiver) static, + Object Function(PipelineExpression receiver) fluent, { + bool fieldName = true, +}) { + return _on('called', static: static, fluent: fluent, fieldName: fieldName); +} + +/// A function of its receiver and one argument, called with each of +/// [arguments]. +List<_Call> _binary( + Object Function(Object? receiver, Object? argument) static, + Object Function(PipelineExpression receiver, Object? argument) fluent, { + Object Function(Object? receiver, Object? argument)? topLevel, + List? arguments, + bool fieldName = true, +}) { + return [ + for (final argument in arguments ?? [2, 'text', field('other')]) + ..._on( + 'with $argument', + static: (receiver) => static(receiver, argument), + topLevel: topLevel == null + ? null + : (receiver) => topLevel(receiver, argument), + fluent: (receiver) => fluent(receiver, argument), + fieldName: fieldName, + ), + ]; +} + +/// A comparison, which also has a top-level form. +List<_Call> _comparison( + Object Function(Object? left, Object? right) static, + Object Function(Object? left, Object? right) topLevel, + Object Function(PipelineExpression left, Object? right) fluent, +) { + return _binary( + static, + fluent, + topLevel: topLevel, + arguments: [2, 'text', field('other'), null], + ); +} + +/// A function of a boolean receiver. +List<_Call> _onCondition( + String description, { + required Object Function(PipelineBooleanExpression condition) static, + Object Function(PipelineBooleanExpression condition)? topLevel, + required Object Function(PipelineBooleanExpression condition) fluent, +}) { + return [ + _Call(description, { + 'static': () => static(_condition), + if (topLevel case final build?) 'topLevel': () => build(_condition), + 'fluent': () => fluent(_condition), + }), + ]; +} + +/// A list holding an expression, sent as an `array(...)` function. +final _mixedList = [1, field('other')]; + +/// Every Pipeline function pair and fluent alias, written in each form. +final _behaviour = >{ + // Comparisons. + 'equal': _comparison(PipelineFunctions.equal, equal, (e, v) => e.equal(v)), + 'notEqual': _comparison( + PipelineFunctions.notEqual, + notEqual, + (e, v) => e.notEqual(v), + ), + 'lessThan': _comparison( + PipelineFunctions.lessThan, + lessThan, + (e, v) => e.lessThan(v), + ), + 'lessThanOrEqual': _comparison( + PipelineFunctions.lessThanOrEqual, + lessThanOrEqual, + (e, v) => e.lessThanOrEqual(v), + ), + 'greaterThan': _comparison( + PipelineFunctions.greaterThan, + greaterThan, + (e, v) => e.greaterThan(v), + ), + 'greaterThanOrEqual': _comparison( + PipelineFunctions.greaterThanOrEqual, + greaterThanOrEqual, + (e, v) => e.greaterThanOrEqual(v), + ), + 'equalAny': _binary( + PipelineFunctions.equalAny, + (e, v) => e.equalAny(v), + arguments: [ + [1, 2], + _mixedList, + field('options'), + ], + ), + 'notEqualAny': _binary( + PipelineFunctions.notEqualAny, + (e, v) => e.notEqualAny(v), + arguments: [ + [1, 2], + _mixedList, + field('options'), + ], + ), + + // Logic. + 'and': [ + _Call('of two conditions', { + 'static': () => PipelineFunctions.and([_condition, field('b').exists()]), + 'topLevel': () => and([_condition, field('b').exists()]), + }), + ], + 'or': [ + _Call('of two conditions', { + 'static': () => PipelineFunctions.or([_condition, field('b').exists()]), + 'topLevel': () => or([_condition, field('b').exists()]), + }), + ], + 'not': _onCondition( + 'of a condition', + static: PipelineFunctions.not, + topLevel: not, + fluent: (c) => c.not(), + ), + 'countIf': _onCondition( + 'of a condition', + static: PipelineFunctions.countIf, + fluent: (c) => c.countIf(), + ), + 'conditional': [ + ..._onCondition( + 'with literals', + static: (c) => PipelineFunctions.conditional(c, 1, 'text'), + fluent: (c) => c.conditional(1, 'text'), + ), + ..._onCondition( + 'with expressions', + static: (c) => PipelineFunctions.conditional(c, field('a'), _mixedList), + fluent: (c) => c.conditional(field('a'), _mixedList), + ), + ], + 'exists': _unary(PipelineFunctions.exists, (e) => e.exists()), + 'isAbsent': _unary(PipelineFunctions.isAbsent, (e) => e.isAbsent()), + 'isError': _unary( + PipelineFunctions.isError, + (e) => e.isError(), + fieldName: false, + ), + 'ifAbsent': _binary(PipelineFunctions.ifAbsent, (e, v) => e.ifAbsent(v)), + 'ifNull': _binary(PipelineFunctions.ifNull, (e, v) => e.ifNull(v)), + 'ifError': _binary( + PipelineFunctions.ifError, + (e, v) => e.ifError(v), + fieldName: false, + ), + 'coalesce': [ + ..._on( + 'with one replacement', + static: (r) => PipelineFunctions.coalesce(r, 0), + fluent: (e) => e.coalesce(0), + ), + ..._on( + 'with more replacements', + static: (r) => PipelineFunctions.coalesce(r, field('b'), [0, 'text']), + fluent: (e) => e.coalesce(field('b'), [0, 'text']), + ), + ], + 'logicalMaximum': [ + ..._on( + 'with two operands', + static: (r) => PipelineFunctions.logicalMaximum(r, 1), + fluent: (e) => e.logicalMaximum(1), + ), + ..._on( + 'with more operands', + static: (r) => PipelineFunctions.logicalMaximum(r, 1, [field('b'), 3]), + fluent: (e) => e.logicalMaximum(1, [field('b'), 3]), + ), + ], + 'logicalMinimum': [ + ..._on( + 'with two operands', + static: (r) => PipelineFunctions.logicalMinimum(r, 1), + fluent: (e) => e.logicalMinimum(1), + ), + ..._on( + 'with more operands', + static: (r) => PipelineFunctions.logicalMinimum(r, 1, [field('b'), 3]), + fluent: (e) => e.logicalMinimum(1, [field('b'), 3]), + ), + ], + + // Arithmetic. + 'add': _binary(PipelineFunctions.add, (e, v) => e.add(v)), + 'subtract': _binary(PipelineFunctions.subtract, (e, v) => e.subtract(v)), + 'multiply': _binary(PipelineFunctions.multiply, (e, v) => e.multiply(v)), + 'divide': _binary(PipelineFunctions.divide, (e, v) => e.divide(v)), + 'mod': _binary(PipelineFunctions.mod, (e, v) => e.mod(v)), + 'pow': _binary(PipelineFunctions.pow, (e, v) => e.pow(v)), + 'abs': _unary(PipelineFunctions.abs, (e) => e.abs()), + 'ceil': _unary(PipelineFunctions.ceil, (e) => e.ceil()), + 'floor': _unary(PipelineFunctions.floor, (e) => e.floor()), + 'sqrt': _unary(PipelineFunctions.sqrt, (e) => e.sqrt()), + 'exp': _unary(PipelineFunctions.exp, (e) => e.exp()), + 'ln': _unary(PipelineFunctions.ln, (e) => e.ln()), + 'log10': _unary(PipelineFunctions.log10, (e) => e.log10()), + 'log': [ + ..._unary(PipelineFunctions.log, (e) => e.log()), + ..._binary(PipelineFunctions.log, (e, v) => e.log(v)), + ], + 'round': [ + ..._unary(PipelineFunctions.round, (e) => e.round()), + ..._binary(PipelineFunctions.round, (e, v) => e.round(v)), + ], + 'trunc': [ + ..._unary(PipelineFunctions.trunc, (e) => e.trunc()), + ..._binary(PipelineFunctions.trunc, (e, v) => e.trunc(v)), + ], + + // Aggregates. + 'count': _unary(PipelineFunctions.count, (e) => e.count()), + 'countDistinct': _unary( + PipelineFunctions.countDistinct, + (e) => e.countDistinct(), + ), + 'sum': _unary(PipelineFunctions.sum, (e) => e.sum()), + 'average': _unary(PipelineFunctions.average, (e) => e.average()), + 'minimum': _unary(PipelineFunctions.minimum, (e) => e.minimum()), + 'maximum': _unary(PipelineFunctions.maximum, (e) => e.maximum()), + 'first': _unary(PipelineFunctions.first, (e) => e.first()), + 'last': _unary(PipelineFunctions.last, (e) => e.last()), + 'arrayAgg': _unary(PipelineFunctions.arrayAgg, (e) => e.arrayAgg()), + 'arrayAggDistinct': _unary( + PipelineFunctions.arrayAggDistinct, + (e) => e.arrayAggDistinct(), + ), + + // Arrays. + 'array': [ + _Call('of literals and expressions', { + 'static': () => PipelineFunctions.array(_mixedList), + 'expression': () => Expression.array(_mixedList), + }), + ], + 'arrayConcat': _binary( + (r, v) => PipelineFunctions.arrayConcat([r, v]), + (e, v) => e.arrayConcat(v), + arguments: [ + ['text'], + _mixedList, + field('other'), + ], + ), + 'arrayConcatMultiple': _on( + 'with two arrays', + static: (r) => PipelineFunctions.arrayConcat([r, _mixedList, field('b')]), + alias: (e) => e.arrayConcatMultiple([_mixedList, field('b')]), + ), + 'arrayContains': _binary( + PipelineFunctions.arrayContains, + (e, v) => e.arrayContains(v), + ), + 'arrayContainsAll': _binary( + PipelineFunctions.arrayContainsAll, + (e, v) => e.arrayContainsAll(v), + arguments: [ + ['a', 'b'], + _mixedList, + field('wanted'), + ], + ), + 'arrayContainsAllFrom': _on( + 'with an array expression', + static: (r) => PipelineFunctions.arrayContainsAll(r, field('wanted')), + alias: (e) => e.arrayContainsAllFrom(field('wanted')), + ), + 'arrayContainsAny': _binary( + PipelineFunctions.arrayContainsAny, + (e, v) => e.arrayContainsAny(v), + arguments: [ + ['a', 'b'], + _mixedList, + field('wanted'), + ], + ), + 'arrayFilter': _on( + 'with a predicate', + static: (r) => PipelineFunctions.arrayFilter( + r, + 'tag', + variable('tag').notEqual('draft'), + ), + fluent: (e) => e.arrayFilter('tag', variable('tag').notEqual('draft')), + ), + 'arrayTransform': _on( + 'with a transform', + static: (r) => + PipelineFunctions.arrayTransform(r, 'n', variable('n').add(1)), + fluent: (e) => e.arrayTransform('n', variable('n').add(1)), + ), + 'arrayTransformWithIndex': _on( + 'with a transform', + static: (r) => PipelineFunctions.arrayTransformWithIndex( + r, + 'n', + 'i', + variable('n').add(variable('i')), + ), + fluent: (e) => + e.arrayTransformWithIndex('n', 'i', variable('n').add(variable('i'))), + ), + 'arrayGet': _binary(PipelineFunctions.arrayGet, (e, v) => e.arrayGet(v)), + 'arrayFirst': _unary(PipelineFunctions.arrayFirst, (e) => e.arrayFirst()), + 'arrayFirstN': _binary( + PipelineFunctions.arrayFirstN, + (e, v) => e.arrayFirstN(v), + ), + 'arrayLast': _unary(PipelineFunctions.arrayLast, (e) => e.arrayLast()), + 'arrayLastN': _binary( + PipelineFunctions.arrayLastN, + (e, v) => e.arrayLastN(v), + ), + 'arrayIndexOf': _binary( + PipelineFunctions.arrayIndexOf, + (e, v) => e.arrayIndexOf(v), + ), + 'arrayIndexOfAll': _binary( + PipelineFunctions.arrayIndexOfAll, + (e, v) => e.arrayIndexOfAll(v), + ), + 'arrayLastIndexOf': _binary( + PipelineFunctions.arrayLastIndexOf, + (e, v) => e.arrayLastIndexOf(v), + ), + 'arrayLength': _unary(PipelineFunctions.arrayLength, (e) => e.arrayLength()), + 'arrayReverse': _unary( + PipelineFunctions.arrayReverse, + (e) => e.arrayReverse(), + ), + 'arrayMaximum': _unary( + PipelineFunctions.arrayMaximum, + (e) => e.arrayMaximum(), + ), + 'arrayMaximumN': _binary( + PipelineFunctions.arrayMaximumN, + (e, v) => e.arrayMaximumN(v), + ), + 'arrayMinimum': _unary( + PipelineFunctions.arrayMinimum, + (e) => e.arrayMinimum(), + ), + 'arrayMinimumN': _binary( + PipelineFunctions.arrayMinimumN, + (e, v) => e.arrayMinimumN(v), + ), + 'arraySum': _unary(PipelineFunctions.arraySum, (e) => e.arraySum()), + 'arraySlice': [ + ..._binary(PipelineFunctions.arraySlice, (e, v) => e.arraySlice(v)), + ..._on( + 'with a length', + static: (r) => PipelineFunctions.arraySlice(r, 1, 2), + fluent: (e) => e.arraySlice(1, 2), + ), + ..._on( + 'with expressions', + static: (r) => PipelineFunctions.arraySlice(r, field('o'), field('l')), + fluent: (e) => e.arraySlice(field('o'), field('l')), + ), + ], + + // Maps. + 'mapGet': _binary(PipelineFunctions.mapGet, (e, v) => e.mapGet(v)), + 'mapGetLiteral': _on( + 'with a key', + fluent: (e) => e.mapGet('key'), + alias: (e) => e.mapGetLiteral('key'), + fieldName: false, + ), + 'getField': _binary(PipelineFunctions.getField, (e, v) => e.getField(v)), + 'mapSet': [ + ..._on( + 'with one entry', + static: (r) => PipelineFunctions.mapSet(r, ['key', 1]), + fluent: (e) => e.mapSet('key', 1), + ), + ..._on( + 'with more entries', + static: (r) => PipelineFunctions.mapSet(r, [ + field('key'), + _mixedList, + 'other', + {'a': field('a')}, + ]), + fluent: (e) => e.mapSet(field('key'), _mixedList, [ + 'other', + {'a': field('a')}, + ]), + ), + ], + 'mapRemove': _binary( + PipelineFunctions.mapRemove, + (e, v) => e.mapRemove(v), + arguments: ['key', field('key')], + ), + 'mapMerge': _on( + 'with two maps', + static: (r) => PipelineFunctions.mapMerge([ + r, + {'a': 1}, + {'b': field('b')}, + ]), + fluent: (e) => e.mapMerge([ + {'a': 1}, + {'b': field('b')}, + ]), + ), + 'mapKeys': _unary(PipelineFunctions.mapKeys, (e) => e.mapKeys()), + 'mapValues': _unary(PipelineFunctions.mapValues, (e) => e.mapValues()), + 'mapEntries': _unary(PipelineFunctions.mapEntries, (e) => e.mapEntries()), + + // Strings. + 'join': _binary(PipelineFunctions.join, (e, v) => e.join(v)), + 'joinLiteral': _on( + 'with a delimiter', + fluent: (e) => e.join(', '), + alias: (e) => e.joinLiteral(', '), + fieldName: false, + ), + 'split': _binary(PipelineFunctions.split, (e, v) => e.split(v)), + 'splitLiteral': _on( + 'with a delimiter', + fluent: (e) => e.split(','), + alias: (e) => e.splitLiteral(','), + fieldName: false, + ), + 'byteLength': _unary(PipelineFunctions.byteLength, (e) => e.byteLength()), + 'charLength': _unary(PipelineFunctions.charLength, (e) => e.charLength()), + 'length': _unary(PipelineFunctions.length, (e) => e.length()), + 'reverse': _unary(PipelineFunctions.reverse, (e) => e.reverse()), + 'stringReverse': _unary( + PipelineFunctions.stringReverse, + (e) => e.stringReverse(), + ), + 'concat': [ + ..._on( + 'with one operand', + static: (r) => PipelineFunctions.concat(r, field('b')), + fluent: (e) => e.concat([field('b')]), + ), + ..._on( + 'with more operands', + static: (r) => PipelineFunctions.concat(r, ' ', [_mixedList]), + fluent: (e) => e.concat([' ', _mixedList]), + ), + ], + 'stringConcat': _on( + 'with two operands', + static: (r) => PipelineFunctions.stringConcat([r, ' ', field('b')]), + fluent: (e) => e.stringConcat([' ', field('b')]), + ), + 'toLowerCase': _on( + 'called', + static: PipelineFunctions.toLower, + alias: (e) => e.toLowerCase(), + ), + 'toUpperCase': _on( + 'called', + static: PipelineFunctions.toUpper, + alias: (e) => e.toUpperCase(), + ), + 'trim': [ + ..._unary(PipelineFunctions.trim, (e) => e.trim()), + ..._binary(PipelineFunctions.trim, (e, v) => e.trim(v)), + ], + 'ltrim': [ + ..._unary(PipelineFunctions.ltrim, (e) => e.ltrim()), + ..._binary(PipelineFunctions.ltrim, (e, v) => e.ltrim(v)), + ], + 'rtrim': [ + ..._unary(PipelineFunctions.rtrim, (e) => e.rtrim()), + ..._binary(PipelineFunctions.rtrim, (e, v) => e.rtrim(v)), + ], + 'stringIndexOf': _binary( + PipelineFunctions.stringIndexOf, + (e, v) => e.stringIndexOf(v), + ), + 'stringContains': _binary( + PipelineFunctions.stringContains, + (e, v) => e.stringContains(v), + ), + 'stringRepeat': _binary( + PipelineFunctions.stringRepeat, + (e, v) => e.stringRepeat(v), + ), + 'stringReplaceAll': [ + ..._on( + 'with literals', + static: (r) => PipelineFunctions.stringReplaceAll(r, 'a', 'b'), + fluent: (e) => e.stringReplaceAll('a', 'b'), + ), + ..._on( + 'with expressions', + static: (r) => + PipelineFunctions.stringReplaceAll(r, field('a'), field('b')), + fluent: (e) => e.stringReplaceAll(field('a'), field('b')), + ), + ], + 'stringReplaceAllLiteral': _on( + 'with literals', + fluent: (e) => e.stringReplaceAll('a', 'b'), + alias: (e) => e.stringReplaceAllLiteral('a', 'b'), + fieldName: false, + ), + 'stringReplaceOne': [ + ..._on( + 'with literals', + static: (r) => PipelineFunctions.stringReplaceOne(r, 'a', 'b'), + fluent: (e) => e.stringReplaceOne('a', 'b'), + ), + ..._on( + 'with expressions', + static: (r) => + PipelineFunctions.stringReplaceOne(r, field('a'), field('b')), + fluent: (e) => e.stringReplaceOne(field('a'), field('b')), + ), + ], + 'stringReplaceOneLiteral': _on( + 'with literals', + fluent: (e) => e.stringReplaceOne('a', 'b'), + alias: (e) => e.stringReplaceOneLiteral('a', 'b'), + fieldName: false, + ), + 'substring': [ + ..._binary(PipelineFunctions.substring, (e, v) => e.substring(v)), + ..._on( + 'with a length', + static: (r) => PipelineFunctions.substring(r, 1, 3), + fluent: (e) => e.substring(1, 3), + ), + ..._on( + 'with expressions', + static: (r) => PipelineFunctions.substring(r, field('p'), field('l')), + fluent: (e) => e.substring(field('p'), field('l')), + ), + ], + 'substringLiteral': [ + ..._on( + 'with a position', + fluent: (e) => e.substring(1), + alias: (e) => e.substringLiteral(1), + fieldName: false, + ), + ..._on( + 'with a length', + fluent: (e) => e.substring(1, 3), + alias: (e) => e.substringLiteral(1, 3), + fieldName: false, + ), + ], + 'startsWith': _binary( + PipelineFunctions.startsWith, + (e, v) => e.startsWith(v), + ), + 'endsWith': _binary(PipelineFunctions.endsWith, (e, v) => e.endsWith(v)), + 'like': _binary(PipelineFunctions.like, (e, v) => e.like(v)), + 'regexContains': _binary( + PipelineFunctions.regexContains, + (e, v) => e.regexContains(v), + ), + 'regexMatch': _binary( + PipelineFunctions.regexMatch, + (e, v) => e.regexMatch(v), + ), + 'regexFind': _binary(PipelineFunctions.regexFind, (e, v) => e.regexFind(v)), + 'regexFindAll': _binary( + PipelineFunctions.regexFindAll, + (e, v) => e.regexFindAll(v), + ), + + // Timestamps. + 'timestampTruncate': [ + ..._binary( + PipelineFunctions.timestampTruncate, + (e, v) => e.timestampTruncate(v), + arguments: ['day', field('granularity')], + ), + ..._on( + 'with a timezone', + static: (r) => PipelineFunctions.timestampTruncate(r, 'day', 'UTC'), + fluent: (e) => e.timestampTruncate('day', 'UTC'), + ), + ], + 'timestampExtract': [ + ..._binary( + PipelineFunctions.timestampExtract, + (e, v) => e.timestampExtract(v), + arguments: ['year', field('part')], + ), + ..._on( + 'with a timezone', + static: (r) => PipelineFunctions.timestampExtract(r, 'year', 'UTC'), + fluent: (e) => e.timestampExtract('year', 'UTC'), + ), + ], + 'timestampAdd': [ + ..._on( + 'with literals', + static: (r) => PipelineFunctions.timestampAdd(r, 'day', 1), + fluent: (e) => e.timestampAdd('day', 1), + ), + ..._on( + 'with expressions', + static: (r) => + PipelineFunctions.timestampAdd(r, field('unit'), field('amount')), + fluent: (e) => e.timestampAdd(field('unit'), field('amount')), + ), + ], + 'timestampSubtract': [ + ..._on( + 'with literals', + static: (r) => PipelineFunctions.timestampSubtract(r, 'day', 1), + fluent: (e) => e.timestampSubtract('day', 1), + ), + ..._on( + 'with expressions', + static: (r) => PipelineFunctions.timestampSubtract( + r, + field('unit'), + field('amount'), + ), + fluent: (e) => e.timestampSubtract(field('unit'), field('amount')), + ), + ], + 'timestampDiff': [ + ..._on( + 'with a start field name', + static: (r) => PipelineFunctions.timestampDiff(r, 'createdAt', 'day'), + fluent: (e) => e.timestampDiff('createdAt', 'day'), + ), + ..._on( + 'with expressions', + static: (r) => + PipelineFunctions.timestampDiff(r, _expression, field('unit')), + fluent: (e) => e.timestampDiff(_expression, field('unit')), + ), + ], + 'timestampToUnixMicros': _unary( + PipelineFunctions.timestampToUnixMicros, + (e) => e.timestampToUnixMicros(), + ), + 'timestampToUnixMillis': _unary( + PipelineFunctions.timestampToUnixMillis, + (e) => e.timestampToUnixMillis(), + ), + 'timestampToUnixSeconds': _unary( + PipelineFunctions.timestampToUnixSeconds, + (e) => e.timestampToUnixSeconds(), + ), + 'unixMicrosToTimestamp': _unary( + PipelineFunctions.unixMicrosToTimestamp, + (e) => e.unixMicrosToTimestamp(), + ), + 'unixMillisToTimestamp': _unary( + PipelineFunctions.unixMillisToTimestamp, + (e) => e.unixMillisToTimestamp(), + ), + 'unixSecondsToTimestamp': _unary( + PipelineFunctions.unixSecondsToTimestamp, + (e) => e.unixSecondsToTimestamp(), + ), + + // References. + 'collectionId': _unary( + PipelineFunctions.collectionId, + (e) => e.collectionId(), + ), + 'documentId': _unary( + PipelineFunctions.documentId, + (e) => e.documentId(), + fieldName: false, + ), + 'parent': _unary( + PipelineFunctions.parent, + (e) => e.parent(), + fieldName: false, + ), + 'referenceSlice': [ + ..._on( + 'with literals', + static: (r) => PipelineFunctions.referenceSlice(r, 0, 2), + fluent: (e) => e.referenceSlice(0, 2), + ), + ..._on( + 'with expressions', + static: (r) => + PipelineFunctions.referenceSlice(r, field('o'), field('l')), + fluent: (e) => e.referenceSlice(field('o'), field('l')), + ), + ], + + // Types. + 'type': _unary(PipelineFunctions.type, (e) => e.type()), + 'isType': _binary( + PipelineFunctions.isType, + (e, v) => e.isType(v), + arguments: ['string', PipelineValueType.double], + ), + + // Vectors and geo points. + 'cosineDistance': _binary( + PipelineFunctions.cosineDistance, + (e, v) => e.cosineDistance(v), + arguments: _vectors, + ), + 'dotProduct': _binary( + PipelineFunctions.dotProduct, + (e, v) => e.dotProduct(v), + arguments: _vectors, + ), + 'euclideanDistance': _binary( + PipelineFunctions.euclideanDistance, + (e, v) => e.euclideanDistance(v), + arguments: _vectors, + ), + 'vectorLength': _unary( + PipelineFunctions.vectorLength, + (e) => e.vectorLength(), + ), + 'geoDistance': _binary( + PipelineFunctions.geoDistance, + (e, v) => e.geoDistance(v), + arguments: [GeoPoint(latitude: 1, longitude: 2), field('location')], + ), + + // Expressions with no receiver. + 'field': [ + _Call('of a path', { + 'expression': () => Expression.field('metadata.lang'), + 'topLevel': () => field('metadata.lang'), + }), + ], + 'constant': [ + for (final value in [ + 1, + 'text', + null, + [1, 2], + ]) + _Call('of $value', { + 'expression': () => Expression.constant(value), + 'topLevel': () => constant(value), + }), + ], + 'variable': [ + _Call('of a name', { + 'expression': () => Expression.variable('tag'), + 'topLevel': () => variable('tag'), + }), + ], + 'raw': [ + _Call('without options', { + 'static': () => PipelineFunctions.raw('fn', [1, field('a')]), + 'expression': () => Expression.raw('fn', [1, field('a')]), + }), + _Call('with options', { + 'static': () => + PipelineFunctions.raw('fn', [field('a')], options: {'mode': 'fast'}), + 'expression': () => + Expression.raw('fn', [field('a')], options: {'mode': 'fast'}), + }), + ], + 'currentDocument': [ + _Call('called', { + 'static': PipelineFunctions.currentDocument, + 'topLevel': currentDocument, + }), + ], + 'score': [ + _Call('called', {'static': PipelineFunctions.score, 'topLevel': score}), + ], + 'documentMatches': [ + for (final query in ['dart', field('query')]) + _Call('with $query', { + 'static': () => PipelineFunctions.documentMatches(query), + 'topLevel': () => documentMatches(query), + }), + ], + + // Orderings. + 'ascending': _on( + 'called', + topLevel: (r) => ascending(r!), + fluent: (e) => e.ascending(), + ), + 'descending': _on( + 'called', + topLevel: (r) => descending(r!), + fluent: (e) => e.descending(), + ), +}; + +/// Vector arguments: a list of numbers, a vector value and an expression. +final _vectors = [ + [1.0, 2.0], + FieldValue.vector([1.0, 2.0]), + field('other'), +]; From e3887a29b4886dd46efdf33ae8724b0a01073288 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:12:51 +0200 Subject: [PATCH 06/29] test(firestore): compare Pipeline requests against the Node SDK Add a golden wire-format harness for Firestore Pipelines. The expected ExecutePipelineRequests come from the Node.js SDK, never from this package, so a test can no longer pass by asserting our own output. tool/pipeline_golden builds 840 named Pipelines with a pinned @google-cloud/firestore (9.3.1) and records the request it sends, by stubbing Firestore.requestStream, as proto3 JSON fixtures. The corpus covers every source, createFrom of collection, collection-group and vector queries, every stage, every function in both its top-level and Expression method forms, every aggregate and the execute options; the generator fails if a Node function or method has no case. test/pipeline_golden_test.dart builds the same Pipelines with the Dart SDK and requires an identical request. Intended differences, pending fixes and Node APIs Dart cannot express are listed explicitly, and each entry is checked so it cannot silently go stale. A path-filtered workflow regenerates the fixtures and fails when the committed ones are stale. --- .github/workflows/pipeline_golden.yml | 62 + CONTRIBUTING.md | 26 + .../fixtures/pipeline_golden/aggregates.json | 943 + .../fixtures/pipeline_golden/functions.json | 17683 ++++++++++++++++ .../fixtures/pipeline_golden/options.json | 209 + .../fixtures/pipeline_golden/queries.json | 3846 ++++ .../fixtures/pipeline_golden/sources.json | 181 + .../test/fixtures/pipeline_golden/stages.json | 1901 ++ .../test/fixtures/pipeline_golden/values.json | 1596 ++ .../test/pipeline_golden_test.dart | 2940 +++ .../tool/pipeline_golden/.pubignore | 2 + .../tool/pipeline_golden/README.md | 101 + .../tool/pipeline_golden/cases.js | 1291 ++ .../tool/pipeline_golden/generate.js | 259 + .../tool/pipeline_golden/package-lock.json | 1358 ++ .../tool/pipeline_golden/package.json | 15 + 16 files changed, 32413 insertions(+) create mode 100644 .github/workflows/pipeline_golden.yml create mode 100644 packages/google_cloud_firestore/test/fixtures/pipeline_golden/aggregates.json create mode 100644 packages/google_cloud_firestore/test/fixtures/pipeline_golden/functions.json create mode 100644 packages/google_cloud_firestore/test/fixtures/pipeline_golden/options.json create mode 100644 packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json create mode 100644 packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json create mode 100644 packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json create mode 100644 packages/google_cloud_firestore/test/fixtures/pipeline_golden/values.json create mode 100644 packages/google_cloud_firestore/test/pipeline_golden_test.dart create mode 100644 packages/google_cloud_firestore/tool/pipeline_golden/.pubignore create mode 100644 packages/google_cloud_firestore/tool/pipeline_golden/README.md create mode 100644 packages/google_cloud_firestore/tool/pipeline_golden/cases.js create mode 100644 packages/google_cloud_firestore/tool/pipeline_golden/generate.js create mode 100644 packages/google_cloud_firestore/tool/pipeline_golden/package-lock.json create mode 100644 packages/google_cloud_firestore/tool/pipeline_golden/package.json diff --git a/.github/workflows/pipeline_golden.yml b/.github/workflows/pipeline_golden.yml new file mode 100644 index 00000000..62b5fc93 --- /dev/null +++ b/.github/workflows/pipeline_golden.yml @@ -0,0 +1,62 @@ +name: Pipeline Golden Fixtures + +on: + pull_request: + # The fixtures only depend on google_cloud_firestore's generator and the + # Node SDK it pins, so there is nothing to check for other changes. + paths: + - "packages/google_cloud_firestore/**" + - ".github/workflows/pipeline_golden.yml" + push: + branches: + - main + paths: + - "packages/google_cloud_firestore/**" + - ".github/workflows/pipeline_golden.yml" + + workflow_dispatch: + +permissions: + contents: read + +jobs: + pipeline-golden: + name: Pipeline golden fixtures are up to date + runs-on: ubuntu-latest + timeout-minutes: 10 + + defaults: + run: + working-directory: packages/google_cloud_firestore/tool/pipeline_golden + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + # The pinned @google-cloud/firestore requires Node.js 22 or later. + node-version: "24" + + - name: Install the pinned Node SDK + run: npm ci --no-audit --no-fund + + - name: Regenerate the fixtures + run: npm run generate + + # test/pipeline_golden_test.dart compares against the committed fixtures + # in the regular `test` job of build.yml; this only proves they are what + # the pinned Node SDK produces for tool/pipeline_golden/cases.js. + - name: Fail if the committed fixtures are stale + working-directory: . + run: | + fixtures=packages/google_cloud_firestore/test/fixtures/pipeline_golden + if [ -n "$(git status --porcelain -- "$fixtures")" ]; then + git status --short -- "$fixtures" + git --no-pager diff --stat -- "$fixtures" + git --no-pager diff -- "$fixtures" | head -n 200 + echo "::error::The Pipeline golden fixtures are stale. Run 'npm ci && npm run generate' in packages/google_cloud_firestore/tool/pipeline_golden and commit the result." + exit 1 + fi + git diff --exit-code -- "$fixtures" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 417c4655..d309dee2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -137,6 +137,23 @@ No setup needed: dart test ``` +#### `google_cloud_firestore` Pipeline golden fixtures + +`test/pipeline_golden_test.dart` is a unit test that compares every Pipeline +request against the request the Node.js SDK sends for the same Pipeline. The +expected requests in `test/fixtures/pipeline_golden/` are generated by the +pinned Node SDK; regenerate them (Node.js 22+) whenever you add a case or bump +that SDK: + +```bash +# From packages/google_cloud_firestore/tool/pipeline_golden +npm ci && npm run generate +``` + +See +[`tool/pipeline_golden/README.md`](packages/google_cloud_firestore/tool/pipeline_golden/README.md) +for how capture works and how to record intended differences. + #### `firebase_admin_sdk` emulator tests Requires the [Firebase CLI](https://firebase.google.com/docs/cli) and Java 21+. @@ -314,6 +331,15 @@ can affect it. See [`packages/google_cloud_firestore/test/e2e/README.md`](packages/google_cloud_firestore/test/e2e/README.md) for the required secrets and the one-time vector index setup. +**pipeline_golden.yml** keeps the Node-generated Pipeline fixtures honest: + +| Job | Trigger | What it does | +|-----|---------|--------------| +| `pipeline-golden` | PRs touching `packages/google_cloud_firestore/**`, pushes to `main` & manual | Regenerates `test/fixtures/pipeline_golden/` with the pinned Node SDK and fails if the committed fixtures differ | + +The comparison itself, `test/pipeline_golden_test.dart`, runs with the other +unit tests in `build.yml`. + Tests run against both `stable` and `beta` Dart SDK channels. Coverage is reported as a PR comment and uploaded to Codecov. The minimum threshold is **40%**. ## License diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/aggregates.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/aggregates.json new file mode 100644 index 00000000..da8f05f6 --- /dev/null +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/aggregates.json @@ -0,0 +1,943 @@ +{ + "generator": "tool/pipeline_golden (npm run generate)", + "sdk": "@google-cloud/firestore@9.3.1", + "cases": { + "aggregates/arrayAgg/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_agg", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/arrayAgg/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_agg", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/arrayAgg/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_agg", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/arrayAggDistinct/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_agg_distinct", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/arrayAggDistinct/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_agg_distinct", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/arrayAggDistinct/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_agg_distinct", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/average/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "average", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/average/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "average", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/average/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "average", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/count/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "count", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/count/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "count", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/count/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "count", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/countAll/static": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": {"result": {"functionValue": {"name": "count"}}} + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/countDistinct/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "count_distinct", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/countDistinct/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "count_distinct", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/countDistinct/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "count_distinct", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/countIf/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "count_if", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/countIf/static": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "count_if", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/first/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "first", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/first/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "first", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/first/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "first", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/last/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "last", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/last/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "last", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/last/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "last", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/maximum/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/maximum/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/maximum/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/minimum/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/minimum/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/minimum/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/sum/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "sum", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/sum/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "sum", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "aggregates/sum/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "sum", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + } + } +} diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/functions.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/functions.json new file mode 100644 index 00000000..eb769c0d --- /dev/null +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/functions.json @@ -0,0 +1,17683 @@ +{ + "generator": "tool/pipeline_golden (npm run generate)", + "sdk": "@google-cloud/firestore@9.3.1", + "cases": { + "functions/abs/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "abs", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/abs/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "abs", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/abs/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "abs", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/add/method-double": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"doubleValue": 0.5} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/add/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "bonus"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/add/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/add/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"}, + {"fieldReferenceValue": "bonus"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/add/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "bonus"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/add/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/add/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "bonus"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/add/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/and/static-three": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "published"}, + {"integerValue": "1950"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/and/static-two": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/array/static-empty": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"functionValue": {"name": "array"}}} + } + } + ] + } + ] + } + } + }, + "functions/array/static-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array", + "args": [ + {"integerValue": "1"}, + {"stringValue": "a"}, + {"booleanValue": true}, + {"nullValue": null} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/array/static-nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array", + "args": [ + { + "functionValue": { + "name": "array", + "args": [ + {"integerValue": "1"}, + {"fieldReferenceValue": "a"} + ] + } + }, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "k"}, + {"fieldReferenceValue": "b"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/array/static-with-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array", + "args": [ + {"fieldReferenceValue": "title"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayConcat/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_concat", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "moreTags"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayConcat/method-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_concat", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "functionValue": { + "name": "array", + "args": [ + {"stringValue": "x"}, + {"stringValue": "y"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayConcat/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_concat", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "moreTags"}, + { + "functionValue": { + "name": "array", + "args": [{"stringValue": "z"}] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayConcat/static-expression-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_concat", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "moreTags"}, + { + "functionValue": { + "name": "array", + "args": [{"stringValue": "z"}] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayConcat/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_concat", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "moreTags"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayConcat/static-field-name-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_concat", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "functionValue": { + "name": "array", + "args": [ + {"stringValue": "x"}, + {"stringValue": "y"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayContains/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContains/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContains/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContains/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContains/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContains/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAll/method-array-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_all", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAll/method-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_all", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"stringValue": "epic"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAll/method-list-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_all", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAll/static-expression-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_all", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"stringValue": "epic"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAll/static-field-name-array-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_all", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAll/static-field-name-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_all", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"stringValue": "epic"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAll/static-field-name-list-nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_all", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + { + "functionValue": { + "name": "array", + "args": [{"stringValue": "epic"}] + } + } + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAll/static-field-name-list-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_all", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAny/method-array-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_any", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAny/method-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_any", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"stringValue": "epic"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAny/method-list-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_any", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAny/static-expression-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_any", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"stringValue": "epic"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAny/static-field-name-array-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_any", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAny/static-field-name-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_any", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"stringValue": "epic"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAny/static-field-name-list-nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_any", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + { + "functionValue": { + "name": "array", + "args": [{"stringValue": "epic"}] + } + } + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayContainsAny/static-field-name-list-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "array_contains_any", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/arrayFilter/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_filter", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "tag"}, + { + "functionValue": { + "name": "equal", + "args": [ + {"variableReferenceValue": "tag"}, + {"stringValue": "magic"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFilter/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_filter", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "tag"}, + { + "functionValue": { + "name": "equal", + "args": [ + {"variableReferenceValue": "tag"}, + {"stringValue": "magic"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFilter/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_filter", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "tag"}, + { + "functionValue": { + "name": "equal", + "args": [ + {"variableReferenceValue": "tag"}, + {"stringValue": "magic"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFirst/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_first", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFirst/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_first", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFirst/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_first", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFirstN/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_first_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFirstN/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_first_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFirstN/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_first_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFirstN/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_first_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFirstN/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_first_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayFirstN/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_first_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayGet/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_get", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "index"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayGet/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_get", + "args": [ + {"fieldReferenceValue": "tags"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayGet/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_get", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "index"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayGet/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_get", + "args": [ + {"fieldReferenceValue": "tags"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayGet/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_get", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "index"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayGet/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_get", + "args": [ + {"fieldReferenceValue": "tags"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOf/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"}, + {"stringValue": "first"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOf/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"}, + {"stringValue": "first"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOf/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"}, + {"stringValue": "first"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOf/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"}, + {"stringValue": "first"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOf/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"}, + {"stringValue": "first"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOf/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"}, + {"stringValue": "first"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOfAll/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of_all", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOfAll/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of_all", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOfAll/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of_all", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOfAll/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of_all", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOfAll/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of_all", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayIndexOfAll/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of_all", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLast/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_last", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLast/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_last", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLast/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_last", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastIndexOf/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"}, + {"stringValue": "last"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastIndexOf/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"}, + {"stringValue": "last"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastIndexOf/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"}, + {"stringValue": "last"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastIndexOf/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"}, + {"stringValue": "last"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastIndexOf/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "wanted"}, + {"stringValue": "last"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastIndexOf/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_index_of", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"}, + {"stringValue": "last"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastN/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_last_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastN/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_last_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastN/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_last_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastN/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_last_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastN/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_last_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLastN/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_last_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLength/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_length", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLength/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_length", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayLength/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_length", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMaximum/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMaximum/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMaximum/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMaximumN/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMaximumN/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMaximumN/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMaximumN/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMaximumN/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMaximumN/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMinimum/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMinimum/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMinimum/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMinimumN/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMinimumN/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMinimumN/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMinimumN/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMinimumN/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"fieldReferenceValue": "n"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayMinimumN/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum_n", + "args": [ + {"fieldReferenceValue": "scores"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayReverse/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_reverse", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayReverse/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_reverse", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayReverse/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_reverse", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arraySlice/method-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_slice", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "start"}, + {"fieldReferenceValue": "count"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arraySlice/method-offset": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_slice", + "args": [ + {"fieldReferenceValue": "tags"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arraySlice/method-offset-length": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_slice", + "args": [ + {"fieldReferenceValue": "tags"}, + {"integerValue": "1"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arraySlice/static-expression-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_slice", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "start"}, + {"fieldReferenceValue": "count"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arraySlice/static-field-name-offset": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_slice", + "args": [ + {"fieldReferenceValue": "tags"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arraySlice/static-field-name-offset-length": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_slice", + "args": [ + {"fieldReferenceValue": "tags"}, + {"integerValue": "1"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arraySum/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "sum", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arraySum/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "sum", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arraySum/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "sum", + "args": [{"fieldReferenceValue": "tags"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayTransform/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_transform", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "tag"}, + { + "functionValue": { + "name": "to_upper", + "args": [{"variableReferenceValue": "tag"}] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayTransform/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_transform", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "tag"}, + { + "functionValue": { + "name": "to_upper", + "args": [{"variableReferenceValue": "tag"}] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayTransform/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_transform", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "tag"}, + { + "functionValue": { + "name": "to_upper", + "args": [{"variableReferenceValue": "tag"}] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayTransformWithIndex/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_transform", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "tag"}, + {"stringValue": "i"}, + { + "functionValue": { + "name": "string_concat", + "args": [ + {"variableReferenceValue": "tag"}, + {"variableReferenceValue": "i"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayTransformWithIndex/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_transform", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "tag"}, + {"stringValue": "i"}, + { + "functionValue": { + "name": "string_concat", + "args": [ + {"variableReferenceValue": "tag"}, + {"variableReferenceValue": "i"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/arrayTransformWithIndex/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "array_transform", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "tag"}, + {"stringValue": "i"}, + { + "functionValue": { + "name": "string_concat", + "args": [ + {"variableReferenceValue": "tag"}, + {"variableReferenceValue": "i"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/byteLength/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "byte_length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/byteLength/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "byte_length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/byteLength/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "byte_length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ceil/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ceil", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ceil/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ceil", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ceil/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ceil", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/charLength/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "char_length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/charLength/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "char_length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/charLength/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "char_length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/coalesce/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "coalesce", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/coalesce/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "coalesce", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"fieldReferenceValue": "name"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/coalesce/static-expression-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "coalesce", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"fieldReferenceValue": "name"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/coalesce/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "coalesce", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/collectionId/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "collection_id", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/collectionId/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "collection_id", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/collectionId/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "collection_id", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/concat/method-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "concat", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "functionValue": { + "name": "array", + "args": [{"stringValue": "extra"}] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/concat/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "concat", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "!"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/concat/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "concat", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": " by "}, + {"fieldReferenceValue": "author"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/concat/static-expression-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "concat", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": " by "}, + {"fieldReferenceValue": "author"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/concat/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "concat", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "!"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/conditional/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "conditional", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + {"fieldReferenceValue": "title"}, + {"stringValue": "n/a"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/conditional/static": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "conditional", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + {"fieldReferenceValue": "title"}, + {"stringValue": "n/a"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/cosineDistance/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "cosine_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/cosineDistance/method-number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "cosine_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/cosineDistance/method-vector-value": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "cosine_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/cosineDistance/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "cosine_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/cosineDistance/static-expression-number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "cosine_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/cosineDistance/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "cosine_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/cosineDistance/static-field-name-integer-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "cosine_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 1}, + {"doubleValue": 2}, + {"doubleValue": 3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/cosineDistance/static-field-name-number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "cosine_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/cosineDistance/static-field-name-vector-value": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "cosine_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/currentDocument/static": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": {"functionValue": {"name": "current_document"}} + } + } + } + ] + } + ] + } + } + }, + "functions/currentTimestamp/static": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": {"functionValue": {"name": "current_timestamp"}} + } + } + } + ] + } + ] + } + } + }, + "functions/divide/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "count"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/divide/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/divide/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "count"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/divide/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/divide/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "count"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/divide/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/documentId/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "document_id", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/documentId/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "document_id", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/documentId/static-path": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "document_id", + "args": [{"stringValue": "books/book1"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/documentId/static-reference": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "document_id", + "args": [ + { + "referenceValue": "projects/test-project/databases/(default)/documents/books/book1" + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/documentMatches/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "document_matches", + "args": [{"fieldReferenceValue": "query"}] + } + } + ] + } + ] + } + } + }, + "functions/documentMatches/static-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "document_matches", + "args": [{"stringValue": "breakfast"}] + } + } + ] + } + ] + } + } + }, + "functions/dotProduct/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "dot_product", + "args": [ + {"fieldReferenceValue": "embedding"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/dotProduct/method-number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "dot_product", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/dotProduct/method-vector-value": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "dot_product", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/dotProduct/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "dot_product", + "args": [ + {"fieldReferenceValue": "embedding"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/dotProduct/static-expression-number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "dot_product", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/dotProduct/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "dot_product", + "args": [ + {"fieldReferenceValue": "embedding"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/dotProduct/static-field-name-integer-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "dot_product", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 1}, + {"doubleValue": 2}, + {"doubleValue": 3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/dotProduct/static-field-name-number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "dot_product", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/dotProduct/static-field-name-vector-value": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "dot_product", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/endsWith/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "ends_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "suffix"} + ] + } + } + ] + } + ] + } + } + }, + "functions/endsWith/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "ends_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Rings"} + ] + } + } + ] + } + ] + } + } + }, + "functions/endsWith/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "ends_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "suffix"} + ] + } + } + ] + } + ] + } + } + }, + "functions/endsWith/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "ends_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Rings"} + ] + } + } + ] + } + ] + } + } + }, + "functions/endsWith/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "ends_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "suffix"} + ] + } + } + ] + } + ] + } + } + }, + "functions/endsWith/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "ends_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Rings"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equal/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equal/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equal/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equal/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equal/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equal/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equal/static-field-name-string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equalAny/method-array-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + {"fieldReferenceValue": "genres"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equalAny/method-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"stringValue": "Sci-Fi"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/equalAny/method-list-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"fieldReferenceValue": "favorite"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/equalAny/static-expression-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"stringValue": "Sci-Fi"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/equalAny/static-field-name-array-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + {"fieldReferenceValue": "genres"} + ] + } + } + ] + } + ] + } + } + }, + "functions/equalAny/static-field-name-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"stringValue": "Sci-Fi"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/equalAny/static-field-name-list-nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + { + "functionValue": { + "name": "array", + "args": [{"stringValue": "Sci-Fi"}] + } + } + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/equalAny/static-field-name-list-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"fieldReferenceValue": "favorite"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/euclideanDistance/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "euclidean_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/euclideanDistance/method-number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "euclidean_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/euclideanDistance/method-vector-value": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "euclidean_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/euclideanDistance/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "euclidean_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/euclideanDistance/static-expression-number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "euclidean_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/euclideanDistance/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "euclidean_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/euclideanDistance/static-field-name-integer-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "euclidean_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 1}, + {"doubleValue": 2}, + {"doubleValue": 3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/euclideanDistance/static-field-name-number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "euclidean_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/euclideanDistance/static-field-name-vector-value": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "euclidean_distance", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/exists/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "title"}] + } + } + ] + } + ] + } + } + }, + "functions/exists/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "title"}] + } + } + ] + } + ] + } + } + }, + "functions/exists/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "title"}] + } + } + ] + } + ] + } + } + }, + "functions/exp/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "exp", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/exp/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "exp", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/exp/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "exp", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/floor/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "floor", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/floor/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "floor", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/floor/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "floor", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/geoDistance/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "geo_distance", + "args": [ + {"fieldReferenceValue": "location"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/geoDistance/method-geo-point": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "geo_distance", + "args": [ + {"fieldReferenceValue": "location"}, + { + "geoPointValue": { + "latitude": 37.7749, + "longitude": -122.4194 + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/geoDistance/static-field-geo-point": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "geo_distance", + "args": [ + {"fieldReferenceValue": "location"}, + { + "geoPointValue": { + "latitude": 37.7749, + "longitude": -122.4194 + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/geoDistance/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "geo_distance", + "args": [ + {"fieldReferenceValue": "location"}, + {"fieldReferenceValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/geoDistance/static-field-name-geo-point": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "geo_distance", + "args": [ + {"fieldReferenceValue": "location"}, + { + "geoPointValue": { + "latitude": 37.7749, + "longitude": -122.4194 + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/getField/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "get_field", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"fieldReferenceValue": "key"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/getField/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "get_field", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/getField/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "get_field", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"fieldReferenceValue": "key"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/getField/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "get_field", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/getField/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "get_field", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"fieldReferenceValue": "key"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/getField/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "get_field", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/greaterThan/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThan/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThan/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThan/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThan/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThan/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThan/static-field-name-string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThanOrEqual/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThanOrEqual/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThanOrEqual/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThanOrEqual/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThanOrEqual/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThanOrEqual/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/greaterThanOrEqual/static-field-name-string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than_or_equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + ] + } + } + }, + "functions/ifAbsent/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_absent", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"fieldReferenceValue": "name"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifAbsent/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_absent", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifAbsent/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_absent", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"fieldReferenceValue": "name"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifAbsent/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_absent", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifAbsent/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_absent", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"fieldReferenceValue": "name"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifAbsent/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_absent", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifError/method-boolean-boolean": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "if_error", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + {"booleanValue": false} + ] + } + } + ] + } + ] + } + } + }, + "functions/ifError/method-boolean-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "if_error", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + {"booleanValue": false} + ] + } + } + ] + } + ] + } + } + }, + "functions/ifError/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_error", + "args": [ + { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "0"} + ] + } + }, + {"fieldReferenceValue": "fallback"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifError/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_error", + "args": [ + { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "0"} + ] + } + }, + {"integerValue": "0"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifError/static-boolean-boolean": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "if_error", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + {"booleanValue": false} + ] + } + } + ] + } + ] + } + } + }, + "functions/ifError/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_error", + "args": [ + { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "0"} + ] + } + }, + {"fieldReferenceValue": "fallback"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifError/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_error", + "args": [ + { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "0"} + ] + } + }, + {"integerValue": "0"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifNull/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_null", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"fieldReferenceValue": "name"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifNull/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_null", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifNull/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_null", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"fieldReferenceValue": "name"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifNull/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_null", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifNull/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_null", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"fieldReferenceValue": "name"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ifNull/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "if_null", + "args": [ + {"fieldReferenceValue": "nickname"}, + {"stringValue": "anonymous"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/isAbsent/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_absent", + "args": [{"fieldReferenceValue": "title"}] + } + } + ] + } + ] + } + } + }, + "functions/isAbsent/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_absent", + "args": [{"fieldReferenceValue": "title"}] + } + } + ] + } + ] + } + } + }, + "functions/isAbsent/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_absent", + "args": [{"fieldReferenceValue": "title"}] + } + } + ] + } + ] + } + } + }, + "functions/isError/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_error", + "args": [ + { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "0"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/isError/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_error", + "args": [ + { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "0"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "array"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-boolean": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "boolean"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-bytes": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "bytes"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-decimal128": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "decimal128"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-float64": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "float64"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-geo-point": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "geo_point"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-int32": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "int32"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-int64": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "int64"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-map": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "map"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-max-key": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "max_key"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-min-key": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "min_key"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-null": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "null"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-number": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "number"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-object-id": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "object_id"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-reference": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "reference"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-regex": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "regex"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-request-timestamp": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "request_timestamp"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "string"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-timestamp": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "timestamp"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/method-vector": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "vector"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "string"} + ] + } + } + ] + } + ] + } + } + }, + "functions/isType/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "is_type", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "string"} + ] + } + } + ] + } + ] + } + } + }, + "functions/join/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "join", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "delimiter"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/join/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "join", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": ", "} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/join/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "join", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "delimiter"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/join/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "join", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": ", "} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/join/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "join", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "delimiter"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/join/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "join", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": ", "} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/length/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/length/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/length/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/lessThan/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThan/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThan/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThan/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThan/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThan/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThan/static-field-name-string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThanOrEqual/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThanOrEqual/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThanOrEqual/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThanOrEqual/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThanOrEqual/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThanOrEqual/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/lessThanOrEqual/static-field-name-string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than_or_equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + ] + } + } + }, + "functions/like/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "like", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + ] + } + ] + } + } + }, + "functions/like/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "like", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "The%"} + ] + } + } + ] + } + ] + } + } + }, + "functions/like/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "like", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + ] + } + ] + } + } + }, + "functions/like/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "like", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "The%"} + ] + } + } + ] + } + ] + } + } + }, + "functions/like/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "like", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + ] + } + ] + } + } + }, + "functions/like/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "like", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "The%"} + ] + } + } + ] + } + ] + } + } + }, + "functions/ln/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ln", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ln/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ln", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ln/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ln", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/log10/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "log10", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/log10/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "log10", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/log10/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "log10", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/logicalMaximum/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/logicalMaximum/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "floor"}, + {"integerValue": "3"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/logicalMaximum/static-expression-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "floor"}, + {"integerValue": "3"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/logicalMaximum/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "maximum", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/logicalMinimum/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "5"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/logicalMinimum/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "ceiling"}, + {"integerValue": "3"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/logicalMinimum/static-expression-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "ceiling"}, + {"integerValue": "3"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/logicalMinimum/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "minimum", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "5"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ltrim/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ltrim", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ltrim/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ltrim", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "chars"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ltrim/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ltrim", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "-"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ltrim/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ltrim", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ltrim/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ltrim", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "chars"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ltrim/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ltrim", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/ltrim/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "ltrim", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "-"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/map/static-empty": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"functionValue": {"name": "map"}}} + } + } + ] + } + ] + } + } + }, + "functions/map/static-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "a"}, + {"integerValue": "1"}, + {"stringValue": "b"}, + {"stringValue": "x"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/map/static-nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "inner"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "k"}, + {"fieldReferenceValue": "y"} + ] + } + }, + {"stringValue": "list"}, + { + "functionValue": { + "name": "array", + "args": [ + {"fieldReferenceValue": "z"}, + {"integerValue": "1"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/map/static-with-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "a"}, + {"fieldReferenceValue": "title"}, + {"stringValue": "b"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapEntries/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_entries", + "args": [{"fieldReferenceValue": "metadata"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapEntries/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_entries", + "args": [{"fieldReferenceValue": "metadata"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapEntries/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_entries", + "args": [{"fieldReferenceValue": "metadata"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapGet/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_get", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapGet/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_get", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapGet/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_get", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapKeys/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_keys", + "args": [{"fieldReferenceValue": "metadata"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapKeys/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_keys", + "args": [{"fieldReferenceValue": "metadata"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapKeys/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_keys", + "args": [{"fieldReferenceValue": "metadata"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapMerge/method-map": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_merge", + "args": [ + {"fieldReferenceValue": "metadata"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "extra"}, + {"integerValue": "1"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapMerge/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_merge", + "args": [ + {"fieldReferenceValue": "metadata"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "extra"}, + {"fieldReferenceValue": "title"} + ] + } + }, + {"fieldReferenceValue": "overrides"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapMerge/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_merge", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"fieldReferenceValue": "overrides"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapMerge/static-field-name-map": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_merge", + "args": [ + {"fieldReferenceValue": "metadata"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "extra"}, + {"integerValue": "1"} + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapMerge/static-field-name-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_merge", + "args": [ + {"fieldReferenceValue": "metadata"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "extra"}, + {"fieldReferenceValue": "title"} + ] + } + }, + {"fieldReferenceValue": "overrides"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapRemove/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_remove", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"fieldReferenceValue": "key"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapRemove/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_remove", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapRemove/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_remove", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"fieldReferenceValue": "key"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapRemove/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_remove", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapRemove/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_remove", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"fieldReferenceValue": "key"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapRemove/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_remove", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapSet/method-expression-key": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_set", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"fieldReferenceValue": "key"}, + {"fieldReferenceValue": "value"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapSet/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_set", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"}, + {"stringValue": "en"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapSet/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_set", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"}, + {"stringValue": "en"}, + {"stringValue": "pages"}, + {"fieldReferenceValue": "pageCount"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapSet/static-expression-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_set", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"}, + {"stringValue": "en"}, + {"stringValue": "pages"}, + {"fieldReferenceValue": "pageCount"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapSet/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_set", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "lang"}, + {"stringValue": "en"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapValues/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_values", + "args": [{"fieldReferenceValue": "metadata"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapValues/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_values", + "args": [{"fieldReferenceValue": "metadata"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mapValues/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "map_values", + "args": [{"fieldReferenceValue": "metadata"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mod/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "mod", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "divisor"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mod/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "mod", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mod/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "mod", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "divisor"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mod/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "mod", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mod/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "mod", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "divisor"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/mod/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "mod", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/multiply/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "weight"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/multiply/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/multiply/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"}, + {"fieldReferenceValue": "weight"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/multiply/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "weight"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/multiply/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/multiply/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "weight"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/multiply/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/nor/static-three": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "nor", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "published"}, + {"integerValue": "1950"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/nor/static-two": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "nor", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/not/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/not/static": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqual/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqual/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqual/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqual/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqual/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "threshold"} + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqual/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqual/static-field-name-string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqualAny/method-array-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + {"fieldReferenceValue": "genres"} + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqualAny/method-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"stringValue": "Sci-Fi"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqualAny/method-list-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"fieldReferenceValue": "favorite"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqualAny/static-expression-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"stringValue": "Sci-Fi"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqualAny/static-field-name-array-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + {"fieldReferenceValue": "genres"} + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqualAny/static-field-name-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"stringValue": "Sci-Fi"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqualAny/static-field-name-list-nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + { + "functionValue": { + "name": "array", + "args": [{"stringValue": "Sci-Fi"}] + } + } + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/notEqualAny/static-field-name-list-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"fieldReferenceValue": "favorite"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/or/static-three": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "published"}, + {"integerValue": "1950"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/or/static-two": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/parent/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "parent", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/parent/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "parent", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/parent/static-path": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "parent", + "args": [{"stringValue": "books/book1"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/parent/static-reference": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "parent", + "args": [ + { + "referenceValue": "projects/test-project/databases/(default)/documents/books/book1" + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/pow/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "pow", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "exponent"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/pow/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "pow", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/pow/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "pow", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "exponent"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/pow/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "pow", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/pow/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "pow", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "exponent"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/pow/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "pow", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/rand/static": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"functionValue": {"name": "rand"}}} + } + } + ] + } + ] + } + } + }, + "functions/regexContains/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexContains/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Lord"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexContains/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexContains/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Lord"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexContains/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexContains/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Lord"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexFind/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFind/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "[A-Z]\\w+"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFind/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFind/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "[A-Z]\\w+"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFind/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFind/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "[A-Z]\\w+"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFindAll/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFindAll/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "[A-Z]\\w+"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFindAll/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFindAll/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "[A-Z]\\w+"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFindAll/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexFindAll/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "regex_find_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "[A-Z]\\w+"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/regexMatch/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_match", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexMatch/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_match", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "^The.*"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexMatch/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_match", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexMatch/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_match", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "^The.*"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexMatch/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_match", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "pattern"} + ] + } + } + ] + } + ] + } + } + }, + "functions/regexMatch/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "regex_match", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "^The.*"} + ] + } + } + ] + } + ] + } + } + }, + "functions/reverse/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "reverse", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/reverse/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "reverse", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/reverse/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "reverse", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/round/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "round", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/round/method-places-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "round", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "places"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/round/method-places-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "round", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/round/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "round", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/round/static-expression-places-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "round", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "places"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/round/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "round", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/round/static-field-name-places-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "round", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/rtrim/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "rtrim", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/rtrim/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "rtrim", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "chars"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/rtrim/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "rtrim", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "-"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/rtrim/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "rtrim", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/rtrim/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "rtrim", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "chars"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/rtrim/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "rtrim", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/rtrim/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "rtrim", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "-"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/score/static": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"functionValue": {"name": "score"}}} + } + } + ] + } + ] + } + } + }, + "functions/split/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "split", + "args": [ + {"fieldReferenceValue": "csv"}, + {"fieldReferenceValue": "delimiter"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/split/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "split", + "args": [ + {"fieldReferenceValue": "csv"}, + {"stringValue": ","} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/split/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "split", + "args": [ + {"fieldReferenceValue": "csv"}, + {"fieldReferenceValue": "delimiter"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/split/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "split", + "args": [ + {"fieldReferenceValue": "csv"}, + {"stringValue": ","} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/split/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "split", + "args": [ + {"fieldReferenceValue": "csv"}, + {"fieldReferenceValue": "delimiter"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/split/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "split", + "args": [ + {"fieldReferenceValue": "csv"}, + {"stringValue": ","} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/sqrt/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "sqrt", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/sqrt/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "sqrt", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/sqrt/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "sqrt", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/startsWith/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "starts_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "prefix"} + ] + } + } + ] + } + ] + } + } + }, + "functions/startsWith/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "starts_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "The"} + ] + } + } + ] + } + ] + } + } + }, + "functions/startsWith/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "starts_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "prefix"} + ] + } + } + ] + } + ] + } + } + }, + "functions/startsWith/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "starts_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "The"} + ] + } + } + ] + } + ] + } + } + }, + "functions/startsWith/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "starts_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "prefix"} + ] + } + } + ] + } + ] + } + } + }, + "functions/startsWith/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "starts_with", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "The"} + ] + } + } + ] + } + ] + } + } + }, + "functions/stringConcat/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_concat", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": " by "} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringConcat/method-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_concat", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": " by "}, + {"fieldReferenceValue": "author"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringConcat/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_concat", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "author"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringConcat/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_concat", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": " by "} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringConcat/static-field-name-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_concat", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": " by "}, + {"fieldReferenceValue": "author"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringContains/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "string_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "needle"} + ] + } + } + ] + } + ] + } + } + }, + "functions/stringContains/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "string_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Ring"} + ] + } + } + ] + } + ] + } + } + }, + "functions/stringContains/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "string_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "needle"} + ] + } + } + ] + } + ] + } + } + }, + "functions/stringContains/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "string_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Ring"} + ] + } + } + ] + } + ] + } + } + }, + "functions/stringContains/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "string_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "needle"} + ] + } + } + ] + } + ] + } + } + }, + "functions/stringContains/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "string_contains", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Ring"} + ] + } + } + ] + } + ] + } + } + }, + "functions/stringIndexOf/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_index_of", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "needle"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringIndexOf/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_index_of", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Ring"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringIndexOf/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_index_of", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "needle"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringIndexOf/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_index_of", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Ring"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringIndexOf/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_index_of", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "needle"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringIndexOf/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_index_of", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "Ring"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringRepeat/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_repeat", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "times"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringRepeat/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_repeat", + "args": [ + {"fieldReferenceValue": "title"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringRepeat/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_repeat", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "times"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringRepeat/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_repeat", + "args": [ + {"fieldReferenceValue": "title"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringRepeat/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_repeat", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "times"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringRepeat/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_repeat", + "args": [ + {"fieldReferenceValue": "title"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReplaceAll/method-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_replace_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "find"}, + {"fieldReferenceValue": "replacement"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReplaceAll/method-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_replace_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "a"}, + {"stringValue": "b"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReplaceAll/static-expression-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_replace_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "find"}, + {"fieldReferenceValue": "replacement"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReplaceAll/static-field-name-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_replace_all", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "a"}, + {"stringValue": "b"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReplaceOne/method-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_replace_one", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "find"}, + {"fieldReferenceValue": "replacement"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReplaceOne/method-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_replace_one", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "a"}, + {"stringValue": "b"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReplaceOne/static-expression-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_replace_one", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "find"}, + {"fieldReferenceValue": "replacement"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReplaceOne/static-field-name-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_replace_one", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "a"}, + {"stringValue": "b"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReverse/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_reverse", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReverse/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_reverse", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/stringReverse/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "string_reverse", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/substring/method-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "substring", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "start"}, + {"fieldReferenceValue": "count"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/substring/method-position": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "substring", + "args": [ + {"fieldReferenceValue": "title"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/substring/method-position-length": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "substring", + "args": [ + {"fieldReferenceValue": "title"}, + {"integerValue": "2"}, + {"integerValue": "3"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/substring/static-expression-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "substring", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "start"}, + {"fieldReferenceValue": "count"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/substring/static-field-name-position": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "substring", + "args": [ + {"fieldReferenceValue": "title"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/substring/static-field-name-position-length": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "substring", + "args": [ + {"fieldReferenceValue": "title"}, + {"integerValue": "2"}, + {"integerValue": "3"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/subtract/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "subtract", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "penalty"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/subtract/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "subtract", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/subtract/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "subtract", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "penalty"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/subtract/static-expression-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "subtract", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/subtract/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "subtract", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "penalty"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/subtract/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "subtract", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/switchOn/static-pair": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "switch_on", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + {"stringValue": "good"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/switchOn/static-pairs-and-default": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "switch_on", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + {"stringValue": "good"}, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "published"}, + {"integerValue": "1950"} + ] + } + }, + {"stringValue": "old"}, + {"stringValue": "other"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampAdd/method-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_add", + "args": [ + {"fieldReferenceValue": "published"}, + {"fieldReferenceValue": "unit"}, + {"fieldReferenceValue": "amount"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampAdd/method-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_add", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "minute"}, + {"integerValue": "30"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampAdd/static-expression-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_add", + "args": [ + {"fieldReferenceValue": "published"}, + {"fieldReferenceValue": "unit"}, + {"fieldReferenceValue": "amount"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampAdd/static-expression-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_add", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "hour"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampAdd/static-field-name-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_add", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "day"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampDiff/method-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_diff", + "args": [ + {"fieldReferenceValue": "end"}, + {"fieldReferenceValue": "start"}, + {"fieldReferenceValue": "unit"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampDiff/method-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_diff", + "args": [ + {"fieldReferenceValue": "end"}, + {"fieldReferenceValue": "start"}, + {"stringValue": "day"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampDiff/static-expression-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_diff", + "args": [ + {"fieldReferenceValue": "end"}, + {"fieldReferenceValue": "start"}, + {"stringValue": "hour"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampDiff/static-expressions-unit-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_diff", + "args": [ + {"fieldReferenceValue": "end"}, + {"fieldReferenceValue": "start"}, + {"fieldReferenceValue": "unit"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampDiff/static-field-name-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_diff", + "args": [ + {"fieldReferenceValue": "end"}, + {"fieldReferenceValue": "start"}, + {"stringValue": "second"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampDiff/static-field-names": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_diff", + "args": [ + {"fieldReferenceValue": "end"}, + {"fieldReferenceValue": "start"}, + {"stringValue": "day"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampExtract/method-expression-timezone-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_extract", + "args": [ + {"fieldReferenceValue": "published"}, + {"fieldReferenceValue": "part"}, + {"fieldReferenceValue": "tz"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampExtract/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_extract", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "dayofyear"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampExtract/method-literal-timezone": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_extract", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "hour"}, + {"stringValue": "UTC"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampExtract/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_extract", + "args": [ + {"fieldReferenceValue": "published"}, + {"fieldReferenceValue": "part"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampExtract/static-expression-timezone-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_extract", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "month"}, + {"fieldReferenceValue": "tz"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampExtract/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_extract", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "year"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampExtract/static-field-name-literal-timezone": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_extract", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "dayofweek"}, + {"stringValue": "America/New_York"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampSubtract/method-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_subtract", + "args": [ + {"fieldReferenceValue": "published"}, + {"fieldReferenceValue": "unit"}, + {"fieldReferenceValue": "amount"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampSubtract/method-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_subtract", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "minute"}, + {"integerValue": "30"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampSubtract/static-expression-expressions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_subtract", + "args": [ + {"fieldReferenceValue": "published"}, + {"fieldReferenceValue": "unit"}, + {"fieldReferenceValue": "amount"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampSubtract/static-expression-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_subtract", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "hour"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampSubtract/static-field-name-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_subtract", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "day"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampToUnixMicros/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_to_unix_micros", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampToUnixMicros/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_to_unix_micros", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampToUnixMicros/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_to_unix_micros", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampToUnixMillis/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_to_unix_millis", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampToUnixMillis/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_to_unix_millis", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampToUnixMillis/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_to_unix_millis", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampToUnixSeconds/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_to_unix_seconds", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampToUnixSeconds/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_to_unix_seconds", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampToUnixSeconds/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_to_unix_seconds", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampTruncate/method-expression-timezone-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_trunc", + "args": [ + {"fieldReferenceValue": "published"}, + {"fieldReferenceValue": "granularity"}, + {"fieldReferenceValue": "tz"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampTruncate/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_trunc", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "year"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampTruncate/method-literal-timezone": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_trunc", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "day"}, + {"stringValue": "UTC"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampTruncate/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_trunc", + "args": [ + {"fieldReferenceValue": "published"}, + {"fieldReferenceValue": "granularity"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampTruncate/static-expression-timezone-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_trunc", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "month"}, + {"fieldReferenceValue": "tz"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampTruncate/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_trunc", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "day"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/timestampTruncate/static-field-name-literal-timezone": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "timestamp_trunc", + "args": [ + {"fieldReferenceValue": "published"}, + {"stringValue": "week(monday)"}, + {"stringValue": "America/New_York"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/toLower/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "to_lower", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/toLower/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "to_lower", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/toLower/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "to_lower", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/toUpper/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "to_upper", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/toUpper/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "to_upper", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/toUpper/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "to_upper", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trim/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trim", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trim/method-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trim", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "chars"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trim/method-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trim", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "-"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trim/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trim", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trim/static-expression-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trim", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "chars"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trim/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trim", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trim/static-field-name-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trim", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "-"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trunc/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trunc", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trunc/method-places-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trunc", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "places"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trunc/method-places-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trunc", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trunc/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trunc", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trunc/static-expression-places-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trunc", + "args": [ + {"fieldReferenceValue": "rating"}, + {"fieldReferenceValue": "places"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trunc/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trunc", + "args": [{"fieldReferenceValue": "rating"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/trunc/static-field-name-places-literal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "trunc", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/type/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "type", + "args": [{"fieldReferenceValue": "value"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/type/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "type", + "args": [{"fieldReferenceValue": "value"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/type/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "type", + "args": [{"fieldReferenceValue": "value"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/unixMicrosToTimestamp/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "unix_micros_to_timestamp", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/unixMicrosToTimestamp/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "unix_micros_to_timestamp", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/unixMicrosToTimestamp/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "unix_micros_to_timestamp", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/unixMillisToTimestamp/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "unix_millis_to_timestamp", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/unixMillisToTimestamp/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "unix_millis_to_timestamp", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/unixMillisToTimestamp/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "unix_millis_to_timestamp", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/unixSecondsToTimestamp/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "unix_seconds_to_timestamp", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/unixSecondsToTimestamp/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "unix_seconds_to_timestamp", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/unixSecondsToTimestamp/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "unix_seconds_to_timestamp", + "args": [{"fieldReferenceValue": "published"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/vectorLength/method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "vector_length", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/vectorLength/static-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "vector_length", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/vectorLength/static-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "vector_length", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "functions/xor/static-three": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "xor", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "published"}, + {"integerValue": "1950"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "functions/xor/static-two": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "xor", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + ] + } + } + } + } +} diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/options.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/options.json new file mode 100644 index 00000000..0d4c069f --- /dev/null +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/options.json @@ -0,0 +1,209 @@ +{ + "generator": "tool/pipeline_golden (npm run generate)", + "sdk": "@google-cloud/firestore@9.3.1", + "cases": { + "options/combined/index-mode-and-explain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": { + "fields": { + "mode": {"stringValue": "analyze"}, + "output_format": {"stringValue": "text"} + } + } + }, + "index_mode": {"stringValue": "recommended"} + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/explain/empty": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": {"explain_options": {"mapValue": {}}}, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/explain/mode-analyze": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": {"fields": {"mode": {"stringValue": "analyze"}}} + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/explain/mode-and-output-format": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": { + "fields": { + "mode": {"stringValue": "analyze"}, + "output_format": {"stringValue": "text"} + } + } + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/explain/mode-execute": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": {"fields": {"mode": {"stringValue": "execute"}}} + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/explain/output-format-text": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": {"fields": {"output_format": {"stringValue": "text"}}} + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/index-mode/recommended": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": {"index_mode": {"stringValue": "recommended"}}, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/none/empty-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/none/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/raw-options/dot-notation": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": {"fields": {"mode": {"stringValue": "analyze"}}} + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/raw-options/flat": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "count": {"integerValue": "3"}, + "foo": {"stringValue": "bar"} + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/raw-options/merge-into-explain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": { + "fields": { + "mode": {"stringValue": "analyze"}, + "output_format": {"stringValue": "text"} + } + } + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/raw-options/nested-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": {"fields": {"mode": {"stringValue": "analyze"}}} + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/raw-options/override-known": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": {"index_mode": {"stringValue": "custom"}}, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + } + } +} diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json new file mode 100644 index 00000000..61e2a12e --- /dev/null +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json @@ -0,0 +1,3846 @@ +{ + "generator": "tool/pipeline_golden (npm run generate)", + "sdk": "@google-cloud/firestore@9.3.1", + "cases": { + "queries/collection-group/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection_group", + "args": [{"referenceValue": ""}, {"stringValue": "reviews"}] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/collection-group/where": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection_group", + "args": [{"referenceValue": ""}, {"stringValue": "reviews"}] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "3"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/collection/nested-path": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection", + "args": [{"referenceValue": "/authors/author1/books"}] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/collection/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/cursor/end-at": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "queries/cursor/end-before": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "queries/cursor/limit-to-last": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "5"} + ] + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "3"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/cursor/multiple-fields": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "title"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "title"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "M"} + ] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "M"} + ] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "queries/cursor/start-after": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "queries/cursor/start-and-end": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "queries/cursor/start-at": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "queries/limit-to-last/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "5"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/limit/with-order-by": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "5"}]} + ] + } + } + }, + "queries/limit/without-order-by": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "5"}]} + ] + } + } + }, + "queries/offset/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "offset", "args": [{"integerValue": "10"}]} + ] + } + } + }, + "queries/offset/with-limit": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "5"}]}, + {"name": "offset", "args": [{"integerValue": "10"}]} + ] + } + } + }, + "queries/order-by/ascending": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/descending": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/document-id": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/explicit-and-inequality": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "title"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "title"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/implicit-from-inequalities": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "published"}] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "published"}, + {"integerValue": "2000"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "published"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/implicit-from-inequality": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/multiple": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "title"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "title"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "`first-name`"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "`first-name`"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/select/empty": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"__name__": {"fieldReferenceValue": "__name__"}} + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/select/fields": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "author": {"fieldReferenceValue": "author"}, + "title": {"fieldReferenceValue": "title"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/vector/cosine": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "cosine"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/vector/distance-result-field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/vector/distance-threshold": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/vector/dot-product": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "dot_product"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/vector/euclidean": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/vector/vector-value": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/vector/with-prefilter": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/where/array-contains": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "tags"}] + } + }, + { + "functionValue": { + "name": "array_contains", + "args": [ + {"fieldReferenceValue": "tags"}, + {"stringValue": "magic"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/array-contains-any": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "tags"}] + } + }, + { + "functionValue": { + "name": "array_contains_any", + "args": [ + {"fieldReferenceValue": "tags"}, + { + "arrayValue": { + "values": [ + {"stringValue": "magic"}, + {"stringValue": "epic"} + ] + } + } + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/composite-and": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + }, + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/composite-nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + }, + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [ + {"fieldReferenceValue": "rating"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + }, + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "published"}] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "published"}, + {"integerValue": "1950"} + ] + } + } + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "published"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/composite-or": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + }, + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/document-id": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "__name__"}, + { + "referenceValue": "projects/test-project/databases/(default)/documents/books/book1" + } + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/double": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"doubleValue": 4.5} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/equal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/equal-nan": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"doubleValue": "NaN"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/equal-null": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"nullValue": null} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/greater-than": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/greater-than-or-equal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/in": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"stringValue": "Sci-Fi"} + ] + } + } + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/less-than": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/less-than-or-equal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "less_than_or_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/multiple": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/nested-field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "metadata.lang"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "metadata.lang"}, + {"stringValue": "en"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/not-equal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "genre"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/not-equal-null": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"nullValue": null} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "genre"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/not-in": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Fantasy"}, + {"stringValue": "Sci-Fi"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "genre"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "`first-name`"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "`first-name`"}, + {"stringValue": "Ada"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/where/timestamp": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "published"}] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "published"}, + {"timestampValue": "2023-11-14T22:13:20.123456789Z"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "published"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + } + } +} diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json new file mode 100644 index 00000000..54b6c7ef --- /dev/null +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json @@ -0,0 +1,181 @@ +{ + "generator": "tool/pipeline_golden (npm run generate)", + "sdk": "@google-cloud/firestore@9.3.1", + "cases": { + "sources/collection-group/force-index": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection_group", + "args": [{"referenceValue": ""}, {"stringValue": "reviews"}], + "options": {"force_index": {"stringValue": "my_index"}} + } + ] + } + } + }, + "sources/collection-group/id": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection_group", + "args": [{"referenceValue": ""}, {"stringValue": "reviews"}] + } + ] + } + } + }, + "sources/collection/force-index": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection", + "args": [{"referenceValue": "/books"}], + "options": {"force_index": {"stringValue": "my_index"}} + } + ] + } + } + }, + "sources/collection/nested-path": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection", + "args": [{"referenceValue": "/authors/author1/books"}] + } + ] + } + } + }, + "sources/collection/path": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "sources/collection/reference": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "sources/database/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": {"pipeline": {"stages": [{"name": "database"}]}} + }, + "sources/documents/nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "documents", + "args": [{"referenceValue": "/authors/author1/books/book1"}] + } + ] + } + } + }, + "sources/documents/paths": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "documents", + "args": [ + {"referenceValue": "/books/book1"}, + {"referenceValue": "/books/book2"} + ] + } + ] + } + } + }, + "sources/documents/references": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "documents", + "args": [ + {"referenceValue": "/books/book1"}, + {"referenceValue": "/books/book2"} + ] + } + ] + } + } + }, + "sources/subcollection/as-array-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "add_fields", + "args": [ + { + "mapValue": { + "fields": { + "reviews": { + "functionValue": { + "name": "array", + "args": [ + { + "pipelineValue": { + "stages": [ + { + "name": "subcollection", + "args": [{"stringValue": "reviews"}] + }, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "rating": { + "fieldReferenceValue": "rating" + } + } + } + } + ] + } + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + } + } +} diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json new file mode 100644 index 00000000..43e8be71 --- /dev/null +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json @@ -0,0 +1,1901 @@ +{ + "generator": "tool/pipeline_golden (npm run generate)", + "sdk": "@google-cloud/firestore@9.3.1", + "cases": { + "stages/add-fields/field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "add_fields", + "args": [ + { + "mapValue": { + "fields": {"rating": {"fieldReferenceValue": "rating"}} + } + } + ] + } + ] + } + } + }, + "stages/add-fields/multiple": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "add_fields", + "args": [ + { + "mapValue": { + "fields": { + "annotated": {"booleanValue": true}, + "copiedRating": {"fieldReferenceValue": "rating"} + } + } + } + ] + } + ] + } + } + }, + "stages/add-fields/single": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "add_fields", + "args": [ + { + "mapValue": { + "fields": { + "copiedRating": {"fieldReferenceValue": "rating"} + } + } + } + ] + } + ] + } + } + }, + "stages/aggregate/groups-field-names": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": {"total": {"functionValue": {"name": "count"}}} + } + }, + { + "mapValue": { + "fields": {"genre": {"fieldReferenceValue": "genre"}} + } + } + ] + } + ] + } + } + }, + "stages/aggregate/groups-mixed": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "best": { + "functionValue": { + "name": "maximum", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + "total": {"functionValue": {"name": "count"}} + } + } + }, + { + "mapValue": { + "fields": { + "author": {"fieldReferenceValue": "author"}, + "decade": { + "functionValue": { + "name": "floor", + "args": [ + { + "functionValue": { + "name": "divide", + "args": [ + {"fieldReferenceValue": "published"}, + {"integerValue": "10"} + ] + } + } + ] + } + }, + "genre": {"fieldReferenceValue": "genre"} + } + } + } + ] + } + ] + } + } + }, + "stages/aggregate/multiple": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "averageRating": { + "functionValue": { + "name": "average", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + "total": {"functionValue": {"name": "count"}} + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "stages/aggregate/single": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": {"total": {"functionValue": {"name": "count"}}} + } + }, + {"mapValue": {}} + ] + } + ] + } + } + }, + "stages/define/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "let", + "args": [ + { + "mapValue": { + "fields": { + "currentRating": {"fieldReferenceValue": "rating"} + } + } + } + ] + } + ] + } + } + }, + "stages/delete/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + {"name": "delete"} + ] + } + } + }, + "stages/distinct/aliased": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "distinct", + "args": [ + { + "mapValue": { + "fields": { + "lowerGenre": { + "functionValue": { + "name": "to_lower", + "args": [{"fieldReferenceValue": "genre"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/distinct/field-names": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "distinct", + "args": [ + { + "mapValue": { + "fields": { + "author": {"fieldReferenceValue": "author"}, + "genre": {"fieldReferenceValue": "genre"} + } + } + } + ] + } + ] + } + } + }, + "stages/distinct/fields": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "distinct", + "args": [ + { + "mapValue": { + "fields": {"genre": {"fieldReferenceValue": "genre"}} + } + } + ] + } + ] + } + } + }, + "stages/find-nearest/all-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": { + "distance_field": {"fieldReferenceValue": "distance"}, + "limit": {"integerValue": "10"} + } + } + ] + } + } + }, + "stages/find-nearest/cosine": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "cosine"} + ] + } + ] + } + } + }, + "stages/find-nearest/distance-field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"distance_field": {"fieldReferenceValue": "distance"}} + } + ] + } + } + }, + "stages/find-nearest/dot-product": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "dot_product"} + ] + } + ] + } + } + }, + "stages/find-nearest/field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ] + } + ] + } + } + }, + "stages/find-nearest/integer-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 1}, + {"doubleValue": 2}, + {"doubleValue": 3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ] + } + ] + } + } + }, + "stages/find-nearest/limit": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "10"}} + } + ] + } + } + }, + "stages/find-nearest/number-array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ] + } + ] + } + } + }, + "stages/find-nearest/vector-value": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ] + } + ] + } + } + }, + "stages/limit/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + {"name": "limit", "args": [{"integerValue": "10"}]} + ] + } + } + }, + "stages/limit/zero": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + {"name": "limit", "args": [{"integerValue": "0"}]} + ] + } + } + }, + "stages/offset/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + {"name": "offset", "args": [{"integerValue": "10"}]} + ] + } + } + }, + "stages/raw-stage/aggregate-params": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "best": { + "functionValue": { + "name": "maximum", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + "total": {"functionValue": {"name": "count"}} + } + } + }, + { + "mapValue": { + "fields": {"genre": {"fieldReferenceValue": "genre"}} + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/array-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "arrayValue": { + "values": [ + {"integerValue": "1"}, + {"stringValue": "a"}, + {"fieldReferenceValue": "title"} + ] + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/expression-params": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/literal-params": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + {"name": "limit", "args": [{"integerValue": "10"}]} + ] + } + } + }, + "stages/raw-stage/nested-plain-object-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "meta": { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "lang"}, + {"fieldReferenceValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": { + "distance_field": {"fieldReferenceValue": "distance"}, + "limit": {"integerValue": "10"} + } + } + ] + } + } + }, + "stages/raw-stage/options-dot-notation": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "options": { + "outer": { + "mapValue": {"fields": {"inner": {"stringValue": "value"}}} + } + } + } + ] + } + } + }, + "stages/raw-stage/plain-object-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "one": {"integerValue": "1"}, + "title": {"fieldReferenceValue": "title"} + } + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/string-params": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + {"stringValue": "a"}, + {"doubleValue": 1.5}, + {"booleanValue": true}, + {"nullValue": null} + ] + } + ] + } + } + }, + "stages/remove-fields/field-names": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "remove_fields", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "author"} + ] + } + ] + } + } + }, + "stages/remove-fields/fields": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "remove_fields", + "args": [ + {"fieldReferenceValue": "title"}, + {"fieldReferenceValue": "author"} + ] + } + ] + } + } + }, + "stages/remove-fields/nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "remove_fields", + "args": [{"fieldReferenceValue": "metadata.lang"}] + } + ] + } + } + }, + "stages/replace-with/field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "replace_with", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "full_replace"} + ] + } + ] + } + } + }, + "stages/replace-with/field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "replace_with", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "full_replace"} + ] + } + ] + } + } + }, + "stages/replace-with/map-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "replace_with", + "args": [ + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "title"}, + {"fieldReferenceValue": "title"}, + {"stringValue": "rating"}, + {"fieldReferenceValue": "rating"} + ] + } + }, + {"stringValue": "full_replace"} + ] + } + ] + } + } + }, + "stages/sample/documents": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sample", + "args": [{"integerValue": "10"}, {"stringValue": "documents"}] + } + ] + } + } + }, + "stages/sample/documents-option": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sample", + "args": [{"integerValue": "10"}, {"stringValue": "documents"}] + } + ] + } + } + }, + "stages/sample/percentage": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sample", + "args": [{"doubleValue": 0.25}, {"stringValue": "percent"}] + } + ] + } + } + }, + "stages/search/add-fields": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "search", + "options": { + "add_fields": { + "mapValue": { + "fields": { + "searchScore": {"functionValue": {"name": "score"}} + } + } + }, + "query": { + "functionValue": { + "name": "document_matches", + "args": [{"stringValue": "breakfast"}] + } + } + } + } + ] + } + } + }, + "stages/search/limit-offset": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "search", + "options": { + "limit": {"integerValue": "10"}, + "offset": {"integerValue": "5"}, + "query": { + "functionValue": { + "name": "document_matches", + "args": [{"stringValue": "breakfast"}] + } + } + } + } + ] + } + } + }, + "stages/search/query-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "search", + "options": { + "query": { + "functionValue": { + "name": "document_matches", + "args": [{"stringValue": "breakfast"}] + } + } + } + } + ] + } + } + }, + "stages/search/query-string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "search", + "options": { + "query": { + "functionValue": { + "name": "document_matches", + "args": [{"stringValue": "breakfast"}] + } + } + } + } + ] + } + } + }, + "stages/search/retrieval-depth-language": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "search", + "options": { + "language_code": {"stringValue": "en"}, + "query": { + "functionValue": { + "name": "document_matches", + "args": [{"stringValue": "breakfast"}] + } + }, + "retrieval_depth": {"integerValue": "100"} + } + } + ] + } + } + }, + "stages/search/sort-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "search", + "options": { + "query": { + "functionValue": { + "name": "document_matches", + "args": [{"stringValue": "breakfast"}] + } + }, + "sort": { + "arrayValue": { + "values": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"functionValue": {"name": "score"}} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "title"} + } + } + } + ] + } + } + } + } + ] + } + } + }, + "stages/search/sort-single": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "search", + "options": { + "query": { + "functionValue": { + "name": "document_matches", + "args": [{"stringValue": "breakfast"}] + } + }, + "sort": { + "arrayValue": { + "values": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"functionValue": {"name": "score"}} + } + } + } + ] + } + } + } + } + ] + } + } + }, + "stages/select/aliased": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "name": {"fieldReferenceValue": "title"}, + "doubled": { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/select/field-names": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "author": {"fieldReferenceValue": "author"}, + "title": {"fieldReferenceValue": "title"} + } + } + } + ] + } + ] + } + } + }, + "stages/select/fields": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "author": {"fieldReferenceValue": "author"}, + "title": {"fieldReferenceValue": "title"} + } + } + } + ] + } + ] + } + } + }, + "stages/select/mixed": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "author": {"fieldReferenceValue": "author"}, + "score": {"fieldReferenceValue": "rating"}, + "title": {"fieldReferenceValue": "title"} + } + } + } + ] + } + ] + } + } + }, + "stages/select/nested-field-path": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "awards.hugo": {"fieldReferenceValue": "awards.hugo"}, + "metadata.lang": {"fieldReferenceValue": "metadata.lang"} + } + } + } + ] + } + ] + } + } + }, + "stages/select/options-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "score": {"fieldReferenceValue": "rating"}, + "title": {"fieldReferenceValue": "title"} + } + } + } + ] + } + ] + } + } + }, + "stages/select/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"title": {"fieldReferenceValue": "title"}} + } + } + ], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, + "stages/select/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "`last name`": {"fieldReferenceValue": "`last name`"}, + "first-name": {"fieldReferenceValue": "`first-name`"} + } + } + } + ] + } + ] + } + } + }, + "stages/sort/ascending-method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + } + ] + } + ] + } + } + }, + "stages/sort/descending-method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + } + ] + } + ] + } + } + }, + "stages/sort/expression-method": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": { + "functionValue": { + "name": "char_length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/sort/multiple": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "title"} + } + } + } + ] + } + ] + } + } + }, + "stages/sort/top-level-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": { + "functionValue": { + "name": "char_length", + "args": [{"fieldReferenceValue": "title"}] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/sort/top-level-field-name": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "title"} + } + } + } + ] + } + ] + } + } + }, + "stages/to-scalar-expression/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "add_fields", + "args": [ + { + "mapValue": { + "fields": { + "averageRating": { + "functionValue": { + "name": "scalar", + "args": [ + { + "pipelineValue": { + "stages": [ + { + "name": "collection", + "args": [{"referenceValue": "/reviews"}] + }, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": { + "avg": { + "functionValue": { + "name": "average", + "args": [ + { + "fieldReferenceValue": "rating" + } + ] + } + } + } + } + }, + {"mapValue": {}} + ] + } + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/union/complex": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"title": {"fieldReferenceValue": "title"}} + } + } + ] + }, + { + "name": "union", + "args": [ + { + "pipelineValue": { + "stages": [ + { + "name": "collection_group", + "args": [ + {"referenceValue": ""}, + {"stringValue": "reviews"} + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "3"} + ] + } + } + ] + }, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "title": {"fieldReferenceValue": "title"} + } + } + } + ] + } + ] + } + } + ] + } + ] + } + } + }, + "stages/union/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "union", + "args": [ + { + "pipelineValue": { + "stages": [ + { + "name": "collection", + "args": [{"referenceValue": "/magazines"}] + } + ] + } + } + ] + } + ] + } + } + }, + "stages/unnest/aliased-field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "unnest", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "tag"} + ] + } + ] + } + } + }, + "stages/unnest/expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "unnest", + "args": [ + { + "functionValue": { + "name": "array", + "args": [ + {"integerValue": "1"}, + {"integerValue": "2"}, + {"fieldReferenceValue": "rating"} + ] + } + }, + {"fieldReferenceValue": "item"} + ] + } + ] + } + } + }, + "stages/unnest/field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "unnest", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "tags"} + ] + } + ] + } + } + }, + "stages/unnest/index-field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "unnest", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "tag"} + ], + "options": {"index_field": {"fieldReferenceValue": "index"}} + } + ] + } + } + }, + "stages/unnest/options-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "unnest", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "tag"} + ], + "options": {"index_field": {"fieldReferenceValue": "idx"}} + } + ] + } + } + }, + "stages/update/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "update", + "args": [ + { + "mapValue": { + "fields": { + "rating": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/where/chained": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Fantasy"} + ] + } + } + ] + } + ] + } + } + }, + "stages/where/condition": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + } + } +} diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/values.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/values.json new file mode 100644 index 00000000..c4c6918a --- /dev/null +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/values.json @@ -0,0 +1,1596 @@ +{ + "generator": "tool/pipeline_golden (npm run generate)", + "sdk": "@google-cloud/firestore@9.3.1", + "cases": { + "values/constant/array": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "arrayValue": { + "values": [ + {"integerValue": "1"}, + {"stringValue": "a"}, + {"booleanValue": true}, + {"nullValue": null} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "values/constant/bytes": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"bytesValue": "AQID/w=="}}}} + ] + } + ] + } + } + }, + "values/constant/date": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": {"timestampValue": "2024-01-02T03:04:05.678Z"} + } + } + } + ] + } + ] + } + } + }, + "values/constant/document-reference": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "referenceValue": "projects/test-project/databases/(default)/documents/books/book1" + } + } + } + } + ] + } + ] + } + } + }, + "values/constant/double": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"doubleValue": 3.14}}}} + ] + } + ] + } + } + }, + "values/constant/empty-string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"stringValue": ""}}}} + ] + } + ] + } + } + }, + "values/constant/false": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"booleanValue": false}}}} + ] + } + ] + } + } + }, + "values/constant/geo-point": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "geoPointValue": { + "latitude": 37.7749, + "longitude": -122.4194 + } + } + } + } + } + ] + } + ] + } + } + }, + "values/constant/infinity": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"doubleValue": "Infinity"}} + } + } + ] + } + ] + } + } + }, + "values/constant/integer": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"integerValue": "42"}}}} + ] + } + ] + } + } + }, + "values/constant/map": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "mapValue": { + "fields": { + "a": {"integerValue": "1"}, + "b": {"stringValue": "x"}, + "nested": { + "mapValue": { + "fields": { + "c": { + "arrayValue": { + "values": [ + {"integerValue": "1"}, + {"integerValue": "2"} + ] + } + } + } + } + } + } + } + } + } + } + } + ] + } + ] + } + } + }, + "values/constant/nan": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"doubleValue": "NaN"}}}} + ] + } + ] + } + } + }, + "values/constant/negative-infinity": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"doubleValue": "-Infinity"}} + } + } + ] + } + ] + } + } + }, + "values/constant/negative-integer": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"integerValue": "-7"}}}} + ] + } + ] + } + } + }, + "values/constant/null": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"nullValue": null}}}} + ] + } + ] + } + } + }, + "values/constant/string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"stringValue": "Fantasy"}}}} + ] + } + ] + } + } + }, + "values/constant/timestamp": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "timestampValue": "2023-11-14T22:13:20.123456789Z" + } + } + } + } + ] + } + ] + } + } + }, + "values/constant/timestamp-millis": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": {"timestampValue": "2023-11-14T22:13:20.123Z"} + } + } + } + ] + } + ] + } + } + }, + "values/constant/true": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"booleanValue": true}}}} + ] + } + ] + } + } + }, + "values/constant/vector": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + } + } + } + ] + } + ] + } + } + }, + "values/constant/zero": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + {"mapValue": {"fields": {"result": {"integerValue": "0"}}}} + ] + } + ] + } + } + }, + "values/data-types/all-constants": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + {"name": "limit", "args": [{"integerValue": "1"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "array": { + "functionValue": { + "name": "array", + "args": [ + {"integerValue": "1"}, + {"stringValue": "a string"}, + {"booleanValue": true}, + {"nullValue": null}, + { + "geoPointValue": { + "latitude": 37.7749, + "longitude": -122.4194 + } + }, + {"timestampValue": "2023-11-14T22:13:20.123456789Z"}, + {"timestampValue": "2024-01-02T03:04:05.678Z"}, + {"bytesValue": "AQID/w=="}, + { + "referenceValue": "projects/test-project/databases/(default)/documents/books/book1" + }, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "number"}, + {"integerValue": "2"}, + {"stringValue": "string"}, + {"stringValue": "b string"} + ] + } + } + ] + } + }, + "boolean": {"booleanValue": true}, + "bytes": {"bytesValue": "AQID/w=="}, + "date": {"timestampValue": "2024-01-02T03:04:05.678Z"}, + "documentReference": { + "referenceValue": "projects/test-project/databases/(default)/documents/books/book1" + }, + "geoPoint": { + "geoPointValue": { + "latitude": 37.7749, + "longitude": -122.4194 + } + }, + "map": { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "number"}, + {"integerValue": "1"}, + {"stringValue": "string"}, + {"stringValue": "a string"}, + {"stringValue": "boolean"}, + {"booleanValue": true}, + {"stringValue": "null"}, + {"nullValue": null}, + {"stringValue": "geoPoint"}, + { + "geoPointValue": { + "latitude": 37.7749, + "longitude": -122.4194 + } + }, + {"stringValue": "timestamp"}, + {"timestampValue": "2023-11-14T22:13:20.123456789Z"}, + {"stringValue": "date"}, + {"timestampValue": "2024-01-02T03:04:05.678Z"}, + {"stringValue": "uint8Array"}, + {"bytesValue": "AQID/w=="}, + {"stringValue": "documentReference"}, + { + "referenceValue": "projects/test-project/databases/(default)/documents/books/book1" + }, + {"stringValue": "vectorValue"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "map"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "number"}, + {"integerValue": "2"}, + {"stringValue": "string"}, + {"stringValue": "b string"} + ] + } + }, + {"stringValue": "array"}, + { + "functionValue": { + "name": "array", + "args": [ + {"integerValue": "1"}, + {"stringValue": "c string"} + ] + } + } + ] + } + }, + "null": {"nullValue": null}, + "number": {"integerValue": "1"}, + "string": {"stringValue": "a string"}, + "timestamp": { + "timestampValue": "2023-11-14T22:13:20.123456789Z" + }, + "vectorValue": { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + } + } + } + ] + } + ] + } + } + }, + "values/data-types/array-and-map-functions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "1"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "author": {"fieldReferenceValue": "author"}, + "genre": {"fieldReferenceValue": "genre"}, + "published": {"fieldReferenceValue": "published"}, + "rating": {"fieldReferenceValue": "rating"}, + "tags": {"fieldReferenceValue": "tags"}, + "title": {"fieldReferenceValue": "title"} + } + } + } + ] + }, + { + "name": "add_fields", + "args": [ + { + "mapValue": { + "fields": { + "metadata": { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "genre"}, + {"fieldReferenceValue": "genre"}, + {"stringValue": "rating"}, + { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "10"} + ] + } + }, + {"stringValue": "nestedArray"}, + { + "functionValue": { + "name": "array", + "args": [{"fieldReferenceValue": "title"}] + } + }, + {"stringValue": "nestedMap"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "published"}, + {"fieldReferenceValue": "published"} + ] + } + } + ] + } + }, + "metadataArray": { + "functionValue": { + "name": "array", + "args": [ + {"integerValue": "1"}, + {"integerValue": "2"}, + {"fieldReferenceValue": "genre"}, + { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "10"} + ] + } + }, + { + "functionValue": { + "name": "array", + "args": [{"fieldReferenceValue": "title"}] + } + }, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "published"}, + {"fieldReferenceValue": "published"} + ] + } + } + ] + } + } + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "metadataArray"}, + { + "functionValue": { + "name": "array", + "args": [ + {"integerValue": "1"}, + {"integerValue": "2"}, + {"fieldReferenceValue": "genre"}, + { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "10"} + ] + } + }, + { + "functionValue": { + "name": "array", + "args": [{"fieldReferenceValue": "title"}] + } + }, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "published"}, + {"fieldReferenceValue": "published"} + ] + } + } + ] + } + } + ] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "metadata"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "genre"}, + {"fieldReferenceValue": "genre"}, + {"stringValue": "rating"}, + { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "10"} + ] + } + }, + {"stringValue": "nestedArray"}, + { + "functionValue": { + "name": "array", + "args": [{"fieldReferenceValue": "title"}] + } + }, + {"stringValue": "nestedMap"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "published"}, + {"fieldReferenceValue": "published"} + ] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/field/document-id": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"fieldReferenceValue": "__name__"}} + } + } + ] + } + ] + } + } + }, + "values/field/field-path-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"fieldReferenceValue": "`a.b`.c"}} + } + } + ] + } + ] + } + } + }, + "values/field/nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": {"fieldReferenceValue": "metadata.lang"} + } + } + } + ] + } + ] + } + } + }, + "values/field/non-ascii": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"fieldReferenceValue": "`naïve`"}} + } + } + ] + } + ] + } + } + }, + "values/field/simple": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"fieldReferenceValue": "title"}} + } + } + ] + } + ] + } + } + }, + "values/field/space": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"fieldReferenceValue": "`last name`"}} + } + } + ] + } + ] + } + } + }, + "values/field/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": {"fieldReferenceValue": "`first-name`"} + } + } + } + ] + } + ] + } + } + }, + "values/value-position/boolean": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"booleanValue": true} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/bytes": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"bytesValue": "AQID/w=="} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/constant-list": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "arrayValue": { + "values": [{"stringValue": "a"}, {"stringValue": "b"}] + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/date": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"timestampValue": "2024-01-02T03:04:05.678Z"} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/document-reference": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "referenceValue": "projects/test-project/databases/(default)/documents/books/book1" + } + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/double": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"doubleValue": 1.5} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/geo-point": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "geoPointValue": { + "latitude": 37.7749, + "longitude": -122.4194 + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/integer": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"integerValue": "1"} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/list-empty": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"functionValue": {"name": "array"}} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/list-nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "functionValue": { + "name": "array", + "args": [ + { + "functionValue": { + "name": "array", + "args": [{"stringValue": "a"}] + } + }, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "k"}, + {"stringValue": "v"} + ] + } + } + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/list-plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "functionValue": { + "name": "array", + "args": [{"stringValue": "a"}, {"stringValue": "b"}] + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/list-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "functionValue": { + "name": "array", + "args": [ + {"stringValue": "a"}, + {"fieldReferenceValue": "b"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/map-empty": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"functionValue": {"name": "map"}} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/map-nested": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "inner"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "k"}, + {"fieldReferenceValue": "y"} + ] + } + }, + {"stringValue": "list"}, + { + "functionValue": { + "name": "array", + "args": [{"fieldReferenceValue": "z"}] + } + } + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/map-plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "a"}, + {"integerValue": "1"}, + {"stringValue": "b"}, + {"stringValue": "x"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/map-with-expression": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "a"}, + {"fieldReferenceValue": "x"}, + {"stringValue": "b"}, + {"integerValue": "1"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/null": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"nullValue": null} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/string": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"stringValue": "a"} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/timestamp": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + {"timestampValue": "2023-11-14T22:13:20.123456789Z"} + ] + } + } + ] + } + ] + } + } + }, + "values/value-position/vector": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "value"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + } + ] + } + } + ] + } + ] + } + } + }, + "values/variable/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"variableReferenceValue": "x"}} + } + } + ] + } + ] + } + } + } + } +} diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart new file mode 100644 index 00000000..e05dbd17 --- /dev/null +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -0,0 +1,2940 @@ +// 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. + +/// Compares the Pipeline requests this SDK sends with the ones the Node.js +/// SDK sends for the same Pipelines. +/// +/// The expected requests in `test/fixtures/pipeline_golden/*.json` are +/// generated by the Node.js SDK (see `tool/pipeline_golden/README.md`), never +/// by this package. Every fixture id has a Dart case below that builds the +/// equivalent Pipeline; the request it sends must equal the fixture. +/// +/// When this SDK deliberately sends something else, the id is listed in +/// [_knownDivergences]. Differences that look like bugs, still to be fixed, +/// are listed in [_pendingFixes]. Node APIs with no Dart equivalent are +/// listed in [_dartApiGaps]. All three lists are checked: an entry fails as +/// soon as it no longer describes reality. +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +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:mocktail/mocktail.dart'; +import 'package:test/test.dart' hide greaterThan, lessThan; + +const _projectId = 'test-project'; +const _databaseId = '(default)'; +const _fixtureDir = 'test/fixtures/pipeline_golden'; + +class _MockFirestoreHttpClient extends Mock implements FirestoreHttpClient {} + +/// Where this SDK deliberately sends something other than the Node SDK. +/// +/// [_Divergence.check] asserts the Dart request still has the intended shape. +final _knownDivergences = { + for (final name in [ + 'equalAny', + 'notEqualAny', + 'arrayContainsAll', + 'arrayContainsAny', + ]) + for (final variant in [ + 'static-field-name-list-with-expression', + 'method-list-with-expression', + ]) + 'functions/$name/$variant': const _Divergence( + 'A search-space list holding expressions is sent as an array(...) ' + 'function. Node sends a literal arrayValue holding a field ' + 'reference, which the backend rejects ("Value type is not ' + 'supported: FIELD_REFERENCE_VALUE").', + _searchSpaceIsArrayFunction, + ), +}; + +/// Differences from the Node SDK that are not intended, awaiting a fix. +/// +/// Each entry must still differ from its fixture; remove it once fixed. +const _pendingFixes = { + // field() sends the path verbatim; Node formats it like FieldPath, quoting + // segments that are not simple identifiers. + 'values/field/special-characters': + "field('first-name') sends `first-name` unquoted; Node sends " + '"`first-name`".', + 'values/field/space': + "field('last name') sends `last name` unquoted; Node sends " + '"`last name`".', + 'values/field/non-ascii': + "field('naïve') sends `naïve` unquoted; Node sends \"`naïve`\".", + 'stages/select/special-characters': + 'select() leaves special-character field paths unquoted; Node quotes ' + 'them, and keys a PipelineField by its quoted path ("`last name`").', + // Ordering helpers. + 'stages/sort/top-level-field-name': + "ascending('rating') / descending('title') sort by the string " + 'constant (stringValue) instead of the field (fieldReferenceValue).', + // Query to Pipeline conversion. + 'queries/where/not-equal': + "createFrom(query) leaves '!=' fields out of the implicit sort; " + 'Node sorts by them before __name__ (isInequalityFilter omits ' + 'notEqual).', + 'queries/where/not-equal-null': + "createFrom(query) leaves '!= null' fields out of the implicit sort; " + 'Node sorts by them before __name__.', + 'queries/where/not-in': + "createFrom(query) leaves 'not-in' fields out of the implicit sort; " + 'Node sorts by them before __name__ (isInequalityFilter omits notIn).', + 'queries/vector/distance-result-field': + 'createFrom(vectorQuery) sends a distance_field option; Node drops ' + 'distanceResultField. Dart keeps the query semantics: decide whether ' + 'this should be a known divergence.', + 'queries/vector/distance-threshold': + 'createFrom(vectorQuery) sends a distance_threshold option; Node drops ' + 'distanceThreshold and its find_nearest stage has no such option, so ' + 'the backend name is unverified.', + // Nested plain collections. Node turns a nested list or map into an + // array(...) / map(...) function; Dart sends a nested literal. + 'functions/equalAny/static-field-name-list-nested': + "A nested list in the search space (['Fantasy', ['Sci-Fi']]) is sent " + 'as a literal arrayValue; Node sends an array(...) function inside the ' + 'literal arrayValue (the known-divergence family: prod likely rejects ' + "Node's form).", + 'functions/notEqualAny/static-field-name-list-nested': + 'As equalAny: nested literal arrayValue vs Node array(...) function.', + 'functions/arrayContainsAll/static-field-name-list-nested': + 'As equalAny: nested literal arrayValue vs Node array(...) function.', + 'functions/arrayContainsAny/static-field-name-list-nested': + 'As equalAny: nested literal arrayValue vs Node array(...) function.', + 'stages/raw-stage/nested-plain-object-param': + 'rawStage sends a nested map param as a literal mapValue holding a ' + 'field reference; Node sends a map(...) function for the nested map.', + // Dotted option keys. + 'options/raw-options/dot-notation': + "execute(rawOptions: {'explain_options.mode': ...}) sends the dotted " + 'key verbatim; Node expands it into explain_options.mode.', + 'options/raw-options/merge-into-explain': + 'A dotted rawOptions key is sent verbatim instead of being merged into ' + 'the explain_options map built from explain:.', + 'stages/raw-stage/options-dot-notation': + "rawStage(options: {'outer.inner': ...}) sends the dotted key " + 'verbatim; Node expands it into a nested map.', +}; + +/// Node APIs this SDK has no way to express; there is no Dart case for them. +const _dartApiGaps = { + 'sources/collection/force-index': + 'PipelineSource.collection has no forceIndex option.', + 'sources/collection-group/force-index': + 'PipelineSource.collectionGroup has no forceIndex option.', + 'sources/subcollection/as-array-expression': + 'No subcollection() source and no Pipeline.toArrayExpression().', + 'stages/select/raw-options': + 'Typed stages take no per-stage rawOptions; only rawStage does.', + 'stages/define/plain': 'No Pipeline.define (let) stage.', + 'stages/delete/plain': 'No Pipeline.delete stage.', + 'stages/update/plain': 'No Pipeline.update stage.', + 'stages/to-scalar-expression/plain': 'No Pipeline.toScalarExpression().', + 'values/field/field-path-object': 'field() takes a String, not a FieldPath.', + 'functions/add/method-variadic': + 'PipelineExpression.add takes a single operand.', + 'functions/multiply/method-variadic': + 'PipelineExpression.multiply takes a single operand.', + 'functions/arraySlice/static-field-name-offset': + 'PipelineFunctions.arraySlice requires a length; only the ' + 'PipelineExpression.arraySlice method makes it optional.', +}; + +void main() { + final fixtures = _loadFixtures(); + final registry = _Registry(); + _registerCases(registry); + + group('Node golden corpus', () { + test('every fixture has a Dart case or a documented API gap', () { + final missing = [ + for (final id in fixtures.keys) + if (!registry.cases.containsKey(id) && !_dartApiGaps.containsKey(id)) + id, + ]; + expect( + missing, + isEmpty, + reason: + 'Add a Dart case to _registerCases for each of these fixture ' + 'ids, or list it in _dartApiGaps.', + ); + }); + + test('every Dart case has a fixture', () { + final orphans = [ + for (final id in registry.cases.keys) + if (!fixtures.containsKey(id)) id, + ]; + expect( + orphans, + isEmpty, + reason: + 'Add these cases to tool/pipeline_golden/cases.js and regenerate ' + 'the fixtures (see tool/pipeline_golden/README.md).', + ); + }); + + test('documented API gaps have a fixture and no Dart case', () { + for (final id in _dartApiGaps.keys) { + expect(fixtures, contains(id), reason: '_dartApiGaps lists $id'); + expect( + registry.cases, + isNot(contains(id)), + reason: '$id has a Dart case: remove it from _dartApiGaps.', + ); + } + }); + + test('divergences and pending fixes have a fixture and a Dart case', () { + for (final id in [..._knownDivergences.keys, ..._pendingFixes.keys]) { + expect(fixtures, contains(id)); + expect(registry.cases, contains(id)); + } + expect( + _knownDivergences.keys.toSet().intersection(_pendingFixes.keys.toSet()), + isEmpty, + ); + }); + }); + + group('matches the Node SDK request', () { + late _MockFirestoreHttpClient client; + late Firestore db; + firestore_v1.ExecutePipelineRequest? captured; + + setUp(() { + client = _MockFirestoreHttpClient(); + when(() => client.cachedProjectId).thenReturn(_projectId); + when( + () => client.v1>(any()), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> + Function(firestore_v1.Firestore api, String projectId); + final api = FakeFirestore( + executePipeline: (request) { + captured = request; + return const Stream.empty(); + }, + ); + return callback(api, _projectId); + }); + db = Firestore.internal( + settings: const Settings( + projectId: _projectId, + databaseId: _databaseId, + ), + client: client, + ); + }); + + Future capture(_Run run) async { + captured = null; + await run(db); + final request = captured; + if (request == null) fail('The case sent no request.'); + return _canonicalize(jsonDecode(jsonEncode(request.toJson()))); + } + + for (final id in fixtures.keys.toList()..sort()) { + final runs = registry.cases[id]; + if (runs == null) continue; + final expected = fixtures[id]; + + test(id, () async { + for (final (index, run) in runs.indexed) { + final actual = await capture(run); + final form = runs.length == 1 ? '' : ' (Dart form #${index + 1})'; + final differences = []; + _diff(expected, actual, r'$', differences); + + final divergence = _knownDivergences[id]; + final pending = _pendingFixes[id]; + if (divergence != null) { + expect( + differences, + isNotEmpty, + reason: + '$id$form now matches Node: remove it from ' + '_knownDivergences.', + ); + expect( + divergence.check(actual), + isTrue, + reason: + '$id$form no longer has the intended shape: ' + '${divergence.reason}\n' + 'Dart sent:\n${_pretty(actual)}', + ); + } else if (pending != null) { + expect( + differences, + isNotEmpty, + reason: + '$id$form now matches Node: remove it from ' + '_pendingFixes.', + ); + } else if (differences.isNotEmpty) { + fail( + '$id$form differs from the Node SDK request:\n' + ' ${differences.join('\n ')}\n\n' + 'Dart sent:\n${_pretty(actual)}\n\n' + 'Node sent:\n${_pretty(expected)}', + ); + } + } + }); + } + }); +} + +// ----------------------------------------------------------------------------- +// Fixtures and comparison +// ----------------------------------------------------------------------------- + +/// Loads every case of every fixture file, keyed by id. +Map _loadFixtures() { + final files = + Directory(_fixtureDir) + .listSync() + .whereType() + .where((file) => file.path.endsWith('.json')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + if (files.isEmpty) { + throw StateError('No fixtures in $_fixtureDir; run from the package root.'); + } + + final result = {}; + for (final file in files) { + final json = jsonDecode(file.readAsStringSync()) as Map; + final cases = json['cases']! as Map; + for (final MapEntry(:key, :value) in cases.entries) { + if (result.containsKey(key)) { + throw StateError('Duplicate fixture id $key in ${file.path}.'); + } + result[key] = _canonicalize(value); + } + } + return result; +} + +/// Repeated and map fields that proto3 JSON omits when empty. +/// +/// The same normalization `tool/pipeline_golden/generate.js` applies. +const _omitWhenEmpty = {'args', 'options', 'fields', 'values'}; + +/// Sorts keys (`name` first, as in the fixtures) and drops [_omitWhenEmpty] +/// fields that are empty. +Object? _canonicalize(Object? value) { + return switch (value) { + List() => [for (final item in value) _canonicalize(item)], + Map() => { + for (final key in value.keys.cast().toList()..sort(_compareKeys)) + if (_canonicalize(value[key]) case final child + when !(_omitWhenEmpty.contains(key) && _isEmpty(child))) + key: child, + }, + _ => value, + }; +} + +int _compareKeys(String a, String b) { + if (a == b) return 0; + if (a == 'name') return -1; + if (b == 'name') return 1; + return a.compareTo(b); +} + +bool _isEmpty(Object? value) { + return switch (value) { + List() => value.isEmpty, + Map() => value.isEmpty, + _ => false, + }; +} + +/// Lists the paths at which [expected] and [actual] differ. +/// +/// Numbers compare by value, so `1` and `1.0` (a `doubleValue` encoded by +/// JavaScript and by Dart) are equal. +void _diff(Object? expected, Object? actual, String path, List out) { + if (expected is Map && actual is Map) { + for (final key in {...expected.keys, ...actual.keys}) { + final child = '$path.$key'; + if (!expected.containsKey(key)) { + out.add('$child: missing in Node, Dart has ${_short(actual[key])}'); + } else if (!actual.containsKey(key)) { + out.add('$child: Node has ${_short(expected[key])}, missing in Dart'); + } else { + _diff(expected[key], actual[key], child, out); + } + } + } else if (expected is List && actual is List) { + if (expected.length != actual.length) { + out.add( + '$path: Node has ${expected.length} items, Dart has ' + '${actual.length}: Node ${_short(expected)}, Dart ${_short(actual)}', + ); + return; + } + for (var i = 0; i < expected.length; i++) { + _diff(expected[i], actual[i], '$path[$i]', out); + } + } else if (expected != actual) { + out.add('$path: Node ${_short(expected)}, Dart ${_short(actual)}'); + } +} + +String _short(Object? value) { + final json = jsonEncode(value); + return json.length <= 200 ? json : '${json.substring(0, 200)}...'; +} + +String _pretty(Object? value) => + const JsonEncoder.withIndent(' ').convert(value); + +/// A deliberate difference from the Node SDK. +final class _Divergence { + const _Divergence(this.reason, this.check); + + /// Why this SDK sends something else. + final String reason; + + /// Whether the canonical Dart request has the intended shape. + final bool Function(Object? request) check; +} + +/// Whether the `where` stage's function receives its search space as an +/// `array(...)` function. +bool _searchSpaceIsArrayFunction(Object? request) { + final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); + if (stages is! List || stages.length != 2) return false; + final searchSpace = _path(stages[1], ['args', 0, 'functionValue', 'args', 1]); + return _path(searchSpace, ['functionValue', 'name']) == 'array'; +} + +Object? _path(Object? value, List path) { + var current = value; + for (final segment in path) { + current = switch ((current, segment)) { + (final Map map, final String key) => map[key], + (final List list, final int index) when index < list.length => + list[index], + _ => null, + }; + } + return current; +} + +// ----------------------------------------------------------------------------- +// Case registry +// ----------------------------------------------------------------------------- + +/// Executes a Pipeline against [db]; the request it sends is captured. +typedef _Run = Future Function(Firestore db); + +typedef _Build = Pipeline Function(Firestore db); +typedef _Expr = PipelineExpression Function(); +typedef _DbExpr = PipelineExpression Function(Firestore db); +typedef _Static1 = PipelineExpression Function(Object? target); +typedef _Method0 = PipelineExpression Function(PipelineExpression target); +typedef _Static2 = PipelineExpression Function(Object? target, Object? operand); +typedef _Method1 = + PipelineExpression Function(PipelineExpression target, Object? operand); + +/// The Dart cases, keyed by fixture id. +/// +/// A case may have several Dart forms (for example a top-level helper and +/// the matching [PipelineFunctions] member); each one must match. +final class _Registry { + final cases = >{}; + + void run(String id, List<_Run> runs) { + if (cases.containsKey(id)) throw StateError('Duplicate case id $id.'); + cases[id] = runs; + } + + /// A Pipeline executed without options. + void pipeline( + String id, + _Build build, [ + List<_Build> alternatives = const [], + ]) { + run(id, [ + for (final b in [build, ...alternatives]) (db) => b(db).execute(), + ]); + } + + /// A function wrapped as in `cases.js`: boolean expressions in `where`, + /// everything else in `select(.as('result'))`. + void expr(String id, _Expr build, [List<_Expr> alternatives = const []]) { + pipeline(id, (db) => _wrap(db, build()), [ + for (final b in alternatives) (db) => _wrap(db, b()), + ]); + } + + /// A function that needs the Firestore instance, such as for a reference. + void dbExpr(String id, _DbExpr build) { + pipeline(id, (db) => _wrap(db, build(db))); + } + + /// An aggregate function, in `aggregate(.as('result'))`. + void aggregate( + String id, + _Expr build, [ + List<_Expr> alternatives = const [], + ]) { + pipeline(id, (db) => _books(db).aggregate([build().as('result')]), [ + for (final b in alternatives) + (db) => _books(db).aggregate([b().as('result')]), + ]); + } + + /// Mirrors `unary` in `cases.js`. + void unary( + String name, + String target, + _Static1 static, + _Method0 method, { + String area = 'functions', + }) { + final register = area == 'aggregates' ? aggregate : expr; + register('$area/$name/static-field-name', () => static(target)); + register('$area/$name/static-expression', () => static(field(target))); + register('$area/$name/method', () => method(field(target))); + } + + /// Mirrors `binary` in `cases.js`. + void binary( + String name, + String target, + Object? literal, + _Expr operand, + _Static2 static, + _Method1 method, { + List<_Static2> staticAlternatives = const [], + List<_Method1> methodAlternatives = const [], + }) { + final statics = [static, ...staticAlternatives]; + final methods = [method, ...methodAlternatives]; + void staticCase( + String variant, + Object? Function() target, + Object? Function() op, + ) { + expr('functions/$name/static-$variant', () => static(target(), op()), [ + for (final s in statics.skip(1)) () => s(target(), op()), + ]); + } + + staticCase('field-name-literal', () => target, () => literal); + staticCase('field-name-expression', () => target, operand); + staticCase('expression-literal', () => field(target), () => literal); + staticCase('expression-expression', () => field(target), operand); + expr( + 'functions/$name/method-literal', + () => method(field(target), literal), + [for (final m in methods.skip(1)) () => m(field(target), literal)], + ); + expr( + 'functions/$name/method-expression', + () => method(field(target), operand()), + ); + } +} + +Pipeline _books(Firestore db) => db.pipeline().collection('books'); + +Pipeline _wrap(Firestore db, PipelineExpression expression) { + return switch (expression) { + PipelineBooleanExpression() => _books(db).where(expression), + _ => _books(db).select([expression.as('result')]), + }; +} + +// Fixed values, identical to the ones in `cases.js`. +final _timestamp = Timestamp(seconds: 1700000000, nanoseconds: 123456789); +final _date = DateTime.utc(2024, 1, 2, 3, 4, 5, 678); +final _geoPoint = GeoPoint(latitude: 37.7749, longitude: -122.4194); +final _bytes = Uint8List.fromList([1, 2, 3, 255]); +final _vector = FieldValue.vector([0.1, 0.2, 0.3]); +DocumentReference _docRef(Firestore db) => db.doc('books/book1'); + +PipelineBooleanExpression _isFantasy() => field('genre').equal('Fantasy'); +PipelineBooleanExpression _isGood() => field('rating').greaterThan(4); +PipelineBooleanExpression _isOld() => field('published').lessThan(1950); + +void _registerCases(_Registry r) { + _registerSources(r); + _registerQueries(r); + _registerStages(r); + _registerOptions(r); + _registerValues(r); + _registerArithmetic(r); + _registerComparisonAndLogic(r); + _registerStrings(r); + _registerArrays(r); + _registerMaps(r); + _registerTimestamps(r); + _registerTypesVectorsAndReferences(r); + _registerAggregates(r); +} + +// ----------------------------------------------------------------------------- +// Sources and queries +// ----------------------------------------------------------------------------- + +void _registerSources(_Registry r) { + r.pipeline( + 'sources/collection/path', + (db) => db.pipeline().collection('books'), + ); + r.pipeline( + 'sources/collection/nested-path', + (db) => db.pipeline().collection('authors/author1/books'), + ); + r.pipeline( + 'sources/collection/reference', + (db) => db.pipeline().collectionReference(db.collection('books')), + ); + r.pipeline( + 'sources/collection-group/id', + (db) => db.pipeline().collectionGroup('reviews'), + ); + r.pipeline('sources/database/plain', (db) => db.pipeline().database()); + // Dart only takes references; Node turns its paths into references. + r.pipeline( + 'sources/documents/paths', + (db) => + db.pipeline().documents([db.doc('books/book1'), db.doc('books/book2')]), + ); + r.pipeline( + 'sources/documents/references', + (db) => + db.pipeline().documents([db.doc('books/book1'), db.doc('books/book2')]), + ); + r.pipeline( + 'sources/documents/nested', + (db) => db.pipeline().documents([db.doc('authors/author1/books/book1')]), + ); +} + +void _registerQueries(_Registry r) { + void query(String id, Object Function(Firestore db) build) { + r.pipeline('queries/$id', (db) => db.pipeline().createFrom(build(db))); + } + + CollectionReference col(Firestore db) => db.collection('books'); + + query('collection/plain', col); + query( + 'collection/nested-path', + (db) => db.collection('authors/author1/books'), + ); + query('collection-group/plain', (db) => db.collectionGroup('reviews')); + query( + 'collection-group/where', + (db) => db + .collectionGroup('reviews') + .where('rating', WhereFilter.greaterThan, 3), + ); + + query( + 'where/less-than', + (db) => col(db).where('rating', WhereFilter.lessThan, 4), + ); + query( + 'where/less-than-or-equal', + (db) => col(db).where('rating', WhereFilter.lessThanOrEqual, 4), + ); + query( + 'where/equal', + (db) => col(db).where('genre', WhereFilter.equal, 'Fantasy'), + ); + query( + 'where/not-equal', + (db) => col(db).where('genre', WhereFilter.notEqual, 'Fantasy'), + ); + query( + 'where/greater-than-or-equal', + (db) => col(db).where('rating', WhereFilter.greaterThanOrEqual, 4), + ); + query( + 'where/greater-than', + (db) => col(db).where('rating', WhereFilter.greaterThan, 4), + ); + query( + 'where/array-contains', + (db) => col(db).where('tags', WhereFilter.arrayContains, 'magic'), + ); + query( + 'where/in', + (db) => col(db).where('genre', WhereFilter.isIn, ['Fantasy', 'Sci-Fi']), + ); + query( + 'where/not-in', + (db) => col(db).where('genre', WhereFilter.notIn, ['Fantasy', 'Sci-Fi']), + ); + query( + 'where/array-contains-any', + (db) => + col(db).where('tags', WhereFilter.arrayContainsAny, ['magic', 'epic']), + ); + query( + 'where/equal-null', + (db) => col(db).where('genre', WhereFilter.equal, null), + ); + query( + 'where/not-equal-null', + (db) => col(db).where('genre', WhereFilter.notEqual, null), + ); + query( + 'where/equal-nan', + (db) => col(db).where('rating', WhereFilter.equal, double.nan), + ); + query( + 'where/double', + (db) => col(db).where('rating', WhereFilter.greaterThan, 4.5), + ); + query( + 'where/timestamp', + (db) => col(db).where('published', WhereFilter.lessThan, _timestamp), + ); + query( + 'where/nested-field', + (db) => col(db).where('metadata.lang', WhereFilter.equal, 'en'), + ); + query( + 'where/special-characters', + (db) => col(db).where('first-name', WhereFilter.equal, 'Ada'), + ); + query( + 'where/document-id', + // Node also accepts the bare ID 'book1'; Dart's Query.where requires the + // reference it resolves to. + (db) => col( + db, + ).where(FieldPath.documentId, WhereFilter.equal, col(db).doc('book1')), + ); + query( + 'where/multiple', + (db) => col(db) + .where('genre', WhereFilter.equal, 'Fantasy') + .where('rating', WhereFilter.greaterThan, 4), + ); + query( + 'where/composite-and', + (db) => col(db).whereFilter( + Filter.and([ + Filter.where('genre', WhereFilter.equal, 'Fantasy'), + Filter.where('rating', WhereFilter.greaterThan, 4), + ]), + ), + ); + query( + 'where/composite-or', + (db) => col(db).whereFilter( + Filter.or([ + Filter.where('genre', WhereFilter.equal, 'Fantasy'), + Filter.where('rating', WhereFilter.greaterThan, 4), + ]), + ), + ); + query( + 'where/composite-nested', + (db) => col(db).whereFilter( + Filter.or([ + Filter.and([ + Filter.where('genre', WhereFilter.equal, 'Fantasy'), + Filter.where('rating', WhereFilter.greaterThan, 4), + ]), + Filter.where('published', WhereFilter.lessThan, 1950), + ]), + ), + ); + + query('order-by/ascending', (db) => col(db).orderBy('rating')); + query( + 'order-by/descending', + (db) => col(db).orderBy('rating', descending: true), + ); + query( + 'order-by/multiple', + (db) => col(db).orderBy('rating', descending: true).orderBy('title'), + ); + query('order-by/document-id', (db) => col(db).orderBy(FieldPath.documentId)); + query('order-by/special-characters', (db) => col(db).orderBy('first-name')); + query( + 'order-by/implicit-from-inequality', + (db) => col(db).where('rating', WhereFilter.greaterThan, 4), + ); + query( + 'order-by/implicit-from-inequalities', + (db) => col(db) + .where('rating', WhereFilter.greaterThan, 4) + .where('published', WhereFilter.lessThan, 2000), + ); + query( + 'order-by/explicit-and-inequality', + (db) => col(db) + .where('rating', WhereFilter.greaterThan, 4) + .orderBy('title', descending: true), + ); + + query('limit/with-order-by', (db) => col(db).orderBy('rating').limit(5)); + query('limit/without-order-by', (db) => col(db).limit(5)); + query( + 'limit-to-last/plain', + (db) => col(db).orderBy('rating').limitToLast(5), + ); + query('offset/plain', (db) => col(db).offset(10)); + query( + 'offset/with-limit', + (db) => col(db).orderBy('rating').limit(5).offset(10), + ); + + query('cursor/start-at', (db) => col(db).orderBy('rating').startAt([4])); + query( + 'cursor/start-after', + (db) => col(db).orderBy('rating').startAfter([4]), + ); + query('cursor/end-at', (db) => col(db).orderBy('rating').endAt([4])); + query('cursor/end-before', (db) => col(db).orderBy('rating').endBefore([4])); + query( + 'cursor/multiple-fields', + (db) => col(db).orderBy('rating').orderBy('title').startAt([4, 'M']), + ); + query( + 'cursor/start-and-end', + (db) => col(db).orderBy('rating').startAfter([2]).endAt([4]), + ); + query( + 'cursor/limit-to-last', + (db) => + col(db).orderBy('rating').startAt([2]).endBefore([5]).limitToLast(3), + ); + + query( + 'select/fields', + (db) => col(db).select([ + FieldPath(const ['title']), + FieldPath(const ['author']), + ]), + ); + query('select/empty', (db) => col(db).select()); + + VectorQuery nearest( + Query base, { + Object queryVector = const [0.1, 0.2, 0.3], + DistanceMeasure distanceMeasure = DistanceMeasure.euclidean, + String? distanceResultField, + double? distanceThreshold, + }) { + return base.findNearest( + vectorField: 'embedding', + queryVector: queryVector, + limit: 5, + distanceMeasure: distanceMeasure, + distanceResultField: distanceResultField, + distanceThreshold: distanceThreshold, + ); + } + + query('vector/euclidean', (db) => nearest(col(db))); + query( + 'vector/cosine', + (db) => nearest(col(db), distanceMeasure: DistanceMeasure.cosine), + ); + query( + 'vector/dot-product', + (db) => nearest(col(db), distanceMeasure: DistanceMeasure.dotProduct), + ); + query('vector/vector-value', (db) => nearest(col(db), queryVector: _vector)); + query( + 'vector/with-prefilter', + (db) => nearest(col(db).where('genre', WhereFilter.equal, 'Fantasy')), + ); + query( + 'vector/distance-result-field', + (db) => nearest(col(db), distanceResultField: 'distance'), + ); + query( + 'vector/distance-threshold', + (db) => nearest(col(db), distanceThreshold: 0.5), + ); +} + +// ----------------------------------------------------------------------------- +// Stages and execute options +// ----------------------------------------------------------------------------- + +void _registerStages(_Registry r) { + void stage(String id, _Build build, [List<_Build> alternatives = const []]) { + r.pipeline('stages/$id', build, alternatives); + } + + stage( + 'where/condition', + (db) => _books(db).where(field('rating').greaterThan(4)), + ); + stage( + 'where/chained', + (db) => _books(db) + .where(field('rating').greaterThan(4)) + .where(field('genre').equal('Fantasy')), + ); + + stage('select/field-names', (db) => _books(db).select(['title', 'author'])); + stage( + 'select/fields', + (db) => _books(db).select([field('title'), field('author')]), + ); + stage( + 'select/aliased', + (db) => _books(db).select([ + field('title').as('name'), + field('rating').multiply(2).as('doubled'), + ]), + ); + stage( + 'select/mixed', + (db) => _books( + db, + ).select(['title', field('author'), field('rating').as('score')]), + ); + stage( + 'select/nested-field-path', + (db) => _books(db).select(['metadata.lang', field('awards.hugo')]), + ); + stage( + 'select/special-characters', + (db) => _books(db).select(['first-name', field('last name')]), + ); + stage( + 'select/options-object', + (db) => _books(db).select(['title', field('rating').as('score')]), + ); + + stage( + 'add-fields/single', + (db) => _books(db).addFields([field('rating').as('copiedRating')]), + ); + stage( + 'add-fields/multiple', + (db) => _books(db).addFields([ + field('rating').as('copiedRating'), + constant(true).as('annotated'), + ]), + ); + // Dart's addFields only takes aliased expressions; a field aliased to its + // own name is what Node sends for a bare field. + stage( + 'add-fields/field', + (db) => _books(db).addFields([field('rating').as('rating')]), + ); + + stage( + 'remove-fields/field-names', + (db) => _books(db).removeFields(['title', 'author']), + ); + stage( + 'remove-fields/fields', + (db) => _books(db).removeFields([field('title'), field('author')]), + ); + stage( + 'remove-fields/nested', + (db) => _books(db).removeFields(['metadata.lang']), + ); + + stage( + 'sort/ascending-method', + (db) => _books(db).sort([field('rating').ascending()]), + ); + stage( + 'sort/descending-method', + (db) => _books(db).sort([field('rating').descending()]), + ); + stage( + 'sort/multiple', + (db) => _books( + db, + ).sort([field('rating').descending(), field('title').ascending()]), + ); + stage( + 'sort/top-level-field-name', + (db) => _books(db).sort([ascending('rating'), descending('title')]), + ); + stage( + 'sort/top-level-expression', + (db) => _books(db).sort([ + ascending(field('rating')), + descending(field('title').charLength()), + ]), + ); + stage( + 'sort/expression-method', + (db) => _books(db).sort([field('title').charLength().descending()]), + ); + + stage('offset/plain', (db) => _books(db).offset(10)); + stage('limit/plain', (db) => _books(db).limit(10)); + stage('limit/zero', (db) => _books(db).limit(0)); + + stage( + 'distinct/field-names', + (db) => _books(db).distinct(['genre', 'author']), + ); + stage('distinct/fields', (db) => _books(db).distinct([field('genre')])); + stage( + 'distinct/aliased', + (db) => + _books(db).distinct([field('genre').toLowerCase().as('lowerGenre')]), + ); + + stage( + 'aggregate/single', + (db) => _books(db).aggregate([PipelineFunctions.countAll().as('total')]), + ); + stage( + 'aggregate/multiple', + (db) => _books(db).aggregate([ + PipelineFunctions.countAll().as('total'), + PipelineFunctions.average('rating').as('averageRating'), + ]), + ); + stage( + 'aggregate/groups-field-names', + (db) => _books( + db, + ).aggregate([PipelineFunctions.countAll().as('total')], groups: ['genre']), + ); + stage( + 'aggregate/groups-mixed', + (db) => _books(db).aggregate( + [ + PipelineFunctions.countAll().as('total'), + PipelineFunctions.maximum('rating').as('best'), + ], + groups: [ + 'genre', + field('author'), + field('published').divide(10).floor().as('decade'), + ], + ), + ); + + Pipeline findNearest( + Firestore db, { + Object vectorField = 'embedding', + Object queryVector = const [0.1, 0.2, 0.3], + DistanceMeasure distanceMeasure = DistanceMeasure.euclidean, + int? limit, + String? distanceResultField, + }) { + return _books(db).findNearest( + vectorField: vectorField, + queryVector: queryVector, + distanceMeasure: distanceMeasure, + limit: limit, + distanceResultField: distanceResultField, + ); + } + + stage('find-nearest/number-array', findNearest); + stage( + 'find-nearest/integer-array', + (db) => findNearest(db, queryVector: const [1, 2, 3]), + ); + stage( + 'find-nearest/vector-value', + (db) => findNearest(db, queryVector: _vector), + ); + stage( + 'find-nearest/field', + (db) => findNearest(db, vectorField: field('embedding')), + ); + stage( + 'find-nearest/cosine', + (db) => findNearest(db, distanceMeasure: DistanceMeasure.cosine), + ); + stage( + 'find-nearest/dot-product', + (db) => findNearest(db, distanceMeasure: DistanceMeasure.dotProduct), + ); + stage('find-nearest/limit', (db) => findNearest(db, limit: 10)); + stage( + 'find-nearest/distance-field', + (db) => findNearest(db, distanceResultField: 'distance'), + ); + stage( + 'find-nearest/all-options', + (db) => findNearest(db, limit: 10, distanceResultField: 'distance'), + ); + + stage('replace-with/field-name', (db) => _books(db).replaceWith('metadata')); + stage( + 'replace-with/field', + (db) => _books(db).replaceWith(field('metadata')), + ); + stage( + 'replace-with/map-expression', + (db) => _books(db).replaceWith( + PipelineFunctions.map([ + 'title', + field('title'), + 'rating', + field('rating'), + ]), + ), + ); + + stage('sample/documents', (db) => _books(db).sample(documents: 10)); + stage('sample/documents-option', (db) => _books(db).sample(documents: 10)); + stage('sample/percentage', (db) => _books(db).sample(percentage: 0.25)); + + stage( + 'union/plain', + (db) => _books(db).union(db.pipeline().collection('magazines')), + ); + stage( + 'union/complex', + (db) => _books(db) + .select(['title']) + .union( + db + .pipeline() + .collectionGroup('reviews') + .where(field('rating').greaterThan(3)) + .select(['title']), + ), + ); + + stage('unnest/field', (db) => _books(db).unnest(field('tags')), [ + (db) => _books(db).unnest('tags'), + ]); + stage( + 'unnest/aliased-field', + (db) => _books(db).unnest(field('tags').as('tag')), + ); + stage( + 'unnest/index-field', + (db) => _books(db).unnest(field('tags').as('tag'), indexField: 'index'), + ); + stage( + 'unnest/expression', + (db) => _books( + db, + ).unnest(PipelineFunctions.array([1, 2, field('rating')]).as('item')), + ); + stage( + 'unnest/options-object', + (db) => _books(db).unnest(field('tags').as('tag'), indexField: 'idx'), + ); + + stage( + 'raw-stage/expression-params', + (db) => _books(db).rawStage('where', [field('rating').greaterThan(4)]), + ); + stage('raw-stage/literal-params', (db) => _books(db).rawStage('limit', [10])); + stage( + 'raw-stage/string-params', + (db) => _books(db).rawStage('custom', ['a', 1.5, true, null]), + ); + stage( + 'raw-stage/plain-object-param', + (db) => _books(db).rawStage('select', [ + {'title': field('title'), 'one': 1}, + ]), + ); + stage( + 'raw-stage/nested-plain-object-param', + (db) => _books(db).rawStage('select', [ + { + 'meta': {'lang': field('lang')}, + }, + ]), + ); + stage( + 'raw-stage/array-param', + (db) => _books(db).rawStage('custom', [ + [1, 'a', field('title')], + ]), + ); + stage( + 'raw-stage/aggregate-params', + (db) => _books(db).rawStage('aggregate', [ + { + 'total': PipelineFunctions.countAll(), + 'best': PipelineFunctions.maximum('rating'), + }, + {'genre': field('genre')}, + ]), + ); + stage( + 'raw-stage/options', + (db) => _books(db).rawStage( + 'find_nearest', + [field('embedding'), _vector, 'euclidean'], + options: {'limit': 10, 'distance_field': field('distance')}, + ), + ); + stage( + 'raw-stage/options-dot-notation', + (db) => + _books(db).rawStage('custom', [], options: {'outer.inner': 'value'}), + ); + + // Dart's search stage takes the backend options as-is. + final breakfast = documentMatches('breakfast'); + stage('search/query-string', (db) => _books(db).search({'query': breakfast})); + stage( + 'search/query-expression', + (db) => _books(db).search({'query': breakfast}), + ); + stage( + 'search/limit-offset', + (db) => _books(db).search({'query': breakfast, 'limit': 10, 'offset': 5}), + ); + stage( + 'search/retrieval-depth-language', + (db) => _books(db).search({ + 'query': breakfast, + 'retrieval_depth': 100, + 'language_code': 'en', + }), + ); + stage( + 'search/sort-single', + (db) => _books(db).search({ + 'query': breakfast, + 'sort': [score().descending()], + }), + ); + stage( + 'search/sort-list', + (db) => _books(db).search({ + 'query': breakfast, + 'sort': [score().descending(), field('title').ascending()], + }), + ); + stage( + 'search/add-fields', + (db) => _books(db).search({ + 'query': breakfast, + 'add_fields': {'searchScore': score()}, + }), + ); +} + +void _registerOptions(_Registry r) { + void execute( + String id, + Future Function(Pipeline pipeline) run, + ) { + r.run('options/$id', [(db) => run(_books(db))]); + } + + const analyze = PipelineExplainMode.analyze; + const text = PipelineExplainOutputFormat.text; + + execute('none/plain', (p) => p.execute()); + execute('none/empty-object', (p) => p.execute()); + execute( + 'index-mode/recommended', + (p) => p.execute(indexMode: PipelineIndexMode.recommended), + ); + execute( + 'explain/mode-analyze', + (p) => p.execute(explain: const PipelineExplainOptions(mode: analyze)), + ); + execute( + 'explain/mode-execute', + (p) => p.execute( + explain: const PipelineExplainOptions(mode: PipelineExplainMode.execute), + ), + ); + execute( + 'explain/output-format-text', + (p) => p.execute(explain: const PipelineExplainOptions(outputFormat: text)), + ); + execute( + 'explain/mode-and-output-format', + (p) => p.execute( + explain: const PipelineExplainOptions(mode: analyze, outputFormat: text), + ), + ); + execute( + 'explain/empty', + (p) => p.execute(explain: const PipelineExplainOptions()), + ); + execute( + 'combined/index-mode-and-explain', + (p) => p.execute( + indexMode: PipelineIndexMode.recommended, + explain: const PipelineExplainOptions(mode: analyze, outputFormat: text), + ), + ); + execute( + 'raw-options/flat', + (p) => p.execute(rawOptions: {'foo': 'bar', 'count': 3}), + ); + execute( + 'raw-options/nested-object', + (p) => p.execute( + rawOptions: { + 'explain_options': {'mode': 'analyze'}, + }, + ), + ); + execute( + 'raw-options/dot-notation', + (p) => p.execute(rawOptions: {'explain_options.mode': 'analyze'}), + ); + execute( + 'raw-options/override-known', + (p) => p.execute( + indexMode: PipelineIndexMode.recommended, + rawOptions: {'index_mode': 'custom'}, + ), + ); + execute( + 'raw-options/merge-into-explain', + (p) => p.execute( + explain: const PipelineExplainOptions(mode: analyze), + rawOptions: {'explain_options.output_format': 'text'}, + ), + ); +} + +// ----------------------------------------------------------------------------- +// Values +// ----------------------------------------------------------------------------- + +void _registerValues(_Registry r) { + void selectConstant(String id, Object? value, [List<_Expr> alts = const []]) { + r.expr('values/constant/$id', () => constant(value), alts); + } + + selectConstant('integer', 42); + selectConstant('negative-integer', -7); + selectConstant('zero', 0); + selectConstant('double', 3.14); + selectConstant('nan', double.nan); + selectConstant('infinity', double.infinity); + selectConstant('negative-infinity', double.negativeInfinity); + selectConstant('string', 'Fantasy'); + selectConstant('empty-string', ''); + selectConstant('true', true); + selectConstant('false', false); + selectConstant('null', null); + selectConstant('timestamp', _timestamp); + selectConstant( + 'timestamp-millis', + Timestamp(seconds: 1700000000, nanoseconds: 123000000), + ); + selectConstant('date', _date); + selectConstant('geo-point', _geoPoint); + selectConstant('bytes', _bytes); + r.dbExpr('values/constant/document-reference', (db) => constant(_docRef(db))); + selectConstant('vector', _vector, [ + () => Expression.vector([0.1, 0.2, 0.3]), + ]); + selectConstant('array', [1, 'a', true, null]); + selectConstant('map', { + 'a': 1, + 'b': 'x', + 'nested': { + 'c': [1, 2], + }, + }); + + void selectField(String id, String name) { + r.expr('values/field/$id', () => field(name), [ + () => Expression.field(name), + ]); + } + + selectField('simple', 'title'); + selectField('nested', 'metadata.lang'); + selectField('document-id', '__name__'); + selectField('special-characters', 'first-name'); + selectField('space', 'last name'); + selectField('non-ascii', 'naïve'); + + r.expr('values/variable/plain', () => variable('x'), [ + () => Expression.variable('x'), + ]); + + void equalValue(String id, Object? Function() value) { + r.expr('values/value-position/$id', () => field('value').equal(value())); + } + + equalValue('integer', () => 1); + equalValue('double', () => 1.5); + equalValue('string', () => 'a'); + equalValue('boolean', () => true); + equalValue('null', () => null); + equalValue('timestamp', () => _timestamp); + equalValue('date', () => _date); + equalValue('geo-point', () => _geoPoint); + equalValue('bytes', () => _bytes); + r.dbExpr( + 'values/value-position/document-reference', + (db) => field('value').equal(_docRef(db)), + ); + equalValue('vector', () => _vector); + equalValue('list-plain', () => ['a', 'b']); + equalValue('list-with-expression', () => ['a', field('b')]); + equalValue( + 'list-nested', + () => [ + ['a'], + {'k': 'v'}, + ], + ); + equalValue('list-empty', () => []); + equalValue('map-plain', () => {'a': 1, 'b': 'x'}); + equalValue('map-with-expression', () => {'a': field('x'), 'b': 1}); + equalValue( + 'map-nested', + () => { + 'inner': {'k': field('y')}, + 'list': [field('z')], + }, + ); + equalValue('map-empty', () => {}); + equalValue('constant-list', () => constant(['a', 'b'])); + + r.pipeline('values/data-types/all-constants', (db) { + final docRef = _docRef(db); + return _books(db).limit(1).select([ + constant(1).as('number'), + constant('a string').as('string'), + constant(true).as('boolean'), + constant(null).as('null'), + constant(_geoPoint).as('geoPoint'), + constant(_timestamp).as('timestamp'), + constant(_date).as('date'), + constant(_bytes).as('bytes'), + constant(docRef).as('documentReference'), + constant(_vector).as('vectorValue'), + PipelineFunctions.map([ + 'number', + 1, + 'string', + 'a string', + 'boolean', + true, + 'null', + null, + 'geoPoint', + _geoPoint, + 'timestamp', + _timestamp, + 'date', + _date, + 'uint8Array', + _bytes, + 'documentReference', + docRef, + 'vectorValue', + _vector, + 'map', + {'number': 2, 'string': 'b string'}, + 'array', + [1, 'c string'], + ]).as('map'), + PipelineFunctions.array([ + 1, + 'a string', + true, + null, + _geoPoint, + _timestamp, + _date, + _bytes, + docRef, + _vector, + {'number': 2, 'string': 'b string'}, + ]).as('array'), + ]); + }); + + r.pipeline('values/data-types/array-and-map-functions', (db) { + List metadataArray() => [ + 1, + 2, + field('genre'), + PipelineFunctions.multiply('rating', 10), + [field('title')], + {'published': field('published')}, + ]; + Map metadata() => { + 'genre': field('genre'), + 'rating': PipelineFunctions.multiply('rating', 10), + 'nestedArray': [field('title')], + 'nestedMap': {'published': field('published')}, + }; + + return _books(db) + .sort([field('rating').descending()]) + .limit(1) + .select(['title', 'author', 'genre', 'rating', 'published', 'tags']) + .addFields([ + PipelineFunctions.array(metadataArray()).as('metadataArray'), + PipelineFunctions.map([ + for (final MapEntry(:key, :value) in metadata().entries) ...[ + key, + value, + ], + ]).as('metadata'), + ]) + .where( + and([ + PipelineFunctions.equal('metadataArray', metadataArray()), + PipelineFunctions.equal('metadata', metadata()), + ]), + ); + }); +} + +// ----------------------------------------------------------------------------- +// Functions +// ----------------------------------------------------------------------------- + +void _registerArithmetic(_Registry r) { + r.binary( + 'add', + 'rating', + 1, + () => field('bonus'), + PipelineFunctions.add, + (e, v) => e.add(v), + ); + r.expr('functions/add/method-double', () => field('rating').add(0.5)); + r.binary( + 'subtract', + 'rating', + 1, + () => field('penalty'), + PipelineFunctions.subtract, + (e, v) => e.subtract(v), + ); + r.binary( + 'multiply', + 'rating', + 2, + () => field('weight'), + PipelineFunctions.multiply, + (e, v) => e.multiply(v), + ); + r.binary( + 'divide', + 'rating', + 2, + () => field('count'), + PipelineFunctions.divide, + (e, v) => e.divide(v), + ); + r.binary( + 'mod', + 'rating', + 2, + () => field('divisor'), + PipelineFunctions.mod, + (e, v) => e.mod(v), + ); + r.binary( + 'pow', + 'rating', + 2, + () => field('exponent'), + PipelineFunctions.pow, + (e, v) => e.pow(v), + ); + + r.unary('abs', 'rating', PipelineFunctions.abs, (e) => e.abs()); + r.unary('ceil', 'rating', PipelineFunctions.ceil, (e) => e.ceil()); + r.unary('floor', 'rating', PipelineFunctions.floor, (e) => e.floor()); + r.unary('exp', 'rating', PipelineFunctions.exp, (e) => e.exp()); + r.unary('ln', 'rating', PipelineFunctions.ln, (e) => e.ln()); + r.unary('sqrt', 'rating', PipelineFunctions.sqrt, (e) => e.sqrt()); + r.unary('log10', 'rating', PipelineFunctions.log10, (e) => e.log10()); + + for (final (name, static, method) in [ + ( + 'round', + PipelineFunctions.round, + (PipelineExpression e, [Object? places]) => e.round(places), + ), + ( + 'trunc', + PipelineFunctions.trunc, + (PipelineExpression e, [Object? places]) => e.trunc(places), + ), + ]) { + r.unary(name, 'rating', static, method); + r.expr( + 'functions/$name/static-field-name-places-literal', + () => static('rating', 2), + ); + r.expr( + 'functions/$name/static-expression-places-expression', + () => static(field('rating'), field('places')), + ); + r.expr( + 'functions/$name/method-places-literal', + () => method(field('rating'), 2), + ); + r.expr( + 'functions/$name/method-places-expression', + () => method(field('rating'), field('places')), + ); + } + + r.expr('functions/rand/static', PipelineFunctions.rand); +} + +void _registerComparisonAndLogic(_Registry r) { + for (final (name, static, topLevel, method) + in <(String, _Static2, _Static2, _Method1)>[ + ('equal', PipelineFunctions.equal, equal, (e, v) => e.equal(v)), + ( + 'notEqual', + PipelineFunctions.notEqual, + notEqual, + (e, v) => e.notEqual(v), + ), + ( + 'lessThan', + PipelineFunctions.lessThan, + lessThan, + (e, v) => e.lessThan(v), + ), + ( + 'lessThanOrEqual', + PipelineFunctions.lessThanOrEqual, + lessThanOrEqual, + (e, v) => e.lessThanOrEqual(v), + ), + ( + 'greaterThan', + PipelineFunctions.greaterThan, + greaterThan, + (e, v) => e.greaterThan(v), + ), + ( + 'greaterThanOrEqual', + PipelineFunctions.greaterThanOrEqual, + greaterThanOrEqual, + (e, v) => e.greaterThanOrEqual(v), + ), + ]) { + r.binary( + name, + 'rating', + 4, + () => field('threshold'), + static, + method, + staticAlternatives: [topLevel], + ); + r.expr( + 'functions/$name/static-field-name-string', + () => static('genre', 'Fantasy'), + [() => topLevel('genre', 'Fantasy')], + ); + } + + r.expr( + 'functions/and/static-two', + () => PipelineFunctions.and([_isFantasy(), _isGood()]), + [ + () => and([_isFantasy(), _isGood()]), + ], + ); + r.expr( + 'functions/and/static-three', + () => PipelineFunctions.and([_isFantasy(), _isGood(), _isOld()]), + [ + () => and([_isFantasy(), _isGood(), _isOld()]), + ], + ); + r.expr( + 'functions/or/static-two', + () => PipelineFunctions.or([_isFantasy(), _isGood()]), + [ + () => or([_isFantasy(), _isGood()]), + ], + ); + r.expr( + 'functions/or/static-three', + () => PipelineFunctions.or([_isFantasy(), _isGood(), _isOld()]), + [ + () => or([_isFantasy(), _isGood(), _isOld()]), + ], + ); + r.expr( + 'functions/xor/static-two', + () => PipelineFunctions.xor([_isFantasy(), _isGood()]), + ); + r.expr( + 'functions/xor/static-three', + () => PipelineFunctions.xor([_isFantasy(), _isGood(), _isOld()]), + ); + r.expr( + 'functions/nor/static-two', + () => PipelineFunctions.nor([_isFantasy(), _isGood()]), + ); + r.expr( + 'functions/nor/static-three', + () => PipelineFunctions.nor([_isFantasy(), _isGood(), _isOld()]), + ); + r.expr('functions/not/static', () => PipelineFunctions.not(_isFantasy()), [ + () => not(_isFantasy()), + ]); + r.expr('functions/not/method', () => _isFantasy().not()); + r.expr( + 'functions/conditional/static', + () => PipelineFunctions.conditional( + _isGood(), + field('title'), + constant('n/a'), + ), + ); + r.expr( + 'functions/conditional/method', + () => _isGood().conditional(field('title'), constant('n/a')), + ); + r.expr( + 'functions/switchOn/static-pair', + () => PipelineFunctions.switchOn([_isGood(), constant('good')]), + ); + r.expr( + 'functions/switchOn/static-pairs-and-default', + () => PipelineFunctions.switchOn([ + _isGood(), + constant('good'), + _isOld(), + constant('old'), + constant('other'), + ]), + ); + + r.expr( + 'functions/logicalMaximum/static-field-name-literal', + () => PipelineFunctions.logicalMaximum('rating', 1), + ); + r.expr( + 'functions/logicalMaximum/static-expression-variadic', + () => + PipelineFunctions.logicalMaximum(field('rating'), field('floor'), [3]), + ); + r.expr( + 'functions/logicalMaximum/method-literal', + () => field('rating').logicalMaximum(1), + ); + r.expr( + 'functions/logicalMaximum/method-variadic', + () => field('rating').logicalMaximum(field('floor'), [3]), + ); + r.expr( + 'functions/logicalMinimum/static-field-name-literal', + () => PipelineFunctions.logicalMinimum('rating', 5), + ); + r.expr( + 'functions/logicalMinimum/static-expression-variadic', + () => PipelineFunctions.logicalMinimum(field('rating'), field('ceiling'), [ + 3, + ]), + ); + r.expr( + 'functions/logicalMinimum/method-literal', + () => field('rating').logicalMinimum(5), + ); + r.expr( + 'functions/logicalMinimum/method-variadic', + () => field('rating').logicalMinimum(field('ceiling'), [3]), + ); + + r.unary('exists', 'title', PipelineFunctions.exists, (e) => e.exists()); + r.unary('isAbsent', 'title', PipelineFunctions.isAbsent, (e) => e.isAbsent()); + r.binary( + 'ifAbsent', + 'nickname', + 'anonymous', + () => field('name'), + PipelineFunctions.ifAbsent, + (e, v) => e.ifAbsent(v), + ); + r.binary( + 'ifNull', + 'nickname', + 'anonymous', + () => field('name'), + PipelineFunctions.ifNull, + (e, v) => e.ifNull(v), + ); + + PipelineExpression failing() => field('rating').divide(0); + r.expr( + 'functions/isError/static-expression', + () => PipelineFunctions.isError(failing()), + ); + r.expr('functions/isError/method', () => failing().isError()); + r.expr( + 'functions/ifError/static-expression-literal', + () => PipelineFunctions.ifError(failing(), 0), + ); + r.expr( + 'functions/ifError/static-expression-expression', + () => PipelineFunctions.ifError(failing(), field('fallback')), + ); + // Node types ifError of two booleans as a boolean, hence the where stage. + r.expr( + 'functions/ifError/static-boolean-boolean', + () => PipelineFunctions.ifError(_isGood(), constant(false)).asBoolean(), + ); + r.expr('functions/ifError/method-literal', () => failing().ifError(0)); + r.expr( + 'functions/ifError/method-expression', + () => failing().ifError(field('fallback')), + ); + r.expr( + 'functions/ifError/method-boolean-boolean', + () => _isGood().ifError(constant(false)).asBoolean(), + ); + r.expr( + 'functions/ifError/method-boolean-literal', + () => _isGood().ifError(false).asBoolean(), + ); + + r.expr( + 'functions/coalesce/static-field-name-literal', + () => PipelineFunctions.coalesce('nickname', 'anonymous'), + ); + r.expr( + 'functions/coalesce/static-expression-variadic', + () => PipelineFunctions.coalesce(field('nickname'), field('name'), [ + 'anonymous', + ]), + ); + r.expr( + 'functions/coalesce/method-literal', + () => field('nickname').coalesce('anonymous'), + ); + r.expr( + 'functions/coalesce/method-variadic', + () => field('nickname').coalesce(field('name'), ['anonymous']), + ); +} + +void _registerStrings(_Registry r) { + r.unary( + 'charLength', + 'title', + PipelineFunctions.charLength, + (e) => e.charLength(), + ); + r.unary( + 'byteLength', + 'title', + PipelineFunctions.byteLength, + (e) => e.byteLength(), + ); + r.unary( + 'toLower', + 'title', + PipelineFunctions.toLower, + (e) => e.toLowerCase(), + ); + r.unary( + 'toUpper', + 'title', + PipelineFunctions.toUpper, + (e) => e.toUpperCase(), + ); + r.unary( + 'stringReverse', + 'title', + PipelineFunctions.stringReverse, + (e) => e.stringReverse(), + ); + r.unary('reverse', 'title', PipelineFunctions.reverse, (e) => e.reverse()); + r.unary('length', 'title', PipelineFunctions.length, (e) => e.length()); + + r.binary( + 'like', + 'title', + 'The%', + () => field('pattern'), + PipelineFunctions.like, + (e, v) => e.like(v), + ); + r.binary( + 'regexContains', + 'title', + 'Lord', + () => field('pattern'), + PipelineFunctions.regexContains, + (e, v) => e.regexContains(v), + ); + r.binary( + 'regexMatch', + 'title', + '^The.*', + () => field('pattern'), + PipelineFunctions.regexMatch, + (e, v) => e.regexMatch(v), + ); + r.binary( + 'regexFind', + 'title', + r'[A-Z]\w+', + () => field('pattern'), + PipelineFunctions.regexFind, + (e, v) => e.regexFind(v), + ); + r.binary( + 'regexFindAll', + 'title', + r'[A-Z]\w+', + () => field('pattern'), + PipelineFunctions.regexFindAll, + (e, v) => e.regexFindAll(v), + ); + r.binary( + 'stringContains', + 'title', + 'Ring', + () => field('needle'), + PipelineFunctions.stringContains, + (e, v) => e.stringContains(v), + ); + r.binary( + 'startsWith', + 'title', + 'The', + () => field('prefix'), + PipelineFunctions.startsWith, + (e, v) => e.startsWith(v), + ); + r.binary( + 'endsWith', + 'title', + 'Rings', + () => field('suffix'), + PipelineFunctions.endsWith, + (e, v) => e.endsWith(v), + ); + r.binary( + 'stringIndexOf', + 'title', + 'Ring', + () => field('needle'), + PipelineFunctions.stringIndexOf, + (e, v) => e.stringIndexOf(v), + ); + r.binary( + 'stringRepeat', + 'title', + 2, + () => field('times'), + PipelineFunctions.stringRepeat, + (e, v) => e.stringRepeat(v), + ); + r.binary( + 'split', + 'csv', + ',', + () => field('delimiter'), + PipelineFunctions.split, + (e, v) => e.split(v), + methodAlternatives: [(e, v) => e.splitLiteral(v! as String)], + ); + r.binary( + 'join', + 'tags', + ', ', + () => field('delimiter'), + PipelineFunctions.join, + (e, v) => e.join(v), + methodAlternatives: [(e, v) => e.joinLiteral(v! as String)], + ); + + for (final (name, static, method) in [ + ( + 'trim', + PipelineFunctions.trim, + (PipelineExpression e, [Object? chars]) => e.trim(chars), + ), + ( + 'ltrim', + PipelineFunctions.ltrim, + (PipelineExpression e, [Object? chars]) => e.ltrim(chars), + ), + ( + 'rtrim', + PipelineFunctions.rtrim, + (PipelineExpression e, [Object? chars]) => e.rtrim(chars), + ), + ]) { + r.unary(name, 'title', static, method); + r.expr( + 'functions/$name/static-field-name-literal', + () => static('title', '-'), + ); + r.expr( + 'functions/$name/static-expression-expression', + () => static(field('title'), field('chars')), + ); + r.expr('functions/$name/method-literal', () => method(field('title'), '-')); + r.expr( + 'functions/$name/method-expression', + () => method(field('title'), field('chars')), + ); + } + + r.expr( + 'functions/stringConcat/static-field-name-literal', + () => PipelineFunctions.stringConcat(['title', ' by ']), + ); + r.expr( + 'functions/stringConcat/static-field-name-variadic', + () => PipelineFunctions.stringConcat(['title', ' by ', field('author')]), + ); + r.expr( + 'functions/stringConcat/static-expression-expression', + () => PipelineFunctions.stringConcat([field('title'), field('author')]), + ); + r.expr( + 'functions/stringConcat/method-literal', + () => field('title').stringConcat([' by ']), + ); + r.expr( + 'functions/stringConcat/method-variadic', + () => field('title').stringConcat([' by ', field('author')]), + ); + + r.expr( + 'functions/concat/static-field-name-literal', + () => PipelineFunctions.concat('title', '!'), + ); + r.expr( + 'functions/concat/static-expression-variadic', + () => PipelineFunctions.concat(field('title'), ' by ', [field('author')]), + ); + r.expr('functions/concat/method-literal', () => field('title').concat(['!'])); + r.expr( + 'functions/concat/method-list', + () => field('tags').concat([ + ['extra'], + ]), + ); + r.expr( + 'functions/concat/method-variadic', + () => field('title').concat([' by ', field('author')]), + ); + + for (final (name, static, method, literalMethod) in [ + ( + 'stringReplaceAll', + PipelineFunctions.stringReplaceAll, + (PipelineExpression e, Object? f, Object? t) => e.stringReplaceAll(f, t), + (PipelineExpression e, String f, String t) => + e.stringReplaceAllLiteral(f, t), + ), + ( + 'stringReplaceOne', + PipelineFunctions.stringReplaceOne, + (PipelineExpression e, Object? f, Object? t) => e.stringReplaceOne(f, t), + (PipelineExpression e, String f, String t) => + e.stringReplaceOneLiteral(f, t), + ), + ]) { + r.expr( + 'functions/$name/static-field-name-literals', + () => static('title', 'a', 'b'), + ); + r.expr( + 'functions/$name/static-expression-expressions', + () => static(field('title'), field('find'), field('replacement')), + ); + r.expr( + 'functions/$name/method-literals', + () => method(field('title'), 'a', 'b'), + [() => literalMethod(field('title'), 'a', 'b')], + ); + r.expr( + 'functions/$name/method-expressions', + () => method(field('title'), field('find'), field('replacement')), + ); + } + + r.expr( + 'functions/substring/static-field-name-position', + () => PipelineFunctions.substring('title', 2), + ); + r.expr( + 'functions/substring/static-field-name-position-length', + () => PipelineFunctions.substring('title', 2, 3), + ); + r.expr( + 'functions/substring/static-expression-expressions', + () => PipelineFunctions.substring( + field('title'), + field('start'), + field('count'), + ), + ); + r.expr( + 'functions/substring/method-position', + () => field('title').substring(2), + [() => field('title').substringLiteral(2)], + ); + r.expr( + 'functions/substring/method-position-length', + () => field('title').substring(2, 3), + [() => field('title').substringLiteral(2, 3)], + ); + r.expr( + 'functions/substring/method-expressions', + () => field('title').substring(field('start'), field('count')), + ); +} + +void _registerArrays(_Registry r) { + void array(String variant, List Function() values) { + r.expr( + 'functions/array/$variant', + () => PipelineFunctions.array(values()), + [() => Expression.array(values())], + ); + } + + array('static-literals', () => [1, 'a', true, null]); + array('static-with-expressions', () => [field('title'), 1]); + array( + 'static-nested', + () => [ + [1, field('a')], + {'k': field('b')}, + ], + ); + array('static-empty', () => []); + + r.expr( + 'functions/arrayConcat/static-field-name-list', + () => PipelineFunctions.arrayConcat([ + 'tags', + ['x', 'y'], + ]), + ); + r.expr( + 'functions/arrayConcat/static-field-name-expression', + () => PipelineFunctions.arrayConcat(['tags', field('moreTags')]), + ); + r.expr( + 'functions/arrayConcat/static-expression-variadic', + () => PipelineFunctions.arrayConcat([ + field('tags'), + field('moreTags'), + ['z'], + ]), + ); + r.expr( + 'functions/arrayConcat/method-list', + () => field('tags').arrayConcat(['x', 'y']), + ); + r.expr( + 'functions/arrayConcat/method-expression', + () => field('tags').arrayConcat(field('moreTags')), + ); + r.expr( + 'functions/arrayConcat/method-variadic', + () => field('tags').arrayConcatMultiple([ + field('moreTags'), + ['z'], + ]), + ); + + r.binary( + 'arrayContains', + 'tags', + 'magic', + () => field('wanted'), + PipelineFunctions.arrayContains, + (e, v) => e.arrayContains(v), + ); + + for (final (name, target, values, withExpression, nested, static, method) in [ + ( + 'arrayContainsAll', + 'tags', + ['magic', 'epic'], + () => ['magic', field('wanted')], + [ + 'magic', + ['epic'], + ], + PipelineFunctions.arrayContainsAll, + (PipelineExpression e, Object? v) => switch (v) { + Iterable() => e.arrayContainsAll(v), + _ => e.arrayContainsAllFrom(v), + }, + ), + ( + 'arrayContainsAny', + 'tags', + ['magic', 'epic'], + () => ['magic', field('wanted')], + [ + 'magic', + ['epic'], + ], + PipelineFunctions.arrayContainsAny, + // PipelineExpression.arrayContainsAny only takes a list; the + // PipelineFunctions form takes an array expression. + (PipelineExpression e, Object? v) => switch (v) { + Iterable() => e.arrayContainsAny(v), + _ => PipelineFunctions.arrayContainsAny(e, v), + }, + ), + ( + 'equalAny', + 'genre', + ['Fantasy', 'Sci-Fi'], + () => ['Fantasy', field('favorite')], + [ + 'Fantasy', + ['Sci-Fi'], + ], + PipelineFunctions.equalAny, + (PipelineExpression e, Object? v) => e.equalAny(v), + ), + ( + 'notEqualAny', + 'genre', + ['Fantasy', 'Sci-Fi'], + () => ['Fantasy', field('favorite')], + [ + 'Fantasy', + ['Sci-Fi'], + ], + PipelineFunctions.notEqualAny, + (PipelineExpression e, Object? v) => e.notEqualAny(v), + ), + ]) { + final arrayExpression = target == 'tags' ? 'wanted' : 'genres'; + r.expr( + 'functions/$name/static-field-name-list', + () => static(target, values), + ); + r.expr( + 'functions/$name/static-field-name-list-with-expression', + () => static(target, withExpression()), + ); + r.expr( + 'functions/$name/static-field-name-list-nested', + () => static(target, nested), + ); + r.expr( + 'functions/$name/static-field-name-array-expression', + () => static(target, field(arrayExpression)), + ); + r.expr( + 'functions/$name/static-expression-list', + () => static(field(target), values), + ); + r.expr('functions/$name/method-list', () => method(field(target), values)); + r.expr( + 'functions/$name/method-list-with-expression', + () => method(field(target), withExpression()), + ); + r.expr( + 'functions/$name/method-array-expression', + () => method(field(target), field(arrayExpression)), + ); + } + + r.unary( + 'arrayLength', + 'tags', + PipelineFunctions.arrayLength, + (e) => e.arrayLength(), + ); + r.unary( + 'arrayFirst', + 'tags', + PipelineFunctions.arrayFirst, + (e) => e.arrayFirst(), + ); + r.unary( + 'arrayLast', + 'tags', + PipelineFunctions.arrayLast, + (e) => e.arrayLast(), + ); + r.unary( + 'arrayReverse', + 'tags', + PipelineFunctions.arrayReverse, + (e) => e.arrayReverse(), + ); + r.unary('arraySum', 'tags', PipelineFunctions.arraySum, (e) => e.arraySum()); + r.unary( + 'arrayMaximum', + 'tags', + PipelineFunctions.arrayMaximum, + (e) => e.arrayMaximum(), + ); + r.unary( + 'arrayMinimum', + 'tags', + PipelineFunctions.arrayMinimum, + (e) => e.arrayMinimum(), + ); + + r.binary( + 'arrayFirstN', + 'scores', + 2, + () => field('n'), + PipelineFunctions.arrayFirstN, + (e, v) => e.arrayFirstN(v), + ); + r.binary( + 'arrayLastN', + 'scores', + 2, + () => field('n'), + PipelineFunctions.arrayLastN, + (e, v) => e.arrayLastN(v), + ); + r.binary( + 'arrayMaximumN', + 'scores', + 2, + () => field('n'), + PipelineFunctions.arrayMaximumN, + (e, v) => e.arrayMaximumN(v), + ); + r.binary( + 'arrayMinimumN', + 'scores', + 2, + () => field('n'), + PipelineFunctions.arrayMinimumN, + (e, v) => e.arrayMinimumN(v), + ); + r.binary( + 'arrayGet', + 'tags', + 1, + () => field('index'), + PipelineFunctions.arrayGet, + (e, v) => e.arrayGet(v), + ); + r.binary( + 'arrayIndexOf', + 'tags', + 'magic', + () => field('wanted'), + PipelineFunctions.arrayIndexOf, + (e, v) => e.arrayIndexOf(v), + ); + r.binary( + 'arrayLastIndexOf', + 'tags', + 'magic', + () => field('wanted'), + PipelineFunctions.arrayLastIndexOf, + (e, v) => e.arrayLastIndexOf(v), + ); + r.binary( + 'arrayIndexOfAll', + 'tags', + 'magic', + () => field('wanted'), + PipelineFunctions.arrayIndexOfAll, + (e, v) => e.arrayIndexOfAll(v), + ); + + r.expr( + 'functions/arraySlice/static-field-name-offset-length', + () => PipelineFunctions.arraySlice('tags', 1, 2), + ); + r.expr( + 'functions/arraySlice/static-expression-expressions', + () => PipelineFunctions.arraySlice( + field('tags'), + field('start'), + field('count'), + ), + ); + r.expr( + 'functions/arraySlice/method-offset', + () => field('tags').arraySlice(1), + ); + r.expr( + 'functions/arraySlice/method-offset-length', + () => field('tags').arraySlice(1, 2), + ); + r.expr( + 'functions/arraySlice/method-expressions', + () => field('tags').arraySlice(field('start'), field('count')), + ); + + PipelineBooleanExpression isMagic() => variable('tag').equal('magic'); + r.expr( + 'functions/arrayFilter/static-field-name', + () => PipelineFunctions.arrayFilter('tags', 'tag', isMagic()), + ); + r.expr( + 'functions/arrayFilter/static-expression', + () => PipelineFunctions.arrayFilter(field('tags'), 'tag', isMagic()), + ); + r.expr( + 'functions/arrayFilter/method', + () => field('tags').arrayFilter('tag', isMagic()), + ); + + PipelineExpression upper() => variable('tag').toUpperCase(); + r.expr( + 'functions/arrayTransform/static-field-name', + () => PipelineFunctions.arrayTransform('tags', 'tag', upper()), + ); + r.expr( + 'functions/arrayTransform/static-expression', + () => PipelineFunctions.arrayTransform(field('tags'), 'tag', upper()), + ); + r.expr( + 'functions/arrayTransform/method', + () => field('tags').arrayTransform('tag', upper()), + ); + + PipelineExpression indexed() => variable('tag').stringConcat([variable('i')]); + r.expr( + 'functions/arrayTransformWithIndex/static-field-name', + () => PipelineFunctions.arrayTransform('tags', 'tag', indexed(), 'i'), + ); + r.expr( + 'functions/arrayTransformWithIndex/static-expression', + () => + PipelineFunctions.arrayTransform(field('tags'), 'tag', indexed(), 'i'), + ); + r.expr( + 'functions/arrayTransformWithIndex/method', + () => field('tags').arrayTransformWithIndex('tag', 'i', indexed()), + ); +} + +void _registerMaps(_Registry r) { + r.expr( + 'functions/map/static-literals', + () => PipelineFunctions.map(['a', 1, 'b', 'x']), + ); + r.expr( + 'functions/map/static-with-expressions', + () => PipelineFunctions.map(['a', field('title'), 'b', 1]), + ); + r.expr( + 'functions/map/static-nested', + () => PipelineFunctions.map([ + 'inner', + {'k': field('y')}, + 'list', + [field('z'), 1], + ]), + ); + r.expr('functions/map/static-empty', () => PipelineFunctions.map([])); + + r.expr( + 'functions/mapGet/static-field-name', + () => PipelineFunctions.mapGet('metadata', 'lang'), + ); + r.expr( + 'functions/mapGet/static-expression', + () => PipelineFunctions.mapGet(field('metadata'), 'lang'), + ); + r.expr('functions/mapGet/method', () => field('metadata').mapGet('lang'), [ + () => field('metadata').mapGetLiteral('lang'), + ]); + + r.expr( + 'functions/mapSet/static-field-name-literal', + () => PipelineFunctions.mapSet('metadata', ['lang', 'en']), + ); + r.expr( + 'functions/mapSet/static-expression-variadic', + () => PipelineFunctions.mapSet(field('metadata'), [ + 'lang', + 'en', + 'pages', + field('pageCount'), + ]), + ); + r.expr( + 'functions/mapSet/method-literal', + () => field('metadata').mapSet('lang', 'en'), + ); + r.expr( + 'functions/mapSet/method-expression-key', + () => field('metadata').mapSet(field('key'), field('value')), + ); + r.expr( + 'functions/mapSet/method-variadic', + () => field('metadata').mapSet('lang', 'en', ['pages', field('pageCount')]), + ); + + r.binary( + 'mapRemove', + 'metadata', + 'lang', + () => field('key'), + PipelineFunctions.mapRemove, + (e, v) => e.mapRemove(v), + ); + + r.expr( + 'functions/mapMerge/static-field-name-map', + () => PipelineFunctions.mapMerge([ + 'metadata', + {'extra': 1}, + ]), + ); + r.expr( + 'functions/mapMerge/static-expression-expression', + () => PipelineFunctions.mapMerge([field('metadata'), field('overrides')]), + ); + r.expr( + 'functions/mapMerge/static-field-name-variadic', + () => PipelineFunctions.mapMerge([ + 'metadata', + {'extra': field('title')}, + field('overrides'), + ]), + ); + r.expr( + 'functions/mapMerge/method-map', + () => field('metadata').mapMerge([ + {'extra': 1}, + ]), + ); + r.expr( + 'functions/mapMerge/method-variadic', + () => field('metadata').mapMerge([ + {'extra': field('title')}, + field('overrides'), + ]), + ); + + r.unary('mapKeys', 'metadata', PipelineFunctions.mapKeys, (e) => e.mapKeys()); + r.unary( + 'mapValues', + 'metadata', + PipelineFunctions.mapValues, + (e) => e.mapValues(), + ); + r.unary( + 'mapEntries', + 'metadata', + PipelineFunctions.mapEntries, + (e) => e.mapEntries(), + ); + r.binary( + 'getField', + 'metadata', + 'lang', + () => field('key'), + PipelineFunctions.getField, + (e, v) => e.getField(v), + ); +} + +void _registerTimestamps(_Registry r) { + r.unary( + 'unixMicrosToTimestamp', + 'published', + PipelineFunctions.unixMicrosToTimestamp, + (e) => e.unixMicrosToTimestamp(), + ); + r.unary( + 'unixMillisToTimestamp', + 'published', + PipelineFunctions.unixMillisToTimestamp, + (e) => e.unixMillisToTimestamp(), + ); + r.unary( + 'unixSecondsToTimestamp', + 'published', + PipelineFunctions.unixSecondsToTimestamp, + (e) => e.unixSecondsToTimestamp(), + ); + r.unary( + 'timestampToUnixMicros', + 'published', + PipelineFunctions.timestampToUnixMicros, + (e) => e.timestampToUnixMicros(), + ); + r.unary( + 'timestampToUnixMillis', + 'published', + PipelineFunctions.timestampToUnixMillis, + (e) => e.timestampToUnixMillis(), + ); + r.unary( + 'timestampToUnixSeconds', + 'published', + PipelineFunctions.timestampToUnixSeconds, + (e) => e.timestampToUnixSeconds(), + ); + + for (final (name, static, method) in [ + ( + 'timestampAdd', + PipelineFunctions.timestampAdd, + (PipelineExpression e, Object? unit, Object? amount) => + e.timestampAdd(unit, amount), + ), + ( + 'timestampSubtract', + PipelineFunctions.timestampSubtract, + (PipelineExpression e, Object? unit, Object? amount) => + e.timestampSubtract(unit, amount), + ), + ]) { + r.expr( + 'functions/$name/static-field-name-literals', + () => static('published', 'day', 1), + ); + r.expr( + 'functions/$name/static-expression-literals', + () => static(field('published'), 'hour', 2), + ); + r.expr( + 'functions/$name/static-expression-expressions', + () => static(field('published'), field('unit'), field('amount')), + ); + r.expr( + 'functions/$name/method-literals', + () => method(field('published'), 'minute', 30), + ); + r.expr( + 'functions/$name/method-expressions', + () => method(field('published'), field('unit'), field('amount')), + ); + } + + for (final (name, static, method, literal, timezoneLiteral, methodLiteral) + in [ + ( + 'timestampTruncate', + PipelineFunctions.timestampTruncate, + (PipelineExpression e, Object? unit, [Object? timezone]) => + e.timestampTruncate(unit, timezone), + 'day', + 'week(monday)', + 'year', + ), + ( + 'timestampExtract', + PipelineFunctions.timestampExtract, + (PipelineExpression e, Object? part, [Object? timezone]) => + e.timestampExtract(part, timezone), + 'year', + 'dayofweek', + 'dayofyear', + ), + ]) { + final unit = name == 'timestampTruncate' ? 'granularity' : 'part'; + final literalWithTimezone = name == 'timestampTruncate' ? 'day' : 'hour'; + r.expr( + 'functions/$name/static-field-name-literal', + () => static('published', literal), + ); + r.expr( + 'functions/$name/static-field-name-literal-timezone', + () => static('published', timezoneLiteral, 'America/New_York'), + ); + r.expr( + 'functions/$name/static-expression-expression', + () => static(field('published'), field(unit)), + ); + r.expr( + 'functions/$name/static-expression-timezone-expression', + () => static(field('published'), 'month', field('tz')), + ); + r.expr( + 'functions/$name/method-literal', + () => method(field('published'), methodLiteral), + ); + r.expr( + 'functions/$name/method-literal-timezone', + () => method(field('published'), literalWithTimezone, 'UTC'), + ); + r.expr( + 'functions/$name/method-expression-timezone-expression', + () => method(field('published'), field(unit), field('tz')), + ); + } + + r.expr( + 'functions/timestampDiff/static-field-names', + () => PipelineFunctions.timestampDiff('end', 'start', 'day'), + ); + r.expr( + 'functions/timestampDiff/static-field-name-expression', + () => PipelineFunctions.timestampDiff('end', field('start'), 'second'), + ); + r.expr( + 'functions/timestampDiff/static-expression-field-name', + () => PipelineFunctions.timestampDiff(field('end'), 'start', 'hour'), + ); + r.expr( + 'functions/timestampDiff/static-expressions-unit-expression', + () => PipelineFunctions.timestampDiff( + field('end'), + field('start'), + field('unit'), + ), + ); + r.expr( + 'functions/timestampDiff/method-field-name', + () => field('end').timestampDiff('start', 'day'), + ); + r.expr( + 'functions/timestampDiff/method-expressions', + () => field('end').timestampDiff(field('start'), field('unit')), + ); + r.expr( + 'functions/currentTimestamp/static', + PipelineFunctions.currentTimestamp, + ); +} + +void _registerTypesVectorsAndReferences(_Registry r) { + r.unary('type', 'value', PipelineFunctions.type, (e) => e.type()); + r.expr( + 'functions/isType/static-field-name', + () => PipelineFunctions.isType('value', PipelineValueType.string), + [() => PipelineFunctions.isType('value', 'string')], + ); + r.expr( + 'functions/isType/static-expression', + () => PipelineFunctions.isType(field('value'), PipelineValueType.string), + [() => PipelineFunctions.isType(field('value'), 'string')], + ); + for (final (name, type) in [ + ('null', PipelineValueType.nullValue), + ('array', PipelineValueType.array), + ('boolean', PipelineValueType.boolean), + ('bytes', PipelineValueType.bytes), + ('timestamp', PipelineValueType.timestamp), + ('geo_point', PipelineValueType.geoPoint), + ('number', PipelineValueType.number), + ('int32', PipelineValueType.int32), + ('int64', PipelineValueType.int64), + ('float64', PipelineValueType.double), + ('decimal128', PipelineValueType.decimal128), + ('map', PipelineValueType.map), + ('reference', PipelineValueType.reference), + ('string', PipelineValueType.string), + ('vector', PipelineValueType.vector), + ('max_key', PipelineValueType.maxKey), + ('min_key', PipelineValueType.minKey), + ('object_id', PipelineValueType.objectId), + ('regex', PipelineValueType.regex), + // No PipelineValueType member: isType takes the raw name. + ('request_timestamp', null), + ]) { + final id = 'functions/isType/method-${name.replaceAll('_', '-')}'; + if (type == null) { + r.expr(id, () => field('value').isType(name)); + } else { + r.expr(id, () => field('value').isType(type), [ + () => field('value').isType(name), + ]); + } + } + + for (final (name, static, method) in [ + ( + 'cosineDistance', + PipelineFunctions.cosineDistance, + (PipelineExpression e, Object? v) => e.cosineDistance(v), + ), + ( + 'dotProduct', + PipelineFunctions.dotProduct, + (PipelineExpression e, Object? v) => e.dotProduct(v), + ), + ( + 'euclideanDistance', + PipelineFunctions.euclideanDistance, + (PipelineExpression e, Object? v) => e.euclideanDistance(v), + ), + ]) { + r.expr( + 'functions/$name/static-field-name-number-array', + () => static('embedding', const [0.1, 0.2, 0.3]), + ); + r.expr( + 'functions/$name/static-field-name-integer-array', + () => static('embedding', const [1, 2, 3]), + ); + r.expr( + 'functions/$name/static-field-name-vector-value', + () => static('embedding', _vector), + [ + () => static('embedding', Expression.vector([0.1, 0.2, 0.3])), + ], + ); + r.expr( + 'functions/$name/static-field-name-expression', + () => static('embedding', field('other')), + ); + r.expr( + 'functions/$name/static-expression-number-array', + () => static(field('embedding'), const [0.1, 0.2, 0.3]), + ); + r.expr( + 'functions/$name/static-expression-expression', + () => static(field('embedding'), field('other')), + ); + r.expr( + 'functions/$name/method-number-array', + () => method(field('embedding'), const [0.1, 0.2, 0.3]), + ); + r.expr( + 'functions/$name/method-vector-value', + () => method(field('embedding'), _vector), + ); + r.expr( + 'functions/$name/method-expression', + () => method(field('embedding'), field('other')), + ); + } + r.unary( + 'vectorLength', + 'embedding', + PipelineFunctions.vectorLength, + (e) => e.vectorLength(), + ); + + r.expr( + 'functions/geoDistance/static-field-name-geo-point', + () => PipelineFunctions.geoDistance('location', _geoPoint), + ); + r.expr( + 'functions/geoDistance/static-field-geo-point', + () => PipelineFunctions.geoDistance(field('location'), _geoPoint), + ); + r.expr( + 'functions/geoDistance/static-field-name-expression', + () => PipelineFunctions.geoDistance('location', field('other')), + ); + r.expr( + 'functions/geoDistance/method-geo-point', + () => field('location').geoDistance(_geoPoint), + ); + r.expr( + 'functions/geoDistance/method-expression', + () => field('location').geoDistance(field('other')), + ); + + r.unary( + 'collectionId', + '__name__', + PipelineFunctions.collectionId, + (e) => e.collectionId(), + ); + for (final (name, static, method) in [ + ( + 'documentId', + PipelineFunctions.documentId, + (PipelineExpression e) => e.documentId(), + ), + ('parent', PipelineFunctions.parent, (PipelineExpression e) => e.parent()), + ]) { + r.expr('functions/$name/static-path', () => static('books/book1')); + r.dbExpr('functions/$name/static-reference', (db) => static(_docRef(db))); + r.expr( + 'functions/$name/static-expression', + () => static(field('__name__')), + ); + r.expr('functions/$name/method', () => method(field('__name__'))); + } + + r.expr( + 'functions/currentDocument/static', + PipelineFunctions.currentDocument, + [currentDocument], + ); + r.expr( + 'functions/documentMatches/static-literal', + () => PipelineFunctions.documentMatches('breakfast'), + [() => documentMatches('breakfast')], + ); + r.expr( + 'functions/documentMatches/static-expression', + () => PipelineFunctions.documentMatches(field('query')), + [() => documentMatches(field('query'))], + ); + r.expr('functions/score/static', PipelineFunctions.score, [score]); +} + +void _registerAggregates(_Registry r) { + r.aggregate('aggregates/countAll/static', PipelineFunctions.countAll, [ + PipelineFunctions.count, + ]); + for (final (name, static, method) in <(String, _Static1, _Method0)>[ + ('count', PipelineFunctions.count, (e) => e.count()), + ('sum', PipelineFunctions.sum, (e) => e.sum()), + ('average', PipelineFunctions.average, (e) => e.average()), + ('minimum', PipelineFunctions.minimum, (e) => e.minimum()), + ('maximum', PipelineFunctions.maximum, (e) => e.maximum()), + ('first', PipelineFunctions.first, (e) => e.first()), + ('last', PipelineFunctions.last, (e) => e.last()), + ('arrayAgg', PipelineFunctions.arrayAgg, (e) => e.arrayAgg()), + ( + 'arrayAggDistinct', + PipelineFunctions.arrayAggDistinct, + (e) => e.arrayAggDistinct(), + ), + ( + 'countDistinct', + PipelineFunctions.countDistinct, + (e) => e.countDistinct(), + ), + ]) { + r.unary(name, 'rating', static, method, area: 'aggregates'); + } + r.aggregate( + 'aggregates/countIf/static', + () => PipelineFunctions.countIf(_isGood()), + ); + r.aggregate('aggregates/countIf/method', () => _isGood().countIf()); +} diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/.pubignore b/packages/google_cloud_firestore/tool/pipeline_golden/.pubignore new file mode 100644 index 00000000..ecfd1949 --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_golden/.pubignore @@ -0,0 +1,2 @@ +# The Node.js fixture generator is a development tool, not part of the package. +* diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/README.md b/packages/google_cloud_firestore/tool/pipeline_golden/README.md new file mode 100644 index 00000000..8a49d341 --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_golden/README.md @@ -0,0 +1,101 @@ +# Pipeline golden fixtures + +`test/pipeline_golden_test.dart` checks that every Firestore Pipeline request +this package sends is identical to the one the Node.js SDK +(`@google-cloud/firestore`, pinned in `package.json`) sends for the same +Pipeline. The expected requests come from the Node SDK itself, never from this +package, so a test cannot pass by asserting our own output. + +This directory holds the Node side: `cases.js` builds a corpus of named +Pipelines with the Node SDK, and `generate.js` writes the request Node sends for +each one to `test/fixtures/pipeline_golden/.json`. + +## Regenerating + +Requires Node.js 22 or later. From this directory: + +```sh +npm ci +npm run generate +``` + +Then run `dart test test/pipeline_golden_test.dart` from the package root. + +Regenerate when you: + +- **add or change a case** in `cases.js` (add the matching Dart case to + `test/pipeline_golden_test.dart` in the same change), or +- **bump the pinned Node SDK**: change the exact version in `package.json`, + run `npm install` to update `package-lock.json`, regenerate, and review the + fixture diff; it is the list of wire-format changes Node made. + +Never edit the fixtures by hand. CI (`.github/workflows/pipeline_golden.yml`) +regenerates them and fails if the committed files are stale. + +## How capture works + +Nothing touches the network. `generate.js` creates a `Firestore` for project +`test-project`, database `(default)` (the ids the Dart test uses), and replaces +`Firestore.requestStream`, the single call the SDK makes to run +`executePipeline`, with a stub that records the request and returns an empty +response stream. Everything before it is the SDK's own code: +`Pipeline.execute(options)`, user data validation and `StructuredPipeline` +serialization. + +The recorded `ExecutePipelineRequest` is converted to proto3 JSON the way +google-gax does for the REST transport (`Type.fromObject` + `toProto3JSON`, +using the SDK's own `protobufjs`, protos and `proto3-json-serializer`). That is +the same encoding the Dart SDK sends, via +`ExecutePipelineRequest.toJson()`. + +Both sides then apply the same normalization before comparing: + +- object keys are sorted (`name` first, for readability); protobuf maps are + unordered, +- empty `args`, `options`, `fields` and `values` are dropped: proto3 JSON omits + empty repeated and map fields, and protobufjs keeps some (`options: {}` on + every stage). An empty `mapValue` or `arrayValue` is kept: it is a value. + +Numbers compare by value, so a `doubleValue` of `1` (JavaScript) equals `1.0` +(Dart). Every value is deterministic: fixed timestamps, no generated IDs. + +## The corpus + +Case ids are `//`, and each area has its own fixture +file: + +| Area | Covers | +| --- | --- | +| `sources` | `collection` (path, nested path, reference), `collectionGroup`, `database`, `documents` | +| `queries` | `createFrom` of collection, collection-group and vector queries: every filter operator, composite filters, orderings, implicit orderings, limits, `limitToLast`, offsets, cursors, projections | +| `stages` | every stage Node exposes, with each argument shape and option | +| `options` | `execute` options: `indexMode`, `explainOptions`, `rawOptions` | +| `values` | constants of every type, field paths, variables, plain values and collections in value positions | +| `functions` | every Node Pipeline function | +| `aggregates` | every aggregate function | + +Function variants follow one scheme. `static-...` calls the top-level function +(`PipelineFunctions.x`, or a top-level helper such as `equal`, in Dart) and +`method-...` the `Expression` method (`PipelineExpression` in Dart). +`field-name` is a field passed as a string, `expression` an `Expression`, +`literal` a plain value. Functions are wrapped in the smallest Pipeline that +takes them: `aggregate(fn.as('result'))`, `where(fn)` for booleans, otherwise +`select(fn.as('result'))`. + +`generate.js` fails if any top-level function or `Expression` method the Node +SDK exports has no case, so a Node upgrade that adds a function also adds a +gap to fill. + +## When Dart and Node differ + +Every fixture id needs a Dart case in `test/pipeline_golden_test.dart`, and +every Dart case a fixture. A difference that is not listed fails the test with +a path-by-path diff. Three lists in the test document the exceptions, and each +entry is itself checked: + +- `_knownDivergences`: Dart deliberately sends something else. The entry's + check asserts the intended Dart shape. +- `_pendingFixes`: an unintended difference awaiting a fix. The test fails + once it matches Node, so the entry gets removed. +- `_dartApiGaps`: Node APIs Dart cannot express. The test fails if a Dart case + is added for one. diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js new file mode 100644 index 00000000..7b335c99 --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js @@ -0,0 +1,1291 @@ +// 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. + +'use strict'; + +/** + * The golden corpus: named Pipelines built with the Node.js SDK. + * + * Every case id must have a matching entry in + * `test/pipeline_golden_test.dart`, which builds the same Pipeline with the + * Dart SDK and compares the requests. + * + * Id scheme: `//`. + * + * - `static-...` variants call the top-level function + * (`PipelineFunctions.x` or a top-level helper in Dart), `method-...` + * variants call the `Expression` method (`PipelineExpression` in Dart). + * - `field-name` is a field passed as a plain string, `expression` an + * `Expression`, `literal` a plain value. + * + * Function cases are wrapped in a minimal Pipeline over `books`: + * aggregate functions in `aggregate(.as('result'))`, boolean + * expressions in `where()`, and everything else in + * `select(.as('result'))`. + * + * @param {object} sdk The loaded `@google-cloud/firestore` module. + * @param {FirebaseFirestore.Firestore} db The Firestore instance under test. + * @returns {Array<{id: string, pipeline: () => object, options?: object}>} + */ +module.exports = function buildCases(sdk, db) { + const {FieldPath, FieldValue, Filter, GeoPoint, Timestamp, Pipelines: P} = + sdk; + const {field, constant, variable} = P; + + const cases = []; + const ids = new Set(); + + /** Registers a case whose Pipeline is built lazily by [pipeline]. */ + function add(id, pipeline, options) { + if (ids.has(id)) throw new Error(`Duplicate case id: ${id}`); + ids.add(id); + cases.push({id, pipeline, options}); + } + + const books = () => db.pipeline().collection('books'); + + /** Wraps an expression in the smallest Pipeline that accepts it. */ + function wrap(expr) { + if (expr instanceof P.AggregateFunction) { + return books().aggregate(expr.as('result')); + } + if (expr instanceof P.BooleanExpression) { + return books().where(expr); + } + return books().select(expr.as('result')); + } + + /** Registers one function case per entry of [variants]. */ + function fn(area, name, variants) { + for (const [variant, makeExpr] of Object.entries(variants)) { + add(`${area}/${name}/${variant}`, () => wrap(makeExpr())); + } + } + + /** + * A function of one target, accepted as a field name or an expression. + * + * `static-field-name`: `fn('target')`, `static-expression`: + * `fn(field('target'))`, `method`: `field('target').method()`. + */ + function unary(name, target, {area = 'functions', method = name} = {}) { + fn(area, name, { + 'static-field-name': () => P[name](target), + 'static-expression': () => P[name](field(target)), + method: () => field(target)[method](), + }); + } + + /** + * A function of a target and a second operand. + * + * The target is a field name or an expression; the operand is [literal] + * or the expression built by [operand]. + */ + function binary(name, target, literal, operand, {method = name} = {}) { + fn('functions', name, { + 'static-field-name-literal': () => P[name](target, literal), + 'static-field-name-expression': () => P[name](target, operand()), + 'static-expression-literal': () => P[name](field(target), literal), + 'static-expression-expression': () => P[name](field(target), operand()), + 'method-literal': () => field(target)[method](literal), + 'method-expression': () => field(target)[method](operand()), + }); + } + + // Fixed, deterministic values. + const timestamp = new Timestamp(1700000000, 123456789); + const date = new Date(Date.UTC(2024, 0, 2, 3, 4, 5, 678)); + const geoPoint = new GeoPoint(37.7749, -122.4194); + const bytes = new Uint8Array([1, 2, 3, 255]); + const vector = FieldValue.vector([0.1, 0.2, 0.3]); + const docRef = () => db.doc('books/book1'); + + // --------------------------------------------------------------------------- + // Sources + // --------------------------------------------------------------------------- + + add('sources/collection/path', () => db.pipeline().collection('books')); + add('sources/collection/nested-path', () => + db.pipeline().collection('authors/author1/books'), + ); + add('sources/collection/reference', () => + db.pipeline().collection(db.collection('books')), + ); + add('sources/collection/force-index', () => + db.pipeline().collection({collection: 'books', forceIndex: 'my_index'}), + ); + add('sources/collection-group/id', () => + db.pipeline().collectionGroup('reviews'), + ); + add('sources/collection-group/force-index', () => + db + .pipeline() + .collectionGroup({collectionId: 'reviews', forceIndex: 'my_index'}), + ); + add('sources/database/plain', () => db.pipeline().database()); + add('sources/documents/paths', () => + db.pipeline().documents(['books/book1', 'books/book2']), + ); + add('sources/documents/references', () => + db.pipeline().documents([db.doc('books/book1'), db.doc('books/book2')]), + ); + add('sources/documents/nested', () => + db.pipeline().documents([db.doc('authors/author1/books/book1')]), + ); + add('sources/subcollection/as-array-expression', () => + db + .pipeline() + .collection('books') + .addFields( + P.subcollection('reviews') + .select('rating') + .toArrayExpression() + .as('reviews'), + ), + ); + + // --------------------------------------------------------------------------- + // createFrom(query) + // --------------------------------------------------------------------------- + + const fromQuery = query => () => db.pipeline().createFrom(query()); + const col = () => db.collection('books'); + const query = (id, q) => add(`queries/${id}`, fromQuery(q)); + + query('collection/plain', () => col()); + query('collection/nested-path', () => db.collection('authors/author1/books')); + query('collection-group/plain', () => db.collectionGroup('reviews')); + query('collection-group/where', () => + db.collectionGroup('reviews').where('rating', '>', 3), + ); + + query('where/less-than', () => col().where('rating', '<', 4)); + query('where/less-than-or-equal', () => col().where('rating', '<=', 4)); + query('where/equal', () => col().where('genre', '==', 'Fantasy')); + query('where/not-equal', () => col().where('genre', '!=', 'Fantasy')); + query('where/greater-than-or-equal', () => col().where('rating', '>=', 4)); + query('where/greater-than', () => col().where('rating', '>', 4)); + query('where/array-contains', () => + col().where('tags', 'array-contains', 'magic'), + ); + query('where/in', () => col().where('genre', 'in', ['Fantasy', 'Sci-Fi'])); + query('where/not-in', () => + col().where('genre', 'not-in', ['Fantasy', 'Sci-Fi']), + ); + query('where/array-contains-any', () => + col().where('tags', 'array-contains-any', ['magic', 'epic']), + ); + query('where/equal-null', () => col().where('genre', '==', null)); + query('where/not-equal-null', () => col().where('genre', '!=', null)); + query('where/equal-nan', () => col().where('rating', '==', NaN)); + query('where/double', () => col().where('rating', '>', 4.5)); + query('where/timestamp', () => col().where('published', '<', timestamp)); + query('where/nested-field', () => + col().where('metadata.lang', '==', 'en'), + ); + query('where/special-characters', () => + col().where('first-name', '==', 'Ada'), + ); + query('where/document-id', () => + col().where(FieldPath.documentId(), '==', 'book1'), + ); + query('where/multiple', () => + col().where('genre', '==', 'Fantasy').where('rating', '>', 4), + ); + query('where/composite-and', () => + col().where( + Filter.and( + Filter.where('genre', '==', 'Fantasy'), + Filter.where('rating', '>', 4), + ), + ), + ); + query('where/composite-or', () => + col().where( + Filter.or( + Filter.where('genre', '==', 'Fantasy'), + Filter.where('rating', '>', 4), + ), + ), + ); + query('where/composite-nested', () => + col().where( + Filter.or( + Filter.and( + Filter.where('genre', '==', 'Fantasy'), + Filter.where('rating', '>', 4), + ), + Filter.where('published', '<', 1950), + ), + ), + ); + + query('order-by/ascending', () => col().orderBy('rating')); + query('order-by/descending', () => col().orderBy('rating', 'desc')); + query('order-by/multiple', () => + col().orderBy('rating', 'desc').orderBy('title'), + ); + query('order-by/document-id', () => col().orderBy(FieldPath.documentId())); + query('order-by/special-characters', () => col().orderBy('first-name')); + query('order-by/implicit-from-inequality', () => + col().where('rating', '>', 4), + ); + query('order-by/implicit-from-inequalities', () => + col().where('rating', '>', 4).where('published', '<', 2000), + ); + query('order-by/explicit-and-inequality', () => + col().where('rating', '>', 4).orderBy('title', 'desc'), + ); + + query('limit/with-order-by', () => col().orderBy('rating').limit(5)); + query('limit/without-order-by', () => col().limit(5)); + query('limit-to-last/plain', () => col().orderBy('rating').limitToLast(5)); + query('offset/plain', () => col().offset(10)); + query('offset/with-limit', () => col().orderBy('rating').limit(5).offset(10)); + + query('cursor/start-at', () => col().orderBy('rating').startAt(4)); + query('cursor/start-after', () => col().orderBy('rating').startAfter(4)); + query('cursor/end-at', () => col().orderBy('rating').endAt(4)); + query('cursor/end-before', () => col().orderBy('rating').endBefore(4)); + query('cursor/multiple-fields', () => + col().orderBy('rating').orderBy('title').startAt(4, 'M'), + ); + query('cursor/start-and-end', () => + col().orderBy('rating').startAfter(2).endAt(4), + ); + query('cursor/limit-to-last', () => + col().orderBy('rating').startAt(2).endBefore(5).limitToLast(3), + ); + + query('select/fields', () => col().select('title', 'author')); + query('select/empty', () => col().select()); + + const vectorQuery = (id, makeQuery) => query(`vector/${id}`, makeQuery); + const nearest = (options, base = col()) => + base.findNearest({ + vectorField: 'embedding', + queryVector: [0.1, 0.2, 0.3], + limit: 5, + distanceMeasure: 'EUCLIDEAN', + ...options, + }); + vectorQuery('euclidean', () => nearest({})); + vectorQuery('cosine', () => nearest({distanceMeasure: 'COSINE'})); + vectorQuery('dot-product', () => nearest({distanceMeasure: 'DOT_PRODUCT'})); + vectorQuery('vector-value', () => nearest({queryVector: vector})); + vectorQuery('with-prefilter', () => + nearest({}, col().where('genre', '==', 'Fantasy')), + ); + vectorQuery('distance-result-field', () => + nearest({distanceResultField: 'distance'}), + ); + vectorQuery('distance-threshold', () => nearest({distanceThreshold: 0.5})); + + // --------------------------------------------------------------------------- + // Stages + // --------------------------------------------------------------------------- + + const stage = (id, pipeline) => add(`stages/${id}`, pipeline); + + stage('where/condition', () => + books().where(field('rating').greaterThan(4)), + ); + stage('where/chained', () => + books() + .where(field('rating').greaterThan(4)) + .where(field('genre').equal('Fantasy')), + ); + + stage('select/field-names', () => books().select('title', 'author')); + stage('select/fields', () => + books().select(field('title'), field('author')), + ); + stage('select/aliased', () => + books().select( + field('title').as('name'), + field('rating').multiply(2).as('doubled'), + ), + ); + stage('select/mixed', () => + books().select('title', field('author'), field('rating').as('score')), + ); + stage('select/nested-field-path', () => + books().select('metadata.lang', field('awards.hugo')), + ); + stage('select/special-characters', () => + books().select('first-name', field('last name')), + ); + stage('select/options-object', () => + books().select({selections: ['title', field('rating').as('score')]}), + ); + stage('select/raw-options', () => + books().select({selections: ['title'], rawOptions: {foo: 'bar'}}), + ); + + stage('add-fields/single', () => + books().addFields(field('rating').as('copiedRating')), + ); + stage('add-fields/multiple', () => + books().addFields( + field('rating').as('copiedRating'), + constant(true).as('annotated'), + ), + ); + stage('add-fields/field', () => books().addFields(field('rating'))); + + stage('remove-fields/field-names', () => + books().removeFields('title', 'author'), + ); + stage('remove-fields/fields', () => + books().removeFields(field('title'), field('author')), + ); + stage('remove-fields/nested', () => books().removeFields('metadata.lang')); + + stage('sort/ascending-method', () => books().sort(field('rating').ascending())); + stage('sort/descending-method', () => + books().sort(field('rating').descending()), + ); + stage('sort/multiple', () => + books().sort(field('rating').descending(), field('title').ascending()), + ); + stage('sort/top-level-field-name', () => + books().sort(P.ascending('rating'), P.descending('title')), + ); + stage('sort/top-level-expression', () => + books().sort( + P.ascending(field('rating')), + P.descending(field('title').charLength()), + ), + ); + stage('sort/expression-method', () => + books().sort(field('title').charLength().descending()), + ); + + stage('offset/plain', () => books().offset(10)); + stage('limit/plain', () => books().limit(10)); + stage('limit/zero', () => books().limit(0)); + + stage('distinct/field-names', () => books().distinct('genre', 'author')); + stage('distinct/fields', () => books().distinct(field('genre'))); + stage('distinct/aliased', () => + books().distinct(field('genre').toLower().as('lowerGenre')), + ); + + stage('aggregate/single', () => books().aggregate(P.countAll().as('total'))); + stage('aggregate/multiple', () => + books().aggregate( + P.countAll().as('total'), + P.average('rating').as('averageRating'), + ), + ); + stage('aggregate/groups-field-names', () => + books().aggregate({ + accumulators: [P.countAll().as('total')], + groups: ['genre'], + }), + ); + stage('aggregate/groups-mixed', () => + books().aggregate({ + accumulators: [ + P.countAll().as('total'), + P.maximum('rating').as('best'), + ], + groups: [ + 'genre', + field('author'), + field('published').divide(10).floor().as('decade'), + ], + }), + ); + + const findNearest = options => + books().findNearest({ + field: 'embedding', + vectorValue: [0.1, 0.2, 0.3], + distanceMeasure: 'euclidean', + ...options, + }); + stage('find-nearest/number-array', () => findNearest({})); + stage('find-nearest/integer-array', () => + findNearest({vectorValue: [1, 2, 3]}), + ); + stage('find-nearest/vector-value', () => findNearest({vectorValue: vector})); + stage('find-nearest/field', () => findNearest({field: field('embedding')})); + stage('find-nearest/cosine', () => findNearest({distanceMeasure: 'cosine'})); + stage('find-nearest/dot-product', () => + findNearest({distanceMeasure: 'dot_product'}), + ); + stage('find-nearest/limit', () => findNearest({limit: 10})); + stage('find-nearest/distance-field', () => + findNearest({distanceField: 'distance'}), + ); + stage('find-nearest/all-options', () => + findNearest({limit: 10, distanceField: 'distance'}), + ); + + stage('replace-with/field-name', () => books().replaceWith('metadata')); + stage('replace-with/field', () => books().replaceWith(field('metadata'))); + stage('replace-with/map-expression', () => + books().replaceWith( + P.map({title: field('title'), rating: field('rating')}), + ), + ); + + stage('sample/documents', () => books().sample(10)); + stage('sample/documents-option', () => books().sample({documents: 10})); + stage('sample/percentage', () => books().sample({percentage: 0.25})); + + stage('union/plain', () => + books().union(db.pipeline().collection('magazines')), + ); + stage('union/complex', () => + books() + .select('title') + .union( + db + .pipeline() + .collectionGroup('reviews') + .where(field('rating').greaterThan(3)) + .select('title'), + ), + ); + + stage('unnest/field', () => books().unnest(field('tags'))); + stage('unnest/aliased-field', () => books().unnest(field('tags').as('tag'))); + stage('unnest/index-field', () => + books().unnest(field('tags').as('tag'), 'index'), + ); + stage('unnest/expression', () => + books().unnest(P.array([1, 2, field('rating')]).as('item')), + ); + stage('unnest/options-object', () => + books().unnest({selectable: field('tags').as('tag'), indexField: 'idx'}), + ); + + stage('raw-stage/expression-params', () => + books().rawStage('where', [field('rating').greaterThan(4)]), + ); + stage('raw-stage/literal-params', () => books().rawStage('limit', [10])); + stage('raw-stage/string-params', () => + books().rawStage('custom', ['a', 1.5, true, null]), + ); + stage('raw-stage/plain-object-param', () => + books().rawStage('select', [{title: field('title'), one: 1}]), + ); + stage('raw-stage/nested-plain-object-param', () => + books().rawStage('select', [{meta: {lang: field('lang')}}]), + ); + stage('raw-stage/array-param', () => + books().rawStage('custom', [[1, 'a', field('title')]]), + ); + stage('raw-stage/aggregate-params', () => + books().rawStage('aggregate', [ + {total: P.countAll(), best: P.maximum('rating')}, + {genre: field('genre')}, + ]), + ); + stage('raw-stage/options', () => + books().rawStage( + 'find_nearest', + [field('embedding'), vector, 'euclidean'], + {limit: 10, distance_field: field('distance')}, + ), + ); + stage('raw-stage/options-dot-notation', () => + books().rawStage('custom', [], {'outer.inner': 'value'}), + ); + + stage('search/query-string', () => books().search({query: 'breakfast'})); + stage('search/query-expression', () => + books().search({query: P.documentMatches('breakfast')}), + ); + stage('search/limit-offset', () => + books().search({query: 'breakfast', limit: 10, offset: 5}), + ); + stage('search/retrieval-depth-language', () => + books().search({ + query: 'breakfast', + retrievalDepth: 100, + languageCode: 'en', + }), + ); + stage('search/sort-single', () => + books().search({query: 'breakfast', sort: P.score().descending()}), + ); + stage('search/sort-list', () => + books().search({ + query: 'breakfast', + sort: [P.score().descending(), field('title').ascending()], + }), + ); + stage('search/add-fields', () => + books().search({ + query: 'breakfast', + addFields: [P.score().as('searchScore')], + }), + ); + + stage('define/plain', () => + books().define(field('rating').as('currentRating')), + ); + stage('delete/plain', () => books().delete()); + stage('update/plain', () => + books().update([field('rating').add(1).as('rating')]), + ); + stage('to-scalar-expression/plain', () => + books().addFields( + db + .pipeline() + .collection('reviews') + .aggregate(P.average('rating').as('avg')) + .toScalarExpression() + .as('averageRating'), + ), + ); + + // --------------------------------------------------------------------------- + // Execute options + // --------------------------------------------------------------------------- + + const execute = (id, options) => add(`options/${id}`, books, options); + + execute('none/plain', undefined); + execute('none/empty-object', {}); + execute('index-mode/recommended', {indexMode: 'recommended'}); + execute('explain/mode-analyze', {explainOptions: {mode: 'analyze'}}); + execute('explain/mode-execute', {explainOptions: {mode: 'execute'}}); + execute('explain/output-format-text', {explainOptions: {outputFormat: 'text'}}); + execute('explain/mode-and-output-format', { + explainOptions: {mode: 'analyze', outputFormat: 'text'}, + }); + execute('explain/empty', {explainOptions: {}}); + execute('combined/index-mode-and-explain', { + indexMode: 'recommended', + explainOptions: {mode: 'analyze', outputFormat: 'text'}, + }); + execute('raw-options/flat', {rawOptions: {foo: 'bar', count: 3}}); + execute('raw-options/nested-object', { + rawOptions: {explain_options: {mode: 'analyze'}}, + }); + execute('raw-options/dot-notation', { + rawOptions: {'explain_options.mode': 'analyze'}, + }); + execute('raw-options/override-known', { + indexMode: 'recommended', + rawOptions: {index_mode: 'custom'}, + }); + execute('raw-options/merge-into-explain', { + explainOptions: {mode: 'analyze'}, + rawOptions: {'explain_options.output_format': 'text'}, + }); + + // --------------------------------------------------------------------------- + // Values: constants, fields, variables and plain values in value positions + // --------------------------------------------------------------------------- + + const selectConstant = (id, value) => + add(`values/constant/${id}`, () => + books().select(constant(value).as('result')), + ); + selectConstant('integer', 42); + selectConstant('negative-integer', -7); + selectConstant('zero', 0); + selectConstant('double', 3.14); + selectConstant('nan', NaN); + selectConstant('infinity', Infinity); + selectConstant('negative-infinity', -Infinity); + selectConstant('string', 'Fantasy'); + selectConstant('empty-string', ''); + selectConstant('true', true); + selectConstant('false', false); + selectConstant('null', null); + selectConstant('timestamp', timestamp); + selectConstant('timestamp-millis', new Timestamp(1700000000, 123000000)); + selectConstant('date', date); + selectConstant('geo-point', geoPoint); + selectConstant('bytes', bytes); + add('values/constant/document-reference', () => + books().select(constant(docRef()).as('result')), + ); + selectConstant('vector', vector); + selectConstant('array', [1, 'a', true, null]); + selectConstant('map', {a: 1, b: 'x', nested: {c: [1, 2]}}); + + const selectField = (id, name) => + add(`values/field/${id}`, () => books().select(field(name).as('result'))); + selectField('simple', 'title'); + selectField('nested', 'metadata.lang'); + selectField('document-id', '__name__'); + selectField('special-characters', 'first-name'); + selectField('space', 'last name'); + selectField('non-ascii', 'naïve'); + add('values/field/field-path-object', () => + books().select(field(new FieldPath('a.b', 'c')).as('result')), + ); + + add('values/variable/plain', () => + books().select(variable('x').as('result')), + ); + + // A plain value in a value position, carried by `equal`. + const equalValue = (id, makeValue) => + add(`values/value-position/${id}`, () => + books().where(field('value').equal(makeValue())), + ); + equalValue('integer', () => 1); + equalValue('double', () => 1.5); + equalValue('string', () => 'a'); + equalValue('boolean', () => true); + equalValue('null', () => null); + equalValue('timestamp', () => timestamp); + equalValue('date', () => date); + equalValue('geo-point', () => geoPoint); + equalValue('bytes', () => bytes); + equalValue('document-reference', docRef); + equalValue('vector', () => vector); + equalValue('list-plain', () => ['a', 'b']); + equalValue('list-with-expression', () => ['a', field('b')]); + equalValue('list-nested', () => [['a'], {k: 'v'}]); + equalValue('list-empty', () => []); + equalValue('map-plain', () => ({a: 1, b: 'x'})); + equalValue('map-with-expression', () => ({a: field('x'), b: 1})); + equalValue('map-nested', () => ({inner: {k: field('y')}, list: [field('z')]})); + equalValue('map-empty', () => ({})); + equalValue('constant-list', () => constant(['a', 'b'])); + + // The "supported data types" Node system test. + add('values/data-types/all-constants', () => + books() + .limit(1) + .select( + constant(1).as('number'), + constant('a string').as('string'), + constant(true).as('boolean'), + constant(null).as('null'), + constant(geoPoint).as('geoPoint'), + constant(timestamp).as('timestamp'), + constant(date).as('date'), + constant(bytes).as('bytes'), + constant(docRef()).as('documentReference'), + constant(vector).as('vectorValue'), + P.map({ + number: 1, + string: 'a string', + boolean: true, + null: null, + geoPoint, + timestamp, + date, + uint8Array: bytes, + documentReference: docRef(), + vectorValue: vector, + map: {number: 2, string: 'b string'}, + array: [1, 'c string'], + }).as('map'), + P.array([ + 1, + 'a string', + true, + null, + geoPoint, + timestamp, + date, + bytes, + docRef(), + vector, + {number: 2, string: 'b string'}, + ]).as('array'), + ), + ); + // The "converts arrays and plain objects to functionValues" Node system + // test. + add('values/data-types/array-and-map-functions', () => + books() + .sort(field('rating').descending()) + .limit(1) + .select('title', 'author', 'genre', 'rating', 'published', 'tags') + .addFields( + P.array([ + 1, + 2, + field('genre'), + P.multiply('rating', 10), + [field('title')], + {published: field('published')}, + ]).as('metadataArray'), + P.map({ + genre: field('genre'), + rating: P.multiply('rating', 10), + nestedArray: [field('title')], + nestedMap: {published: field('published')}, + }).as('metadata'), + ) + .where( + P.and( + P.equal('metadataArray', [ + 1, + 2, + field('genre'), + P.multiply('rating', 10), + [field('title')], + {published: field('published')}, + ]), + P.equal('metadata', { + genre: field('genre'), + rating: P.multiply('rating', 10), + nestedArray: [field('title')], + nestedMap: {published: field('published')}, + }), + ), + ), + ); + + // --------------------------------------------------------------------------- + // Functions: arithmetic + // --------------------------------------------------------------------------- + + binary('add', 'rating', 1, () => field('bonus')); + fn('functions', 'add', { + 'method-double': () => field('rating').add(0.5), + 'method-variadic': () => field('rating').add(1, field('bonus')), + }); + binary('subtract', 'rating', 1, () => field('penalty')); + binary('multiply', 'rating', 2, () => field('weight')); + fn('functions', 'multiply', { + 'method-variadic': () => field('rating').multiply(2, field('weight')), + }); + binary('divide', 'rating', 2, () => field('count')); + binary('mod', 'rating', 2, () => field('divisor')); + binary('pow', 'rating', 2, () => field('exponent')); + for (const name of ['abs', 'ceil', 'floor', 'exp', 'ln', 'sqrt', 'log10']) { + unary(name, 'rating'); + } + for (const name of ['round', 'trunc']) { + unary(name, 'rating'); + fn('functions', name, { + 'static-field-name-places-literal': () => P[name]('rating', 2), + 'static-expression-places-expression': () => + P[name](field('rating'), field('places')), + 'method-places-literal': () => field('rating')[name](2), + 'method-places-expression': () => field('rating')[name](field('places')), + }); + } + fn('functions', 'rand', {static: () => P.rand()}); + + // --------------------------------------------------------------------------- + // Functions: comparison + // --------------------------------------------------------------------------- + + for (const name of [ + 'equal', + 'notEqual', + 'lessThan', + 'lessThanOrEqual', + 'greaterThan', + 'greaterThanOrEqual', + ]) { + binary(name, 'rating', 4, () => field('threshold')); + fn('functions', name, { + 'static-field-name-string': () => P[name]('genre', 'Fantasy'), + }); + } + + // --------------------------------------------------------------------------- + // Functions: logical + // --------------------------------------------------------------------------- + + const isFantasy = () => field('genre').equal('Fantasy'); + const isGood = () => field('rating').greaterThan(4); + const isOld = () => field('published').lessThan(1950); + + for (const name of ['and', 'or', 'xor', 'nor']) { + fn('functions', name, { + 'static-two': () => P[name](isFantasy(), isGood()), + 'static-three': () => P[name](isFantasy(), isGood(), isOld()), + }); + } + fn('functions', 'not', { + static: () => P.not(isFantasy()), + method: () => isFantasy().not(), + }); + fn('functions', 'conditional', { + static: () => P.conditional(isGood(), field('title'), constant('n/a')), + method: () => isGood().conditional(field('title'), constant('n/a')), + }); + fn('functions', 'switchOn', { + 'static-pair': () => P.switchOn(isGood(), constant('good')), + 'static-pairs-and-default': () => + P.switchOn( + isGood(), + constant('good'), + isOld(), + constant('old'), + constant('other'), + ), + }); + fn('functions', 'logicalMaximum', { + 'static-field-name-literal': () => P.logicalMaximum('rating', 1), + 'static-expression-variadic': () => + P.logicalMaximum(field('rating'), field('floor'), 3), + 'method-literal': () => field('rating').logicalMaximum(1), + 'method-variadic': () => field('rating').logicalMaximum(field('floor'), 3), + }); + fn('functions', 'logicalMinimum', { + 'static-field-name-literal': () => P.logicalMinimum('rating', 5), + 'static-expression-variadic': () => + P.logicalMinimum(field('rating'), field('ceiling'), 3), + 'method-literal': () => field('rating').logicalMinimum(5), + 'method-variadic': () => + field('rating').logicalMinimum(field('ceiling'), 3), + }); + + // --------------------------------------------------------------------------- + // Functions: existence, null and error handling + // --------------------------------------------------------------------------- + + unary('exists', 'title'); + unary('isAbsent', 'title'); + binary('ifAbsent', 'nickname', 'anonymous', () => field('name')); + binary('ifNull', 'nickname', 'anonymous', () => field('name')); + const failing = () => field('rating').divide(0); + fn('functions', 'isError', { + 'static-expression': () => P.isError(failing()), + method: () => failing().isError(), + }); + fn('functions', 'ifError', { + 'static-expression-literal': () => P.ifError(failing(), 0), + 'static-expression-expression': () => + P.ifError(failing(), field('fallback')), + 'static-boolean-boolean': () => P.ifError(isGood(), constant(false)), + 'method-literal': () => failing().ifError(0), + 'method-expression': () => failing().ifError(field('fallback')), + 'method-boolean-boolean': () => isGood().ifError(constant(false)), + 'method-boolean-literal': () => isGood().ifError(false), + }); + fn('functions', 'coalesce', { + 'static-field-name-literal': () => P.coalesce('nickname', 'anonymous'), + 'static-expression-variadic': () => + P.coalesce(field('nickname'), field('name'), 'anonymous'), + 'method-literal': () => field('nickname').coalesce('anonymous'), + 'method-variadic': () => + field('nickname').coalesce(field('name'), 'anonymous'), + }); + + // --------------------------------------------------------------------------- + // Functions: strings + // --------------------------------------------------------------------------- + + for (const name of [ + 'charLength', + 'byteLength', + 'toLower', + 'toUpper', + 'stringReverse', + 'reverse', + 'length', + ]) { + unary(name, 'title'); + } + binary('like', 'title', 'The%', () => field('pattern')); + binary('regexContains', 'title', 'Lord', () => field('pattern')); + binary('regexMatch', 'title', '^The.*', () => field('pattern')); + binary('regexFind', 'title', '[A-Z]\\w+', () => field('pattern')); + binary('regexFindAll', 'title', '[A-Z]\\w+', () => field('pattern')); + binary('stringContains', 'title', 'Ring', () => field('needle')); + binary('startsWith', 'title', 'The', () => field('prefix')); + binary('endsWith', 'title', 'Rings', () => field('suffix')); + binary('stringIndexOf', 'title', 'Ring', () => field('needle')); + binary('stringRepeat', 'title', 2, () => field('times')); + binary('split', 'csv', ',', () => field('delimiter')); + binary('join', 'tags', ', ', () => field('delimiter')); + for (const name of ['trim', 'ltrim', 'rtrim']) { + unary(name, 'title'); + fn('functions', name, { + 'static-field-name-literal': () => P[name]('title', '-'), + 'static-expression-expression': () => + P[name](field('title'), field('chars')), + 'method-literal': () => field('title')[name]('-'), + 'method-expression': () => field('title')[name](field('chars')), + }); + } + fn('functions', 'stringConcat', { + 'static-field-name-literal': () => P.stringConcat('title', ' by '), + 'static-field-name-variadic': () => + P.stringConcat('title', ' by ', field('author')), + 'static-expression-expression': () => + P.stringConcat(field('title'), field('author')), + 'method-literal': () => field('title').stringConcat(' by '), + 'method-variadic': () => field('title').stringConcat(' by ', field('author')), + }); + fn('functions', 'concat', { + 'static-field-name-literal': () => P.concat('title', '!'), + 'static-expression-variadic': () => + P.concat(field('title'), ' by ', field('author')), + 'method-literal': () => field('title').concat('!'), + 'method-list': () => field('tags').concat(['extra']), + 'method-variadic': () => field('title').concat(' by ', field('author')), + }); + for (const name of ['stringReplaceAll', 'stringReplaceOne']) { + fn('functions', name, { + 'static-field-name-literals': () => P[name]('title', 'a', 'b'), + 'static-expression-expressions': () => + P[name](field('title'), field('find'), field('replacement')), + 'method-literals': () => field('title')[name]('a', 'b'), + 'method-expressions': () => + field('title')[name](field('find'), field('replacement')), + }); + } + fn('functions', 'substring', { + 'static-field-name-position': () => P.substring('title', 2), + 'static-field-name-position-length': () => P.substring('title', 2, 3), + 'static-expression-expressions': () => + P.substring(field('title'), field('start'), field('count')), + 'method-position': () => field('title').substring(2), + 'method-position-length': () => field('title').substring(2, 3), + 'method-expressions': () => + field('title').substring(field('start'), field('count')), + }); + + // --------------------------------------------------------------------------- + // Functions: arrays + // --------------------------------------------------------------------------- + + fn('functions', 'array', { + 'static-literals': () => P.array([1, 'a', true, null]), + 'static-with-expressions': () => P.array([field('title'), 1]), + 'static-nested': () => P.array([[1, field('a')], {k: field('b')}]), + 'static-empty': () => P.array([]), + }); + fn('functions', 'arrayConcat', { + 'static-field-name-list': () => P.arrayConcat('tags', ['x', 'y']), + 'static-field-name-expression': () => + P.arrayConcat('tags', field('moreTags')), + 'static-expression-variadic': () => + P.arrayConcat(field('tags'), field('moreTags'), ['z']), + 'method-list': () => field('tags').arrayConcat(['x', 'y']), + 'method-expression': () => field('tags').arrayConcat(field('moreTags')), + 'method-variadic': () => + field('tags').arrayConcat(field('moreTags'), ['z']), + }); + binary('arrayContains', 'tags', 'magic', () => field('wanted')); + for (const name of ['arrayContainsAll', 'arrayContainsAny']) { + fn('functions', name, { + 'static-field-name-list': () => P[name]('tags', ['magic', 'epic']), + 'static-field-name-list-with-expression': () => + P[name]('tags', ['magic', field('wanted')]), + 'static-field-name-list-nested': () => P[name]('tags', ['magic', ['epic']]), + 'static-field-name-array-expression': () => + P[name]('tags', field('wanted')), + 'static-expression-list': () => P[name](field('tags'), ['magic', 'epic']), + 'method-list': () => field('tags')[name](['magic', 'epic']), + 'method-list-with-expression': () => + field('tags')[name](['magic', field('wanted')]), + 'method-array-expression': () => field('tags')[name](field('wanted')), + }); + } + for (const name of ['equalAny', 'notEqualAny']) { + fn('functions', name, { + 'static-field-name-list': () => P[name]('genre', ['Fantasy', 'Sci-Fi']), + 'static-field-name-list-with-expression': () => + P[name]('genre', ['Fantasy', field('favorite')]), + 'static-field-name-list-nested': () => + P[name]('genre', ['Fantasy', ['Sci-Fi']]), + 'static-field-name-array-expression': () => + P[name]('genre', field('genres')), + 'static-expression-list': () => + P[name](field('genre'), ['Fantasy', 'Sci-Fi']), + 'method-list': () => field('genre')[name](['Fantasy', 'Sci-Fi']), + 'method-list-with-expression': () => + field('genre')[name](['Fantasy', field('favorite')]), + 'method-array-expression': () => field('genre')[name](field('genres')), + }); + } + for (const name of [ + 'arrayLength', + 'arrayFirst', + 'arrayLast', + 'arrayReverse', + 'arraySum', + 'arrayMaximum', + 'arrayMinimum', + ]) { + unary(name, 'tags'); + } + for (const name of [ + 'arrayFirstN', + 'arrayLastN', + 'arrayMaximumN', + 'arrayMinimumN', + ]) { + binary(name, 'scores', 2, () => field('n')); + } + binary('arrayGet', 'tags', 1, () => field('index')); + binary('arrayIndexOf', 'tags', 'magic', () => field('wanted')); + binary('arrayLastIndexOf', 'tags', 'magic', () => field('wanted')); + binary('arrayIndexOfAll', 'tags', 'magic', () => field('wanted')); + fn('functions', 'arraySlice', { + 'static-field-name-offset': () => P.arraySlice('tags', 1), + 'static-field-name-offset-length': () => P.arraySlice('tags', 1, 2), + 'static-expression-expressions': () => + P.arraySlice(field('tags'), field('start'), field('count')), + 'method-offset': () => field('tags').arraySlice(1), + 'method-offset-length': () => field('tags').arraySlice(1, 2), + 'method-expressions': () => + field('tags').arraySlice(field('start'), field('count')), + }); + const isMagic = () => variable('tag').equal('magic'); + fn('functions', 'arrayFilter', { + 'static-field-name': () => P.arrayFilter('tags', 'tag', isMagic()), + 'static-expression': () => P.arrayFilter(field('tags'), 'tag', isMagic()), + method: () => field('tags').arrayFilter('tag', isMagic()), + }); + const upper = () => variable('tag').toUpper(); + fn('functions', 'arrayTransform', { + 'static-field-name': () => P.arrayTransform('tags', 'tag', upper()), + 'static-expression': () => P.arrayTransform(field('tags'), 'tag', upper()), + method: () => field('tags').arrayTransform('tag', upper()), + }); + const indexed = () => variable('tag').stringConcat(variable('i')); + fn('functions', 'arrayTransformWithIndex', { + 'static-field-name': () => + P.arrayTransformWithIndex('tags', 'tag', 'i', indexed()), + 'static-expression': () => + P.arrayTransformWithIndex(field('tags'), 'tag', 'i', indexed()), + method: () => field('tags').arrayTransformWithIndex('tag', 'i', indexed()), + }); + + // --------------------------------------------------------------------------- + // Functions: maps + // --------------------------------------------------------------------------- + + fn('functions', 'map', { + 'static-literals': () => P.map({a: 1, b: 'x'}), + 'static-with-expressions': () => P.map({a: field('title'), b: 1}), + 'static-nested': () => + P.map({inner: {k: field('y')}, list: [field('z'), 1]}), + 'static-empty': () => P.map({}), + }); + fn('functions', 'mapGet', { + 'static-field-name': () => P.mapGet('metadata', 'lang'), + 'static-expression': () => P.mapGet(field('metadata'), 'lang'), + method: () => field('metadata').mapGet('lang'), + }); + fn('functions', 'mapSet', { + 'static-field-name-literal': () => P.mapSet('metadata', 'lang', 'en'), + 'static-expression-variadic': () => + P.mapSet(field('metadata'), 'lang', 'en', 'pages', field('pageCount')), + 'method-literal': () => field('metadata').mapSet('lang', 'en'), + 'method-expression-key': () => + field('metadata').mapSet(field('key'), field('value')), + 'method-variadic': () => + field('metadata').mapSet('lang', 'en', 'pages', field('pageCount')), + }); + binary('mapRemove', 'metadata', 'lang', () => field('key')); + fn('functions', 'mapMerge', { + 'static-field-name-map': () => P.mapMerge('metadata', {extra: 1}), + 'static-expression-expression': () => + P.mapMerge(field('metadata'), field('overrides')), + 'static-field-name-variadic': () => + P.mapMerge('metadata', {extra: field('title')}, field('overrides')), + 'method-map': () => field('metadata').mapMerge({extra: 1}), + 'method-variadic': () => + field('metadata').mapMerge({extra: field('title')}, field('overrides')), + }); + for (const name of ['mapKeys', 'mapValues', 'mapEntries']) { + unary(name, 'metadata'); + } + binary('getField', 'metadata', 'lang', () => field('key')); + + // --------------------------------------------------------------------------- + // Functions: timestamps + // --------------------------------------------------------------------------- + + for (const name of [ + 'unixMicrosToTimestamp', + 'unixMillisToTimestamp', + 'unixSecondsToTimestamp', + 'timestampToUnixMicros', + 'timestampToUnixMillis', + 'timestampToUnixSeconds', + ]) { + unary(name, 'published'); + } + for (const name of ['timestampAdd', 'timestampSubtract']) { + fn('functions', name, { + 'static-field-name-literals': () => P[name]('published', 'day', 1), + 'static-expression-literals': () => + P[name](field('published'), 'hour', 2), + 'static-expression-expressions': () => + P[name](field('published'), field('unit'), field('amount')), + 'method-literals': () => field('published')[name]('minute', 30), + 'method-expressions': () => + field('published')[name](field('unit'), field('amount')), + }); + } + fn('functions', 'timestampTruncate', { + 'static-field-name-literal': () => P.timestampTruncate('published', 'day'), + 'static-field-name-literal-timezone': () => + P.timestampTruncate('published', 'week(monday)', 'America/New_York'), + 'static-expression-expression': () => + P.timestampTruncate(field('published'), field('granularity')), + 'static-expression-timezone-expression': () => + P.timestampTruncate(field('published'), 'month', field('tz')), + 'method-literal': () => field('published').timestampTruncate('year'), + 'method-literal-timezone': () => + field('published').timestampTruncate('day', 'UTC'), + 'method-expression-timezone-expression': () => + field('published').timestampTruncate(field('granularity'), field('tz')), + }); + fn('functions', 'timestampExtract', { + 'static-field-name-literal': () => P.timestampExtract('published', 'year'), + 'static-field-name-literal-timezone': () => + P.timestampExtract('published', 'dayofweek', 'America/New_York'), + 'static-expression-expression': () => + P.timestampExtract(field('published'), field('part')), + 'static-expression-timezone-expression': () => + P.timestampExtract(field('published'), 'month', field('tz')), + 'method-literal': () => field('published').timestampExtract('dayofyear'), + 'method-literal-timezone': () => + field('published').timestampExtract('hour', 'UTC'), + 'method-expression-timezone-expression': () => + field('published').timestampExtract(field('part'), field('tz')), + }); + fn('functions', 'timestampDiff', { + 'static-field-names': () => P.timestampDiff('end', 'start', 'day'), + 'static-field-name-expression': () => + P.timestampDiff('end', field('start'), 'second'), + 'static-expression-field-name': () => + P.timestampDiff(field('end'), 'start', 'hour'), + 'static-expressions-unit-expression': () => + P.timestampDiff(field('end'), field('start'), field('unit')), + 'method-field-name': () => field('end').timestampDiff('start', 'day'), + 'method-expressions': () => + field('end').timestampDiff(field('start'), field('unit')), + }); + fn('functions', 'currentTimestamp', {static: () => P.currentTimestamp()}); + + // --------------------------------------------------------------------------- + // Functions: types + // --------------------------------------------------------------------------- + + unary('type', 'value'); + fn('functions', 'isType', { + 'static-field-name': () => P.isType('value', 'string'), + 'static-expression': () => P.isType(field('value'), 'string'), + }); + for (const type of [ + 'null', + 'array', + 'boolean', + 'bytes', + 'timestamp', + 'geo_point', + 'number', + 'int32', + 'int64', + 'float64', + 'decimal128', + 'map', + 'reference', + 'string', + 'vector', + 'max_key', + 'min_key', + 'object_id', + 'regex', + 'request_timestamp', + ]) { + fn('functions', 'isType', { + [`method-${type.replace(/_/g, '-')}`]: () => field('value').isType(type), + }); + } + + // --------------------------------------------------------------------------- + // Functions: vectors and geo + // --------------------------------------------------------------------------- + + for (const name of ['cosineDistance', 'dotProduct', 'euclideanDistance']) { + fn('functions', name, { + 'static-field-name-number-array': () => + P[name]('embedding', [0.1, 0.2, 0.3]), + 'static-field-name-integer-array': () => P[name]('embedding', [1, 2, 3]), + 'static-field-name-vector-value': () => P[name]('embedding', vector), + 'static-field-name-expression': () => + P[name]('embedding', field('other')), + 'static-expression-number-array': () => + P[name](field('embedding'), [0.1, 0.2, 0.3]), + 'static-expression-expression': () => + P[name](field('embedding'), field('other')), + 'method-number-array': () => field('embedding')[name]([0.1, 0.2, 0.3]), + 'method-vector-value': () => field('embedding')[name](vector), + 'method-expression': () => field('embedding')[name](field('other')), + }); + } + unary('vectorLength', 'embedding'); + fn('functions', 'geoDistance', { + 'static-field-name-geo-point': () => P.geoDistance('location', geoPoint), + 'static-field-geo-point': () => P.geoDistance(field('location'), geoPoint), + 'static-field-name-expression': () => + P.geoDistance('location', field('other')), + 'method-geo-point': () => field('location').geoDistance(geoPoint), + 'method-expression': () => field('location').geoDistance(field('other')), + }); + + // --------------------------------------------------------------------------- + // Functions: references, documents and search + // --------------------------------------------------------------------------- + + unary('collectionId', '__name__'); + for (const name of ['documentId', 'parent']) { + fn('functions', name, { + 'static-path': () => P[name]('books/book1'), + 'static-reference': () => P[name](docRef()), + 'static-expression': () => P[name](field('__name__')), + method: () => field('__name__')[name](), + }); + } + fn('functions', 'currentDocument', {static: () => P.currentDocument()}); + fn('functions', 'documentMatches', { + 'static-literal': () => P.documentMatches('breakfast'), + 'static-expression': () => P.documentMatches(field('query')), + }); + fn('functions', 'score', {static: () => P.score()}); + + // --------------------------------------------------------------------------- + // Aggregates + // --------------------------------------------------------------------------- + + fn('aggregates', 'countAll', {static: () => P.countAll()}); + for (const name of [ + 'count', + 'sum', + 'average', + 'minimum', + 'maximum', + 'first', + 'last', + 'arrayAgg', + 'arrayAggDistinct', + 'countDistinct', + ]) { + unary(name, 'rating', {area: 'aggregates'}); + } + fn('aggregates', 'countIf', { + static: () => P.countIf(isGood()), + method: () => isGood().countIf(), + }); + + return cases; +}; diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/generate.js b/packages/google_cloud_firestore/tool/pipeline_golden/generate.js new file mode 100644 index 00000000..b0900495 --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_golden/generate.js @@ -0,0 +1,259 @@ +// 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. + +'use strict'; + +/** + * Writes the `ExecutePipelineRequest` the Node.js Firestore SDK sends for + * every case in `cases.js` to `test/fixtures/pipeline_golden/.json`. + * + * Nothing touches the network: `Firestore.requestStream`, the single call the + * SDK makes to run `executePipeline`, is replaced with a stub that records + * the request and returns an empty response stream. Everything up to that + * point (`Pipeline.execute`, user data validation, `StructuredPipeline` + * serialization) is the SDK's own code. + * + * The recorded request is converted to proto3 JSON the same way google-gax + * does for the REST transport (`Type.fromObject` + `toProto3JSON`), which is + * also the encoding the Dart SDK sends. See README.md for the normalization + * applied on top. + */ + +const fs = require('fs'); +const path = require('path'); +const {PassThrough} = require('stream'); + +const sdkPackage = require.resolve('@google-cloud/firestore/package.json'); +const sdkDir = path.dirname(sdkPackage); +const gaxDir = path.dirname(require.resolve('google-gax', {paths: [sdkDir]})); +// Resolve protobufjs and the proto3 JSON serializer the same way the SDK and +// google-gax do, so the encoding matches the REST transport exactly. +const protobuf = require(require.resolve('protobufjs', {paths: [sdkDir]})); +const {toProto3JSON} = require( + require.resolve('proto3-json-serializer', {paths: [gaxDir]}), +); + +const sdk = require('@google-cloud/firestore'); +const buildCases = require('./cases'); + +const SDK_VERSION = require(sdkPackage).version; +const PROJECT_ID = 'test-project'; +const DATABASE_ID = '(default)'; +const OUT_DIR = path.resolve( + __dirname, + '..', + '..', + 'test', + 'fixtures', + 'pipeline_golden', +); + +/** + * Repeated and map fields that proto3 JSON omits when empty. + * + * protobufjs keeps some of them (for example `options: {}` on every stage), + * the Dart SDK drops them; both mean the same thing on the wire. Message + * fields such as an empty `mapValue` are kept: they carry a value. + */ +const OMIT_WHEN_EMPTY = new Set(['args', 'options', 'fields', 'values']); + +/** + * Names that are deliberately not exercised as `functions//...` or + * `aggregates//...`, and where they are covered instead. + */ +const COVERED_ELSEWHERE = { + field: 'values/field/*', + constant: 'values/constant/*', + variable: 'values/variable/*, functions/arrayFilter/*', + ascending: 'stages/sort/*', + descending: 'stages/sort/*', + subcollection: 'sources/subcollection/*', +}; + +/** `Expression` methods that are not Pipeline functions. */ +const NOT_FUNCTIONS = new Set(['constructor', 'as', 'asBoolean']); + +function isEmpty(value) { + if (Array.isArray(value)) return value.length === 0; + return ( + value !== null && typeof value === 'object' && Object.keys(value).length === 0 + ); +} + +/** Orders keys alphabetically, except that a function or stage `name` leads. */ +function compareKeys(a, b) { + if (a === b) return 0; + if (a === 'name') return -1; + if (b === 'name') return 1; + return a < b ? -1 : 1; +} + +/** Sorts object keys and drops empty repeated/map fields, recursively. */ +function canonicalize(value) { + if (Array.isArray(value)) return value.map(canonicalize); + if (value === null || typeof value !== 'object') return value; + const result = {}; + for (const key of Object.keys(value).sort(compareKeys)) { + const child = canonicalize(value[key]); + if (OMIT_WHEN_EMPTY.has(key) && isEmpty(child)) continue; + result[key] = child; + } + return result; +} + +/** + * Pretty-prints [value] as JSON, keeping any object or array that fits in + * [LINE_WIDTH] columns on one line, so a fixture reads like the request: + * `{"fieldReferenceValue": "rating"}` instead of three lines. + */ +const LINE_WIDTH = 80; + +function format(value, indent = '', column = indent.length) { + const inline = formatInline(value); + if (column + inline.length <= LINE_WIDTH || value === null) return inline; + if (typeof value !== 'object') return inline; + + const inner = indent + ' '; + if (Array.isArray(value)) { + const items = value.map(item => inner + format(item, inner)); + return `[\n${items.join(',\n')}\n${indent}]`; + } + const entries = Object.keys(value).map(key => { + const prefix = `${inner}${JSON.stringify(key)}: `; + return prefix + format(value[key], inner, prefix.length); + }); + return `{\n${entries.join(',\n')}\n${indent}}`; +} + +function formatInline(value) { + if (Array.isArray(value)) { + return `[${value.map(formatInline).join(', ')}]`; + } + if (value !== null && typeof value === 'object') { + const entries = Object.keys(value).map( + key => `${JSON.stringify(key)}: ${formatInline(value[key])}`, + ); + return `{${entries.join(', ')}}`; + } + return JSON.stringify(value); +} + +/** Fails when a Node function or method is not exercised by any case. */ +function checkCoverage(ids) { + const P = sdk.Pipelines; + const subjects = new Set(); + const methodSubjects = new Set(); + for (const id of ids) { + const [area, subject, variant] = id.split('/'); + if (area !== 'functions' && area !== 'aggregates') continue; + subjects.add(subject); + if (variant.startsWith('method')) methodSubjects.add(subject); + } + + const missing = []; + for (const name of Object.keys(P).sort()) { + if (typeof P[name] !== 'function' || /^[A-Z]/.test(name)) continue; + if (!subjects.has(name) && !(name in COVERED_ELSEWHERE)) { + missing.push(`top-level function ${name}()`); + } + } + + const methods = new Set([ + ...Object.getOwnPropertyNames(P.Expression.prototype), + ...Object.getOwnPropertyNames(P.BooleanExpression.prototype), + ...Object.getOwnPropertyNames(P.Field.prototype), + ]); + for (const name of [...methods].sort()) { + if (NOT_FUNCTIONS.has(name) || name.startsWith('_')) continue; + if (name === 'fieldName' || name === 'ascending' || name === 'descending') { + continue; + } + if (!methodSubjects.has(name)) missing.push(`Expression.${name}()`); + } + + if (missing.length > 0) { + throw new Error( + 'These Node Pipeline APIs have no golden case:\n ' + + missing.join('\n '), + ); + } +} + +async function main() { + const db = new sdk.Firestore({projectId: PROJECT_ID, databaseId: DATABASE_ID}); + const requestType = protobuf.Root.fromJSON( + require(path.join(sdkDir, 'build', 'protos', 'v1.json')), + ).lookupType('google.firestore.v1.ExecutePipelineRequest'); + + let captured; + db.requestStream = async (methodName, bidirectional, request) => { + if (methodName !== 'executePipeline') { + throw new Error(`Unexpected RPC ${methodName}`); + } + captured = request; + const stream = new PassThrough({objectMode: true}); + stream.end(); + return stream; + }; + + const cases = buildCases(sdk, db); + checkCoverage(cases.map(c => c.id)); + + const areas = new Map(); + for (const {id, pipeline, options} of cases) { + captured = undefined; + try { + await pipeline().execute(options); + } catch (e) { + throw new Error(`Case ${id} failed: ${e.stack || e}`); + } + if (!captured) throw new Error(`Case ${id} sent no request`); + + const json = canonicalize(toProto3JSON(requestType.fromObject(captured))); + const area = id.split('/')[0]; + if (!areas.has(area)) areas.set(area, {}); + areas.get(area)[id] = json; + } + + fs.mkdirSync(OUT_DIR, {recursive: true}); + for (const file of fs.readdirSync(OUT_DIR)) { + if (file.endsWith('.json')) fs.rmSync(path.join(OUT_DIR, file)); + } + for (const [area, requests] of [...areas].sort(([a], [b]) => + a < b ? -1 : 1, + )) { + const sorted = {}; + for (const id of Object.keys(requests).sort()) sorted[id] = requests[id]; + const fixture = { + generator: 'tool/pipeline_golden (npm run generate)', + sdk: `@google-cloud/firestore@${SDK_VERSION}`, + cases: sorted, + }; + fs.writeFileSync( + path.join(OUT_DIR, `${area}.json`), + format(fixture) + '\n', + ); + } + + await db.terminate(); + const counts = [...areas] + .map(([area, requests]) => `${area}: ${Object.keys(requests).length}`) + .join(', '); + console.log(`Wrote ${cases.length} cases (${counts}) to ${OUT_DIR}`); +} + +main().catch(e => { + console.error(e); + process.exitCode = 1; +}); diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/package-lock.json b/packages/google_cloud_firestore/tool/pipeline_golden/package-lock.json new file mode 100644 index 00000000..252c4b83 --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_golden/package-lock.json @@ -0,0 +1,1358 @@ +{ + "name": "pipeline-golden", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pipeline-golden", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/firestore": "9.3.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-9.3.1.tgz", + "integrity": "sha512-ofGTiSpfXrHrrS3KaDOmsupSzCHr4WVl1+s6Ke0F8rPBRyb/OuZSC8u1NjCKtt/fO7A1GvqASQGhdAwVsnGpQA==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/firestore-api": "^0.2.0", + "@opentelemetry/api": "^1.9.0", + "fast-deep-equal": "^3.1.3", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^6.0.1", + "protobufjs": "^7.5.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/firestore-api": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore-api/-/firestore-api-0.2.0.tgz", + "integrity": "sha512-wfHJPZmLqzpy6FN6U+7HkkP/AKhGsbHSpEy/qL2TyI3/pKRAvpamQxZ2qvps5hMRgAjfuHCim+oeJj9RveCaqg==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/google-gax": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.8.tgz", + "integrity": "sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "3.0.4", + "protobufjs": "^7.5.4", + "retry-request": "^8.0.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/retry-request": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.4.tgz", + "integrity": "sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/teeny-request": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.4.tgz", + "integrity": "sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT" + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.0.4.tgz", + "integrity": "sha512-p6WvbXOhfwnVtv/MyPq5kBiVhfV/qglMIxwwTVNEI1Ha1HmOgz5uHYHtYO6SfGckFaXe8blA11IJJ3wqXmvmBA==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.1.3", + "google-logging-utils": "^2.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.1.0.tgz", + "integrity": "sha512-YIBq5KNoHQrZ9PM4PeodDBqqBGlSTIxh1zibN8u3zNIO9UJg8gvKuCpdKV7fWXhsZ2zRkU6FhGrgKNgLJ2IRgw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "^9.0.0", + "google-logging-utils": "^2.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-gax": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-6.10.0.tgz", + "integrity": "sha512-P9+6mNoC97uSUj55MptrxNj9muoDwc05mcY49R8jyg3kqzkPK+uWHApLuAHsNPKh3dtk1M1IuVZIto8e/fvpQA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "@opentelemetry/api": "^1.9.0", + "duplexify": "^4.1.3", + "google-auth-library": "^11.0.0", + "google-logging-utils": "^2.0.0", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^4.0.0", + "protobufjs": "^7.5.4", + "retry-request": "^9.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-logging-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-2.0.1.tgz", + "integrity": "sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/proto3-json-serializer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-4.0.2.tgz", + "integrity": "sha512-VUQlb/J2WjOG8BKhdLwJUz+MKxSMtBGYh+TaNmRtiYVhlIzXjzeAGIx7gtc3lXlA2iURzfF2+cXaQvC2ofwzpg==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.5.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry-request": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-9.0.1.tgz", + "integrity": "sha512-4kGHEBl0ME6gR+8QB1sPMyg58jUxLUcCZNeqLDrfqzk0eWQN8XJFmeu7fmZCjlZCRW0S2u64Ik1HkO/0sWgCYQ==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^11.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, + "node_modules/teeny-request": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-11.0.1.tgz", + "integrity": "sha512-bNr5j2YjSdajgCVsp+8JVjRf1uHIbpNISLeKp/7V1NnVMX3Gh6H/kECNGlm2Q3I68ACODQ0iv6aZ3Rvm8WgLGA==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/package.json b/packages/google_cloud_firestore/tool/pipeline_golden/package.json new file mode 100644 index 00000000..8d2eff9c --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_golden/package.json @@ -0,0 +1,15 @@ +{ + "name": "pipeline-golden", + "private": true, + "description": "Generates golden ExecutePipelineRequest fixtures for google_cloud_firestore from the Node.js Firestore SDK.", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + }, + "scripts": { + "generate": "node generate.js" + }, + "dependencies": { + "@google-cloud/firestore": "9.3.1" + } +} From 96c8ca0c6f74da580a0f6018bf69382775d62e48 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:14:00 +0200 Subject: [PATCH 07/29] test(firestore): check Pipeline API signatures against the Node SDK Unit tests only exercise the signature they were written against, so they missed Pipeline API mismatches such as an optional join() delimiter or a mapRemove() that took an Iterable of keys. tool/pipeline_api_parity extracts the Pipelines declarations of the pinned @google-cloud/firestore 9.3.1 into test/fixtures/node_pipeline_api.json with the TypeScript compiler API. test/pipeline_api_parity_test.dart reads the Dart API with package:analyzer and compares the two: missing and Dart-only members, argument counts and optional positions, single values versus lists, accepted kinds of values, options objects versus named parameters, return types, the isType values, and barrel exports. Intentional differences are listed with their reasons, and the gaps that still need a fix in lib/ are listed separately; stale entries fail the test. A workflow regenerates the snapshot on google_cloud_firestore changes and fails when the committed file is out of date. --- .github/workflows/pipeline_api_snapshot.yml | 55 + packages/google_cloud_firestore/pubspec.yaml | 3 + .../test/fixtures/node_pipeline_api.json | 5023 +++++++++++++++++ .../test/pipeline_api_parity_test.dart | 1695 ++++++ .../tool/pipeline_api_parity/.gitignore | 1 + .../tool/pipeline_api_parity/README.md | 58 + .../pipeline_api_parity/extract_node_api.mjs | 741 +++ .../pipeline_api_parity/package-lock.json | 1372 +++++ .../tool/pipeline_api_parity/package.json | 17 + 9 files changed, 8965 insertions(+) create mode 100644 .github/workflows/pipeline_api_snapshot.yml create mode 100644 packages/google_cloud_firestore/test/fixtures/node_pipeline_api.json create mode 100644 packages/google_cloud_firestore/test/pipeline_api_parity_test.dart create mode 100644 packages/google_cloud_firestore/tool/pipeline_api_parity/.gitignore create mode 100644 packages/google_cloud_firestore/tool/pipeline_api_parity/README.md create mode 100644 packages/google_cloud_firestore/tool/pipeline_api_parity/extract_node_api.mjs create mode 100644 packages/google_cloud_firestore/tool/pipeline_api_parity/package-lock.json create mode 100644 packages/google_cloud_firestore/tool/pipeline_api_parity/package.json diff --git a/.github/workflows/pipeline_api_snapshot.yml b/.github/workflows/pipeline_api_snapshot.yml new file mode 100644 index 00000000..4d766bb8 --- /dev/null +++ b/.github/workflows/pipeline_api_snapshot.yml @@ -0,0 +1,55 @@ +name: Pipeline API snapshot + +# Checks that test/fixtures/node_pipeline_api.json, which +# test/pipeline_api_parity_test.dart compares the Dart Pipelines API against, +# is what tool/pipeline_api_parity extracts from the pinned Node SDK. The Dart +# test itself runs with the other google_cloud_firestore tests in build.yml. +# +# A separate workflow so that it only runs for google_cloud_firestore changes: +# build.yml cannot filter paths per job. +on: + pull_request: + paths: + - "packages/google_cloud_firestore/**" + - ".github/workflows/pipeline_api_snapshot.yml" + push: + branches: + - main + paths: + - "packages/google_cloud_firestore/**" + - ".github/workflows/pipeline_api_snapshot.yml" + + workflow_dispatch: + +permissions: + contents: read + +jobs: + snapshot: + name: Node Pipeline API snapshot is up to date + runs-on: ubuntu-latest + timeout-minutes: 5 + + defaults: + run: + working-directory: packages/google_cloud_firestore/tool/pipeline_api_parity + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 24 + + - run: npm ci --ignore-scripts --no-audit --no-fund + + - run: npm run extract + + - name: Fail if the snapshot is stale + run: | + if ! git diff --exit-code -- ../../test/fixtures/node_pipeline_api.json; then + echo "::error::test/fixtures/node_pipeline_api.json is stale. Regenerate it with 'npm ci && npm run extract' in packages/google_cloud_firestore/tool/pipeline_api_parity." + exit 1 + fi diff --git a/packages/google_cloud_firestore/pubspec.yaml b/packages/google_cloud_firestore/pubspec.yaml index 205b6826..444ea828 100644 --- a/packages/google_cloud_firestore/pubspec.yaml +++ b/packages/google_cloud_firestore/pubspec.yaml @@ -25,6 +25,9 @@ dependencies: retry: ^3.1.2 dev_dependencies: + # Used by the analyzer-based Pipeline tests (API parity, single + # implementation, E2E coverage). Spans the releases that run on the minimum + # SDK (up to 13.0.0 on Dart 3.9) and the newer ones (14.x needs Dart 3.11). analyzer: '>=10.0.0 <15.0.0' build_runner: ^2.4.7 mocktail: ^1.0.2 diff --git a/packages/google_cloud_firestore/test/fixtures/node_pipeline_api.json b/packages/google_cloud_firestore/test/fixtures/node_pipeline_api.json new file mode 100644 index 00000000..694f6d2e --- /dev/null +++ b/packages/google_cloud_firestore/test/fixtures/node_pipeline_api.json @@ -0,0 +1,5023 @@ +{ + "classes": { + "AggregateFunction": { + "abstract": false, + "constructors": [ + { + "params": [ + {"kinds":["string"],"name":"name","type":"string"}, + {"elements":{"kinds":["expression"]},"kinds":["array"],"name":"params","type":"Expression[]"} + ], + "returns": {"kinds":["any"],"type":null} + } + ], + "extends": null, + "implements": [], + "methods": { + "as": { + "overloads": [ + { + "params": [{"kinds":["string"],"name":"name","type":"string"}], + "returns": {"kinds":["aliasedAggregate"],"type":"AliasedAggregate"} + } + ], + "static": false + } + }, + "properties": { + "expressionType": { + "aliases": ["ExpressionType"], + "kinds": ["literal"], + "optional": false, + "readonly": false, + "static": false, + "type": "ExpressionType" + } + } + }, + "AliasedAggregate": {"abstract":false,"extends":null,"implements":[],"methods":{},"properties":{}}, + "AliasedExpression": { + "abstract": false, + "extends": null, + "implements": ["Selectable"], + "methods": {}, + "properties": { + "expressionType": { + "aliases": ["ExpressionType"], + "kinds": ["literal"], + "optional": false, + "readonly": false, + "static": false, + "type": "ExpressionType" + }, + "selectable": {"kinds":["boolean"],"optional":false,"readonly":false,"static":false,"type":"true"} + } + }, + "BooleanExpression": { + "abstract": true, + "extends": "Expression", + "implements": [], + "methods": { + "conditional": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"thenExpr","type":"Expression"}, + {"kinds":["expression"],"name":"elseExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "countIf": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "ifError": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"catchValue","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["boolean"],"name":"catchValue","type":"boolean"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["booleanExpression"],"name":"catchValue","type":"BooleanExpression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"catchValue","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "not": { + "overloads": [{"params":[],"returns":{"kinds":["booleanExpression"],"type":"BooleanExpression"}}], + "static": false + } + }, + "properties": {} + }, + "Constant": { + "abstract": false, + "extends": "Expression", + "implements": [], + "methods": {}, + "properties": { + "expressionType": { + "aliases": ["ExpressionType"], + "kinds": ["literal"], + "optional": false, + "readonly": true, + "static": false, + "type": "ExpressionType" + } + } + }, + "ExplainStats": { + "abstract": false, + "extends": null, + "implements": [], + "methods": {}, + "properties": { + "rawData": { + "kinds": ["map"], + "optional": false, + "readonly": true, + "static": false, + "type": "{type_url?: string | null; value?: Uint8Array | null;}" + }, + "text": {"kinds":["string"],"optional":false,"readonly":true,"static":false,"type":"string"} + } + }, + "Expression": { + "abstract": true, + "extends": null, + "implements": [], + "methods": { + "abs": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "add": { + "overloads": [ + { + "params": [ + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayAgg": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "arrayAggDistinct": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "arrayConcat": { + "overloads": [ + { + "params": [ + { + "elements": {"kinds":["any"]}, + "kinds": ["array","expression"], + "name": "secondArray", + "type": "Expression | unknown[]" + }, + { + "elements": {"kinds":["any"]}, + "kinds": ["array","expression"], + "name": "otherArrays", + "rest": true, + "type": "Array" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayContains": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "arrayContainsAll": { + "overloads": [ + { + "params": [ + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"arrayExpression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "arrayContainsAny": { + "overloads": [ + { + "params": [ + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"arrayExpression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "arrayFilter": { + "overloads": [ + { + "params": [ + {"kinds":["string"],"name":"alias","type":"string"}, + {"kinds":["booleanExpression"],"name":"filter","type":"BooleanExpression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayFirst": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "arrayFirstN": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"n","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"n","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayGet": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"indexExpr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"index","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayIndexOf": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"search","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"search","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayIndexOfAll": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"search","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"search","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayLast": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "arrayLastIndexOf": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"search","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"search","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayLastN": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"n","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"n","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayLength": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "arrayMaximum": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "arrayMaximumN": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"n","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"n","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayMinimum": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "arrayMinimumN": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"n","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"n","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arraySlice": { + "overloads": [ + { + "params": [ + {"kinds":["expression","number"],"name":"offset","type":"number | Expression"}, + {"kinds":["expression","number"],"name":"length","optional":true,"type":"number | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arraySum": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "arrayTransform": { + "overloads": [ + { + "params": [ + {"kinds":["string"],"name":"elementAlias","type":"string"}, + {"kinds":["expression"],"name":"transform","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "arrayTransformWithIndex": { + "overloads": [ + { + "params": [ + {"kinds":["string"],"name":"elementAlias","type":"string"}, + {"kinds":["string"],"name":"indexAlias","type":"string"}, + {"kinds":["expression"],"name":"transform","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "as": { + "overloads": [ + { + "params": [{"kinds":["string"],"name":"name","type":"string"}], + "returns": {"kinds":["aliasedExpression"],"type":"AliasedExpression"} + } + ], + "static": false + }, + "asBoolean": { + "overloads": [{"params":[],"returns":{"kinds":["booleanExpression"],"type":"BooleanExpression"}}], + "static": false + }, + "ascending": {"overloads":[{"params":[],"returns":{"kinds":["ordering"],"type":"Ordering"}}],"static":false}, + "average": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "byteLength": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "ceil": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "charLength": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "coalesce": { + "overloads": [ + { + "params": [ + {"kinds":["any","expression"],"name":"replacement","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "collectionId": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "cosineDistance": { + "overloads": [ + { + "params": [ + { + "elements": {"kinds":["number"]}, + "kinds": ["array","vectorValue"], + "name": "vector", + "type": "VectorValue | number[]" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"vectorExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "count": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "countDistinct": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "descending": {"overloads":[{"params":[],"returns":{"kinds":["ordering"],"type":"Ordering"}}],"static":false}, + "divide": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"divisor","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"divisor","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "documentId": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "dotProduct": { + "overloads": [ + { + "params": [ + { + "elements": {"kinds":["number"]}, + "kinds": ["array","vectorValue"], + "name": "vector", + "type": "VectorValue | number[]" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"vectorExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "endsWith": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"suffix","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"suffix","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "equal": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "equalAny": { + "overloads": [ + { + "params": [ + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"arrayExpression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "euclideanDistance": { + "overloads": [ + { + "params": [ + { + "elements": {"kinds":["number"]}, + "kinds": ["array","vectorValue"], + "name": "vector", + "type": "VectorValue | number[]" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"vectorExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "exists": { + "overloads": [{"params":[],"returns":{"kinds":["booleanExpression"],"type":"BooleanExpression"}}], + "static": false + }, + "exp": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "first": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "floor": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "getField": { + "overloads": [ + { + "params": [{"kinds":["expression","string"],"name":"key","type":"string | Expression"}], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ], + "static": false + }, + "greaterThan": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "greaterThanOrEqual": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "ifAbsent": { + "overloads": [ + { + "params": [{"kinds":["any","expression"],"name":"elseValueOrExpression","type":"Expression | unknown"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["any"],"name":"elseExpression","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["any"],"name":"elseValue","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ], + "static": false + }, + "ifError": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"catchValue","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"catchExpr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "ifNull": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"elseValue","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"elseExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "isAbsent": { + "overloads": [{"params":[],"returns":{"kinds":["booleanExpression"],"type":"BooleanExpression"}}], + "static": false + }, + "isError": { + "overloads": [{"params":[],"returns":{"kinds":["booleanExpression"],"type":"BooleanExpression"}}], + "static": false + }, + "isType": { + "overloads": [ + { + "params": [{"kinds":["string"],"name":"type","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "join": { + "overloads": [ + { + "params": [{"kinds":["expression","string"],"name":"delimeterValueOrExpression","type":"string | Expression"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["expression"],"name":"delimiterExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["string"],"name":"delimiter","type":"string"}], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ], + "static": false + }, + "last": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "length": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "lessThan": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "lessThanOrEqual": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "like": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"pattern","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"pattern","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "ln": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "log10": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "logicalMaximum": { + "overloads": [ + { + "params": [ + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "logicalMinimum": { + "overloads": [ + { + "params": [ + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "ltrim": { + "overloads": [ + { + "params": [ + { + "kinds": ["bytes","expression","string"], + "name": "valueToTrim", + "optional": true, + "type": "string | Expression | Uint8Array | Buffer" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "mapEntries": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "mapGet": { + "overloads": [ + { + "params": [{"kinds":["string"],"name":"subfield","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "mapKeys": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "mapMerge": { + "overloads": [ + { + "params": [ + {"kinds":["expression","map"],"name":"secondMap","type":"Record | Expression"}, + { + "kinds": ["expression","map"], + "name": "otherMaps", + "rest": true, + "type": "Array | Expression>" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "mapRemove": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"keyExpr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"key","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "mapSet": { + "overloads": [ + { + "params": [ + {"kinds":["expression","string"],"name":"key","type":"string | Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"}, + {"kinds":["any"],"name":"moreKeyValues","rest":true,"type":"unknown[]"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "mapValues": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "maximum": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "minimum": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "mod": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"value","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "multiply": { + "overloads": [ + { + "params": [ + {"kinds":["expression","number"],"name":"second","type":"Expression | number"}, + {"kinds":["expression","number"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "notEqual": { + "overloads": [ + { + "params": [{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "notEqualAny": { + "overloads": [ + { + "params": [ + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"arrayExpression","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "parent": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "pow": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"exponent","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"exponent","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "regexContains": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"pattern","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"pattern","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "regexFind": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"pattern","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"pattern","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "regexFindAll": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"pattern","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"pattern","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "regexMatch": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"pattern","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"pattern","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "reverse": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "round": { + "overloads": [ + {"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}, + { + "params": [{"kinds":["expression"],"name":"decimalPlaces","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"decimalPlaces","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "rtrim": { + "overloads": [ + { + "params": [ + { + "kinds": ["bytes","expression","string"], + "name": "valueToTrim", + "optional": true, + "type": "string | Expression | Uint8Array | Buffer" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "split": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"delimiter","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"delimiter","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "sqrt": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "startsWith": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"prefix","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"prefix","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "stringConcat": { + "overloads": [ + { + "params": [ + {"kinds":["expression","string"],"name":"secondString","type":"Expression | string"}, + {"kinds":["expression","string"],"name":"otherStrings","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "stringContains": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"substring","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ], + "static": false + }, + "stringIndexOf": { + "overloads": [ + { + "params": [ + { + "kinds": ["bytes","expression","string"], + "name": "search", + "type": "string | Expression | Uint8Array | Buffer" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "stringRepeat": { + "overloads": [ + { + "params": [{"kinds":["expression","number"],"name":"repetitions","type":"number | Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "stringReplaceAll": { + "overloads": [ + { + "params": [ + { + "kinds": ["bytes","expression","string"], + "name": "find", + "type": "string | Expression | Uint8Array | Buffer" + }, + { + "kinds": ["bytes","expression","string"], + "name": "replacement", + "type": "string | Expression | Uint8Array | Buffer" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "stringReplaceOne": { + "overloads": [ + { + "params": [ + { + "kinds": ["bytes","expression","string"], + "name": "find", + "type": "string | Expression | Uint8Array | Buffer" + }, + { + "kinds": ["bytes","expression","string"], + "name": "replacement", + "type": "string | Expression | Uint8Array | Buffer" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "stringReverse": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "substring": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"position","type":"Expression"}, + {"kinds":["expression"],"name":"length","optional":true,"type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["number"],"name":"position","type":"number"}, + {"kinds":["number"],"name":"length","optional":true,"type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "subtract": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"subtrahend","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"subtrahend","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "sum": { + "overloads": [{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}], + "static": false + }, + "timestampAdd": { + "overloads": [ + { + "params": [ + {"aliases":["TimeUnit"],"kinds":["literal"],"name":"unit","type":"TimeUnit"}, + {"kinds":["number"],"name":"amount","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"unit","type":"Expression"}, + {"kinds":["expression"],"name":"amount","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "timestampDiff": { + "overloads": [ + { + "params": [ + {"kinds":["expression","string"],"name":"start","type":"string | Expression"}, + {"aliases":["TimeUnit"],"kinds":["literal"],"name":"unit","type":"TimeUnit"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"start","type":"Expression"}, + {"kinds":["expression"],"name":"unit","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "timestampExtract": { + "overloads": [ + { + "params": [ + {"aliases":["TimePart"],"kinds":["literal"],"name":"part","type":"TimePart"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"part","type":"Expression"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "timestampSubtract": { + "overloads": [ + { + "params": [ + {"aliases":["TimeUnit"],"kinds":["literal"],"name":"unit","type":"TimeUnit"}, + {"kinds":["number"],"name":"amount","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"unit","type":"Expression"}, + {"kinds":["expression"],"name":"amount","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "timestampToUnixMicros": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "timestampToUnixMillis": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "timestampToUnixSeconds": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "timestampTruncate": { + "overloads": [ + { + "params": [ + {"aliases":["TimeGranularity"],"kinds":["literal"],"name":"granularity","type":"TimeGranularity"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"granularity","type":"Expression"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "toLower": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "toUpper": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "trim": { + "overloads": [ + { + "params": [ + { + "kinds": ["bytes","expression","string"], + "name": "valueToTrim", + "optional": true, + "type": "string | Expression | Uint8Array | Buffer" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "trunc": { + "overloads": [ + {"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}, + { + "params": [{"kinds":["expression"],"name":"decimalPlaces","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["number"],"name":"decimalPlaces","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ], + "static": false + }, + "type": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "unixMicrosToTimestamp": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "unixMillisToTimestamp": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "unixSecondsToTimestamp": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false}, + "vectorLength": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}],"static":false} + }, + "properties": { + "expressionType": { + "aliases": ["ExpressionType"], + "kinds": ["literal"], + "optional": false, + "readonly": true, + "static": false, + "type": "ExpressionType" + } + } + }, + "Field": { + "abstract": false, + "extends": "Expression", + "implements": ["Selectable"], + "methods": { + "geoDistance": { + "overloads": [ + { + "params": [{"kinds":["expression","geoPoint"],"name":"location","type":"GeoPoint | Expression"}], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ], + "static": false + } + }, + "properties": { + "expressionType": { + "aliases": ["ExpressionType"], + "kinds": ["literal"], + "optional": false, + "readonly": true, + "static": false, + "type": "ExpressionType" + }, + "fieldName": {"kinds":["string"],"optional":false,"readonly":true,"static":false,"type":"string"}, + "selectable": {"kinds":["boolean"],"optional":false,"readonly":false,"static":false,"type":"true"} + } + }, + "FunctionExpression": { + "abstract": false, + "constructors": [ + { + "params": [ + {"kinds":["string"],"name":"name","type":"string"}, + {"elements":{"kinds":["expression"]},"kinds":["array"],"name":"params","type":"Expression[]"} + ], + "returns": {"kinds":["any"],"type":null} + } + ], + "extends": "Expression", + "implements": [], + "methods": {}, + "properties": { + "expressionType": { + "aliases": ["ExpressionType"], + "kinds": ["literal"], + "optional": false, + "readonly": true, + "static": false, + "type": "ExpressionType" + } + } + }, + "Ordering": { + "abstract": false, + "extends": null, + "implements": [], + "methods": {}, + "properties": { + "direction": { + "kinds": ["literal"], + "literals": ["ascending","descending"], + "optional": false, + "readonly": true, + "static": false, + "type": "'ascending' | 'descending'" + }, + "expr": {"kinds":["expression"],"optional":false,"readonly":true,"static":false,"type":"Expression"} + } + }, + "Pipeline": { + "abstract": false, + "extends": null, + "implements": [], + "methods": { + "addFields": { + "overloads": [ + { + "params": [ + { + "kinds": ["options"], + "name": "options", + "options": ["AddFieldsStageOptions"], + "type": "AddFieldsStageOptions" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + {"kinds":["selectable"],"name":"field","type":"Selectable"}, + {"kinds":["selectable"],"name":"additionalFields","rest":true,"type":"Selectable[]"} + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "aggregate": { + "overloads": [ + { + "params": [ + {"kinds":["aliasedAggregate"],"name":"accumulator","type":"AliasedAggregate"}, + {"kinds":["aliasedAggregate"],"name":"additionalAccumulators","rest":true,"type":"AliasedAggregate[]"} + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + { + "kinds": ["options"], + "name": "options", + "options": ["AggregateStageOptions"], + "type": "AggregateStageOptions" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "define": { + "overloads": [ + { + "params": [ + {"kinds":["aliasedExpression"],"name":"aliasedExpression","type":"AliasedExpression"}, + {"kinds":["aliasedExpression"],"name":"additionalExpressions","rest":true,"type":"AliasedExpression[]"} + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [{"kinds":["options"],"name":"options","options":["DefineStageOptions"],"type":"DefineStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "delete": {"overloads":[{"params":[],"returns":{"kinds":["pipeline"],"type":"Pipeline"}}],"static":false}, + "distinct": { + "overloads": [ + { + "params": [{"kinds":["options"],"name":"options","options":["DistinctStageOptions"],"type":"DistinctStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + {"kinds":["selectable","string"],"name":"group","type":"string | Selectable"}, + { + "kinds": ["selectable","string"], + "name": "additionalGroups", + "rest": true, + "type": "Array" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "execute": { + "overloads": [ + { + "params": [ + { + "kinds": ["options"], + "name": "pipelineExecuteOptions", + "optional": true, + "options": ["PipelineExecuteOptions"], + "type": "PipelineExecuteOptions" + } + ], + "returns": {"elements":{"kinds":["pipelineSnapshot"]},"kinds":["promise"],"type":"Promise"} + } + ], + "static": false + }, + "findNearest": { + "overloads": [ + { + "params": [ + { + "kinds": ["options"], + "name": "options", + "options": ["FindNearestStageOptions"], + "type": "FindNearestStageOptions" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "limit": { + "overloads": [ + { + "params": [{"kinds":["number"],"name":"limit","type":"number"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [{"kinds":["options"],"name":"options","options":["LimitStageOptions"],"type":"LimitStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "offset": { + "overloads": [ + { + "params": [{"kinds":["number"],"name":"offset","type":"number"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [{"kinds":["options"],"name":"options","options":["OffsetStageOptions"],"type":"OffsetStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "rawStage": { + "overloads": [ + { + "params": [ + {"kinds":["string"],"name":"name","type":"string"}, + {"elements":{"kinds":["any"]},"kinds":["array"],"name":"params","type":"any[]"} + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "removeFields": { + "overloads": [ + { + "params": [ + {"kinds":["field","string"],"name":"fieldValue","type":"Field | string"}, + {"kinds":["field","string"],"name":"additionalFields","rest":true,"type":"Array"} + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + { + "kinds": ["options"], + "name": "options", + "options": ["RemoveFieldsStageOptions"], + "type": "RemoveFieldsStageOptions" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "replaceWith": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + { + "kinds": ["options"], + "name": "options", + "options": ["ReplaceWithStageOptions"], + "type": "ReplaceWithStageOptions" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "sample": { + "overloads": [ + { + "params": [{"kinds":["number"],"name":"documents","type":"number"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [{"kinds":["options"],"name":"options","options":["SampleStageOptions"],"type":"SampleStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "search": { + "overloads": [ + { + "params": [{"kinds":["options"],"name":"options","options":["SearchStageOptions"],"type":"SearchStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "select": { + "overloads": [ + { + "params": [{"kinds":["options"],"name":"options","options":["SelectStageOptions"],"type":"SelectStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + {"kinds":["selectable","string"],"name":"selection","type":"Selectable | string"}, + { + "kinds": ["selectable","string"], + "name": "additionalSelections", + "rest": true, + "type": "Array" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "sort": { + "overloads": [ + { + "params": [{"kinds":["options"],"name":"options","options":["SortStageOptions"],"type":"SortStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + {"kinds":["ordering"],"name":"ordering","type":"Ordering"}, + {"kinds":["ordering"],"name":"additionalOrderings","rest":true,"type":"Ordering[]"} + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "stream": { + "overloads": [{"params":[],"returns":{"kinds":["readableStream"],"type":"NodeJS.ReadableStream"}}], + "static": false + }, + "toArrayExpression": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"Expression"}}],"static":false}, + "toScalarExpression": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"Expression"}}],"static":false}, + "union": { + "overloads": [ + { + "params": [{"kinds":["options"],"name":"options","options":["UnionStageOptions"],"type":"UnionStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [{"kinds":["pipeline"],"name":"other","type":"Pipeline"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "unnest": { + "overloads": [ + { + "params": [{"kinds":["options"],"name":"options","options":["UnnestStageOptions"],"type":"UnnestStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + {"kinds":["selectable"],"name":"selectable","type":"Selectable"}, + {"kinds":["string"],"name":"indexField","optional":true,"type":"string"} + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "update": { + "overloads": [ + {"params":[],"returns":{"kinds":["pipeline"],"type":"Pipeline"}}, + { + "params": [ + { + "elements": {"kinds":["aliasedExpression"]}, + "kinds": ["array"], + "name": "transformedFields", + "type": "AliasedExpression[]" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "where": { + "overloads": [ + { + "params": [{"kinds":["booleanExpression"],"name":"condition","type":"BooleanExpression"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [{"kinds":["options"],"name":"options","options":["WhereStageOptions"],"type":"WhereStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + } + }, + "properties": {} + }, + "PipelineResult": { + "abstract": false, + "extends": null, + "implements": [], + "methods": { + "data": {"overloads":[{"params":[],"returns":{"kinds":["documentData"],"type":"DocumentData"}}],"static":false}, + "get": { + "overloads": [ + { + "params": [{"kinds":["fieldPath","string"],"name":"fieldPath","type":"string | FieldPath"}], + "returns": {"kinds":["any"],"type":"any"} + } + ], + "static": false + }, + "isEqual": { + "overloads": [ + { + "params": [{"kinds":["pipelineResult"],"name":"other","type":"PipelineResult"}], + "returns": {"kinds":["boolean"],"type":"boolean"} + } + ], + "static": false + } + }, + "properties": { + "createTime": { + "kinds": ["timestamp","undefined"], + "optional": false, + "readonly": true, + "static": false, + "type": "Timestamp | undefined" + }, + "id": {"kinds":["string","undefined"],"optional":false,"readonly":true,"static":false,"type":"string | undefined"}, + "ref": { + "kinds": ["documentReference","undefined"], + "optional": false, + "readonly": true, + "static": false, + "type": "DocumentReference | undefined" + }, + "updateTime": { + "kinds": ["timestamp","undefined"], + "optional": false, + "readonly": true, + "static": false, + "type": "Timestamp | undefined" + } + } + }, + "PipelineSnapshot": { + "abstract": false, + "extends": null, + "implements": [], + "methods": {}, + "properties": { + "executionTime": {"kinds":["timestamp"],"optional":false,"readonly":true,"static":false,"type":"Timestamp"}, + "explainStats": { + "kinds": ["explainStats","undefined"], + "optional": false, + "readonly": true, + "static": false, + "type": "ExplainStats | undefined" + }, + "pipeline": {"kinds":["pipeline"],"optional":false,"readonly":true,"static":false,"type":"Pipeline"}, + "results": { + "elements": {"kinds":["pipelineResult"]}, + "kinds": ["array"], + "optional": false, + "readonly": true, + "static": false, + "type": "PipelineResult[]" + } + } + }, + "PipelineSource": { + "abstract": false, + "extends": null, + "implements": [], + "methods": { + "collection": { + "overloads": [ + { + "params": [{"kinds":["collectionReference","string"],"name":"collectionPath","type":"string | CollectionReference"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + { + "kinds": ["options"], + "name": "options", + "options": ["CollectionStageOptions"], + "type": "CollectionStageOptions" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "collectionGroup": { + "overloads": [ + { + "params": [ + { + "kinds": ["options"], + "name": "options", + "options": ["CollectionGroupStageOptions"], + "type": "CollectionGroupStageOptions" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [{"kinds":["string"],"name":"collectionId","type":"string"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "createFrom": { + "overloads": [ + { + "params": [{"kinds":["query"],"name":"query","type":"Query"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "database": { + "overloads": [ + {"params":[],"returns":{"kinds":["pipeline"],"type":"Pipeline"}}, + { + "params": [{"kinds":["options"],"name":"options","options":["DatabaseStageOptions"],"type":"DatabaseStageOptions"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + }, + "documents": { + "overloads": [ + { + "params": [ + { + "elements": {"kinds":["documentReference","string"]}, + "kinds": ["array"], + "name": "docs", + "type": "Array" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [ + { + "kinds": ["options"], + "name": "options", + "options": ["DocumentsStageOptions"], + "type": "DocumentsStageOptions" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ], + "static": false + } + }, + "properties": {} + } + }, + "entryPoints": { + "Firestore.pipeline": {"overloads":[{"params":[],"returns":{"kinds":["pipelineSource"],"type":"Pipelines.PipelineSource"}}]}, + "Transaction.execute": { + "overloads": [ + { + "params": [{"kinds":["pipeline"],"name":"pipeline","type":"Pipelines.Pipeline"}], + "returns": {"elements":{"kinds":["pipelineSnapshot"]},"kinds":["promise"],"type":"Promise"} + } + ] + } + }, + "formatVersion": 1, + "functions": { + "abs": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "add": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"first","type":"Expression"}, + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "and": { + "overloads": [ + { + "params": [ + {"kinds":["booleanExpression"],"name":"first","type":"BooleanExpression"}, + {"kinds":["booleanExpression"],"name":"second","type":"BooleanExpression"}, + {"kinds":["booleanExpression"],"name":"more","rest":true,"type":"BooleanExpression[]"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "array": { + "overloads": [ + { + "params": [{"elements":{"kinds":["any"]},"kinds":["array"],"name":"elements","type":"unknown[]"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayAgg": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "arrayAggDistinct": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "arrayConcat": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"firstArray","type":"Expression"}, + { + "elements": {"kinds":["any"]}, + "kinds": ["array","expression"], + "name": "secondArray", + "type": "Expression | unknown[]" + }, + { + "elements": {"kinds":["any"]}, + "kinds": ["array","expression"], + "name": "otherArrays", + "rest": true, + "type": "Array" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"firstArrayField","type":"string"}, + { + "elements": {"kinds":["any"]}, + "kinds": ["array","expression"], + "name": "secondArray", + "type": "Expression | unknown[]" + }, + { + "elements": {"kinds":["any"]}, + "kinds": ["array","expression"], + "name": "otherArrays", + "rest": true, + "type": "Array" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayContains": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"array","type":"Expression"}, + {"kinds":["any"],"name":"element","type":"unknown"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"array","type":"Expression"}, + {"kinds":["expression"],"name":"element","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["any"],"name":"element","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"element","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "arrayContainsAll": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"array","type":"Expression"}, + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"array","type":"Expression"}, + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "arrayContainsAny": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"array","type":"Expression"}, + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"array","type":"Expression"}, + {"kinds":["expression"],"name":"values","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"values","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "arrayFilter": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["string"],"name":"alias","type":"string"}, + {"kinds":["booleanExpression"],"name":"filter","type":"BooleanExpression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"alias","type":"string"}, + {"kinds":["booleanExpression"],"name":"filter","type":"BooleanExpression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayFirst": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"arrayExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayFirstN": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["expression"],"name":"n","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["number"],"name":"n","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"n","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["number"],"name":"n","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayGet": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["expression"],"name":"indexExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["number"],"name":"index","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"arrayField","type":"string"}, + {"kinds":["expression"],"name":"indexExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"arrayField","type":"string"},{"kinds":["number"],"name":"index","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayIndexOf": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["any","expression"],"name":"search","type":"unknown | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["any","expression"],"name":"search","type":"unknown | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayIndexOfAll": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["any","expression"],"name":"search","type":"unknown | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["any","expression"],"name":"search","type":"unknown | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayLast": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"arrayExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayLastIndexOf": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["any","expression"],"name":"search","type":"unknown | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["any","expression"],"name":"search","type":"unknown | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayLastN": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["expression"],"name":"n","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["number"],"name":"n","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"n","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["number"],"name":"n","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayLength": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"array","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayMaximum": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"arrayExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayMaximumN": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["expression"],"name":"n","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["number"],"name":"n","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"n","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["number"],"name":"n","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayMinimum": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"arrayExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayMinimumN": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["expression"],"name":"n","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["number"],"name":"n","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"n","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["number"],"name":"n","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayReverse": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"arrayExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arraySlice": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["expression","number"],"name":"offset","type":"number | Expression"}, + {"kinds":["expression","number"],"name":"length","optional":true,"type":"number | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"arrayName","type":"string"}, + {"kinds":["expression","number"],"name":"offset","type":"number | Expression"}, + {"kinds":["expression","number"],"name":"length","optional":true,"type":"number | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arraySum": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayTransform": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["string"],"name":"elementAlias","type":"string"}, + {"kinds":["expression"],"name":"transform","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"elementAlias","type":"string"}, + {"kinds":["expression"],"name":"transform","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "arrayTransformWithIndex": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["string"],"name":"elementAlias","type":"string"}, + {"kinds":["string"],"name":"indexAlias","type":"string"}, + {"kinds":["expression"],"name":"transform","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"elementAlias","type":"string"}, + {"kinds":["string"],"name":"indexAlias","type":"string"}, + {"kinds":["expression"],"name":"transform","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "ascending": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["ordering"],"type":"Ordering"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["ordering"],"type":"Ordering"} + } + ] + }, + "average": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "byteLength": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "ceil": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "charLength": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"stringExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "coalesce": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["any","expression"],"name":"replacement","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["any","expression"],"name":"replacement","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "collectionId": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "concat": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"first","type":"Expression"}, + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "conditional": { + "overloads": [ + { + "params": [ + {"kinds":["booleanExpression"],"name":"condition","type":"BooleanExpression"}, + {"kinds":["expression"],"name":"thenExpr","type":"Expression"}, + {"kinds":["expression"],"name":"elseExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "constant": { + "overloads": [ + { + "params": [{"kinds":["boolean"],"name":"value","type":"boolean"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["bytes"],"name":"value","type":"Buffer | Uint8Array"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["date"],"name":"value","type":"Date"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["documentReference"],"name":"value","type":"DocumentReference"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["geoPoint"],"name":"value","type":"GeoPoint"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["null"],"name":"value","type":"null"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["number"],"name":"value","type":"number"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["string"],"name":"value","type":"string"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["timestamp"],"name":"value","type":"Timestamp"}], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["vectorValue"],"name":"value","type":"VectorValue"}], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ] + }, + "cosineDistance": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"vectorExpression","type":"Expression"}, + { + "elements": {"kinds":["number"]}, + "kinds": ["array","expression"], + "name": "vector", + "type": "number[] | Expression" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"vectorExpression","type":"Expression"}, + {"kinds":["expression"],"name":"otherVectorExpression","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + { + "elements": {"kinds":["number"]}, + "kinds": ["array","vectorValue"], + "name": "vector", + "type": "number[] | VectorValue" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"vectorExpression","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "count": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "countAll": {"overloads":[{"params":[],"returns":{"kinds":["aggregateFunction"],"type":"AggregateFunction"}}]}, + "countDistinct": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "countIf": { + "overloads": [ + { + "params": [{"kinds":["booleanExpression"],"name":"booleanExpr","type":"BooleanExpression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "currentDocument": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"Expression"}}]}, + "currentTimestamp": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}]}, + "descending": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["ordering"],"type":"Ordering"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["ordering"],"type":"Ordering"} + } + ] + }, + "divide": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"dividend","type":"Expression"}, + {"kinds":["any"],"name":"divisor","type":"unknown"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"dividend","type":"Expression"}, + {"kinds":["expression"],"name":"divisor","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"dividend","type":"string"},{"kinds":["any"],"name":"divisor","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"dividend","type":"string"}, + {"kinds":["expression"],"name":"divisor","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "documentId": { + "overloads": [ + { + "params": [{"kinds":["documentReference","string"],"name":"documentPath","type":"string | DocumentReference"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"documentPathExpr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "documentMatches": { + "overloads": [ + { + "params": [{"kinds":["expression","string"],"name":"rquery","type":"string | Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "dotProduct": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"vectorExpression","type":"Expression"}, + { + "elements": {"kinds":["number"]}, + "kinds": ["array","vectorValue"], + "name": "vector", + "type": "number[] | VectorValue" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"vectorExpression","type":"Expression"}, + {"kinds":["expression"],"name":"otherVectorExpression","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + { + "elements": {"kinds":["number"]}, + "kinds": ["array","vectorValue"], + "name": "vector", + "type": "number[] | VectorValue" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"vectorExpression","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "endsWith": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["expression"],"name":"suffix","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["string"],"name":"suffix","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"suffix","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["string"],"name":"suffix","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "equal": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"left","type":"Expression"}, + {"kinds":["expression"],"name":"right","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"expression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "equalAny": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "euclideanDistance": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"vectorExpression","type":"Expression"}, + { + "elements": {"kinds":["number"]}, + "kinds": ["array","vectorValue"], + "name": "vector", + "type": "number[] | VectorValue" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"vectorExpression","type":"Expression"}, + {"kinds":["expression"],"name":"otherVectorExpression","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + { + "elements": {"kinds":["number"]}, + "kinds": ["array","vectorValue"], + "name": "vector", + "type": "number[] | VectorValue" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"vectorExpression","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "exists": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"value","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "exp": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "field": { + "overloads": [ + { + "params": [{"kinds":["fieldPath"],"name":"path","type":"FieldPath"}], + "returns": {"kinds":["field"],"type":"Field"} + }, + {"params":[{"kinds":["string"],"name":"name","type":"string"}],"returns":{"kinds":["field"],"type":"Field"}} + ] + }, + "first": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "geoDistance": { + "overloads": [ + { + "params": [ + {"kinds":["field","string"],"name":"fieldName","type":"string | Field"}, + {"kinds":["expression","geoPoint"],"name":"location","type":"GeoPoint | Expression"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ] + }, + "getField": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["expression"],"name":"keyExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["string"],"name":"key","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"keyExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["string"],"name":"key","type":"string"}], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ] + }, + "greaterThan": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"left","type":"Expression"}, + {"kinds":["expression"],"name":"right","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"expression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "greaterThanOrEqual": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"left","type":"Expression"}, + {"kinds":["expression"],"name":"right","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"value","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "ifAbsent": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"ifExpr","type":"Expression"}, + {"kinds":["any"],"name":"elseValue","type":"unknown"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"ifExpr","type":"Expression"}, + {"kinds":["expression"],"name":"elseExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [ + {"kinds":["string"],"name":"ifFieldName","type":"string"}, + {"kinds":["expression"],"name":"elseExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ] + }, + "ifError": { + "overloads": [ + { + "params": [ + {"kinds":["booleanExpression"],"name":"tryExpr","type":"BooleanExpression"}, + {"kinds":["booleanExpression"],"name":"catchExpr","type":"BooleanExpression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"tryExpr","type":"Expression"}, + {"kinds":["any"],"name":"catchValue","type":"unknown"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"tryExpr","type":"Expression"}, + {"kinds":["expression"],"name":"catchExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "ifNull": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"ifExpr","type":"Expression"}, + {"kinds":["any"],"name":"elseValue","type":"unknown"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"ifExpr","type":"Expression"}, + {"kinds":["expression"],"name":"elseExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"ifFieldName","type":"string"}, + {"kinds":["any"],"name":"elseValue","type":"unknown"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"ifFieldName","type":"string"}, + {"kinds":["expression"],"name":"elseExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "isAbsent": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"value","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"field","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "isError": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"value","type":"Expression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "isType": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["string"],"name":"type","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["string"],"name":"type","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "join": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["expression"],"name":"delimiterExpression","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"}, + {"kinds":["string"],"name":"delimiter","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + }, + { + "params": [ + {"kinds":["string"],"name":"arrayFieldName","type":"string"}, + {"kinds":["string"],"name":"delimiter","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ] + }, + "last": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "length": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "lessThan": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"left","type":"Expression"}, + {"kinds":["expression"],"name":"right","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"expression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "lessThanOrEqual": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"left","type":"Expression"}, + {"kinds":["expression"],"name":"right","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"expression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "like": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "ln": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "log10": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "logicalMaximum": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"first","type":"Expression"}, + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "logicalMinimum": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"first","type":"Expression"}, + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "map": { + "overloads": [ + { + "params": [{"kinds":["map"],"name":"elements","type":"Record"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "mapEntries": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"mapExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"mapField","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "mapGet": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"mapExpression","type":"Expression"}, + {"kinds":["string"],"name":"subField","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"subField","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "mapKeys": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"mapExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"mapField","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "mapMerge": { + "overloads": [ + { + "params": [ + {"kinds":["expression","map"],"name":"firstMap","type":"Record | Expression"}, + {"kinds":["expression","map"],"name":"secondMap","type":"Record | Expression"}, + { + "kinds": ["expression","map"], + "name": "otherMaps", + "rest": true, + "type": "Array | Expression>" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"mapField","type":"string"}, + {"kinds":["expression","map"],"name":"secondMap","type":"Record | Expression"}, + { + "kinds": ["expression","map"], + "name": "otherMaps", + "rest": true, + "type": "Array | Expression>" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "mapRemove": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"mapExpr","type":"Expression"}, + {"kinds":["expression"],"name":"keyExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"mapExpr","type":"Expression"}, + {"kinds":["string"],"name":"key","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"mapField","type":"string"}, + {"kinds":["expression"],"name":"keyExpr","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"mapField","type":"string"},{"kinds":["string"],"name":"key","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "mapSet": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"mapExpression","type":"Expression"}, + {"kinds":["expression","string"],"name":"key","type":"string | Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"}, + {"kinds":["any"],"name":"moreKeyValues","rest":true,"type":"unknown[]"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"mapField","type":"string"}, + {"kinds":["expression","string"],"name":"key","type":"string | Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"}, + {"kinds":["any"],"name":"moreKeyValues","rest":true,"type":"unknown[]"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "mapValues": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"mapExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"mapField","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "maximum": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "minimum": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "mod": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"left","type":"Expression"}, + {"kinds":["expression"],"name":"right","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"expression","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "multiply": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"first","type":"Expression"}, + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["any","expression"],"name":"second","type":"Expression | unknown"}, + {"kinds":["any","expression"],"name":"others","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "nor": { + "overloads": [ + { + "params": [ + {"kinds":["booleanExpression"],"name":"first","type":"BooleanExpression"}, + {"kinds":["booleanExpression"],"name":"second","type":"BooleanExpression"}, + {"kinds":["booleanExpression"],"name":"more","rest":true,"type":"BooleanExpression[]"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "not": { + "overloads": [ + { + "params": [{"kinds":["booleanExpression"],"name":"booleanExpr","type":"BooleanExpression"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "notEqual": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["any"],"name":"value","type":"unknown"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"left","type":"Expression"}, + {"kinds":["expression"],"name":"right","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["any"],"name":"value","type":"unknown"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"expression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "notEqualAny": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"element","type":"Expression"}, + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"element","type":"Expression"}, + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + { + "elements": {"kinds":["any","expression"]}, + "kinds": ["array"], + "name": "values", + "type": "Array" + } + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"arrayExpression","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "or": { + "overloads": [ + { + "params": [ + {"kinds":["booleanExpression"],"name":"first","type":"BooleanExpression"}, + {"kinds":["booleanExpression"],"name":"second","type":"BooleanExpression"}, + {"kinds":["booleanExpression"],"name":"more","rest":true,"type":"BooleanExpression[]"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "parent": { + "overloads": [ + { + "params": [{"kinds":["documentReference","string"],"name":"documentPath","type":"string | DocumentReference"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"documentPathExpr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "pow": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"base","type":"Expression"}, + {"kinds":["expression"],"name":"exponent","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"base","type":"Expression"}, + {"kinds":["number"],"name":"exponent","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"base","type":"string"}, + {"kinds":["expression"],"name":"exponent","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"base","type":"string"},{"kinds":["number"],"name":"exponent","type":"number"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "rand": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"FunctionExpression"}}]}, + "regexContains": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "regexFind": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "regexFindAll": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "regexMatch": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"pattern","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"pattern","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "reverse": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"stringExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"field","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "round": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["expression","number"],"name":"decimalPlaces","type":"number | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression","number"],"name":"decimalPlaces","type":"number | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "score": {"overloads":[{"params":[],"returns":{"kinds":["expression"],"type":"Expression"}}]}, + "split": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["expression"],"name":"delimiter","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["string"],"name":"delimiter","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"delimiter","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"delimiter","type":"string"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "sqrt": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "startsWith": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["expression"],"name":"prefix","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["string"],"name":"prefix","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"prefix","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"},{"kinds":["string"],"name":"prefix","type":"string"}], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "stringConcat": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"firstString","type":"Expression"}, + {"kinds":["expression","string"],"name":"secondString","type":"Expression | string"}, + {"kinds":["expression","string"],"name":"otherStrings","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression","string"],"name":"secondString","type":"Expression | string"}, + {"kinds":["expression","string"],"name":"otherStrings","rest":true,"type":"Array"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "stringContains": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["expression"],"name":"substring","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["string"],"name":"substring","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"substring","type":"Expression"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["string"],"name":"substring","type":"string"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + }, + "stringReverse": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"stringExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"field","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "subcollection": { + "overloads": [ + { + "params": [ + { + "kinds": ["options"], + "name": "options", + "options": ["SubcollectionStageOptions"], + "type": "SubcollectionStageOptions" + } + ], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + }, + { + "params": [{"kinds":["string"],"name":"path","type":"string"}], + "returns": {"kinds":["pipeline"],"type":"Pipeline"} + } + ] + }, + "substring": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"input","type":"Expression"}, + {"kinds":["expression"],"name":"position","type":"Expression"}, + {"kinds":["expression"],"name":"length","optional":true,"type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"input","type":"Expression"}, + {"kinds":["number"],"name":"position","type":"number"}, + {"kinds":["number"],"name":"length","optional":true,"type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"field","type":"string"}, + {"kinds":["expression"],"name":"position","type":"Expression"}, + {"kinds":["expression"],"name":"length","optional":true,"type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"field","type":"string"}, + {"kinds":["number"],"name":"position","type":"number"}, + {"kinds":["number"],"name":"length","optional":true,"type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "subtract": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"minuend","type":"Expression"}, + {"kinds":["any"],"name":"subtrahend","type":"unknown"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"minuend","type":"Expression"}, + {"kinds":["expression"],"name":"subtrahend","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"minuendFieldName","type":"string"}, + {"kinds":["any"],"name":"subtrahend","type":"unknown"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"minuendFieldName","type":"string"}, + {"kinds":["expression"],"name":"subtrahend","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "sum": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["aggregateFunction"],"type":"AggregateFunction"} + } + ] + }, + "switchOn": { + "overloads": [ + { + "params": [ + {"kinds":["booleanExpression"],"name":"condition","type":"BooleanExpression"}, + {"kinds":["expression"],"name":"result","type":"Expression"}, + { + "kinds": ["booleanExpression","expression"], + "name": "others", + "rest": true, + "type": "Array" + } + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "timestampAdd": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"timestamp","type":"Expression"}, + {"aliases":["TimeUnit"],"kinds":["literal"],"name":"unit","type":"TimeUnit"}, + {"kinds":["number"],"name":"amount","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"timestamp","type":"Expression"}, + {"kinds":["expression"],"name":"unit","type":"Expression"}, + {"kinds":["expression"],"name":"amount","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"aliases":["TimeUnit"],"kinds":["literal"],"name":"unit","type":"TimeUnit"}, + {"kinds":["number"],"name":"amount","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "timestampDiff": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"endExpression","type":"Expression"}, + {"kinds":["expression"],"name":"startExpression","type":"Expression"}, + {"aliases":["TimeUnit"],"kinds":["expression","literal"],"name":"unit","type":"TimeUnit | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"endExpression","type":"Expression"}, + {"kinds":["string"],"name":"startFieldName","type":"string"}, + {"aliases":["TimeUnit"],"kinds":["expression","literal"],"name":"unit","type":"TimeUnit | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"endFieldName","type":"string"}, + {"kinds":["expression"],"name":"startExpression","type":"Expression"}, + {"aliases":["TimeUnit"],"kinds":["expression","literal"],"name":"unit","type":"TimeUnit | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"endFieldName","type":"string"}, + {"kinds":["string"],"name":"startFieldName","type":"string"}, + {"aliases":["TimeUnit"],"kinds":["expression","literal"],"name":"unit","type":"TimeUnit | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "timestampExtract": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"timestampExpression","type":"Expression"}, + {"aliases":["TimePart"],"kinds":["literal"],"name":"part","type":"TimePart"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"timestampExpression","type":"Expression"}, + {"kinds":["expression"],"name":"part","type":"Expression"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"aliases":["TimePart"],"kinds":["literal"],"name":"part","type":"TimePart"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"part","type":"Expression"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "timestampSubtract": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"timestamp","type":"Expression"}, + {"aliases":["TimeUnit"],"kinds":["literal"],"name":"unit","type":"TimeUnit"}, + {"kinds":["number"],"name":"amount","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"timestamp","type":"Expression"}, + {"kinds":["expression"],"name":"unit","type":"Expression"}, + {"kinds":["expression"],"name":"amount","type":"Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"aliases":["TimeUnit"],"kinds":["literal"],"name":"unit","type":"TimeUnit"}, + {"kinds":["number"],"name":"amount","type":"number"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "timestampToUnixMicros": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "timestampToUnixMillis": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "timestampToUnixSeconds": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "timestampTruncate": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"timestampExpression","type":"Expression"}, + {"aliases":["TimeGranularity"],"kinds":["literal"],"name":"granularity","type":"TimeGranularity"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["expression"],"name":"timestampExpression","type":"Expression"}, + {"kinds":["expression"],"name":"granularity","type":"Expression"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"aliases":["TimeGranularity"],"kinds":["literal"],"name":"granularity","type":"TimeGranularity"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression"],"name":"granularity","type":"Expression"}, + {"kinds":["expression","string"],"name":"timezone","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "toLower": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"stringExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "toUpper": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"stringExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "trim": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"stringExpression","type":"Expression"}, + {"kinds":["expression","string"],"name":"valueToTrim","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression","string"],"name":"valueToTrim","optional":true,"type":"string | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "trunc": { + "overloads": [ + { + "params": [ + {"kinds":["expression"],"name":"expression","type":"Expression"}, + {"kinds":["expression","number"],"name":"decimalPlaces","type":"number | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [ + {"kinds":["string"],"name":"fieldName","type":"string"}, + {"kinds":["expression","number"],"name":"decimalPlaces","type":"number | Expression"} + ], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "type": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "unixMicrosToTimestamp": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "unixMillisToTimestamp": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "unixSecondsToTimestamp": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"expr","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "variable": { + "overloads": [ + { + "params": [{"kinds":["string"],"name":"name","type":"string"}], + "returns": {"kinds":["expression"],"type":"Expression"} + } + ] + }, + "vectorLength": { + "overloads": [ + { + "params": [{"kinds":["expression"],"name":"vectorExpression","type":"Expression"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + }, + { + "params": [{"kinds":["string"],"name":"fieldName","type":"string"}], + "returns": {"kinds":["expression"],"type":"FunctionExpression"} + } + ] + }, + "xor": { + "overloads": [ + { + "params": [ + {"kinds":["booleanExpression"],"name":"first","type":"BooleanExpression"}, + {"kinds":["booleanExpression"],"name":"second","type":"BooleanExpression"}, + {"kinds":["booleanExpression"],"name":"additionalConditions","rest":true,"type":"BooleanExpression[]"} + ], + "returns": {"kinds":["booleanExpression"],"type":"BooleanExpression"} + } + ] + } + }, + "interfaces": { + "Selectable": { + "abstract": false, + "extends": null, + "implements": [], + "methods": {}, + "properties": {"selectable":{"kinds":["boolean"],"optional":false,"readonly":false,"static":false,"type":"true"}} + } + }, + "runtimeExports": [ + "AggregateFunction", + "AliasedAggregate", + "AliasedExpression", + "BooleanExpression", + "Constant", + "Expression", + "Field", + "FunctionExpression", + "Ordering", + "Pipeline", + "PipelineResult", + "PipelineSnapshot", + "PipelineSource", + "abs", + "add", + "and", + "array", + "arrayAgg", + "arrayAggDistinct", + "arrayConcat", + "arrayContains", + "arrayContainsAll", + "arrayContainsAny", + "arrayFilter", + "arrayFirst", + "arrayFirstN", + "arrayGet", + "arrayIndexOf", + "arrayIndexOfAll", + "arrayLast", + "arrayLastIndexOf", + "arrayLastN", + "arrayLength", + "arrayMaximum", + "arrayMaximumN", + "arrayMinimum", + "arrayMinimumN", + "arrayReverse", + "arraySlice", + "arraySum", + "arrayTransform", + "arrayTransformWithIndex", + "ascending", + "average", + "byteLength", + "ceil", + "charLength", + "coalesce", + "collectionId", + "concat", + "conditional", + "constant", + "cosineDistance", + "count", + "countAll", + "countDistinct", + "countIf", + "currentDocument", + "currentTimestamp", + "descending", + "divide", + "documentId", + "documentMatches", + "dotProduct", + "endsWith", + "equal", + "equalAny", + "euclideanDistance", + "exists", + "exp", + "field", + "first", + "floor", + "geoDistance", + "getField", + "greaterThan", + "greaterThanOrEqual", + "ifAbsent", + "ifError", + "ifNull", + "isAbsent", + "isError", + "isType", + "join", + "last", + "length", + "lessThan", + "lessThanOrEqual", + "like", + "ln", + "log10", + "logicalMaximum", + "logicalMinimum", + "ltrim", + "map", + "mapEntries", + "mapGet", + "mapKeys", + "mapMerge", + "mapRemove", + "mapSet", + "mapValues", + "maximum", + "minimum", + "mod", + "multiply", + "nor", + "not", + "notEqual", + "notEqualAny", + "or", + "parent", + "pow", + "rand", + "regexContains", + "regexFind", + "regexFindAll", + "regexMatch", + "reverse", + "round", + "rtrim", + "score", + "split", + "sqrt", + "startsWith", + "stringConcat", + "stringContains", + "stringIndexOf", + "stringRepeat", + "stringReplaceAll", + "stringReplaceOne", + "stringReverse", + "subcollection", + "substring", + "subtract", + "sum", + "switchOn", + "timestampAdd", + "timestampDiff", + "timestampExtract", + "timestampSubtract", + "timestampToUnixMicros", + "timestampToUnixMillis", + "timestampToUnixSeconds", + "timestampTruncate", + "toLower", + "toUpper", + "trim", + "trunc", + "type", + "unixMicrosToTimestamp", + "unixMillisToTimestamp", + "unixSecondsToTimestamp", + "variable", + "vectorLength", + "xor" + ], + "runtimeOnlyFunctions": { + "floor": {"module":"expression","params":[{"name":"expr"}]}, + "ltrim": {"module":"expression","params":[{"name":"expr"},{"name":"valueToTrim"}]}, + "rtrim": {"module":"expression","params":[{"name":"expr"},{"name":"valueToTrim"}]}, + "stringIndexOf": {"module":"expression","params":[{"name":"expr"},{"name":"search"}]}, + "stringRepeat": {"module":"expression","params":[{"name":"expr"},{"name":"repetitions"}]}, + "stringReplaceAll": {"module":"expression","params":[{"name":"expr"},{"name":"find"},{"name":"replacement"}]}, + "stringReplaceOne": {"module":"expression","params":[{"name":"expr"},{"name":"find"},{"name":"replacement"}]} + }, + "runtimeOnlyMembers": { + "Expression": { + "arrayReverse": {"kind":"method","module":"expression.js","params":[]}, + "concat": {"kind":"method","module":"expression.js","params":[{"name":"second"},{"name":"others","rest":true}]} + }, + "PipelineResult": {"protoField":{"kind":"method","module":"pipelines.js","params":[{"name":"field"}]}} + }, + "source": { + "generator": "packages/google_cloud_firestore/tool/pipeline_api_parity", + "namespace": "FirebaseFirestore.Pipelines", + "package": "@google-cloud/firestore", + "runtimeIndex": "build/src/pipelines/index.js", + "typings": "types/firestore.d.ts", + "version": "9.3.1" + }, + "types": { + "AddFieldsStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": {"fields":{"elements":{"kinds":["selectable"]},"kinds":["array"],"optional":false,"type":"Selectable[]"}} + }, + "AggregateStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "accumulators": {"elements":{"kinds":["aliasedAggregate"]},"kinds":["array"],"optional":false,"type":"AliasedAggregate[]"}, + "groups": { + "elements": {"kinds":["selectable","string"]}, + "kinds": ["array"], + "optional": true, + "type": "Array" + } + } + }, + "CollectionGroupStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "collectionId": {"kinds":["string"],"optional":false,"type":"string"}, + "forceIndex": {"kinds":["string"],"optional":true,"type":"string"} + } + }, + "CollectionStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "collection": {"kinds":["collectionReference","string"],"optional":false,"type":"string | CollectionReference"}, + "forceIndex": {"kinds":["string"],"optional":true,"type":"string"} + } + }, + "DatabaseStageOptions": {"extends":["StageOptions"],"kind":"object","properties":{}}, + "DefineStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "variables": {"elements":{"kinds":["aliasedExpression"]},"kinds":["array"],"optional":false,"type":"AliasedExpression[]"} + } + }, + "DistinctStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "groups": { + "elements": {"kinds":["selectable","string"]}, + "kinds": ["array"], + "optional": false, + "type": "Array" + } + } + }, + "DocumentsStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "docs": { + "elements": {"kinds":["documentReference","string"]}, + "kinds": ["array"], + "optional": false, + "type": "Array" + } + } + }, + "ExplainStatsFieldValue": { + "elements": {"kinds":["explainStatsFieldValue"]}, + "kind": "other", + "kinds": ["array","boolean","map","null","number","string"], + "type": "null | string | number | boolean | {[key: string]: ExplainStatsFieldValue;} | ExplainStatsFieldValue[]" + }, + "ExpressionType": { + "kind": "stringUnion", + "values": ["AggregateFunction","AliasedExpression","Constant","Field","Function","ListOfExprs","PipelineValue","Variable"] + }, + "FindNearestStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "distanceField": {"kinds":["string"],"optional":true,"type":"string"}, + "distanceMeasure": { + "kinds": ["literal"], + "literals": ["cosine","dot_product","euclidean"], + "optional": false, + "type": "'euclidean' | 'cosine' | 'dot_product'" + }, + "field": {"kinds":["field","string"],"optional":false,"type":"Field | string"}, + "limit": {"kinds":["number"],"optional":true,"type":"number"}, + "vectorValue": { + "elements": {"kinds":["number"]}, + "kinds": ["array","vectorValue"], + "optional": false, + "type": "VectorValue | number[]" + } + } + }, + "LimitStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": {"limit":{"kinds":["number"],"optional":false,"type":"number"}} + }, + "OffsetStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": {"offset":{"kinds":["number"],"optional":false,"type":"number"}} + }, + "PipelineExecuteOptions": { + "kind": "object", + "properties": { + "explainOptions": { + "kinds": ["map"], + "optional": true, + "properties": { + "mode": {"kinds":["literal"],"literals":["analyze","execute"],"optional":true,"type":"'execute' | 'analyze'"}, + "outputFormat": {"kinds":["literal"],"literals":["text"],"optional":true,"type":"'text'"} + }, + "type": "{mode?: 'execute' | 'analyze'; outputFormat?: 'text';}" + }, + "indexMode": {"kinds":["literal"],"literals":["recommended"],"optional":true,"type":"'recommended'"}, + "rawOptions": { + "kinds": ["map"], + "optional": true, + "properties": {"[key]":{"kinds":["any"],"optional":true,"type":"unknown"}}, + "type": "{[name: string]: unknown;}" + } + } + }, + "RemoveFieldsStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "fields": {"elements":{"kinds":["field","string"]},"kinds":["array"],"optional":false,"type":"Array"} + } + }, + "ReplaceWithStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": {"map":{"kinds":["expression","string"],"optional":false,"type":"Expression | string"}} + }, + "SampleStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "oneOf": [["documents","percentage"]], + "properties": { + "documents": {"kinds":["number"],"optional":true,"type":"number"}, + "percentage": {"kinds":["number"],"optional":true,"type":"number"} + } + }, + "SearchStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "addFields": {"elements":{"kinds":["selectable"]},"kinds":["array"],"optional":true,"type":"Selectable[]"}, + "languageCode": {"kinds":["string"],"optional":true,"type":"string"}, + "limit": {"kinds":["number"],"optional":true,"type":"number"}, + "offset": {"kinds":["number"],"optional":true,"type":"number"}, + "query": {"kinds":["booleanExpression","string"],"optional":false,"type":"BooleanExpression | string"}, + "retrievalDepth": {"kinds":["number"],"optional":true,"type":"number"}, + "sort": {"elements":{"kinds":["ordering"]},"kinds":["array","ordering"],"optional":true,"type":"Ordering | Ordering[]"} + } + }, + "SelectStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "selections": { + "elements": {"kinds":["selectable","string"]}, + "kinds": ["array"], + "optional": false, + "type": "Array" + } + } + }, + "SortStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": {"orderings":{"elements":{"kinds":["ordering"]},"kinds":["array"],"optional":false,"type":"Ordering[]"}} + }, + "StageOptions": { + "kind": "object", + "properties": { + "rawOptions": { + "kinds": ["map"], + "optional": true, + "properties": {"[key]":{"kinds":["any"],"optional":true,"type":"unknown"}}, + "type": "{[name: string]: unknown;}" + } + } + }, + "SubcollectionStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": {"path":{"kinds":["string"],"optional":false,"type":"string"}} + }, + "TimeGranularity": { + "kind": "stringUnion", + "values": [ + "day", + "hour", + "isoweek", + "isoyear", + "microsecond", + "millisecond", + "minute", + "month", + "quarter", + "second", + "week", + "week(friday)", + "week(monday)", + "week(saturday)", + "week(sunday)", + "week(thursday)", + "week(tuesday)", + "week(wednesday)", + "year" + ] + }, + "TimePart": { + "kind": "stringUnion", + "values": [ + "day", + "dayofweek", + "dayofyear", + "hour", + "isoweek", + "isoyear", + "microsecond", + "millisecond", + "minute", + "month", + "quarter", + "second", + "week", + "week(friday)", + "week(monday)", + "week(saturday)", + "week(sunday)", + "week(thursday)", + "week(tuesday)", + "week(wednesday)", + "year" + ] + }, + "TimeUnit": {"kind":"stringUnion","values":["day","hour","microsecond","millisecond","minute","second"]}, + "Type": { + "kind": "stringUnion", + "values": [ + "array", + "boolean", + "bytes", + "decimal128", + "float64", + "geo_point", + "int32", + "int64", + "map", + "max_key", + "min_key", + "null", + "number", + "object_id", + "reference", + "regex", + "request_timestamp", + "string", + "timestamp", + "vector" + ] + }, + "UnionStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": {"other":{"kinds":["pipeline"],"optional":false,"type":"Pipeline"}} + }, + "UnnestStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": { + "indexField": {"kinds":["string"],"optional":true,"type":"string"}, + "selectable": {"kinds":["selectable"],"optional":false,"type":"Selectable"} + } + }, + "WhereStageOptions": { + "extends": ["StageOptions"], + "kind": "object", + "properties": {"condition":{"kinds":["booleanExpression"],"optional":false,"type":"BooleanExpression"}} + } + } +} diff --git a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart new file mode 100644 index 00000000..25224efa --- /dev/null +++ b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart @@ -0,0 +1,1695 @@ +// 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. + +/// Compares the signatures of the Dart Pipelines API with the Node.js SDK's. +/// +/// The Node API is read from `test/fixtures/node_pipeline_api.json`, which +/// `tool/pipeline_api_parity` extracts from the pinned +/// `@google-cloud/firestore` typings. The Dart API is read with +/// `package:analyzer` from what `lib/google_cloud_firestore.dart` exports. +/// +/// How Node maps onto Dart: +/// +/// * A Node top-level function `x` is `PipelineFunctions.x`, a top-level `x` +/// from `lib/src/pipeline*.dart`, or `Expression.x`. Every one of those that +/// exists is compared. +/// * A Node class maps onto the Dart type in [_classes]; its methods and +/// properties are looked up on that type, inherited members included. +/// * A Node options object (`CollectionStageOptions`, ...) maps onto Dart +/// named parameters; a stage's required keys may instead be positional. +/// * A Node string union maps onto the `value`s of a Dart enum ([_enums]). +/// +/// Signatures are compared on shape, not on exact types: argument counts, +/// which positions are optional, whether a parameter takes a single value or +/// a list, and whether Dart's type accepts each kind of value Node does (an +/// expression, a string, a number, ...). A Node rest parameter matches a +/// trailing Dart `Iterable`, either in its own position or folding the +/// arguments before it, as in `and(Iterable)` for +/// Node's `and(first, second, ...more)`. +/// +/// Every difference is reported as `id: message`, one per line. An `id` is +/// the Dart member (`PipelineFunctions.join`, `Pipeline.where`, `field` for a +/// top-level function) optionally followed by `#` and what differs: `#arity`, +/// a Dart parameter name, a Node option key, or `#returns`. Entries in +/// [_knownDifferences] and [_pendingFixes] are keyed by a full `id`, or by a +/// bare member to cover all of its differences. Stale entries fail the test. +@TestOn('vm') +@Timeout(Duration(minutes: 5)) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:analyzer/dart/analysis/analysis_context_collection.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:analyzer/dart/element/type_provider.dart'; +import 'package:analyzer/dart/element/type_system.dart'; +import 'package:test/test.dart'; + +/// The Node SDK version the snapshot must come from. +const _nodeVersion = '9.3.1'; + +/// Node member names that Dart spells differently, keyed by +/// `NodeOwner.member` (a class, `Transaction`, or an options type). +const _renames = { + // Named after Dart's String.toLowerCase()/toUpperCase(). + 'Expression.toLower': 'toLowerCase', + 'Expression.toUpper': 'toUpperCase', + 'Field.fieldName': 'path', + 'PipelineResult.isEqual': '==', + // Dart decodes the protobuf Any into a map; Node returns it as is. + 'ExplainStats.rawData': 'raw', + 'Transaction.execute': 'executePipeline', + 'PipelineExecuteOptions.explainOptions': 'explain', + // Named like the VectorQueryOptions they are built from. + 'FindNearestStageOptions.field': 'vectorField', + 'FindNearestStageOptions.vectorValue': 'queryVector', + 'FindNearestStageOptions.distanceField': 'distanceResultField', +}; + +/// The Dart type standing for each Node class or interface. +const _classes = { + 'Expression': 'PipelineExpression', + // Node's implementation classes; Dart keeps them private and builds them + // with constant() and pipelineFunction(). Their constructors are not + // compared. + 'Constant': 'PipelineExpression', + 'FunctionExpression': 'PipelineExpression', + 'AggregateFunction': 'PipelineAggregateFunction', + 'BooleanExpression': 'PipelineBooleanExpression', + 'Field': 'PipelineField', + 'AliasedExpression': 'PipelineAliasedExpression', + // Dart aliases aggregates like any other expression. + 'AliasedAggregate': 'PipelineAliasedExpression', + 'Selectable': 'Selectable', + 'Ordering': 'PipelineOrdering', + 'Pipeline': 'Pipeline', + 'PipelineSource': 'PipelineSource', + 'PipelineResult': 'PipelineResult', + 'PipelineSnapshot': 'PipelineSnapshot', + 'ExplainStats': 'ExplainStats', +}; + +/// The Dart enum standing for each Node string union, compared by `value`. +const _enums = {'Type': 'PipelineValueType'}; + +/// Dart types that Node models as option objects or inline string unions, +/// and where. They are compared through the named parameters that use them. +const _optionTypes = { + 'PipelineExplainOptions': 'PipelineExecuteOptions.explainOptions', + 'PipelineExplainMode': 'PipelineExecuteOptions.explainOptions.mode', + 'PipelineExplainOutputFormat': + 'PipelineExecuteOptions.explainOptions.outputFormat', + 'PipelineIndexMode': 'PipelineExecuteOptions.indexMode', +}; + +/// Dart classes whose static methods stand for Node's top-level functions. +const _functionNamespaces = {'PipelineFunctions', 'Expression'}; + +/// Node properties that only exist for TypeScript runtime type tests, such as +/// `expr.expressionType === 'Field'` or `'selectable' in value`. Dart uses its +/// type system (sealed classes and `is`) instead. +const _nodeTypeTags = {'expressionType', 'selectable'}; + +/// Public Dart members with no Node counterpart, and why. +/// +/// `xLiteral` variants of an existing `x` are allowed without an entry; their +/// arity is still compared with Node's `x`. +const _dartOnly = { + 'Expression.raw': + 'Escape hatch for backend functions this SDK does not wrap.', + 'Expression.vector': 'Shorthand for constant(FieldValue.vector(...)).', + 'PipelineFunctions.raw': 'Escape hatch, same as pipelineFunction().', + 'PipelineFunctions.maximumN': + 'Named after the backend maximum_n function; same as arrayMaximumN.', + 'PipelineFunctions.minimumN': + 'Named after the backend minimum_n function; same as arrayMinimumN.', + 'pipelineFunction': + 'Escape hatch for backend functions this SDK does not wrap; Node only ' + 'offers `new FunctionExpression(name, params)`.', + 'PipelineExpression.arrayConcatMultiple': + "Dart's arrayConcat takes one array; this is Node's variadic form.", + 'PipelineExpression.arrayContainsAllFrom': + 'Node overloads arrayContainsAll(values | arrayExpression); Dart splits ' + 'off the expression form.', + 'PipelineExpression.geoDistance': + 'Node only offers geoDistance on Field; Dart allows any geo point ' + 'expression.', + 'PipelineAliasedExpression.expression': + 'Node exposes it as the underscored _expr.', + 'PipelineAliasedExpression.name': + 'Node exposes it as the underscored _alias.', + 'Pipeline.firestore': 'Node keeps the Firestore instance private.', + 'PipelineResult.name': + 'The document name as returned by the backend; Node only exposes ref.', + 'PipelineSnapshot.empty': 'Mirrors QuerySnapshot.empty.', + 'PipelineSnapshot.size': 'Mirrors QuerySnapshot.size.', + 'ExplainStats.typeName': 'Node exposes it as rawData.type_url.', +}; + +/// Intentional differences, keyed by mismatch id or bare member. +const _knownDifferences = { + 'PipelineFunctions.count#arity': + 'count() with no argument counts every input, like countAll(); Node ' + 'requires an argument.', + 'PipelineExpression.arrayConcat#arity': + 'Takes a single array; the variadic form is arrayConcatMultiple.', + 'PipelineExpression.arrayContainsAll#values': + 'Takes an Iterable; an array expression goes to arrayContainsAllFrom.', + 'PipelineSource.collection#collectionPath': + 'Takes a path; a CollectionReference goes to collectionReference(), ' + 'since Dart has no overloads.', + 'PipelineSource.collectionReference': + 'The CollectionReference form of Node collection().', + 'Pipeline.addFields#fields': + 'Only aliased expressions: adding a bare field to itself is a no-op.', + 'Pipeline.search': + 'The search stage is still evolving; Dart passes a raw option map ' + 'through instead of typing SearchStageOptions.', + 'Pipeline.rawStage#options': + "Node's rawStage(name, params, options) takes options at runtime, but " + 'its typings omit them.', + 'Pipeline.execute#readTime': + 'Reads at a past time (ExecutePipelineRequest.read_time); Node only ' + 'passes a read time internally.', + 'Transaction.executePipeline': + 'Takes the same execute options as Pipeline.execute; Node takes none.', + 'PipelineValueType#request_timestamp': + "The backend's is_type rejects it: its list of accepted types does not " + "include 'request_timestamp'.", + 'type:ExpressionType': 'The values of the expressionType type tag.', + 'type:TimeGranularity': 'Dart takes these as plain strings (Object?).', + 'type:TimePart': 'Dart takes these as plain strings (Object?).', + 'type:TimeUnit': 'Dart takes these as plain strings (Object?).', +}; + +/// Differences that look like Dart bugs or gaps, to be fixed in `lib/`. +/// +/// Remove an entry once its fix lands; the test fails while it is stale. +const _pendingFixes = { + // Signatures. + 'PipelineFunctions.add#arity': + 'Node add(first, second, ...others) is variadic; Dart add(left, right) ' + 'takes exactly two.', + 'PipelineFunctions.multiply#arity': + 'Node multiply(first, second, ...others) is variadic; Dart takes two.', + 'PipelineExpression.add#arity': + 'Node add(second, ...others) is variadic; Dart add(other) takes one.', + 'PipelineExpression.multiply#arity': + 'Node multiply(second, ...others) is variadic; Dart takes one.', + 'PipelineFunctions.arraySlice#arity': + 'Node arraySlice(array, offset, length?) makes length optional, like ' + 'PipelineExpression.arraySlice; the static requires it.', + 'PipelineFunctions.arrayTransform#arity': + 'Takes a 4th optional indexVariableName after the transform; Node has ' + 'arrayTransform(array, elementAlias, transform) and a separate ' + 'arrayTransformWithIndex(array, elementAlias, indexAlias, transform).', + 'PipelineFunctions.map#keyValues': + 'Takes an Iterable of alternating keys and values; Node ' + 'map(elements: Record) takes a map, which Dart ' + 'callers cannot pass.', + 'PipelineExpression.arrayContainsAny#values': + 'Only takes an Iterable; Node also takes an array expression and Dart ' + 'has no arrayContainsAnyFrom, unlike arrayContainsAll.', + 'field#fieldPath': + 'Only takes a String; Node field() also takes a FieldPath.', + 'Expression.field#fieldPath': + 'Only takes a String; Node field() also takes a FieldPath.', + 'PipelineSource.documents#documents': + 'Only takes DocumentReferences; Node documents() also takes document ' + 'path strings.', + 'Pipeline.findNearest#distanceThreshold': + 'Dart-only option, sent as `distance_threshold`: Node has no such ' + 'find_nearest option and its createFrom(vectorQuery) drops ' + 'distanceThreshold. Confirm the backend accepts it.', + // Options. + 'PipelineSource.collection#forceIndex': + 'Node collection({collection, forceIndex}) can force an index; Dart ' + 'cannot.', + 'PipelineSource.collectionGroup#forceIndex': + 'Node collectionGroup({collectionId, forceIndex}) can force an index; ' + 'Dart cannot.', + 'StageOptions#rawOptions': + 'Every Node stage takes rawOptions; Dart stages only take them through ' + 'rawStage(options:).', + // Dart-only functions with no Node counterpart. + 'PipelineFunctions.log': + 'Node has no log function; check the backend supports `log`, or drop.', + 'PipelineExpression.log': + 'Node has no log method; check the backend supports `log`, or drop.', + 'PipelineFunctions.cmp': + 'Node has no cmp function; check the backend supports `cmp`, or drop.', + 'PipelineFunctions.referenceSlice': + 'Node has no referenceSlice; check the backend supports ' + '`reference_slice`, or drop.', + 'PipelineExpression.referenceSlice': + 'Node has no referenceSlice; check the backend supports ' + '`reference_slice`, or drop.', + // Node APIs Dart does not implement yet. + 'PipelineFunctions.arrayTransformWithIndex': + 'Missing static; only PipelineExpression.arrayTransformWithIndex exists.', + 'PipelineFunctions.subcollection': + 'Missing: Node subcollection(path | options) starts a subcollection ' + 'Pipeline for use inside another stage.', + 'Pipeline.define': 'Missing stage: Node define(...aliasedExpressions).', + 'Pipeline.delete': 'Missing stage: Node delete().', + 'Pipeline.update': 'Missing stage: Node update(transformedFields?).', + 'Pipeline.toArrayExpression': + 'Missing: Node turns a Pipeline into an array subquery expression.', + 'Pipeline.toScalarExpression': + 'Missing: Node turns a Pipeline into a scalar subquery expression.', + 'Pipeline.stream': 'Missing: Node streams results; Dart only has execute().', + 'PipelineOrdering.expr': + 'Missing getter: Node Ordering exposes its expression.', + 'PipelineOrdering.direction': + "Missing getter: Node Ordering exposes 'ascending' | 'descending'.", +}; + +void main() { + late _Report report; + + setUpAll(() async { + final node = _NodeApi.load( + File('test/fixtures/node_pipeline_api.json').readAsStringSync(), + ); + final dart = await _DartApi.load(Directory.current.path); + report = _Checker(node, dart).run(); + }); + + test('the snapshot comes from the pinned Node SDK', () { + expect(report.node.formatVersion, 1); + expect(report.node.version, _nodeVersion); + }); + + test('the Dart Pipeline API matches the Node SDK signatures', () { + final unexplained = [ + for (final mismatch in report.mismatches) + if (report.coveringKey(mismatch) == null) mismatch, + ]; + if (unexplained.isNotEmpty) { + fail( + '${unexplained.length} difference(s) from the Node SDK ' + '$_nodeVersion. Fix the Dart API, or record the difference in ' + '_knownDifferences (intentional) or _pendingFixes (to fix):\n' + '${unexplained.join('\n')}', + ); + } + }); + + test('the parity tables have no stale entries', () { + final problems = [ + ...report.problems, + for (final key in _knownDifferences.keys) + if (_pendingFixes.containsKey(key)) + '$key: in both _knownDifferences and _pendingFixes', + for (final MapEntry(key: name, value: entries) in { + '_knownDifferences': _knownDifferences, + '_pendingFixes': _pendingFixes, + }.entries) + for (final key in entries.keys) + if (!report.mismatches.any((m) => m.matches(key))) + '$key: stale $name entry, the difference is gone', + for (final key in _dartOnly.keys) + if (!report.dartOnlyUsed.contains(key)) + '$key: stale _dartOnly entry, Node has it or Dart no longer does', + for (final key in _renames.keys) + if (!report.renamesUsed.contains(key)) + '$key: stale _renames entry, Node or Dart no longer has it', + ]; + if (problems.isNotEmpty) fail(problems.join('\n')); + }); +} + +// --- Node API ---------------------------------------------------------------- + +final class _NodeType { + _NodeType.fromJson(Map json) + : kinds = _strings(json['kinds']), + literals = _strings(json['literals']), + aliases = _strings(json['aliases']), + options = _strings(json['options']), + elements = json['elements'] == null + ? null + : _NodeType.fromJson(json['elements']! as Map), + properties = { + for (final MapEntry(:key, :value) + in ((json['properties'] as Map?) ?? {}).entries) + key: _NodeProperty.fromJson(value! as Map), + }; + + _NodeType.union(Iterable<_NodeType> types) + : kinds = {for (final type in types) ...type.kinds}, + literals = {for (final type in types) ...type.literals}, + aliases = {for (final type in types) ...type.aliases}, + options = {for (final type in types) ...type.options}, + elements = _unionOrNull([for (final type in types) ?type.elements]), + properties = const {}; + + static _NodeType? _unionOrNull(List<_NodeType> types) { + return types.isEmpty ? null : _NodeType.union(types); + } + + final Set kinds; + final Set literals; + final Set aliases; + final Set options; + final _NodeType? elements; + final Map properties; + + String get label { + final sorted = kinds.toList()..sort(); + return sorted.join('|'); + } +} + +final class _NodeProperty { + _NodeProperty.fromJson(Map json) + : optional = json['optional'] == true, + type = _NodeType.fromJson(json); + + final bool optional; + final _NodeType type; +} + +final class _NodeParam { + _NodeParam.fromJson(Map json) + : name = json['name']! as String, + optional = json['optional'] == true, + rest = json['rest'] == true, + // Runtime-only members have no types: anything goes. + type = json.containsKey('kinds') + ? _NodeType.fromJson(json) + : _NodeType.fromJson(const { + 'kinds': ['any'], + }); + + final String name; + final bool optional; + final bool rest; + final _NodeType type; + + bool get required => !optional && !rest; + + bool get isOptions => + type.kinds.length == 1 && type.kinds.single == 'options'; +} + +final class _NodeSignature { + _NodeSignature.fromJson(Map json) + : params = [ + for (final param in json['params']! as List) + _NodeParam.fromJson(param as Map), + ], + returns = json['returns'] == null + ? null + : _NodeType.fromJson(json['returns']! as Map); + + final List<_NodeParam> params; + final _NodeType? returns; +} + +final class _NodeMember { + _NodeMember({ + required this.owner, + required this.name, + required this.isMethod, + required this.overloads, + }) : runtimeOnly = false; + + /// A member only the JavaScript implementation has, without types. + _NodeMember.runtimeOnly({ + required this.owner, + required this.name, + required Map json, + }) : isMethod = json['kind'] != 'getter', + overloads = [ + if (json['kind'] != 'getter') _NodeSignature.fromJson(json), + ], + runtimeOnly = true; + + final String owner; + final String name; + final bool isMethod; + final List<_NodeSignature> overloads; + + /// Whether only the JavaScript implementation has this member: the typings + /// omit it, so it is not part of the contract and has no types. + final bool runtimeOnly; +} + +final class _NodeClass { + _NodeClass(this.name, Map json, Map? js) + : superclass = json['extends'] as String?, + interfaces = _strings(json['implements']), + members = { + for (final MapEntry(:key, :value) + in (json['methods']! as Map).entries) + key: _NodeMember( + owner: name, + name: key, + isMethod: true, + overloads: _signatures(value), + ), + for (final key in (json['properties']! as Map).keys) + key: _NodeMember( + owner: name, + name: key, + isMethod: false, + overloads: const [], + ), + for (final MapEntry(:key, :value) in (js ?? {}).entries) + key: _NodeMember.runtimeOnly( + owner: name, + name: key, + json: value! as Map, + ), + }; + + final String name; + final String? superclass; + final Set interfaces; + final Map members; +} + +final class _NodeApi { + _NodeApi._(this._json); + + factory _NodeApi.load(String source) { + return _NodeApi._(jsonDecode(source) as Map); + } + + final Map _json; + + int get formatVersion => _json['formatVersion']! as int; + + String get version => (_json['source']! as Map)['version']! as String; + + late final Map functions = { + for (final MapEntry(:key, :value) + in (_json['functions']! as Map).entries) + key: _NodeMember( + owner: '', + name: key, + isMethod: true, + overloads: _signatures(value), + ), + for (final MapEntry(:key, :value) + in (_json['runtimeOnlyFunctions']! as Map).entries) + key: _NodeMember.runtimeOnly( + owner: '', + name: key, + json: value! as Map, + ), + }; + + late final Map classes = () { + final js = _json['runtimeOnlyMembers']! as Map; + return { + for (final section in ['classes', 'interfaces']) + for (final MapEntry(:key, :value) + in (_json[section]! as Map).entries) + key: _NodeClass( + key, + value! as Map, + js[key] as Map?, + ), + }; + }(); + + late final Map> types = { + for (final MapEntry(:key, :value) + in (_json['types']! as Map).entries) + key: value! as Map, + }; + + /// The pipeline entry points declared outside the namespace, by owner. + late final Map> entryPoints = () { + final result = >{}; + for (final MapEntry(:key, :value) + in (_json['entryPoints']! as Map).entries) { + final [owner, name] = key.split('.'); + (result[owner] ??= {})[name] = _NodeMember( + owner: owner, + name: name, + isMethod: true, + overloads: _signatures(value), + ); + } + return result; + }(); + + /// A member of [className] or of the classes it extends or implements. + _NodeMember? lookUp(String className, String member) { + final cls = classes[className]; + if (cls == null) return null; + return cls.members[member] ?? + [ + ?cls.superclass, + ...cls.interfaces, + ].map((name) => lookUp(name, member)).nonNulls.firstOrNull; + } + + /// The string values of a literal [type], resolving named unions. + Set literalValues(_NodeType type) { + return { + ...type.literals, + for (final alias in type.aliases) ..._strings(types[alias]?['values']), + }; + } + + /// The properties of the options object [name], without its base types. + Map optionProperties(String name) { + return _NodeType.fromJson({ + 'kinds': const ['options'], + 'properties': types[name]!['properties'], + }).properties; + } + + Set optionBases(String name) => _strings(types[name]!['extends']); +} + +Set _strings(Object? json) => {...?(json as List?)?.cast()}; + +List<_NodeSignature> _signatures(Object? json) { + return [ + for (final overload in (json! as Map)['overloads']! as List) + _NodeSignature.fromJson(overload as Map), + ]; +} + +// --- Dart API ---------------------------------------------------------------- + +final class _DartApi { + _DartApi._(this.barrel, this.typedData, this.core); + + static Future<_DartApi> load(String packageRoot) async { + final collection = AnalysisContextCollection(includedPaths: [packageRoot]); + final session = collection.contextFor(packageRoot).currentSession; + Future library(String uri) async { + final result = await session.getLibraryByUri(uri); + if (result is! LibraryElementResult) { + throw StateError('Could not resolve $uri: $result'); + } + return result.element; + } + + return _DartApi._( + await library( + 'package:google_cloud_firestore/google_cloud_firestore.dart', + ), + await library('dart:typed_data'), + await library('dart:core'), + ); + } + + final LibraryElement barrel; + final LibraryElement typedData; + final LibraryElement core; + + TypeSystem get typeSystem => barrel.typeSystem; + + TypeProvider get typeProvider => barrel.typeProvider; + + late final Map exports = + barrel.exportNamespace.definedNames2; + + /// The class an exported name stands for, looking through typedefs. + InterfaceElement? interfaceNamed(String name) { + return switch (exports[name]) { + final InterfaceElement element => element, + final TypeAliasElement alias => switch (alias.aliasedType) { + final InterfaceType type => type.element, + _ => null, + }, + _ => null, + }; + } + + /// [element]'s type with its type parameters at their bounds. + InterfaceType instantiate(InterfaceElement element) { + return typeSystem.instantiateInterfaceToBounds( + element: element, + nullabilitySuffix: NullabilitySuffix.none, + ); + } + + InterfaceType exportedType(String name) { + final element = interfaceNamed(name); + if (element == null) throw StateError('$name is not exported.'); + return instantiate(element); + } + + InterfaceType libraryType(LibraryElement library, String name) { + return instantiate( + library.exportNamespace.definedNames2[name]! as InterfaceElement, + ); + } + + /// The library declaring the Pipeline API (`lib/src/firestore.dart`). + late final LibraryElement implementation = interfaceNamed( + 'PipelineFunctions', + )!.library; + + /// Whether [element] is declared in a `lib/src/pipeline*.dart` file. + bool isPipelineDeclaration(Element element) { + final path = element.firstFragment.libraryFragment?.source.fullName ?? ''; + return RegExp( + r'[/\\]lib[/\\]src[/\\]pipeline[^/\\]*\.dart$', + ).hasMatch(path) || + RegExp(r'[/\\]lib[/\\]src[/\\]pipeline[/\\]').hasMatch(path); + } + + /// Every public top-level declaration of the Pipeline API files. + late final List pipelineDeclarations = [ + for (final element in [ + ...implementation.classes, + ...implementation.enums, + ...implementation.mixins, + ...implementation.extensions, + ...implementation.extensionTypes, + ...implementation.typeAliases, + ...implementation.topLevelFunctions, + ...implementation.topLevelVariables, + ]) + if (element.isPublic && isPipelineDeclaration(element)) element, + ]; + + late final Map topLevelFunctions = { + for (final element + in pipelineDeclarations.whereType()) + element.name!: element, + }; + + /// Public instance members of [element], inherited ones included unless + /// [declaredOnly]. + Map members( + InterfaceElement element, { + bool declaredOnly = false, + }) { + final result = {}; + final chain = [ + element, + if (!declaredOnly) + for (final type in element.allSupertypes) + if (!type.isDartCoreObject) type.element, + ]; + for (final cls in chain) { + for (final member in [ + ...cls.methods, + ...cls.getters, + ]) { + final name = member.name; + if (name == null || member.isStatic || name.startsWith('_')) continue; + result.putIfAbsent(name, () => member); + } + } + return result; + } + + Map statics(InterfaceElement element) { + return { + for (final method in element.methods) + if (method.isStatic && method.isPublic) method.name!: method, + }; + } + + /// The `value` of each constant of [element], an enum. + Set enumValues(EnumElement element) { + return { + for (final constant in element.constants) + ?constant.computeConstantValue()?.getField('value')?.toStringValue(), + }; + } +} + +final class _DartCallable { + _DartCallable(this.id, this.element); + + final String id; + final ExecutableElement element; + + bool get isGetter => element is GetterElement; + + List get positional => [ + for (final param in element.formalParameters) + if (!param.isNamed) param, + ]; + + Map get named => { + for (final param in element.formalParameters) + if (param.isNamed) param.name!: param, + }; + + int get requiredPositional => + positional.where((param) => param.isRequiredPositional).length; + + String render() { + final positional = this.positional; + final required = [ + for (final param in positional) + if (param.isRequiredPositional) _renderParam(param), + ]; + final optional = [ + for (final param in positional) + if (param.isOptionalPositional) _renderParam(param), + ]; + final named = [ + for (final param in this.named.values) + '${param.isRequiredNamed ? 'required ' : ''}${_renderParam(param)}', + ]; + final parts = [ + ...required, + if (optional.isNotEmpty) '[${optional.join(', ')}]', + if (named.isNotEmpty) '{${named.join(', ')}}', + ]; + return '${element.name}(${parts.join(', ')})'; + } + + static String _renderParam(FormalParameterElement param) { + return '${param.type.getDisplayString()} ${param.name}'; + } +} + +// --- Comparison -------------------------------------------------------------- + +final class _Mismatch { + _Mismatch(this.id, this.message); + + final String id; + final String message; + + String get member => id.split('#').first; + + bool matches(String key) => key == id || key == member; + + @override + String toString() => '$id: $message'; +} + +final class _Report { + _Report(this.node); + + final _NodeApi node; + final List<_Mismatch> mismatches = []; + + /// Problems with the tables themselves, such as unknown names. + final List problems = []; + final Set dartOnlyUsed = {}; + final Set renamesUsed = {}; + + String? coveringKey(_Mismatch mismatch) { + for (final table in [_knownDifferences, _pendingFixes]) { + if (table.containsKey(mismatch.id)) return mismatch.id; + if (table.containsKey(mismatch.member)) return mismatch.member; + } + return null; + } +} + +/// Signature style of a callable, which decides what may differ. +enum _Style { + /// Functions and expression methods. + plain, + + /// Pipeline stages: options objects map onto named parameters. + stage, + + /// `xLiteral` conveniences: only the arity is compared. + literal, +} + +final class _Checker { + _Checker(this.node, this.dart) : report = _Report(node); + + final _NodeApi node; + final _DartApi dart; + final _Report report; + + /// For each base option key (`StageOptions.rawOptions`), the stages that + /// lack it in Dart, and how many stages Node offers it on. + final Map> _missingBaseKeys = {}; + final Map _baseKeyStages = {}; + + void _mismatch(String id, String message) { + report.mismatches.add(_Mismatch(id, message)); + } + + String _rename(String nodeOwner, String name) { + final key = '$nodeOwner.$name'; + final renamed = _renames[key]; + if (renamed == null) return name; + report.renamesUsed.add(key); + return renamed; + } + + /// The Dart-only check: allowed when listed in [_dartOnly]. + void _dartOnlyMember(String id, String message) { + if (_dartOnly.containsKey(id)) { + report.dartOnlyUsed.add(id); + return; + } + _mismatch(id, message); + } + + _Report run() { + _checkFunctions(); + _checkClasses(); + _checkEntryPoints(); + _checkEnums(); + _checkTypes(); + _checkExports(); + for (final MapEntry(key: key, value: missing) in _missingBaseKeys.entries) { + if (missing.isEmpty) continue; + _mismatch( + 'StageOptions#$key', + 'Node accepts `$key` on every stage; Dart lacks it on ' + '${missing.length} of the ${_baseKeyStages[key]} stages it has: ' + '${missing.join(', ')}', + ); + } + return report; + } + + // Top-level functions. + + void _checkFunctions() { + final namespaces = >{}; + for (final name in _functionNamespaces) { + final element = dart.interfaceNamed(name); + if (element == null) { + report.problems.add('_functionNamespaces: Dart exports no $name'); + } else { + namespaces[name] = dart.statics(element); + } + } + + for (final function in node.functions.values) { + final candidates = [ + if (dart.topLevelFunctions[function.name] case final element?) + _DartCallable(function.name, element), + for (final MapEntry(key: owner, value: statics) in namespaces.entries) + if (statics[function.name] case final element?) + _DartCallable('$owner.${function.name}', element), + ]; + if (candidates.isEmpty) { + if (!function.runtimeOnly) { + _mismatch( + 'PipelineFunctions.${function.name}', + 'missing in Dart: Node exports ' + '${_renderNode(function.name, function.overloads)}', + ); + } + continue; + } + for (final candidate in candidates) { + _compare(function, candidate, _Style.plain); + } + } + + for (final MapEntry(key: owner, value: statics) in namespaces.entries) { + for (final name in statics.keys) { + if (!node.functions.containsKey(name)) { + _dartOnlyMember( + '$owner.$name', + 'Dart-only: Node has no top-level function `$name`', + ); + } + } + } + for (final name in dart.topLevelFunctions.keys) { + if (!node.functions.containsKey(name)) { + _dartOnlyMember(name, 'Dart-only: Node has no function `$name`'); + } + } + } + + // Classes. + + void _checkClasses() { + final nodeClassesByDart = >{}; + for (final MapEntry(key: nodeName, value: dartName) in _classes.entries) { + final nodeClass = node.classes[nodeName]; + final dartClass = dart.interfaceNamed(dartName); + if (nodeClass == null) { + report.problems.add('_classes: Node has no $nodeName'); + continue; + } + if (dartClass == null) { + _mismatch( + 'export:$dartName', + 'Node exports $nodeName, but its Dart counterpart $dartName is not ' + 'exported from lib/google_cloud_firestore.dart', + ); + continue; + } + final dartLabel = dartClass.name!; + (nodeClassesByDart[dartLabel] ??= []).add(nodeName); + final dartMembers = dart.members(dartClass); + + for (final member in nodeClass.members.values) { + // Runtime-only members are not part of the typed contract; they only + // count as Node having the Dart member. + if (member.runtimeOnly) continue; + if (_nodeTypeTags.contains(member.name)) continue; + final dartName = _rename(nodeName, member.name); + final id = '$dartLabel.$dartName'; + final element = dartMembers[dartName]; + if (element == null) { + _mismatch( + id, + 'missing in Dart: Node $nodeName.' + '${member.isMethod ? _renderNode(member.name, member.overloads) : member.name}', + ); + continue; + } + _compareMember(member, _DartCallable(id, element)); + } + } + + // Dart members must exist on one of the Node classes they stand for. + for (final MapEntry(key: dartName, value: nodeNames) + in nodeClassesByDart.entries) { + final dartClass = dart.interfaceNamed(dartName)!; + final reverseRenames = { + for (final nodeName in nodeNames) + for (final MapEntry(:key, :value) in _renames.entries) + if (key.startsWith('$nodeName.')) value: key.split('.').last, + }; + final declared = dart.members(dartClass, declaredOnly: true); + for (final MapEntry(key: name, value: element) in declared.entries) { + if (const { + 'hashCode', + 'toString', + 'noSuchMethod', + 'runtimeType', + }.contains(name)) { + continue; + } + final nodeName = reverseRenames[name] ?? name; + if (nodeName == '==' && !reverseRenames.containsKey('==')) continue; + if (nodeNames.any((cls) => node.lookUp(cls, nodeName) != null)) { + continue; + } + final id = '$dartName.$name'; + if (_isLiteralVariant(name, declared)) { + final base = name.substring(0, name.length - 'Literal'.length); + final nodeBase = nodeNames + .map((cls) => node.lookUp(cls, _unrename(cls, base))) + .nonNulls + .firstOrNull; + if (nodeBase != null) { + _compare(nodeBase, _DartCallable(id, element), _Style.literal); + } + continue; + } + _dartOnlyMember( + id, + 'Dart-only: Node ${nodeNames.join('/')} has no `$name`', + ); + } + } + } + + String _unrename(String nodeClass, String dartName) { + for (final MapEntry(:key, :value) in _renames.entries) { + if (value == dartName && key.startsWith('$nodeClass.')) { + return key.split('.').last; + } + } + return dartName; + } + + bool _isLiteralVariant(String name, Map members) { + if (!name.endsWith('Literal') || name == 'Literal') return false; + return members.containsKey( + name.substring(0, name.length - 'Literal'.length), + ); + } + + void _compareMember(_NodeMember member, _DartCallable callable) { + if (member.isMethod == callable.isGetter) { + _mismatch( + '${callable.id}#kind', + 'Node ${member.owner}.${member.name} is a ' + '${member.isMethod ? 'method' : 'property'}, Dart has a ' + '${callable.isGetter ? 'getter' : 'method'}', + ); + return; + } + if (!member.isMethod) return; + final stage = const {'Pipeline', 'PipelineSource'}.contains(member.owner); + _compare(member, callable, stage ? _Style.stage : _Style.plain); + } + + void _checkEntryPoints() { + for (final MapEntry(key: owner, value: members) + in node.entryPoints.entries) { + final dartClass = dart.interfaceNamed(owner); + if (dartClass == null) { + _mismatch(owner, 'missing in Dart: Node entry point owner $owner'); + continue; + } + final dartMembers = dart.members(dartClass); + for (final member in members.values) { + final dartName = _rename(owner, member.name); + final id = '$owner.$dartName'; + final element = dartMembers[dartName]; + if (element == null) { + _mismatch( + id, + 'missing in Dart: Node ${_renderNode(member.name, member.overloads)}', + ); + continue; + } + _compare(member, _DartCallable(id, element), _Style.plain); + } + } + } + + // Signatures. + + void _compare(_NodeMember member, _DartCallable dart, _Style style) { + final optionOverloads = [ + for (final overload in member.overloads) + if (overload.params.length == 1 && overload.params.single.isOptions) + overload, + ]; + var positional = [ + for (final overload in member.overloads) + if (!optionOverloads.contains(overload)) overload.params, + ]; + final named = dart.named; + + if (style == _Style.stage) { + // A stage may take a Node positional parameter as a named one, as + // Dart's unnest(selectable, {indexField}) does. + positional = [ + for (final params in positional) _dropTrailingNamed(params, named), + ]; + } + + final coveredNamed = {}; + if (optionOverloads.isNotEmpty) { + coveredNamed.addAll( + _compareOptions( + dart, + optionOverloads, + hasPositionalForm: positional.isNotEmpty, + ), + ); + } + if (style == _Style.stage) { + for (final params in member.overloads.map((o) => o.params)) { + coveredNamed.addAll(params.map((param) => param.name)); + } + } + if (style != _Style.literal) { + for (final name in named.keys) { + if (coveredNamed.contains(name)) continue; + _mismatch( + '${dart.id}#$name', + 'Dart-only named parameter `$name`: Node ' + '${_renderNode(member.name, member.overloads)} has no such option', + ); + } + } + + if (positional.isEmpty) { + if (dart.positional.isNotEmpty) { + _mismatch( + '${dart.id}#arity', + 'Node takes only an options object, Dart takes ' + '${dart.positional.length} positional argument(s) — Node ' + '${_renderNode(member.name, member.overloads)}; Dart ' + '${dart.render()}', + ); + } + } else { + _comparePositional(member, positional, dart, style); + } + + if (style != _Style.literal && !member.runtimeOnly) { + _compareReturns(member, dart); + } + } + + List<_NodeParam> _dropTrailingNamed( + List<_NodeParam> params, + Map named, + ) { + var end = params.length; + while (end > 0 && named.containsKey(params[end - 1].name)) { + end--; + } + return params.sublist(0, end); + } + + void _comparePositional( + _NodeMember member, + List> overloads, + _DartCallable dart, + _Style style, + ) { + final nodeMin = overloads + .map((params) => params.where((param) => param.required).length) + .reduce((a, b) => a < b ? a : b); + final restIndexes = [ + for (final params in overloads) + for (final (index, param) in params.indexed) + if (param.rest) index, + ]; + final restIndex = restIndexes.isEmpty + ? null + : restIndexes.reduce((a, b) => a < b ? a : b); + final nodeMax = restIndex != null + ? null + : overloads + .map((params) => params.length) + .reduce((a, b) => a > b ? a : b); + + final positional = dart.positional; + final dartRequired = dart.requiredPositional; + final dartTotal = positional.length; + + // A trailing Dart Iterable takes Node's rest parameter, possibly folding + // the parameters before it too. + final collapsedAt = + restIndex != null && + dartTotal > 0 && + dartTotal - 1 <= restIndex && + _iterableElement(positional.last.type) != null + ? dartTotal - 1 + : null; + + final problems = []; + if (member.runtimeOnly) { + // JavaScript cannot tell which parameters are optional; only check that + // Dart takes as many. + if (collapsedAt == null && (nodeMax == null || nodeMax > dartTotal)) { + problems.add( + 'Node takes ${nodeMax ?? 'any number of'} argument(s), Dart at most ' + '$dartTotal', + ); + } + } else if (collapsedAt != null) { + final expected = nodeMin < collapsedAt ? nodeMin : collapsedAt; + final actual = dartRequired < collapsedAt ? dartRequired : collapsedAt; + if (expected != actual) { + problems.add( + 'Node requires $expected argument(s) before ' + '`${positional[collapsedAt].name}`, Dart requires $actual', + ); + } + if (collapsedAt >= nodeMin && + positional[collapsedAt].isRequiredPositional) { + problems.add( + 'Node lets the trailing arguments be omitted, Dart requires the ' + 'Iterable `${positional[collapsedAt].name}`', + ); + } + } else { + if (dartRequired != nodeMin) { + problems.add( + 'Node requires $nodeMin argument(s), Dart requires $dartRequired', + ); + } + if (nodeMax == null) { + problems.add( + 'Node accepts any number of arguments, Dart at most $dartTotal', + ); + } else if (nodeMax > dartTotal) { + problems.add('Node accepts up to $nodeMax, Dart at most $dartTotal'); + } else if (dartTotal > nodeMax) { + problems.add('Dart accepts up to $dartTotal, Node at most $nodeMax'); + } + } + if (problems.isNotEmpty) { + _mismatch( + '${dart.id}#arity', + '${problems.join('; ')} — Node ' + '${_renderNode(member.name, member.overloads)}; Dart ' + '${dart.render()}', + ); + } + + if (style == _Style.literal || member.runtimeOnly) return; + + _NodeType? typeAt(int index) { + final types = [ + for (final params in overloads) + for (final (i, param) in params.indexed) + if (i == index && !param.rest || i <= index && param.rest) + param.type, + ]; + return types.isEmpty ? null : _NodeType.union(types); + } + + final compared = collapsedAt ?? dartTotal; + for (var i = 0; i < compared; i++) { + final type = typeAt(i); + if (type == null) continue; + _compareType( + '${dart.id}#${positional[i].name}', + 'parameter ${i + 1} (`${positional[i].name}`)', + type, + positional[i].type, + ); + } + if (collapsedAt != null) { + final folded = _NodeType.union([ + for (var i = collapsedAt; i <= restIndex!; i++) ?typeAt(i), + ]); + final param = positional[collapsedAt]; + _compareType( + '${dart.id}#${param.name}', + 'the elements of `${param.name}`, which take Node\'s arguments from ' + 'position ${collapsedAt + 1} on', + folded, + _iterableElement(param.type)!, + ); + } + } + + /// Compares the keys of Node's options objects with Dart's named + /// parameters, returning the named parameters they account for. + Set _compareOptions( + _DartCallable dart, + List<_NodeSignature> overloads, { + required bool hasPositionalForm, + }) { + final covered = {}; + final named = dart.named; + for (final optionsName in { + for (final overload in overloads) ...overload.params.single.type.options, + }) { + for (final base in node.optionBases(optionsName)) { + for (final key in node.optionProperties(base).keys) { + _baseKeyStages[key] = (_baseKeyStages[key] ?? 0) + 1; + final missing = _missingBaseKeys[key] ??= []; + if (named.containsKey(key)) { + covered.add(key); + } else { + missing.add(dart.id); + } + } + } + for (final MapEntry(key: key, value: property) + in node.optionProperties(optionsName).entries) { + final dartName = _rename(optionsName, key); + final param = named[dartName]; + if (param == null) { + // A stage's required option is its positional argument. + if (property.optional || !hasPositionalForm) { + _mismatch( + '${dart.id}#$key', + 'Node option `$key` (${property.type.label}) of $optionsName ' + 'has no Dart named parameter — Dart ${dart.render()}', + ); + } + continue; + } + covered.add(dartName); + if (property.optional == param.isRequiredNamed) { + _mismatch( + '${dart.id}#$dartName', + 'Node option `$key` of $optionsName is ' + '${property.optional ? 'optional' : 'required'}, Dart\'s ' + '`$dartName` is ${param.isRequiredNamed ? 'required' : 'optional'}', + ); + } + _compareOption( + '${dart.id}#$dartName', + '$optionsName.$key', + property, + param.type, + ); + } + } + return covered; + } + + /// Compares an option with the Dart type of its named parameter, recursing + /// into inline option objects such as `explainOptions`. + void _compareOption( + String id, + String nodeLabel, + _NodeProperty property, + DartType dartType, + ) { + // An index signature (`{[name: string]: unknown}`) is a plain map. + final nested = { + for (final MapEntry(:key, :value) in property.type.properties.entries) + if (key != '[key]') key: value, + }; + if (nested.isEmpty || property.type.kinds.length != 1) { + _compareType(id, 'option `$nodeLabel`', property.type, dartType); + return; + } + final element = dartType is InterfaceType ? dartType.element : null; + final constructor = element?.unnamedConstructor; + if (constructor == null) { + _compareType(id, 'option `$nodeLabel`', property.type, dartType); + return; + } + final params = { + for (final param in constructor.formalParameters) + if (param.isNamed) param.name!: param, + }; + for (final MapEntry(key: key, value: nestedProperty) in nested.entries) { + final param = params[key]; + if (param == null) { + _mismatch( + '$id.$key', + 'Node option `$nodeLabel.$key` has no named parameter on ' + '${element!.name}()', + ); + continue; + } + _compareOption('$id.$key', '$nodeLabel.$key', nestedProperty, param.type); + } + for (final name in params.keys) { + if (!nested.containsKey(name)) { + _mismatch( + '$id.$name', + 'Dart-only parameter `$name` of ${element!.name}(): Node ' + '`$nodeLabel` has no such key', + ); + } + } + } + + void _compareReturns(_NodeMember member, _DartCallable callable) { + final dartTypes = []; + for (final overload in member.overloads) { + final returns = overload.returns; + if (returns == null) return; + for (final kind in returns.kinds) { + final types = _dartTypesFor(kind, returns); + // Unmapped kinds (`any`, `documentData`, ...) cannot be compared. + if (types == null || types.length != 1) return; + dartTypes.add(types.single); + } + } + if (dartTypes.isEmpty) return; + final expected = dartTypes.reduce(dart.typeSystem.leastUpperBound); + final actual = callable.element.returnType; + if (!dart.typeSystem.isSubtypeOf(actual, expected)) { + _mismatch( + '${callable.id}#returns', + 'Node returns ${expected.getDisplayString()}, Dart returns ' + '${actual.getDisplayString()} — Node ' + '${_renderNode(member.name, member.overloads)}', + ); + } + } + + // Types. + + DartType? _iterableElement(DartType type) { + if (type is! InterfaceType) return null; + return type + .asInstanceOf(dart.typeProvider.iterableElement) + ?.typeArguments + .single; + } + + bool _isObject(DartType type) => type is DynamicType || type.isDartCoreObject; + + /// The Dart types a Node [kind] stands for, all of which a parameter must + /// accept; null when Dart has no equivalent. + List? _dartTypesFor(String kind, _NodeType type) { + final provider = dart.typeProvider; + InterfaceType exported(String name) => dart.exportedType(name); + return switch (kind) { + 'string' || 'literal' => [provider.stringType], + 'number' => [provider.numType], + 'boolean' => [provider.boolType], + 'map' => [ + provider.mapType(provider.stringType, provider.objectQuestionType), + ], + 'array' => [provider.iterableType(provider.objectQuestionType)], + 'expression' || 'aggregateFunction' => [exported('PipelineExpression')], + 'booleanExpression' => [exported('PipelineBooleanExpression')], + 'field' => [exported('PipelineField')], + 'aliasedExpression' || + 'aliasedAggregate' => [exported('PipelineAliasedExpression')], + 'selectable' => [ + exported('PipelineField'), + exported('PipelineAliasedExpression'), + ], + 'ordering' => [exported('PipelineOrdering')], + 'pipeline' => [exported('Pipeline')], + 'pipelineSnapshot' => [exported('PipelineSnapshot')], + 'pipelineResult' => [exported('PipelineResult')], + 'pipelineSource' => [exported('PipelineSource')], + 'explainStats' => [exported('ExplainStats')], + 'vectorValue' => [exported('VectorValue')], + 'geoPoint' => [exported('GeoPoint')], + 'timestamp' => [exported('Timestamp')], + 'documentReference' => [exported('DocumentReference')], + 'collectionReference' => [exported('CollectionReference')], + 'fieldPath' => [exported('FieldPath')], + 'query' => [exported('Query')], + 'bytes' => [dart.libraryType(dart.typedData, 'Uint8List')], + 'date' => [dart.libraryType(dart.core, 'DateTime')], + 'promise' => switch (type.elements) { + final elements? when elements.kinds.length == 1 => + switch (_dartTypesFor(elements.kinds.single, elements)) { + [final value] => [provider.futureType(value)], + _ => null, + }, + _ => null, + }, + _ => null, + }; + } + + /// Whether a Dart parameter of type [dartType] accepts every value of + /// Node [kind]. + bool _accepts(DartType dartType, String kind, _NodeType type) { + if (kind == 'null' || kind == 'undefined') { + return dart.typeSystem.isNullable(dartType); + } + if (_isObject(dartType)) return true; + if (kind == 'any') return false; + if (kind == 'number') { + return dartType.isDartCoreNum || + dartType.isDartCoreInt || + dartType.isDartCoreDouble; + } + if (kind == 'array') return _iterableElement(dartType) != null; + if (kind == 'literal' && dartType.element is EnumElement) { + final values = { + for (final value in dart.enumValues(dartType.element! as EnumElement)) + value.toLowerCase(), + }; + return node + .literalValues(type) + .every((value) => values.contains(value.toLowerCase())); + } + final types = _dartTypesFor(kind, type); + if (types == null) return false; + return types.every((t) => dart.typeSystem.isSubtypeOf(t, dartType)); + } + + void _compareType( + String id, + String where, + _NodeType nodeType, + DartType dartType, + ) { + final iterable = _iterableElement(dartType) != null; + if (iterable && + !nodeType.kinds.contains('array') && + !nodeType.kinds.contains('any')) { + _mismatch( + id, + 'Node takes a single value (${nodeType.label}) for $where, Dart takes ' + 'an Iterable (${dartType.getDisplayString()})', + ); + return; + } + final rejected = [ + for (final kind in nodeType.kinds) + if (!_accepts(dartType, kind, nodeType)) kind, + ]..sort(); + if (rejected.isNotEmpty) { + _mismatch( + id, + 'Node accepts ${nodeType.label} for $where, Dart\'s ' + '${dartType.getDisplayString()} rejects ${rejected.join('|')}', + ); + return; + } + // Compare the elements of a list with Node's array elements. + final nodeElements = nodeType.elements; + final dartElement = _iterableElement(dartType); + if (nodeElements != null && dartElement != null) { + _compareType(id, 'the elements of $where', nodeElements, dartElement); + } + } + + // String unions and exports. + + void _checkEnums() { + for (final MapEntry(key: nodeName, value: dartName) in _enums.entries) { + final element = dart.exports[dartName]; + final union = node.types[nodeName]; + if (element is! EnumElement || union?['kind'] != 'stringUnion') { + report.problems.add( + '_enums: $nodeName -> $dartName is not a union/enum', + ); + continue; + } + final nodeValues = _strings(union!['values']); + final dartValues = dart.enumValues(element); + for (final value in nodeValues.difference(dartValues)) { + _mismatch( + '$dartName#$value', + "Node's $nodeName includes '$value'; $dartName has no member with " + 'that value', + ); + } + for (final value in dartValues.difference(nodeValues)) { + _mismatch( + '$dartName#$value', + "Dart-only value '$value': Node's $nodeName does not include it", + ); + } + } + } + + void _checkTypes() { + // Options objects are compared through the parameters that take them. + final options = {}; + void addOptions(_NodeType type) { + for (final name in type.options) { + options + ..add(name) + ..addAll(node.optionBases(name)); + } + } + + final members = [ + ...node.functions.values, + for (final cls in node.classes.values) ...cls.members.values, + for (final owner in node.entryPoints.values) ...owner.values, + ]; + for (final member in members) { + for (final overload in member.overloads) { + overload.params.map((param) => param.type).forEach(addOptions); + } + } + + for (final MapEntry(key: name, value: type) in node.types.entries) { + switch (type['kind']) { + case 'stringUnion' when !_enums.containsKey(name): + _mismatch( + 'type:$name', + 'Node string union $name has no Dart enum in _enums', + ); + case 'object' when !options.contains(name): + _mismatch( + 'type:$name', + 'Node object type $name is not an options parameter of any ' + 'member; teach this test how it maps onto Dart', + ); + } + } + + for (final tag in _nodeTypeTags) { + if (!node.classes.values.any((cls) => cls.members.containsKey(tag))) { + report.problems.add('_nodeTypeTags: no Node class has `$tag`'); + } + } + } + + void _checkExports() { + final accounted = { + ..._classes.values, + ..._enums.values, + ..._optionTypes.keys, + ..._functionNamespaces, + ...dart.topLevelFunctions.keys, + }; + // Typedefs of a mapped class, such as BooleanExpression, are accounted + // for by that class. + final accountedClasses = { + for (final name in accounted) ?dart.interfaceNamed(name), + }; + for (final name in {..._optionTypes.keys, ..._enums.values}) { + if (!dart.exports.containsKey(name)) { + report.problems.add('$name is listed but not exported'); + } + } + for (final element in dart.pipelineDeclarations) { + final name = element.name!; + if (dart.exports[name] != element) { + _mismatch( + 'export:$name', + '$name is declared in ${element.firstFragment.libraryFragment?.source.shortName} ' + 'but not exported from lib/google_cloud_firestore.dart', + ); + } + if (!accounted.contains(name) && + !(element is TypeAliasElement && + accountedClasses.contains(dart.interfaceNamed(name)))) { + _dartOnlyMember( + name, + 'Dart-only type: map it onto Node in _classes, _enums or ' + '_optionTypes, or list it in _dartOnly', + ); + } + } + } +} + +/// Renders a Node overload set as one merged signature, such as +/// `join(arrayExpression: expression|string, delimiter: expression|string)`. +String _renderNode(String name, List<_NodeSignature> overloads) { + if (overloads.isEmpty) return name; + final length = overloads + .map((overload) => overload.params.length) + .reduce((a, b) => a > b ? a : b); + final parts = []; + for (var i = 0; i < length; i++) { + final params = [ + for (final overload in overloads) + if (i < overload.params.length) overload.params[i], + ]; + final names = {}; + for (final param in params) { + names[param.name] = (names[param.name] ?? 0) + 1; + } + final paramName = + (names.entries.toList()..sort((a, b) { + final byCount = b.value.compareTo(a.value); + return byCount != 0 ? byCount : a.key.compareTo(b.key); + })) + .first + .key; + final optional = + params.length < overloads.length || params.any((p) => p.optional); + final rest = params.any((p) => p.rest); + final type = _NodeType.union(params.map((p) => p.type)); + parts.add( + '${rest ? '...' : ''}$paramName${optional && !rest ? '?' : ''}: ' + '${type.label}', + ); + } + return '$name(${parts.join(', ')})'; +} diff --git a/packages/google_cloud_firestore/tool/pipeline_api_parity/.gitignore b/packages/google_cloud_firestore/tool/pipeline_api_parity/.gitignore new file mode 100644 index 00000000..c2658d7d --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_api_parity/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/google_cloud_firestore/tool/pipeline_api_parity/README.md b/packages/google_cloud_firestore/tool/pipeline_api_parity/README.md new file mode 100644 index 00000000..8810d441 --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_api_parity/README.md @@ -0,0 +1,58 @@ +# Pipeline API parity snapshot + +`test/pipeline_api_parity_test.dart` checks that the Dart Pipelines API keeps +the same signatures as the Node.js Admin SDK: argument counts, optional +parameters, single values versus lists, which kinds of values each parameter +accepts, options objects versus named parameters, and the `isType` values. +Unit tests cannot catch these mismatches, since a test only exercises the +signature it was written against. + +The test does not need Node. It reads +`test/fixtures/node_pipeline_api.json`, which this tool extracts from the +TypeScript declarations of the pinned `@google-cloud/firestore` release +(`types/firestore.d.ts`, namespace `FirebaseFirestore.Pipelines`), with the +TypeScript compiler API. It also lists the few functions and methods that the +JavaScript build exports without declaring them, so the test does not report +them as Dart-only. + +## Regenerating the snapshot + +```sh +cd packages/google_cloud_firestore/tool/pipeline_api_parity +npm ci +npm run extract +``` + +The output only depends on the pinned versions in `package-lock.json` and on +`extract_node_api.mjs`, so it is byte-for-byte reproducible. The +"Pipeline API snapshot" workflow regenerates it and fails when the committed +file differs. + +Regenerate it when you change `extract_node_api.mjs`, or to compare against a +newer Node SDK: + +```sh +npm install --save-exact @google-cloud/firestore@ +npm run extract +``` + +Then update `_nodeVersion` in `test/pipeline_api_parity_test.dart`, run that +test from `packages/google_cloud_firestore`, and triage what it reports. + +## Triaging a failure + +Each difference is one line, `id: message`, for example: + +``` +PipelineFunctions.join#arity: Node requires 2 argument(s), Dart requires 1 — Node join(arrayExpression: expression|string, delimiter: expression|string); Dart join(Object? array, [Object? delimiter]) +``` + +Fix the Dart API, or record the difference in the test: + +- `_knownDifferences`: intentional, with the reason. +- `_pendingFixes`: a Dart bug or gap to fix later. +- `_dartOnly`: a public Dart member Node has no counterpart for. +- `_renames`: a Node member Dart spells differently. + +The test fails on stale entries, so remove an entry once the difference is +gone. diff --git a/packages/google_cloud_firestore/tool/pipeline_api_parity/extract_node_api.mjs b/packages/google_cloud_firestore/tool/pipeline_api_parity/extract_node_api.mjs new file mode 100644 index 00000000..45481481 --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_api_parity/extract_node_api.mjs @@ -0,0 +1,741 @@ +// 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. + +// Extracts the Pipelines API of the Node.js Firestore SDK into a JSON +// snapshot that test/pipeline_api_parity_test.dart compares the Dart API +// against. See README.md in this directory. +// +// The declarations are read syntactically with the TypeScript compiler API +// (no type checking), so the output only depends on the pinned +// @google-cloud/firestore and typescript versions and on this script. + +import fs from 'node:fs'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import ts from 'typescript'; + +const toolDir = path.dirname(fileURLToPath(import.meta.url)); +const outputPath = path.resolve( + toolDir, + '../../test/fixtures/node_pipeline_api.json', +); + +const require = createRequire(import.meta.url); +const packageJsonPath = require.resolve('@google-cloud/firestore/package.json'); +const packageDir = path.dirname(packageJsonPath); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); +const pinnedVersion = JSON.parse( + fs.readFileSync(path.join(toolDir, 'package.json'), 'utf8'), +).dependencies['@google-cloud/firestore']; +if (packageJson.version !== pinnedVersion) { + throw new Error( + `Installed @google-cloud/firestore ${packageJson.version} does not match ` + + `the pinned ${pinnedVersion}. Run \`npm ci\` in ${toolDir}.`, + ); +} + +const typingsRelativePath = 'types/firestore.d.ts'; +const runtimeIndexRelativePath = 'build/src/pipelines/index.js'; + +// The simplified kind of the classes that need one; any other class is named +// after itself (`VectorValue` is `vectorValue`). +const classKinds = new Map([ + ['Expression', 'expression'], + ['FunctionExpression', 'expression'], + ['Constant', 'expression'], + ['BooleanExpression', 'booleanExpression'], + ['Field', 'field'], + ['AggregateFunction', 'aggregateFunction'], + ['AliasedExpression', 'aliasedExpression'], + ['AliasedAggregate', 'aliasedAggregate'], + ['Selectable', 'selectable'], + ['Ordering', 'ordering'], + ['Pipeline', 'pipeline'], + ['PipelineSnapshot', 'pipelineSnapshot'], + ['PipelineResult', 'pipelineResult'], + ['PipelineSource', 'pipelineSource'], + ['ExplainStats', 'explainStats'], + ['Buffer', 'bytes'], + ['Uint8Array', 'bytes'], +]); + +function readSource(relativePath, scriptKind) { + const filePath = path.join(packageDir, relativePath); + return ts.createSourceFile( + filePath, + fs.readFileSync(filePath, 'utf8'), + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + scriptKind, + ); +} + +function isExported(node) { + return (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0; +} + +function hasModifier(node, kind) { + return (node.modifiers ?? []).some(modifier => modifier.kind === kind); +} + +function nameOf(node) { + return node.name && ts.isIdentifier(node.name) + ? node.name.text + : node.name?.getText(); +} + +const printer = ts.createPrinter({removeComments: true}); + +/** Comment-free, single-line text of a type, for humans reading diffs. */ +function typeText(node) { + if (!node) return null; + return printer + .printNode(ts.EmitHint.Unspecified, node, node.getSourceFile()) + .replace(/\s+/g, ' ') + .replace(/([({[<]) /g, '$1') + .replace(/,? ([)}\]>])/g, '$1') + .replace(/^\| /, ''); +} + +function sorted(values) { + return [...new Set(values)].sort(); +} + +// --- Locate the Pipelines namespace --------------------------------------- + +const typings = readSource(typingsRelativePath, ts.ScriptKind.TS); + +function findNamespace(statements, name) { + for (const statement of statements) { + if ( + ts.isModuleDeclaration(statement) && + statement.name.text === name && + statement.body && + ts.isModuleBlock(statement.body) + ) { + return statement.body; + } + } + throw new Error(`Namespace ${name} not found in ${typingsRelativePath}.`); +} + +const firebaseFirestore = findNamespace( + typings.statements, + 'FirebaseFirestore', +); +const pipelines = findNamespace(firebaseFirestore.statements, 'Pipelines'); + +const typeAliases = new Map(); +for (const statement of pipelines.statements) { + if (ts.isTypeAliasDeclaration(statement)) { + typeAliases.set(statement.name.text, statement); + } +} + +// --- Type simplification ---------------------------------------------------- + +/** String literal values of [node] when it is a union of string literals. */ +function stringLiterals(node, seen = new Set()) { + if (ts.isParenthesizedTypeNode(node)) return stringLiterals(node.type, seen); + if (ts.isUnionTypeNode(node)) { + const values = []; + for (const member of node.types) { + const memberValues = stringLiterals(member, seen); + if (memberValues === null) return null; + values.push(...memberValues); + } + return values; + } + if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) { + return [node.literal.text]; + } + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + const alias = typeAliases.get(node.typeName.text); + if (alias && !seen.has(alias)) { + seen.add(alias); + return stringLiterals(alias.type, seen); + } + } + return null; +} + +/** + * Simplifies [node] to a set of kinds such as `expression`, `string`, + * `array` or `options`, collecting details into [info]. + */ +function collectKinds(node, info) { + if (ts.isParenthesizedTypeNode(node)) return collectKinds(node.type, info); + if (ts.isUnionTypeNode(node)) { + for (const member of node.types) collectKinds(member, info); + return; + } + if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) { + info.kinds.add('literal'); + info.literals.add(node.literal.text); + return; + } + if ( + ts.isTypeReferenceNode(node) && + ts.isIdentifier(node.typeName) && + typeAliases.has(node.typeName.text) && + stringLiterals(node) !== null + ) { + // A named string union such as `TimeUnit`; its values are under `types`. + info.kinds.add('literal'); + info.aliases.add(node.typeName.text); + return; + } + switch (node.kind) { + case ts.SyntaxKind.StringKeyword: + info.kinds.add('string'); + return; + case ts.SyntaxKind.NumberKeyword: + info.kinds.add('number'); + return; + case ts.SyntaxKind.BooleanKeyword: + info.kinds.add('boolean'); + return; + case ts.SyntaxKind.UnknownKeyword: + case ts.SyntaxKind.AnyKeyword: + info.kinds.add('any'); + return; + case ts.SyntaxKind.VoidKeyword: + info.kinds.add('void'); + return; + case ts.SyntaxKind.UndefinedKeyword: + info.kinds.add('undefined'); + return; + } + if (ts.isLiteralTypeNode(node)) { + if (node.literal.kind === ts.SyntaxKind.NullKeyword) { + info.kinds.add('null'); + } else if ( + node.literal.kind === ts.SyntaxKind.TrueKeyword || + node.literal.kind === ts.SyntaxKind.FalseKeyword + ) { + info.kinds.add('boolean'); + } else if (ts.isNumericLiteral(node.literal)) { + info.kinds.add('number'); + } else { + info.kinds.add('other'); + } + return; + } + if (ts.isArrayTypeNode(node)) { + info.kinds.add('array'); + collectKinds(node.elementType, info.elements); + return; + } + if (ts.isTypeLiteralNode(node)) { + info.kinds.add('map'); + return; + } + if (ts.isTypeReferenceNode(node)) { + const name = node.typeName.getText().replace(/^Pipelines\./, ''); + const typeArguments = node.typeArguments ?? []; + if (name === 'Array' && typeArguments.length === 1) { + info.kinds.add('array'); + collectKinds(typeArguments[0], info.elements); + return; + } + if (name === 'Record') { + info.kinds.add('map'); + return; + } + if (name === 'Promise' && typeArguments.length === 1) { + info.kinds.add('promise'); + collectKinds(typeArguments[0], info.elements); + return; + } + if (classKinds.has(name)) { + info.kinds.add(classKinds.get(name)); + return; + } + if (typeAliases.has(name) && isOptionsType(typeAliases.get(name))) { + info.kinds.add('options'); + info.options.add(name); + return; + } + const simpleName = name.split('.').pop(); + info.kinds.add(simpleName.charAt(0).toLowerCase() + simpleName.slice(1)); + return; + } + info.kinds.add('other'); +} + +function newInfo() { + return { + kinds: new Set(), + literals: new Set(), + aliases: new Set(), + options: new Set(), + get elements() { + return (this._elements ??= newInfo()); + }, + }; +} + +function encodeInfo(info) { + const result = {kinds: sorted(info.kinds)}; + if (info.literals.size > 0) result.literals = sorted(info.literals); + if (info.aliases.size > 0) result.aliases = sorted(info.aliases); + if (info.options.size > 0) result.options = sorted(info.options); + if (info._elements && info._elements.kinds.size > 0) { + result.elements = encodeInfo(info._elements); + } + return result; +} + +function simplifyType(node) { + if (!node) return {kinds: ['any'], type: null}; + const info = newInfo(); + collectKinds(node, info); + return {...encodeInfo(info), type: typeText(node)}; +} + +// --- Signatures ------------------------------------------------------------- + +function encodeParameter(parameter) { + const rest = parameter.dotDotDotToken !== undefined; + let type = simplifyType(parameter.type); + if (rest) { + // A rest parameter is typed as an array; each argument is one element. + if (!type.elements) { + throw new Error(`Rest parameter without an array type: ${parameter}`); + } + type = {...type.elements, type: type.type}; + } + const result = {name: parameter.name.getText(), ...type}; + // Only flag the exceptions, to keep the snapshot compact. + if (parameter.questionToken || parameter.initializer) result.optional = true; + if (rest) result.rest = true; + return result; +} + +function encodeSignature(node) { + return { + params: node.parameters.map(encodeParameter), + returns: simplifyType(node.type), + }; +} + +function sortOverloads(overloads) { + const unique = new Map(); + for (const overload of overloads) { + unique.set(stableStringify(overload), overload); + } + return [...unique.keys()].sort().map(key => unique.get(key)); +} + +/** Properties of an object type literal, keyed by name. */ +function encodeProperties(typeLiteral) { + const properties = {}; + for (const member of typeLiteral.members) { + if (ts.isPropertySignature(member)) { + const property = { + optional: member.questionToken !== undefined, + ...simplifyType(member.type), + }; + if (member.type && ts.isTypeLiteralNode(member.type)) { + property.properties = encodeProperties(member.type); + } + properties[nameOf(member)] = property; + } else if (ts.isIndexSignatureDeclaration(member)) { + properties['[key]'] = {optional: true, ...simplifyType(member.type)}; + } + } + return properties; +} + +/** + * Whether [alias] is a `*Options` object type, possibly built with + * `StageOptions & {...}` or `OneOf<{...}>`. + */ +function isOptionsType(alias) { + return ( + /Options$/.test(alias.name.text) && objectParts(alias.type) !== null + ); +} + +/** Splits an object type into its base aliases and literal members. */ +function objectParts(node) { + if (ts.isParenthesizedTypeNode(node)) return objectParts(node.type); + if (ts.isTypeLiteralNode(node)) { + return {bases: [], literals: [node], oneOf: []}; + } + if (ts.isIntersectionTypeNode(node)) { + const result = {bases: [], literals: [], oneOf: []}; + for (const member of node.types) { + const part = objectParts(member); + if (part === null) return null; + result.bases.push(...part.bases); + result.literals.push(...part.literals); + result.oneOf.push(...part.oneOf); + } + return result; + } + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + const name = node.typeName.text; + const typeArguments = node.typeArguments ?? []; + if (name === 'OneOf' && typeArguments.length === 1) { + const inner = objectParts(typeArguments[0]); + if (inner === null) return null; + return {bases: [], literals: [], oneOf: inner.literals}; + } + if (typeAliases.has(name) && /Options$/.test(name)) { + return {bases: [name], literals: [], oneOf: []}; + } + } + return null; +} + +function encodeTypeAlias(alias) { + const literals = stringLiterals(alias.type); + if (literals !== null) { + return {kind: 'stringUnion', values: sorted(literals)}; + } + const parts = objectParts(alias.type); + if (parts !== null) { + const properties = {}; + for (const literal of parts.literals) { + Object.assign(properties, encodeProperties(literal)); + } + const oneOf = []; + for (const literal of parts.oneOf) { + const members = encodeProperties(literal); + // OneOf makes every member optional; exactly one must be given. + for (const [name, property] of Object.entries(members)) { + properties[name] = {...property, optional: true}; + } + oneOf.push(sorted(Object.keys(members))); + } + const result = {kind: 'object', properties}; + if (parts.bases.length > 0) result.extends = sorted(parts.bases); + if (oneOf.length > 0) result.oneOf = oneOf; + return result; + } + return {kind: 'other', ...simplifyType(alias.type)}; +} + +function encodeClassLike(node) { + const methods = new Map(); + const properties = {}; + const constructors = []; + for (const member of node.members) { + if ( + hasModifier(member, ts.SyntaxKind.PrivateKeyword) || + hasModifier(member, ts.SyntaxKind.ProtectedKeyword) + ) { + continue; + } + if (ts.isConstructorDeclaration(member)) { + constructors.push(encodeSignature(member)); + continue; + } + const name = nameOf(member); + // Underscored members are internal by convention. + if (!name || name.startsWith('_')) continue; + const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword); + if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) { + const overloads = methods.get(name) ?? {static: isStatic, overloads: []}; + overloads.overloads.push(encodeSignature(member)); + methods.set(name, overloads); + } else if ( + ts.isPropertyDeclaration(member) || + ts.isPropertySignature(member) + ) { + properties[name] = { + static: isStatic, + readonly: hasModifier(member, ts.SyntaxKind.ReadonlyKeyword), + optional: member.questionToken !== undefined, + ...simplifyType(member.type), + }; + } else if (ts.isGetAccessorDeclaration(member)) { + properties[name] = { + static: isStatic, + readonly: true, + optional: false, + ...simplifyType(member.type), + }; + } + } + const result = { + abstract: hasModifier(node, ts.SyntaxKind.AbstractKeyword), + extends: null, + implements: [], + methods: Object.fromEntries( + [...methods].map(([name, value]) => [ + name, + {...value, overloads: sortOverloads(value.overloads)}, + ]), + ), + properties, + }; + if (constructors.length > 0) { + result.constructors = sortOverloads(constructors); + } + for (const clause of node.heritageClauses ?? []) { + const names = clause.types.map(type => type.expression.getText()); + if (clause.token === ts.SyntaxKind.ExtendsKeyword) { + result.extends = names.length === 1 ? names[0] : sorted(names); + } else { + result.implements = sorted(names); + } + } + return result; +} + +// --- Walk the namespace --------------------------------------------------- + +const functions = new Map(); +const classes = {}; +const interfaces = {}; +const types = {}; + +for (const statement of pipelines.statements) { + if (!isExported(statement)) continue; + if (ts.isFunctionDeclaration(statement)) { + const name = nameOf(statement); + const overloads = functions.get(name) ?? []; + overloads.push(encodeSignature(statement)); + functions.set(name, overloads); + } else if (ts.isClassDeclaration(statement)) { + classes[nameOf(statement)] = encodeClassLike(statement); + } else if (ts.isInterfaceDeclaration(statement)) { + interfaces[nameOf(statement)] = encodeClassLike(statement); + } else if (ts.isTypeAliasDeclaration(statement)) { + types[statement.name.text] = encodeTypeAlias(statement); + } else { + throw new Error( + `Unhandled export in the Pipelines namespace: ${statement.getText()}`, + ); + } +} + +// Pipeline entry points declared outside the namespace. +function findClassMethod(className, methodName) { + for (const statement of firebaseFirestore.statements) { + if (ts.isClassDeclaration(statement) && nameOf(statement) === className) { + const overloads = statement.members + .filter( + member => + ts.isMethodDeclaration(member) && nameOf(member) === methodName, + ) + .map(encodeSignature); + if (overloads.length > 0) return {overloads: sortOverloads(overloads)}; + } + } + throw new Error(`${className}.${methodName} not found.`); +} + +const entryPoints = { + 'Firestore.pipeline': findClassMethod('Firestore', 'pipeline'), + 'Transaction.execute': findClassMethod('Transaction', 'execute'), +}; + +// --- Runtime exports -------------------------------------------------------- + +// The typings omit some functions that the `@google-cloud/firestore/pipelines` +// entry point exports at runtime. Record their names and JavaScript parameter +// names so the Dart side can match them too. +const runtimeIndex = readSource(runtimeIndexRelativePath, ts.ScriptKind.JS); +const moduleVariables = new Map(); +const runtimeExports = new Map(); + +function visitRuntimeIndex(node) { + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + ts.isCallExpression(node.initializer) && + node.initializer.expression.getText() === 'require' + ) { + moduleVariables.set(node.name.text, node.initializer.arguments[0].text); + } + if ( + ts.isCallExpression(node) && + node.expression.getText() === 'Object.defineProperty' && + node.arguments[0].getText() === 'exports' && + ts.isStringLiteral(node.arguments[1]) + ) { + const name = node.arguments[1].text; + const getter = node.arguments[2].getText(); + const match = /return\s+(\w+)\.(\w+)/.exec(getter); + if (name !== '__esModule' && match) { + runtimeExports.set(name, {variable: match[1], symbol: match[2]}); + } + } + ts.forEachChild(node, visitRuntimeIndex); +} +visitRuntimeIndex(runtimeIndex); + +const declaredNames = new Set([ + ...functions.keys(), + ...Object.keys(classes), + ...Object.keys(interfaces), + ...Object.keys(types), +]); + +function encodeJsParameters(declaration) { + return declaration.parameters.map(parameter => { + const result = {name: parameter.name.getText()}; + if (parameter.initializer) result.optional = true; + if (parameter.dotDotDotToken) result.rest = true; + return result; + }); +} + +const runtimeOnlyFunctions = {}; +for (const [name, {variable, symbol}] of runtimeExports) { + if (declaredNames.has(name)) continue; + const modulePath = moduleVariables.get(variable); + const moduleSource = readSource( + path.posix.join(path.posix.dirname(runtimeIndexRelativePath), modulePath) + + '.js', + ts.ScriptKind.JS, + ); + const declaration = moduleSource.statements.find( + statement => + ts.isFunctionDeclaration(statement) && nameOf(statement) === symbol, + ); + if (!declaration) { + throw new Error( + `Runtime export ${name} is neither declared in the typings nor a ` + + `function of ${modulePath}.js; extend this script to handle it.`, + ); + } + runtimeOnlyFunctions[name] = { + module: modulePath.replace(/^\.\//, ''), + params: encodeJsParameters(declaration), + }; +} + +// Likewise, public methods and getters of the declared classes that only the +// JavaScript implementation has. +const runtimeOnlyMembers = {}; +const runtimeDir = path.join(packageDir, path.dirname(runtimeIndexRelativePath)); +for (const file of fs.readdirSync(runtimeDir).sort()) { + if (!file.endsWith('.js')) continue; + const source = readSource( + path.posix.join(path.posix.dirname(runtimeIndexRelativePath), file), + ts.ScriptKind.JS, + ); + for (const statement of source.statements) { + if (!ts.isClassDeclaration(statement)) continue; + const declared = classes[nameOf(statement)]; + if (!declared) continue; + for (const member of statement.members) { + const isMethod = ts.isMethodDeclaration(member); + if (!isMethod && !ts.isGetAccessorDeclaration(member)) continue; + const name = nameOf(member); + if ( + !name || + name.startsWith('_') || + name.startsWith('#') || + name in declared.methods || + name in declared.properties + ) { + continue; + } + const members = (runtimeOnlyMembers[nameOf(statement)] ??= {}); + members[name] = isMethod + ? {kind: 'method', module: file, params: encodeJsParameters(member)} + : {kind: 'getter', module: file}; + } + } +} + +// --- Output ----------------------------------------------------------------- + +function stableStringify(value) { + return JSON.stringify(sortKeys(value)); +} + +/** + * Pretty-prints [value] with sorted keys, keeping any object or array that + * fits on one line on a single line, so that each parameter is one line and + * diffs stay readable. + */ +function format(value, indent = '') { + const compact = stableStringify(value); + if ( + value === null || + typeof value !== 'object' || + indent.length + compact.length <= 120 + ) { + return compact; + } + const inner = indent + ' '; + if (Array.isArray(value)) { + if (value.length === 0) return '[]'; + const items = value.map(item => inner + format(item, inner)); + return `[\n${items.join(',\n')}\n${indent}]`; + } + const keys = Object.keys(value).sort(); + if (keys.length === 0) return '{}'; + const entries = keys.map( + key => `${inner}${JSON.stringify(key)}: ${format(value[key], inner)}`, + ); + return `{\n${entries.join(',\n')}\n${indent}}`; +} + +function sortKeys(value) { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map(key => [key, sortKeys(value[key])]), + ); + } + return value; +} + +const snapshot = { + // Bump when the shape of this file changes, so the Dart test can tell. + formatVersion: 1, + source: { + package: packageJson.name, + version: packageJson.version, + typings: typingsRelativePath, + namespace: 'FirebaseFirestore.Pipelines', + runtimeIndex: runtimeIndexRelativePath, + generator: 'packages/google_cloud_firestore/tool/pipeline_api_parity', + }, + functions: Object.fromEntries( + [...functions].map(([name, overloads]) => [ + name, + {overloads: sortOverloads(overloads)}, + ]), + ), + classes, + interfaces, + types, + entryPoints, + runtimeExports: sorted(runtimeExports.keys()), + runtimeOnlyFunctions, + runtimeOnlyMembers, +}; + +fs.writeFileSync(outputPath, format(snapshot) + '\n'); +console.log( + `Wrote ${path.relative(process.cwd(), outputPath)}: ` + + `${functions.size} functions, ${Object.keys(classes).length} classes, ` + + `${Object.keys(types).length} types, ` + + `${Object.keys(runtimeOnlyFunctions).length} runtime-only functions, ` + + `${Object.values(runtimeOnlyMembers).flatMap(Object.keys).length} ` + + 'runtime-only members ' + + `(${packageJson.name} ${packageJson.version}).`, +); diff --git a/packages/google_cloud_firestore/tool/pipeline_api_parity/package-lock.json b/packages/google_cloud_firestore/tool/pipeline_api_parity/package-lock.json new file mode 100644 index 00000000..cf4b27ba --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_api_parity/package-lock.json @@ -0,0 +1,1372 @@ +{ + "name": "pipeline-api-parity", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pipeline-api-parity", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/firestore": "9.3.1", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-9.3.1.tgz", + "integrity": "sha512-ofGTiSpfXrHrrS3KaDOmsupSzCHr4WVl1+s6Ke0F8rPBRyb/OuZSC8u1NjCKtt/fO7A1GvqASQGhdAwVsnGpQA==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/firestore-api": "^0.2.0", + "@opentelemetry/api": "^1.9.0", + "fast-deep-equal": "^3.1.3", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^6.0.1", + "protobufjs": "^7.5.3" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@google-cloud/firestore-api": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore-api/-/firestore-api-0.2.0.tgz", + "integrity": "sha512-wfHJPZmLqzpy6FN6U+7HkkP/AKhGsbHSpEy/qL2TyI3/pKRAvpamQxZ2qvps5hMRgAjfuHCim+oeJj9RveCaqg==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/google-gax": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.8.tgz", + "integrity": "sha512-M4vpZcXQIC1gqIVGQ7eaU3jXQA6zecStyTXu514TYfThlgSurYJOxHZo9fzU6hAgwPWvuEynAVHWyaIk80VeEA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "10.5.0", + "google-logging-utils": "1.1.3", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "3.0.4", + "protobufjs": "^7.5.4", + "retry-request": "^8.0.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/retry-request": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.4.tgz", + "integrity": "sha512-pI6/7eabUYkZxamkOq0g0uMxKLLGnjzhefY+vL8bVXag5rto4OU2YBTPytWLuHH7aEKD6fn7kJycQfid0Mwnkw==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/firestore-api/node_modules/teeny-request": { + "version": "10.1.4", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.4.tgz", + "integrity": "sha512-R1Cg4Vu0UULeDfHL/kjABLaTW++9yD/B6n2g48y5dJ04hsEaxcfmAqbNDzNsbqAYJyIpZafjklLG9YxRu9uzOg==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.5", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz", + "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT" + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.0.4.tgz", + "integrity": "sha512-p6WvbXOhfwnVtv/MyPq5kBiVhfV/qglMIxwwTVNEI1Ha1HmOgz5uHYHtYO6SfGckFaXe8blA11IJJ3wqXmvmBA==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.1.3", + "google-logging-utils": "^2.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.1.0.tgz", + "integrity": "sha512-YIBq5KNoHQrZ9PM4PeodDBqqBGlSTIxh1zibN8u3zNIO9UJg8gvKuCpdKV7fWXhsZ2zRkU6FhGrgKNgLJ2IRgw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "^9.0.0", + "google-logging-utils": "^2.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-gax": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-6.10.0.tgz", + "integrity": "sha512-P9+6mNoC97uSUj55MptrxNj9muoDwc05mcY49R8jyg3kqzkPK+uWHApLuAHsNPKh3dtk1M1IuVZIto8e/fvpQA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "@opentelemetry/api": "^1.9.0", + "duplexify": "^4.1.3", + "google-auth-library": "^11.0.0", + "google-logging-utils": "^2.0.0", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^4.0.0", + "protobufjs": "^7.5.4", + "retry-request": "^9.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/google-logging-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-2.0.1.tgz", + "integrity": "sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/proto3-json-serializer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-4.0.2.tgz", + "integrity": "sha512-VUQlb/J2WjOG8BKhdLwJUz+MKxSMtBGYh+TaNmRtiYVhlIzXjzeAGIx7gtc3lXlA2iURzfF2+cXaQvC2ofwzpg==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.5.4" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry-request": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-9.0.1.tgz", + "integrity": "sha512-4kGHEBl0ME6gR+8QB1sPMyg58jUxLUcCZNeqLDrfqzk0eWQN8XJFmeu7fmZCjlZCRW0S2u64Ik1HkO/0sWgCYQ==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^11.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, + "node_modules/teeny-request": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-11.0.1.tgz", + "integrity": "sha512-bNr5j2YjSdajgCVsp+8JVjRf1uHIbpNISLeKp/7V1NnVMX3Gh6H/kECNGlm2Q3I68ACODQ0iv6aZ3Rvm8WgLGA==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/packages/google_cloud_firestore/tool/pipeline_api_parity/package.json b/packages/google_cloud_firestore/tool/pipeline_api_parity/package.json new file mode 100644 index 00000000..5482c5b5 --- /dev/null +++ b/packages/google_cloud_firestore/tool/pipeline_api_parity/package.json @@ -0,0 +1,17 @@ +{ + "name": "pipeline-api-parity", + "private": true, + "description": "Extracts the Node.js Firestore Pipelines API into test/fixtures/node_pipeline_api.json.", + "license": "Apache-2.0", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "extract": "node extract_node_api.mjs" + }, + "dependencies": { + "@google-cloud/firestore": "9.3.1", + "typescript": "5.9.3" + } +} From f0a91794f8bc74b93f16d164d2b2e729da13999f Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:19:19 +0200 Subject: [PATCH 08/29] test(firestore): update parity tables after the single-implementation refactor The refactor fixed arraySlice, arrayTransform(WithIndex) and the fluent arrayContains* signatures, and top-level ascending/descending, so their pending entries are gone; the golden cases now call arrayTransformWithIndex. --- .../test/pipeline_api_parity_test.dart | 14 -------------- .../test/pipeline_golden_test.dart | 18 ++++++++++++------ 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart index 25224efa..11632f5c 100644 --- a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart @@ -168,8 +168,6 @@ const _knownDifferences = { 'requires an argument.', 'PipelineExpression.arrayConcat#arity': 'Takes a single array; the variadic form is arrayConcatMultiple.', - 'PipelineExpression.arrayContainsAll#values': - 'Takes an Iterable; an array expression goes to arrayContainsAllFrom.', 'PipelineSource.collection#collectionPath': 'Takes a path; a CollectionReference goes to collectionReference(), ' 'since Dart has no overloads.', @@ -211,20 +209,10 @@ const _pendingFixes = { 'Node add(second, ...others) is variadic; Dart add(other) takes one.', 'PipelineExpression.multiply#arity': 'Node multiply(second, ...others) is variadic; Dart takes one.', - 'PipelineFunctions.arraySlice#arity': - 'Node arraySlice(array, offset, length?) makes length optional, like ' - 'PipelineExpression.arraySlice; the static requires it.', - 'PipelineFunctions.arrayTransform#arity': - 'Takes a 4th optional indexVariableName after the transform; Node has ' - 'arrayTransform(array, elementAlias, transform) and a separate ' - 'arrayTransformWithIndex(array, elementAlias, indexAlias, transform).', 'PipelineFunctions.map#keyValues': 'Takes an Iterable of alternating keys and values; Node ' 'map(elements: Record) takes a map, which Dart ' 'callers cannot pass.', - 'PipelineExpression.arrayContainsAny#values': - 'Only takes an Iterable; Node also takes an array expression and Dart ' - 'has no arrayContainsAnyFrom, unlike arrayContainsAll.', 'field#fieldPath': 'Only takes a String; Node field() also takes a FieldPath.', 'Expression.field#fieldPath': @@ -260,8 +248,6 @@ const _pendingFixes = { 'Node has no referenceSlice; check the backend supports ' '`reference_slice`, or drop.', // Node APIs Dart does not implement yet. - 'PipelineFunctions.arrayTransformWithIndex': - 'Missing static; only PipelineExpression.arrayTransformWithIndex exists.', 'PipelineFunctions.subcollection': 'Missing: Node subcollection(path | options) starts a subcollection ' 'Pipeline for use inside another stage.', diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index e05dbd17..23c9c841 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -85,9 +85,6 @@ const _pendingFixes = { 'select() leaves special-character field paths unquoted; Node quotes ' 'them, and keys a PipelineField by its quoted path ("`last name`").', // Ordering helpers. - 'stages/sort/top-level-field-name': - "ascending('rating') / descending('title') sort by the string " - 'constant (stringValue) instead of the field (fieldReferenceValue).', // Query to Pipeline conversion. 'queries/where/not-equal': "createFrom(query) leaves '!=' fields out of the implicit sort; " @@ -2438,12 +2435,21 @@ void _registerArrays(_Registry r) { PipelineExpression indexed() => variable('tag').stringConcat([variable('i')]); r.expr( 'functions/arrayTransformWithIndex/static-field-name', - () => PipelineFunctions.arrayTransform('tags', 'tag', indexed(), 'i'), + () => PipelineFunctions.arrayTransformWithIndex( + 'tags', + 'tag', + 'i', + indexed(), + ), ); r.expr( 'functions/arrayTransformWithIndex/static-expression', - () => - PipelineFunctions.arrayTransform(field('tags'), 'tag', indexed(), 'i'), + () => PipelineFunctions.arrayTransformWithIndex( + field('tags'), + 'tag', + 'i', + indexed(), + ), ); r.expr( 'functions/arrayTransformWithIndex/method', From c06c7dee4b68f0b3bbc11cf946be09faa4a32bb3 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:38:25 +0200 Subject: [PATCH 09/29] test(firestore): cover every Pipeline API member and argument shape live Fill the E2E suite until the coverage guard reports no gap: every stage, source, result accessor, execute option, enum value and function (static and fluent) now runs against the live database, with optional arguments both supplied and omitted, expressions and plain values in value positions, collections holding expressions and numeric lists for vectors. Exempt PipelineFunctions.raw(options) like its Expression.raw counterpart. --- .../test/e2e/pipeline/aggregates_test.dart | 195 +++++- .../test/e2e/pipeline/execute_test.dart | 185 ++++- .../pipeline/functions_arithmetic_test.dart | 553 ++++++++++++++- .../e2e/pipeline/functions_array_test.dart | 654 ++++++++++++++++-- .../functions_comparison_logical_test.dart | 533 +++++++++++++- .../functions_map_reference_test.dart | 367 +++++++++- .../e2e/pipeline/functions_string_test.dart | 608 +++++++++++++++- .../functions_timestamp_vector_test.dart | 589 ++++++++++++++-- .../pipeline/functions_type_debug_test.dart | 264 ++++++- .../test/e2e/pipeline/results_test.dart | 181 ++++- .../test/e2e/pipeline/sources_test.dart | 67 ++ .../test/e2e/pipeline/stages_test.dart | 408 ++++++++++- .../test/pipeline_e2e_coverage_test.dart | 3 + 13 files changed, 4383 insertions(+), 224 deletions(-) diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart index ae89f7f0..aaf66d72 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart @@ -41,38 +41,203 @@ void main() { expect(aggregateSnapshot.results.single.get('bookCount'), 2); }); - ctx.aggregateCases('aggregate functions', [ - AggregateCase('count', PipelineFunctions.count(), 3), + // Aggregated over the run's three books; see buildSeed in harness.dart. + ctx.aggregateCases('count', [ + // count() with no argument counts every input, like countAll(). + AggregateCase('static, no argument', PipelineFunctions.count(), 3), + AggregateCase('countAll', PipelineFunctions.countAll(), 3), + // Only book 1 has `sparse`, and no book has `missing`. + AggregateCase('static, field name', PipelineFunctions.count('sparse'), 1), AggregateCase( - 'countIf', + 'static, expression', + PipelineFunctions.count(Expression.field('missing')), + 0, + ), + AggregateCase('fluent', Expression.field('title').count(), 3), + ]); + + ctx.aggregateCases('countIf', [ + AggregateCase( + 'static, expression', PipelineFunctions.countIf(Expression.field('active')), 2, ), + // A constant true condition holds for every input. + AggregateCase('static, constant', PipelineFunctions.countIf(true), 3), + // price > 15: books 2 and 3. + AggregateCase( + 'fluent', + Expression.field('price').greaterThan(15).countIf(), + 2, + ), + ]); + + ctx.aggregateCases('countDistinct', [ + // active: true, true, false. + AggregateCase( + 'static, field name', + PipelineFunctions.countDistinct('active'), + 2, + ), + // half is 2.5 on every book. AggregateCase( - 'countDistinct', + 'static, expression', + PipelineFunctions.countDistinct(Expression.field('half')), + 1, + ), + AggregateCase( + 'fluent', Expression.field('metadata').mapGetLiteral('lang').countDistinct(), 1, ), - AggregateCase('sum', Expression.field('price').sum(), 60), + ]); + + ctx.aggregateCases('sum', [ + // rating: 5 + 4 + 2. + AggregateCase('static, field name', PipelineFunctions.sum('rating'), 11), + // score: -12.7 + 3.2 + 8.9. + AggregateCase( + 'static, expression', + PipelineFunctions.sum(Expression.field('score')), + isNumber(-0.6), + ), + AggregateCase('fluent', Expression.field('price').sum(), 60), + ]); + + ctx.aggregateCases('average', [ + // price: (10 + 20 + 30) / 3. + AggregateCase( + 'static, field name', + PipelineFunctions.average('price'), + isDouble(20), + ), + // ratio: (0.5 + 1.25 + 2.0) / 3. + AggregateCase( + 'static, expression', + PipelineFunctions.average(Expression.field('ratio')), + isDouble(1.25), + ), + // rating: (5 + 4 + 2) / 3. AggregateCase( - 'average', + 'fluent', Expression.field('rating').average(), - closeTo(11 / 3, 0.0001), + isDouble(11 / 3), + ), + ]); + + ctx.aggregateCases('minimum', [ + AggregateCase( + 'static, field name', + PipelineFunctions.minimum('score'), + isDouble(-12.7), + ), + AggregateCase( + 'static, expression on timestamps', + PipelineFunctions.minimum(Expression.field('createdAt')), + Timestamp(seconds: 1700000000, nanoseconds: 0), + ), + AggregateCase('fluent', Expression.field('price').minimum(), 10), + ]); + + ctx.aggregateCases('maximum', [ + AggregateCase( + 'static, field name', + PipelineFunctions.maximum('rating'), + 5, + ), + AggregateCase( + 'static, expression on strings', + PipelineFunctions.maximum(Expression.field('title')), + 'Inactive Draft', + ), + AggregateCase('fluent', Expression.field('price').maximum(), 30), + ]); + + ctx.aggregateCases('arrayAggDistinct', [ + // The order of distinct values is unspecified. + AggregateCase( + 'static, field name', + PipelineFunctions.arrayAggDistinct('active'), + unorderedEquals([true, false]), ), - AggregateCase('minimum', Expression.field('price').minimum(), 10), - AggregateCase('maximum', Expression.field('price').maximum(), 30), - AggregateCase('first', Expression.field('title').first(), isA()), - AggregateCase('last', Expression.field('title').last(), isA()), AggregateCase( - 'arrayAgg', - Expression.field('title').arrayAgg(), - containsAll(['Dart Pipelines', 'Firestore Admin', 'Inactive Draft']), + 'static, expression', + PipelineFunctions.arrayAggDistinct(Expression.field('half')), + [2.5], ), AggregateCase( - 'arrayAggDistinct', + 'fluent', Expression.field('metadata').mapGetLiteral('lang').arrayAggDistinct(), ['dart'], ), ]); + + test('first, last and arrayAgg follow the input order', () async { + final snapshot = + await ctx.runPipeline().sort([ascending('price')]).aggregate([ + PipelineFunctions.first('title').as('firstTitle'), + PipelineFunctions.first(Expression.field('price')).as('firstPrice'), + Expression.field('rating').first().as('firstRating'), + PipelineFunctions.last('title').as('lastTitle'), + PipelineFunctions.last(Expression.field('price')).as('lastPrice'), + Expression.field('rating').last().as('lastRating'), + PipelineFunctions.arrayAgg('title').as('titles'), + PipelineFunctions.arrayAgg( + Expression.field('rating'), + ).as('ratings'), + Expression.field('price').arrayAgg().as('prices'), + ]).execute(); + + expect(snapshot.results.single.data(), { + 'firstTitle': 'Dart Pipelines', + 'firstPrice': 10, + 'firstRating': 5, + 'lastTitle': 'Inactive Draft', + 'lastPrice': 30, + 'lastRating': 2, + 'titles': ['Dart Pipelines', 'Firestore Admin', 'Inactive Draft'], + 'ratings': [5, 4, 2], + 'prices': [10, 20, 30], + }); + }); + + test('aggregates per group', () async { + final snapshot = await ctx + .runPipeline() + .aggregate( + [ + PipelineFunctions.countAll().as('books'), + PipelineFunctions.sum('rating').as('totalRating'), + PipelineFunctions.average('price').as('averagePrice'), + ], + groups: [ + 'active', + Expression.field('metadata').mapGetLiteral('lang').as('lang'), + ], + ) + .sort([ascending('active')]) + .execute(); + + // Book 3 is inactive; books 1 and 2 are active. + expect( + [for (final result in snapshot.results) result.data()], + [ + { + 'active': false, + 'lang': 'dart', + 'books': 1, + 'totalRating': 2, + 'averagePrice': isDouble(30), + }, + { + 'active': true, + 'lang': 'dart', + 'books': 2, + 'totalRating': 9, + 'averagePrice': isDouble(15), + }, + ], + ); + }); }); } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart index 1c072bff..b2a279ce 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart @@ -22,25 +22,156 @@ import 'package:test/test.dart'; import 'harness.dart'; +/// The titles of the three seeded books, in book order. +const _allTitles = ['Dart Pipelines', 'Firestore Admin', 'Inactive Draft']; + +/// The titles in [snapshot], in order. +List _titles(PipelineSnapshot snapshot) { + return [for (final result in snapshot.results) result.get('title')]; +} + +/// [timestamp] in whole microseconds, for ordering comparisons. +int _micros(Timestamp timestamp) { + return timestamp.seconds * 1000000 + timestamp.nanoseconds ~/ 1000; +} + +/// [timestamp] truncated to whole microseconds, the precision the backend +/// accepts for a read time. +Timestamp _truncateToMicros(Timestamp timestamp, {int secondsEarlier = 0}) { + return Timestamp( + seconds: timestamp.seconds - secondsEarlier, + nanoseconds: timestamp.nanoseconds - timestamp.nanoseconds % 1000, + ); +} + +/// Expects [stats] to hold a human-readable text plan, as the backend +/// returns for the `text` format (and by default). +void _expectTextStats(ExplainStats? stats) { + expect(stats, isNotNull); + expect(stats!.typeName, 'google.protobuf.StringValue'); + expect(stats.text, isNotEmpty); + // Text, not JSON. + expect(stats.text, isNot(startsWith('{'))); + expect(stats.raw, isNotNull); +} + void main() { pipelineE2E('Pipeline execution', (ctx) { - test('requests explain stats via typed options', () async { - final snapshot = await ctx.firestore - .pipeline() - .collection(ctx.collectionPath) - .where(ctx.runFilter(Expression.field('active').equal(true))) - .execute( - explain: const PipelineExplainOptions( - mode: PipelineExplainMode.analyze, - outputFormat: PipelineExplainOutputFormat.text, - ), - ); + /// This run's books, in book order. + Pipeline sortedBooks() => ctx.runPipeline().sort([ascending('price')]); + + test('a pipeline keeps the Firestore instance it was built from', () { + expect(sortedBooks().firestore, same(ctx.firestore)); + }); + + test('explain analyze in text format returns stats and results', () async { + const options = PipelineExplainOptions( + mode: PipelineExplainMode.analyze, + outputFormat: PipelineExplainOutputFormat.text, + ); + expect(options.mode, PipelineExplainMode.analyze); + expect(options.outputFormat, PipelineExplainOutputFormat.text); + + final snapshot = await sortedBooks().execute(explain: options); // `analyze` runs the pipeline and returns planning stats alongside the // results, which is what proves the option names reached the backend. - expect(snapshot.results, isNotEmpty); - expect(snapshot.explainStats, isNotNull); - expect(snapshot.explainStats!.text, isNotEmpty); + _expectTextStats(snapshot.explainStats); + expect(_titles(snapshot), _allTitles); + }); + + test('explain analyze defaults to the text format', () async { + const options = PipelineExplainOptions(mode: PipelineExplainMode.analyze); + expect(options.outputFormat, isNull); + + final snapshot = await sortedBooks().execute(explain: options); + + _expectTextStats(snapshot.explainStats); + expect(_titles(snapshot), _allTitles); + }); + + test('explain execute returns results without stats', () async { + final snapshot = await sortedBooks().execute( + explain: const PipelineExplainOptions( + mode: PipelineExplainMode.execute, + outputFormat: PipelineExplainOutputFormat.text, + ), + ); + + expect(snapshot.explainStats, isNull); + expect(_titles(snapshot), _allTitles); + }); + + test('explain without a mode defaults to execute', () async { + for (final options in const [ + PipelineExplainOptions(), + PipelineExplainOptions(outputFormat: PipelineExplainOutputFormat.text), + ]) { + expect(options.mode, isNull); + + final snapshot = await sortedBooks().execute(explain: options); + + expect(snapshot.explainStats, isNull); + expect(_titles(snapshot), _allTitles); + } + }); + + test('executes with the recommended index mode', () async { + final snapshot = await sortedBooks().execute( + indexMode: PipelineIndexMode.recommended, + ); + + expect(_titles(snapshot), _allTitles); + }); + + test('rawOptions reach the backend', () async { + final snapshot = await sortedBooks().execute( + rawOptions: const { + 'explain_options': {'mode': 'analyze'}, + }, + ); + + _expectTextStats(snapshot.explainStats); + expect(_titles(snapshot), _allTitles); + }); + + test('rawOptions take precedence over typed options', () async { + final snapshot = await sortedBooks().execute( + explain: const PipelineExplainOptions( + mode: PipelineExplainMode.execute, + ), + rawOptions: const { + 'explain_options': {'mode': 'analyze'}, + }, + ); + + _expectTextStats(snapshot.explainStats); + expect(_titles(snapshot), _allTitles); + }); + + test('readTime reads the database as it was at that time', () async { + final current = await sortedBooks().execute(); + expect(_titles(current), _allTitles); + + // Before the first book was written, none of this run's books existed. + final firstWrite = current.results + .map((result) => result.createTime!) + .reduce((a, b) => _micros(a) <= _micros(b) ? a : b); + final beforeSeed = _truncateToMicros(firstWrite, secondsEarlier: 1); + final past = await sortedBooks().execute(readTime: beforeSeed); + expect(past.results, isEmpty); + + // By the time the first pipeline ran, all of them did. + final seeded = _truncateToMicros(current.executionTime!); + final atSeed = await sortedBooks().execute(readTime: seeded); + expect(_titles(atSeed), _allTitles); + + // A read-only transaction at a read time reads at that time too. + final inTransaction = await ctx.firestore.runTransaction( + (transaction) => transaction.executePipeline(sortedBooks()), + transactionOptions: ReadOnlyTransactionOptions(readTime: beforeSeed), + ); + expect(inTransaction.results, isEmpty); }); test('executes a pipeline inside a transaction', () async { @@ -53,10 +184,34 @@ void main() { .sort([Expression.field('price').ascending()]) .select([Expression.field('title')]), ); - return [for (final result in snapshot.results) result.get('title')]; + return _titles(snapshot); }, transactionOptions: ReadOnlyTransactionOptions()); expect(titles, ['Dart Pipelines', 'Firestore Admin']); }); + + test('a transaction forwards execute options', () async { + final pipeline = sortedBooks(); + + final snapshot = await pipeline.firestore.runTransaction( + (transaction) => transaction.executePipeline( + pipeline, + indexMode: PipelineIndexMode.recommended, + // rawOptions win over the typed explain options, so this runs in + // the default `execute` mode and returns no stats. + explain: const PipelineExplainOptions( + mode: PipelineExplainMode.analyze, + outputFormat: PipelineExplainOutputFormat.text, + ), + rawOptions: const { + 'explain_options': {'mode': 'execute'}, + }, + ), + transactionOptions: ReadOnlyTransactionOptions(), + ); + + expect(snapshot.explainStats, isNull); + expect(_titles(snapshot), _allTitles); + }); }); } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart index fd0b8afe..27f95dd6 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart @@ -13,9 +13,23 @@ // limitations under the License. /// Live coverage of the arithmetic Pipeline functions. +/// +/// Result types follow the backend's arithmetic reference: `add`, `subtract`, +/// `multiply`, `divide`, `mod`, `abs`, `ceil`, `floor`, `round` and `trunc` +/// keep the type of their (widest) operand, so two int64 operands give an +/// int64 and any float64 operand gives a float64; `pow`, `sqrt`, `exp`, `ln`, +/// `log`, `log10` and `rand` always return a float64. Integer division +/// truncates toward zero and `mod` takes the sign of the dividend. Arithmetic +/// on a `NULL` operand is `NULL`. +/// +/// `logicalMaximum` / `logicalMinimum` (the backend's scalar `maximum` / +/// `minimum`) return the largest / smallest operand unchanged, skipping +/// `NULL` and absent operands. @Tags(['prod']) library; +import 'dart:math' as math; + import 'package:google_cloud_firestore/google_cloud_firestore.dart'; import 'package:test/test.dart'; @@ -23,38 +37,525 @@ import 'harness.dart'; void main() { pipelineE2E('Pipeline arithmetic functions', (ctx) { - ctx.functionCases('arithmetic functions', [ + // Book 1: price 10, rating 5, discount 2, quantity 7, zero 0 (ints); + // score -12.7, ratio 0.5, half 2.5, negativeHalf -2.5, precise 4.123456 + // (doubles); nullable null. + + ctx.functionCases('add', [ + FunctionCase( + 'fluent, int literal: int + int is int', + Expression.field('price').add(2), + isInt(12), + ), + FunctionCase( + 'fluent, field expression', + Expression.field('price').add(Expression.field('rating')), + isInt(15), + ), + FunctionCase( + 'int + double widens to double', + Expression.field('price').add(Expression.field('ratio')), + isDouble(10.5), + ), + FunctionCase( + 'int64 beyond 2^53 keeps full precision', + Expression.field('largeInt').add(1), + isInt(9007199254740994), + ), + FunctionCase( + 'a NULL operand gives NULL', + Expression.field('price').add(Expression.field('nullable')), + isNull, + ), + FunctionCase( + 'static, field name and literal', + PipelineFunctions.add('price', 5), + isInt(15), + ), + FunctionCase( + 'static, expressions', + PipelineFunctions.add( + Expression.field('price'), + Expression.field('discount'), + ), + isInt(12), + ), + ]); + + ctx.functionCases('subtract', [ + FunctionCase( + 'fluent, int literal', + Expression.field('price').subtract(3), + isInt(7), + ), + FunctionCase( + 'fluent, field expression', + Expression.field('price').subtract(Expression.field('quantity')), + isInt(3), + ), + FunctionCase( + 'static, field name and literal: result may be negative', + PipelineFunctions.subtract('price', 15), + isInt(-5), + ), + FunctionCase( + 'static, expressions: double - double', + PipelineFunctions.subtract( + Expression.field('ratio'), + Expression.field('half'), + ), + isDouble(-2), + ), + ]); + + ctx.functionCases('multiply', [ + FunctionCase( + 'fluent, int literal', + Expression.field('price').multiply(2), + isInt(20), + ), + FunctionCase( + 'fluent, field expression: int * double is double', + Expression.field('price').multiply(Expression.field('ratio')), + isDouble(5), + ), + FunctionCase( + 'static, field name and literal', + PipelineFunctions.multiply('rating', 3), + isInt(15), + ), + FunctionCase( + 'static, expressions', + PipelineFunctions.multiply( + Expression.field('price'), + Expression.field('quantity'), + ), + isInt(70), + ), + ]); + + ctx.functionCases('divide', [ + FunctionCase( + 'fluent, int literal', + Expression.field('price').divide(2), + isInt(5), + ), + FunctionCase( + 'fluent, double literal: widens to double', + Expression.field('price').divide(4.0), + isDouble(2.5), + ), + FunctionCase( + 'fluent, field expression: int / double', + Expression.field('price').divide(Expression.field('ratio')), + isDouble(20), + ), + FunctionCase( + 'static, field name and literal: integer division truncates', + PipelineFunctions.divide('price', 3), + isInt(3), + ), + FunctionCase( + 'static, expressions: integer division truncates toward zero', + PipelineFunctions.divide( + Expression.constant(-7), + Expression.field('discount'), + ), + isInt(-3), + ), + ]); + + ctx.functionCases('mod', [ FunctionCase( - 'abs', + 'fluent, int literal', + Expression.field('price').mod(4), + isInt(2), + ), + FunctionCase( + 'fluent, field expression', + Expression.field('price').mod(Expression.field('quantity')), + isInt(3), + ), + FunctionCase( + 'fluent, double dividend', + Expression.field('half').mod(2), + isDouble(0.5), + ), + FunctionCase( + 'static, field name and literal', + PipelineFunctions.mod('price', 3), + isInt(1), + ), + FunctionCase( + 'static, expressions: takes the sign of the dividend', + PipelineFunctions.mod(Expression.constant(-10), Expression.constant(3)), + isInt(-1), + ), + ]); + + ctx.functionCases('abs', [ + FunctionCase( + 'fluent, double', Expression.field('score').abs(), - closeTo(12.7, 0.0001), - ), - FunctionCase('add', Expression.field('price').add(2), 12), - FunctionCase('subtract', Expression.field('price').subtract(3), 7), - FunctionCase('multiply', Expression.field('price').multiply(2), 20), - FunctionCase('divide', Expression.field('price').divide(2), 5), - FunctionCase('mod', Expression.field('price').mod(4), 2), - FunctionCase('ceil', Expression.constant(12.2).ceil(), 13), - FunctionCase('floor', Expression.constant(12.8).floor(), 12), - FunctionCase('round', Expression.constant(12.6).round(), 13), - FunctionCase('trunc', Expression.constant(12.8).trunc(), 12), - FunctionCase( - 'roundDecimalPlaces', + isDouble(12.7), + ), + FunctionCase( + 'static, field name', + PipelineFunctions.abs('negativeHalf'), + isDouble(2.5), + ), + FunctionCase( + 'static, expression: int stays int', + PipelineFunctions.abs(Expression.constant(-10)), + isInt(10), + ), + ]); + + ctx.functionCases('ceil', [ + FunctionCase('fluent', Expression.constant(12.2).ceil(), isDouble(13)), + FunctionCase( + 'fluent, int stays int', + Expression.field('price').ceil(), + isInt(10), + ), + FunctionCase( + 'static, field name: negative half', + PipelineFunctions.ceil('negativeHalf'), + isDouble(-2), + ), + FunctionCase( + 'static, expression', + PipelineFunctions.ceil(Expression.field('score')), + isDouble(-12), + ), + ]); + + ctx.functionCases('floor', [ + FunctionCase('fluent', Expression.constant(12.8).floor(), isDouble(12)), + FunctionCase( + 'static, field name: negative half', + PipelineFunctions.floor('negativeHalf'), + isDouble(-3), + ), + FunctionCase( + 'static, expression', + PipelineFunctions.floor(Expression.field('ratio')), + isDouble(0), + ), + ]); + + ctx.functionCases('round', [ + FunctionCase('fluent', Expression.constant(12.6).round(), isDouble(13)), + FunctionCase( + 'fluent: positive half rounds away from zero', + Expression.field('half').round(), + isDouble(3), + ), + FunctionCase( + 'fluent: negative half rounds away from zero', + Expression.field('negativeHalf').round(), + isDouble(-3), + ), + FunctionCase( + 'fluent, literal decimal places', Expression.constant(12.68).round(1), - closeTo(12.7, 0.0001), + isDouble(12.7), + ), + FunctionCase( + 'fluent, expression decimal places', + Expression.field('precise').round(Expression.field('discount')), + isDouble(4.12), + ), + FunctionCase( + 'static, field name, no decimal places', + PipelineFunctions.round('half'), + isDouble(3), + ), + FunctionCase( + 'static, field name, literal decimal places', + PipelineFunctions.round('precise', 4), + isDouble(4.1235), + ), + FunctionCase( + 'static, expression, expression decimal places', + PipelineFunctions.round( + Expression.field('precise'), + Expression.constant(1), + ), + isDouble(4.1), ), FunctionCase( - 'truncDecimalPlaces', + 'static, expression, negative decimal places: int stays int', + PipelineFunctions.round(Expression.constant(15), -1), + isInt(20), + ), + ]); + + ctx.functionCases('trunc', [ + FunctionCase('fluent', Expression.constant(12.8).trunc(), isDouble(12)), + FunctionCase( + 'fluent: truncates toward zero', + Expression.field('negativeHalf').trunc(), + isDouble(-2), + ), + FunctionCase( + 'fluent, literal decimal places', Expression.constant(12.89).trunc(1), - closeTo(12.8, 0.0001), - ), - FunctionCase('pow', PipelineFunctions.pow(2, 3), 8), - FunctionCase('sqrt', Expression.constant(9).sqrt(), 3), - FunctionCase('exp', PipelineFunctions.exp(1), closeTo(2.71828, 0.001)), - FunctionCase('ln', PipelineFunctions.ln(2.718281828), closeTo(1, 0.001)), - FunctionCase('log', PipelineFunctions.log(8, 2), closeTo(3, 0.001)), - FunctionCase('log10', PipelineFunctions.log10(100), closeTo(2, 0.001)), - FunctionCase('rand', PipelineFunctions.rand(), isA()), + isDouble(12.8), + ), + FunctionCase( + 'fluent, expression decimal places', + Expression.field('precise').trunc(Expression.field('discount')), + isDouble(4.12), + ), + FunctionCase( + 'static, field name, no decimal places', + PipelineFunctions.trunc('score'), + isDouble(-12), + ), + FunctionCase( + 'static, field name, literal decimal places', + PipelineFunctions.trunc('precise', 4), + isDouble(4.1234), + ), + FunctionCase( + 'static, expression, expression decimal places', + PipelineFunctions.trunc( + Expression.field('precise'), + Expression.constant(1), + ), + isDouble(4.1), + ), + FunctionCase( + 'static, expression, negative decimal places: int stays int', + PipelineFunctions.trunc(Expression.constant(15), -1), + isInt(10), + ), + ]); + + ctx.functionCases('pow', [ + FunctionCase( + 'static, literals: int operands give a double', + PipelineFunctions.pow(2, 3), + isDouble(8), + ), + FunctionCase( + 'static, field name base', + PipelineFunctions.pow('ratio', 2), + isDouble(0.25), + ), + FunctionCase( + 'static, expressions: negative exponent', + PipelineFunctions.pow( + Expression.field('discount'), + Expression.constant(-1), + ), + isDouble(0.5), + ), + FunctionCase( + 'fluent, literal exponent', + Expression.field('rating').pow(2), + isDouble(25), + ), + FunctionCase( + 'fluent, expression exponent', + Expression.field('price').pow(Expression.field('discount')), + isDouble(100), + ), + ]); + + ctx.functionCases('sqrt', [ + FunctionCase( + 'fluent: int operand gives a double', + Expression.constant(9).sqrt(), + isDouble(3), + ), + FunctionCase( + 'static, field name', + PipelineFunctions.sqrt('discount'), + isDouble(math.sqrt2), + ), + FunctionCase( + 'static, expression', + PipelineFunctions.sqrt(Expression.field('ratio')), + isDouble(math.sqrt1_2), + ), + ]); + + ctx.functionCases('exp', [ + FunctionCase( + 'static, literal', + PipelineFunctions.exp(1), + isDouble(math.e), + ), + FunctionCase( + 'static, expression', + PipelineFunctions.exp(Expression.field('discount')), + isDouble(math.exp(2)), + ), + FunctionCase('fluent', Expression.field('zero').exp(), isDouble(1)), + ]); + + ctx.functionCases('ln', [ + FunctionCase( + 'static, literal', + PipelineFunctions.ln(math.e), + isDouble(1), + ), + FunctionCase( + 'static, expression', + PipelineFunctions.ln(Expression.constant(1)), + isDouble(0), + ), + FunctionCase( + 'fluent', + Expression.field('price').ln(), + isDouble(math.log(10)), + ), + ]); + + ctx.functionCases('log', [ + FunctionCase( + 'static, literals', + PipelineFunctions.log(8, 2), + isDouble(3), + ), + FunctionCase( + 'static, expressions', + PipelineFunctions.log( + Expression.constant(100), + Expression.field('price'), + ), + isDouble(2), + ), + FunctionCase( + 'static, field name, no base: natural logarithm', + PipelineFunctions.log('price'), + isDouble(math.log(10)), + ), + FunctionCase( + 'fluent, literal base', + Expression.field('price').log(10), + isDouble(1), + ), + FunctionCase( + 'fluent, expression base', + Expression.constant(1024).log(Expression.field('discount')), + isDouble(10), + ), + FunctionCase( + 'fluent, no base: natural logarithm', + Expression.field('price').log(), + isDouble(math.log(10)), + ), + ]); + + ctx.functionCases('log10', [ + FunctionCase( + 'static, literal', + PipelineFunctions.log10(100), + isDouble(2), + ), + FunctionCase( + 'static, expression', + PipelineFunctions.log10(Expression.constant(1000)), + isDouble(3), + ), + FunctionCase('fluent', Expression.field('price').log10(), isDouble(1)), + ]); + + ctx.functionCases('rand', [ + FunctionCase( + 'a double in [0, 1)', + PipelineFunctions.rand(), + allOf(isA(), inClosedOpenRange(0, 1)), + ), + ]); + + ctx.functionCases('logicalMaximum', [ + FunctionCase( + 'static, field name and literal', + PipelineFunctions.logicalMaximum('price', 15), + isInt(15), + ), + FunctionCase( + 'static, expressions and others', + PipelineFunctions.logicalMaximum( + Expression.field('rating'), + Expression.field('discount'), + [Expression.field('price'), 3], + ), + isInt(10), + ), + FunctionCase( + 'static: NULL and absent operands are skipped', + PipelineFunctions.logicalMaximum( + 'missing', + Expression.field('nullable'), + [Expression.field('rating')], + ), + isInt(5), + ), + FunctionCase( + 'fluent, literal: returns the operand unchanged', + Expression.field('score').logicalMaximum(-20), + isDouble(-12.7), + ), + FunctionCase( + 'fluent, expression and others', + Expression.field('price').logicalMaximum(Expression.field('rating'), [ + 30, + Expression.field('quantity'), + ]), + isInt(30), + ), + ]); + + ctx.functionCases('logicalMinimum', [ + FunctionCase( + 'static, field name and literal', + PipelineFunctions.logicalMinimum('price', 15), + isInt(10), + ), + FunctionCase( + 'static, expressions and others', + PipelineFunctions.logicalMinimum( + Expression.field('rating'), + Expression.field('price'), + [Expression.field('quantity'), 3], + ), + isInt(3), + ), + FunctionCase( + 'static: NULL and absent operands are skipped', + PipelineFunctions.logicalMinimum( + 'nullable', + Expression.field('missing'), + [Expression.field('discount')], + ), + isInt(2), + ), + FunctionCase( + 'fluent, literal: returns the operand unchanged', + Expression.field('score').logicalMinimum(0), + isDouble(-12.7), + ), + FunctionCase( + 'fluent, expression and others: int and double operands compare ' + 'numerically', + Expression.field('price').logicalMinimum(Expression.field('ratio'), [ + 20, + Expression.field('rating'), + ]), + isDouble(0.5), + ), + FunctionCase( + 'fluent: only NULL and absent operands give NULL', + Expression.field( + 'nullable', + ).logicalMinimum(Expression.field('missing')), + isNull, + ), ]); }); } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_array_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_array_test.dart index d5b1d4e1..8dbcfd06 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_array_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_array_test.dart @@ -23,13 +23,40 @@ import 'harness.dart'; void main() { pipelineE2E('Pipeline array functions', (ctx) { - ctx.functionCases('array functions', [ + ctx.functionCases('array construction', [ FunctionCase('array', Expression.array([1, 2, 3]), [1, 2, 3]), + FunctionCase( + 'array with an expression element', + Expression.array([Expression.field('prefix'), 'x']), + ['Dart', 'x'], + ), + FunctionCase( + 'static array of plain values', + PipelineFunctions.array(['a', 1, true, null]), + ['a', 1, true, null], + ), + FunctionCase( + 'static array with expression elements', + PipelineFunctions.array([ + Expression.field('title'), + Expression.field('rating'), + 0, + ]), + ['Dart Pipelines', 5, 0], + ), + ]); + + ctx.functionCases('arrayConcat', [ FunctionCase( 'arrayConcat', Expression.field('tags').arrayConcat(['admin']), ['dart', 'firebase', 'admin'], ), + FunctionCase( + 'arrayConcat with an array expression', + Expression.field('tags').arrayConcat(Expression.field('words')), + ['dart', 'firebase', 'dart', 'firebase'], + ), FunctionCase( 'arrayConcatMultiple', Expression.field('tags').arrayConcatMultiple([ @@ -38,6 +65,27 @@ void main() { ]), ['dart', 'firebase', 'admin', 'sdk'], ), + FunctionCase( + 'static arrayConcat', + PipelineFunctions.arrayConcat([ + 'tags', + ['admin'], + Expression.field('emptyArray'), + [null], + ]), + ['dart', 'firebase', 'admin', null], + ), + FunctionCase( + 'static arrayConcat of expressions', + PipelineFunctions.arrayConcat([ + Expression.field('numbers'), + Expression.field('scores'), + ]), + [3, 1, 2, 3, 1.5, 2.5, -0.5], + ), + ]); + + ctx.functionCases('arrayContains', [ FunctionCase( 'arrayContainsValue', Expression.field('tags').arrayContains('dart'), @@ -48,11 +96,49 @@ void main() { Expression.field('tags').arrayContains(Expression.constant('firebase')), true, ), + FunctionCase( + 'static arrayContains', + PipelineFunctions.arrayContains('tags', 'firebase'), + true, + ), + FunctionCase( + 'static arrayContains with expressions', + PipelineFunctions.arrayContains( + Expression.field('numbers'), + Expression.field('discount'), + ), + true, + ), + FunctionCase( + 'static arrayContains of a value not in the array', + PipelineFunctions.arrayContains('numbers', Expression.field('rating')), + false, + ), FunctionCase( 'arrayContainsAll', Expression.field('tags').arrayContainsAll(['dart', 'firebase']), true, ), + FunctionCase( + 'arrayContainsAll with an array expression', + Expression.field( + 'words', + ).arrayContainsAll(Expression.array(['firebase', 'dart'])), + true, + ), + FunctionCase( + 'static arrayContainsAll with expressions', + PipelineFunctions.arrayContainsAll( + Expression.field('tags'), + Expression.field('words'), + ), + true, + ), + FunctionCase( + 'static arrayContainsAll with a missing value', + PipelineFunctions.arrayContainsAll('numbers', [1, 4]), + false, + ), FunctionCase( 'arrayContainsAllFrom', Expression.field( @@ -60,11 +146,39 @@ void main() { ).arrayContainsAllFrom(Expression.array(['dart'])), true, ), + FunctionCase( + 'arrayContainsAllFrom with a list', + Expression.field('tags').arrayContainsAllFrom(['dart', 'missing']), + false, + ), FunctionCase( 'arrayContainsAny', Expression.field('tags').arrayContainsAny(['missing', 'dart']), true, ), + FunctionCase( + 'arrayContainsAny with an array expression', + Expression.field( + 'tags', + ).arrayContainsAny(Expression.array(['sdk', 'admin'])), + false, + ), + FunctionCase( + 'static arrayContainsAny', + PipelineFunctions.arrayContainsAny('tags', ['missing', 'firebase']), + true, + ), + FunctionCase( + 'static arrayContainsAny with expressions', + PipelineFunctions.arrayContainsAny( + Expression.field('numbers'), + Expression.array([9, 4]), + ), + false, + ), + ]); + + ctx.functionCases('arrayFilter and arrayTransform', [ FunctionCase( 'arrayFilter', Expression.field( @@ -72,115 +186,496 @@ void main() { ).arrayFilter('tag', Expression.variable('tag').notEqual('firebase')), ['dart'], ), + FunctionCase( + 'static arrayFilter', + PipelineFunctions.arrayFilter( + 'numbers', + 'n', + variable('n').greaterThan(1), + ), + [3, 2, 3], + ), + FunctionCase( + 'static arrayFilter with a constant predicate', + PipelineFunctions.arrayFilter(Expression.field('tags'), 'tag', false), + [], + ), + FunctionCase( + 'arrayTransform', + Expression.field( + 'numbers', + ).arrayTransform('n', Expression.variable('n').add(1)), + [4, 2, 3, 4], + ), + // A list body holding the variable is built with array(...), so each + // element maps to a pair. + FunctionCase( + 'arrayTransform with a list body', + Expression.field('tags').arrayTransform('t', [variable('t'), 1]), + [ + ['dart', 1], + ['firebase', 1], + ], + ), + FunctionCase( + 'static arrayTransform', + PipelineFunctions.arrayTransform( + 'numbers', + 'n', + variable('n').multiply(2), + ), + [6, 2, 4, 6], + ), + FunctionCase( + 'static arrayTransform with a constant body', + PipelineFunctions.arrayTransform(Expression.field('tags'), 't', 'x'), + ['x', 'x'], + ), + FunctionCase( + 'arrayTransformWithIndex', + Expression.field('numbers').arrayTransformWithIndex( + 'n', + 'i', + Expression.variable('n').add(Expression.variable('i')), + ), + [3, 2, 4, 6], + ), + FunctionCase( + 'arrayTransformWithIndex with a constant body', + Expression.field('tags').arrayTransformWithIndex('t', 'i', 7), + [7, 7], + ), + FunctionCase( + 'static arrayTransformWithIndex', + PipelineFunctions.arrayTransformWithIndex( + 'tags', + 't', + 'i', + variable('i'), + ), + [0, 1], + ), + FunctionCase( + 'static arrayTransformWithIndex with a list body', + PipelineFunctions.arrayTransformWithIndex( + Expression.field('scores'), + 's', + 'i', + [variable('i'), variable('s')], + ), + [ + [0, 1.5], + [1, 2.5], + [2, -0.5], + ], + ), + ]); + + ctx.functionCases('element access', [ FunctionCase( 'arrayGet', + Expression.field('tags').arrayGet(1), + 'firebase', + ), + // A negative index counts from the end. + FunctionCase( + 'arrayGet with a negative index', + Expression.field('scores').arrayGet(Expression.constant(-1)), + -0.5, + ), + FunctionCase( + 'static arrayGet', PipelineFunctions.arrayGet(Expression.field('numbers'), 1), 1, ), - FunctionCase('arrayLength', Expression.field('numbers').arrayLength(), 4), - FunctionCase('arrayReverse', Expression.field('numbers').arrayReverse(), [ - 3, + FunctionCase( + 'static arrayGet with an index expression', + PipelineFunctions.arrayGet('numbers', Expression.field('discount')), 2, - 1, - 3, - ]), + ), FunctionCase('arrayFirst', Expression.field('numbers').arrayFirst(), 3), + FunctionCase( + 'static arrayFirst', + PipelineFunctions.arrayFirst('tags'), + 'dart', + ), + FunctionCase( + 'static arrayFirst of an expression', + PipelineFunctions.arrayFirst(Expression.field('scores')), + 1.5, + ), FunctionCase('arrayFirstN', Expression.field('numbers').arrayFirstN(2), [ 3, 1, ]), FunctionCase( - 'arrayIndexOf', - Expression.field('numbers').arrayIndexOf(1), - 1, + 'arrayFirstN with an expression count', + Expression.field('scores').arrayFirstN(Expression.constant(2)), + [1.5, 2.5], ), FunctionCase( - 'arrayIndexOfAll', - Expression.field('numbers').arrayIndexOfAll(3), - [0, 3], + 'static arrayFirstN', + PipelineFunctions.arrayFirstN('tags', 1), + ['dart'], + ), + FunctionCase( + 'static arrayFirstN with expressions', + PipelineFunctions.arrayFirstN( + Expression.field('numbers'), + Expression.field('discount'), + ), + [3, 1], ), FunctionCase('arrayLast', Expression.field('numbers').arrayLast(), 3), + FunctionCase( + 'static arrayLast', + PipelineFunctions.arrayLast('tags'), + 'firebase', + ), + FunctionCase( + 'static arrayLast of an expression', + PipelineFunctions.arrayLast(Expression.field('scores')), + -0.5, + ), FunctionCase('arrayLastN', Expression.field('numbers').arrayLastN(2), [ 2, 3, ]), FunctionCase( - 'arrayLastIndexOf', - Expression.field('numbers').arrayLastIndexOf(3), - 3, + 'arrayLastN with an expression count', + Expression.field('tags').arrayLastN(Expression.constant(1)), + ['firebase'], + ), + FunctionCase( + 'static arrayLastN', + PipelineFunctions.arrayLastN('scores', 2), + [2.5, -0.5], + ), + FunctionCase( + 'static arrayLastN with expressions', + PipelineFunctions.arrayLastN( + Expression.field('numbers'), + Expression.field('discount'), + ), + [2, 3], ), FunctionCase('arraySlice', Expression.field('numbers').arraySlice(1, 2), [ 1, 2, ]), FunctionCase( - 'arrayTransform', + 'arraySlice without a length runs to the end', + Expression.field('scores').arraySlice(1), + [2.5, -0.5], + ), + FunctionCase( + 'arraySlice with expressions', Expression.field( 'numbers', - ).arrayTransform('n', Expression.variable('n').add(1)), - [4, 2, 3, 4], + ).arraySlice(Expression.constant(1), Expression.field('discount')), + [1, 2], ), + // A negative offset counts from the end: -2 is index 2 of [3, 1, 2, 3]. FunctionCase( - 'arrayTransformWithIndex', - Expression.field('numbers').arrayTransformWithIndex( - 'n', - 'i', - Expression.variable('n').add(Expression.variable('i')), + 'arraySlice with a negative offset', + Expression.field('numbers').arraySlice(-2, 1), + [2], + ), + FunctionCase( + 'static arraySlice', + PipelineFunctions.arraySlice('tags', 0, 1), + ['dart'], + ), + FunctionCase( + 'static arraySlice without a length runs to the end', + PipelineFunctions.arraySlice('numbers', 1), + [1, 2, 3], + ), + FunctionCase( + 'static arraySlice with expressions', + PipelineFunctions.arraySlice( + Expression.field('numbers'), + Expression.field('discount'), + Expression.constant(1), ), - [3, 2, 4, 6], + [2], ), + ]); + + ctx.functionCases('index search', [ FunctionCase( - 'arrayMaximum', - Expression.field('numbers').arrayMaximum(), + 'arrayIndexOf', + Expression.field('numbers').arrayIndexOf(1), + 1, + ), + FunctionCase( + 'arrayIndexOf an expression', + Expression.field('tags').arrayIndexOf(Expression.constant('firebase')), + isInt(1), + ), + FunctionCase( + 'static arrayIndexOf', + PipelineFunctions.arrayIndexOf('numbers', 3), + isInt(0), + ), + FunctionCase( + 'static arrayIndexOf with expressions', + PipelineFunctions.arrayIndexOf( + Expression.field('numbers'), + Expression.field('discount'), + ), + isInt(2), + ), + FunctionCase( + 'static arrayIndexOf of a value not in the array', + PipelineFunctions.arrayIndexOf('tags', 'missing'), + isInt(-1), + ), + FunctionCase( + 'static arrayIndexOf of null', + PipelineFunctions.arrayIndexOf('withNulls', null), + isInt(1), + ), + FunctionCase( + 'arrayIndexOfAll', + Expression.field('numbers').arrayIndexOfAll(3), + [0, 3], + ), + FunctionCase( + 'arrayIndexOfAll an expression', + Expression.field( + 'numbers', + ).arrayIndexOfAll(Expression.field('discount')), + [2], + ), + FunctionCase( + 'arrayIndexOfAll of a value not in the array', + Expression.field('tags').arrayIndexOfAll('missing'), + [], + ), + FunctionCase( + 'static arrayIndexOfAll', + PipelineFunctions.arrayIndexOfAll('booleans', true), + [0, 2], + ), + FunctionCase( + 'static arrayIndexOfAll with expressions', + PipelineFunctions.arrayIndexOfAll( + Expression.field('booleans'), + Expression.field('archived'), + ), + [1], + ), + FunctionCase( + 'arrayLastIndexOf', + Expression.field('numbers').arrayLastIndexOf(3), 3, ), - FunctionCase('maximumN', Expression.field('numbers').arrayMaximumN(2), [ + FunctionCase( + 'arrayLastIndexOf an expression', + Expression.field( + 'booleans', + ).arrayLastIndexOf(Expression.field('active')), + isInt(2), + ), + FunctionCase( + 'static arrayLastIndexOf', + PipelineFunctions.arrayLastIndexOf('booleans', false), + isInt(1), + ), + FunctionCase( + 'static arrayLastIndexOf with expressions', + PipelineFunctions.arrayLastIndexOf( + Expression.field('numbers'), + Expression.constant(3), + ), + isInt(3), + ), + FunctionCase( + 'static arrayLastIndexOf of a value not in the array', + PipelineFunctions.arrayLastIndexOf('tags', 'missing'), + isInt(-1), + ), + ]); + + ctx.functionCases('arrayLength and arrayReverse', [ + FunctionCase('arrayLength', Expression.field('numbers').arrayLength(), 4), + FunctionCase( + 'static arrayLength', + PipelineFunctions.arrayLength('tags'), + isInt(2), + ), + FunctionCase( + 'static arrayLength of an expression', + PipelineFunctions.arrayLength(Expression.field('emptyArray')), + isInt(0), + ), + FunctionCase('arrayReverse', Expression.field('numbers').arrayReverse(), [ 3, + 2, + 1, 3, ]), + FunctionCase( + 'static arrayReverse', + PipelineFunctions.arrayReverse('scores'), + [-0.5, 2.5, 1.5], + ), + FunctionCase( + 'static arrayReverse of an expression', + PipelineFunctions.arrayReverse(Expression.field('tags')), + ['firebase', 'dart'], + ), + ]); + + // maximumN returns the largest elements in descending order and minimumN + // the smallest in ascending order, as in the Node SDK system tests. + ctx.functionCases('maximum, minimum and sum', [ + FunctionCase( + 'arrayMaximum', + Expression.field('numbers').arrayMaximum(), + 3, + ), + FunctionCase( + 'arrayMaximumN', + Expression.field('numbers').arrayMaximumN(2), + [3, 3], + ), + FunctionCase( + 'arrayMaximumN with an expression count', + Expression.field('scores').arrayMaximumN(Expression.constant(1)), + [2.5], + ), FunctionCase( 'arrayMinimum', Expression.field('numbers').arrayMinimum(), 1, ), - FunctionCase('minimumN', Expression.field('numbers').arrayMinimumN(2), [ - 1, - 2, - ]), + FunctionCase( + 'arrayMinimumN', + Expression.field('numbers').arrayMinimumN(2), + [1, 2], + ), + FunctionCase( + 'arrayMinimumN with an expression count', + Expression.field('tags').arrayMinimumN(Expression.field('discount')), + ['dart', 'firebase'], + ), FunctionCase('arraySum', Expression.field('numbers').arraySum(), 9), + FunctionCase( + 'arraySum of mixed ints and doubles', + Expression.field('mixedNumbers').arraySum(), + isDouble(0.5), + ), // The static helpers must emit the same backend names as the fluent // forms (`maximum`, not `array_maximum`). FunctionCase( - 'staticArrayMaximum', + 'static arrayMaximum', PipelineFunctions.arrayMaximum('numbers'), 3, ), FunctionCase( - 'staticArrayMaximumN', + 'static arrayMaximum of an expression', + PipelineFunctions.arrayMaximum(Expression.field('scores')), + isDouble(2.5), + ), + FunctionCase( + 'static arrayMaximumN', PipelineFunctions.arrayMaximumN('numbers', 2), [3, 3], ), FunctionCase( - 'staticArrayMinimum', + 'static arrayMaximumN with expressions', + PipelineFunctions.arrayMaximumN( + Expression.field('tags'), + Expression.constant(1), + ), + ['firebase'], + ), + FunctionCase( + 'static arrayMinimum', PipelineFunctions.arrayMinimum('numbers'), 1, ), FunctionCase( - 'staticArrayMinimumN', + 'static arrayMinimum of an expression', + PipelineFunctions.arrayMinimum(Expression.field('mixedNumbers')), + isInt(-3), + ), + FunctionCase( + 'static arrayMinimumN', PipelineFunctions.arrayMinimumN('numbers', 2), [1, 2], ), - FunctionCase('staticArraySum', PipelineFunctions.arraySum('numbers'), 9), FunctionCase( - 'join', + 'static arrayMinimumN with expressions', + PipelineFunctions.arrayMinimumN( + Expression.field('scores'), + Expression.constant(2), + ), + [-0.5, 1.5], + ), + FunctionCase('static arraySum', PipelineFunctions.arraySum('numbers'), 9), + FunctionCase( + 'static arraySum of an expression', + PipelineFunctions.arraySum(Expression.field('scores')), + isDouble(3.5), + ), + FunctionCase('static maximumN', PipelineFunctions.maximumN('scores', 2), [ + 2.5, + 1.5, + ]), + FunctionCase( + 'static maximumN with expressions', + PipelineFunctions.maximumN( + Expression.field('mixedNumbers'), + Expression.field('discount'), + ), + [2.5, 1], + ), + FunctionCase('static minimumN', PipelineFunctions.minimumN('scores', 2), [ + -0.5, + 1.5, + ]), + FunctionCase( + 'static minimumN with expressions', + PipelineFunctions.minimumN( + Expression.field('mixedNumbers'), + Expression.field('discount'), + ), + [-3, 0], + ), + ]); + + ctx.functionCases('join', [ + FunctionCase( + 'joinLiteral', Expression.field('words').joinLiteral('-'), 'dart-firebase', ), FunctionCase( - 'joinStatic', + 'join', + Expression.field('words').join('/'), + 'dart/firebase', + ), + FunctionCase( + 'join with an expression delimiter', + Expression.field('tags').join(Expression.field('delimiter')), + 'dart;firebase', + ), + FunctionCase( + 'static join', PipelineFunctions.join('words', ', '), 'dart, firebase', ), + FunctionCase( + 'static join with expressions', + PipelineFunctions.join( + Expression.array(['a', 'b', 'c']), + Expression.field('delimiter'), + ), + 'a;b;c', + ), ]); // Collections that hold expressions must be built with array(...) / @@ -194,6 +689,20 @@ void main() { ]), true, ), + FunctionCase( + 'fluent arrayContainsAll', + Expression.field( + 'numbers', + ).arrayContainsAll([Expression.field('discount'), 3]), + true, + ), + FunctionCase( + 'fluent arrayContainsAll with a value not in the array', + Expression.field( + 'numbers', + ).arrayContainsAll([Expression.field('rating'), 3]), + false, + ), FunctionCase( 'arrayContainsAny', Expression.field('tags').arrayContainsAny([ @@ -202,13 +711,37 @@ void main() { ]), true, ), + FunctionCase( + 'static arrayContainsAny', + PipelineFunctions.arrayContainsAny('numbers', [ + Expression.field('rating'), + Expression.field('discount'), + ]), + true, + ), FunctionCase( 'arrayConcat', Expression.field('tags').arrayConcat([Expression.field('title')]), ['dart', 'firebase', 'Dart Pipelines'], ), FunctionCase( - 'nestedArray', + 'static arrayConcat', + PipelineFunctions.arrayConcat([ + 'tags', + [Expression.field('prefix'), 'sdk'], + ]), + ['dart', 'firebase', 'Dart', 'sdk'], + ), + FunctionCase( + 'arrayConcatMultiple', + Expression.field('numbers').arrayConcatMultiple([ + Expression.field('emptyArray'), + [Expression.field('rating'), 0], + ]), + [3, 1, 2, 3, 5, 0], + ), + FunctionCase( + 'nested array', Expression.array([ 1, [Expression.field('price')], @@ -218,6 +751,47 @@ void main() { [10], ], ), + FunctionCase( + 'static nested array and map', + PipelineFunctions.array([ + [Expression.field('rating'), 0], + {'price': Expression.field('price')}, + ]), + [ + [5, 0], + {'price': 10}, + ], + ), ]); + + // As in the Node SDK system tests: the first or last element of an empty + // array is absent, while the maximum of an empty array, any of these on + // null, and an index search in an absent field are null. + test('empty, null and absent arrays', () async { + final snapshot = await ctx.book1Pipeline().select([ + PipelineFunctions.arrayFirst('emptyArray').as('firstOfEmpty'), + Expression.field('emptyArray').arrayLast().as('lastOfEmpty'), + PipelineFunctions.arrayFirst('nullable').as('firstOfNull'), + PipelineFunctions.arrayLast('missing').as('lastOfAbsent'), + PipelineFunctions.arrayFirstN('emptyArray', 1).as('firstNOfEmpty'), + PipelineFunctions.arrayLastN('nullable', 1).as('lastNOfNull'), + PipelineFunctions.arrayMaximum('emptyArray').as('maximumOfEmpty'), + PipelineFunctions.arrayMinimum('nullable').as('minimumOfNull'), + PipelineFunctions.arrayIndexOf('missing', 'x').as('indexOfAbsent'), + PipelineFunctions.arrayIndexOfAll('missing', 'x').as('indexesOfAbsent'), + ]).execute(); + + expect(snapshot.results, hasLength(1)); + expect(snapshot.results.single.data(), { + 'firstOfNull': null, + 'lastOfAbsent': null, + 'firstNOfEmpty': [], + 'lastNOfNull': null, + 'maximumOfEmpty': null, + 'minimumOfNull': null, + 'indexOfAbsent': null, + 'indexesOfAbsent': null, + }); + }); }); } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_comparison_logical_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_comparison_logical_test.dart index 79372535..5e870948 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_comparison_logical_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_comparison_logical_test.dart @@ -17,12 +17,16 @@ library; import 'package:google_cloud_firestore/google_cloud_firestore.dart'; -import 'package:test/test.dart'; +// The top-level Pipeline `greaterThan` / `lessThan` share their names with +// package:matcher's matchers. +import 'package:test/test.dart' hide greaterThan, lessThan; import 'harness.dart'; void main() { pipelineE2E('Pipeline comparison and logical functions', (ctx) { + // Book 1: price 10, rating 5, discount 2, quantity 7, zero 0, + // score -12.7, ratio 0.5, half 2.5, title 'Dart Pipelines'. ctx.functionCases('comparison functions', [ FunctionCase('equal', Expression.field('price').equal(10), true), FunctionCase('notEqual', Expression.field('price').notEqual(11), true), @@ -42,13 +46,229 @@ void main() { Expression.field('price').lessThanOrEqual(10), true, ), + FunctionCase( + 'lessThan is false at the boundary', + Expression.field('price').lessThan(10), + false, + ), + FunctionCase( + 'equal against an expression', + Expression.field('price').equal(Expression.field('rating').multiply(2)), + true, + ), + FunctionCase( + 'notEqual against an expression', + Expression.field('price').notEqual(Expression.field('discount')), + true, + ), + FunctionCase( + 'greaterThan against an expression', + Expression.field('price').greaterThan(Expression.field('quantity')), + true, + ), + FunctionCase( + 'greaterThanOrEqual against an expression', + Expression.field( + 'discount', + ).greaterThanOrEqual(Expression.field('rating')), + false, + ), + FunctionCase( + 'lessThan against an expression', + Expression.field('rating').lessThan(Expression.field('price')), + true, + ), + FunctionCase( + 'lessThanOrEqual against an expression', + Expression.field('price').lessThanOrEqual(Expression.field('discount')), + false, + ), + ]); + + // In the static and top-level forms the left operand is a field position + // (a String names a field) while the right operand is a value position (a + // String is a string literal). + ctx.functionCases('comparison functions (PipelineFunctions)', [ + FunctionCase( + 'equal with a field name and a literal', + PipelineFunctions.equal('rating', 5), + true, + ), + FunctionCase( + 'equal with two expressions', + PipelineFunctions.equal( + Expression.field('price'), + Expression.field('rating').multiply(2), + ), + true, + ), + FunctionCase( + 'notEqual with a field name and a literal', + PipelineFunctions.notEqual('title', 'Firestore Admin'), + true, + ), + FunctionCase( + 'notEqual with two expressions', + PipelineFunctions.notEqual( + Expression.field('quantity'), + Expression.field('stats').mapGetLiteral('likes'), + ), + false, + ), + FunctionCase( + 'lessThan with a field name and a literal', + PipelineFunctions.lessThan('score', 0), + true, + ), + FunctionCase( + 'lessThan with a literal left operand', + PipelineFunctions.lessThan(5, Expression.field('price')), + true, + ), + FunctionCase( + 'lessThan with two expressions', + PipelineFunctions.lessThan( + Expression.field('price'), + Expression.field('discount'), + ), + false, + ), + FunctionCase( + 'lessThanOrEqual with a field name and a literal', + PipelineFunctions.lessThanOrEqual('zero', 0), + true, + ), + FunctionCase( + 'lessThanOrEqual with two expressions', + PipelineFunctions.lessThanOrEqual( + Expression.field('quantity'), + Expression.field('discount'), + ), + false, + ), + FunctionCase( + 'greaterThan with a field name and a string literal', + PipelineFunctions.greaterThan('title', 'Dart'), + true, + ), + FunctionCase( + 'greaterThan with two expressions', + PipelineFunctions.greaterThan( + Expression.field('zero'), + Expression.field('quantity'), + ), + false, + ), + FunctionCase( + 'greaterThanOrEqual with a field name and a literal', + PipelineFunctions.greaterThanOrEqual('rating', 5), + true, + ), + FunctionCase( + 'greaterThanOrEqual with two expressions', + PipelineFunctions.greaterThanOrEqual( + Expression.field('ratio'), + Expression.field('half'), + ), + false, + ), + ]); + + ctx.functionCases('comparison functions (top-level)', [ + FunctionCase( + 'equal with a field name and a literal', + equal('price', 10), + true, + ), + FunctionCase( + 'equal treats a right-hand String as a literal', + equal('title', 'title'), + false, + ), + FunctionCase( + 'equal with two expressions', + equal(Expression.field('rating'), Expression.field('price')), + false, + ), + FunctionCase( + 'notEqual with a field name and a literal', + notEqual('price', 11), + true, + ), + FunctionCase( + 'notEqual with two expressions', + notEqual(Expression.field('price'), Expression.constant(10)), + false, + ), + FunctionCase( + 'lessThan with a field name and an expression', + lessThan('rating', Expression.field('price')), + true, + ), + FunctionCase( + 'lessThan with an expression and a literal', + lessThan(Expression.field('price'), 10), + false, + ), + FunctionCase( + 'lessThanOrEqual with a field name and a literal', + lessThanOrEqual('price', 10), + true, + ), + FunctionCase( + 'lessThanOrEqual with two expressions', + lessThanOrEqual(Expression.field('price'), Expression.field('rating')), + false, + ), + FunctionCase( + 'greaterThan with a field name and an expression', + greaterThan('price', Expression.field('rating')), + true, + ), + FunctionCase( + 'greaterThan with an expression and a literal', + greaterThan(Expression.field('rating'), 5), + false, + ), + FunctionCase( + 'greaterThanOrEqual with a field name and a literal', + greaterThanOrEqual('rating', 5), + true, + ), + FunctionCase( + 'greaterThanOrEqual with two expressions', + greaterThanOrEqual( + Expression.field('discount'), + Expression.field('rating'), + ), + false, + ), + ]); + + // cmp returns -1, 0 or 1 as the left operand sorts before, equal to or + // after the right one. + ctx.functionCases('cmp', [ FunctionCase( 'cmp', PipelineFunctions.cmp(Expression.field('price'), 10), 0, ), + FunctionCase( + 'cmp with a field name and an expression', + PipelineFunctions.cmp('rating', Expression.field('price')), + isInt(-1), + ), + FunctionCase( + 'cmp with two expressions', + PipelineFunctions.cmp( + Expression.field('price'), + Expression.field('discount'), + ), + isInt(1), + ), ]); + // Book 1: active true, archived false. ctx.functionCases('logical functions', [ FunctionCase( 'and', @@ -61,18 +281,219 @@ void main() { true, ), FunctionCase('xor', PipelineFunctions.xor([true, false]), true), + FunctionCase( + 'xor with expressions', + PipelineFunctions.xor([ + Expression.field('active'), + Expression.field('archived'), + ]), + true, + ), + FunctionCase( + 'xor with two true operands', + PipelineFunctions.xor([ + Expression.field('price').equal(10), + Expression.field('rating').equal(5), + Expression.field('archived'), + ]), + false, + ), FunctionCase('nor', PipelineFunctions.nor([false, false]), true), + FunctionCase( + 'nor with expressions', + PipelineFunctions.nor([ + Expression.field('archived'), + Expression.field('price').greaterThan(10), + ]), + true, + ), + FunctionCase( + 'nor with a true operand', + PipelineFunctions.nor([false, Expression.field('active')]), + false, + ), FunctionCase('not', PipelineFunctions.not(false), true), + FunctionCase( + 'not with an expression', + PipelineFunctions.not(Expression.field('active')), + false, + ), + FunctionCase( + 'fluent not', + Expression.field('archived').equal(true).not(), + true, + ), + FunctionCase( + 'top-level and', + and([equal('price', 10), greaterThan('rating', 4)]), + true, + ), + FunctionCase( + 'top-level and with a false operand', + and([equal('price', 10), equal('archived', true)]), + false, + ), + FunctionCase( + 'top-level or', + or([equal('price', 20), equal('rating', 5)]), + true, + ), + FunctionCase( + 'top-level or with no true operand', + or([equal('price', 20), lessThan('rating', 3)]), + false, + ), + FunctionCase('top-level not', not(equal('price', 10)), false), + ]); + + ctx.functionCases('conditional', [ FunctionCase( 'conditional', PipelineFunctions.conditional(Expression.field('active'), 'yes', 'no'), 'yes', ), + FunctionCase( + 'conditional with a literal condition and expression branches', + PipelineFunctions.conditional( + false, + Expression.field('price'), + Expression.field('rating'), + ), + isInt(5), + ), + FunctionCase( + 'fluent conditional with literal branches', + Expression.field( + 'rating', + ).greaterThanOrEqual(5).conditional('great', 'good'), + 'great', + ), + FunctionCase( + 'fluent conditional with expression branches', + Expression.field('price') + .greaterThan(15) + .conditional( + Expression.field('price'), + Expression.field('discount'), + ), + isInt(2), + ), + ]); + + // Book 1: nullable null, no field named `missing`. coalesce returns its + // first argument that is neither absent nor null. + ctx.functionCases('ifNull and coalesce', [ FunctionCase( 'ifNull', PipelineFunctions.ifNull(Expression.field('nullable'), 'fallback'), 'fallback', ), + FunctionCase( + 'ifNull with a field name and an expression', + PipelineFunctions.ifNull('nullable', Expression.field('price')), + isInt(10), + ), + FunctionCase( + 'ifNull keeps a non-null value', + PipelineFunctions.ifNull('title', 'fallback'), + 'Dart Pipelines', + ), + FunctionCase( + 'fluent ifNull with a literal', + Expression.field('nullable').ifNull(42), + isInt(42), + ), + FunctionCase( + 'fluent ifNull with an expression', + Expression.field('nullable').ifNull(Expression.field('title')), + 'Dart Pipelines', + ), + FunctionCase( + 'fluent ifNull keeps a non-null value', + Expression.field('price').ifNull(0), + isInt(10), + ), + FunctionCase( + 'coalesce with a field name and a literal', + PipelineFunctions.coalesce('nullable', 'fallback'), + 'fallback', + ), + FunctionCase( + 'coalesce keeps a present value', + PipelineFunctions.coalesce('price', 0), + isInt(10), + ), + FunctionCase( + 'coalesce skips absent and null values', + PipelineFunctions.coalesce( + Expression.field('missing'), + Expression.field('nullable'), + [Expression.field('title'), 'unused'], + ), + 'Dart Pipelines', + ), + FunctionCase( + 'coalesce falls through to a literal', + PipelineFunctions.coalesce( + Expression.field('missing'), + Expression.field('nullable'), + ['last'], + ), + 'last', + ), + FunctionCase( + 'fluent coalesce with a literal', + Expression.field('nullable').coalesce('fallback'), + 'fallback', + ), + FunctionCase( + 'fluent coalesce with expressions', + Expression.field('missing').coalesce(Expression.field('nullable'), [ + Expression.field('rating'), + 0, + ]), + isInt(5), + ), + ]); + + // switchOn takes alternating conditions and results, and an optional + // trailing default; it returns the result of the first true condition. + ctx.functionCases('switchOn', [ + FunctionCase( + 'switchOn picks the first true condition', + PipelineFunctions.switchOn([ + Expression.field('price').greaterThan(20), + 'expensive', + Expression.field('price').greaterThan(5), + 'mid', + 'cheap', + ]), + 'mid', + ), + FunctionCase( + 'switchOn falls back to the default', + PipelineFunctions.switchOn([ + Expression.field('archived'), + 'archived', + Expression.field('price').lessThan(5), + 'cheap', + Expression.field('title'), + ]), + 'Dart Pipelines', + ), + FunctionCase( + 'switchOn with literal conditions', + PipelineFunctions.switchOn([ + false, + 'no', + true, + Expression.field('rating'), + ]), + isInt(5), + ), + ]); + + ctx.functionCases('equalAny and notEqualAny', [ // The first argument is a field position: a bare String means a field // reference, not a string literal. Passing 'dart' here asked about a // non-existent field named `dart`, which made equalAny false and made @@ -93,6 +514,33 @@ void main() { ), true, ), + FunctionCase( + 'equalAny with an expression and a list', + PipelineFunctions.equalAny(Expression.field('price'), [10, 20]), + true, + ), + FunctionCase( + 'notEqualAny with an expression and a list', + PipelineFunctions.notEqualAny(Expression.field('price'), [10, 20]), + false, + ), + FunctionCase( + 'fluent equalAny with an array field', + Expression.constant('firebase').equalAny(Expression.field('tags')), + true, + ), + FunctionCase( + 'fluent notEqualAny with a list', + Expression.field( + 'title', + ).notEqualAny(['Firestore Admin', 'Inactive Draft']), + true, + ), + FunctionCase( + 'fluent notEqualAny with an array field', + Expression.constant('graphql').notEqualAny(Expression.field('tags')), + true, + ), ]); // Collections that hold expressions must be built with array(...) / @@ -115,6 +563,14 @@ void main() { PipelineFunctions.notEqualAny('rating', [Expression.field('price'), 4]), true, ), + FunctionCase( + 'fluent notEqualAny', + Expression.field('rating').notEqualAny([ + Expression.field('discount'), + Expression.field('price').subtract(5), + ]), + false, + ), FunctionCase( 'equal', Expression.field('tags').equal([ @@ -123,8 +579,43 @@ void main() { ]), true, ), + FunctionCase( + 'top-level equal', + equal('tags', [ + Expression.field('metadata').mapGetLiteral('lang'), + 'firebase', + ]), + true, + ), + // Book 1's stats are {views: 100, likes: 7}. + FunctionCase( + 'PipelineFunctions.equal with a map', + PipelineFunctions.equal('stats', { + 'views': Expression.field('price').multiply(10), + 'likes': Expression.field('quantity'), + }), + true, + ), + FunctionCase( + 'conditional', + PipelineFunctions.conditional( + Expression.field('price').greaterThan(5), + [Expression.field('price'), Expression.field('rating')], + Expression.field('tags'), + ), + [10, 5], + ), + FunctionCase( + 'fluent conditional', + Expression.field('price').equal(10).conditional({ + 'doubled': Expression.field('price').multiply(2), + }, 'n/a'), + {'doubled': 20}, + ), ]); + // Books 1-3: price 10/20/30, rating 5/4/2, discount 2/3/4, + // active true/true/false, archived false/false/true. ctx.filterCases('filters', [ // Regression: the list was sent as a literal array value, which the // backend rejected with "Value type is not supported: @@ -135,6 +626,46 @@ void main() { PipelineFunctions.equalAny('discount', [Expression.field('flags'), 2]), ['Dart Pipelines', 'Firestore Admin'], ), + FilterCase( + 'compares two fields per document', + Expression.field('rating').lessThan(Expression.field('discount')), + ['Inactive Draft'], + ), + FilterCase( + 'and', + and([greaterThan('price', 10), lessThan('rating', 5)]), + ['Firestore Admin', 'Inactive Draft'], + ), + FilterCase('or', or([equal('price', 10), equal('active', false)]), [ + 'Dart Pipelines', + 'Inactive Draft', + ]), + FilterCase('not', not(equal('active', true)), ['Inactive Draft']), + FilterCase( + 'xor', + PipelineFunctions.xor([ + Expression.field('active'), + greaterThan('price', 15), + ]), + ['Dart Pipelines', 'Inactive Draft'], + ), + FilterCase( + 'nor', + PipelineFunctions.nor([ + Expression.field('archived'), + greaterThan('price', 15), + ]), + ['Dart Pipelines'], + ), + // The second entry is rating * 5. Book 1: 10 is in [10, 25]; book 2: 20 + // is in [10, 20]; book 3: 30 is not in [10, 10]. + FilterCase( + 'notEqualAny with a per-document search space', + Expression.field( + 'price', + ).notEqualAny([10, Expression.field('rating').multiply(5)]), + ['Inactive Draft'], + ), ]); }); } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart index 6bf83943..e9bc3575 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart @@ -21,83 +21,373 @@ import 'package:test/test.dart'; import 'harness.dart'; +/// Matches a [DocumentReference] to the same path as [expected]. +Matcher isReferenceTo(DocumentReference expected) { + return isA>().having( + (reference) => reference.path, + 'path', + expected.path, + ); +} + void main() { pipelineE2E('Pipeline map and reference functions', (ctx) { - ctx.functionCases('map functions', [ + // Book 1's map fields, from the seed table. + const metadata = {'lang': 'dart', 'category': 'sdk'}; + const stats = {'views': 100, 'likes': 7}; + + ctx.functionCases('map construction and access', [ FunctionCase('map', PipelineFunctions.map(['a', 1, 'b', 2]), { 'a': 1, 'b': 2, }), FunctionCase( - 'mapGet', + 'map with expression values', + PipelineFunctions.map([ + 'title', + Expression.field('title'), + 'rating', + Expression.field('rating'), + ]), + {'title': 'Dart Pipelines', 'rating': 5}, + ), + FunctionCase( + 'mapGetLiteral', Expression.field('metadata').mapGetLiteral('category'), 'sdk', ), + FunctionCase( + 'mapGet', + Expression.field('metadata').mapGet('lang'), + 'dart', + ), + FunctionCase( + 'mapGet with an expression key', + Expression.field('metadata').mapGet(Expression.constant('category')), + 'sdk', + ), + // The key is a literal map key, not a field path. + FunctionCase( + 'mapGet with a dotted key', + Expression.field('nested').mapGet('dotted.key'), + 'dot', + ), + FunctionCase( + 'static mapGet on a field name', + PipelineFunctions.mapGet('stats', 'views'), + isInt(100), + ), + FunctionCase( + 'static mapGet on an expression', + PipelineFunctions.mapGet( + PipelineFunctions.mapGet('nested', 'level1'), + Expression.constant('level2'), + ), + {'value': 42}, + ), + FunctionCase( + 'getField', + Expression.field('stats').getField('likes'), + isInt(7), + ), + FunctionCase( + 'getField with an expression key', + Expression.field('metadata').getField(Expression.constant('lang')), + 'dart', + ), + FunctionCase( + 'static getField on a field name', + PipelineFunctions.getField('metadata', 'category'), + 'sdk', + ), + FunctionCase( + 'static getField on the current document', + PipelineFunctions.getField( + PipelineFunctions.currentDocument(), + Expression.constant('title'), + ), + 'Dart Pipelines', + ), + FunctionCase( + 'currentDocument', + currentDocument(), + allOf( + containsPair('title', 'Dart Pipelines'), + containsPair('price', 10), + containsPair('metadata', metadata), + ), + ), + ]); + + ctx.functionCases('map updates', [ FunctionCase( 'mapSet', Expression.field('metadata').mapSet('edition', 'enterprise'), - containsPair('edition', 'enterprise'), + {...metadata, 'edition': 'enterprise'}, + ), + FunctionCase( + 'mapSet overwrites a key and sets more pairs', + Expression.field( + 'metadata', + ).mapSet('lang', 'go', ['edition', 'enterprise']), + {'lang': 'go', 'category': 'sdk', 'edition': 'enterprise'}, + ), + FunctionCase( + 'mapSet with expression keys and values', + Expression.field('stats').mapSet( + Expression.constant('shares'), + Expression.field('rating'), + [Expression.constant('likes'), Expression.field('discount')], + ), + {'views': 100, 'likes': 2, 'shares': 5}, + ), + // Setting a key to an absent value removes it. + FunctionCase( + 'mapSet to an absent value', + Expression.field( + 'metadata', + ).mapSet('lang', Expression.field('missing')), + {'category': 'sdk'}, + ), + FunctionCase( + 'static mapSet on a field name', + PipelineFunctions.mapSet('metadata', ['edition', 'enterprise']), + {...metadata, 'edition': 'enterprise'}, + ), + FunctionCase( + 'static mapSet on an expression', + PipelineFunctions.mapSet(Expression.field('stats'), [ + 'views', + Expression.field('price'), + ]), + {'views': 10, 'likes': 7}, ), FunctionCase( 'mapRemove', Expression.field('metadata').mapRemove('lang'), - isNot(contains('lang')), + {'category': 'sdk'}, ), FunctionCase( 'mapRemove with an expression key', Expression.field('metadata').mapRemove(Expression.constant('lang')), - isNot(contains('lang')), + {'category': 'sdk'}, ), FunctionCase( 'chained mapRemove', Expression.field('metadata').mapRemove('lang').mapRemove('category'), - isEmpty, + {}, + ), + FunctionCase( + 'mapRemove of an absent key', + Expression.field('metadata').mapRemove('missing'), + metadata, + ), + FunctionCase( + 'static mapRemove on a field name', + PipelineFunctions.mapRemove('stats', 'likes'), + {'views': 100}, + ), + FunctionCase( + 'static mapRemove on an expression', + PipelineFunctions.mapRemove( + PipelineFunctions.map(['a', 1, 'b', 2]), + Expression.constant('a'), + ), + {'b': 2}, ), FunctionCase( 'mapMerge', Expression.field('metadata').mapMerge([ {'edition': 'enterprise'}, ]), - containsPair('edition', 'enterprise'), + {...metadata, 'edition': 'enterprise'}, ), FunctionCase( - 'currentDocument', - currentDocument(), - isA>(), + 'mapMerge with a map expression', + Expression.field('metadata').mapMerge([Expression.field('stats')]), + {...metadata, ...stats}, ), + FunctionCase( + 'mapMerge with several maps, later keys winning', + Expression.field('metadata').mapMerge([ + {'lang': 'go'}, + Expression.field('stats'), + ]), + {'lang': 'go', 'category': 'sdk', ...stats}, + ), + FunctionCase( + 'static mapMerge on a field name', + PipelineFunctions.mapMerge([ + 'metadata', + {'edition': 'enterprise'}, + ]), + {...metadata, 'edition': 'enterprise'}, + ), + FunctionCase( + 'static mapMerge on expressions', + PipelineFunctions.mapMerge([ + Expression.field('stats'), + Expression.field('metadata'), + ]), + {...stats, ...metadata}, + ), + ]); + + // The backend does not guarantee the order of keys, values or entries. + ctx.functionCases('map decomposition', [ FunctionCase( 'mapKeys', Expression.field('metadata').mapKeys(), - containsAll(['lang', 'category']), + unorderedEquals(['lang', 'category']), + ), + FunctionCase( + 'static mapKeys on a field name', + PipelineFunctions.mapKeys('stats'), + unorderedEquals(['views', 'likes']), + ), + FunctionCase( + 'static mapKeys on an expression', + PipelineFunctions.mapKeys(Expression.field('emptyMap')), + [], ), FunctionCase( 'mapValues', Expression.field('metadata').mapValues(), - containsAll(['dart', 'sdk']), + unorderedEquals(['dart', 'sdk']), + ), + FunctionCase( + 'static mapValues on a field name', + PipelineFunctions.mapValues('stats'), + unorderedEquals([100, 7]), + ), + FunctionCase( + 'static mapValues on an expression', + PipelineFunctions.mapValues(Expression.field('nested.level1')), + [ + { + 'level2': {'value': 42}, + }, + ], ), FunctionCase( 'mapEntries', Expression.field('metadata').mapEntries(), - isA>(), + unorderedEquals([ + {'k': 'lang', 'v': 'dart'}, + {'k': 'category', 'v': 'sdk'}, + ]), + ), + FunctionCase( + 'static mapEntries on a field name', + PipelineFunctions.mapEntries('stats'), + unorderedEquals([ + {'k': 'views', 'v': 100}, + {'k': 'likes', 'v': 7}, + ]), + ), + FunctionCase( + 'static mapEntries on an expression', + PipelineFunctions.mapEntries(Expression.field('emptyMap')), + [], ), ]); + // Book 1's `pathRef` is book 2, a top-level document; `chapter` is a + // document in a subcollection of book 1, so its parent is a document. + final chapter = ctx.firestore.doc('${ctx.book1Ref.path}/chapters/c1'); + ctx.functionCases('reference functions', [ FunctionCase( 'collectionId', Expression.field('pathRef').collectionId(), - isA(), + pipelineE2ECollection, + ), + FunctionCase( + 'collectionId of a subcollection document', + Expression.constant(chapter).collectionId(), + 'chapters', + ), + FunctionCase( + 'static collectionId on a field name', + PipelineFunctions.collectionId('pathRef'), + pipelineE2ECollection, + ), + FunctionCase( + 'static collectionId on an expression', + PipelineFunctions.collectionId(Expression.field('__name__')), + pipelineE2ECollection, ), FunctionCase( 'documentId', Expression.field('pathRef').documentId(), - allOf(startsWith('run_'), endsWith('_book_2')), + ctx.book2Ref.id, + ), + FunctionCase( + 'static documentId on an expression', + PipelineFunctions.documentId(Expression.field('__name__')), + ctx.book1Ref.id, + ), + FunctionCase( + 'static documentId on a DocumentReference', + PipelineFunctions.documentId(chapter), + 'c1', + ), + FunctionCase( + 'parent', + Expression.constant(chapter).parent(), + isReferenceTo(ctx.book1Ref), ), - FunctionCase('parent', Expression.field('pathRef').parent(), isNotNull), + FunctionCase( + 'static parent on a DocumentReference', + PipelineFunctions.parent(chapter), + isReferenceTo(ctx.book1Ref), + ), + FunctionCase( + 'static parent on an expression', + PipelineFunctions.parent(Expression.constant(chapter)), + isReferenceTo(ctx.book1Ref), + ), + // The parent of a top-level document is the database root, a reference + // that has no parent itself. + FunctionCase( + 'parent of a top-level document', + PipelineFunctions.parent( + Expression.field('pathRef'), + ).isType(PipelineValueType.reference), + true, + ), + FunctionCase( + 'parent of the root', + Expression.field('pathRef').parent().parent(), + isNull, + ), + // A reference is a list of (collection ID, document ID) segments, which + // referenceSlice slices like an array. FunctionCase( 'referenceSlice', - Expression.field('pathRef').referenceSlice(0, 2), - isNotNull, + Expression.constant(chapter).referenceSlice(0, 1), + isReferenceTo(ctx.book1Ref), + ), + FunctionCase( + 'referenceSlice with expression bounds', + Expression.field( + 'pathRef', + ).referenceSlice(Expression.constant(0), Expression.constant(1)), + isReferenceTo(ctx.book2Ref), + ), + FunctionCase( + 'static referenceSlice on a field name', + PipelineFunctions.referenceSlice('pathRef', 0, 1), + isReferenceTo(ctx.book2Ref), + ), + FunctionCase( + 'static referenceSlice on an expression', + PipelineFunctions.referenceSlice( + Expression.constant(chapter), + Expression.constant(1), + Expression.constant(1), + ), + isReferenceTo(ctx.firestore.doc('chapters/c1')), ), ]); @@ -109,7 +399,46 @@ void main() { Expression.field('metadata').mapMerge([ {'title': Expression.field('title')}, ]), - containsPair('title', 'Dart Pipelines'), + {...metadata, 'title': 'Dart Pipelines'}, + ), + FunctionCase( + 'static mapMerge', + PipelineFunctions.mapMerge([ + Expression.field('metadata'), + {'price': Expression.field('price')}, + ]), + {...metadata, 'price': 10}, + ), + FunctionCase( + 'mapSet with a map value', + Expression.field( + 'metadata', + ).mapSet('extra', {'price': Expression.field('price')}), + { + ...metadata, + 'extra': {'price': 10}, + }, + ), + FunctionCase( + 'mapSet with an array value', + Expression.field( + 'metadata', + ).mapSet('extra', [Expression.field('price'), 1]), + { + ...metadata, + 'extra': [10, 1], + }, + ), + FunctionCase( + 'static mapSet', + PipelineFunctions.mapSet('stats', [ + 'extra', + {'rating': Expression.field('rating')}, + ]), + { + ...stats, + 'extra': {'rating': 5}, + }, ), FunctionCase( 'nestedMap', diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_string_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_string_test.dart index 6634bcc8..9a1dc89b 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_string_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_string_test.dart @@ -14,6 +14,10 @@ /// Live coverage of the string Pipeline functions and the generic `length`, /// `reverse` and `concat` functions. +/// +/// Each function is exercised in its static form with a field-name `String` +/// and with an expression, and in its fluent form; value arguments are passed +/// both as plain values and as expressions (`constant(...)` or a field). @Tags(['prod']) library; @@ -24,114 +28,666 @@ import 'harness.dart'; void main() { pipelineE2E('Pipeline string functions', (ctx) { - ctx.functionCases('string functions', [ - FunctionCase('byteLength', Expression.field('title').byteLength(), 14), - FunctionCase('charLength', Expression.field('title').length(), 14), + // `unicode` is 'café ☕ 日本': 9 code points, 16 UTF-8 bytes. + ctx.functionCases('lengths', [ + FunctionCase( + 'byteLength', + Expression.field('title').byteLength(), + isInt(14), + ), + FunctionCase( + 'byteLength static counts UTF-8 bytes', + PipelineFunctions.byteLength('unicode'), + isInt(16), + ), + FunctionCase( + 'byteLength static of a bytes value', + PipelineFunctions.byteLength(Expression.field('bytes')), + isInt(2), + ), + FunctionCase( + 'charLength', + Expression.field('empty').charLength(), + isInt(0), + ), + FunctionCase( + 'charLength static counts code points', + PipelineFunctions.charLength('unicode'), + isInt(9), + ), + FunctionCase( + 'charLength static with an expression', + PipelineFunctions.charLength(Expression.field('greeting')), + isInt(13), + ), + FunctionCase( + 'length of a string', + Expression.field('title').length(), + isInt(14), + ), + FunctionCase( + 'length of a vector', + Expression.field('embedding').length(), + isInt(3), + ), + FunctionCase( + 'length static of an array', + PipelineFunctions.length('tags'), + isInt(2), + ), + FunctionCase( + 'length static of a map', + PipelineFunctions.length(Expression.field('metadata')), + isInt(2), + ), + ]); + + ctx.functionCases('reverse', [ + FunctionCase( + 'reverse', + Expression.field('greeting').reverse(), + '!dlroW ,olleH', + ), + FunctionCase( + 'reverse static', + PipelineFunctions.reverse('prefix'), + 'traD', + ), + FunctionCase( + 'reverse static with an expression', + PipelineFunctions.reverse(Expression.field('title')), + 'senilepiP traD', + ), + FunctionCase( + 'stringReverse', + Expression.field('semicolons').stringReverse(), + 'c;b;a', + ), + FunctionCase( + 'stringReverse static', + PipelineFunctions.stringReverse('title'), + 'senilepiP traD', + ), + FunctionCase( + 'stringReverse static with an expression', + PipelineFunctions.stringReverse(Expression.constant('Dart')), + 'traD', + ), + ]); + + ctx.functionCases('concatenation', [ + FunctionCase( + 'concat', + Expression.field('title').concat([' v2']), + 'Dart Pipelines v2', + ), + FunctionCase( + 'concat with an expression', + Expression.field('prefix').concat([', ', Expression.field('greeting')]), + 'Dart, Hello, World!', + ), + // A field-name first argument; the other Strings are literals. + FunctionCase( + 'concat static with more values', + PipelineFunctions.concat('prefix', ' & ', [ + Expression.field('title'), + '!', + ]), + 'Dart & Dart Pipelines!', + ), + FunctionCase( + 'concat static of two arrays', + PipelineFunctions.concat( + Expression.field('tags'), + Expression.field('words'), + ), + ['dart', 'firebase', 'dart', 'firebase'], + ), + FunctionCase( + 'stringConcat', + Expression.field( + 'prefix', + ).stringConcat([' + ', Expression.field('title')]), + 'Dart + Dart Pipelines', + ), + // The first value names a field; the others are literals or expressions. + FunctionCase( + 'stringConcat static', + PipelineFunctions.stringConcat([ + 'prefix', + ' ', + Expression.field('greeting'), + ]), + 'Dart Hello, World!', + ), + ]); + + ctx.functionCases('prefix, suffix and pattern predicates', [ FunctionCase( 'startsWith', Expression.field('title').startsWith('Dart'), true, ), + FunctionCase( + 'startsWith is case-sensitive', + Expression.field('title').startsWith('dart'), + false, + ), + FunctionCase( + 'startsWith with an expression', + Expression.field('sentence').startsWith(Expression.field('prefix')), + true, + ), + FunctionCase( + 'startsWith static', + PipelineFunctions.startsWith('greeting', 'Hello'), + true, + ), FunctionCase( 'endsWith', Expression.field('title').endsWith('lines'), true, ), + FunctionCase( + 'endsWith with an expression', + Expression.field('sentence').endsWith(Expression.constant('fast')), + true, + ), + FunctionCase( + 'endsWith static', + PipelineFunctions.endsWith('email', '.com'), + true, + ), FunctionCase('like', Expression.field('title').like('Dart%'), true), + FunctionCase( + 'like is case-sensitive', + Expression.field('title').like('dart%'), + false, + ), + FunctionCase( + 'like static', + PipelineFunctions.like('email', '%@example.com'), + true, + ), + FunctionCase( + 'like static with an expression', + PipelineFunctions.like( + Expression.field('title'), + Expression.constant('%Pipe%'), + ), + true, + ), + FunctionCase( + 'stringContains', + Expression.field('title').stringContains('Pipeline'), + true, + ), + FunctionCase( + 'stringContains with an expression', + Expression.field( + 'email', + ).stringContains(Expression.constant('@example.')), + true, + ), + FunctionCase( + 'stringContains static', + PipelineFunctions.stringContains('sentence', 'fun'), + true, + ), + ]); + + // Patterns use RE2 syntax. + ctx.functionCases('regular expressions', [ FunctionCase( 'regexContains', Expression.field('title').regexContains('Pipe'), true, ), + FunctionCase( + 'regexContains is case-sensitive', + Expression.field('title').regexContains('pipe'), + false, + ), + FunctionCase( + 'regexContains static with an expression', + PipelineFunctions.regexContains( + Expression.field('title'), + Expression.constant('(?i)pipe'), + ), + true, + ), FunctionCase( 'regexMatch', Expression.field('title').regexMatch(r'^Dart.*'), true, ), + FunctionCase( + 'regexMatch static', + PipelineFunctions.regexMatch('csv', '[a-z,]+'), + true, + ), + // Unlike regexContains, the pattern must match the whole string. + FunctionCase( + 'regexMatch static needs a full match', + PipelineFunctions.regexMatch( + Expression.field('title'), + Expression.constant('Dart'), + ), + false, + ), FunctionCase( 'regexFind', - Expression.field('title').regexFind('Pipe'), - isNotNull, + Expression.field('title').regexFind(r'P\w+'), + 'Pipelines', + ), + FunctionCase( + 'regexFind with an expression', + Expression.field('greeting').regexFind(Expression.constant(r'\w+')), + 'Hello', + ), + FunctionCase( + 'regexFind static', + PipelineFunctions.regexFind('email', '@.+'), + '@example.com', + ), + FunctionCase( + 'regexFind static returns the first match', + PipelineFunctions.regexFind( + Expression.field('sentence'), + Expression.constant(r'f\w+'), + ), + 'fun', ), FunctionCase( 'regexFindAll', Expression.field('title').regexFindAll('[aei]'), - isA>(), + ['a', 'i', 'e', 'i', 'e'], ), FunctionCase( - 'stringConcat', - Expression.field('title').concat([' v2']), - 'Dart Pipelines v2', + 'regexFindAll with an expression', + Expression.field('semicolons').regexFindAll(Expression.constant(r'\w')), + ['a', 'b', 'c'], ), FunctionCase( - 'stringContains', - Expression.field('title').stringContains('Pipeline'), - true, + 'regexFindAll static', + PipelineFunctions.regexFindAll('csv', '[a-z]+'), + ['dart', 'firebase', 'admin'], + ), + FunctionCase( + 'regexFindAll static with an expression', + PipelineFunctions.regexFindAll( + Expression.field('sentence'), + Expression.constant('Dart'), + ), + ['Dart', 'Dart'], ), + ]); + + // Indexes are 0-based. + ctx.functionCases('stringIndexOf', [ FunctionCase( 'stringIndexOf', Expression.field('title').stringIndexOf('Pipeline'), - 5, + isInt(5), + ), + FunctionCase( + 'stringIndexOf with an expression', + Expression.field( + 'semicolons', + ).stringIndexOf(Expression.field('delimiter')), + isInt(1), ), FunctionCase( - 'toUpper', + 'stringIndexOf static', + PipelineFunctions.stringIndexOf('sentence', 'fun'), + isInt(8), + ), + FunctionCase( + 'stringIndexOf static with an expression', + PipelineFunctions.stringIndexOf( + Expression.field('greeting'), + Expression.constant('World'), + ), + isInt(7), + ), + ]); + + ctx.functionCases('case conversion', [ + FunctionCase( + 'toUpperCase', Expression.field('title').toUpperCase(), 'DART PIPELINES', ), FunctionCase( - 'toLower', + 'toLowerCase', Expression.field('title').toLowerCase(), 'dart pipelines', ), FunctionCase( - 'substring', + 'toUpper static', + PipelineFunctions.toUpper('prefix'), + 'DART', + ), + FunctionCase( + 'toUpper static with an expression', + PipelineFunctions.toUpper(Expression.field('greeting')), + 'HELLO, WORLD!', + ), + FunctionCase( + 'toLower static', + PipelineFunctions.toLower('title'), + 'dart pipelines', + ), + FunctionCase( + 'toLower static with an expression', + PipelineFunctions.toLower(Expression.field('greeting')), + 'hello, world!', + ), + ]); + + // The position is a 0-based index and the optional second argument a + // length, not an end index. `rating` is 5 on book 1. + ctx.functionCases('substring', [ + FunctionCase( + 'substringLiteral', Expression.field('title').substringLiteral(0, 4), 'Dart', ), - // The second argument is a length, not an end index. FunctionCase( - 'substringLength', + 'substringLiteral to the end', + Expression.field('email').substringLiteral(9), + 'example.com', + ), + FunctionCase( + 'substring with a length', Expression.field('title').substring(5, 3), 'Pip', ), FunctionCase( - 'substringToEnd', + 'substring to the end', Expression.field('title').substring(5), 'Pipelines', ), FunctionCase( - 'stringReverse', - PipelineFunctions.stringReverse(Expression.constant('Dart')), - 'traD', + 'substring with expressions', + Expression.field( + 'greeting', + ).substring(Expression.constant(7), Expression.field('rating')), + 'World', + ), + FunctionCase( + 'substring static', + PipelineFunctions.substring('email', 0, 3), + 'dev', + ), + FunctionCase( + 'substring static to the end with an expression', + PipelineFunctions.substring( + Expression.field('title'), + Expression.field('rating'), + ), + 'Pipelines', + ), + FunctionCase( + 'substring static with an expression length', + PipelineFunctions.substring('sentence', 8, Expression.constant(3)), + 'fun', ), + // Positions and lengths count code points for a string. + FunctionCase( + 'substring static counts code points', + PipelineFunctions.substring('unicode', 5, 1), + '☕', + ), + ]); + + // `discount` is 2 and `rating` 5 on book 1. + ctx.functionCases('stringRepeat', [ FunctionCase( 'stringRepeat', Expression.constant('ha').stringRepeat(3), 'hahaha', ), FunctionCase( - 'stringReplaceAll', + 'stringRepeat with an expression', + Expression.constant('-').stringRepeat(Expression.field('rating')), + '-----', + ), + FunctionCase( + 'stringRepeat static', + PipelineFunctions.stringRepeat('prefix', 2), + 'DartDart', + ), + FunctionCase( + 'stringRepeat static with expressions', + PipelineFunctions.stringRepeat( + Expression.field('delimiter'), + Expression.field('discount'), + ), + ';;', + ), + ]); + + // The search value is a literal substring, not a pattern. + ctx.functionCases('replacement', [ + FunctionCase( + 'stringReplaceAllLiteral', Expression.field('title').stringReplaceAllLiteral('i', 'I'), 'Dart PIpelInes', ), FunctionCase( - 'stringReplaceOne', + 'stringReplaceAll', + Expression.field('sentence').stringReplaceAll('Dart', 'Go'), + 'Go is fun and Go is fast', + ), + FunctionCase( + 'stringReplaceAll with expressions', + Expression.field('csv').stringReplaceAll( + Expression.constant(','), + Expression.field('delimiter'), + ), + 'dart;firebase;admin', + ), + FunctionCase( + 'stringReplaceAll static replaces a literal dot', + PipelineFunctions.stringReplaceAll('email', '.', '_'), + 'dev_team@example_com', + ), + FunctionCase( + 'stringReplaceAll static with expressions', + PipelineFunctions.stringReplaceAll( + Expression.field('semicolons'), + Expression.field('delimiter'), + Expression.constant(', '), + ), + 'a, b, c', + ), + FunctionCase( + 'stringReplaceOneLiteral', Expression.field('title').stringReplaceOneLiteral('i', 'I'), 'Dart PIpelines', ), + FunctionCase( + 'stringReplaceOne', + Expression.field('email').stringReplaceOne('.', '_'), + 'dev_team@example.com', + ), + FunctionCase( + 'stringReplaceOne with expressions', + Expression.field('csv').stringReplaceOne( + Expression.constant(','), + Expression.constant(' & '), + ), + 'dart & firebase,admin', + ), + FunctionCase( + 'stringReplaceOne static', + PipelineFunctions.stringReplaceOne('sentence', 'Dart', 'Go'), + 'Go is fun and Dart is fast', + ), + FunctionCase( + 'stringReplaceOne static with expressions', + PipelineFunctions.stringReplaceOne( + Expression.field('semicolons'), + Expression.field('delimiter'), + Expression.constant('+'), + ), + 'a+b;c', + ), + ]); + + // Without a value, whitespace is trimmed; a value is a set of characters, + // not a substring. + ctx.functionCases('trimming', [ FunctionCase('trim', Expression.field('spaced').trim(), 'Dart'), + FunctionCase( + 'trim a value', + Expression.field('padded').trim('_'), + 'Dart', + ), + FunctionCase( + 'trim an expression', + Expression.field('semicolons').trim(Expression.constant('ac')), + ';b;', + ), + FunctionCase('trim static', PipelineFunctions.trim('spaced'), 'Dart'), + FunctionCase( + 'trim static a value', + PipelineFunctions.trim(Expression.field('padded'), '_'), + 'Dart', + ), + FunctionCase( + 'trim static an expression', + PipelineFunctions.trim('greeting', Expression.constant('H!')), + 'ello, World', + ), FunctionCase('ltrim', Expression.field('spaced').ltrim(), 'Dart '), + FunctionCase( + 'ltrim a value', + Expression.field('padded').ltrim('_'), + 'Dart__', + ), + FunctionCase( + 'ltrim an expression', + Expression.field('semicolons').ltrim(Expression.constant('a;')), + 'b;c', + ), + FunctionCase('ltrim static', PipelineFunctions.ltrim('spaced'), 'Dart '), + FunctionCase( + 'ltrim static a value', + PipelineFunctions.ltrim(Expression.field('padded'), '_'), + 'Dart__', + ), + FunctionCase( + 'ltrim static an expression', + PipelineFunctions.ltrim('greeting', Expression.constant('Hel')), + 'o, World!', + ), FunctionCase('rtrim', Expression.field('spaced').rtrim(), ' Dart'), - FunctionCase('split', Expression.field('csv').splitLiteral(','), [ + FunctionCase( + 'rtrim a value', + Expression.field('padded').rtrim('_'), + '__Dart', + ), + FunctionCase( + 'rtrim an expression', + Expression.field('semicolons').rtrim(Expression.constant(';c')), + 'a;b', + ), + FunctionCase('rtrim static', PipelineFunctions.rtrim('spaced'), ' Dart'), + FunctionCase( + 'rtrim static a value', + PipelineFunctions.rtrim(Expression.field('padded'), '_'), + '__Dart', + ), + FunctionCase( + 'rtrim static an expression', + PipelineFunctions.rtrim('greeting', Expression.constant('!dl')), + 'Hello, Wor', + ), + ]); + + ctx.functionCases('split', [ + FunctionCase('splitLiteral', Expression.field('csv').splitLiteral(','), [ 'dart', 'firebase', 'admin', ]), - FunctionCase('splitStatic', PipelineFunctions.split('csv', ','), [ + FunctionCase('split', Expression.field('email').split('@'), [ + 'dev.team', + 'example.com', + ]), + FunctionCase( + 'split with an expression', + Expression.field('sentence').split(Expression.constant(' and ')), + ['Dart is fun', 'Dart is fast'], + ), + // The field-name first argument is a field; the delimiter a literal. + FunctionCase('split static', PipelineFunctions.split('csv', ','), [ 'dart', 'firebase', 'admin', ]), + FunctionCase( + 'split static with expressions', + PipelineFunctions.split( + Expression.field('semicolons'), + Expression.field('delimiter'), + ), + ['a', 'b', 'c'], + ), + ]); + + // Comparing two fields of each book, so every book gives its own answer. + ctx.filterCases('string predicates across books', [ + FilterCase( + 'startsWith static with expressions', + PipelineFunctions.startsWith( + Expression.field('title'), + Expression.field('prefix'), + ), + ['Dart Pipelines', 'Firestore Admin'], + ), + FilterCase( + 'endsWith static with expressions', + PipelineFunctions.endsWith( + Expression.field('title'), + Expression.field('prefix'), + ), + ['Inactive Draft'], + ), + FilterCase( + 'stringContains static with expressions', + PipelineFunctions.stringContains( + Expression.field('greeting'), + Expression.field('prefix'), + ), + ['Inactive Draft'], + ), + FilterCase( + 'like with an expression', + Expression.field('greeting').like(Expression.constant('Hello%')), + ['Dart Pipelines', 'Firestore Admin'], + ), + FilterCase( + 'regexContains static', + PipelineFunctions.regexContains('email', r'@example\.(com|org)$'), + ['Dart Pipelines', 'Firestore Admin'], + ), + // Each book's `prefix` is used as its pattern. + FilterCase( + 'regexContains with an expression', + Expression.field('greeting').regexContains(Expression.field('prefix')), + ['Inactive Draft'], + ), + // `dev.team` holds a dot, so book 1's email cannot fully match. + FilterCase( + 'regexMatch with an expression', + Expression.field( + 'email', + ).regexMatch(Expression.constant(r'[a-z]+@example\.(org|net)')), + ['Firestore Admin', 'Inactive Draft'], + ), ]); }); } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_timestamp_vector_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_timestamp_vector_test.dart index 3042f55b..62404523 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_timestamp_vector_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_timestamp_vector_test.dart @@ -13,6 +13,20 @@ // limitations under the License. /// Live coverage of the timestamp and vector Pipeline functions. +/// +/// Book 1's `createdAt` is 2023-11-14T22:13:20Z (`1700000000`), and its +/// `updatedAt` is exactly one day and 123456 microseconds later +/// (`Timestamp(1700086400, 123456000)`). In the time zones used below that +/// instant is: +/// +/// | Zone | Local time | UTC offset | +/// | --- | --- | --- | +/// | `UTC` | 2023-11-14 22:13:20 | +0 | +/// | `Asia/Tokyo` | 2023-11-15 07:13:20 | +9 | +/// | `MST` | 2023-11-14 15:13:20 | -7 | +/// | `America/Los_Angeles` | 2023-11-14 14:13:20 (PST) | -8 | +/// +/// Book 1's `embedding` is the vector `[1, 0, 0]`. @Tags(['prod']) library; @@ -21,118 +35,589 @@ import 'package:test/test.dart'; import 'harness.dart'; +Timestamp _ts(int seconds, [int nanoseconds = 0]) { + return Timestamp(seconds: seconds, nanoseconds: nanoseconds); +} + +/// Book 1's `createdAt`. +final _createdAt = DateTime.utc(2023, 11, 14, 22, 13, 20); + +/// 2023-11-14T00:00:00Z, book 1's `createdAt` truncated to the UTC day. +const _createdAtUtcDay = 1699920000; + void main() { pipelineE2E('Pipeline timestamp and vector functions', (ctx) { - ctx.functionCases('timestamp functions', [ + ctx.functionCases('currentTimestamp', [ FunctionCase( - 'currentTimestamp', + 'is close to the client clock', PipelineFunctions.currentTimestamp(), - isA(), + isA().having( + (now) => now.toDate().difference(DateTime.now()).inMinutes.abs(), + 'minutes from the client clock', + inInclusiveRange(0, 9), + ), ), FunctionCase( - 'timestampTrunc', - Expression.field('createdAt').timestampTruncate('day', 'UTC'), - isA(), + 'composes as an operand', + PipelineFunctions.timestampDiff( + PipelineFunctions.currentTimestamp(), + 'createdAt', + 'day', + ), + allOf( + isA(), + closeTo(DateTime.now().difference(_createdAt).inDays, 1), + ), + ), + ]); + + ctx.functionCases('timestamp to Unix conversions', [ + // The conversions truncate: updatedAt carries 123456000 nanoseconds. + FunctionCase( + 'static timestampToUnixSeconds with a field name', + PipelineFunctions.timestampToUnixSeconds('updatedAt'), + isInt(1700086400), + ), + FunctionCase( + 'static timestampToUnixSeconds with an expression', + PipelineFunctions.timestampToUnixSeconds( + Expression.constant(_ts(1741380235, 123456789)), + ), + isInt(1741380235), + ), + FunctionCase( + 'fluent timestampToUnixSeconds', + Expression.field('createdAt').timestampToUnixSeconds(), + isInt(1700000000), + ), + FunctionCase( + 'static timestampToUnixMillis with a field name', + PipelineFunctions.timestampToUnixMillis('updatedAt'), + isInt(1700086400123), + ), + FunctionCase( + 'static timestampToUnixMillis with an expression', + PipelineFunctions.timestampToUnixMillis( + Expression.constant(_ts(1741380235, 123456789)), + ), + isInt(1741380235123), + ), + FunctionCase( + 'fluent timestampToUnixMillis', + Expression.field('createdAt').timestampToUnixMillis(), + isInt(1700000000000), ), FunctionCase( - 'unixMicrosToTimestamp', - PipelineFunctions.unixMicrosToTimestamp(1700000000000000), - isA(), + 'static timestampToUnixMicros with a field name', + PipelineFunctions.timestampToUnixMicros('updatedAt'), + isInt(1700086400123456), ), FunctionCase( - 'unixMillisToTimestamp', - PipelineFunctions.unixMillisToTimestamp(1700000000000), - isA(), + 'static timestampToUnixMicros with an expression', + PipelineFunctions.timestampToUnixMicros( + Expression.constant(_ts(1741380235, 123456789)), + ), + isInt(1741380235123456), ), FunctionCase( - 'unixSecondsToTimestamp', + 'fluent timestampToUnixMicros', + Expression.field('createdAt').timestampToUnixMicros(), + isInt(1700000000000000), + ), + ]); + + ctx.functionCases('Unix to timestamp conversions', [ + FunctionCase( + 'static unixSecondsToTimestamp with a number', PipelineFunctions.unixSecondsToTimestamp(1700000000), - isA(), + _ts(1700000000), + ), + FunctionCase( + 'static unixSecondsToTimestamp with an expression', + PipelineFunctions.unixSecondsToTimestamp( + Expression.field('unixSeconds'), + ), + _ts(1700000000), + ), + FunctionCase( + 'fluent unixSecondsToTimestamp', + Expression.field('unixSeconds').unixSecondsToTimestamp(), + _ts(1700000000), + ), + FunctionCase( + 'static unixMillisToTimestamp with a number', + PipelineFunctions.unixMillisToTimestamp(1700086400123), + _ts(1700086400, 123000000), + ), + // A String is a field name, not a constant. + FunctionCase( + 'static unixMillisToTimestamp with a field name', + PipelineFunctions.unixMillisToTimestamp('unixMillis'), + _ts(1700000000), + ), + FunctionCase( + 'static unixMillisToTimestamp with an expression', + PipelineFunctions.unixMillisToTimestamp(Expression.field('unixMillis')), + _ts(1700000000), + ), + FunctionCase( + 'fluent unixMillisToTimestamp', + Expression.constant(1700086400123).unixMillisToTimestamp(), + _ts(1700086400, 123000000), + ), + FunctionCase( + 'static unixMicrosToTimestamp with a number', + PipelineFunctions.unixMicrosToTimestamp(1700086400123456), + _ts(1700086400, 123456000), + ), + FunctionCase( + 'static unixMicrosToTimestamp with an expression', + PipelineFunctions.unixMicrosToTimestamp(Expression.field('unixMicros')), + _ts(1700000000), ), FunctionCase( - 'timestampAdd', + 'fluent unixMicrosToTimestamp', + Expression.field('unixMicros').unixMicrosToTimestamp(), + _ts(1700000000), + ), + FunctionCase( + 'micros round trip keeps the sub-second part', + Expression.field( + 'updatedAt', + ).timestampToUnixMicros().unixMicrosToTimestamp(), + _ts(1700086400, 123456000), + ), + ]); + + ctx.functionCases('timestampAdd', [ + FunctionCase( + 'static with plain values', + PipelineFunctions.timestampAdd('createdAt', 'day', 1), + _ts(1700086400), + ), + // rating is 5 on book 1. + FunctionCase( + 'static with expressions', + PipelineFunctions.timestampAdd( + Expression.field('createdAt'), + Expression.constant('hour'), + Expression.field('rating'), + ), + _ts(1700018000), + ), + FunctionCase( + 'fluent with plain values', Expression.field('createdAt').timestampAdd('second', 60), - isA(), + _ts(1700000060), + ), + FunctionCase( + 'fluent with expressions', + Expression.field( + 'createdAt', + ).timestampAdd(Expression.constant('minute'), Expression.constant(30)), + _ts(1700001800), + ), + FunctionCase( + 'microsecond keeps nanosecond precision', + Expression.field('updatedAt').timestampAdd('microsecond', 10), + _ts(1700086400, 123466000), + ), + FunctionCase( + 'millisecond carries into seconds', + Expression.field('updatedAt').timestampAdd('millisecond', 900), + _ts(1700086401, 23456000), + ), + ]); + + ctx.functionCases('timestampSubtract', [ + FunctionCase( + 'static with plain values', + PipelineFunctions.timestampSubtract('updatedAt', 'day', 1), + _ts(1700000000, 123456000), + ), + // price is 10 on book 1. + FunctionCase( + 'static with expressions', + PipelineFunctions.timestampSubtract( + Expression.field('updatedAt'), + Expression.constant('millisecond'), + Expression.field('price'), + ), + _ts(1700086400, 113456000), ), FunctionCase( - 'timestampSubtract', + 'fluent with plain values', Expression.field('createdAt').timestampSubtract('second', 60), - isA(), + _ts(1699999940), ), FunctionCase( - 'timestampToUnixMicros', - Expression.field('createdAt').timestampToUnixMicros(), - 1700000000000000, + 'fluent with expressions borrows from seconds', + Expression.field('createdAt').timestampSubtract( + Expression.constant('microsecond'), + Expression.constant(1), + ), + _ts(1699999999, 999999000), ), + ]); + + // updatedAt - createdAt is exactly 86400.123456 seconds, so every unit + // below gives the same whole number however the backend rounds. + ctx.functionCases('timestampDiff', [ FunctionCase( - 'timestampToUnixMillis', - Expression.field('createdAt').timestampToUnixMillis(), - 1700000000000, + 'static with field names', + PipelineFunctions.timestampDiff( + 'updatedAt', + 'createdAt', + 'microsecond', + ), + isInt(86400123456), ), FunctionCase( - 'timestampToUnixSeconds', - Expression.field('createdAt').timestampToUnixSeconds(), - 1700000000, + 'static with field names, end before start', + PipelineFunctions.timestampDiff( + 'createdAt', + 'updatedAt', + 'microsecond', + ), + isInt(-86400123456), ), FunctionCase( - 'timestampDiff', + 'static in hours', + PipelineFunctions.timestampDiff('updatedAt', 'createdAt', 'hour'), + isInt(24), + ), + FunctionCase( + 'static with expressions', + PipelineFunctions.timestampDiff( + Expression.field('updatedAt'), + Expression.field('createdAt'), + Expression.constant('day'), + ), + isInt(1), + ), + FunctionCase( + 'static with a computed start', PipelineFunctions.timestampDiff( Expression.field('createdAt'), PipelineFunctions.unixSecondsToTimestamp(1699999940), 'second', ), - 60, + isInt(60), ), FunctionCase( - 'timestampExtract', + 'fluent with plain values', + Expression.field('updatedAt').timestampDiff('createdAt', 'millisecond'), + isInt(86400123), + ), + FunctionCase( + 'fluent with expressions', + Expression.field('updatedAt').timestampDiff( + Expression.field('createdAt'), + Expression.constant('minute'), + ), + isInt(1440), + ), + ]); + + ctx.functionCases('timestampTruncate', [ + FunctionCase( + 'static with plain values, timezone omitted', + PipelineFunctions.timestampTruncate('createdAt', 'day'), + _ts(_createdAtUtcDay), + ), + FunctionCase( + 'static to the month', + PipelineFunctions.timestampTruncate('createdAt', 'month'), + _ts(1698796800), + ), + FunctionCase( + 'static to the year', + PipelineFunctions.timestampTruncate('createdAt', 'year'), + _ts(1672531200), + ), + // Local midnight on 2023-11-15 in Tokyo is 2023-11-14T15:00:00Z. + FunctionCase( + 'static with expressions and a plain timezone', + PipelineFunctions.timestampTruncate( + Expression.field('createdAt'), + Expression.constant('day'), + 'Asia/Tokyo', + ), + _ts(_createdAtUtcDay + 15 * 3600), + ), + // Local midnight on 2023-11-14 in MST is 2023-11-14T07:00:00Z. + FunctionCase( + 'static with a timezone expression', + PipelineFunctions.timestampTruncate( + 'createdAt', + 'day', + Expression.constant('MST'), + ), + _ts(_createdAtUtcDay + 7 * 3600), + ), + FunctionCase( + 'fluent with a plain timezone', + Expression.field('createdAt').timestampTruncate('day', 'UTC'), + _ts(_createdAtUtcDay), + ), + FunctionCase( + 'fluent to the second drops the sub-second part', + Expression.field('updatedAt').timestampTruncate('second'), + _ts(1700086400), + ), + FunctionCase( + 'fluent to the millisecond', + Expression.field('updatedAt').timestampTruncate('millisecond'), + _ts(1700086400, 123000000), + ), + FunctionCase( + 'fluent with a granularity expression, timezone omitted', + Expression.field( + 'createdAt', + ).timestampTruncate(Expression.constant('hour')), + _ts(1699999200), + ), + // Local midnight on 2023-11-14 in Los Angeles (PST) is 08:00Z. + FunctionCase( + 'fluent with expressions', + Expression.field('createdAt').timestampTruncate( + Expression.constant('day'), + Expression.constant('America/Los_Angeles'), + ), + _ts(_createdAtUtcDay + 8 * 3600), + ), + ]); + + ctx.functionCases('timestampExtract', [ + FunctionCase( + 'static with plain values, timezone omitted', + PipelineFunctions.timestampExtract('createdAt', 'year'), + isInt(2023), + ), + FunctionCase( + 'static month', + PipelineFunctions.timestampExtract('createdAt', 'month'), + isInt(11), + ), + FunctionCase( + 'static day defaults to UTC', + PipelineFunctions.timestampExtract('createdAt', 'day'), + isInt(14), + ), + FunctionCase( + 'static with expressions and a plain timezone', + PipelineFunctions.timestampExtract( + Expression.field('createdAt'), + Expression.constant('day'), + 'Asia/Tokyo', + ), + isInt(15), + ), + FunctionCase( + 'static with a timezone expression', + PipelineFunctions.timestampExtract( + 'createdAt', + 'hour', + Expression.constant('MST'), + ), + isInt(15), + ), + FunctionCase( + 'fluent with a plain timezone', Expression.field('createdAt').timestampExtract('year', 'UTC'), - 2023, + isInt(2023), + ), + FunctionCase( + 'fluent with timezone omitted', + Expression.field('createdAt').timestampExtract('hour'), + isInt(22), + ), + FunctionCase( + 'fluent minute', + Expression.field('createdAt').timestampExtract('minute'), + isInt(13), + ), + FunctionCase( + 'fluent with expressions', + Expression.field('createdAt').timestampExtract( + Expression.constant('hour'), + Expression.constant('America/Los_Angeles'), + ), + isInt(14), ), ]); - ctx.functionCases('vector functions', [ + // Vector positions take a VectorValue, a plain list of numbers (which must + // reach the backend as a vector, not an array) or an expression. The + // [0.1, 0.1] / [0.5, 0.8] values are the Node SDK system test's. + ctx.functionCases('cosineDistance', [ FunctionCase( - 'cosineDistance', + 'static with a field name and a list', + PipelineFunctions.cosineDistance('embedding', [0, 1, 0]), + isDouble(1), + ), + FunctionCase( + 'static with expressions', + PipelineFunctions.cosineDistance( + Expression.field('embedding'), + Expression.vector([1, 1, 0]), + ), + isDouble(0.29289321881345254), + ), + FunctionCase( + 'static with a field expression on the right', + PipelineFunctions.cosineDistance( + Expression.vector([1, 1, 0]), + Expression.field('embedding'), + ), + isDouble(0.29289321881345254), + ), + FunctionCase( + 'static with a VectorValue', + PipelineFunctions.cosineDistance( + Expression.constant(FieldValue.vector([0.1, 0.1])), + FieldValue.vector([0.5, 0.8]), + ), + isDouble(0.02560880430538015), + ), + FunctionCase( + 'fluent with a vector expression', Expression.field( 'embedding', ).cosineDistance(Expression.vector([1, 0, 0])), - closeTo(0, 0.0001), + isDouble(0), + ), + FunctionCase( + 'fluent with a list', + Expression.field('embedding').cosineDistance(const [1.0, 1.0, 0.0]), + isDouble(0.29289321881345254), + ), + FunctionCase( + 'fluent with a VectorValue', + Expression.constant( + FieldValue.vector([0.1, 0.1]), + ).cosineDistance(FieldValue.vector([0.5, 0.8])), + isDouble(0.02560880430538015), + ), + ]); + + ctx.functionCases('dotProduct', [ + FunctionCase( + 'static with a field name and a list', + PipelineFunctions.dotProduct('embedding', const [2.5, 4, 5]), + isDouble(2.5), + ), + FunctionCase( + 'static with expressions', + PipelineFunctions.dotProduct( + Expression.field('embedding'), + Expression.vector([2.5, 4, 5]), + ), + isDouble(2.5), + ), + FunctionCase( + 'static with a VectorValue', + PipelineFunctions.dotProduct( + Expression.constant(FieldValue.vector([0.1, 0.1])), + FieldValue.vector([0.5, 0.8]), + ), + isDouble(0.13), ), FunctionCase( - 'dotProduct', + 'fluent with a vector expression', Expression.field('embedding').dotProduct(Expression.vector([1, 0, 0])), - closeTo(1, 0.0001), + isDouble(1), ), FunctionCase( - 'euclideanDistance', + 'fluent with a field expression', + Expression.vector([ + 2.5, + 4, + 5, + ]).dotProduct(Expression.field('embedding')), + isDouble(2.5), + ), + FunctionCase( + 'fluent with a list', + Expression.field('embedding').dotProduct([-1.5, 4, 5]), + isDouble(-1.5), + ), + FunctionCase( + 'fluent with a VectorValue', + Expression.constant( + FieldValue.vector([0.1, 0.1]), + ).dotProduct(FieldValue.vector([0.5, 0.8])), + isDouble(0.13), + ), + ]); + + ctx.functionCases('euclideanDistance', [ + FunctionCase( + 'static with a field name and a list', + PipelineFunctions.euclideanDistance('embedding', [4, 4, 0]), + isDouble(5), + ), + FunctionCase( + 'static with expressions', + PipelineFunctions.euclideanDistance( + Expression.field('embedding'), + Expression.vector([0, 1, 0]), + ), + isDouble(1.4142135623730951), + ), + FunctionCase( + 'static with a VectorValue', + PipelineFunctions.euclideanDistance( + Expression.constant(FieldValue.vector([0.1, 0.1])), + FieldValue.vector([0.5, 0.8]), + ), + isDouble(0.806225774829855), + ), + FunctionCase( + 'fluent with a vector expression', Expression.field( 'embedding', ).euclideanDistance(Expression.vector([1, 0, 0])), - closeTo(0, 0.0001), + isDouble(0), ), FunctionCase( - 'vectorLength', - Expression.field('embedding').vectorLength(), - 3, + 'fluent with a field expression', + Expression.vector([ + 4, + 4, + 0, + ]).euclideanDistance(Expression.field('embedding')), + isDouble(5), + ), + FunctionCase( + 'fluent with a list', + Expression.field('embedding').euclideanDistance(const [1.0, 2.0, 2.0]), + isDouble(2.8284271247461903), + ), + FunctionCase( + 'fluent with a VectorValue', + Expression.field( + 'embedding', + ).euclideanDistance(FieldValue.vector([1, 3, 4])), + isDouble(5), ), ]); - // A plain list of numbers must reach the backend as a vector, not an array. - ctx.functionCases('vector functions with list arguments', [ + ctx.functionCases('vectorLength', [ FunctionCase( - 'cosineDistance', - Expression.field('embedding').cosineDistance(const [1.0, 0.0, 0.0]), - closeTo(0, 0.0001), + 'static with a field name', + PipelineFunctions.vectorLength('embedding'), + isInt(3), ), FunctionCase( - 'dotProduct', - PipelineFunctions.dotProduct('embedding', const [1, 0, 0]), - closeTo(1, 0.0001), + 'static with an expression', + PipelineFunctions.vectorLength(Expression.vector([1, 2, 3, 4])), + isInt(4), ), FunctionCase( - 'euclideanDistance', - Expression.field('embedding').euclideanDistance(const [1.0, 0.0, 0.0]), - closeTo(0, 0.0001), + 'fluent', + Expression.field('embedding').vectorLength(), + isInt(3), ), ]); }); diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart index da4d2835..96cc1687 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart @@ -23,44 +23,276 @@ import 'package:test/test.dart'; import 'harness.dart'; +/// A book 1 field holding a value of [type], or `null` when the Admin SDK +/// cannot write one, and a field holding a value of another type. +/// +/// Exhaustive on purpose: a new [PipelineValueType] fails to compile here +/// until it gets a case. +(String?, String) _isTypeFields(PipelineValueType type) { + return switch (type) { + PipelineValueType.nullValue => ('nullable', 'title'), + PipelineValueType.boolean => ('active', 'price'), + PipelineValueType.number => ('score', 'title'), + PipelineValueType.int32 => (null, 'price'), + PipelineValueType.int64 => ('price', 'score'), + PipelineValueType.double => ('score', 'price'), + PipelineValueType.decimal128 => (null, 'score'), + PipelineValueType.timestamp => ('createdAt', 'unixSeconds'), + PipelineValueType.string => ('title', 'bytes'), + PipelineValueType.bytes => ('bytes', 'title'), + PipelineValueType.reference => ('pathRef', 'title'), + PipelineValueType.geoPoint => ('location', 'metadata'), + PipelineValueType.array => ('tags', 'metadata'), + PipelineValueType.map => ('metadata', 'tags'), + PipelineValueType.vector => ('embedding', 'numbers'), + PipelineValueType.maxKey => (null, 'title'), + PipelineValueType.minKey => (null, 'title'), + PipelineValueType.objectId => (null, 'title'), + PipelineValueType.regex => (null, 'title'), + }; +} + +/// The backend type name of each book 1 field, as `type` returns it. +const _typeNames = { + 'nullable': 'null', + 'active': 'boolean', + 'price': 'int64', + 'score': 'float64', + 'createdAt': 'timestamp', + 'title': 'string', + 'bytes': 'bytes', + 'pathRef': 'reference', + 'location': 'geo_point', + 'tags': 'array', + 'metadata': 'map', + 'embedding': 'vector', +}; + void main() { pipelineE2E('Pipeline type and debugging functions', (ctx) { + // Integer division by zero is an error. + final divisionByZero = Expression.constant(1).divide(0); + + ctx.functionCases('expression primitives', [ + FunctionCase('field', field('title'), 'Dart Pipelines'), + FunctionCase( + 'field with a nested path', + field('nested.level1.level2.value'), + isInt(42), + ), + FunctionCase('constant', constant('literal'), 'literal'), + ]); + + test('PipelineField.path names the selected field', () async { + final title = field('title'); + final snapshot = await ctx.book1Pipeline().select([title]).execute(); + expect(title.path, 'title'); + expect(snapshot.results.single.get(title.path), 'Dart Pipelines'); + }); + + ctx.functionCases('raw functions', [ + FunctionCase( + 'pipelineFunction', + pipelineFunction('add', [field('price'), 1]), + isInt(11), + ), + FunctionCase( + 'Expression.raw', + Expression.raw('multiply', [field('rating'), 3]), + isInt(15), + ), + FunctionCase( + 'PipelineFunctions.raw', + PipelineFunctions.raw('string_concat', [field('prefix'), '!']), + 'Dart!', + ), + FunctionCase( + 'asBoolean', + pipelineFunction('equal', [field('rating'), 5]).asBoolean().not(), + false, + ), + ]); + + ctx.filterCases('raw boolean functions', [ + FilterCase( + 'PipelineFunctions.raw as a condition', + PipelineFunctions.raw('greater_than', [field('price'), 15]).asBoolean(), + ['Firestore Admin', 'Inactive Draft'], + ), + ]); + ctx.functionCases('debugging functions', [ - FunctionCase('exists', Expression.field('title').exists(), true), - FunctionCase('isAbsent', Expression.field('missing').isAbsent(), true), + FunctionCase('exists', field('title').exists(), true), + FunctionCase( + 'exists on an absent field', + field('missing').exists(), + false, + ), + FunctionCase('exists on a null field', field('nullable').exists(), true), + FunctionCase( + 'static exists on an expression', + PipelineFunctions.exists(field('missing')), + false, + ), + FunctionCase('isAbsent', field('missing').isAbsent(), true), + FunctionCase( + 'isAbsent on a null field', + field('nullable').isAbsent(), + false, + ), + FunctionCase( + 'static isAbsent on an expression', + PipelineFunctions.isAbsent(field('title')), + false, + ), FunctionCase( 'ifAbsent', - Expression.field('missing').ifAbsent('fallback'), + field('missing').ifAbsent('fallback'), 'fallback', ), - FunctionCase('isError', Expression.field('title').isError(), false), FunctionCase( - 'ifError', - Expression.field('title').ifError('caught'), + 'ifAbsent on a present field', + field('title').ifAbsent('fallback'), + 'Dart Pipelines', + ), + FunctionCase( + 'ifAbsent on a null field', + field('nullable').ifAbsent('fallback'), + isNull, + ), + FunctionCase( + 'ifAbsent with an expression', + field('missing').ifAbsent(field('title')), + 'Dart Pipelines', + ), + // Only book 1 has `sparse`; a literal 'sparse' would come back as is. + FunctionCase( + 'static ifAbsent on a field name', + PipelineFunctions.ifAbsent('sparse', 'none'), + 'book 1 only', + ), + FunctionCase( + 'static ifAbsent on expressions', + PipelineFunctions.ifAbsent(field('missing'), field('prefix')), + 'Dart', + ), + FunctionCase('isError', divisionByZero.isError(), true), + FunctionCase('isError without an error', field('title').isError(), false), + FunctionCase( + 'static isError on an expression', + PipelineFunctions.isError(divisionByZero), + true, + ), + FunctionCase( + 'static isError on a plain value', + PipelineFunctions.isError(0), + false, + ), + FunctionCase('ifError', divisionByZero.ifError('was error'), 'was error'), + FunctionCase( + 'ifError with an expression', + divisionByZero.ifError(field('title')), + 'Dart Pipelines', + ), + FunctionCase( + 'ifError without an error', + field('title').ifError('caught'), 'Dart Pipelines', ), + FunctionCase( + 'ifError on a boolean expression', + divisionByZero.greaterThan(1).ifError(constant(true)).asBoolean().not(), + false, + ), + FunctionCase( + 'static ifError on expressions', + PipelineFunctions.ifError(divisionByZero, constant('was error')), + 'was error', + ), + FunctionCase( + 'static ifError on plain values', + PipelineFunctions.ifError(42, 'caught'), + isInt(42), + ), + ]); + + // A field-name String is a field reference: `exists('sparse')` holds for + // book 1 only, where a literal 'sparse' would hold for every book. + ctx.filterCases('debugging functions on field names', [ + FilterCase('static exists', PipelineFunctions.exists('sparse'), [ + 'Dart Pipelines', + ]), + FilterCase('static isAbsent', PipelineFunctions.isAbsent('sparse'), [ + 'Firestore Admin', + 'Inactive Draft', + ]), ]); - ctx.functionCases('type functions', [ - FunctionCase('type', Expression.field('price').type(), isA()), + ctx.functionCases('type', [ + for (final MapEntry(key: fieldName, value: typeName) + in _typeNames.entries) + FunctionCase('type of $fieldName', field(fieldName).type(), typeName), + // A literal 'price' would be a string. + FunctionCase( + 'static type on a field name', + PipelineFunctions.type('price'), + 'int64', + ), + FunctionCase( + 'static type on an expression', + PipelineFunctions.type(field('location')), + 'geo_point', + ), + ]); + + // Each value is checked against a field of its type, where one can be + // written, and against a field of another type. The negative case alone + // still proves the backend accepts the type name. + ctx.functionCases('isType', [ + for (final type in PipelineValueType.values) + if (_isTypeFields(type) case (final matching, final other)) ...[ + if (matching != null) + FunctionCase( + '${type.name} on $matching', + field(matching).isType(type), + true, + ), + FunctionCase( + '${type.name} on $other', + field(other).isType(type), + false, + ), + ], + FunctionCase( + 'number on an integer', + field('price').isType(PipelineValueType.number), + true, + ), + FunctionCase( + 'decimal128 on an integer', + field('price').isType(PipelineValueType.decimal128), + false, + ), + FunctionCase('a type name String', field('price').isType('int64'), true), FunctionCase( - 'isType', - Expression.field('price').isType(PipelineValueType.number), + 'a type name expression', + field('price').isType(constant('int64')), true, ), + // A literal 'price' would be a string. FunctionCase( - 'isTypeInt64', - Expression.field('price').isType(PipelineValueType.int64), + 'static isType on a field name', + PipelineFunctions.isType('price', PipelineValueType.int64), true, ), FunctionCase( - 'isTypeFloat64', - Expression.field('score').isType(PipelineValueType.double), + 'static isType on expressions', + PipelineFunctions.isType(field('score'), constant('float64')), true, ), FunctionCase( - 'isTypeNotFloat64', - Expression.field('price').isType(PipelineValueType.double), + 'static isType with a type name String', + PipelineFunctions.isType('score', 'int64'), false, ), ]); diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart index e6db8b3d..7e4d1c73 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart @@ -17,28 +17,183 @@ @Tags(['prod']) library; +import 'dart:typed_data'; + import 'package:google_cloud_firestore/google_cloud_firestore.dart'; import 'package:test/test.dart'; import 'harness.dart'; +/// [timestamp] in whole microseconds, for ordering comparisons. +int _micros(Timestamp timestamp) { + return timestamp.seconds * 1000000 + timestamp.nanoseconds ~/ 1000; +} + void main() { pipelineE2E('Pipeline results', (ctx) { - test('exposes document metadata and nested fields', () async { - final metadataSnapshot = await ctx.book1Pipeline().limit(1).execute(); - - expect(metadataSnapshot.results.single.createTime, isNotNull); - expect(metadataSnapshot.results.single.updateTime, isNotNull); - expect(metadataSnapshot.results.single.ref, isNotNull); - // get resolves nested dot-paths and FieldPaths, as in Node. - expect(metadataSnapshot.results.single.get('metadata.lang'), 'dart'); + test('exposes document metadata', () async { + final snapshot = await ctx.book1Pipeline().limit(1).execute(); + final result = snapshot.results.single; + + expect(result.ref, isNotNull); + expect(result.ref!.path, ctx.book1Ref.path); + expect(result.id, '${ctx.runId}_book_1'); + // The full resource name of the document. + expect(result.name, startsWith('projects/')); + expect(result.name, endsWith('/documents/${ctx.book1Ref.path}')); + + // Each book is written once, so it was last updated when created, and + // before the pipeline ran. + expect(result.createTime, isNotNull); + expect(result.updateTime, result.createTime); + expect(snapshot.executionTime, isNotNull); + expect( + _micros(result.createTime!), + lessThanOrEqualTo(_micros(snapshot.executionTime!)), + ); + }); + + test('get resolves dotted paths and FieldPaths', () async { + final snapshot = await ctx.book1Pipeline().limit(1).execute(); + final result = snapshot.results.single; + + expect(result.get('metadata.lang'), 'dart'); + expect(result.get(FieldPath(const ['metadata', 'category'])), 'sdk'); + expect(result.get('nested.level1.level2.value'), 42); + expect( + result.get(FieldPath(const ['nested', 'level1', 'level2', 'value'])), + 42, + ); + // A FieldPath segment may contain a dot; a dotted string splits on it. + expect(result.get(FieldPath(const ['nested', 'dotted.key'])), 'dot'); + expect(result.get('nested.dotted.key'), isNull); + // Missing segments, and segments that traverse a non-map, read null. + expect(result.get('metadata.missing'), isNull); + expect(result.get('missing'), isNull); + expect(result.get('title.length'), isNull); + }); + + test('data decodes every seeded value type', () async { + final snapshot = await ctx.book1Pipeline().limit(1).execute(); + final data = snapshot.results.single.data(); + final seeded = ctx.seed[0]; + + // Every seeded field comes back, including the null, empty and sparse + // ones, with the value that was written. + expect(data.keys, unorderedEquals(seeded.keys)); + for (final key in seeded.keys) { + if (key == 'pathRef') continue; + expect(data[key], equals(seeded[key]), reason: key); + } + + // Numbers decode by wire type. + expect(data['price'], isInt(10)); + expect(data['largeInt'], isInt(9007199254740993)); + expect(data['score'], isDouble(-12.7)); + expect(data['ratio'], isDouble(0.5)); + expect(data['numbers'], [isInt(3), isInt(1), isInt(2), isInt(3)]); + expect(data['scores'], [isDouble(1.5), isDouble(2.5), isDouble(-0.5)]); + expect(data['mixedNumbers'], [ + isInt(1), + isDouble(2.5), + isInt(-3), + isInt(0), + ]); + expect(data['items'], [ + {'name': 'pen', 'qty': isInt(2), 'price': isDouble(1.5)}, + {'name': 'ink', 'qty': isInt(1), 'price': isDouble(4)}, + ]); + + // The other value types decode to their Dart classes. + expect(data['nullable'], isNull); + expect(data['bytes'], isA()); + expect(data['createdAt'], Timestamp(seconds: 1700000000, nanoseconds: 0)); + expect( + data['updatedAt'], + Timestamp(seconds: 1700086400, nanoseconds: 123456000), + ); expect( - metadataSnapshot.results.single.get( - FieldPath(const ['metadata', 'category']), - ), - 'sdk', + data['location'], + GeoPoint(latitude: 37.7749, longitude: -122.4194), ); - expect(metadataSnapshot.results.single.get('metadata.missing'), isNull); + expect(data['embedding'], isA()); + expect((data['embedding']! as VectorValue).toArray(), [1, 0, 0]); + expect(data['pathRef'], isA>()); + expect( + (data['pathRef']! as DocumentReference).path, + ctx.book2Ref.path, + ); + + // The returned map is read-only. + expect(() => data['title'] = 'changed', throwsUnsupportedError); + }); + + test('snapshot exposes its pipeline, size and execution time', () async { + final pipeline = ctx.runPipeline().sort([ascending('price')]); + final before = DateTime.now(); + final snapshot = await pipeline.execute(); + final after = DateTime.now(); + + expect(snapshot.pipeline, same(pipeline)); + expect(snapshot.size, 3); + expect(snapshot.empty, isFalse); + expect(snapshot.results, hasLength(3)); + expect(snapshot.results.map((result) => result.id), [ + '${ctx.runId}_book_1', + '${ctx.runId}_book_2', + '${ctx.runId}_book_3', + ]); + // The execution time is the backend's clock; allow for skew. + final executionTime = snapshot.executionTime!.toDate(); + expect( + executionTime.isAfter(before.subtract(const Duration(minutes: 5))), + isTrue, + reason: '$executionTime vs $before', + ); + expect( + executionTime.isBefore(after.add(const Duration(minutes: 5))), + isTrue, + reason: '$executionTime vs $after', + ); + }); + + test('an empty snapshot still has an execution time', () async { + final snapshot = await ctx.runPipeline().limit(0).execute(); + + expect(snapshot.empty, isTrue); + expect(snapshot.size, 0); + expect(snapshot.results, isEmpty); + expect(snapshot.executionTime, isNotNull); + }); + + test('results for the same document compare equal', () async { + final first = await ctx.book1Pipeline().execute(); + final second = await ctx.book1Pipeline().execute(); + final others = await ctx + .runPipeline() + .where(Expression.field('title').notEqual('Dart Pipelines')) + .execute(); + + expect(first.results.single, second.results.single); + expect(first.results.single.hashCode, second.results.single.hashCode); + expect(others.results, hasLength(2)); + for (final other in others.results) { + expect(first.results.single, isNot(other)); + } + }); + + test('aggregate results carry no document metadata', () async { + final snapshot = await ctx.runPipeline().aggregate([ + PipelineFunctions.countAll().as('books'), + ]).execute(); + final result = snapshot.results.single; + + expect(result.data(), {'books': 3}); + expect(result.ref, isNull); + expect(result.id, isNull); + expect(result.name, isNull); + expect(result.createTime, isNull); + expect(result.updateTime, isNull); }); }); } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart index 8d0b4e98..46b23655 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart @@ -52,6 +52,73 @@ void main() { ]); }); + test('collectionReference starts from that collection', () async { + expect( + await ctx.titlesOf( + ctx.firestore + .pipeline() + .collectionReference(ctx.firestore.collection(ctx.collectionPath)) + .where(ctx.runFilter()) + .sort([ascending('price')]) + .select(['title']), + ), + ['Dart Pipelines', 'Firestore Admin', 'Inactive Draft'], + ); + }); + + test('database starts from every document', () async { + // The database source cannot be filtered before its first stage, so the + // run filter comes straight after it. + expect( + await ctx.titlesOf( + ctx.firestore + .pipeline() + .database() + .where(ctx.runFilter()) + .sort([ascending('price')]) + .select(['title']), + ), + ['Dart Pipelines', 'Firestore Admin', 'Inactive Draft'], + ); + }); + + test('executes a pipeline created from a query', () async { + // Translates the filter, the descending order and the limit. + expect( + await ctx.titlesOf( + ctx.firestore.pipeline().createFrom( + ctx.firestore + .collection(ctx.collectionPath) + .where('runId', WhereFilter.equal, ctx.runId) + .orderBy('price', descending: true) + .limit(2), + ), + ), + ['Inactive Draft', 'Firestore Admin'], + ); + }); + + test('executes a pipeline created from a vector query', () async { + // Euclidean distances from [3, 2, 1]: book 1 = 3, book 2 = sqrt(11), + // book 3 = sqrt(13). + expect( + await ctx.titlesOf( + ctx.firestore.pipeline().createFrom( + ctx.firestore + .collection(ctx.collectionPath) + .where('runId', WhereFilter.equal, ctx.runId) + .findNearest( + vectorField: 'embedding', + queryVector: const [3, 2, 1], + limit: 2, + distanceMeasure: DistanceMeasure.euclidean, + ), + ), + ), + ['Dart Pipelines', 'Firestore Admin'], + ); + }); + test('executes a pipeline created from a collection group query', () async { final snapshot = await ctx.firestore .pipeline() diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart index 022b58cb..edfdb7d0 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart @@ -16,11 +16,22 @@ @Tags(['prod']) library; +import 'dart:math' as math; + import 'package:google_cloud_firestore/google_cloud_firestore.dart'; import 'package:test/test.dart'; import 'harness.dart'; +/// The titles of the three seeded books, in book order. +const _allTitles = ['Dart Pipelines', 'Firestore Admin', 'Inactive Draft']; + +/// The data of every result [pipeline] produces, in order. +Future> _dataOf(Pipeline pipeline) async { + final snapshot = await pipeline.execute(); + return [for (final result in snapshot.results) result.data()]; +} + void main() { pipelineE2E('Pipeline stages', (ctx) { test('where, sort, select and limit', () async { @@ -51,6 +62,34 @@ void main() { expect(snapshot.results.first.get('upperTitle'), 'DART PIPELINES'); expect(snapshot.results.first.get('tagCount'), 2); expect(snapshot.results.first.get('category'), 'sdk'); + expect(snapshot.results.last.get('title'), 'Firestore Admin'); + }); + + test('select takes field names, fields and aliased expressions', () async { + final data = await _dataOf( + ctx.book1Pipeline().select([ + 'title', + 'price', + Expression.field('rating'), + Expression.field('discount').as('markdown'), + ]), + ); + + // Only the selected fields remain. + expect(data, [ + {'title': 'Dart Pipelines', 'price': 10, 'rating': 5, 'markdown': 2}, + ]); + }); + + test('an alias names the field its expression lands on', () async { + final aliased = Expression.field('price').as('cost'); + + expect(aliased.name, 'cost'); + expect(aliased.expression, isA()); + expect((aliased.expression as PipelineField).path, 'price'); + expect(await _dataOf(ctx.book1Pipeline().select([aliased])), [ + {'cost': 10}, + ]); }); test('executes an addFields stage', () async { @@ -74,6 +113,219 @@ void main() { expect(single.results.single.get('upper'), 'DART PIPELINES'); }); + test('removeFields drops fields named by string or field', () async { + final data = await _dataOf( + ctx.book1Pipeline().removeFields([Expression.field('price'), 'rating']), + ); + + expect(data, hasLength(1)); + // Every other seeded field is still there. + expect( + data.single.keys, + unorderedEquals( + ctx.seed[0].keys.where((key) => key != 'price' && key != 'rating'), + ), + ); + expect(data.single['title'], 'Dart Pipelines'); + }); + + test('sort applies its orderings in sequence', () async { + // active descending puts the two active books first (false sorts before + // true); price descending then orders those two. + expect( + await ctx.titlesOf( + ctx.runPipeline().sort([ + descending(Expression.field('active')), + descending('price'), + ]), + ), + ['Firestore Admin', 'Dart Pipelines', 'Inactive Draft'], + ); + + // half is 2.5 on every book, so score descending decides the order. + expect( + await ctx.titlesOf( + ctx.runPipeline().sort([ + ascending('half'), + Expression.field('score').descending(), + ]), + ), + ['Inactive Draft', 'Firestore Admin', 'Dart Pipelines'], + ); + + // flags: book 1 = 6, book 2 = 3, book 3 = 5. + expect( + await ctx.titlesOf( + ctx.runPipeline().sort([ascending(Expression.field('flags'))]), + ), + ['Firestore Admin', 'Inactive Draft', 'Dart Pipelines'], + ); + }); + + test('offset skips sorted inputs before limit', () async { + final sorted = ctx.runPipeline().sort([ + Expression.field('price').ascending(), + ]); + + expect(await ctx.titlesOf(sorted.offset(1)), [ + 'Firestore Admin', + 'Inactive Draft', + ]); + expect(await ctx.titlesOf(sorted.offset(1).limit(1)), [ + 'Firestore Admin', + ]); + expect(await ctx.titlesOf(sorted.offset(3)), isEmpty); + }); + + test('distinct returns each unique combination of groups', () async { + expect( + await _dataOf( + ctx + .runPipeline() + .distinct([ + 'active', + Expression.field('metadata').mapGetLiteral('lang').as('lang'), + ]) + .sort([ascending('active')]), + ), + [ + {'active': false, 'lang': 'dart'}, + {'active': true, 'lang': 'dart'}, + ], + ); + }); + + test('unnest by field name replaces the array with each element', () async { + expect( + await _dataOf( + ctx.book1Pipeline().unnest('tags').select(['title', 'tags']).sort([ + ascending('tags'), + ]), + ), + [ + {'title': 'Dart Pipelines', 'tags': 'dart'}, + {'title': 'Dart Pipelines', 'tags': 'firebase'}, + ], + ); + }); + + test('unnest an aliased field keeps the source array', () async { + expect( + await _dataOf( + ctx + .book1Pipeline() + .unnest(Expression.field('scores').as('element')) + .select(['scores', 'element']) + .sort([ascending('element')]), + ), + [ + { + 'scores': [1.5, 2.5, -0.5], + 'element': -0.5, + }, + { + 'scores': [1.5, 2.5, -0.5], + 'element': 1.5, + }, + { + 'scores': [1.5, 2.5, -0.5], + 'element': 2.5, + }, + ], + ); + }); + + test('unnest with an index field numbers each element', () async { + // tags: book 1 = [dart, firebase], book 2 = [firebase], book 3 = [draft]. + expect( + await _dataOf( + ctx + .runPipeline() + .unnest( + Expression.field('tags').as('tag'), + indexField: 'tagIndex', + ) + .select(['title', 'tag', 'tagIndex']) + .sort([ascending('tag'), ascending('tagIndex')]), + ), + [ + {'title': 'Dart Pipelines', 'tag': 'dart', 'tagIndex': 0}, + {'title': 'Inactive Draft', 'tag': 'draft', 'tagIndex': 0}, + {'title': 'Firestore Admin', 'tag': 'firebase', 'tagIndex': 0}, + {'title': 'Dart Pipelines', 'tag': 'firebase', 'tagIndex': 1}, + ], + ); + }); + + test('replaceWith a field name promotes that map', () async { + expect(await _dataOf(ctx.book1Pipeline().replaceWith('metadata')), [ + {'lang': 'dart', 'category': 'sdk'}, + ]); + }); + + test('replaceWith an expression uses the map it evaluates to', () async { + expect( + await _dataOf( + ctx.book1Pipeline().replaceWith(Expression.field('stats')), + ), + [ + {'views': 100, 'likes': 7}, + ], + ); + expect( + await _dataOf( + ctx.book1Pipeline().replaceWith( + PipelineFunctions.map([ + 'name', + Expression.field('title'), + 'answer', + 42, + ]), + ), + ), + [ + {'name': 'Dart Pipelines', 'answer': 42}, + ], + ); + }); + + test('union appends the other pipeline, keeping duplicates', () async { + expect( + await ctx.titlesOf( + ctx + .book1Pipeline() + .union(ctx.runPipeline()) + .sort([ascending('price')]) + .select(['title']), + ), + [ + 'Dart Pipelines', + 'Dart Pipelines', + 'Firestore Admin', + 'Inactive Draft', + ], + ); + }); + + test('sample by document count returns that many inputs', () async { + final titles = await ctx.titlesOf(ctx.runPipeline().sample(documents: 2)); + + expect(titles, hasLength(2)); + expect(titles.toSet(), hasLength(2)); + expect(titles, everyElement(isIn(_allTitles))); + }); + + test('sample by percentage returns a subset of the inputs', () async { + final titles = await ctx.titlesOf( + ctx.runPipeline().sample(percentage: 0.5), + ); + + // Each input is kept at random, so only the bounds are deterministic. + expect(titles.length, lessThanOrEqualTo(3)); + expect(titles.toSet(), hasLength(titles.length)); + expect(titles, everyElement(isIn(_allTitles))); + }); + test('executes vector nearest-neighbor stage', () async { final vectorSnapshot = await ctx .runPipeline() @@ -88,7 +340,11 @@ void main() { expect(vectorSnapshot.results, hasLength(1)); expect(vectorSnapshot.results.single.get('title'), 'Dart Pipelines'); - expect(vectorSnapshot.results.single.get('distance'), isNotNull); + // Book 1's embedding is the query vector itself. + expect( + vectorSnapshot.results.single.get('distance'), + isNumber(0, tolerance: 1e-9), + ); }); test('executes vector nearest-neighbor stage with a list', () async { @@ -106,5 +362,155 @@ void main() { expect(vectorSnapshot.results, hasLength(1)); expect(vectorSnapshot.results.single.get('title'), 'Dart Pipelines'); }); + + // The query vector [3, 2, 1] ranks the books 1, 2, 3 under every measure: + // euclidean distances 3, sqrt(11), sqrt(13); dot products 3, 2, 1; cosine + // distances 1 - 3/sqrt(14), 1 - 2/sqrt(14), 1 - 1/sqrt(14). + + test('findNearest by euclidean distance, without a limit', () async { + final snapshot = await ctx + .runPipeline() + .findNearest( + vectorField: Expression.field('embedding'), + queryVector: const [3.0, 2.0, 1.0], + distanceMeasure: DistanceMeasure.euclidean, + distanceResultField: 'distance', + ) + .execute(); + + expect([ + for (final result in snapshot.results) result.get('title'), + ], _allTitles); + expect( + [for (final result in snapshot.results) result.get('distance')], + [isNumber(3), isNumber(math.sqrt(11)), isNumber(math.sqrt(13))], + ); + }); + + test('findNearest by dot product returns the largest first', () async { + final snapshot = await ctx + .runPipeline() + .findNearest( + vectorField: 'embedding', + queryVector: Expression.vector([3, 2, 1]), + distanceMeasure: DistanceMeasure.dotProduct, + limit: 2, + distanceResultField: 'similarity', + ) + .execute(); + + expect( + [for (final result in snapshot.results) result.get('title')], + ['Dart Pipelines', 'Firestore Admin'], + ); + expect( + [for (final result in snapshot.results) result.get('similarity')], + [isNumber(3), isNumber(2)], + ); + }); + + test('findNearest by cosine distance', () async { + final snapshot = await ctx + .runPipeline() + .findNearest( + vectorField: 'embedding', + queryVector: const [3.0, 2.0, 1.0], + distanceMeasure: DistanceMeasure.cosine, + limit: 3, + distanceResultField: 'distance', + ) + .execute(); + + expect([ + for (final result in snapshot.results) result.get('title'), + ], _allTitles); + expect( + [for (final result in snapshot.results) result.get('distance')], + [ + isNumber(1 - 3 / math.sqrt(14)), + isNumber(1 - 2 / math.sqrt(14)), + isNumber(1 - 1 / math.sqrt(14)), + ], + ); + }); + + test('findNearest drops inputs beyond the distance threshold', () async { + // Euclidean distances 3 and sqrt(11) ~ 3.317 are within 3.5; + // sqrt(13) ~ 3.606 is not. + expect( + await ctx.titlesOf( + ctx.runPipeline().findNearest( + vectorField: 'embedding', + queryVector: const [3.0, 2.0, 1.0], + distanceMeasure: DistanceMeasure.euclidean, + limit: 3, + distanceThreshold: 3.5, + ), + ), + ['Dart Pipelines', 'Firestore Admin'], + ); + }); + + test('rawStage sends stages by their backend name', () async { + // price > 15 keeps books 2 and 3; descending price puts book 3 first, + // and the offset skips it. + expect( + await ctx.titlesOf( + ctx + .runPipeline() + .rawStage('where', [Expression.field('price').greaterThan(15)]) + .rawStage('sort', [ + { + 'direction': 'descending', + 'expression': Expression.field('price'), + }, + ]) + .rawStage('offset', [1]) + .rawStage('limit', [1]), + ), + ['Firestore Admin'], + ); + }); + + test('rawStage select takes a map of expressions', () async { + expect( + await _dataOf( + ctx.book1Pipeline().rawStage('select', [ + { + 'name': Expression.field('title'), + 'cost': Expression.field('price'), + }, + ]), + ), + [ + {'name': 'Dart Pipelines', 'cost': 10}, + ], + ); + }); + + test('rawStage passes stage options', () async { + final data = await _dataOf( + ctx + .runPipeline() + .rawStage( + 'find_nearest', + [ + Expression.field('embedding'), + FieldValue.vector([3, 2, 1]), + 'euclidean', + ], + options: { + 'distance_field': Expression.field('distance'), + 'limit': 2, + }, + ) + .select(['title', 'distance']), + ); + + expect(data, [ + {'title': 'Dart Pipelines', 'distance': isNumber(3)}, + {'title': 'Firestore Admin', 'distance': isNumber(math.sqrt(11))}, + ]); + }); }); } diff --git a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart index a4662a9f..b4edfc30 100644 --- a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart @@ -442,6 +442,9 @@ const _notExecutable = { 'Expression.raw(options)': 'no backend function takes options yet (the Node SDK only uses them for ' 'the disabled snippet function); an empty map equals omitting it', + 'PipelineFunctions.raw(options)': + 'no backend function takes options yet (the Node SDK only uses them for ' + 'the disabled snippet function); an empty map equals omitting it', 'pipelineFunction(options)': 'no backend function takes options yet (the Node SDK only uses them for ' 'the disabled snippet function); an empty map equals omitting it', From 675fba69c8b591eb4f80698ac64a15cbb80360ef Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:39:20 +0200 Subject: [PATCH 10/29] feat(firestore): encode Pipeline raw options and rawStage params like Node Mirror the Node SDK's OptionsUtil and rawStage, found by the golden harness: - rawOptions keys are dot-separated paths. `execute(rawOptions:)`, `Transaction.executePipeline(rawOptions:)` and `rawStage(options:)` expand `'a.b'` into nested maps and overlay them on the typed options key by key, so `explain:` plus `{'explain_options.output_format': ...}` sends one explain_options map. Keys with an empty segment throw an ArgumentError, as Node does. - rawStage sends a Map param as a literal map, but converts the collections nested in it to map(...)/array(...) functions; prod rejects expressions inside a nested literal map. Other params, lists included, are still sent as is, like Node. - Every stage and source takes an optional `rawOptions`, as every Node stage does, overlaid on the stage's typed options. Golden cases cover rawStage params (maps and lists, with and without expressions, nested), dotted option keys and their ordering rules for stages and execute, and rawOptions on every stage and source. --- packages/google_cloud_firestore/README.md | 18 +- .../lib/src/pipeline.dart | 370 +++++-- .../fixtures/pipeline_golden/options.json | 104 ++ .../fixtures/pipeline_golden/sources.json | 71 ++ .../test/fixtures/pipeline_golden/stages.json | 909 ++++++++++++++++-- .../test/pipeline_api_parity_test.dart | 3 - .../test/pipeline_golden_test.dart | 323 ++++++- .../test/pipeline_test.dart | 360 +++++++ .../tool/pipeline_golden/cases.js | 187 ++++ 9 files changed, 2206 insertions(+), 139 deletions(-) diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 886b5e53..600d912e 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -312,15 +312,29 @@ expression. So does the second argument of `cosineDistance`, `dotProduct` and `euclideanDistance`; either way it is sent as a vector. **`rawStage`** — escape hatch for preview stages this SDK does not yet wrap. -`search` is a thin wrapper over the same mechanism. +`search` is a thin wrapper over the same mechanism. As in the Node.js SDK, a +`Map` argument is sent as a literal map, but a `List` or `Map` nested in it is +sent as the `array(...)` or `map(...)` function that builds it, so it may hold +expressions. ```dart .rawStage('sample', [10, 'documents'], options: {'stable': true}) ``` +Every stage and source also takes `rawOptions`, for stage options this SDK +does not wrap yet; they take precedence over the typed ones. A key may be a +dot-separated path into a map option: `{'outer.inner': 1}` sends +`outer: {inner: 1}`, merged with anything else set inside `outer`. + +```dart +.limit(10, rawOptions: {'some_option.enabled': true}) +``` + Query-level options are passed to `execute()` rather than built onto the Pipeline, so a Pipeline value stays a pure description of what to fetch. Use -`rawOptions` for options this SDK does not wrap yet; they take precedence. +`rawOptions` for options this SDK does not wrap yet; they take precedence, and +dotted keys merge into the typed options, so `explain` plus +`{'explain_options.output_format': 'json'}` sends one `explain_options` map. ```dart .execute( diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index ae4e14b4..c10129b3 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -313,6 +313,24 @@ Object? _valueToDefaultExpr(Object? value) { }; } +/// Converts a [Pipeline.rawStage] argument. +/// +/// Mirrors the Node SDK's `rawStage`, which sends a plain object as a literal +/// map value (`_mapValue`) and anything else as is (`constant`). A literal map +/// that is a stage argument may hold expressions, as `select`'s does, but the +/// backend rejects them one level deeper, so each value of the map goes +/// through [_valueToDefaultExpr]: a nested collection becomes the `map(...)` +/// or `array(...)` function that builds it. +Object? _rawStageArg(Object? value) { + return switch (value) { + Map() => { + for (final entry in value.entries) + entry.key.toString(): _valueToDefaultExpr(entry.value), + }, + _ => value, + }; +} + /// Whether [value] is, or holds, an expression the backend has to evaluate. /// /// Constants already encode to plain values, so they don't count. @@ -1418,13 +1436,28 @@ final class PipelineSource { final Firestore _firestore; /// Starts a Pipeline over documents in the collection at [collectionPath]. - Pipeline collection(String collectionPath) { + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [Pipeline.rawStage]'s `options` do. + Pipeline collection( + String collectionPath, { + Map rawOptions = const {}, + }) { _validateResourcePath('collectionPath', collectionPath); - return collectionReference(_firestore.collection(collectionPath)); + return collectionReference( + _firestore.collection(collectionPath), + rawOptions: rawOptions, + ); } /// Starts a Pipeline over every collection with [collectionId]. - Pipeline collectionGroup(String collectionId) { + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [Pipeline.rawStage]'s `options` do. + Pipeline collectionGroup( + String collectionId, { + Map rawOptions = const {}, + }) { if (collectionId.contains('/')) { throw ArgumentError( 'Invalid collectionId "$collectionId". Collection IDs must not contain "/".', @@ -1436,32 +1469,47 @@ final class PipelineSource { return _start('collection_group', [ _PipelineProtoValue(firestore_v1.Value(referenceValue: '')), collectionId, - ]); + ], rawOptions: rawOptions); } /// Starts a Pipeline over every document in the database. - Pipeline database() => _start('database', const []); + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [Pipeline.rawStage]'s `options` do. + Pipeline database({Map rawOptions = const {}}) { + return _start('database', const [], rawOptions: rawOptions); + } /// Starts a Pipeline over the provided collection reference. /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [Pipeline.rawStage]'s `options` do. + /// /// Throws an [ArgumentError] when [collectionReference] targets a different /// database than this Pipeline. Pipeline collectionReference( - CollectionReference collectionReference, - ) { + CollectionReference collectionReference, { + Map rawOptions = const {}, + }) { _validateSameDatabase( _firestore, collectionReference.firestore, 'collectionReference', ); - return _start('collection', [collectionReference]); + return _start('collection', [collectionReference], rawOptions: rawOptions); } /// Starts a Pipeline over the provided document references. /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [Pipeline.rawStage]'s `options` do. + /// /// Throws an [ArgumentError] when any of [documents] targets a different /// database than this Pipeline. - Pipeline documents(Iterable> documents) { + Pipeline documents( + Iterable> documents, { + Map rawOptions = const {}, + }) { final refs = documents.toList(); if (refs.isEmpty) { throw ArgumentError.value(documents, 'documents', 'Must not be empty.'); @@ -1474,7 +1522,7 @@ final class PipelineSource { // name. The full name is only correct for references in a value position. return _start('documents', [ for (final ref in refs) _PipelineProtoValue(_relativeReference(ref.path)), - ]); + ], rawOptions: rawOptions); } /// Converts [query] into an equivalent Pipeline. @@ -1502,10 +1550,14 @@ final class PipelineSource { } } - Pipeline _start(String name, List args) { + Pipeline _start( + String name, + List args, { + required Map rawOptions, + }) { return Pipeline._( firestore: _firestore, - stages: [_PipelineStage(name, args)], + stages: [_PipelineStage(name, args, _PipelineOptions(raw: rawOptions))], ); } } @@ -1525,17 +1577,64 @@ final class Pipeline { /// Adds a raw backend Pipeline stage. /// /// Use this for preview stages or options not yet wrapped by this SDK. + /// + /// [args] are converted like the Node SDK's `rawStage` params: each is sent + /// as is (a [List] as a literal array value), except a [Map]. A [Map] is + /// sent as a literal map value, which may hold expressions, but a [List] or + /// [Map] nested in it is sent as the [PipelineFunctions.array] or + /// [PipelineFunctions.map] function that builds it, since the backend + /// rejects expressions nested in a literal value at that depth. Wrap a + /// nested collection in [constant] to send it as a literal value instead. + /// + /// [options] are the stage's options, keyed by the names the backend + /// expects. A key may be a dot-separated path into a map option: + /// `{'outer.inner': 1}` sends `outer: {inner: 1}`, and is merged with any + /// other value set inside `outer`. Keys are applied in order, so a later + /// key overwrites an earlier one it overlaps with. + /// + /// Throws an [ArgumentError] when a key of [options] has an empty segment, + /// such as `''`, `'a.'` or `'a..b'`. Pipeline rawStage( String name, Iterable args, { Map options = const {}, }) { - return _append(_PipelineStage(name, args.toList(), options)); + return _append( + _PipelineStage(name, [ + for (final arg in args) _rawStageArg(arg), + ], _PipelineOptions(raw: options, rawName: 'options')), + ); + } + + /// Adds the stage [name], as one of this SDK's typed stage methods does. + /// + /// Unlike [rawStage], [args] are sent as they are. [options] are the typed + /// options of the stage, under their backend names, and [rawOptions] the + /// caller's raw options, overlaid on them. + Pipeline _stage( + String name, + List args, { + Map options = const {}, + required Map rawOptions, + }) { + return _append( + _PipelineStage( + name, + args, + _PipelineOptions(known: options, raw: rawOptions), + ), + ); } /// Filters inputs using [condition]. - Pipeline where(PipelineBooleanExpression condition) { - return rawStage('where', [condition]); + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline where( + PipelineBooleanExpression condition, { + Map rawOptions = const {}, + }) { + return _stage('where', [condition], rawOptions: rawOptions); } /// Selects or computes fields from the inputs. @@ -1545,8 +1644,16 @@ final class Pipeline { /// /// Throws an [ArgumentError] when two [selections] land on the same field /// name or alias. - Pipeline select(Iterable selections) { - return rawStage('select', [_projectionMap(selections)]); + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline select( + Iterable selections, { + Map rawOptions = const {}, + }) { + return _stage('select', [ + _projectionMap(selections), + ], rawOptions: rawOptions); } /// Adds or overwrites fields on the inputs. @@ -1556,19 +1663,29 @@ final class Pipeline { /// a single map keyed by alias. /// /// Throws an [ArgumentError] when two [fields] share an alias. - Pipeline addFields(Iterable fields) { - return rawStage('add_fields', [ + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline addFields( + Iterable fields, { + Map rawOptions = const {}, + }) { + return _stage('add_fields', [ _projectionMap(fields, argumentName: 'fields'), - ]); + ], rawOptions: rawOptions); } /// Aggregates inputs using aliased aggregate expressions. /// /// Throws an [ArgumentError] when two [accumulators], or two [groups], land /// on the same field name or alias. + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. Pipeline aggregate( Iterable accumulators, { Iterable groups = const [], + Map rawOptions = const {}, }) { final values = accumulators.toList(); if (values.isEmpty) { @@ -1578,10 +1695,10 @@ final class Pipeline { 'Must not be empty.', ); } - return rawStage('aggregate', [ + return _stage('aggregate', [ _projectionMap(values, argumentName: 'accumulators'), _projectionMap(groups, argumentName: 'groups'), - ]); + ], rawOptions: rawOptions); } /// Returns unique combinations of the provided grouping expressions. @@ -1591,47 +1708,71 @@ final class Pipeline { /// /// Throws an [ArgumentError] when two [groups] land on the same field name /// or alias. - Pipeline distinct(Iterable groups) { + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline distinct( + Iterable groups, { + Map rawOptions = const {}, + }) { final values = groups.toList(); if (values.isEmpty) { throw ArgumentError.value(groups, 'groups', 'Must not be empty.'); } - return rawStage('distinct', [ + return _stage('distinct', [ _projectionMap(values, argumentName: 'groups'), - ]); + ], rawOptions: rawOptions); } /// Removes fields from the inputs. - Pipeline removeFields(Iterable fields) { - return rawStage('remove_fields', [ + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline removeFields( + Iterable fields, { + Map rawOptions = const {}, + }) { + return _stage('remove_fields', [ for (final value in fields) if (value is String) field(value) else value, - ]); + ], rawOptions: rawOptions); } /// Sorts inputs according to [orderings]. - Pipeline sort(Iterable orderings) { + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline sort( + Iterable orderings, { + Map rawOptions = const {}, + }) { final values = orderings.toList(); if (values.isEmpty) { throw ArgumentError.value(orderings, 'orderings', 'Must not be empty.'); } - return rawStage('sort', values); + return _stage('sort', values, rawOptions: rawOptions); } /// Skips the first [offset] inputs. - Pipeline offset(int offset) { + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline offset(int offset, {Map rawOptions = const {}}) { if (offset < 0) { throw ArgumentError.value(offset, 'offset', 'Must be non-negative.'); } - return rawStage('offset', [offset]); + return _stage('offset', [offset], rawOptions: rawOptions); } /// Limits the number of returned inputs to [limit]. - Pipeline limit(int limit) { + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline limit(int limit, {Map rawOptions = const {}}) { if (limit < 0) { throw ArgumentError.value(limit, 'limit', 'Must be non-negative.'); } - return rawStage('limit', [limit]); + return _stage('limit', [limit], rawOptions: rawOptions); } /// Emits a document for each element of the array selected by [selectable]. @@ -1643,36 +1784,63 @@ final class Pipeline { /// /// When [indexField] is given, the element's zero-based index is assigned to /// that field. - Pipeline unnest(Object selectable, {String? indexField}) { + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do, and takes precedence over [indexField]. + Pipeline unnest( + Object selectable, { + String? indexField, + Map rawOptions = const {}, + }) { final (expression, alias) = _selectableParts(selectable); - return rawStage( + return _stage( 'unnest', [expression, field(alias)], options: _compactOptions({ 'index_field': indexField == null ? null : field(indexField), }), + rawOptions: rawOptions, ); } /// Replaces each input document with the map produced by [expression]. - Pipeline replaceWith(Object expression) { - return rawStage('replace_with', [ + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline replaceWith( + Object expression, { + Map rawOptions = const {}, + }) { + return _stage('replace_with', [ _fieldOrExpression(expression), 'full_replace', - ]); + ], rawOptions: rawOptions); } /// Performs a union with [pipeline], including duplicates. /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + /// /// Throws an [ArgumentError] when [pipeline] targets a different database /// than this Pipeline. - Pipeline union(Pipeline pipeline) { + Pipeline union( + Pipeline pipeline, { + Map rawOptions = const {}, + }) { _validateSameDatabase(firestore, pipeline.firestore, 'pipeline'); - return rawStage('union', [pipeline]); + return _stage('union', [pipeline], rawOptions: rawOptions); } /// Samples a fixed number or percentage of documents from the input. - Pipeline sample({int? documents, double? percentage}) { + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do. + Pipeline sample({ + int? documents, + double? percentage, + Map rawOptions = const {}, + }) { if ((documents == null) == (percentage == null)) { throw ArgumentError( 'Exactly one of documents or percentage must be provided.', @@ -1698,12 +1866,15 @@ final class Pipeline { ? (documents, 'documents') : (percentage!, 'percent'); - return rawStage('sample', [rate, mode]); + return _stage('sample', [rate, mode], rawOptions: rawOptions); } /// Performs vector nearest-neighbor search. /// /// [queryVector] is a [VectorValue], a list of numbers, or an expression. + /// + /// [rawOptions] sets stage options this SDK does not wrap yet, as + /// [rawStage]'s `options` do, and takes precedence over the typed options. Pipeline findNearest({ required Object vectorField, required Object queryVector, @@ -1711,8 +1882,9 @@ final class Pipeline { int? limit, String? distanceResultField, double? distanceThreshold, + Map rawOptions = const {}, }) { - return rawStage( + return _stage( 'find_nearest', [ if (vectorField is String) field(vectorField) else vectorField, @@ -1726,15 +1898,23 @@ final class Pipeline { : field(distanceResultField), 'distance_threshold': distanceThreshold, }), + rawOptions: rawOptions, ); } /// 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); + /// backend stage as encoded Pipeline values, keyed by the names the backend + /// expects. + /// + /// [rawOptions] is overlaid on [options] the way [rawStage] reads its + /// `options`, so its keys may be dot-separated paths. + Pipeline search( + Map options, { + Map rawOptions = const {}, + }) { + return _stage('search', const [], options: options, rawOptions: rawOptions); } /// Executes this Pipeline and returns the results. @@ -1749,7 +1929,13 @@ final class Pipeline { /// Pass [explain] to ask the backend for planning stats, then read /// [PipelineSnapshot.explainStats]. [rawOptions] sets options this SDK does /// not wrap yet, keyed by the names the backend expects, and takes - /// precedence over the typed options above. + /// precedence over the typed options above. As in [rawStage]'s `options`, a + /// key may be a dot-separated path into a map option, merged with the typed + /// options: `explain` plus `{'explain_options.output_format': 'json'}` send + /// a single `explain_options` map holding both. + /// + /// Throws an [ArgumentError] when a key of [rawOptions] has an empty + /// segment, such as `''`, `'a.'` or `'a..b'`. /// /// ```dart /// final snapshot = await firestore @@ -1782,18 +1968,18 @@ final class Pipeline { } /// Builds the StructuredPipeline options, with [rawOptions] winning. - static Map _executeOptions({ + static _PipelineOptions _executeOptions({ required PipelineIndexMode? indexMode, required PipelineExplainOptions? explain, required Map rawOptions, }) { - return { - ..._compactOptions({ + return _PipelineOptions( + known: _compactOptions({ 'index_mode': indexMode?.value, 'explain_options': explain?._encoded, }), - ...rawOptions, - }; + raw: rawOptions, + ); } /// Executes this Pipeline, optionally as part of a transaction. @@ -1805,7 +1991,7 @@ final class Pipeline { String? transactionId, Timestamp? readTime, firestore_v1.TransactionOptions? transactionOptions, - Map options = const {}, + _PipelineOptions options = const _PipelineOptions.none(), }) { final results = []; Timestamp? executionTime; @@ -1833,7 +2019,7 @@ final class Pipeline { database: 'projects/$projectId/databases/${firestore.databaseId}', structuredPipeline: firestore_v1.StructuredPipeline( pipeline: _toProto(), - options: _encodeOptions(options, firestore), + options: options._toProto(firestore), ), transaction: transactionId.let(base64Decode), newTransaction: transactionOptions, @@ -3118,17 +3304,85 @@ final class _PipelineBooleanCastExpression extends PipelineBooleanExpression { } final class _PipelineStage { - const _PipelineStage(this.name, this.args, [this.options = const {}]); + const _PipelineStage(this.name, this.args, this.options); final String name; final List args; - final Map options; + final _PipelineOptions 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), + options: options._toProto(firestore), + ); + } +} + +/// The options of a stage or of a Pipeline execution. +/// +/// Mirrors the Node SDK's `OptionsUtil.getOptionsProto`. [known] holds the +/// options this SDK types, under their backend names. [raw] holds the +/// caller's raw options, which are overlaid on [known] one key at a time, in +/// order. A [raw] key is a dot-separated path: `'explain_options.mode'` sets +/// `mode` inside the `explain_options` map and keeps the map's other entries, +/// creating the map, or replacing a value that is not a map, on the way. A +/// key without a dot replaces the whole option. Segments are not unescaped, +/// so a backtick is part of the name. +@immutable +final class _PipelineOptions { + /// Throws an [ArgumentError], naming the parameter [rawName], when a key of + /// [raw] has an empty segment, which the Node SDK rejects too. + _PipelineOptions({ + this.known = const {}, + Map raw = const {}, + String rawName = 'rawOptions', + }) : raw = _validateRawOptions(raw, rawName); + + const _PipelineOptions.none() : known = const {}, raw = const {}; + + final Map known; + final Map raw; + + static Map _validateRawOptions( + Map raw, + String name, + ) { + for (final key in raw.keys) { + if (key.split('.').any((segment) => segment.isEmpty)) { + throw ArgumentError.value( + key, + name, + 'Option keys must be dot-separated paths without empty segments.', + ); + } + } + return raw; + } + + Map _toProto(Firestore firestore) { + final result = _encodeOptions(known, firestore); + for (final MapEntry(:key, :value) in raw.entries) { + _setPath(result, key.split('.'), _encodePipelineValue(value, firestore)); + } + return result; + } + + /// Sets [value] at [path] inside [fields], descending into map values. + static void _setPath( + Map fields, + List path, + firestore_v1.Value value, + ) { + final [segment, ...rest] = path; + if (rest.isEmpty) { + fields[segment] = value; + return; + } + final nested = {...?fields[segment]?.mapValue?.fields}; + _setPath(nested, rest, value); + fields[segment] = firestore_v1.Value( + mapValue: firestore_v1.MapValue(fields: nested), ); } } diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/options.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/options.json index 0d4c069f..52f5d5a5 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/options.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/options.json @@ -145,6 +145,95 @@ } } }, + "options/raw-options/dot-notation-deep": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "a": { + "mapValue": { + "fields": { + "b": {"mapValue": {"fields": {"c": {"booleanValue": true}}}} + } + } + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/raw-options/dot-notation-into-empty-explain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": {"fields": {"mode": {"stringValue": "analyze"}}} + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/raw-options/dot-notation-overrides-known": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": { + "fields": { + "mode": {"stringValue": "execute"}, + "output_format": {"stringValue": "text"} + } + } + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/raw-options/dot-notation-replaces-known-scalar": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "index_mode": { + "mapValue": {"fields": {"mode": {"stringValue": "custom"}}} + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, + "options/raw-options/dot-notation-siblings": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "outer": { + "mapValue": { + "fields": { + "a": {"integerValue": "1"}, + "b": {"stringValue": "two"} + } + } + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, "options/raw-options/flat": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -194,6 +283,21 @@ } } }, + "options/raw-options/object-replaces-known": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "options": { + "explain_options": { + "mapValue": {"fields": {"mode": {"stringValue": "execute"}}} + } + }, + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]} + ] + } + } + }, "options/raw-options/override-known": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json index 54b6c7ef..d5cf6f63 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json @@ -29,6 +29,20 @@ } } }, + "sources/collection-group/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection_group", + "args": [{"referenceValue": ""}, {"stringValue": "reviews"}], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "sources/collection/force-index": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -66,6 +80,25 @@ } } }, + "sources/collection/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection", + "args": [{"referenceValue": "/books"}], + "options": { + "foo": {"stringValue": "bar"}, + "outer": { + "mapValue": {"fields": {"inner": {"integerValue": "1"}}} + } + } + } + ] + } + } + }, "sources/collection/reference": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -76,10 +109,34 @@ } } }, + "sources/collection/reference-raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection", + "args": [{"referenceValue": "/books"}], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "sources/database/plain": { "database": "projects/test-project/databases/(default)", "structuredPipeline": {"pipeline": {"stages": [{"name": "database"}]}} }, + "sources/database/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "database", "options": {"foo": {"stringValue": "bar"}}} + ] + } + } + }, "sources/documents/nested": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -109,6 +166,20 @@ } } }, + "sources/documents/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "documents", + "args": [{"referenceValue": "/books/book1"}], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "sources/documents/references": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json index 43e8be71..71f23c2f 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json @@ -45,6 +45,29 @@ } } }, + "stages/add-fields/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "add_fields", + "args": [ + { + "mapValue": { + "fields": { + "copiedRating": {"fieldReferenceValue": "rating"} + } + } + } + ], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/add-fields/single": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -173,6 +196,32 @@ } } }, + "stages/aggregate/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": {"total": {"functionValue": {"name": "count"}}} + } + }, + { + "mapValue": { + "fields": {"genre": {"fieldReferenceValue": "genre"}} + } + } + ], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/aggregate/single": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -297,6 +346,27 @@ } } }, + "stages/distinct/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "distinct", + "args": [ + { + "mapValue": { + "fields": {"genre": {"fieldReferenceValue": "genre"}} + } + } + ], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/find-nearest/all-options": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -567,6 +637,46 @@ } } }, + "stages/find-nearest/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": { + "distance_field": {"fieldReferenceValue": "distance"}, + "extra": { + "mapValue": {"fields": {"flag": {"booleanValue": true}}} + }, + "limit": {"integerValue": "20"} + } + } + ] + } + } + }, "stages/find-nearest/vector-value": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -611,6 +721,21 @@ } } }, + "stages/limit/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "limit", + "args": [{"integerValue": "10"}], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/limit/zero": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -633,6 +758,21 @@ } } }, + "stages/offset/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "offset", + "args": [{"integerValue": "10"}], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/raw-stage/aggregate-params": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -666,6 +806,63 @@ } } }, + "stages/raw-stage/array-in-object-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "mapValue": { + "fields": { + "tags": { + "functionValue": { + "name": "array", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "classic"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/array-in-object-param-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "mapValue": { + "fields": { + "tags": { + "functionValue": { + "name": "array", + "args": [{"integerValue": "1"}, {"integerValue": "2"}] + } + } + } + } + } + ] + } + ] + } + } + }, "stages/raw-stage/array-param": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -690,109 +887,511 @@ } } }, - "stages/raw-stage/expression-params": { + "stages/raw-stage/array-param-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "arrayValue": { + "values": [ + {"integerValue": "1"}, + {"integerValue": "2"}, + {"integerValue": "3"} + ] + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/constant-in-object-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "mapValue": { + "fields": { + "meta": { + "mapValue": {"fields": {"lang": {"stringValue": "en"}}} + } + } + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/deeply-nested-object-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "mapValue": { + "fields": { + "a": { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "b"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "c"}, + {"fieldReferenceValue": "x"} + ] + } + }, + {"stringValue": "d"}, + { + "functionValue": { + "name": "array", + "args": [ + {"fieldReferenceValue": "y"}, + { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "e"}, + {"integerValue": "1"} + ] + } + } + ] + } + } + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/empty-object-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + {"name": "custom", "args": [{"mapValue": {}}]} + ] + } + } + }, + "stages/raw-stage/expression-params": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/literal-params": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + {"name": "limit", "args": [{"integerValue": "10"}]} + ] + } + } + }, + "stages/raw-stage/nested-array-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "arrayValue": { + "values": [ + {"integerValue": "1"}, + { + "arrayValue": { + "values": [ + {"integerValue": "2"}, + {"fieldReferenceValue": "x"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/nested-empty-object-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "mapValue": { + "fields": {"meta": {"functionValue": {"name": "map"}}} + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/nested-plain-object-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "meta": { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "lang"}, + {"fieldReferenceValue": "lang"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/nested-plain-object-param-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "mapValue": { + "fields": { + "meta": { + "functionValue": { + "name": "map", + "args": [ + {"stringValue": "lang"}, + {"stringValue": "en"}, + {"stringValue": "year"}, + {"integerValue": "2020"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/object-in-array-param": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "arrayValue": { + "values": [ + { + "mapValue": { + "fields": { + "a": {"fieldReferenceValue": "x"}, + "b": { + "mapValue": { + "fields": {"c": {"integerValue": "1"}} + } + } + } + } + } + ] + } + } + ] + } + ] + } + } + }, + "stages/raw-stage/options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": { + "distance_field": {"fieldReferenceValue": "distance"}, + "limit": {"integerValue": "10"} + } + } + ] + } + } + }, + "stages/raw-stage/options-dot-notation": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "options": { + "outer": { + "mapValue": {"fields": {"inner": {"stringValue": "value"}}} + } + } + } + ] + } + } + }, + "stages/raw-stage/options-dot-notation-backticks": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "options": { + "a": { + "mapValue": { + "fields": { + "`b": { + "mapValue": {"fields": {"c`": {"integerValue": "1"}}} + } + } + } + } + } + } + ] + } + } + }, + "stages/raw-stage/options-dot-notation-deep": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "options": { + "a": { + "mapValue": { + "fields": { + "b": { + "mapValue": {"fields": {"c": {"booleanValue": true}}} + } + } + } + } + } + } + ] + } + } + }, + "stages/raw-stage/options-dot-notation-merges-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "options": { + "outer": { + "mapValue": { + "fields": { + "a": {"integerValue": "1"}, + "b": {"integerValue": "2"} + } + } + } + } + } + ] + } + } + }, + "stages/raw-stage/options-dot-notation-replaces-scalar": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { "stages": [ {"name": "collection", "args": [{"referenceValue": "/books"}]}, { - "name": "where", - "args": [ - { - "functionValue": { - "name": "greater_than", - "args": [ - {"fieldReferenceValue": "rating"}, - {"integerValue": "4"} - ] - } - } - ] + "name": "custom", + "options": { + "outer": {"mapValue": {"fields": {"a": {"integerValue": "1"}}}} + } } ] } } }, - "stages/raw-stage/literal-params": { + "stages/raw-stage/options-dot-notation-siblings": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { "stages": [ {"name": "collection", "args": [{"referenceValue": "/books"}]}, - {"name": "limit", "args": [{"integerValue": "10"}]} + { + "name": "custom", + "options": { + "outer": { + "mapValue": { + "fields": { + "a": {"integerValue": "1"}, + "b": {"integerValue": "2"} + } + } + } + } + } ] } } }, - "stages/raw-stage/nested-plain-object-param": { + "stages/raw-stage/options-expression-values": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { "stages": [ {"name": "collection", "args": [{"referenceValue": "/books"}]}, { - "name": "select", - "args": [ - { + "name": "custom", + "options": { + "nested": { "mapValue": { - "fields": { - "meta": { - "functionValue": { - "name": "map", - "args": [ - {"stringValue": "lang"}, - {"fieldReferenceValue": "lang"} - ] - } - } - } + "fields": {"target": {"fieldReferenceValue": "y"}} } - } - ] + }, + "target": {"fieldReferenceValue": "x"} + } } ] } } }, - "stages/raw-stage/options": { + "stages/raw-stage/options-nested-object": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { "stages": [ {"name": "collection", "args": [{"referenceValue": "/books"}]}, { - "name": "find_nearest", - "args": [ - {"fieldReferenceValue": "embedding"}, - { + "name": "custom", + "options": { + "outer": { "mapValue": { "fields": { - "__type__": {"stringValue": "__vector__"}, - "value": { - "arrayValue": { - "values": [ - {"doubleValue": 0.1}, - {"doubleValue": 0.2}, - {"doubleValue": 0.3} - ] - } - } + "inner": {"stringValue": "value"}, + "n": {"integerValue": "1"} } } - }, - {"stringValue": "euclidean"} - ], - "options": { - "distance_field": {"fieldReferenceValue": "distance"}, - "limit": {"integerValue": "10"} + } } } ] } } }, - "stages/raw-stage/options-dot-notation": { + "stages/raw-stage/options-object-replaces-dot-notation": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { @@ -801,9 +1400,7 @@ { "name": "custom", "options": { - "outer": { - "mapValue": {"fields": {"inner": {"stringValue": "value"}}} - } + "outer": {"mapValue": {"fields": {"b": {"integerValue": "2"}}}} } } ] @@ -833,6 +1430,30 @@ } } }, + "stages/raw-stage/plain-object-param-literals": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "custom", + "args": [ + { + "mapValue": { + "fields": { + "a": {"integerValue": "1"}, + "b": {"stringValue": "x"}, + "c": {"nullValue": null} + } + } + } + ] + } + ] + } + } + }, "stages/raw-stage/string-params": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -900,6 +1521,21 @@ } } }, + "stages/remove-fields/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "remove_fields", + "args": [{"fieldReferenceValue": "title"}], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/replace-with/field": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -961,6 +1597,24 @@ } } }, + "stages/replace-with/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "replace_with", + "args": [ + {"fieldReferenceValue": "metadata"}, + {"stringValue": "full_replace"} + ], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/sample/documents": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -1003,6 +1657,21 @@ } } }, + "stages/sample/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sample", + "args": [{"integerValue": "10"}, {"stringValue": "documents"}], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/search/add-fields": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -1096,6 +1765,31 @@ } } }, + "stages/search/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "search", + "options": { + "extra": { + "mapValue": {"fields": {"flag": {"booleanValue": true}}} + }, + "limit": {"integerValue": "20"}, + "query": { + "functionValue": { + "name": "document_matches", + "args": [{"stringValue": "breakfast"}] + } + } + } + } + ] + } + } + }, "stages/search/retrieval-depth-language": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -1493,6 +2187,30 @@ } } }, + "stages/sort/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + } + ], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/sort/top-level-expression": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -1709,6 +2427,32 @@ } } }, + "stages/union/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "union", + "args": [ + { + "pipelineValue": { + "stages": [ + { + "name": "collection", + "args": [{"referenceValue": "/magazines"}] + } + ] + } + } + ], + "options": {"foo": {"stringValue": "bar"}} + } + ] + } + } + }, "stages/unnest/aliased-field": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -1805,6 +2549,27 @@ } } }, + "stages/unnest/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "unnest", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "tag"} + ], + "options": { + "foo": {"stringValue": "bar"}, + "index_field": {"fieldReferenceValue": "position"} + } + } + ] + } + } + }, "stages/update/plain": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -1896,6 +2661,36 @@ ] } } + }, + "stages/where/raw-options": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ], + "options": { + "foo": {"stringValue": "bar"}, + "outer": { + "mapValue": {"fields": {"inner": {"booleanValue": true}}} + } + } + } + ] + } + } } } } diff --git a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart index 11632f5c..43942441 100644 --- a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart @@ -231,9 +231,6 @@ const _pendingFixes = { 'PipelineSource.collectionGroup#forceIndex': 'Node collectionGroup({collectionId, forceIndex}) can force an index; ' 'Dart cannot.', - 'StageOptions#rawOptions': - 'Every Node stage takes rawOptions; Dart stages only take them through ' - 'rawStage(options:).', // Dart-only functions with no Node counterpart. 'PipelineFunctions.log': 'Node has no log function; check the backend supports `log`, or drop.', diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index 23c9c841..ee7b1c22 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -117,31 +117,12 @@ const _pendingFixes = { 'As equalAny: nested literal arrayValue vs Node array(...) function.', 'functions/arrayContainsAny/static-field-name-list-nested': 'As equalAny: nested literal arrayValue vs Node array(...) function.', - 'stages/raw-stage/nested-plain-object-param': - 'rawStage sends a nested map param as a literal mapValue holding a ' - 'field reference; Node sends a map(...) function for the nested map.', - // Dotted option keys. - 'options/raw-options/dot-notation': - "execute(rawOptions: {'explain_options.mode': ...}) sends the dotted " - 'key verbatim; Node expands it into explain_options.mode.', - 'options/raw-options/merge-into-explain': - 'A dotted rawOptions key is sent verbatim instead of being merged into ' - 'the explain_options map built from explain:.', - 'stages/raw-stage/options-dot-notation': - "rawStage(options: {'outer.inner': ...}) sends the dotted key " - 'verbatim; Node expands it into a nested map.', }; /// Node APIs this SDK has no way to express; there is no Dart case for them. const _dartApiGaps = { - 'sources/collection/force-index': - 'PipelineSource.collection has no forceIndex option.', - 'sources/collection-group/force-index': - 'PipelineSource.collectionGroup has no forceIndex option.', 'sources/subcollection/as-array-expression': 'No subcollection() source and no Pipeline.toArrayExpression().', - 'stages/select/raw-options': - 'Typed stages take no per-stage rawOptions; only rawStage does.', 'stages/define/plain': 'No Pipeline.define (let) stage.', 'stages/delete/plain': 'No Pipeline.delete stage.', 'stages/update/plain': 'No Pipeline.update stage.', @@ -620,7 +601,48 @@ void _registerSources(_Registry r) { 'sources/collection-group/id', (db) => db.pipeline().collectionGroup('reviews'), ); + // Dart has no typed forceIndex (a pending fix in the API parity test), but + // rawOptions sends the same option. + r.pipeline( + 'sources/collection/force-index', + (db) => db.pipeline().collection( + 'books', + rawOptions: const {'force_index': 'my_index'}, + ), + ); + r.pipeline( + 'sources/collection/raw-options', + (db) => db.pipeline().collection( + 'books', + rawOptions: const {'foo': 'bar', 'outer.inner': 1}, + ), + ); + r.pipeline( + 'sources/collection/reference-raw-options', + (db) => db.pipeline().collectionReference( + db.collection('books'), + rawOptions: const {'foo': 'bar'}, + ), + ); + r.pipeline( + 'sources/collection-group/force-index', + (db) => db.pipeline().collectionGroup( + 'reviews', + rawOptions: const {'force_index': 'my_index'}, + ), + ); + r.pipeline( + 'sources/collection-group/raw-options', + (db) => db.pipeline().collectionGroup( + 'reviews', + rawOptions: const {'foo': 'bar'}, + ), + ); r.pipeline('sources/database/plain', (db) => db.pipeline().database()); + r.pipeline( + 'sources/database/raw-options', + (db) => db.pipeline().database(rawOptions: const {'foo': 'bar'}), + ); // Dart only takes references; Node turns its paths into references. r.pipeline( 'sources/documents/paths', @@ -636,6 +658,13 @@ void _registerSources(_Registry r) { 'sources/documents/nested', (db) => db.pipeline().documents([db.doc('authors/author1/books/book1')]), ); + r.pipeline( + 'sources/documents/raw-options', + (db) => db.pipeline().documents( + [db.doc('books/book1')], + rawOptions: const {'foo': 'bar'}, + ), + ); } void _registerQueries(_Registry r) { @@ -902,6 +931,13 @@ void _registerStages(_Registry r) { .where(field('rating').greaterThan(4)) .where(field('genre').equal('Fantasy')), ); + stage( + 'where/raw-options', + (db) => _books(db).where( + field('rating').greaterThan(4), + rawOptions: const {'foo': 'bar', 'outer.inner': true}, + ), + ); stage('select/field-names', (db) => _books(db).select(['title', 'author'])); stage( @@ -933,6 +969,10 @@ void _registerStages(_Registry r) { 'select/options-object', (db) => _books(db).select(['title', field('rating').as('score')]), ); + stage( + 'select/raw-options', + (db) => _books(db).select(['title'], rawOptions: const {'foo': 'bar'}), + ); stage( 'add-fields/single', @@ -951,6 +991,13 @@ void _registerStages(_Registry r) { 'add-fields/field', (db) => _books(db).addFields([field('rating').as('rating')]), ); + stage( + 'add-fields/raw-options', + (db) => _books(db).addFields( + [field('rating').as('copiedRating')], + rawOptions: const {'foo': 'bar'}, + ), + ); stage( 'remove-fields/field-names', @@ -964,6 +1011,11 @@ void _registerStages(_Registry r) { 'remove-fields/nested', (db) => _books(db).removeFields(['metadata.lang']), ); + stage( + 'remove-fields/raw-options', + (db) => + _books(db).removeFields(['title'], rawOptions: const {'foo': 'bar'}), + ); stage( 'sort/ascending-method', @@ -994,10 +1046,24 @@ void _registerStages(_Registry r) { 'sort/expression-method', (db) => _books(db).sort([field('title').charLength().descending()]), ); + stage( + 'sort/raw-options', + (db) => _books( + db, + ).sort([field('rating').descending()], rawOptions: const {'foo': 'bar'}), + ); stage('offset/plain', (db) => _books(db).offset(10)); + stage( + 'offset/raw-options', + (db) => _books(db).offset(10, rawOptions: const {'foo': 'bar'}), + ); stage('limit/plain', (db) => _books(db).limit(10)); stage('limit/zero', (db) => _books(db).limit(0)); + stage( + 'limit/raw-options', + (db) => _books(db).limit(10, rawOptions: const {'foo': 'bar'}), + ); stage( 'distinct/field-names', @@ -1009,6 +1075,10 @@ void _registerStages(_Registry r) { (db) => _books(db).distinct([field('genre').toLowerCase().as('lowerGenre')]), ); + stage( + 'distinct/raw-options', + (db) => _books(db).distinct(['genre'], rawOptions: const {'foo': 'bar'}), + ); stage( 'aggregate/single', @@ -1041,6 +1111,14 @@ void _registerStages(_Registry r) { ], ), ); + stage( + 'aggregate/raw-options', + (db) => _books(db).aggregate( + [PipelineFunctions.countAll().as('total')], + groups: ['genre'], + rawOptions: const {'foo': 'bar'}, + ), + ); Pipeline findNearest( Firestore db, { @@ -1049,6 +1127,7 @@ void _registerStages(_Registry r) { DistanceMeasure distanceMeasure = DistanceMeasure.euclidean, int? limit, String? distanceResultField, + Map rawOptions = const {}, }) { return _books(db).findNearest( vectorField: vectorField, @@ -1056,6 +1135,7 @@ void _registerStages(_Registry r) { distanceMeasure: distanceMeasure, limit: limit, distanceResultField: distanceResultField, + rawOptions: rawOptions, ); } @@ -1089,6 +1169,15 @@ void _registerStages(_Registry r) { 'find-nearest/all-options', (db) => findNearest(db, limit: 10, distanceResultField: 'distance'), ); + stage( + 'find-nearest/raw-options', + (db) => findNearest( + db, + limit: 10, + distanceResultField: 'distance', + rawOptions: const {'limit': 20, 'extra.flag': true}, + ), + ); stage('replace-with/field-name', (db) => _books(db).replaceWith('metadata')); stage( @@ -1106,10 +1195,19 @@ void _registerStages(_Registry r) { ]), ), ); + stage( + 'replace-with/raw-options', + (db) => + _books(db).replaceWith('metadata', rawOptions: const {'foo': 'bar'}), + ); stage('sample/documents', (db) => _books(db).sample(documents: 10)); stage('sample/documents-option', (db) => _books(db).sample(documents: 10)); stage('sample/percentage', (db) => _books(db).sample(percentage: 0.25)); + stage( + 'sample/raw-options', + (db) => _books(db).sample(documents: 10, rawOptions: const {'foo': 'bar'}), + ); stage( 'union/plain', @@ -1127,6 +1225,13 @@ void _registerStages(_Registry r) { .select(['title']), ), ); + stage( + 'union/raw-options', + (db) => _books(db).union( + db.pipeline().collection('magazines'), + rawOptions: const {'foo': 'bar'}, + ), + ); stage('unnest/field', (db) => _books(db).unnest(field('tags')), [ (db) => _books(db).unnest('tags'), @@ -1149,6 +1254,14 @@ void _registerStages(_Registry r) { 'unnest/options-object', (db) => _books(db).unnest(field('tags').as('tag'), indexField: 'idx'), ); + stage( + 'unnest/raw-options', + (db) => _books(db).unnest( + field('tags').as('tag'), + indexField: 'idx', + rawOptions: {'index_field': field('position'), 'foo': 'bar'}, + ), + ); stage( 'raw-stage/expression-params', @@ -1173,12 +1286,106 @@ void _registerStages(_Registry r) { }, ]), ); + // A map param is a literal map whose values are converted like a value + // position: a nested map or list becomes a map(...) or array(...) function, + // with or without expressions in it. + stage( + 'raw-stage/plain-object-param-literals', + (db) => _books(db).rawStage('custom', [ + {'a': 1, 'b': 'x', 'c': null}, + ]), + ); + stage( + 'raw-stage/empty-object-param', + (db) => _books(db).rawStage('custom', [{}]), + ); + stage( + 'raw-stage/nested-plain-object-param-literals', + (db) => _books(db).rawStage('custom', [ + { + 'meta': {'lang': 'en', 'year': 2020}, + }, + ]), + ); + stage( + 'raw-stage/nested-empty-object-param', + (db) => _books(db).rawStage('custom', [ + {'meta': {}}, + ]), + ); + stage( + 'raw-stage/array-in-object-param', + (db) => _books(db).rawStage('custom', [ + { + 'tags': [field('genre'), 'classic'], + }, + ]), + ); + stage( + 'raw-stage/array-in-object-param-literals', + (db) => _books(db).rawStage('custom', [ + { + 'tags': [1, 2], + }, + ]), + ); + stage( + 'raw-stage/deeply-nested-object-param', + (db) => _books(db).rawStage('custom', [ + { + 'a': { + 'b': {'c': field('x')}, + 'd': [ + field('y'), + {'e': 1}, + ], + }, + }, + ]), + ); + // constant() keeps a nested map literal. + stage( + 'raw-stage/constant-in-object-param', + (db) => _books(db).rawStage('custom', [ + { + 'meta': constant({'lang': 'en'}), + }, + ]), + ); stage( 'raw-stage/array-param', (db) => _books(db).rawStage('custom', [ [1, 'a', field('title')], ]), ); + // Any other param, a list included, is sent as is: a literal value all the + // way down. + stage( + 'raw-stage/array-param-literals', + (db) => _books(db).rawStage('custom', [ + [1, 2, 3], + ]), + ); + stage( + 'raw-stage/nested-array-param', + (db) => _books(db).rawStage('custom', [ + [ + 1, + [2, field('x')], + ], + ]), + ); + stage( + 'raw-stage/object-in-array-param', + (db) => _books(db).rawStage('custom', [ + [ + { + 'a': field('x'), + 'b': {'c': 1}, + }, + ], + ]), + ); stage( 'raw-stage/aggregate-params', (db) => _books(db).rawStage('aggregate', [ @@ -1202,6 +1409,39 @@ void _registerStages(_Registry r) { (db) => _books(db).rawStage('custom', [], options: {'outer.inner': 'value'}), ); + void rawOptions(String id, Map options) { + stage( + 'raw-stage/$id', + (db) => _books(db).rawStage('custom', const [], options: options), + ); + } + + rawOptions('options-nested-object', { + 'outer': {'inner': 'value', 'n': 1}, + }); + rawOptions('options-expression-values', { + 'target': field('x'), + 'nested': {'target': field('y')}, + }); + rawOptions('options-dot-notation-siblings', {'outer.a': 1, 'outer.b': 2}); + rawOptions('options-dot-notation-deep', {'a.b.c': true}); + // Keys apply in order: a dotted key merges into an earlier map, a map + // replaces what earlier dotted keys built, and a dotted key replaces an + // earlier value that is not a map. + rawOptions('options-dot-notation-merges-object', { + 'outer': {'a': 1}, + 'outer.b': 2, + }); + rawOptions('options-object-replaces-dot-notation', { + 'outer.a': 1, + 'outer': {'b': 2}, + }); + rawOptions('options-dot-notation-replaces-scalar', { + 'outer': 'x', + 'outer.a': 1, + }); + // Segments are not unescaped: backticks are part of the name. + rawOptions('options-dot-notation-backticks', {'a.`b.c`': 1}); // Dart's search stage takes the backend options as-is. final breakfast = documentMatches('breakfast'); @@ -1243,6 +1483,13 @@ void _registerStages(_Registry r) { 'add_fields': {'searchScore': score()}, }), ); + stage( + 'search/raw-options', + (db) => _books(db).search( + {'query': breakfast, 'limit': 10}, + rawOptions: const {'limit': 20, 'extra.flag': true}, + ), + ); } void _registerOptions(_Registry r) { @@ -1323,6 +1570,44 @@ void _registerOptions(_Registry r) { rawOptions: {'explain_options.output_format': 'text'}, ), ); + execute( + 'raw-options/dot-notation-overrides-known', + (p) => p.execute( + explain: const PipelineExplainOptions(mode: analyze, outputFormat: text), + rawOptions: {'explain_options.mode': 'execute'}, + ), + ); + execute( + 'raw-options/object-replaces-known', + (p) => p.execute( + explain: const PipelineExplainOptions(mode: analyze, outputFormat: text), + rawOptions: { + 'explain_options': {'mode': 'execute'}, + }, + ), + ); + execute( + 'raw-options/dot-notation-into-empty-explain', + (p) => p.execute( + explain: const PipelineExplainOptions(), + rawOptions: {'explain_options.mode': 'analyze'}, + ), + ); + execute( + 'raw-options/dot-notation-replaces-known-scalar', + (p) => p.execute( + indexMode: PipelineIndexMode.recommended, + rawOptions: {'index_mode.mode': 'custom'}, + ), + ); + execute( + 'raw-options/dot-notation-siblings', + (p) => p.execute(rawOptions: {'outer.a': 1, 'outer.b': 'two'}), + ); + execute( + 'raw-options/dot-notation-deep', + (p) => p.execute(rawOptions: {'a.b.c': true}), + ); } // ----------------------------------------------------------------------------- diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 08d553e4..a5655b76 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -1340,6 +1340,366 @@ void main() { }); }); + // Mirrors the Node SDK's `OptionsUtil` and `rawStage`. + group('raw options and raw stages', () { + final requests = []; + + setUp(() { + requests.clear(); + 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: (request) { + requests.add(request); + return Stream.value( + firestore_v1.ExecutePipelineResponse( + transaction: Uint8List.fromList([9, 9]), + ), + ); + }, + ); + + return callback(api, _projectId); + }); + }); + + Pipeline base() => firestore.pipeline().collection('books'); + + Map json(Map options) { + return { + for (final MapEntry(:key, :value) in options.entries) + key: value.toJson(), + }; + } + + Map executeOptions() { + return json(requests.single.structuredPipeline!.options); + } + + List stages() { + return requests.single.structuredPipeline!.pipeline!.stages; + } + + Map string(String value) => {'stringValue': value}; + + Map map(Map fields) { + return { + 'mapValue': {'fields': fields}, + }; + } + + test( + 'execute merges dotted rawOptions keys into typed options', + () async { + await base().execute( + indexMode: PipelineIndexMode.recommended, + explain: const PipelineExplainOptions( + mode: PipelineExplainMode.analyze, + ), + rawOptions: const { + 'explain_options.output_format': 'json', + 'index_mode': 'custom', + 'a.b.c': 'deep', + }, + ); + + expect(executeOptions(), { + // A single map: the dotted key lands next to the typed mode. + 'explain_options': map({ + 'mode': string('analyze'), + 'output_format': string('json'), + }), + 'index_mode': string('custom'), + 'a': map({ + 'b': map({'c': string('deep')}), + }), + }); + }, + ); + + test( + 'a rawOptions key without a dot replaces the typed option', + () async { + await base().execute( + explain: const PipelineExplainOptions( + mode: PipelineExplainMode.analyze, + outputFormat: PipelineExplainOutputFormat.text, + ), + rawOptions: const { + 'explain_options': {'mode': 'execute'}, + }, + ); + + expect(executeOptions(), { + 'explain_options': map({'mode': string('execute')}), + }); + }, + ); + + test( + 'Transaction.executePipeline merges dotted rawOptions keys', + () async { + await firestore.runTransaction( + (transaction) => transaction.executePipeline( + base(), + explain: const PipelineExplainOptions( + mode: PipelineExplainMode.analyze, + ), + rawOptions: const {'explain_options.output_format': 'text'}, + ), + transactionOptions: ReadOnlyTransactionOptions(), + ); + + expect(executeOptions(), { + 'explain_options': map({ + 'mode': string('analyze'), + 'output_format': string('text'), + }), + }); + }, + ); + + test('raw option keys apply in order', () async { + await base() + .rawStage( + 'custom', + const [], + options: const { + // A dotted key merges into an earlier map... + 'merged': {'a': 1}, + 'merged.b': 'two', + // ...a key without a dot replaces what came before... + 'replaced.a': 1, + 'replaced': 'value', + // ...and a dotted key replaces a value that is not a map. + 'promoted': 'value', + 'promoted.a': true, + // Backticks are not unescaped. + 'a.`b`': 1, + }, + ) + .execute(); + + expect(json(stages().last.options), { + 'merged': map({ + 'a': {'integerValue': '1'}, + 'b': string('two'), + }), + 'replaced': string('value'), + 'promoted': map({ + 'a': {'booleanValue': true}, + }), + 'a': map({ + '`b`': {'integerValue': '1'}, + }), + }); + }); + + test('every stage takes rawOptions', () async { + const raw = {'foo': 'bar', 'outer.inner': 1}; + final other = firestore.pipeline().collection('magazines'); + + await firestore + .pipeline() + .collection('books', rawOptions: raw) + .where(field('rating').greaterThan(1), rawOptions: raw) + .select(['title', 'rating', 'tags'], rawOptions: raw) + .addFields([field('rating').as('score')], rawOptions: raw) + .removeFields(['score'], rawOptions: raw) + .sort([field('title').ascending()], rawOptions: raw) + .offset(1, rawOptions: raw) + .limit(10, rawOptions: raw) + .distinct(['title', 'tags'], rawOptions: raw) + .aggregate( + [PipelineFunctions.countAll().as('total')], + groups: ['title'], + rawOptions: raw, + ) + .replaceWith('title', rawOptions: raw) + .union(other, rawOptions: raw) + .sample(documents: 5, rawOptions: raw) + .unnest('tags', rawOptions: raw) + .findNearest( + vectorField: 'embedding', + queryVector: const [1, 2], + distanceMeasure: DistanceMeasure.cosine, + rawOptions: raw, + ) + .search({'query': documentMatches('dart')}, rawOptions: raw) + .execute(); + + final expected = { + 'foo': string('bar'), + 'outer': map({ + 'inner': {'integerValue': '1'}, + }), + }; + expect(stages().map((stage) => stage.name), [ + 'collection', + 'where', + 'select', + 'add_fields', + 'remove_fields', + 'sort', + 'offset', + 'limit', + 'distinct', + 'aggregate', + 'replace_with', + 'union', + 'sample', + 'unnest', + 'find_nearest', + 'search', + ]); + for (final stage in stages()) { + final options = json(stage.options); + if (stage.name == 'search') options.remove('query'); + expect(options, expected, reason: stage.name); + } + // The nested Pipeline keeps its own, empty, options. + final union = stages().firstWhere((stage) => stage.name == 'union'); + expect(union.args.single.pipelineValue!.stages.single.options, isEmpty); + }); + + test('every source takes rawOptions', () async { + const raw = {'foo': 'bar'}; + final source = firestore.pipeline(); + + for (final pipeline in [ + source.collection('books', rawOptions: raw), + source.collectionReference( + firestore.collection('books'), + rawOptions: raw, + ), + source.collectionGroup('books', rawOptions: raw), + source.database(rawOptions: raw), + source.documents([firestore.doc('books/b')], rawOptions: raw), + ]) { + requests.clear(); + await pipeline.execute(); + expect(json(stages().single.options), {'foo': string('bar')}); + } + }); + + test('rawOptions override typed stage options', () async { + await base() + .findNearest( + vectorField: 'embedding', + queryVector: const [1, 2], + distanceMeasure: DistanceMeasure.euclidean, + limit: 10, + distanceResultField: 'distance', + rawOptions: const {'limit': 20, 'extra.flag': true}, + ) + .unnest( + 'tags', + indexField: 'index', + rawOptions: {'index_field': field('position')}, + ) + .search( + {'query': documentMatches('dart'), 'limit': 10}, + rawOptions: const {'limit': 20}, + ) + .execute(); + + final [_, nearest, unnest, search] = stages(); + expect(json(nearest.options), { + 'limit': {'integerValue': '20'}, + 'distance_field': {'fieldReferenceValue': 'distance'}, + 'extra': map({ + 'flag': {'booleanValue': true}, + }), + }); + expect(json(unnest.options), { + 'index_field': {'fieldReferenceValue': 'position'}, + }); + expect(search.options['limit']!.integerValue, 20); + }); + + test('rejects raw option keys with an empty segment', () async { + for (final key in ['', '.', 'a.', '.a', 'a..b']) { + final options = {key: 1}; + final invalid = throwsA( + isA().having((e) => e.invalidValue, 'key', key), + ); + + expect( + () => base().rawStage('custom', [], options: options), + invalid, + ); + expect(() => base().limit(1, rawOptions: options), invalid); + expect( + () => firestore.pipeline().database(rawOptions: options), + invalid, + ); + await expectLater(base().execute(rawOptions: options), invalid); + await expectLater( + firestore.runTransaction( + (transaction) => + transaction.executePipeline(base(), rawOptions: options), + transactionOptions: ReadOnlyTransactionOptions(), + ), + invalid, + ); + } + expect(requests, isEmpty); + }); + + test('rawStage converts collections nested in a map argument', () async { + await base().rawStage('custom', [ + { + 'field': field('f'), + 'literal': 1, + 'nestedMap': {'lang': field('lang')}, + 'nestedList': [field('a'), 1], + 'literalMap': constant({'x': 1}), + }, + // A list argument stays a literal value, as in Node. + [ + field('b'), + {'c': 1}, + ], + ]).execute(); + + final [mapArg, listArg] = stages().last.args; + final fields = mapArg.mapValue!.fields; + expect(fields['field']!.fieldReferenceValue, 'f'); + expect(fields['literal']!.integerValue, 1); + + final nestedMap = fields['nestedMap']!.functionValue!; + expect(nestedMap.name, 'map'); + expect(nestedMap.args.map((arg) => arg.toJson()), [ + string('lang'), + {'fieldReferenceValue': 'lang'}, + ]); + + final nestedList = fields['nestedList']!.functionValue!; + expect(nestedList.name, 'array'); + expect(nestedList.args.map((arg) => arg.toJson()), [ + {'fieldReferenceValue': 'a'}, + {'integerValue': '1'}, + ]); + + expect(fields['literalMap']!.mapValue!.fields['x']!.integerValue, 1); + + expect(listArg.arrayValue!.values.map((value) => value.toJson()), [ + {'fieldReferenceValue': 'b'}, + map({ + 'c': {'integerValue': '1'}, + }), + ]); + }); + }); + // Mirrors the Node SDK's `selectablesToObject` / `aliasedAggregateToMap`, // which throw rather than let a later entry overwrite an earlier one. group('duplicate aliases or fields', () { diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js index 7b335c99..49a57249 100644 --- a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js +++ b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js @@ -126,6 +126,18 @@ module.exports = function buildCases(sdk, db) { add('sources/collection/force-index', () => db.pipeline().collection({collection: 'books', forceIndex: 'my_index'}), ); + add('sources/collection/raw-options', () => + db.pipeline().collection({ + collection: 'books', + rawOptions: {foo: 'bar', 'outer.inner': 1}, + }), + ); + add('sources/collection/reference-raw-options', () => + db.pipeline().collection({ + collection: db.collection('books'), + rawOptions: {foo: 'bar'}, + }), + ); add('sources/collection-group/id', () => db.pipeline().collectionGroup('reviews'), ); @@ -134,7 +146,16 @@ module.exports = function buildCases(sdk, db) { .pipeline() .collectionGroup({collectionId: 'reviews', forceIndex: 'my_index'}), ); + add('sources/collection-group/raw-options', () => + db.pipeline().collectionGroup({ + collectionId: 'reviews', + rawOptions: {foo: 'bar'}, + }), + ); add('sources/database/plain', () => db.pipeline().database()); + add('sources/database/raw-options', () => + db.pipeline().database({rawOptions: {foo: 'bar'}}), + ); add('sources/documents/paths', () => db.pipeline().documents(['books/book1', 'books/book2']), ); @@ -144,6 +165,12 @@ module.exports = function buildCases(sdk, db) { add('sources/documents/nested', () => db.pipeline().documents([db.doc('authors/author1/books/book1')]), ); + add('sources/documents/raw-options', () => + db.pipeline().documents({ + docs: [db.doc('books/book1')], + rawOptions: {foo: 'bar'}, + }), + ); add('sources/subcollection/as-array-expression', () => db .pipeline() @@ -307,6 +334,12 @@ module.exports = function buildCases(sdk, db) { .where(field('rating').greaterThan(4)) .where(field('genre').equal('Fantasy')), ); + stage('where/raw-options', () => + books().where({ + condition: field('rating').greaterThan(4), + rawOptions: {foo: 'bar', 'outer.inner': true}, + }), + ); stage('select/field-names', () => books().select('title', 'author')); stage('select/fields', () => @@ -344,6 +377,12 @@ module.exports = function buildCases(sdk, db) { ), ); stage('add-fields/field', () => books().addFields(field('rating'))); + stage('add-fields/raw-options', () => + books().addFields({ + fields: [field('rating').as('copiedRating')], + rawOptions: {foo: 'bar'}, + }), + ); stage('remove-fields/field-names', () => books().removeFields('title', 'author'), @@ -352,6 +391,9 @@ module.exports = function buildCases(sdk, db) { books().removeFields(field('title'), field('author')), ); stage('remove-fields/nested', () => books().removeFields('metadata.lang')); + stage('remove-fields/raw-options', () => + books().removeFields({fields: ['title'], rawOptions: {foo: 'bar'}}), + ); stage('sort/ascending-method', () => books().sort(field('rating').ascending())); stage('sort/descending-method', () => @@ -372,16 +414,31 @@ module.exports = function buildCases(sdk, db) { stage('sort/expression-method', () => books().sort(field('title').charLength().descending()), ); + stage('sort/raw-options', () => + books().sort({ + orderings: [field('rating').descending()], + rawOptions: {foo: 'bar'}, + }), + ); stage('offset/plain', () => books().offset(10)); + stage('offset/raw-options', () => + books().offset({offset: 10, rawOptions: {foo: 'bar'}}), + ); stage('limit/plain', () => books().limit(10)); stage('limit/zero', () => books().limit(0)); + stage('limit/raw-options', () => + books().limit({limit: 10, rawOptions: {foo: 'bar'}}), + ); stage('distinct/field-names', () => books().distinct('genre', 'author')); stage('distinct/fields', () => books().distinct(field('genre'))); stage('distinct/aliased', () => books().distinct(field('genre').toLower().as('lowerGenre')), ); + stage('distinct/raw-options', () => + books().distinct({groups: ['genre'], rawOptions: {foo: 'bar'}}), + ); stage('aggregate/single', () => books().aggregate(P.countAll().as('total'))); stage('aggregate/multiple', () => @@ -409,6 +466,13 @@ module.exports = function buildCases(sdk, db) { ], }), ); + stage('aggregate/raw-options', () => + books().aggregate({ + accumulators: [P.countAll().as('total')], + groups: ['genre'], + rawOptions: {foo: 'bar'}, + }), + ); const findNearest = options => books().findNearest({ @@ -434,6 +498,14 @@ module.exports = function buildCases(sdk, db) { stage('find-nearest/all-options', () => findNearest({limit: 10, distanceField: 'distance'}), ); + // rawOptions override the typed options they share a name with. + stage('find-nearest/raw-options', () => + findNearest({ + limit: 10, + distanceField: 'distance', + rawOptions: {limit: 20, 'extra.flag': true}, + }), + ); stage('replace-with/field-name', () => books().replaceWith('metadata')); stage('replace-with/field', () => books().replaceWith(field('metadata'))); @@ -442,10 +514,16 @@ module.exports = function buildCases(sdk, db) { P.map({title: field('title'), rating: field('rating')}), ), ); + stage('replace-with/raw-options', () => + books().replaceWith({map: 'metadata', rawOptions: {foo: 'bar'}}), + ); stage('sample/documents', () => books().sample(10)); stage('sample/documents-option', () => books().sample({documents: 10})); stage('sample/percentage', () => books().sample({percentage: 0.25})); + stage('sample/raw-options', () => + books().sample({documents: 10, rawOptions: {foo: 'bar'}}), + ); stage('union/plain', () => books().union(db.pipeline().collection('magazines')), @@ -461,6 +539,12 @@ module.exports = function buildCases(sdk, db) { .select('title'), ), ); + stage('union/raw-options', () => + books().union({ + other: db.pipeline().collection('magazines'), + rawOptions: {foo: 'bar'}, + }), + ); stage('unnest/field', () => books().unnest(field('tags'))); stage('unnest/aliased-field', () => books().unnest(field('tags').as('tag'))); @@ -473,6 +557,13 @@ module.exports = function buildCases(sdk, db) { stage('unnest/options-object', () => books().unnest({selectable: field('tags').as('tag'), indexField: 'idx'}), ); + stage('unnest/raw-options', () => + books().unnest({ + selectable: field('tags').as('tag'), + indexField: 'idx', + rawOptions: {index_field: field('position'), foo: 'bar'}, + }), + ); stage('raw-stage/expression-params', () => books().rawStage('where', [field('rating').greaterThan(4)]), @@ -487,9 +578,47 @@ module.exports = function buildCases(sdk, db) { stage('raw-stage/nested-plain-object-param', () => books().rawStage('select', [{meta: {lang: field('lang')}}]), ); + // A plain object param is a literal map whose values go through + // valueToDefaultExpr: a nested object or array becomes a map(...) or + // array(...) function, with or without expressions in it. + stage('raw-stage/plain-object-param-literals', () => + books().rawStage('custom', [{a: 1, b: 'x', c: null}]), + ); + stage('raw-stage/empty-object-param', () => books().rawStage('custom', [{}])); + stage('raw-stage/nested-plain-object-param-literals', () => + books().rawStage('custom', [{meta: {lang: 'en', year: 2020}}]), + ); + stage('raw-stage/nested-empty-object-param', () => + books().rawStage('custom', [{meta: {}}]), + ); + stage('raw-stage/array-in-object-param', () => + books().rawStage('custom', [{tags: [field('genre'), 'classic']}]), + ); + stage('raw-stage/array-in-object-param-literals', () => + books().rawStage('custom', [{tags: [1, 2]}]), + ); + stage('raw-stage/deeply-nested-object-param', () => + books().rawStage('custom', [ + {a: {b: {c: field('x')}, d: [field('y'), {e: 1}]}}, + ]), + ); + stage('raw-stage/constant-in-object-param', () => + books().rawStage('custom', [{meta: constant({lang: 'en'})}]), + ); stage('raw-stage/array-param', () => books().rawStage('custom', [[1, 'a', field('title')]]), ); + // Any other param, an array included, is a constant: a literal value all + // the way down. + stage('raw-stage/array-param-literals', () => + books().rawStage('custom', [[1, 2, 3]]), + ); + stage('raw-stage/nested-array-param', () => + books().rawStage('custom', [[1, [2, field('x')]]]), + ); + stage('raw-stage/object-in-array-param', () => + books().rawStage('custom', [[{a: field('x'), b: {c: 1}}]]), + ); stage('raw-stage/aggregate-params', () => books().rawStage('aggregate', [ {total: P.countAll(), best: P.maximum('rating')}, @@ -506,6 +635,37 @@ module.exports = function buildCases(sdk, db) { stage('raw-stage/options-dot-notation', () => books().rawStage('custom', [], {'outer.inner': 'value'}), ); + stage('raw-stage/options-nested-object', () => + books().rawStage('custom', [], {outer: {inner: 'value', n: 1}}), + ); + stage('raw-stage/options-expression-values', () => + books().rawStage('custom', [], { + target: field('x'), + nested: {target: field('y')}, + }), + ); + stage('raw-stage/options-dot-notation-siblings', () => + books().rawStage('custom', [], {'outer.a': 1, 'outer.b': 2}), + ); + stage('raw-stage/options-dot-notation-deep', () => + books().rawStage('custom', [], {'a.b.c': true}), + ); + // Keys apply in order: a dotted key merges into an earlier object, an + // object replaces what earlier dotted keys built, and a dotted key + // replaces an earlier value that is not a map. + stage('raw-stage/options-dot-notation-merges-object', () => + books().rawStage('custom', [], {outer: {a: 1}, 'outer.b': 2}), + ); + stage('raw-stage/options-object-replaces-dot-notation', () => + books().rawStage('custom', [], {'outer.a': 1, outer: {b: 2}}), + ); + stage('raw-stage/options-dot-notation-replaces-scalar', () => + books().rawStage('custom', [], {outer: 'x', 'outer.a': 1}), + ); + // Segments are not unescaped: backticks are part of the name. + stage('raw-stage/options-dot-notation-backticks', () => + books().rawStage('custom', [], {'a.`b.c`': 1}), + ); stage('search/query-string', () => books().search({query: 'breakfast'})); stage('search/query-expression', () => @@ -536,6 +696,13 @@ module.exports = function buildCases(sdk, db) { addFields: [P.score().as('searchScore')], }), ); + stage('search/raw-options', () => + books().search({ + query: 'breakfast', + limit: 10, + rawOptions: {limit: 20, 'extra.flag': true}, + }), + ); stage('define/plain', () => books().define(field('rating').as('currentRating')), @@ -590,6 +757,26 @@ module.exports = function buildCases(sdk, db) { explainOptions: {mode: 'analyze'}, rawOptions: {'explain_options.output_format': 'text'}, }); + execute('raw-options/dot-notation-overrides-known', { + explainOptions: {mode: 'analyze', outputFormat: 'text'}, + rawOptions: {'explain_options.mode': 'execute'}, + }); + execute('raw-options/object-replaces-known', { + explainOptions: {mode: 'analyze', outputFormat: 'text'}, + rawOptions: {explain_options: {mode: 'execute'}}, + }); + execute('raw-options/dot-notation-into-empty-explain', { + explainOptions: {}, + rawOptions: {'explain_options.mode': 'analyze'}, + }); + execute('raw-options/dot-notation-replaces-known-scalar', { + indexMode: 'recommended', + rawOptions: {'index_mode.mode': 'custom'}, + }); + execute('raw-options/dot-notation-siblings', { + rawOptions: {'outer.a': 1, 'outer.b': 'two'}, + }); + execute('raw-options/dot-notation-deep', {rawOptions: {'a.b.c': true}}); // --------------------------------------------------------------------------- // Values: constants, fields, variables and plain values in value positions From 078d0d8fe8566554ae1aff04eb29065aeaf5a63c Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:40:49 +0200 Subject: [PATCH 11/29] fix(firestore): escape backticks in quoted field paths FieldPath's canonical form replaced a backtick inside a quoted segment with a lone backslash, so where('a`b', ...) sent `a\b`, a different field. It now escapes it as \`, like the Node SDK's FieldPath.formattedName. This affects every request that sends a field path: queries, field masks, updates and transforms. --- .../google_cloud_firestore/lib/src/path.dart | 15 +++++-- .../test/query_test.dart | 43 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/google_cloud_firestore/lib/src/path.dart b/packages/google_cloud_firestore/lib/src/path.dart index 11d493a0..7e7a4105 100644 --- a/packages/google_cloud_firestore/lib/src/path.dart +++ b/packages/google_cloud_firestore/lib/src/path.dart @@ -316,12 +316,21 @@ class FieldPath extends _Path { /// Returns the number of segments of this field path. int get _length => segments.length; + /// Segments that are sent as-is; any other segment is backtick-quoted. + static final _simpleSegment = RegExp(r'^[_a-zA-Z][_a-zA-Z0-9]*$'); + + /// The canonical string form of this path, as the backend expects it. + /// + /// Segments are joined with dots. A segment that is not a simple + /// identifier (letters, digits and underscores, not starting with a digit) + /// is wrapped in backticks, with backslashes and backticks inside it + /// escaped by a backslash: `FieldPath(['a.b', 'c-d'])` is `` `a.b`.`c-d` ``. + /// Matches the Node SDK's `FieldPath.formattedName`. String get _formattedName { - final regex = RegExp(r'^[_a-zA-Z][_a-zA-Z0-9]*$'); return segments .map((e) { - if (regex.hasMatch(e)) return e; - return '`${e.replaceAll(r'\', r'\\').replaceAll('`', r'\')}`'; + if (_simpleSegment.hasMatch(e)) return e; + return '`${e.replaceAll(r'\', r'\\').replaceAll('`', r'\`')}`'; }) .join('.'); } diff --git a/packages/google_cloud_firestore/test/query_test.dart b/packages/google_cloud_firestore/test/query_test.dart index d0fd7c5c..10aeb45c 100644 --- a/packages/google_cloud_firestore/test/query_test.dart +++ b/packages/google_cloud_firestore/test/query_test.dart @@ -18,6 +18,7 @@ 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'; import 'package:google_cloud_protobuf/protobuf.dart' as protobuf_v1; import 'package:http/http.dart' as http; import 'package:mocktail/mocktail.dart'; @@ -125,6 +126,48 @@ void main() { }); }); + group('field paths in the request (unit)', () { + test('quotes and escapes segments that are not identifiers', () async { + final mockClient = MockFirestoreHttpClient(); + final firestore = Firestore.internal( + settings: const Settings(projectId: mockProjectId), + client: mockClient, + ); + when(() => mockClient.cachedProjectId).thenReturn(mockProjectId); + + firestore_v1.RunQueryRequest? captured; + when( + () => mockClient.v1>(any()), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> Function( + firestore_v1.Firestore api, + String projectId, + ); + final api = FakeFirestore( + runQuery: (request) { + captured = request; + return const Stream.empty(); + }, + ); + return callback(api, mockProjectId); + }); + + await firestore + .collection('books') + .where('a`b', WhereFilter.equal, 1) + .orderBy(FieldPath(const ['c.d', r'e\f'])) + .get(); + + final query = captured!.structuredQuery!; + // Matches the Node SDK's FieldPath.formattedName: a backtick inside a + // segment is escaped, not replaced. + expect(query.where!.fieldFilter!.field!.fieldPath, r'`a\`b`'); + expect(query.orderBy.first.field!.fieldPath, r'`c.d`.`e\\f`'); + }); + }); + group('query interface', () { late Firestore firestore; From d0fc46b31a65d9a735c322aeb3a21333785f4c20 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:40:49 +0200 Subject: [PATCH 12/29] fix(firestore): quote Pipeline field paths like the Node SDK field() sent its argument verbatim, so field('first-name') reached the backend as first-name instead of `first-name`. It now sends the canonical field path, built with FieldPath's existing formatting: a String is split on dots into segments, and each segment that is not a simple identifier is backtick-quoted. Every String field name a stage or function takes goes through field(), so removeFields, sort, unnest, findNearest, function targets and the select, distinct and aggregate group values are quoted too. As in the Node SDK, select, distinct and aggregate groups key a String by the string itself and a PipelineField by its quoted path, and PipelineField.path (Node's fieldName) is the quoted path. field() and Expression.field() also take a FieldPath now, whose segments stay whole: field(FieldPath(['a.b'])) is `a.b`. createFrom passes the query's FieldPaths through instead of their formatted strings, which would otherwise be split and quoted a second time. Node quotes twice in two places, which Dart does not copy: the target of unnest(field('my tags')) and the projection of createFrom(query.select()) with special characters. Both are golden cases listed as known divergences. --- packages/google_cloud_firestore/README.md | 13 + .../lib/src/pipeline.dart | 172 ++++++++-- .../fixtures/pipeline_golden/queries.json | 305 ++++++++++++++++++ .../test/fixtures/pipeline_golden/stages.json | 170 ++++++++++ .../test/fixtures/pipeline_golden/values.json | 84 +++++ .../test/pipeline_api_parity_test.dart | 4 - .../test/pipeline_e2e_coverage_test.dart | 7 + .../test/pipeline_golden_test.dart | 128 +++++++- .../test/pipeline_test.dart | 290 +++++++++++++++++ .../tool/pipeline_golden/cases.js | 45 +++ 10 files changed, 1166 insertions(+), 52 deletions(-) diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 600d912e..01d2b00f 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -365,6 +365,19 @@ PipelineFunctions.toUpper('title'); for the top-level `field` / `constant` / `variable`, for callers who prefer a namespaced entry point. +**Field paths.** `field` takes a `String` or a `FieldPath`. A `String` is a +dot-separated path, so `field('address.city')` reads `city` inside the +`address` map; use a `FieldPath` for a field whose name contains a dot. Names +that are not simple identifiers need no escaping: they are sent +backtick-quoted, as the Node.js SDK sends them. The same rules apply to every +`String` field name a stage or function takes. + +```dart +field('first-name'); // sent as `first-name` +field('author.last name'); // sent as author.`last name` +field(FieldPath(['a.b'])); // a single field named "a.b", sent as `a.b` +``` + `variable` references a name bound by the enclosing expression, such as the element alias of `arrayFilter` / `arrayTransform`: diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index c10129b3..5347d080 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -15,7 +15,17 @@ part of 'firestore.dart'; /// Creates a field reference expression for Firestore Pipeline operations. -PipelineField field(String fieldPath) => Expression.field(fieldPath); +/// +/// [fieldPath] is a [String] or a [FieldPath]. A [String] is a dot-separated +/// path, so `field('address.city')` reads the `city` field of the `address` +/// map; use a [FieldPath] for a field whose name contains a dot, as in +/// `field(FieldPath(['a.b']))`. Field names that are not simple identifiers +/// (such as `first-name`, `last name` or `naïve`) need no escaping: they are +/// quoted with backticks when sent, as [PipelineField.path] shows. +/// +/// Throws an [ArgumentError] when [fieldPath] is neither a [String] nor a +/// [FieldPath], or has an empty segment (`''`, `'a..b'`). +PipelineField field(Object fieldPath) => Expression.field(fieldPath); /// Creates a constant expression for Firestore Pipeline operations. PipelineExpression constant(Object? value) => Expression.constant(value); @@ -30,7 +40,12 @@ abstract final class Expression { // Expression.field itself. /// Creates a field reference expression. - static PipelineField field(String fieldPath) => PipelineField._(fieldPath); + /// + /// [fieldPath] is a [String] (a dot-separated path) or a [FieldPath]; see + /// the top-level [field]. + static PipelineField field(Object fieldPath) { + return PipelineField._(_canonicalFieldPath(fieldPath)); + } /// Creates a constant expression. static PipelineExpression constant(Object? value) => _PipelineConstant(value); @@ -251,6 +266,27 @@ PipelineBooleanExpression greaterThanOrEqual(Object? left, Object? right) { return PipelineFunctions.greaterThanOrEqual(left, right); } +/// The canonical string the backend expects for the field at [fieldPath]. +/// +/// Mirrors the Node SDK's `field()`, which sends +/// `FieldPath.fromArgument(fieldPath).formattedName`: a [String] is split on +/// dots into segments, a [FieldPath] keeps its segments (dots included), and +/// [FieldPath._formattedName] backtick-quotes every segment that is not a +/// simple identifier. Like Node, and unlike [Query.where], a [String] may hold +/// any character other than a dot. +String _canonicalFieldPath(Object fieldPath) { + final path = switch (fieldPath) { + FieldPath() => fieldPath, + String() => FieldPath(fieldPath.split('.')), + _ => throw ArgumentError.value( + fieldPath, + 'fieldPath', + 'Expected a String or a FieldPath.', + ), + }; + return path._formattedName; +} + /// Interprets a [String] in a field position as a field reference. /// /// Mirrors the Node SDK's `fieldOrExpression`: arguments that name the target @@ -374,8 +410,11 @@ PipelineBooleanExpression documentMatches(Object? rquery) { /// 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. +/// Firestore Pipeline functions reference. A [String] in a field position +/// (the target of most functions, such as `'title'` in +/// `startsWith('title', 'Harry')`) is a field name, read like [field]; in a +/// value position it is a string literal. Use [field] to reference a document +/// field in a value position. /// /// A [List] or [Map] argument may hold expressions, as in `[field('a'), 1]`: /// it is sent as an [array] or [map] function so the backend evaluates them. @@ -1639,8 +1678,14 @@ final class Pipeline { /// Selects or computes fields from the inputs. /// - /// Entries may be [String] field names, [PipelineField] references, - /// [PipelineExpression] instances, or [PipelineAliasedExpression] values. + /// Entries may be [String] field names (read like [field]), [PipelineField] + /// references, [PipelineExpression] instances, or + /// [PipelineAliasedExpression] values. + /// + /// The selections are sent as a map keyed by the field they land on: a + /// [String] is keyed by the string itself, a [PipelineField] by its + /// [PipelineField.path] and an aliased expression by its alias, as in the + /// Node SDK. /// /// Throws an [ArgumentError] when two [selections] land on the same field /// name or alias. @@ -1677,6 +1722,8 @@ final class Pipeline { /// Aggregates inputs using aliased aggregate expressions. /// + /// [groups] takes the same entries as [distinct], keyed as in [select]. + /// /// Throws an [ArgumentError] when two [accumulators], or two [groups], land /// on the same field name or alias. /// @@ -1703,8 +1750,8 @@ final class Pipeline { /// Returns unique combinations of the provided grouping expressions. /// - /// Entries may be [String] field names, [PipelineField] references, or - /// [PipelineAliasedExpression] values. + /// Entries may be [String] field names (read like [field]), [PipelineField] + /// references, or [PipelineAliasedExpression] values, keyed as in [select]. /// /// Throws an [ArgumentError] when two [groups] land on the same field name /// or alias. @@ -1726,6 +1773,9 @@ final class Pipeline { /// Removes fields from the inputs. /// + /// Entries may be [String] field names, read like [field], or + /// [PipelineField] references. + /// /// [rawOptions] sets stage options this SDK does not wrap yet, as /// [rawStage]'s `options` do. Pipeline removeFields( @@ -1780,7 +1830,8 @@ final class Pipeline { /// Each emitted document has the array element assigned to the selection's /// alias. [selectable] may be a [String] field name, a [PipelineField], or a /// [PipelineAliasedExpression] created with [PipelineExpression.as] when the - /// element should land on a different field than the source array. + /// element should land on a different field than the source array. A + /// [String] field name, an alias and [indexField] are all read like [field]. /// /// When [indexField] is given, the element's zero-based index is assigned to /// that field. @@ -1792,10 +1843,10 @@ final class Pipeline { String? indexField, Map rawOptions = const {}, }) { - final (expression, alias) = _selectableParts(selectable); + final (expression, target) = _selectableParts(selectable); return _stage( 'unnest', - [expression, field(alias)], + [expression, target], options: _compactOptions({ 'index_field': indexField == null ? null : field(indexField), }), @@ -1871,7 +1922,10 @@ final class Pipeline { /// Performs vector nearest-neighbor search. /// + /// [vectorField] is a field name, read like [field], or an expression. /// [queryVector] is a [VectorValue], a list of numbers, or an expression. + /// [distanceResultField] is the field name, read like [field], that each + /// result's distance is written to. /// /// [rawOptions] sets stage options this SDK does not wrap yet, as /// [rawStage]'s `options` do, and takes precedence over the typed options. @@ -1883,19 +1937,41 @@ final class Pipeline { String? distanceResultField, double? distanceThreshold, Map rawOptions = const {}, + }) { + return _findNearest( + vectorField: vectorField is String ? field(vectorField) : vectorField, + queryVector: queryVector, + distanceMeasure: distanceMeasure, + limit: limit, + distanceField: distanceResultField == null + ? null + : field(distanceResultField), + distanceThreshold: distanceThreshold, + rawOptions: rawOptions, + ); + } + + /// [findNearest], with the distance field already resolved, so that + /// [VectorQuery] conversion can pass a [FieldPath] one. + Pipeline _findNearest({ + required Object vectorField, + required Object queryVector, + required DistanceMeasure distanceMeasure, + required int? limit, + required PipelineField? distanceField, + required double? distanceThreshold, + Map rawOptions = const {}, }) { return _stage( 'find_nearest', [ - if (vectorField is String) field(vectorField) else vectorField, + vectorField, _vectorOrExpression(queryVector, 'queryVector'), distanceMeasure.value.toLowerCase(), ], options: _compactOptions({ 'limit': limit, - 'distance_field': distanceResultField == null - ? null - : field(distanceResultField), + 'distance_field': distanceField, 'distance_threshold': distanceThreshold, }), rawOptions: rawOptions, @@ -2095,8 +2171,11 @@ extension _QueryToPipeline on Query { final projections = options.projection?.fields ?? const []; if (projections.isNotEmpty) { + // The projection already holds canonical field paths. Node passes them + // back through field(), quoting a path such as `first-name` twice. pipeline = pipeline.select([ - for (final projection in projections) field(projection.fieldPath), + for (final projection in projections) + PipelineField._(projection.fieldPath), ]); } @@ -2104,7 +2183,7 @@ extension _QueryToPipeline on Query { // has already emitted their existence checks. final existsConditions = [ for (final fieldOrder in _implicitOrderBy(ignoreInequalityFields: true)) - field(fieldOrder.fieldPath._formattedName).exists(), + field(fieldOrder.fieldPath).exists(), ]; pipeline = pipeline.where( existsConditions.length == 1 @@ -2118,7 +2197,7 @@ extension _QueryToPipeline on Query { fieldOrder.direction == _Direction.ascending ? 'ascending' : 'descending', - field(fieldOrder.fieldPath._formattedName), + field(fieldOrder.fieldPath), ), ]; @@ -2219,7 +2298,7 @@ extension _QueryToPipeline on Query { PipelineBooleanExpression _fieldFilterToPipelineBooleanExpression( _FieldFilterInternal filter, ) { - final target = field(filter.field._formattedName); + final target = field(filter.field); final value = _PipelineProtoValue( firestore._serializer.encodeValue(filter.value) ?? firestore_v1.Value(nullValue: protobuf_v1.NullValue.nullValue), @@ -2316,16 +2395,20 @@ PipelineBooleanExpression _cursorCondition( /// Translates the [VectorQuery] surface onto Pipeline stages. extension _VectorQueryToPipeline on VectorQuery { Pipeline _toPipeline(Firestore firestore) { - final vectorField = _rawVectorField; + // Both fields are a String or a FieldPath; field() reads either. + final vectorField = field(_options.vectorField); + final distanceResultField = _options.distanceResultField; return _query ._toPipeline(firestore) - .where(field(vectorField).exists()) - .findNearest( - vectorField: field(vectorField), + .where(vectorField.exists()) + ._findNearest( + vectorField: vectorField, queryVector: FieldValue.vector(_rawQueryVector), distanceMeasure: _options.distanceMeasure, limit: _options.limit, - distanceResultField: _rawDistanceResultField, + distanceField: distanceResultField == null + ? null + : field(distanceResultField), distanceThreshold: _options.distanceThreshold, ); } @@ -3176,11 +3259,24 @@ sealed class PipelineBooleanExpression extends PipelineExpression { } /// A Pipeline field reference. +/// +/// Create one with [field] or [Expression.field]. @immutable final class PipelineField extends PipelineExpression { + /// Wraps [path], which must already be canonical: build it with + /// [_canonicalFieldPath] or [FieldPath._formattedName]. const PipelineField._(this.path); - /// The field path referenced by this expression. + /// The field path referenced by this expression, in the canonical form sent + /// to the backend. + /// + /// Segments are joined with dots, and a segment that is not a simple + /// identifier is quoted with backticks: `field('address.city').path` is + /// `address.city`, `field('first-name').path` is `` `first-name` `` and + /// `field(FieldPath(['a.b'])).path` is `` `a.b` ``. Like the Node SDK's + /// `Field.fieldName`, it is also the name [Pipeline.select], + /// [Pipeline.distinct] and the `groups` of [Pipeline.aggregate] key this + /// field by. final String path; @override @@ -3513,6 +3609,11 @@ Map _compactOptions(Map options) { /// Throws an [ArgumentError] on a repeated key instead of silently keeping the /// last entry. A [String] or [PipelineField] lands on its own path, so it /// collides with an alias of the same name, matching the Node SDK. +/// +/// Like the Node SDK's `selectablesToObject`, a [String] is keyed by the +/// string itself and a [PipelineField] by its canonical [PipelineField.path]: +/// `select(['first-name', field('last name')])` keys `first-name` and +/// `` `last name` ``, both referencing their backtick-quoted path. Map _projectionMap( Iterable selections, { String argumentName = 'selections', @@ -3528,16 +3629,23 @@ Map _projectionMap( return result; } -/// Splits a selectable into the expression it computes and the alias it lands +/// Splits a selectable into the expression it computes and the field it lands /// on. /// -/// A [String] or [PipelineField] is its own alias, matching the Node SDK where -/// `Field._alias` is the field name. -(Object expression, String alias) _selectableParts(Object selectable) { +/// A [String] or [PipelineField] lands on its own path, matching the Node SDK +/// where `Field._alias` is the field name. An alias is a field path, as in +/// Node's `field(alias)`. +/// +/// Node rebuilds the field from `Field._alias`, its already-quoted name, so it +/// quotes a field such as `last name` twice. The field is reused as is here. +(Object expression, PipelineField target) _selectableParts(Object selectable) { return switch (selectable) { - String() => (field(selectable), selectable), - PipelineField() => (selectable, selectable.path), - PipelineAliasedExpression() => (selectable.expression, selectable.name), + String() => (field(selectable), field(selectable)), + PipelineField() => (selectable, selectable), + PipelineAliasedExpression() => ( + selectable.expression, + field(selectable.name), + ), _ => throw ArgumentError.value( selectable, 'selectable', diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json index 61e2a12e..2bc1be8d 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json @@ -1335,6 +1335,61 @@ } } }, + "queries/order-by/field-path-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "`a.b`"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "`a.b`"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, "queries/order-by/implicit-from-inequalities": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -1723,6 +1778,57 @@ } } }, + "queries/select/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "`\\`first-name\\``": { + "fieldReferenceValue": "`\\`first-name\\``" + }, + "`\\`last name\\``": { + "fieldReferenceValue": "`\\`last name\\``" + } + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, "queries/vector/cosine": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -2068,6 +2174,144 @@ } } }, + "queries/vector/field-path-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "`a.b`"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "`a.b`"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/vector/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "`my embedding`"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "`my embedding`"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, "queries/vector/vector-value": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -3010,6 +3254,67 @@ } } }, + "queries/where/field-path-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "`a.b`"}] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "`a.b`"}, + {"integerValue": "1"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, "queries/where/greater-than": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json index 71f23c2f..6b46458a 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/stages.json @@ -167,6 +167,34 @@ } } }, + "stages/aggregate/groups-special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "aggregate", + "args": [ + { + "mapValue": { + "fields": {"total": {"functionValue": {"name": "count"}}} + } + }, + { + "mapValue": { + "fields": { + "`last name`": {"fieldReferenceValue": "`last name`"}, + "first-name": {"fieldReferenceValue": "`first-name`"} + } + } + } + ] + } + ] + } + } + }, "stages/aggregate/multiple": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -367,6 +395,29 @@ } } }, + "stages/distinct/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "distinct", + "args": [ + { + "mapValue": { + "fields": { + "`last name`": {"fieldReferenceValue": "`last name`"}, + "first-name": {"fieldReferenceValue": "`first-name`"} + } + } + } + ] + } + ] + } + } + }, "stages/find-nearest/all-options": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -677,6 +728,42 @@ } } }, + "stages/find-nearest/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "`my embedding`"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": { + "distance_field": {"fieldReferenceValue": "`my distance`"} + } + } + ] + } + } + }, "stages/find-nearest/vector-value": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -1536,6 +1623,23 @@ } } }, + "stages/remove-fields/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "remove_fields", + "args": [ + {"fieldReferenceValue": "`first-name`"}, + {"fieldReferenceValue": "`last name`"} + ] + } + ] + } + } + }, "stages/replace-with/field": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -2211,6 +2315,37 @@ } } }, + "stages/sort/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "`first-name`"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "`last name`"} + } + } + } + ] + } + ] + } + } + }, "stages/sort/top-level-expression": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -2513,6 +2648,23 @@ } } }, + "stages/unnest/field-special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "unnest", + "args": [ + {"fieldReferenceValue": "`my tags`"}, + {"fieldReferenceValue": "`\\`my tags\\``"} + ] + } + ] + } + } + }, "stages/unnest/index-field": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -2570,6 +2722,24 @@ } } }, + "stages/unnest/special-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "unnest", + "args": [ + {"fieldReferenceValue": "tags"}, + {"fieldReferenceValue": "`my tag`"} + ], + "options": {"index_field": {"fieldReferenceValue": "`tag index`"}} + } + ] + } + } + }, "stages/update/plain": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/values.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/values.json index c4c6918a..402af131 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/values.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/values.json @@ -845,6 +845,46 @@ } } }, + "values/field/backslash": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"fieldReferenceValue": "`a\\\\b`"}} + } + } + ] + } + ] + } + } + }, + "values/field/backtick": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"fieldReferenceValue": "`a\\`b`"}} + } + } + ] + } + ] + } + } + }, "values/field/document-id": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -865,6 +905,30 @@ } } }, + "values/field/field-name-argument": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "`first-name`"}, + {"stringValue": "Ada"} + ] + } + } + ] + } + ] + } + } + }, "values/field/field-path-object": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -927,6 +991,26 @@ } } }, + "values/field/reserved-characters": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": {"result": {"fieldReferenceValue": "`a/b`"}} + } + } + ] + } + ] + } + } + }, "values/field/simple": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { diff --git a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart index 43942441..6ddfafb2 100644 --- a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart @@ -213,10 +213,6 @@ const _pendingFixes = { 'Takes an Iterable of alternating keys and values; Node ' 'map(elements: Record) takes a map, which Dart ' 'callers cannot pass.', - 'field#fieldPath': - 'Only takes a String; Node field() also takes a FieldPath.', - 'Expression.field#fieldPath': - 'Only takes a String; Node field() also takes a FieldPath.', 'PipelineSource.documents#documents': 'Only takes DocumentReferences; Node documents() also takes document ' 'path strings.', diff --git a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart index b4edfc30..afc48714 100644 --- a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart @@ -420,6 +420,9 @@ const _requiredShapes = >{ 'PipelineExpression.dotProduct(other)': [_Shape.numericList], 'PipelineExpression.euclideanDistance(other)': [_Shape.numericList], 'Pipeline.findNearest(queryVector)': [_Shape.numericList], + // Field paths. + 'field(fieldPath)': [_Shape.dottedPath, _Shape.fieldPath], + 'Expression.field(fieldPath)': [_Shape.fieldPath], // Result paths. 'PipelineResult.get(field)': [_Shape.dottedPath, _Shape.fieldPath], }; @@ -455,10 +458,14 @@ const _notExecutable = { const _notValuePositions = { 'Expression.constant(value)': 'wraps a literal value; an expression is not a constant', + 'Expression.field(fieldPath)': + 'takes a String or a FieldPath; see the fieldPath shape', 'PipelineResult.get(field)': 'takes a String or a FieldPath; see the dottedPath and fieldPath shapes', 'PipelineSource.createFrom(query)': 'takes a Query or a VectorQuery', 'constant(value)': 'wraps a literal value; an expression is not a constant', + 'field(fieldPath)': + 'takes a String or a FieldPath; see the dottedPath and fieldPath shapes', }; /// The imports an E2E file may use besides `dart:` libraries. diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index ee7b1c22..7ea2fc93 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -65,25 +65,25 @@ final _knownDivergences = { 'supported: FIELD_REFERENCE_VALUE").', _searchSpaceIsArrayFunction, ), + 'stages/unnest/field-special-characters': const _Divergence( + "unnest(field('my tags')) writes each element back to `my tags`. Node " + 'rebuilds the target with field(Field._alias), quoting the already ' + r'quoted name a second time ("`\`my tags\``").', + _unnestTargetIsQuotedOnce, + ), + 'queries/select/special-characters': const _Divergence( + "createFrom(query.select(['first-name', 'last name'])) selects " + "`first-name` and `last name`. Node passes the projection's quoted " + 'paths back through field(), which quotes them again ' + r'("`\`first-name\``").', + _projectionIsQuotedOnce, + ), }; /// Differences from the Node SDK that are not intended, awaiting a fix. /// /// Each entry must still differ from its fixture; remove it once fixed. const _pendingFixes = { - // field() sends the path verbatim; Node formats it like FieldPath, quoting - // segments that are not simple identifiers. - 'values/field/special-characters': - "field('first-name') sends `first-name` unquoted; Node sends " - '"`first-name`".', - 'values/field/space': - "field('last name') sends `last name` unquoted; Node sends " - '"`last name`".', - 'values/field/non-ascii': - "field('naïve') sends `naïve` unquoted; Node sends \"`naïve`\".", - 'stages/select/special-characters': - 'select() leaves special-character field paths unquoted; Node quotes ' - 'them, and keys a PipelineField by its quoted path ("`last name`").', // Ordering helpers. // Query to Pipeline conversion. 'queries/where/not-equal': @@ -127,7 +127,6 @@ const _dartApiGaps = { 'stages/delete/plain': 'No Pipeline.delete stage.', 'stages/update/plain': 'No Pipeline.update stage.', 'stages/to-scalar-expression/plain': 'No Pipeline.toScalarExpression().', - 'values/field/field-path-object': 'field() takes a String, not a FieldPath.', 'functions/add/method-variadic': 'PipelineExpression.add takes a single operand.', 'functions/multiply/method-variadic': @@ -404,6 +403,33 @@ final class _Divergence { final bool Function(Object? request) check; } +/// Whether the `unnest` stage writes each element back to the quoted +/// `` `my tags` `` field it reads. +bool _unnestTargetIsQuotedOnce(Object? request) { + final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); + if (stages is! List || stages.length != 2) return false; + const target = {'fieldReferenceValue': '`my tags`'}; + return _path(stages[1], ['name']) == 'unnest' && + _jsonEquals(_path(stages[1], ['args']), [target, target]); +} + +/// Whether the `select` stage keys `` `first-name` `` and `` `last name` `` +/// by their singly quoted paths. +bool _projectionIsQuotedOnce(Object? request) { + final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); + if (stages is! List) return false; + final select = stages.cast().firstWhere( + (stage) => _path(stage, ['name']) == 'select', + orElse: () => null, + ); + return _jsonEquals(_path(select, ['args', 0, 'mapValue', 'fields']), { + for (final path in ['`first-name`', '`last name`']) + path: {'fieldReferenceValue': path}, + }); +} + +bool _jsonEquals(Object? a, Object? b) => equals(b).matches(a, {}); + /// Whether the `where` stage's function receives its search space as an /// `array(...)` function. bool _searchSpaceIsArrayFunction(Object? request) { @@ -756,6 +782,10 @@ void _registerQueries(_Registry r) { 'where/special-characters', (db) => col(db).where('first-name', WhereFilter.equal, 'Ada'), ); + query( + 'where/field-path-object', + (db) => col(db).where(FieldPath(const ['a.b']), WhereFilter.equal, 1), + ); query( 'where/document-id', // Node also accepts the bare ID 'book1'; Dart's Query.where requires the @@ -812,6 +842,10 @@ void _registerQueries(_Registry r) { ); query('order-by/document-id', (db) => col(db).orderBy(FieldPath.documentId)); query('order-by/special-characters', (db) => col(db).orderBy('first-name')); + query( + 'order-by/field-path-object', + (db) => col(db).orderBy(FieldPath(const ['a.b'])), + ); query( 'order-by/implicit-from-inequality', (db) => col(db).where('rating', WhereFilter.greaterThan, 4), @@ -870,16 +904,24 @@ void _registerQueries(_Registry r) { ]), ); query('select/empty', (db) => col(db).select()); + query( + 'select/special-characters', + (db) => col(db).select([ + FieldPath(const ['first-name']), + FieldPath(const ['last name']), + ]), + ); VectorQuery nearest( Query base, { + Object vectorField = 'embedding', Object queryVector = const [0.1, 0.2, 0.3], DistanceMeasure distanceMeasure = DistanceMeasure.euclidean, String? distanceResultField, double? distanceThreshold, }) { return base.findNearest( - vectorField: 'embedding', + vectorField: vectorField, queryVector: queryVector, limit: 5, distanceMeasure: distanceMeasure, @@ -910,6 +952,14 @@ void _registerQueries(_Registry r) { 'vector/distance-threshold', (db) => nearest(col(db), distanceThreshold: 0.5), ); + query( + 'vector/special-characters', + (db) => nearest(col(db), vectorField: 'my embedding'), + ); + query( + 'vector/field-path-object', + (db) => nearest(col(db), vectorField: FieldPath(const ['a.b'])), + ); } // ----------------------------------------------------------------------------- @@ -1016,6 +1066,10 @@ void _registerStages(_Registry r) { (db) => _books(db).removeFields(['title'], rawOptions: const {'foo': 'bar'}), ); + stage( + 'remove-fields/special-characters', + (db) => _books(db).removeFields(['first-name', field('last name')]), + ); stage( 'sort/ascending-method', @@ -1052,6 +1106,12 @@ void _registerStages(_Registry r) { db, ).sort([field('rating').descending()], rawOptions: const {'foo': 'bar'}), ); + stage( + 'sort/special-characters', + (db) => _books( + db, + ).sort([ascending('first-name'), field('last name').descending()]), + ); stage('offset/plain', (db) => _books(db).offset(10)); stage( @@ -1079,6 +1139,10 @@ void _registerStages(_Registry r) { 'distinct/raw-options', (db) => _books(db).distinct(['genre'], rawOptions: const {'foo': 'bar'}), ); + stage( + 'distinct/special-characters', + (db) => _books(db).distinct(['first-name', field('last name')]), + ); stage( 'aggregate/single', @@ -1119,6 +1183,13 @@ void _registerStages(_Registry r) { rawOptions: const {'foo': 'bar'}, ), ); + stage( + 'aggregate/groups-special-characters', + (db) => _books(db).aggregate( + [PipelineFunctions.countAll().as('total')], + groups: ['first-name', field('last name')], + ), + ); Pipeline findNearest( Firestore db, { @@ -1178,6 +1249,14 @@ void _registerStages(_Registry r) { rawOptions: const {'limit': 20, 'extra.flag': true}, ), ); + stage( + 'find-nearest/special-characters', + (db) => findNearest( + db, + vectorField: 'my embedding', + distanceResultField: 'my distance', + ), + ); stage('replace-with/field-name', (db) => _books(db).replaceWith('metadata')); stage( @@ -1262,6 +1341,16 @@ void _registerStages(_Registry r) { rawOptions: {'index_field': field('position'), 'foo': 'bar'}, ), ); + stage( + 'unnest/special-characters', + (db) => + _books(db).unnest(field('tags').as('my tag'), indexField: 'tag index'), + ); + stage( + 'unnest/field-special-characters', + (db) => _books(db).unnest(field('my tags')), + [(db) => _books(db).unnest('my tags')], + ); stage( 'raw-stage/expression-params', @@ -1652,7 +1741,7 @@ void _registerValues(_Registry r) { }, }); - void selectField(String id, String name) { + void selectField(String id, Object name) { r.expr('values/field/$id', () => field(name), [ () => Expression.field(name), ]); @@ -1664,6 +1753,13 @@ void _registerValues(_Registry r) { selectField('special-characters', 'first-name'); selectField('space', 'last name'); selectField('non-ascii', 'naïve'); + selectField('backtick', 'a`b'); + selectField('backslash', r'a\b'); + selectField('reserved-characters', 'a/b'); + selectField('field-path-object', FieldPath(const ['a.b', 'c'])); + r.expr('values/field/field-name-argument', () => equal('first-name', 'Ada'), [ + () => PipelineFunctions.equal('first-name', 'Ada'), + ]); r.expr('values/variable/plain', () => variable('x'), [ () => Expression.variable('x'), diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index a5655b76..57027c14 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -1700,6 +1700,296 @@ void main() { }); }); + // Mirrors the Node SDK's `field()`, which sends + // `FieldPath.fromArgument(path).formattedName`. + group('field paths', () { + group('field() canonicalizes the path', () { + void expectPath(Object fieldPath, String expected) { + expect(field(fieldPath).path, expected, reason: '$fieldPath'); + expect(Expression.field(fieldPath).path, expected); + } + + test('keeps simple identifiers as they are', () { + expectPath('title', 'title'); + expectPath('_private', '_private'); + expectPath('isbn13', 'isbn13'); + expectPath('__name__', '__name__'); + }); + + test('quotes segments that are not simple identifiers', () { + expectPath('first-name', '`first-name`'); + expectPath('last name', '`last name`'); + expectPath('naïve', '`naïve`'); + expectPath('1st', '`1st`'); + expectPath('a/b', '`a/b`'); + }); + + test('escapes backticks and backslashes inside a segment', () { + expectPath('a`b', r'`a\`b`'); + expectPath(r'a\b', r'`a\\b`'); + }); + + test('reads a dotted String as nested segments', () { + expectPath('metadata.lang', 'metadata.lang'); + expectPath('author.first-name', 'author.`first-name`'); + expectPath('my map.key', '`my map`.key'); + }); + + test('keeps each FieldPath segment whole, dots included', () { + expectPath(FieldPath(const ['a.b']), '`a.b`'); + expectPath(FieldPath(const ['a.b', 'c']), '`a.b`.c'); + expectPath(FieldPath(const ['metadata', 'lang']), 'metadata.lang'); + expectPath(FieldPath(const ['first-name']), '`first-name`'); + expectPath(FieldPath.documentId, '__name__'); + }); + + test('rejects other types and empty segments', () { + for (final invalid in [42, '', 'a..b', '.a', 'a.']) { + expect( + () => field(invalid), + throwsArgumentError, + reason: '$invalid', + ); + expect(() => Expression.field(invalid), throwsArgumentError); + } + }); + }); + + group('on the wire', () { + late List stages; + + Future run(Object pipelineOrQuery) 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< + firestore_v1.ExecutePipelineResponse + >.empty(); + }, + ); + + return callback(api, _projectId); + }); + + final pipeline = pipelineOrQuery is Pipeline + ? pipelineOrQuery + : firestore.pipeline().createFrom(pipelineOrQuery); + await pipeline.execute(); + stages = capturedRequest!.structuredPipeline!.pipeline!.stages; + } + + Pipeline base() => firestore.pipeline().collection('books'); + Map ref(String path) => {'fieldReferenceValue': path}; + Object? json(firestore_v1.Value value) => value.toJson(); + + test( + 'field() and String field arguments send the quoted path', + () async { + await run( + base().where(PipelineFunctions.equal('first-name', 'Ada')).select( + [ + field(FieldPath(const ['a.b'])).as('result'), + ], + ), + ); + + final equal = stages[1].args.single.functionValue!; + expect(json(equal.args[0]), ref('`first-name`')); + // The value position keeps the string as a literal. + expect(equal.args[1].stringValue, 'Ada'); + + final select = stages[2].args.single.mapValue!.fields; + expect(json(select['result']!), ref('`a.b`')); + }, + ); + + test( + 'select keys a String by itself and a PipelineField by its path', + () async { + await run( + base().select([ + 'first-name', + field('last name'), + 'metadata.lang', + field(FieldPath(const ['a.b'])), + ]), + ); + + final fields = stages[1].args.single.mapValue!.fields; + expect(fields.map((key, value) => MapEntry(key, json(value))), { + 'first-name': ref('`first-name`'), + '`last name`': ref('`last name`'), + 'metadata.lang': ref('metadata.lang'), + '`a.b`': ref('`a.b`'), + }); + }, + ); + + test('distinct and aggregate groups are keyed like select', () async { + await run( + base() + .distinct(['first-name', field('last name')]) + .aggregate( + [PipelineFunctions.countAll().as('total')], + groups: ['first-name', field('last name')], + ), + ); + + final expected = { + 'first-name': ref('`first-name`'), + '`last name`': ref('`last name`'), + }; + final distinct = stages[1].args.single.mapValue!.fields; + expect( + distinct.map((key, value) => MapEntry(key, json(value))), + expected, + ); + final groups = stages[2].args[1].mapValue!.fields; + expect( + groups.map((key, value) => MapEntry(key, json(value))), + expected, + ); + }); + + test('removeFields and sort send quoted paths', () async { + await run( + base().removeFields(['first-name', field('last name')]).sort([ + ascending('first-name'), + field('last name').descending(), + ]), + ); + + expect(stages[1].args.map(json), [ + ref('`first-name`'), + ref('`last name`'), + ]); + expect( + stages[2].args.map( + (ordering) => json(ordering.mapValue!.fields['expression']!), + ), + [ref('`first-name`'), ref('`last name`')], + ); + }); + + test('unnest quotes its target and index field once', () async { + await run(base().unnest(field('my tags'))); + // Node quotes this target twice; see the golden corpus. + expect(stages[1].args.map(json), [ + ref('`my tags`'), + ref('`my tags`'), + ]); + + await run( + base().unnest(field('tags').as('my tag'), indexField: 'tag index'), + ); + expect(stages[1].args.map(json), [ref('tags'), ref('`my tag`')]); + expect(json(stages[1].options['index_field']!), ref('`tag index`')); + }); + + test('findNearest quotes the vector and distance fields', () async { + await run( + base().findNearest( + vectorField: 'my embedding', + queryVector: const [1.0, 2.0], + distanceMeasure: DistanceMeasure.euclidean, + distanceResultField: 'my distance', + ), + ); + + expect(json(stages[1].args[0]), ref('`my embedding`')); + expect( + json(stages[1].options['distance_field']!), + ref('`my distance`'), + ); + }); + + group('createFrom', () { + test('quotes FieldPath filters and orderings once', () async { + await run( + firestore + .collection('books') + .where(FieldPath(const ['a.b']), WhereFilter.equal, 1) + .where('a`b', WhereFilter.equal, 2) + .orderBy(FieldPath(const ['c.d'])), + ); + + final dotted = stages[1].args.single.functionValue!; + expect( + json(dotted.args[0].functionValue!.args.single), + ref('`a.b`'), + ); + final backtick = stages[2].args.single.functionValue!; + expect( + json(backtick.args[0].functionValue!.args.single), + ref(r'`a\`b`'), + ); + final sort = stages.singleWhere((stage) => stage.name == 'sort'); + expect( + json(sort.args.first.mapValue!.fields['expression']!), + ref('`c.d`'), + ); + }); + + test('keeps the projection paths quoted once', () async { + await run( + firestore.collection('books').select([ + FieldPath(const ['first-name']), + FieldPath(const ['a.b']), + FieldPath(const ['metadata', 'lang']), + ]), + ); + + final select = stages.singleWhere( + (stage) => stage.name == 'select', + ); + final fields = select.args.single.mapValue!.fields; + // Node quotes these twice; see the golden corpus. + expect(fields.map((key, value) => MapEntry(key, json(value))), { + '`first-name`': ref('`first-name`'), + '`a.b`': ref('`a.b`'), + 'metadata.lang': ref('metadata.lang'), + }); + }); + + test('reads FieldPath vector and distance fields', () async { + await run( + firestore + .collection('books') + .findNearest( + vectorField: FieldPath(const ['a.b']), + queryVector: const [1.0, 2.0], + limit: 3, + distanceMeasure: DistanceMeasure.euclidean, + distanceResultField: FieldPath(const ['my distance']), + ), + ); + + final nearest = stages.last; + expect(nearest.name, 'find_nearest'); + expect(json(nearest.args[0]), ref('`a.b`')); + expect( + json(nearest.options['distance_field']!), + ref('`my distance`'), + ); + final exists = stages[stages.length - 2].args.single.functionValue!; + expect(json(exists.args.single), ref('`a.b`')); + }); + }); + }); + }); + // Mirrors the Node SDK's `selectablesToObject` / `aliasedAggregateToMap`, // which throw rather than let a later entry overwrite an earlier one. group('duplicate aliases or fields', () { diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js index 49a57249..c942d423 100644 --- a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js +++ b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js @@ -225,6 +225,9 @@ module.exports = function buildCases(sdk, db) { query('where/special-characters', () => col().where('first-name', '==', 'Ada'), ); + query('where/field-path-object', () => + col().where(new FieldPath('a.b'), '==', 1), + ); query('where/document-id', () => col().where(FieldPath.documentId(), '==', 'book1'), ); @@ -266,6 +269,9 @@ module.exports = function buildCases(sdk, db) { ); query('order-by/document-id', () => col().orderBy(FieldPath.documentId())); query('order-by/special-characters', () => col().orderBy('first-name')); + query('order-by/field-path-object', () => + col().orderBy(new FieldPath('a.b')), + ); query('order-by/implicit-from-inequality', () => col().where('rating', '>', 4), ); @@ -298,6 +304,9 @@ module.exports = function buildCases(sdk, db) { query('select/fields', () => col().select('title', 'author')); query('select/empty', () => col().select()); + query('select/special-characters', () => + col().select('first-name', 'last name'), + ); const vectorQuery = (id, makeQuery) => query(`vector/${id}`, makeQuery); const nearest = (options, base = col()) => @@ -319,6 +328,12 @@ module.exports = function buildCases(sdk, db) { nearest({distanceResultField: 'distance'}), ); vectorQuery('distance-threshold', () => nearest({distanceThreshold: 0.5})); + vectorQuery('special-characters', () => + nearest({vectorField: 'my embedding'}), + ); + vectorQuery('field-path-object', () => + nearest({vectorField: new FieldPath('a.b')}), + ); // --------------------------------------------------------------------------- // Stages @@ -394,6 +409,9 @@ module.exports = function buildCases(sdk, db) { stage('remove-fields/raw-options', () => books().removeFields({fields: ['title'], rawOptions: {foo: 'bar'}}), ); + stage('remove-fields/special-characters', () => + books().removeFields('first-name', field('last name')), + ); stage('sort/ascending-method', () => books().sort(field('rating').ascending())); stage('sort/descending-method', () => @@ -420,6 +438,9 @@ module.exports = function buildCases(sdk, db) { rawOptions: {foo: 'bar'}, }), ); + stage('sort/special-characters', () => + books().sort(P.ascending('first-name'), field('last name').descending()), + ); stage('offset/plain', () => books().offset(10)); stage('offset/raw-options', () => @@ -439,6 +460,9 @@ module.exports = function buildCases(sdk, db) { stage('distinct/raw-options', () => books().distinct({groups: ['genre'], rawOptions: {foo: 'bar'}}), ); + stage('distinct/special-characters', () => + books().distinct('first-name', field('last name')), + ); stage('aggregate/single', () => books().aggregate(P.countAll().as('total'))); stage('aggregate/multiple', () => @@ -473,6 +497,12 @@ module.exports = function buildCases(sdk, db) { rawOptions: {foo: 'bar'}, }), ); + stage('aggregate/groups-special-characters', () => + books().aggregate({ + accumulators: [P.countAll().as('total')], + groups: ['first-name', field('last name')], + }), + ); const findNearest = options => books().findNearest({ @@ -506,6 +536,9 @@ module.exports = function buildCases(sdk, db) { rawOptions: {limit: 20, 'extra.flag': true}, }), ); + stage('find-nearest/special-characters', () => + findNearest({field: 'my embedding', distanceField: 'my distance'}), + ); stage('replace-with/field-name', () => books().replaceWith('metadata')); stage('replace-with/field', () => books().replaceWith(field('metadata'))); @@ -564,6 +597,12 @@ module.exports = function buildCases(sdk, db) { rawOptions: {index_field: field('position'), foo: 'bar'}, }), ); + stage('unnest/special-characters', () => + books().unnest(field('tags').as('my tag'), 'tag index'), + ); + stage('unnest/field-special-characters', () => + books().unnest(field('my tags')), + ); stage('raw-stage/expression-params', () => books().rawStage('where', [field('rating').greaterThan(4)]), @@ -818,9 +857,15 @@ module.exports = function buildCases(sdk, db) { selectField('special-characters', 'first-name'); selectField('space', 'last name'); selectField('non-ascii', 'naïve'); + selectField('backtick', 'a`b'); + selectField('backslash', 'a\\b'); + selectField('reserved-characters', 'a/b'); add('values/field/field-path-object', () => books().select(field(new FieldPath('a.b', 'c')).as('result')), ); + add('values/field/field-name-argument', () => + books().where(P.equal('first-name', 'Ada')), + ); add('values/variable/plain', () => books().select(variable('x').as('result')), From 30a9b7abf851174c547af2a82268a59aa489e0c2 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:39:17 +0200 Subject: [PATCH 13/29] feat(firestore): take any number of operands in Pipeline add and multiply The Node SDK's add and multiply are variadic: add(first, second, ...others) and expr.add(second, ...others). PipelineFunctions.add and multiply now take an optional trailing Iterable of further operands, like concat and logicalMaximum, and the fluent methods take (second, [others]). Every operand is sent in a single add/multiply call, as Node's method form sends them. Two-operand calls are unchanged. Node's top-level add/multiply are typed variadic but drop ...others at runtime; the golden corpus now records that as a known divergence. --- packages/google_cloud_firestore/README.md | 7 ++ .../lib/src/pipeline.dart | 39 ++++++-- .../fixtures/pipeline_golden/functions.json | 60 ++++++++++++ .../test/pipeline_api_parity_test.dart | 9 -- .../test/pipeline_golden_test.dart | 44 ++++++++- .../pipeline_single_implementation_test.dart | 18 +++- .../test/pipeline_test.dart | 98 +++++++++++++++++++ .../tool/pipeline_golden/cases.js | 2 + 8 files changed, 252 insertions(+), 25 deletions(-) diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 01d2b00f..96c3ed88 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -440,6 +440,13 @@ maps; `charLength`/`stringReverse`/`stringConcat` and `minimum`/`maximum` are the aggregate forms; use `logicalMinimum` / `logicalMaximum` to compare several operands element-wise. +`add` and `multiply` take extra operands as a trailing list, sent as a single +backend call: + +```dart +field('price').add(field('tax'), [field('shipping')]); +``` + Anything not yet wrapped is reachable via `PipelineFunctions.raw` or `pipelineFunction`. Their arguments are sent as-is, so build a collection that holds expressions with `PipelineFunctions.array` / `PipelineFunctions.map`: diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 5347d080..7700e17c 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -554,8 +554,14 @@ abstract final class PipelineFunctions { } /// ADD arithmetic function. - static PipelineExpression add(Object? left, Object? right) { - return _expr('add', [_fieldOrExpression(left), right]); + /// + /// Adds [first], [second] and any [others], sent as a single `add` call. + static PipelineExpression add( + Object? first, + Object? second, [ + Iterable others = const [], + ]) { + return _expr('add', [_fieldOrExpression(first), second, ...others]); } /// SUBTRACT arithmetic function. @@ -564,8 +570,15 @@ abstract final class PipelineFunctions { } /// MULTIPLY arithmetic function. - static PipelineExpression multiply(Object? left, Object? right) { - return _expr('multiply', [_fieldOrExpression(left), right]); + /// + /// Multiplies [first], [second] and any [others], sent as a single + /// `multiply` call. + static PipelineExpression multiply( + Object? first, + Object? second, [ + Iterable others = const [], + ]) { + return _expr('multiply', [_fieldOrExpression(first), second, ...others]); } /// DIVIDE arithmetic function. @@ -2631,9 +2644,12 @@ sealed class PipelineExpression { return PipelineFunctions.greaterThanOrEqual(this, other); } - /// Creates an addition expression. - PipelineExpression add(Object? other) { - return PipelineFunctions.add(this, other); + /// Adds [second] and any [others] to this expression. + PipelineExpression add( + Object? second, [ + Iterable others = const [], + ]) { + return PipelineFunctions.add(this, second, others); } /// Creates a subtraction expression. @@ -2641,9 +2657,12 @@ sealed class PipelineExpression { return PipelineFunctions.subtract(this, other); } - /// Creates a multiplication expression. - PipelineExpression multiply(Object? other) { - return PipelineFunctions.multiply(this, other); + /// Multiplies this expression by [second] and any [others]. + PipelineExpression multiply( + Object? second, [ + Iterable others = const [], + ]) { + return PipelineFunctions.multiply(this, second, others); } /// Creates a division expression. diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/functions.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/functions.json index eb769c0d..258cd2d7 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/functions.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/functions.json @@ -324,6 +324,36 @@ } } }, + "functions/add/static-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "add", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, "functions/and/static-three": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -11503,6 +11533,36 @@ } } }, + "functions/multiply/static-variadic": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "select", + "args": [ + { + "mapValue": { + "fields": { + "result": { + "functionValue": { + "name": "multiply", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + } + } + } + ] + } + ] + } + } + }, "functions/nor/static-three": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { diff --git a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart index 6ddfafb2..386da8e8 100644 --- a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart @@ -200,15 +200,6 @@ const _knownDifferences = { /// Remove an entry once its fix lands; the test fails while it is stale. const _pendingFixes = { // Signatures. - 'PipelineFunctions.add#arity': - 'Node add(first, second, ...others) is variadic; Dart add(left, right) ' - 'takes exactly two.', - 'PipelineFunctions.multiply#arity': - 'Node multiply(first, second, ...others) is variadic; Dart takes two.', - 'PipelineExpression.add#arity': - 'Node add(second, ...others) is variadic; Dart add(other) takes one.', - 'PipelineExpression.multiply#arity': - 'Node multiply(second, ...others) is variadic; Dart takes one.', 'PipelineFunctions.map#keyValues': 'Takes an Iterable of alternating keys and values; Node ' 'map(elements: Record) takes a map, which Dart ' diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index 7ea2fc93..7ac5ddc0 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -78,6 +78,13 @@ final _knownDivergences = { r'("`\`first-name\``").', _projectionIsQuotedOnce, ), + for (final name in ['add', 'multiply']) + 'functions/$name/static-variadic': const _Divergence( + 'PipelineFunctions.add/multiply send every operand, like the Node ' + 'method form. The Node top-level functions are typed variadic but drop ' + '`...others` at runtime: they only forward `second` to the method.', + _selectsThreeOperands, + ), }; /// Differences from the Node SDK that are not intended, awaiting a fix. @@ -127,10 +134,6 @@ const _dartApiGaps = { 'stages/delete/plain': 'No Pipeline.delete stage.', 'stages/update/plain': 'No Pipeline.update stage.', 'stages/to-scalar-expression/plain': 'No Pipeline.toScalarExpression().', - 'functions/add/method-variadic': - 'PipelineExpression.add takes a single operand.', - 'functions/multiply/method-variadic': - 'PipelineExpression.multiply takes a single operand.', 'functions/arraySlice/static-field-name-offset': 'PipelineFunctions.arraySlice requires a length; only the ' 'PipelineExpression.arraySlice method makes it optional.', @@ -432,6 +435,23 @@ bool _jsonEquals(Object? a, Object? b) => equals(b).matches(a, {}); /// Whether the `where` stage's function receives its search space as an /// `array(...)` function. +/// Whether the selected function receives all three operands of a variadic +/// call. +bool _selectsThreeOperands(Object? request) { + final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); + if (stages is! List || stages.length != 2) return false; + final operands = _path(stages[1], [ + 'args', + 0, + 'mapValue', + 'fields', + 'result', + 'functionValue', + 'args', + ]); + return operands is List && operands.length == 3; +} + bool _searchSpaceIsArrayFunction(Object? request) { final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); if (stages is! List || stages.length != 2) return false; @@ -1912,6 +1932,14 @@ void _registerArithmetic(_Registry r) { (e, v) => e.add(v), ); r.expr('functions/add/method-double', () => field('rating').add(0.5)); + r.expr( + 'functions/add/method-variadic', + () => field('rating').add(1, [field('bonus')]), + ); + r.expr( + 'functions/add/static-variadic', + () => PipelineFunctions.add('rating', 1, [field('bonus')]), + ); r.binary( 'subtract', 'rating', @@ -1928,6 +1956,14 @@ void _registerArithmetic(_Registry r) { PipelineFunctions.multiply, (e, v) => e.multiply(v), ); + r.expr( + 'functions/multiply/method-variadic', + () => field('rating').multiply(2, [field('weight')]), + ); + r.expr( + 'functions/multiply/static-variadic', + () => PipelineFunctions.multiply('rating', 2, [field('weight')]), + ); r.binary( 'divide', 'rating', diff --git a/packages/google_cloud_firestore/test/pipeline_single_implementation_test.dart b/packages/google_cloud_firestore/test/pipeline_single_implementation_test.dart index 9e319294..279ebf4a 100644 --- a/packages/google_cloud_firestore/test/pipeline_single_implementation_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_single_implementation_test.dart @@ -837,9 +837,23 @@ final _behaviour = >{ ], // Arithmetic. - 'add': _binary(PipelineFunctions.add, (e, v) => e.add(v)), + 'add': [ + ..._binary(PipelineFunctions.add, (e, v) => e.add(v)), + ..._on( + 'with more operands', + static: (r) => PipelineFunctions.add(r, 1, [field('b'), 3]), + fluent: (e) => e.add(1, [field('b'), 3]), + ), + ], 'subtract': _binary(PipelineFunctions.subtract, (e, v) => e.subtract(v)), - 'multiply': _binary(PipelineFunctions.multiply, (e, v) => e.multiply(v)), + 'multiply': [ + ..._binary(PipelineFunctions.multiply, (e, v) => e.multiply(v)), + ..._on( + 'with more operands', + static: (r) => PipelineFunctions.multiply(r, 2, [field('b'), 3]), + fluent: (e) => e.multiply(2, [field('b'), 3]), + ), + ], 'divide': _binary(PipelineFunctions.divide, (e, v) => e.divide(v)), 'mod': _binary(PipelineFunctions.mod, (e, v) => e.mod(v)), 'pow': _binary(PipelineFunctions.pow, (e, v) => e.pow(v)), diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 57027c14..f6ff281b 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -3005,6 +3005,104 @@ void main() { expect(fields['minN']!.functionValue!.args[1].integerValue, 2); }); + group('Node SDK signatures', () { + late List stages; + + Future run(Pipeline pipeline) 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 pipeline.execute(); + stages = capturedRequest!.structuredPipeline!.pipeline!.stages; + } + + Future> select( + Iterable selections, + ) async { + await run(firestore.pipeline().collection('books').select(selections)); + return stages[1].args.single.mapValue!.fields; + } + + group('variadic add and multiply', () { + for (final (name, static, fluent) in [ + ( + 'add', + PipelineFunctions.add, + (PipelineExpression e, Object? second, Iterable others) => + e.add(second, others), + ), + ( + 'multiply', + PipelineFunctions.multiply, + (PipelineExpression e, Object? second, Iterable others) => + e.multiply(second, others), + ), + ]) { + test('$name sends every operand to one $name call', () async { + final fields = await select([ + static('rating', 2, [field('bonus'), 3]).as('static'), + fluent(field('rating'), 2, [field('bonus'), 3]).as('fluent'), + ]); + + for (final alias in ['static', 'fluent']) { + final function = fields[alias]!.functionValue!; + expect(function.name, name, reason: alias); + expect(function.args.map((arg) => arg.toJson()), [ + {'fieldReferenceValue': 'rating'}, + {'integerValue': '2'}, + {'fieldReferenceValue': 'bonus'}, + {'integerValue': '3'}, + ], reason: alias); + } + }); + + test('$name still takes exactly two operands', () async { + final fields = await select([ + static('rating', 2).as('static'), + fluent(field('rating'), 2, const []).as('fluent'), + ]); + + for (final alias in ['static', 'fluent']) { + final function = fields[alias]!.functionValue!; + expect(function.name, name, reason: alias); + expect(function.args, hasLength(2), reason: alias); + } + }); + } + + test('collections among the operands are built as functions', () async { + final fields = await select([ + PipelineFunctions.add('a', 1, [ + [field('b')], + ]).as('sum'), + ]); + + final operand = fields['sum']!.functionValue!.args[2]; + expect(operand.functionValue!.name, 'array'); + expect(operand.functionValue!.args.single.fieldReferenceValue, 'b'); + }); + }); + }); + group('createFrom', () { late List stages; diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js index c942d423..4185af20 100644 --- a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js +++ b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js @@ -992,11 +992,13 @@ module.exports = function buildCases(sdk, db) { fn('functions', 'add', { 'method-double': () => field('rating').add(0.5), 'method-variadic': () => field('rating').add(1, field('bonus')), + 'static-variadic': () => P.add('rating', 1, field('bonus')), }); binary('subtract', 'rating', 1, () => field('penalty')); binary('multiply', 'rating', 2, () => field('weight')); fn('functions', 'multiply', { 'method-variadic': () => field('rating').multiply(2, field('weight')), + 'static-variadic': () => P.multiply('rating', 2, field('weight')), }); binary('divide', 'rating', 2, () => field('count')); binary('mod', 'rating', 2, () => field('divisor')); From 3e52ffa266f2e5751d94206e6286cdf275119f51 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:39:58 +0200 Subject: [PATCH 14/29] feat(firestore): accept document paths in PipelineSource.documents The Node SDK's documents() takes Array. documents() now takes an Iterable of DocumentReferences and slash-separated document paths, which are read like Firestore.doc reads them, as Node does with db.doc(path): a leading slash is ignored, and a path that does not point to a document, or that is empty or contains "//", throws an ArgumentError. Existing calls with references are unchanged. --- packages/google_cloud_firestore/README.md | 3 +- .../lib/src/pipeline.dart | 27 ++++++-- .../fixtures/pipeline_golden/sources.json | 39 +++++++++++ .../test/pipeline_api_parity_test.dart | 3 - .../test/pipeline_e2e_coverage_test.dart | 2 + .../test/pipeline_golden_test.dart | 16 ++++- .../test/pipeline_test.dart | 64 +++++++++++++++++++ .../tool/pipeline_golden/cases.js | 9 +++ 8 files changed, 151 insertions(+), 12 deletions(-) diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 96c3ed88..2333bcce 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -197,7 +197,7 @@ A pipeline starts from exactly one source, via `firestore.pipeline()`. | `collectionReference(ref)` | Same, from a `CollectionReference`. | | `collectionGroup(id)` | Every collection with the given ID. | | `database()` | Every document in the database. | -| `documents([refs])` | An explicit set of documents. | +| `documents([refsOrPaths])` | An explicit set of documents, as references or paths. | | `createFrom(query)` | An existing `Query` or `VectorQuery`. | ```dart @@ -206,6 +206,7 @@ firestore.pipeline().collectionReference(firestore.collection('books')); firestore.pipeline().collectionGroup('books'); firestore.pipeline().database(); firestore.pipeline().documents([firestore.doc('books/book-1')]); +firestore.pipeline().documents(['books/book-1', 'books/book-2']); ``` #### Stages diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 7700e17c..5bf9be42 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -1551,18 +1551,35 @@ final class PipelineSource { return _start('collection', [collectionReference], rawOptions: rawOptions); } - /// Starts a Pipeline over the provided document references. + /// Starts a Pipeline over the provided documents. + /// + /// Each of [documents] is a [DocumentReference] or a slash-separated + /// document path, such as `'books/book1'`, read like [Firestore.doc] reads + /// it. /// /// [rawOptions] sets stage options this SDK does not wrap yet, as /// [Pipeline.rawStage]'s `options` do. /// - /// Throws an [ArgumentError] when any of [documents] targets a different - /// database than this Pipeline. + /// Throws an [ArgumentError] when [documents] is empty, when an entry is + /// neither a reference nor a path, when a path does not point to a + /// document, or when a reference targets a different database than this + /// Pipeline. Pipeline documents( - Iterable> documents, { + Iterable documents, { Map rawOptions = const {}, }) { - final refs = documents.toList(); + final refs = [ + for (final document in documents) + switch (document) { + String() => _firestore.doc(document), + DocumentReference() => document, + _ => throw ArgumentError.value( + document, + 'documents', + 'Expected a DocumentReference or a document path.', + ), + }, + ]; if (refs.isEmpty) { throw ArgumentError.value(documents, 'documents', 'Must not be empty.'); } diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json index d5cf6f63..1ae3ae87 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/sources.json @@ -137,6 +137,16 @@ } } }, + "sources/documents/leading-slash-path": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "documents", "args": [{"referenceValue": "/books/book1"}]} + ] + } + } + }, "sources/documents/nested": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -150,6 +160,19 @@ } } }, + "sources/documents/nested-path": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "documents", + "args": [{"referenceValue": "/authors/author1/books/book1"}] + } + ] + } + } + }, "sources/documents/paths": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -166,6 +189,22 @@ } } }, + "sources/documents/paths-and-references": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "documents", + "args": [ + {"referenceValue": "/books/book1"}, + {"referenceValue": "/books/book2"} + ] + } + ] + } + } + }, "sources/documents/raw-options": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { diff --git a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart index 386da8e8..53da37af 100644 --- a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart @@ -204,9 +204,6 @@ const _pendingFixes = { 'Takes an Iterable of alternating keys and values; Node ' 'map(elements: Record) takes a map, which Dart ' 'callers cannot pass.', - 'PipelineSource.documents#documents': - 'Only takes DocumentReferences; Node documents() also takes document ' - 'path strings.', 'Pipeline.findNearest#distanceThreshold': 'Dart-only option, sent as `distance_threshold`: Node has no such ' 'find_nearest option and its createFrom(vectorQuery) drops ' diff --git a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart index afc48714..6ceac6b1 100644 --- a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart @@ -463,6 +463,8 @@ const _notValuePositions = { 'PipelineResult.get(field)': 'takes a String or a FieldPath; see the dottedPath and fieldPath shapes', 'PipelineSource.createFrom(query)': 'takes a Query or a VectorQuery', + 'PipelineSource.documents(documents)': + 'takes DocumentReferences and document path Strings', 'constant(value)': 'wraps a literal value; an expression is not a constant', 'field(fieldPath)': 'takes a String or a FieldPath; see the dottedPath and fieldPath shapes', diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index 7ac5ddc0..92cfe07f 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -689,11 +689,9 @@ void _registerSources(_Registry r) { 'sources/database/raw-options', (db) => db.pipeline().database(rawOptions: const {'foo': 'bar'}), ); - // Dart only takes references; Node turns its paths into references. r.pipeline( 'sources/documents/paths', - (db) => - db.pipeline().documents([db.doc('books/book1'), db.doc('books/book2')]), + (db) => db.pipeline().documents(['books/book1', 'books/book2']), ); r.pipeline( 'sources/documents/references', @@ -711,6 +709,18 @@ void _registerSources(_Registry r) { rawOptions: const {'foo': 'bar'}, ), ); + r.pipeline( + 'sources/documents/nested-path', + (db) => db.pipeline().documents(['authors/author1/books/book1']), + ); + r.pipeline( + 'sources/documents/leading-slash-path', + (db) => db.pipeline().documents(['/books/book1']), + ); + r.pipeline( + 'sources/documents/paths-and-references', + (db) => db.pipeline().documents(['books/book1', db.doc('books/book2')]), + ); } void _registerQueries(_Registry r) { diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index f6ff281b..35861e2e 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -3101,6 +3101,70 @@ void main() { expect(operand.functionValue!.args.single.fieldReferenceValue, 'b'); }); }); + + group('documents', () { + test('takes document paths', () async { + await run( + firestore.pipeline().documents([ + 'books/book-1', + '/books/book-2', + 'authors/author-1/books/book-3', + ]), + ); + + final stage = stages.single; + expect(stage.name, 'documents'); + expect(stage.args.map((arg) => arg.referenceValue), [ + '/books/book-1', + '/books/book-2', + '/authors/author-1/books/book-3', + ]); + }); + + test('mixes paths and references', () async { + await run( + firestore.pipeline().documents([ + 'books/book-1', + firestore.doc('books/book-2'), + ]), + ); + + expect(stages.single.args.map((arg) => arg.referenceValue), [ + '/books/book-1', + '/books/book-2', + ]); + }); + + test('rejects paths that do not point to a document', () { + for (final path in ['books', 'authors/author-1/books', '', 'a//b']) { + expect( + () => firestore.pipeline().documents([path]), + throwsArgumentError, + reason: path, + ); + } + }); + + test('rejects values that are neither paths nor references', () { + expect( + () => firestore.pipeline().documents([firestore.collection('a')]), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('DocumentReference or a document path'), + ), + ), + ); + }); + + test('rejects an empty list', () { + expect( + () => firestore.pipeline().documents(const []), + throwsArgumentError, + ); + }); + }); }); group('createFrom', () { diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js index 4185af20..c20a4d95 100644 --- a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js +++ b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js @@ -171,6 +171,15 @@ module.exports = function buildCases(sdk, db) { rawOptions: {foo: 'bar'}, }), ); + add('sources/documents/nested-path', () => + db.pipeline().documents(['authors/author1/books/book1']), + ); + add('sources/documents/leading-slash-path', () => + db.pipeline().documents(['/books/book1']), + ); + add('sources/documents/paths-and-references', () => + db.pipeline().documents(['books/book1', db.doc('books/book2')]), + ); add('sources/subcollection/as-array-expression', () => db .pipeline() From a3ac241afe4dcf3c8c451778056bdc97d86e0e12 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:40:36 +0200 Subject: [PATCH 15/29] feat(firestore): expose PipelineOrdering.expr and direction Like the Node SDK's Ordering, a PipelineOrdering now exposes the expression it sorts by (expr; a field name given to ascending() or descending() reads back as a PipelineField) and its direction, 'ascending' or 'descending'. --- .../lib/src/pipeline.dart | 14 ++++++++++ .../test/pipeline_api_parity_test.dart | 4 --- .../test/pipeline_e2e_coverage_test.dart | 1 + .../test/pipeline_test.dart | 26 +++++++++++++++++++ 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 5bf9be42..ce06764c 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -3354,6 +3354,20 @@ final class PipelineOrdering { final String _name; final Object _expression; + /// The expression this ordering sorts by. + /// + /// A field name passed to [ascending] or [descending] reads back as a + /// [PipelineField]. + PipelineExpression get expr { + return switch (_expression) { + final PipelineExpression expression => expression, + final value => constant(value), + }; + } + + /// The sort direction: `'ascending'` or `'descending'`. + String get direction => _name; + firestore_v1.Value _toValue(Firestore firestore) { return firestore_v1.Value( mapValue: firestore_v1.MapValue( diff --git a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart index 53da37af..2cb4a24e 100644 --- a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart @@ -240,10 +240,6 @@ const _pendingFixes = { 'Pipeline.toScalarExpression': 'Missing: Node turns a Pipeline into a scalar subquery expression.', 'Pipeline.stream': 'Missing: Node streams results; Dart only has execute().', - 'PipelineOrdering.expr': - 'Missing getter: Node Ordering exposes its expression.', - 'PipelineOrdering.direction': - "Missing getter: Node Ordering exposes 'ascending' | 'descending'.", }; void main() { diff --git a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart index 6ceac6b1..5952739d 100644 --- a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart @@ -135,6 +135,7 @@ const _familyByClass = { 'PipelineExplainOptions': 'execute', 'PipelineExplainOutputFormat': 'execute', 'PipelineIndexMode': 'execute', + 'PipelineOrdering': 'stages', 'PipelineResult': 'results', 'PipelineSnapshot': 'results', 'PipelineSource': 'sources', diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 35861e2e..8e080321 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -3165,6 +3165,32 @@ void main() { ); }); }); + + group('PipelineOrdering', () { + test('exposes its expression and direction', () { + final rating = field('rating'); + final ascendingOrdering = rating.ascending(); + final descendingOrdering = rating.descending(); + + expect(ascendingOrdering.expr, same(rating)); + expect(ascendingOrdering.direction, 'ascending'); + expect(descendingOrdering.expr, same(rating)); + expect(descendingOrdering.direction, 'descending'); + }); + + test('reads a field name passed to the top-level helpers', () { + for (final (ordering, direction) in [ + (ascending('rating'), 'ascending'), + (descending('rating'), 'descending'), + ]) { + expect( + ordering.expr, + isA().having((f) => f.path, 'path', 'rating'), + ); + expect(ordering.direction, direction); + } + }); + }); }); group('createFrom', () { From 120dc75ffee040c04da4f09a7b9b284943fa08b0 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:41:17 +0200 Subject: [PATCH 16/29] feat(firestore): accept a Map in PipelineFunctions.map The Node SDK's map() takes a Record, which Dart callers could not pass: PipelineFunctions.map only took an Iterable alternating keys and values. It now also takes a Map, as in map({'title': field('title')}), sent exactly like the alternating form; the golden cases check both forms against Node's request. Keys are sent as strings, and any other argument throws an ArgumentError. Nested Maps in value positions are built through the same path. --- packages/google_cloud_firestore/README.md | 3 +- .../lib/src/pipeline.dart | 28 ++++-- .../test/pipeline_api_parity_test.dart | 4 - .../test/pipeline_e2e_coverage_test.dart | 3 + .../test/pipeline_golden_test.dart | 93 +++++++++---------- .../test/pipeline_test.dart | 49 ++++++++++ 6 files changed, 122 insertions(+), 58 deletions(-) diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 2333bcce..1ca7f8a1 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -442,10 +442,11 @@ maps; `charLength`/`stringReverse`/`stringConcat` and `logicalMaximum` to compare several operands element-wise. `add` and `multiply` take extra operands as a trailing list, sent as a single -backend call: +backend call, and `map` takes a `Map` or a list alternating keys and values: ```dart field('price').add(field('tax'), [field('shipping')]); +PipelineFunctions.map({'title': field('title'), 'rating': 5}); ``` Anything not yet wrapped is reachable via `PipelineFunctions.raw` or diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index ce06764c..27c9bd23 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -342,9 +342,7 @@ Object? _valueToDefaultExpr(Object? value) { return switch (value) { Uint8List() => value, Iterable() => PipelineFunctions.array(value), - Map() => PipelineFunctions.map([ - for (final entry in value.entries) ...[entry.key.toString(), entry.value], - ]), + Map() => PipelineFunctions.map(value), _ => value, }; } @@ -1026,9 +1024,27 @@ abstract final class PipelineFunctions { /// MAP construction function. /// - /// [keyValues] alternates keys and values; values may be expressions. - static PipelineExpression map(Iterable keyValues) { - return _expr('map', keyValues); + /// [keyValues] is either a [Map], as in `map({'title': field('title')})`, + /// or an [Iterable] alternating keys and values, as in + /// `map(['title', field('title')])`. Values may be expressions; a [Map]'s + /// keys are sent as strings. + /// + /// Throws an [ArgumentError] when [keyValues] is neither. + static PipelineExpression map(Object keyValues) { + return _expr('map', switch (keyValues) { + Map() => [ + for (final MapEntry(:key, :value) in keyValues.entries) ...[ + key.toString(), + value, + ], + ], + Iterable() => keyValues, + _ => throw ArgumentError.value( + keyValues, + 'keyValues', + 'Expected a Map, or an Iterable alternating keys and values.', + ), + }); } /// MAP_GET function. diff --git a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart index 2cb4a24e..cba0abe7 100644 --- a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart @@ -200,10 +200,6 @@ const _knownDifferences = { /// Remove an entry once its fix lands; the test fails while it is stale. const _pendingFixes = { // Signatures. - 'PipelineFunctions.map#keyValues': - 'Takes an Iterable of alternating keys and values; Node ' - 'map(elements: Record) takes a map, which Dart ' - 'callers cannot pass.', 'Pipeline.findNearest#distanceThreshold': 'Dart-only option, sent as `distance_threshold`: Node has no such ' 'find_nearest option and its createFrom(vectorQuery) drops ' diff --git a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart index 5952739d..57ce1db9 100644 --- a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart @@ -461,6 +461,9 @@ const _notValuePositions = { 'wraps a literal value; an expression is not a constant', 'Expression.field(fieldPath)': 'takes a String or a FieldPath; see the fieldPath shape', + 'PipelineFunctions.map(keyValues)': + 'takes the Map or alternating key/value Iterable itself; expressions go ' + 'in its values (see the nestedCollectionWithExpression shape)', 'PipelineResult.get(field)': 'takes a String or a FieldPath; see the dottedPath and fieldPath shapes', 'PipelineSource.createFrom(query)': 'takes a Query or a VectorQuery', diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index 92cfe07f..f5e8a6b4 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -1296,12 +1296,10 @@ void _registerStages(_Registry r) { stage( 'replace-with/map-expression', (db) => _books(db).replaceWith( - PipelineFunctions.map([ - 'title', - field('title'), - 'rating', - field('rating'), - ]), + PipelineFunctions.map({ + 'title': field('title'), + 'rating': field('rating'), + }), ), ); stage( @@ -1848,32 +1846,20 @@ void _registerValues(_Registry r) { constant(_bytes).as('bytes'), constant(docRef).as('documentReference'), constant(_vector).as('vectorValue'), - PipelineFunctions.map([ - 'number', - 1, - 'string', - 'a string', - 'boolean', - true, - 'null', - null, - 'geoPoint', - _geoPoint, - 'timestamp', - _timestamp, - 'date', - _date, - 'uint8Array', - _bytes, - 'documentReference', - docRef, - 'vectorValue', - _vector, - 'map', - {'number': 2, 'string': 'b string'}, - 'array', - [1, 'c string'], - ]).as('map'), + PipelineFunctions.map({ + 'number': 1, + 'string': 'a string', + 'boolean': true, + 'null': null, + 'geoPoint': _geoPoint, + 'timestamp': _timestamp, + 'date': _date, + 'uint8Array': _bytes, + 'documentReference': docRef, + 'vectorValue': _vector, + 'map': {'number': 2, 'string': 'b string'}, + 'array': [1, 'c string'], + }).as('map'), PipelineFunctions.array([ 1, 'a string', @@ -1912,12 +1898,7 @@ void _registerValues(_Registry r) { .select(['title', 'author', 'genre', 'rating', 'published', 'tags']) .addFields([ PipelineFunctions.array(metadataArray()).as('metadataArray'), - PipelineFunctions.map([ - for (final MapEntry(:key, :value) in metadata().entries) ...[ - key, - value, - ], - ]).as('metadata'), + PipelineFunctions.map(metadata()).as('metadata'), ]) .where( and([ @@ -2885,24 +2866,42 @@ void _registerArrays(_Registry r) { } void _registerMaps(_Registry r) { + // Node's map(Record) is a Dart Map; the alternating-Iterable form must + // send the same request. r.expr( 'functions/map/static-literals', - () => PipelineFunctions.map(['a', 1, 'b', 'x']), + () => PipelineFunctions.map({'a': 1, 'b': 'x'}), + [ + () => PipelineFunctions.map(['a', 1, 'b', 'x']), + ], ); r.expr( 'functions/map/static-with-expressions', - () => PipelineFunctions.map(['a', field('title'), 'b', 1]), + () => PipelineFunctions.map({'a': field('title'), 'b': 1}), + [ + () => PipelineFunctions.map(['a', field('title'), 'b', 1]), + ], ); r.expr( 'functions/map/static-nested', - () => PipelineFunctions.map([ - 'inner', - {'k': field('y')}, - 'list', - [field('z'), 1], - ]), + () => PipelineFunctions.map({ + 'inner': {'k': field('y')}, + 'list': [field('z'), 1], + }), + [ + () => PipelineFunctions.map([ + 'inner', + {'k': field('y')}, + 'list', + [field('z'), 1], + ]), + ], + ); + r.expr( + 'functions/map/static-empty', + () => PipelineFunctions.map({}), + [() => PipelineFunctions.map(const [])], ); - r.expr('functions/map/static-empty', () => PipelineFunctions.map([])); r.expr( 'functions/mapGet/static-field-name', diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 8e080321..e07c98e5 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -3191,6 +3191,55 @@ void main() { } }); }); + + group('map', () { + test('takes a Map, encoded like alternating keys and values', () async { + final fields = await select([ + PipelineFunctions.map({ + 'title': field('title'), + 'rating': 5, + 'nested': {'genre': field('genre')}, + 'list': [field('tags'), 1], + }).as('fromMap'), + PipelineFunctions.map([ + 'title', + field('title'), + 'rating', + 5, + 'nested', + {'genre': field('genre')}, + 'list', + [field('tags'), 1], + ]).as('fromIterable'), + ]); + + final fromMap = fields['fromMap']!.functionValue!; + expect(fromMap.name, 'map'); + expect(fromMap.args[0].stringValue, 'title'); + expect(fromMap.args[1].fieldReferenceValue, 'title'); + expect(fromMap.args[5].functionValue!.name, 'map'); + expect(fromMap.args[7].functionValue!.name, 'array'); + expect( + fromMap.toJson(), + fields['fromIterable']!.functionValue!.toJson(), + ); + }); + + test('takes an empty Map', () async { + final fields = await select([ + PipelineFunctions.map({}).as('empty'), + ]); + + final function = fields['empty']!.functionValue!; + expect(function.name, 'map'); + expect(function.args, isEmpty); + }); + + test('rejects values that are neither a Map nor an Iterable', () { + expect(() => PipelineFunctions.map('title'), throwsArgumentError); + expect(() => PipelineFunctions.map(field('a')), throwsArgumentError); + }); + }); }); group('createFrom', () { From 9c5a4445844949d74715ab9037ac8fccf9bbe30d Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:39:55 +0200 Subject: [PATCH 17/29] fix(firestore): treat != and not-in as inequalities in implicit orderings isInequalityFilter only covered <, <=, > and >=, so a query filtering with != or not-in was sorted by the document key alone: createFrom(query) built a Pipeline sorted by __name__ where the backend (and Node) sort by the filtered field first, and startAt/startAfter/endAt/endBefore with a DocumentSnapshot built a cursor on the wrong orderings. The two paths now share one implicit ordering, mirroring Node's createImplicitOrderBy: the explicit orderBy clauses, then every inequality field not already ordered, then __name__, all in the direction of the last explicit orderBy. This also fixes two differences the snapshot cursors had from Node: they only used the first inequality field, and only when there was no explicit orderBy. Inequality fields sort segment by segment as UTF-8, like Node's FieldPath.compareTo, instead of by their quoted form, which put `price-tier` before price.amount. New golden cases cover mixed, repeated and composite inequalities, quoted segments, explicit orderings, document key inequalities, limitToLast and cursors with inequalities. --- .../lib/src/pipeline.dart | 52 +- .../reference/composite_filter_internal.dart | 7 - .../src/reference/field_filter_internal.dart | 21 +- .../lib/src/reference/filter_internal.dart | 3 - .../lib/src/reference/query.dart | 80 +- .../fixtures/pipeline_golden/queries.json | 1731 ++++++++++++++--- .../test/pipeline_golden_test.dart | 95 +- .../test/pipeline_test.dart | 71 + .../test/query_test.dart | 106 + .../tool/pipeline_golden/cases.js | 55 + 10 files changed, 1890 insertions(+), 331 deletions(-) diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 27c9bd23..7c82d7c4 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -2227,8 +2227,9 @@ extension _QueryToPipeline on Query { // Inequality fields are skipped here because `_toPipelineBooleanExpression` // has already emitted their existence checks. + final orderedFields = _createImplicitOrderBy(ignoreInequalityFields: true); final existsConditions = [ - for (final fieldOrder in _implicitOrderBy(ignoreInequalityFields: true)) + for (final fieldOrder in orderedFields) field(fieldOrder.fieldPath).exists(), ]; pipeline = pipeline.where( @@ -2238,7 +2239,7 @@ extension _QueryToPipeline on Query { ); final orderings = [ - for (final fieldOrder in _implicitOrderBy()) + for (final fieldOrder in _createImplicitOrderBy()) PipelineOrdering._( fieldOrder.direction == _Direction.ascending ? 'ascending' @@ -2278,53 +2279,6 @@ extension _QueryToPipeline on Query { return pipeline; } - /// Mirrors the backend's implicit ordering rules for this query. - List<_FieldOrder> _implicitOrderBy({bool ignoreInequalityFields = false}) { - final fieldOrders = _queryOptions.fieldOrders.toList(); - final seen = {for (final fieldOrder in fieldOrders) fieldOrder.fieldPath}; - - // The implicit ordering always follows the last explicit order by. - final lastDirection = fieldOrders.isEmpty - ? _Direction.ascending - : fieldOrders.last.direction; - - if (!ignoreInequalityFields) { - // Inequality fields that are not explicitly ordered are ordered - // lexicographically, with the document key sorted last. - for (final inequalityField in _inequalityFilterFields()) { - // The document key is always appended last, below. - if (seen.contains(inequalityField) || - inequalityField == FieldPath.documentId) { - continue; - } - seen.add(inequalityField); - fieldOrders.add( - _FieldOrder(fieldPath: inequalityField, direction: lastDirection), - ); - } - } - - if (!seen.contains(FieldPath.documentId)) { - fieldOrders.add( - _FieldOrder(fieldPath: FieldPath.documentId, direction: lastDirection), - ); - } - - return fieldOrders; - } - - /// The inequality filter fields of this query, sorted lexicographically. - List _inequalityFilterFields() { - final fields = { - for (final filter in _queryOptions.filters) - for (final subFilter in filter.flattenedFilters) - if (subFilter.isInequalityFilter) subFilter.field, - }; - - return fields.toList() - ..sort((a, b) => a._formattedName.compareTo(b._formattedName)); - } - PipelineBooleanExpression _toPipelineBooleanExpression( _FilterInternal filter, ) { diff --git a/packages/google_cloud_firestore/lib/src/reference/composite_filter_internal.dart b/packages/google_cloud_firestore/lib/src/reference/composite_filter_internal.dart index bd9d3c45..14ae7b73 100644 --- a/packages/google_cloud_firestore/lib/src/reference/composite_filter_internal.dart +++ b/packages/google_cloud_firestore/lib/src/reference/composite_filter_internal.dart @@ -31,13 +31,6 @@ class _CompositeFilterInternal extends _FilterInternal { return allFilters..addAll(subFilter.flattenedFilters); }); - @override - FieldPath? get firstInequalityField { - return flattenedFilters - .firstWhereOrNull((filter) => filter.isInequalityFilter) - ?.field; - } - @override firestore_v1.StructuredQuery_Filter toProto() { if (filters.length == 1) return filters.single.toProto(); diff --git a/packages/google_cloud_firestore/lib/src/reference/field_filter_internal.dart b/packages/google_cloud_firestore/lib/src/reference/field_filter_internal.dart index 7032996f..f35829a9 100644 --- a/packages/google_cloud_firestore/lib/src/reference/field_filter_internal.dart +++ b/packages/google_cloud_firestore/lib/src/reference/field_filter_internal.dart @@ -33,14 +33,21 @@ class _FieldFilterInternal extends _FilterInternal { @override List<_FieldFilterInternal> get filters => [this]; - @override - FieldPath? get firstInequalityField => isInequalityFilter ? field : null; - + /// Whether the backend implicitly orders by this filter's field: `<`, `<=`, + /// `>`, `>=`, `!=` and `not-in`, as in the Node SDK. bool get isInequalityFilter { - return op == WhereFilter.lessThan || - op == WhereFilter.lessThanOrEqual || - op == WhereFilter.greaterThan || - op == WhereFilter.greaterThanOrEqual; + return switch (op) { + WhereFilter.lessThan || + WhereFilter.lessThanOrEqual || + WhereFilter.greaterThan || + WhereFilter.greaterThanOrEqual || + WhereFilter.notEqual || + WhereFilter.notIn => true, + WhereFilter.equal || + WhereFilter.arrayContains || + WhereFilter.isIn || + WhereFilter.arrayContainsAny => false, + }; } @override diff --git a/packages/google_cloud_firestore/lib/src/reference/filter_internal.dart b/packages/google_cloud_firestore/lib/src/reference/filter_internal.dart index 851280a7..acfe9e01 100644 --- a/packages/google_cloud_firestore/lib/src/reference/filter_internal.dart +++ b/packages/google_cloud_firestore/lib/src/reference/filter_internal.dart @@ -22,9 +22,6 @@ sealed class _FilterInternal { /// Returns a list of all filters that are contained within this filter List<_FilterInternal> get filters; - /// Returns the field of the first filter that's an inequality, or null if none. - FieldPath? get firstInequalityField; - /// Returns the proto representation of this filter firestore_v1.StructuredQuery_Filter toProto(); diff --git a/packages/google_cloud_firestore/lib/src/reference/query.dart b/packages/google_cloud_firestore/lib/src/reference/query.dart index 8edbc143..cae15c3b 100644 --- a/packages/google_cloud_firestore/lib/src/reference/query.dart +++ b/packages/google_cloud_firestore/lib/src/reference/query.dart @@ -132,7 +132,11 @@ interface class Query { ); } - final fieldOrders = _createImplicitOrderBy(snapshot); + // A DocumentSnapshot cursor takes its values from the snapshot for every + // field the backend orders by, including the implicit orderings. + final fieldOrders = snapshot == null + ? _queryOptions.fieldOrders + : _createImplicitOrderBy(); final cursor = _createCursor( fieldOrders, fieldValues: fieldValues, @@ -142,37 +146,35 @@ interface class Query { return (cursor, fieldOrders); } - /// Computes the backend ordering semantics for DocumentSnapshot cursors. - List<_FieldOrder> _createImplicitOrderBy( - DocumentSnapshot? snapshot, - ) { - // Add an implicit orderBy if the only cursor value is a DocumentSnapshot - // or a DocumentReference. - if (snapshot == null) return _queryOptions.fieldOrders; - + /// The orderings the backend applies to this query: the explicit orderBy + /// clauses, then every other inequality field, then the document key. + /// + /// Mirrors the Node SDK's `createImplicitOrderBy`. The implicit orderings + /// take the direction of the last explicit orderBy, or ascending without + /// one. [ignoreInequalityFields] leaves out the inequality fields. + List<_FieldOrder> _createImplicitOrderBy({ + bool ignoreInequalityFields = false, + }) { final fieldOrders = _queryOptions.fieldOrders.toList(); - - // If no explicit ordering is specified, use the first inequality to - // define an implicit order. - if (fieldOrders.isEmpty) { - for (final filter in _queryOptions.filters) { - final fieldReference = filter.firstInequalityField; - if (fieldReference != null) { - fieldOrders.add(_FieldOrder(fieldPath: fieldReference)); - break; + final seen = {for (final fieldOrder in fieldOrders) fieldOrder.fieldPath}; + + final lastDirection = fieldOrders.isEmpty + ? _Direction.ascending + : fieldOrders.last.direction; + + if (!ignoreInequalityFields) { + for (final inequalityField in _inequalityFilterFields()) { + // The document key is always ordered last, below. + if (inequalityField == FieldPath.documentId) continue; + if (seen.add(inequalityField)) { + fieldOrders.add( + _FieldOrder(fieldPath: inequalityField, direction: lastDirection), + ); } } } - final hasDocumentId = fieldOrders.any( - (fieldOrder) => fieldOrder.fieldPath == FieldPath.documentId, - ); - if (!hasDocumentId) { - // Add implicit sorting by name, using the last specified direction. - final lastDirection = fieldOrders.isEmpty - ? _Direction.ascending - : fieldOrders.last.direction; - + if (!seen.contains(FieldPath.documentId)) { fieldOrders.add( _FieldOrder(fieldPath: FieldPath.documentId, direction: lastDirection), ); @@ -181,6 +183,17 @@ interface class Query { return fieldOrders; } + /// The fields of this query's inequality filters (`<`, `<=`, `>`, `>=`, + /// `!=` and `not-in`), in field path order. + List _inequalityFilterFields() { + final fields = { + for (final filter in _queryOptions.filters) + for (final subFilter in filter.flattenedFilters) + if (subFilter.isInequalityFilter) subFilter.field, + }; + return fields.toList()..sort(_compareFieldPaths); + } + /// Creates and returns a new [Query] that starts at the provided /// set of field values relative to the order of the query. The order of the /// provided values must match the order of the order by clauses of the query. @@ -1226,3 +1239,16 @@ interface class Query { return VectorQuery._(query: this, options: options); } } + +/// Orders field paths segment by segment, comparing segments by their UTF-8 +/// encoding, as the Node SDK's `FieldPath.compareTo` does. +/// +/// This differs from comparing formatted paths: `a.c` sorts before `` `a-b` ``. +int _compareFieldPaths(FieldPath left, FieldPath right) { + final length = math.min(left.segments.length, right.segments.length); + for (var i = 0; i < length; i++) { + final comparison = _compareUtf8Strings(left.segments[i], right.segments[i]); + if (comparison != 0) return comparison; + } + return left.segments.length.compareTo(right.segments.length); +} diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json index 2bc1be8d..a230e138 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json @@ -2,6 +2,78 @@ "generator": "tool/pipeline_golden (npm run generate)", "sdk": "@google-cloud/firestore@9.3.1", "cases": { + "queries/collection-group/not-equal": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + { + "name": "collection_group", + "args": [{"referenceValue": ""}, {"stringValue": "reviews"}] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "3"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, "queries/collection-group/plain": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -841,12 +913,39 @@ } } }, - "queries/limit-to-last/plain": { + "queries/cursor/with-inequality": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { "stages": [ {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Horror"} + ] + } + } + ] + } + } + ] + }, { "name": "where", "args": [ @@ -877,30 +976,16 @@ { "mapValue": { "fields": { - "direction": {"stringValue": "descending"}, + "direction": {"stringValue": "ascending"}, "expression": {"fieldReferenceValue": "rating"} } } }, - { - "mapValue": { - "fields": { - "direction": {"stringValue": "descending"}, - "expression": {"fieldReferenceValue": "__name__"} - } - } - } - ] - }, - {"name": "limit", "args": [{"integerValue": "5"}]}, - { - "name": "sort", - "args": [ { "mapValue": { "fields": { "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "rating"} + "expression": {"fieldReferenceValue": "genre"} } } }, @@ -913,17 +998,45 @@ } } ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] } ] } } }, - "queries/limit/with-order-by": { + "queries/cursor/with-inequality-and-limit-to-last": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { "stages": [ {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + {"arrayValue": {"values": [{"stringValue": "Horror"}]}} + ] + } + } + ] + }, { "name": "where", "args": [ @@ -954,7 +1067,7 @@ { "mapValue": { "fields": { - "direction": {"stringValue": "ascending"}, + "direction": {"stringValue": "descending"}, "expression": {"fieldReferenceValue": "rating"} } } @@ -962,111 +1075,58 @@ { "mapValue": { "fields": { - "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "__name__"} + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "genre"} } } - } - ] - }, - {"name": "limit", "args": [{"integerValue": "5"}]} - ] - } - } - }, - "queries/limit/without-order-by": { - "database": "projects/test-project/databases/(default)", - "structuredPipeline": { - "pipeline": { - "stages": [ - {"name": "collection", "args": [{"referenceValue": "/books"}]}, - { - "name": "where", - "args": [ - { - "functionValue": { - "name": "exists", - "args": [{"fieldReferenceValue": "__name__"}] - } - } - ] - }, - { - "name": "sort", - "args": [ + }, { "mapValue": { "fields": { - "direction": {"stringValue": "ascending"}, + "direction": {"stringValue": "descending"}, "expression": {"fieldReferenceValue": "__name__"} } } } ] }, - {"name": "limit", "args": [{"integerValue": "5"}]} - ] - } - } - }, - "queries/offset/plain": { - "database": "projects/test-project/databases/(default)", - "structuredPipeline": { - "pipeline": { - "stages": [ - {"name": "collection", "args": [{"referenceValue": "/books"}]}, { "name": "where", "args": [ { "functionValue": { - "name": "exists", - "args": [{"fieldReferenceValue": "__name__"}] - } - } - ] - }, - { - "name": "sort", - "args": [ - { - "mapValue": { - "fields": { - "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "__name__"} - } + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] } } ] }, - {"name": "offset", "args": [{"integerValue": "10"}]} - ] - } - } - }, - "queries/offset/with-limit": { - "database": "projects/test-project/databases/(default)", - "structuredPipeline": { - "pipeline": { - "stages": [ - {"name": "collection", "args": [{"referenceValue": "/books"}]}, { "name": "where", "args": [ { "functionValue": { - "name": "and", + "name": "or", "args": [ { "functionValue": { - "name": "exists", - "args": [{"fieldReferenceValue": "rating"}] + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] } }, { "functionValue": { - "name": "exists", - "args": [{"fieldReferenceValue": "__name__"}] + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] } } ] @@ -1074,6 +1134,7 @@ } ] }, + {"name": "limit", "args": [{"integerValue": "3"}]}, { "name": "sort", "args": [ @@ -1085,6 +1146,14 @@ } } }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "genre"} + } + } + }, { "mapValue": { "fields": { @@ -1094,14 +1163,12 @@ } } ] - }, - {"name": "limit", "args": [{"integerValue": "5"}]}, - {"name": "offset", "args": [{"integerValue": "10"}]} + } ] } } }, - "queries/order-by/ascending": { + "queries/limit-to-last/plain": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { @@ -1137,7 +1204,1200 @@ { "mapValue": { "fields": { - "direction": {"stringValue": "ascending"}, + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "5"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/limit-to-last/with-inequality": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Horror"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "genre"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "2"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "genre"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/limit/with-order-by": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "5"}]} + ] + } + } + }, + "queries/limit/without-order-by": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "5"}]} + ] + } + } + }, + "queries/offset/plain": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "offset", "args": [{"integerValue": "10"}]} + ] + } + } + }, + "queries/offset/with-limit": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "5"}]}, + {"name": "offset", "args": [{"integerValue": "10"}]} + ] + } + } + }, + "queries/order-by/ascending": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/descending": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/document-id": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/document-id-and-inequality": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "__name__"}, + { + "referenceValue": "projects/test-project/databases/(default)/documents/books/book1" + } + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "5"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/explicit-and-inequality": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "title"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "title"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/field-path-object": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "`a.b`"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "`a.b`"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/implicit-field-path-order": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "`price-tier`"}] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "`price-tier`"}, + {"integerValue": "3"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "price.amount"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "price.amount"}, + {"integerValue": "1"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "price.amount"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "`price-tier`"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/implicit-from-composite-or": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + }, + { + "functionValue": { + "name": "not_equal_any", + "args": [ + {"fieldReferenceValue": "genre"}, + { + "arrayValue": { + "values": [ + {"stringValue": "Horror"}, + {"stringValue": "Romance"} + ] + } + } + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "genre"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/implicit-from-inequalities": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "published"}] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "published"}, + {"integerValue": "2000"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "published"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/implicit-from-inequality": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, + "queries/order-by/implicit-from-mixed-inequalities": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Horror"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "genre"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, "expression": {"fieldReferenceValue": "rating"} } } @@ -1156,7 +2416,7 @@ } } }, - "queries/order-by/descending": { + "queries/order-by/implicit-from-repeated-field": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { @@ -1175,10 +2435,40 @@ "args": [{"fieldReferenceValue": "rating"}] } }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ { "functionValue": { "name": "exists", - "args": [{"fieldReferenceValue": "__name__"}] + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "5"} + ] } } ] @@ -1186,13 +2476,24 @@ } ] }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, { "name": "sort", "args": [ { "mapValue": { "fields": { - "direction": {"stringValue": "descending"}, + "direction": {"stringValue": "ascending"}, "expression": {"fieldReferenceValue": "rating"} } } @@ -1200,7 +2501,7 @@ { "mapValue": { "fields": { - "direction": {"stringValue": "descending"}, + "direction": {"stringValue": "ascending"}, "expression": {"fieldReferenceValue": "__name__"} } } @@ -1211,7 +2512,7 @@ } } }, - "queries/order-by/document-id": { + "queries/order-by/implicit-quoted-segments": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { @@ -1222,35 +2523,55 @@ "args": [ { "functionValue": { - "name": "exists", - "args": [{"fieldReferenceValue": "__name__"}] + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "field"}] + } + }, + { + "functionValue": { + "name": "greater_than_or_equal", + "args": [ + {"fieldReferenceValue": "field"}, + {"stringValue": "field 100"} + ] + } + } + ] } } ] }, { - "name": "sort", + "name": "where", "args": [ { - "mapValue": { - "fields": { - "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "__name__"} - } + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "`field.dot`"}] + } + }, + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "`field.dot`"}, + {"integerValue": "300"} + ] + } + } + ] } } ] - } - ] - } - } - }, - "queries/order-by/explicit-and-inequality": { - "database": "projects/test-project/databases/(default)", - "structuredPipeline": { - "pipeline": { - "stages": [ - {"name": "collection", "args": [{"referenceValue": "/books"}]}, + }, { "name": "where", "args": [ @@ -1261,15 +2582,15 @@ { "functionValue": { "name": "exists", - "args": [{"fieldReferenceValue": "rating"}] + "args": [{"fieldReferenceValue": "`field\\\\slash`"}] } }, { "functionValue": { - "name": "greater_than", + "name": "less_than", "args": [ - {"fieldReferenceValue": "rating"}, - {"integerValue": "4"} + {"fieldReferenceValue": "`field\\\\slash`"}, + {"integerValue": "400"} ] } } @@ -1288,7 +2609,7 @@ { "functionValue": { "name": "exists", - "args": [{"fieldReferenceValue": "title"}] + "args": [{"fieldReferenceValue": "name"}] } }, { @@ -1309,7 +2630,7 @@ "mapValue": { "fields": { "direction": {"stringValue": "descending"}, - "expression": {"fieldReferenceValue": "title"} + "expression": {"fieldReferenceValue": "name"} } } }, @@ -1317,7 +2638,23 @@ "mapValue": { "fields": { "direction": {"stringValue": "descending"}, - "expression": {"fieldReferenceValue": "rating"} + "expression": {"fieldReferenceValue": "field"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "`field.dot`"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "`field\\\\slash`"} } } }, @@ -1335,7 +2672,7 @@ } } }, - "queries/order-by/field-path-object": { + "queries/order-by/multiple": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { @@ -1351,7 +2688,13 @@ { "functionValue": { "name": "exists", - "args": [{"fieldReferenceValue": "`a.b`"}] + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "title"}] } }, { @@ -1368,11 +2711,19 @@ { "name": "sort", "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, { "mapValue": { "fields": { "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "`a.b`"} + "expression": {"fieldReferenceValue": "title"} } } }, @@ -1390,7 +2741,7 @@ } } }, - "queries/order-by/implicit-from-inequalities": { + "queries/order-by/not-equal-explicitly-ordered": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { @@ -1406,15 +2757,15 @@ { "functionValue": { "name": "exists", - "args": [{"fieldReferenceValue": "rating"}] + "args": [{"fieldReferenceValue": "genre"}] } }, { "functionValue": { - "name": "greater_than", + "name": "not_equal", "args": [ - {"fieldReferenceValue": "rating"}, - {"integerValue": "4"} + {"fieldReferenceValue": "genre"}, + {"stringValue": "Horror"} ] } } @@ -1433,16 +2784,13 @@ { "functionValue": { "name": "exists", - "args": [{"fieldReferenceValue": "published"}] + "args": [{"fieldReferenceValue": "genre"}] } }, { "functionValue": { - "name": "less_than", - "args": [ - {"fieldReferenceValue": "published"}, - {"integerValue": "2000"} - ] + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] } } ] @@ -1450,40 +2798,21 @@ } ] }, - { - "name": "where", - "args": [ - { - "functionValue": { - "name": "exists", - "args": [{"fieldReferenceValue": "__name__"}] - } - } - ] - }, { "name": "sort", "args": [ { "mapValue": { "fields": { - "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "published"} - } - } - }, - { - "mapValue": { - "fields": { - "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "rating"} + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "genre"} } } }, { "mapValue": { "fields": { - "direction": {"stringValue": "ascending"}, + "direction": {"stringValue": "descending"}, "expression": {"fieldReferenceValue": "__name__"} } } @@ -1494,7 +2823,7 @@ } } }, - "queries/order-by/implicit-from-inequality": { + "queries/order-by/not-in-and-descending": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { "pipeline": { @@ -1505,20 +2834,14 @@ "args": [ { "functionValue": { - "name": "and", + "name": "not_equal_any", "args": [ + {"fieldReferenceValue": "genre"}, { - "functionValue": { - "name": "exists", - "args": [{"fieldReferenceValue": "rating"}] - } - }, - { - "functionValue": { - "name": "greater_than", - "args": [ - {"fieldReferenceValue": "rating"}, - {"integerValue": "4"} + "arrayValue": { + "values": [ + {"stringValue": "Horror"}, + {"stringValue": "Romance"} ] } } @@ -1527,48 +2850,6 @@ } ] }, - { - "name": "where", - "args": [ - { - "functionValue": { - "name": "exists", - "args": [{"fieldReferenceValue": "__name__"}] - } - } - ] - }, - { - "name": "sort", - "args": [ - { - "mapValue": { - "fields": { - "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "rating"} - } - } - }, - { - "mapValue": { - "fields": { - "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "__name__"} - } - } - } - ] - } - ] - } - } - }, - "queries/order-by/multiple": { - "database": "projects/test-project/databases/(default)", - "structuredPipeline": { - "pipeline": { - "stages": [ - {"name": "collection", "args": [{"referenceValue": "/books"}]}, { "name": "where", "args": [ @@ -1582,12 +2863,6 @@ "args": [{"fieldReferenceValue": "rating"}] } }, - { - "functionValue": { - "name": "exists", - "args": [{"fieldReferenceValue": "title"}] - } - }, { "functionValue": { "name": "exists", @@ -1613,15 +2888,15 @@ { "mapValue": { "fields": { - "direction": {"stringValue": "ascending"}, - "expression": {"fieldReferenceValue": "title"} + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "genre"} } } }, { "mapValue": { "fields": { - "direction": {"stringValue": "ascending"}, + "direction": {"stringValue": "descending"}, "expression": {"fieldReferenceValue": "__name__"} } } diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index f5e8a6b4..e96104c3 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -93,16 +93,6 @@ final _knownDivergences = { const _pendingFixes = { // Ordering helpers. // Query to Pipeline conversion. - 'queries/where/not-equal': - "createFrom(query) leaves '!=' fields out of the implicit sort; " - 'Node sorts by them before __name__ (isInequalityFilter omits ' - 'notEqual).', - 'queries/where/not-equal-null': - "createFrom(query) leaves '!= null' fields out of the implicit sort; " - 'Node sorts by them before __name__.', - 'queries/where/not-in': - "createFrom(query) leaves 'not-in' fields out of the implicit sort; " - 'Node sorts by them before __name__ (isInequalityFilter omits notIn).', 'queries/vector/distance-result-field': 'createFrom(vectorQuery) sends a distance_field option; Node drops ' 'distanceResultField. Dart keeps the query semantics: decide whether ' @@ -742,6 +732,11 @@ void _registerQueries(_Registry r) { .collectionGroup('reviews') .where('rating', WhereFilter.greaterThan, 3), ); + query( + 'collection-group/not-equal', + (db) => + db.collectionGroup('reviews').where('rating', WhereFilter.notEqual, 3), + ); query( 'where/less-than', @@ -892,6 +887,63 @@ void _registerQueries(_Registry r) { .where('rating', WhereFilter.greaterThan, 4) .orderBy('title', descending: true), ); + query( + 'order-by/implicit-from-mixed-inequalities', + (db) => col(db) + .where('rating', WhereFilter.greaterThan, 4) + .where('genre', WhereFilter.notEqual, 'Horror'), + ); + query( + 'order-by/implicit-from-repeated-field', + (db) => col(db) + .where('rating', WhereFilter.greaterThan, 2) + .where('rating', WhereFilter.lessThan, 5), + ); + query( + 'order-by/implicit-from-composite-or', + (db) => col(db).whereFilter( + Filter.or([ + Filter.where('rating', WhereFilter.greaterThan, 4), + Filter.where('genre', WhereFilter.notIn, ['Horror', 'Romance']), + ]), + ), + ); + query( + 'order-by/implicit-field-path-order', + (db) => col(db) + .where('price-tier', WhereFilter.lessThan, 3) + .where('price.amount', WhereFilter.greaterThan, 1), + ); + query( + 'order-by/implicit-quoted-segments', + (db) => col(db) + .where('field', WhereFilter.greaterThanOrEqual, 'field 100') + .where(FieldPath(const ['field.dot']), WhereFilter.notEqual, 300) + .where(r'field\slash', WhereFilter.lessThan, 400) + .orderBy('name', descending: true), + ); + query( + 'order-by/not-equal-explicitly-ordered', + (db) => col(db) + .where('genre', WhereFilter.notEqual, 'Horror') + .orderBy('genre', descending: true), + ); + query( + 'order-by/not-in-and-descending', + (db) => col(db) + .where('genre', WhereFilter.notIn, ['Horror', 'Romance']) + .orderBy('rating', descending: true), + ); + query( + 'order-by/document-id-and-inequality', + (db) => col(db) + .where( + FieldPath.documentId, + WhereFilter.greaterThan, + col(db).doc('book1'), + ) + .where('rating', WhereFilter.lessThan, 5), + ); query('limit/with-order-by', (db) => col(db).orderBy('rating').limit(5)); query('limit/without-order-by', (db) => col(db).limit(5)); @@ -899,6 +951,13 @@ void _registerQueries(_Registry r) { 'limit-to-last/plain', (db) => col(db).orderBy('rating').limitToLast(5), ); + query( + 'limit-to-last/with-inequality', + (db) => col(db) + .where('genre', WhereFilter.notEqual, 'Horror') + .orderBy('rating') + .limitToLast(2), + ); query('offset/plain', (db) => col(db).offset(10)); query( 'offset/with-limit', @@ -925,6 +984,22 @@ void _registerQueries(_Registry r) { (db) => col(db).orderBy('rating').startAt([2]).endBefore([5]).limitToLast(3), ); + query( + 'cursor/with-inequality', + (db) => col(db) + .where('genre', WhereFilter.notEqual, 'Horror') + .orderBy('rating') + .startAfter([4]), + ); + query( + 'cursor/with-inequality-and-limit-to-last', + (db) => col(db) + .where('genre', WhereFilter.notIn, ['Horror']) + .orderBy('rating') + .startAfter([2]) + .endAt([4]) + .limitToLast(3), + ); query( 'select/fields', diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index e07c98e5..86bc69dc 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -3342,6 +3342,77 @@ void main() { expect(orderings, ['__name__']); }); + /// The (field, direction) pairs of the last sort stage. + List<(String?, String?)> sortOrder() => [ + for (final arg in stages.lastWhere((s) => s.name == 'sort').args) + ( + arg.mapValue!.fields['expression']!.fieldReferenceValue, + arg.mapValue!.fields['direction']!.stringValue, + ), + ]; + + test('orders by != and not-in fields like range fields', () async { + // Regression: '!=' and 'not-in' fields were left out of the implicit + // ordering, so the Pipeline sorted only by the document key. + await run( + firestore + .collection('books') + .where('rating', WhereFilter.greaterThan, 4) + .where('genre', WhereFilter.notEqual, 'Horror') + .where('author', WhereFilter.notIn, ['Anonymous']), + ); + + // Inequality fields in field path order, then the document key. + expect(sortOrder(), [ + ('author', 'ascending'), + ('genre', 'ascending'), + ('rating', 'ascending'), + ('__name__', 'ascending'), + ]); + }); + + test('sorts inequality fields by segment, not quoted path', () async { + await run( + firestore + .collection('books') + .where('price-tier', WhereFilter.lessThan, 3) + .where('price.amount', WhereFilter.greaterThan, 1), + ); + + // `price-tier` is quoted on the wire, but `price` sorts first. + expect(sortOrder().map((order) => order.$1), [ + 'price.amount', + '`price-tier`', + '__name__', + ]); + }); + + test('implicit orderings take the last explicit direction', () async { + await run( + firestore + .collection('books') + .where('genre', WhereFilter.notIn, ['Horror']) + .orderBy('rating', descending: true), + ); + + expect(sortOrder(), [ + ('rating', 'descending'), + ('genre', 'descending'), + ('__name__', 'descending'), + ]); + + // Existence checks cover the explicit ordering and the key; the + // inequality filter brings its own (none, for not-in). + final exists = stages[2].args.single.functionValue!; + expect(exists.name, 'and'); + expect( + exists.args.map( + (arg) => arg.functionValue!.args.single.fieldReferenceValue, + ), + ['rating', '__name__'], + ); + }); + test('converts composite filters', () async { await run( firestore diff --git a/packages/google_cloud_firestore/test/query_test.dart b/packages/google_cloud_firestore/test/query_test.dart index 10aeb45c..074fef44 100644 --- a/packages/google_cloud_firestore/test/query_test.dart +++ b/packages/google_cloud_firestore/test/query_test.dart @@ -168,6 +168,112 @@ void main() { }); }); + group('DocumentSnapshot cursors (unit)', () { + late MockFirestoreHttpClient mockClient; + late Firestore firestore; + late List requests; + + setUp(() { + mockClient = MockFirestoreHttpClient(); + firestore = Firestore.internal( + settings: const Settings(projectId: mockProjectId), + client: mockClient, + ); + requests = []; + + when(() => mockClient.cachedProjectId).thenReturn(mockProjectId); + when( + () => mockClient.v1>(any()), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> Function( + firestore_v1.Firestore api, + String projectId, + ); + final api = FakeFirestore( + runQuery: (request) { + requests.add(request); + return Stream.value( + _documentResponse( + documentPath: 'books/book1', + fields: {'genre': 'Fantasy', 'rating': 5, 'title': 'Dune'}, + firestore: firestore, + ), + ); + }, + ); + return callback(api, mockProjectId); + }); + }); + + /// Runs `query.startAfterDocument(...)` and returns the orderings and the + /// cursor values it sends. + Future<(List, List)> startAfterDocument( + Query query, + ) async { + final snapshot = (await query.get()).docs.single; + await query.startAfterDocument(snapshot).get(); + + final structuredQuery = requests.last.structuredQuery!; + final orderings = [ + for (final order in structuredQuery.orderBy) + '${order.field!.fieldPath} ${order.direction.value}', + ]; + expect(structuredQuery.startAt!.before, isFalse); + return (orderings, structuredQuery.startAt!.values); + } + + test('orders by a != field before the document key', () async { + // Regression: '!=' was not an inequality, so the cursor ordered by the + // document key alone instead of the order the backend uses. + final (orderings, values) = await startAfterDocument( + firestore + .collection('books') + .where('genre', WhereFilter.notEqual, 'Horror'), + ); + + expect(orderings, ['genre ASCENDING', '__name__ ASCENDING']); + expect(values.first.stringValue, 'Fantasy'); + expect(values.last.referenceValue, endsWith('/documents/books/book1')); + }); + + test('orders by every inequality field, in field path order', () async { + final (orderings, values) = await startAfterDocument( + firestore + .collection('books') + .where('rating', WhereFilter.greaterThan, 4) + .where('genre', WhereFilter.notIn, ['Horror']), + ); + + expect(orderings, [ + 'genre ASCENDING', + 'rating ASCENDING', + '__name__ ASCENDING', + ]); + expect(values.map((value) => value.stringValue ?? value.integerValue), [ + 'Fantasy', + 5, + null, + ]); + }); + + test('appends inequality fields after the explicit orderBy', () async { + final (orderings, _) = await startAfterDocument( + firestore + .collection('books') + .where('rating', WhereFilter.greaterThan, 4) + .orderBy('title', descending: true), + ); + + expect(orderings, [ + 'title DESCENDING', + 'rating DESCENDING', + '__name__ DESCENDING', + ]); + }); + }); + group('query interface', () { late Firestore firestore; diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js index c20a4d95..36823266 100644 --- a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js +++ b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js @@ -206,6 +206,9 @@ module.exports = function buildCases(sdk, db) { query('collection-group/where', () => db.collectionGroup('reviews').where('rating', '>', 3), ); + query('collection-group/not-equal', () => + db.collectionGroup('reviews').where('rating', '!=', 3), + ); query('where/less-than', () => col().where('rating', '<', 4)); query('where/less-than-or-equal', () => col().where('rating', '<=', 4)); @@ -290,10 +293,51 @@ module.exports = function buildCases(sdk, db) { query('order-by/explicit-and-inequality', () => col().where('rating', '>', 4).orderBy('title', 'desc'), ); + // '!=' and 'not-in' are inequalities too: the backend orders by their + // fields, together with the range fields, in field path order. + query('order-by/implicit-from-mixed-inequalities', () => + col().where('rating', '>', 4).where('genre', '!=', 'Horror'), + ); + query('order-by/implicit-from-repeated-field', () => + col().where('rating', '>', 2).where('rating', '<', 5), + ); + query('order-by/implicit-from-composite-or', () => + col().where( + Filter.or( + Filter.where('rating', '>', 4), + Filter.where('genre', 'not-in', ['Horror', 'Romance']), + ), + ), + ); + // Field paths sort segment by segment, not by their quoted form. + query('order-by/implicit-field-path-order', () => + col().where('price-tier', '<', 3).where('price.amount', '>', 1), + ); + query('order-by/implicit-quoted-segments', () => + col() + .where('field', '>=', 'field 100') + .where(new FieldPath('field.dot'), '!=', 300) + .where('field\\slash', '<', 400) + .orderBy('name', 'desc'), + ); + query('order-by/not-equal-explicitly-ordered', () => + col().where('genre', '!=', 'Horror').orderBy('genre', 'desc'), + ); + query('order-by/not-in-and-descending', () => + col() + .where('genre', 'not-in', ['Horror', 'Romance']) + .orderBy('rating', 'desc'), + ); + query('order-by/document-id-and-inequality', () => + col().where(FieldPath.documentId(), '>', 'book1').where('rating', '<', 5), + ); query('limit/with-order-by', () => col().orderBy('rating').limit(5)); query('limit/without-order-by', () => col().limit(5)); query('limit-to-last/plain', () => col().orderBy('rating').limitToLast(5)); + query('limit-to-last/with-inequality', () => + col().where('genre', '!=', 'Horror').orderBy('rating').limitToLast(2), + ); query('offset/plain', () => col().offset(10)); query('offset/with-limit', () => col().orderBy('rating').limit(5).offset(10)); @@ -310,6 +354,17 @@ module.exports = function buildCases(sdk, db) { query('cursor/limit-to-last', () => col().orderBy('rating').startAt(2).endBefore(5).limitToLast(3), ); + query('cursor/with-inequality', () => + col().where('genre', '!=', 'Horror').orderBy('rating').startAfter(4), + ); + query('cursor/with-inequality-and-limit-to-last', () => + col() + .where('genre', 'not-in', ['Horror']) + .orderBy('rating') + .startAfter(2) + .endAt(4) + .limitToLast(3), + ); query('select/fields', () => col().select('title', 'author')); query('select/empty', () => col().select()); From 9ff665f043f595b5797981f0e2d49995b3ceb5db Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:40:36 +0200 Subject: [PATCH 18/29] fix(firestore): keep a VectorQuery's distance threshold in createFrom createFrom(vectorQuery) sent the threshold as a distance_threshold option of find_nearest. The stage documents no such option (only limit and distance_field, in the Firestore docs, the Node SDK and the system tests), so the backend may ignore or reject it. Node drops the threshold, which returns documents beyond it. The threshold is now a where stage after find_nearest that compares the distance with it: at most the threshold for euclidean and cosine, at least for dot product, as VectorQuery documents. It reads the distance result field when the query has one, and otherwise computes the distance with euclidean_distance, cosine_distance or dot_product. The documents within the threshold are the nearest ones, so filtering the limit nearest neighbors returns the documents the VectorQuery returns. The distance result field is still sent as find_nearest's distance_field, which Node drops. Both are now known divergences, with golden cases for every distance measure and for a threshold with a result field. --- .../lib/src/pipeline.dart | 42 ++- .../fixtures/pipeline_golden/queries.json | 311 ++++++++++++++++++ .../test/pipeline_golden_test.dart | 139 +++++++- .../test/pipeline_test.dart | 80 +++++ .../tool/pipeline_golden/cases.js | 12 + 5 files changed, 570 insertions(+), 14 deletions(-) diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 7c82d7c4..415f5950 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -1614,7 +1614,9 @@ final class PipelineSource { /// /// [query] must be a [Query] or a [VectorQuery]. Its filters, projections, /// orderings, cursors, limit and offset are all translated into the - /// corresponding Pipeline stages. + /// corresponding Pipeline stages. A [VectorQuery] becomes a find_nearest + /// stage, followed by a where stage on the distance when it has a + /// distance threshold. /// /// Throws an [ArgumentError] when [query] targets a different database than /// this Pipeline. @@ -2393,24 +2395,54 @@ PipelineBooleanExpression _cursorCondition( } /// Translates the [VectorQuery] surface onto Pipeline stages. +/// +/// Mirrors the Node SDK's `VectorQuery._pipeline()`, except that it keeps the +/// options Node drops: the distance result field becomes find_nearest's +/// `distance_field`, and the distance threshold a filter on the distance. extension _VectorQueryToPipeline on VectorQuery { Pipeline _toPipeline(Firestore firestore) { // Both fields are a String or a FieldPath; field() reads either. final vectorField = field(_options.vectorField); + final queryVector = FieldValue.vector(_rawQueryVector); final distanceResultField = _options.distanceResultField; - return _query + final distanceMeasure = _options.distanceMeasure; + + final pipeline = _query ._toPipeline(firestore) .where(vectorField.exists()) ._findNearest( vectorField: vectorField, - queryVector: FieldValue.vector(_rawQueryVector), - distanceMeasure: _options.distanceMeasure, + queryVector: queryVector, + distanceMeasure: distanceMeasure, limit: _options.limit, distanceField: distanceResultField == null ? null : field(distanceResultField), - distanceThreshold: _options.distanceThreshold, + distanceThreshold: null, ); + + final threshold = _options.distanceThreshold; + if (threshold == null) return pipeline; + + // The find_nearest stage documents no threshold option, so the threshold + // filters the nearest neighbors instead. The documents within the + // threshold are the nearest ones, so filtering the `limit` nearest + // neighbors keeps exactly the documents the query returns. + final distance = distanceResultField != null + ? field(distanceResultField) + : switch (distanceMeasure) { + DistanceMeasure.euclidean => vectorField.euclideanDistance( + queryVector, + ), + DistanceMeasure.cosine => vectorField.cosineDistance(queryVector), + DistanceMeasure.dotProduct => vectorField.dotProduct(queryVector), + }; + // A larger dot product means more similar vectors. + return pipeline.where( + distanceMeasure == DistanceMeasure.dotProduct + ? distance.greaterThanOrEqual(threshold) + : distance.lessThanOrEqual(threshold), + ); } } diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json index a230e138..e992a049 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json @@ -3311,6 +3311,213 @@ } } }, + "queries/vector/distance-threshold-and-result-field": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/vector/distance-threshold-cosine": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "cosine"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, + "queries/vector/distance-threshold-dot-product": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "dot_product"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, "queries/vector/dot-product": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -3656,6 +3863,110 @@ } } }, + "queries/vector/with-inequality-prefilter": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "genre"}] + } + }, + { + "functionValue": { + "name": "not_equal", + "args": [ + {"fieldReferenceValue": "genre"}, + {"stringValue": "Horror"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "genre"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "embedding"}] + } + } + ] + }, + { + "name": "find_nearest", + "args": [ + {"fieldReferenceValue": "embedding"}, + { + "mapValue": { + "fields": { + "__type__": {"stringValue": "__vector__"}, + "value": { + "arrayValue": { + "values": [ + {"doubleValue": 0.1}, + {"doubleValue": 0.2}, + {"doubleValue": 0.3} + ] + } + } + } + } + }, + {"stringValue": "euclidean"} + ], + "options": {"limit": {"integerValue": "5"}} + } + ] + } + } + }, "queries/vector/with-prefilter": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index e96104c3..2d71f884 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -85,22 +85,64 @@ final _knownDivergences = { '`...others` at runtime: they only forward `second` to the method.', _selectsThreeOperands, ), + 'queries/vector/distance-result-field': _Divergence( + _distanceResultFieldReason, + (request) => _findNearestDistanceField(request) == 'distance', + ), + 'queries/vector/distance-threshold': _Divergence( + _distanceThresholdReason, + (request) => _filtersDistance( + request, + comparison: 'less_than_or_equal', + distanceFunction: 'euclidean_distance', + ), + ), + 'queries/vector/distance-threshold-cosine': _Divergence( + _distanceThresholdReason, + (request) => _filtersDistance( + request, + comparison: 'less_than_or_equal', + distanceFunction: 'cosine_distance', + ), + ), + 'queries/vector/distance-threshold-dot-product': _Divergence( + _distanceThresholdReason, + (request) => _filtersDistance( + request, + comparison: 'greater_than_or_equal', + distanceFunction: 'dot_product', + ), + ), + 'queries/vector/distance-threshold-and-result-field': _Divergence( + '$_distanceResultFieldReason $_distanceThresholdReason With a distance ' + 'result field, the filter reads that field.', + (request) => + _findNearestDistanceField(request) == 'distance' && + _filtersDistance( + request, + comparison: 'less_than_or_equal', + distanceField: 'distance', + ), + ), }; +const _distanceResultFieldReason = + "createFrom(vectorQuery) keeps distanceResultField as find_nearest's " + 'distance_field option, so each result carries its distance as it does ' + 'for the VectorQuery. Node drops it.'; + +const _distanceThresholdReason = + 'createFrom(vectorQuery) applies distanceThreshold as a where stage on ' + 'the distance after find_nearest (<= for euclidean and cosine, >= for ' + 'dot product), keeping the VectorQuery results. Node drops the threshold ' + 'and returns documents beyond it; find_nearest documents no threshold ' + 'option.'; + /// Differences from the Node SDK that are not intended, awaiting a fix. /// /// Each entry must still differ from its fixture; remove it once fixed. const _pendingFixes = { // Ordering helpers. - // Query to Pipeline conversion. - 'queries/vector/distance-result-field': - 'createFrom(vectorQuery) sends a distance_field option; Node drops ' - 'distanceResultField. Dart keeps the query semantics: decide whether ' - 'this should be a known divergence.', - 'queries/vector/distance-threshold': - 'createFrom(vectorQuery) sends a distance_threshold option; Node drops ' - 'distanceThreshold and its find_nearest stage has no such option, so ' - 'the backend name is unverified.', // Nested plain collections. Node turns a nested list or map into an // array(...) / map(...) function; Dart sends a nested literal. 'functions/equalAny/static-field-name-list-nested': @@ -449,6 +491,57 @@ bool _searchSpaceIsArrayFunction(Object? request) { return _path(searchSpace, ['functionValue', 'name']) == 'array'; } +/// The field the find_nearest stage writes the distance to, if any. +Object? _findNearestDistanceField(Object? request) { + final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); + if (stages is! List) return null; + final findNearest = stages.lastWhere( + (stage) => _path(stage, ['name']) == 'find_nearest', + orElse: () => null, + ); + return _path(findNearest, [ + 'options', + 'distance_field', + 'fieldReferenceValue', + ]); +} + +/// Whether find_nearest, sent without a threshold option, is followed by a +/// `where` stage comparing the distance with the 0.5 threshold. +/// +/// The distance is either [distanceFunction] of the `embedding` field, or +/// [distanceField]. +bool _filtersDistance( + Object? request, { + required String comparison, + String? distanceFunction, + String? distanceField, +}) { + final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); + if (stages is! List || stages.length < 2) return false; + final findNearest = stages[stages.length - 2]; + if (_path(findNearest, ['name']) != 'find_nearest' || + _path(findNearest, ['options', 'distance_threshold']) != null) { + return false; + } + + final where = stages.last; + final condition = _path(where, ['args', 0, 'functionValue']); + if (_path(where, ['name']) != 'where' || + _path(condition, ['name']) != comparison || + _path(condition, ['args', 1, 'doubleValue']) != 0.5) { + return false; + } + + final distance = _path(condition, ['args', 0]); + if (distanceField != null) { + return _path(distance, ['fieldReferenceValue']) == distanceField; + } + return _path(distance, ['functionValue', 'name']) == distanceFunction && + _path(distance, ['functionValue', 'args', 0, 'fieldReferenceValue']) == + 'embedding'; +} + Object? _path(Object? value, List path) { var current = value; for (final segment in path) { @@ -1065,6 +1158,34 @@ void _registerQueries(_Registry r) { 'vector/field-path-object', (db) => nearest(col(db), vectorField: FieldPath(const ['a.b'])), ); + query( + 'vector/distance-threshold-cosine', + (db) => nearest( + col(db), + distanceMeasure: DistanceMeasure.cosine, + distanceThreshold: 0.5, + ), + ); + query( + 'vector/distance-threshold-dot-product', + (db) => nearest( + col(db), + distanceMeasure: DistanceMeasure.dotProduct, + distanceThreshold: 0.5, + ), + ); + query( + 'vector/distance-threshold-and-result-field', + (db) => nearest( + col(db), + distanceThreshold: 0.5, + distanceResultField: 'distance', + ), + ); + query( + 'vector/with-inequality-prefilter', + (db) => nearest(col(db).where('genre', WhereFilter.notEqual, 'Horror')), + ); } // ----------------------------------------------------------------------------- diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 86bc69dc..91a05209 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -3619,6 +3619,86 @@ void main() { ); }); + group('applies a VectorQuery distanceThreshold as a filter', () { + Future runThreshold( + DistanceMeasure distanceMeasure, { + String? distanceResultField, + }) async { + await run( + firestore + .collection('books') + .findNearest( + vectorField: 'embedding', + queryVector: [1.0, 2.0, 3.0], + limit: 5, + distanceMeasure: distanceMeasure, + distanceResultField: distanceResultField, + distanceThreshold: 0.5, + ), + ); + + // find_nearest documents no threshold option, so none is sent. + final findNearest = stages[stages.length - 2]; + expect(findNearest.name, 'find_nearest'); + expect( + findNearest.options.keys, + isNot(contains('distance_threshold')), + ); + + final where = stages.last; + expect(where.name, 'where'); + final condition = where.args.single.functionValue!; + expect(condition.args[1].doubleValue, 0.5); + return condition; + } + + test('keeps euclidean distances at most the threshold', () async { + final condition = await runThreshold(DistanceMeasure.euclidean); + + expect(condition.name, 'less_than_or_equal'); + final distance = condition.args[0].functionValue!; + expect(distance.name, 'euclidean_distance'); + expect(distance.args[0].fieldReferenceValue, 'embedding'); + expect( + distance.args[1].mapValue!.fields['value']!.arrayValue!.values.map( + (v) => v.doubleValue, + ), + [1.0, 2.0, 3.0], + ); + }); + + test('keeps cosine distances at most the threshold', () async { + final condition = await runThreshold(DistanceMeasure.cosine); + + expect(condition.name, 'less_than_or_equal'); + expect(condition.args[0].functionValue!.name, 'cosine_distance'); + }); + + test('keeps dot products at least the threshold', () async { + // A larger dot product means more similar vectors. + final condition = await runThreshold(DistanceMeasure.dotProduct); + + expect(condition.name, 'greater_than_or_equal'); + expect(condition.args[0].functionValue!.name, 'dot_product'); + }); + + test('reads the distance result field when there is one', () async { + final condition = await runThreshold( + DistanceMeasure.euclidean, + distanceResultField: 'distance', + ); + + expect(condition.name, 'less_than_or_equal'); + expect(condition.args[0].fieldReferenceValue, 'distance'); + expect( + stages[stages.length - 2] + .options['distance_field']! + .fieldReferenceValue, + 'distance', + ); + }); + }); + test('rejects a query from a different database', () { final other = Firestore.internal( settings: const Settings( diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js index 36823266..8c222271 100644 --- a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js +++ b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js @@ -398,6 +398,18 @@ module.exports = function buildCases(sdk, db) { vectorQuery('field-path-object', () => nearest({vectorField: new FieldPath('a.b')}), ); + vectorQuery('distance-threshold-cosine', () => + nearest({distanceMeasure: 'COSINE', distanceThreshold: 0.5}), + ); + vectorQuery('distance-threshold-dot-product', () => + nearest({distanceMeasure: 'DOT_PRODUCT', distanceThreshold: 0.5}), + ); + vectorQuery('distance-threshold-and-result-field', () => + nearest({distanceThreshold: 0.5, distanceResultField: 'distance'}), + ); + vectorQuery('with-inequality-prefilter', () => + nearest({}, col().where('genre', '!=', 'Horror')), + ); // --------------------------------------------------------------------------- // Stages From 8a83d050d5fa57290d38b3feb50e7a962a134419 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:41:53 +0200 Subject: [PATCH 19/29] fix(firestore): flip createFrom cursor bounds on descending orderings A cursor is a position in the query's order, but createFrom(query) always compared cursor values as if every ordering were ascending, as Node's whereConditionsFromCursor does. On orderBy('rating', descending: true), startAt([4]) kept ratings of 4 and more instead of 4 and less, and endBefore kept the wrong side too. Each bound now compares in its ordering's direction. The new golden cases for descending, mixed-direction and limitToLast cursors are known divergences from Node. --- .../lib/src/pipeline.dart | 18 +- .../fixtures/pipeline_golden/queries.json | 406 ++++++++++++++++++ .../test/pipeline_golden_test.dart | 63 +++ .../test/pipeline_test.dart | 37 ++ .../tool/pipeline_golden/cases.js | 14 + 5 files changed, 532 insertions(+), 6 deletions(-) diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 415f5950..3d0b8323 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -2353,6 +2353,11 @@ List _reversed(List orderings) { /// Cursors compare the ordering expressions lexicographically, so each bound /// contributes either a strict comparison or an equality plus the condition for /// the remaining bounds. +/// +/// A cursor is a position in the query's order, so a descending ordering +/// flips the comparison: `startAt` keeps smaller values. Node's +/// `whereConditionsFromCursor` ignores the direction and keeps the wrong side +/// of a descending bound. PipelineBooleanExpression _cursorCondition( _QueryCursor cursor, List orderings, { @@ -2367,16 +2372,17 @@ PipelineBooleanExpression _cursorCondition( ); } - PipelineBooleanExpression compare(Object? expression, Object? value) { - return before - ? lessThan(expression, value) - : greaterThan(expression, value); + PipelineBooleanExpression compare(PipelineOrdering ordering, Object? value) { + final ascending = ordering._name == 'ascending'; + return before == ascending + ? lessThan(ordering._expression, value) + : greaterThan(ordering._expression, value); } var expression = orderings[size - 1]._expression; var value = _PipelineProtoValue(cursor.values[size - 1]); - var condition = compare(expression, value); + var condition = compare(orderings[size - 1], value); // An inclusive bound also matches the cursor value itself. if (before != cursor.before) { condition = or([condition, equal(expression, value)]); @@ -2386,7 +2392,7 @@ PipelineBooleanExpression _cursorCondition( expression = orderings[i]._expression; value = _PipelineProtoValue(cursor.values[i]); condition = or([ - compare(expression, value), + compare(orderings[i], value), and([equal(expression, value), condition]), ]); } diff --git a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json index e992a049..6059e661 100644 --- a/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json +++ b/packages/google_cloud_firestore/test/fixtures/pipeline_golden/queries.json @@ -254,6 +254,143 @@ } } }, + "queries/cursor/descending-limit-to-last": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "1"} + ] + } + } + ] + } + } + ] + }, + {"name": "limit", "args": [{"integerValue": "2"}]}, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + } + ] + } + } + }, "queries/cursor/end-at": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -408,6 +545,75 @@ } } }, + "queries/cursor/end-before-descending": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "less_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "2"} + ] + } + } + ] + } + ] + } + } + }, "queries/cursor/limit-to-last": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -529,6 +735,121 @@ } } }, + "queries/cursor/mixed-directions": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "title"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "title"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "ascending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "title"}, + {"stringValue": "M"} + ] + } + } + ] + } + } + ] + } + } + ] + } + ] + } + } + }, "queries/cursor/multiple-fields": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { @@ -913,6 +1234,91 @@ } } }, + "queries/cursor/start-at-descending": { + "database": "projects/test-project/databases/(default)", + "structuredPipeline": { + "pipeline": { + "stages": [ + {"name": "collection", "args": [{"referenceValue": "/books"}]}, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "and", + "args": [ + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "rating"}] + } + }, + { + "functionValue": { + "name": "exists", + "args": [{"fieldReferenceValue": "__name__"}] + } + } + ] + } + } + ] + }, + { + "name": "sort", + "args": [ + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "rating"} + } + } + }, + { + "mapValue": { + "fields": { + "direction": {"stringValue": "descending"}, + "expression": {"fieldReferenceValue": "__name__"} + } + } + } + ] + }, + { + "name": "where", + "args": [ + { + "functionValue": { + "name": "or", + "args": [ + { + "functionValue": { + "name": "greater_than", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + }, + { + "functionValue": { + "name": "equal", + "args": [ + {"fieldReferenceValue": "rating"}, + {"integerValue": "4"} + ] + } + } + ] + } + } + ] + } + ] + } + } + }, "queries/cursor/with-inequality": { "database": "projects/test-project/databases/(default)", "structuredPipeline": { diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index 2d71f884..d5e3c51d 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -85,6 +85,20 @@ final _knownDivergences = { '`...others` at runtime: they only forward `second` to the method.', _selectsThreeOperands, ), + for (final (id, comparisons) in [ + ('start-at-descending', ['less_than']), + ('end-before-descending', ['greater_than']), + ('mixed-directions', ['less_than', 'greater_than']), + ('descending-limit-to-last', ['less_than', 'greater_than']), + ]) + 'queries/cursor/$id': _Divergence( + 'A cursor on a descending ordering keeps the values on the descending ' + 'side of the bound, as the Query does (startAt(4) keeps ratings up to ' + "4). Node's whereConditionsFromCursor ignores the direction and keeps " + 'the ascending side.', + (request) => + _cursorComparisons(request).join(',') == comparisons.join(','), + ), 'queries/vector/distance-result-field': _Divergence( _distanceResultFieldReason, (request) => _findNearestDistanceField(request) == 'distance', @@ -491,6 +505,33 @@ bool _searchSpaceIsArrayFunction(Object? request) { return _path(searchSpace, ['functionValue', 'name']) == 'array'; } +/// The `less_than` and `greater_than` comparisons of the cursor filters, the +/// `where` stages after the first `sort`, in order. +List _cursorComparisons(Object? request) { + final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); + if (stages is! List) return const []; + final sort = stages.indexWhere((stage) => _path(stage, ['name']) == 'sort'); + if (sort < 0) return const []; + + final comparisons = []; + void visit(Object? value) { + final function = _path(value, ['functionValue']); + final name = _path(function, ['name']); + if (name == 'less_than' || name == 'greater_than') { + comparisons.add(name! as String); + } + final args = _path(function, ['args']); + if (args is List) args.forEach(visit); + } + + for (final stage in stages.skip(sort + 1)) { + if (_path(stage, ['name']) != 'where') continue; + final args = _path(stage, ['args']); + if (args is List) args.forEach(visit); + } + return comparisons; +} + /// The field the find_nearest stage writes the distance to, if any. Object? _findNearestDistanceField(Object? request) { final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); @@ -1077,6 +1118,28 @@ void _registerQueries(_Registry r) { (db) => col(db).orderBy('rating').startAt([2]).endBefore([5]).limitToLast(3), ); + query( + 'cursor/start-at-descending', + (db) => col(db).orderBy('rating', descending: true).startAt([4]), + ); + query( + 'cursor/end-before-descending', + (db) => col(db).orderBy('rating', descending: true).endBefore([2]), + ); + query( + 'cursor/mixed-directions', + (db) => col( + db, + ).orderBy('rating', descending: true).orderBy('title').startAfter([4, 'M']), + ); + query( + 'cursor/descending-limit-to-last', + (db) => col(db) + .orderBy('rating', descending: true) + .startAt([4]) + .endAt([1]) + .limitToLast(2), + ); query( 'cursor/with-inequality', (db) => col(db) diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 91a05209..928afefe 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -3557,6 +3557,43 @@ void main() { expect(endBefore.name, 'less_than'); }); + test('flips cursor comparisons for descending orderings', () async { + // Regression: cursors compared as if every ordering were ascending + // (as Node does), so startAt(4) on a descending rating kept ratings of + // 4 and more instead of 4 and less. + await run( + firestore + .collection('books') + .orderBy('rating', descending: true) + .orderBy('title') + .startAt([4, 'M']) + .endBefore([1]), + ); + + expect(stages.map((stage) => stage.name), [ + 'collection', + 'where', // existence checks + 'sort', + 'where', // startAt + 'where', // endBefore + ]); + + // rating < 4 || (rating == 4 && (title > 'M' || title == 'M')) + final startAt = stages[3].args.single.functionValue!; + expect(startAt.name, 'or'); + expect(startAt.args[0].functionValue!.name, 'less_than'); + final tie = startAt.args[1].functionValue!; + expect(tie.args[0].functionValue!.name, 'equal'); + final title = tie.args[1].functionValue!; + expect(title.args[0].functionValue!.name, 'greater_than'); + expect(title.args[1].functionValue!.name, 'equal'); + + // endBefore(1) on the descending rating keeps ratings above 1. + final endBefore = stages[4].args.single.functionValue!; + expect(endBefore.name, 'greater_than'); + expect(endBefore.args[1].integerValue, 1); + }); + test('sorts twice for limitToLast queries', () async { await run( firestore.collection('books').orderBy('rating').limitToLast(3), diff --git a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js index 8c222271..c8fc1544 100644 --- a/packages/google_cloud_firestore/tool/pipeline_golden/cases.js +++ b/packages/google_cloud_firestore/tool/pipeline_golden/cases.js @@ -354,6 +354,20 @@ module.exports = function buildCases(sdk, db) { query('cursor/limit-to-last', () => col().orderBy('rating').startAt(2).endBefore(5).limitToLast(3), ); + // A cursor is a position in the query's order: on a descending ordering, + // startAt keeps the smaller values. + query('cursor/start-at-descending', () => + col().orderBy('rating', 'desc').startAt(4), + ); + query('cursor/end-before-descending', () => + col().orderBy('rating', 'desc').endBefore(2), + ); + query('cursor/mixed-directions', () => + col().orderBy('rating', 'desc').orderBy('title').startAfter(4, 'M'), + ); + query('cursor/descending-limit-to-last', () => + col().orderBy('rating', 'desc').startAt(4).endAt(1).limitToLast(2), + ); query('cursor/with-inequality', () => col().where('genre', '!=', 'Horror').orderBy('rating').startAfter(4), ); From 6256888696d8462e068149e199b6ee0102548a09 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 14:44:23 +0200 Subject: [PATCH 20/29] fix(firestore): decode non-document references at any depth in Pipeline results A Pipeline result reference that names no document (the database root returned by parent() of a top-level document, or a collection) decoded to its resource name only when it was a top-level field. Nested inside a map or array it went through Serializer.decodeValue, whose Firestore.doc call threw, failing the whole execute(). Thread a reference decoder through the serializer's recursion so Pipeline results apply the same rule at every depth: document references decode to a DocumentReference and any other reference to its resource name as a String. Regular document decoding is unchanged. The Node SDK throws for such references when the field is read; eager decoding cannot, so the existing top-level String behaviour is kept. --- .../lib/src/pipeline.dart | 37 +++-- .../lib/src/serializer.dart | 31 +++- .../test/e2e/pipeline/results_test.dart | 44 +++++ .../test/pipeline_test.dart | 155 ++++++++++++++++++ 4 files changed, 247 insertions(+), 20 deletions(-) diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 3d0b8323..78222508 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -2523,6 +2523,12 @@ final class PipelineResult { /// /// The returned map is unmodifiable. Projection stages may drop every field, /// in which case this is empty rather than `null`. + /// + /// A reference value decodes to a [DocumentReference] when it names a + /// document. A reference to anything else, such as the database root that + /// `parent()` returns for a top-level document, or a collection, decodes to + /// its resource name as a [String]. This holds at every depth, including + /// inside maps and arrays. The Node SDK throws for such references instead. DocumentData data() => _data; /// Returns the decoded value at [field], or `null` when absent. @@ -2561,36 +2567,41 @@ final class PipelineResult { } } +/// Decodes a Pipeline result value, decoding references as documented on +/// [PipelineResult.data] at every depth. 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); + return firestore._serializer._decodeValue(value, (referenceValue) { + final documentPath = _documentPathOf(referenceValue); + return documentPath == null ? referenceValue : firestore.doc(documentPath); + }); } final _documentReferenceRegExp = RegExp( r'^projects/[^/]+/databases/[^/]+(?:/documents(?:/(.*))?)?$', ); -bool _isDocumentReferenceValue(String referenceValue) { +/// The database-relative path of [referenceValue] when it names a document, +/// or `null` for any other reference, such as the database root or a +/// collection. +String? _documentPathOf(String referenceValue) { final value = referenceValue.startsWith('/') ? referenceValue.substring(1) : referenceValue; final match = _documentReferenceRegExp.firstMatch(value); if (match == null) { - return false; + return null; } - final path = match.group(1); - if (path == null || path.isEmpty) { - return false; + final segments = (match.group(1) ?? '') + .split('/') + .where((segment) => segment.isNotEmpty) + .toList(); + if (segments.isEmpty || segments.length.isOdd) { + return null; } - return path.split('/').where((segment) => segment.isNotEmpty).length.isEven; + return segments.join('/'); } /// A snapshot returned by executing a Firestore Pipeline operation. diff --git a/packages/google_cloud_firestore/lib/src/serializer.dart b/packages/google_cloud_firestore/lib/src/serializer.dart index c4ea3f19..96f77b1d 100644 --- a/packages/google_cloud_firestore/lib/src/serializer.dart +++ b/packages/google_cloud_firestore/lib/src/serializer.dart @@ -131,7 +131,25 @@ class Serializer { } /// Decodes a single Firestore 'Value' Protobuf. - Object? decodeValue(Object? proto) { + /// + /// A reference value must name a document and decodes to a + /// [DocumentReference]; any other reference throws an [ArgumentError]. + Object? decodeValue(Object? proto) => _decodeValue(proto, _decodeReference); + + /// Decodes a document reference value, as [decodeValue] does. + DocumentReference _decodeReference(String referenceValue) { + final resourcePath = _QualifiedResourcePath.fromSlashSeparatedString( + referenceValue, + ); + return firestore.doc(resourcePath.relativeName); + } + + /// Decodes [proto] like [decodeValue], but hands every reference value, at + /// any depth, to [decodeReference]. + Object? _decodeValue( + Object? proto, + Object? Function(String referenceValue) decodeReference, + ) { if (proto is! firestore_v1.Value) { throw ArgumentError.value( proto, @@ -153,13 +171,12 @@ class Serializer { case firestore_v1.Value(:final timestampValue?): return Timestamp._fromProto(timestampValue); case firestore_v1.Value(:final referenceValue?): - final resourcePath = _QualifiedResourcePath.fromSlashSeparatedString( - referenceValue, - ); - return firestore.doc(resourcePath.relativeName); + return decodeReference(referenceValue); case firestore_v1.Value(:final arrayValue?): final values = arrayValue.values; - return [for (final value in values) decodeValue(value)]; + return [ + for (final value in values) _decodeValue(value, decodeReference), + ]; case firestore_v1.Value(nullValue: != null): return null; case firestore_v1.Value(:final mapValue?): @@ -176,7 +193,7 @@ class Serializer { } return { for (final entry in fields.entries) - entry.key: decodeValue(entry.value), + entry.key: _decodeValue(entry.value, decodeReference), }; case firestore_v1.Value(:final geoPointValue?): return GeoPoint._fromProto(geoPointValue); diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart index 7e4d1c73..776db50d 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/results_test.dart @@ -128,6 +128,50 @@ void main() { expect(() => data['title'] = 'changed', throwsUnsupportedError); }); + test('decodes a non-document reference the same at every depth', () async { + // Book 1's `pathRef` is book 2, a top-level document, so its parent is + // the database root: a reference that names no document, which decodes + // to its resource name rather than to a DocumentReference. + final root = Expression.field('pathRef').parent(); + final document = Expression.field('pathRef'); + final snapshot = await ctx + .book1Pipeline() + .select([ + root.as('root'), + PipelineFunctions.map([ + 'root', + root, + 'document', + document, + ]).as('inMap'), + PipelineFunctions.array([root, document]).as('inArray'), + PipelineFunctions.map([ + 'list', + PipelineFunctions.array([ + PipelineFunctions.map(['root', root]), + ]), + ]).as('nested'), + ]) + .limit(1) + .execute(); + final result = snapshot.results.single; + final isBook2 = isA>().having( + (reference) => reference.path, + 'path', + ctx.book2Ref.path, + ); + + final rootValue = result.get('root'); + expect(rootValue, isA()); + expect(result.get('inMap'), {'root': rootValue, 'document': isBook2}); + expect(result.get('inArray'), [rootValue, isBook2]); + expect(result.get('nested'), { + 'list': [ + {'root': rootValue}, + ], + }); + }); + test('snapshot exposes its pipeline, size and execution time', () async { final pipeline = ctx.runPipeline().sort([ascending('price')]); final before = DateTime.now(); diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 928afefe..4edf8f1e 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -357,6 +357,161 @@ void main() { expect(snapshot.results.single.ref, isNull); }); + group('reference values in results', () { + const documents = 'projects/$_projectId/databases/enterprise/documents'; + // `parent()` of a top-level document returns the database root, and a + // reference can also name a collection; neither is a document. + const root = documents; + const collection = '$documents/books'; + const subcollection = '$documents/books/book-1/chapters'; + const document = '$documents/books/book-1'; + const subcollectionDocument = '$documents/books/book-1/chapters/c1'; + + firestore_v1.Value ref(String name) { + return firestore_v1.Value(referenceValue: name); + } + + firestore_v1.Value array(List values) { + return firestore_v1.Value( + arrayValue: firestore_v1.ArrayValue(values: values), + ); + } + + firestore_v1.Value map(Map fields) { + return firestore_v1.Value( + mapValue: firestore_v1.MapValue(fields: fields), + ); + } + + Future decode(Map fields) { + when( + () => mockClient.v1>( + any(), + ), + ).thenAnswer((invocation) async { + final callback = + invocation.positionalArguments.single + as Future> + Function(firestore_v1.Firestore api, String projectId); + return callback( + FakeFirestore( + executePipeline: (_) => Stream.value( + firestore_v1.ExecutePipelineResponse( + results: [firestore_v1.Document(fields: fields)], + ), + ), + ), + _projectId, + ); + }); + + return firestore + .pipeline() + .collection('books') + .execute() + .then((snapshot) => snapshot.results.single); + } + + // Every kind of reference, in the order the expectations below use. + final all = [ + ref(document), + ref(subcollectionDocument), + ref(root), + ref(collection), + ref(subcollection), + ]; + // `firestore` is only assigned in setUp, so build these per test. + late List allDecoded; + setUp(() { + allDecoded = [ + firestore.doc('books/book-1'), + firestore.doc('books/book-1/chapters/c1'), + root, + collection, + subcollection, + ]; + }); + + test('decode the same way at the top level', () async { + final result = await decode({ + 'document': all[0], + 'subcollectionDocument': all[1], + 'root': all[2], + 'collection': all[3], + 'subcollection': all[4], + }); + + expect(result.data(), { + 'document': allDecoded[0], + 'subcollectionDocument': allDecoded[1], + 'root': allDecoded[2], + 'collection': allDecoded[3], + 'subcollection': allDecoded[4], + }); + expect(result.get('document'), isA>()); + expect(result.get('root'), isA()); + }); + + test('decode the same way inside a map', () async { + final result = await decode({ + 'm': map({for (var i = 0; i < all.length; i++) 'r$i': all[i]}), + }); + + expect(result.get('m'), { + for (var i = 0; i < allDecoded.length; i++) 'r$i': allDecoded[i], + }); + expect(result.get('m.r0'), firestore.doc('books/book-1')); + expect(result.get('m.r2'), root); + }); + + test('decode the same way inside an array', () async { + final result = await decode({'a': array(all)}); + + expect(result.get('a'), allDecoded); + }); + + test('decode the same way nested two deep', () async { + final result = await decode({ + 'mapInMap': map({ + 'inner': map({'root': all[2], 'document': all[0]}), + }), + 'arrayInMap': map({'inner': array(all)}), + 'mapInArray': array([ + map({'collection': all[3], 'document': all[0]}), + ]), + 'arrayInArray': array([array(all)]), + }); + + expect(result.data(), { + 'mapInMap': { + 'inner': {'root': root, 'document': allDecoded[0]}, + }, + 'arrayInMap': {'inner': allDecoded}, + 'mapInArray': [ + {'collection': collection, 'document': allDecoded[0]}, + ], + 'arrayInArray': [allDecoded], + }); + expect(result.get('mapInMap.inner.root'), root); + }); + + test('leave regular document decoding unchanged', () { + // Outside Pipelines a non-document reference is still rejected, at + // any depth; only document references decode. + final serializer = firestore.serializer; + + expect(serializer.decodeValue(all[0]), allDecoded[0]); + expect(serializer.decodeValue(map({'d': all[0]})), { + 'd': allDecoded[0], + }); + expect(() => serializer.decodeValue(all[3]), throwsArgumentError); + expect( + () => serializer.decodeValue(array([all[4]])), + throwsArgumentError, + ); + }); + }); + test('execute encodes options under their backend names', () async { firestore_v1.ExecutePipelineRequest? capturedRequest; From 133b53216acb3a4483d2bc1a7a79dea2a4c48a19 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 15:05:55 +0200 Subject: [PATCH 21/29] test(firestore): cover the merged Pipeline API additions live Close the coverage guard's gaps for the API merged since the live suite was written, and add live checks for the createFrom fixes: - sources: documents() with paths (and a leading slash) and references; createFrom against the regular Query for != and not-in (same documents, Pipeline sorted by the filtered field), for startAt, startAfter, endBefore and endAt with limitToLast on a descending ordering, and for a DocumentSnapshot cursor on a != query; createFrom of a VectorQuery with a distance threshold under each distance measure, and with a result field, against the VectorQuery itself. - stages: select with field names that are not identifiers, read through the key the backend returns; PipelineOrdering.expr and direction; findNearest and unnest rawOptions overriding their typed options. - functions: add and multiply with further operands, field and Expression.field with a FieldPath, and map from a Map. The seed gains `first-name` and `last name` fields. Stages and sources with no backend option to set (or only force_index, which needs an index the E2E database lacks) exempt their rawOptions in the guard. --- .../pipeline/functions_arithmetic_test.dart | 61 +++++ .../functions_map_reference_test.dart | 18 ++ .../pipeline/functions_type_debug_test.dart | 21 ++ .../test/e2e/pipeline/harness.dart | 5 + .../test/e2e/pipeline/sources_test.dart | 212 ++++++++++++++++++ .../test/e2e/pipeline/stages_test.dart | 92 ++++++++ .../test/pipeline_e2e_coverage_test.dart | 34 +++ 7 files changed, 443 insertions(+) diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart index 27f95dd6..f5c65a64 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart @@ -80,6 +80,37 @@ void main() { ), isInt(12), ), + // Every further operand is sent in the same add call: 10 + 1 + 2 + 3. + FunctionCase( + 'static, further literal operands', + PipelineFunctions.add('price', 1, [2, 3]), + isInt(16), + ), + // 10 + 5 + 2. + FunctionCase( + 'static, further expression operands', + PipelineFunctions.add( + Expression.field('price'), + Expression.field('rating'), + [Expression.field('discount')], + ), + isInt(17), + ), + // 10 + 1 + 2. + FunctionCase( + 'fluent, further literal operands', + Expression.field('price').add(1, [2]), + isInt(13), + ), + // 10 + 2 + 5 + 7. + FunctionCase( + 'fluent, further expression operands', + Expression.field('price').add(Expression.field('discount'), [ + Expression.field('rating'), + Expression.field('quantity'), + ]), + isInt(24), + ), ]); ctx.functionCases('subtract', [ @@ -132,6 +163,36 @@ void main() { ), isInt(70), ), + // Every further operand is sent in the same multiply call: 5 * 2 * 3. + FunctionCase( + 'static, further literal operands', + PipelineFunctions.multiply('rating', 2, [3]), + isInt(30), + ), + // 10 * 5 * 2. + FunctionCase( + 'static, further expression operands', + PipelineFunctions.multiply( + Expression.field('price'), + Expression.field('rating'), + [Expression.field('discount')], + ), + isInt(100), + ), + // 10 * 2 * 3. + FunctionCase( + 'fluent, further literal operands', + Expression.field('price').multiply(2, [3]), + isInt(60), + ), + // 5 * 2 * 6. + FunctionCase( + 'fluent, further expression operands', + Expression.field( + 'rating', + ).multiply(Expression.field('discount'), [Expression.field('flags')]), + isInt(60), + ), ]); ctx.functionCases('divide', [ diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart index e9bc3575..1ce21d09 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart @@ -51,6 +51,24 @@ void main() { ]), {'title': 'Dart Pipelines', 'rating': 5}, ), + // A Map is sent like the alternating key/value form. + FunctionCase( + 'map from a Map', + PipelineFunctions.map({ + 'title': Expression.field('title'), + 'answer': 42, + }), + {'title': 'Dart Pipelines', 'answer': 42}, + ), + FunctionCase( + 'map from a Map holding a nested Map', + PipelineFunctions.map({ + 'nested': {'price': Expression.field('price')}, + }), + { + 'nested': {'price': 10}, + }, + ), FunctionCase( 'mapGetLiteral', Expression.field('metadata').mapGetLiteral('category'), diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart index 96cc1687..15e31bfc 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_type_debug_test.dart @@ -80,6 +80,27 @@ void main() { field('nested.level1.level2.value'), isInt(42), ), + // A FieldPath segment is one field name, even when it holds a dot. + FunctionCase( + 'field with a FieldPath', + field(FieldPath(const ['nested', 'dotted.key'])), + 'dot', + ), + FunctionCase( + 'field with a FieldPath to a name that is not an identifier', + field(FieldPath(const ['first-name'])), + 'Ada', + ), + FunctionCase( + 'field with a name that is not an identifier', + field('last name'), + 'Lovelace', + ), + FunctionCase( + 'Expression.field with a FieldPath', + Expression.field(FieldPath(const ['metadata', 'lang'])), + 'dart', + ), FunctionCase('constant', constant('literal'), 'literal'), ]); diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/harness.dart b/packages/google_cloud_firestore/test/e2e/pipeline/harness.dart index 6aba1944..2b0ca3f2 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/harness.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/harness.dart @@ -433,6 +433,8 @@ Matcher isNumber(num value, {double tolerance = 1e-9}) { /// | `pathRef` (reference) | book 2 | book 1 | book 1 | /// | `embedding` (vector) | `[1, 0, 0]` | `[0, 1, 0]` | `[0, 0, 1]` | /// | `sparse` | `'book 1 only'` | absent | absent | +/// | `first-name` | `'Ada'` | same | same | +/// | `last name` | `'Lovelace'` | same | same | /// /// Document IDs are `_book_1`, `_book_2` and `_book_3`. List buildSeed( @@ -470,6 +472,9 @@ List buildSeed( 'list': [1, 2], }, 'emptyMap': {}, + // Names that are not identifiers, which field paths must quote. + 'first-name': 'Ada', + 'last name': 'Lovelace', }; return [ diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart index 46b23655..b895e17a 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/sources_test.dart @@ -16,13 +16,67 @@ @Tags(['prod']) library; +import 'dart:math' as math; + import 'package:google_cloud_firestore/google_cloud_firestore.dart'; import 'package:test/test.dart'; import 'harness.dart'; +/// The IDs of the documents [query] returns, in order. +Future> _queryIds(Query query) async { + final snapshot = await query.get(); + return [for (final doc in snapshot.docs) doc.id]; +} + +/// The IDs of the documents [vectorQuery] returns, in order. +Future> _vectorQueryIds( + VectorQuery vectorQuery, +) async { + final snapshot = await vectorQuery.get(); + return [for (final doc in snapshot.docs) doc.id]; +} + +/// The IDs of the documents [pipeline] returns, in order. +Future> _pipelineIds(Pipeline pipeline) async { + final snapshot = await pipeline.execute(); + return [for (final result in snapshot.results) result.id]; +} + void main() { pipelineE2E('Pipeline sources', (ctx) { + final book1 = ctx.book1Ref.id; + final book2 = ctx.book2Ref.id; + final book3 = ctx.book3Ref.id; + + /// A Query over this run's three documents. + Query runQuery() { + return ctx.firestore + .collection(ctx.collectionPath) + .where('runId', WhereFilter.equal, ctx.runId); + } + + /// [runQuery], as a vector query from [3, 2, 1] on `embedding`. + /// + /// The query vector ranks the books 1, 2, 3 under every measure: + /// euclidean distances 3, sqrt(11) ~ 3.317 and sqrt(13) ~ 3.606; cosine + /// distances 1 - 3/sqrt(14) ~ 0.198, 1 - 2/sqrt(14) ~ 0.465 and + /// 1 - 1/sqrt(14) ~ 0.733; dot products 3, 2 and 1. + VectorQuery runVectorQuery( + DistanceMeasure distanceMeasure, { + double? distanceThreshold, + String? distanceResultField, + }) { + return runQuery().findNearest( + vectorField: 'embedding', + queryVector: const [3, 2, 1], + limit: 3, + distanceMeasure: distanceMeasure, + distanceThreshold: distanceThreshold, + distanceResultField: distanceResultField, + ); + } + test('executes a documents source stage', () async { final snapshot = await ctx.firestore .pipeline() @@ -37,6 +91,24 @@ void main() { ]); }); + test('documents takes document paths and references', () async { + // A path is read like Firestore.doc reads it, ignoring a leading slash. + expect( + await ctx.titlesOf( + ctx.firestore + .pipeline() + .documents([ + ctx.book1Ref.path, + '/${ctx.book2Ref.path}', + ctx.book3Ref, + ]) + .sort([ascending('price')]) + .select(['title']), + ), + ['Dart Pipelines', 'Firestore Admin', 'Inactive Draft'], + ); + }); + test('executes a collection group source stage', () async { final snapshot = await ctx.firestore .pipeline() @@ -136,5 +208,145 @@ void main() { 'Firestore Admin', ]); }); + + // != and not-in are inequalities, so createFrom sorts by the filtered + // field, then by the document key, as a Standard database orders the + // Query. Ratings are 5, 4 and 2: excluding 4 leaves book 3, then book 1. + // + // Only the documents the Query returns are compared, not their order: the + // Node SDK's system tests skip the != comparison on Enterprise databases, + // whose implicit Query order is not settled. + for (final (name, filter) in [ + ( + '!=', + (Query query) => + query.where('rating', WhereFilter.notEqual, 4), + ), + ( + 'not-in', + (Query query) => + query.where('rating', WhereFilter.notIn, [4]), + ), + ]) { + test('createFrom orders a $name query by its field first', () async { + final query = filter(runQuery()); + + expect(await _pipelineIds(ctx.firestore.pipeline().createFrom(query)), [ + book3, + book1, + ]); + expect(await _queryIds(query), unorderedEquals([book3, book1])); + }); + } + + // A cursor is a position in the query's order: on a descending rating + // (5, 4, 2), startAt(4) keeps 4 and below. + for (final (name, cursor, expected) in [ + ( + 'startAt', + (Query query) => query.startAt([4]), + [book2, book3], + ), + ( + 'startAfter', + (Query query) => query.startAfter([4]), + [book3], + ), + ( + 'endBefore', + (Query query) => query.endBefore([2]), + [book1, book2], + ), + ( + 'endAt with limitToLast', + (Query query) => query.endAt([4]).limitToLast(1), + [book2], + ), + ]) { + test('createFrom keeps the $name side of a descending cursor', () async { + final query = cursor(runQuery().orderBy('rating', descending: true)); + + expect(await _queryIds(query), expected); + expect( + await _pipelineIds(ctx.firestore.pipeline().createFrom(query)), + expected, + ); + }); + } + + test( + 'a DocumentSnapshot cursor on a != query orders by that field', + () async { + // The cursor orders by rating, then the document key, so starting at + // book 3 (rating 2) keeps book 3, then book 1 (rating 5). Ordering by + // the document key alone would keep book 3 only. + final query = runQuery() + .where('rating', WhereFilter.notEqual, 4) + .startAtDocument(await ctx.book3Ref.get()); + + expect(await _queryIds(query), [book3, book1]); + expect(await _pipelineIds(ctx.firestore.pipeline().createFrom(query)), [ + book3, + book1, + ]); + }, + ); + + // Each threshold keeps books 1 and 2 and drops book 3: at most 3.5 for + // euclidean, at most 0.5 for cosine, at least 1.5 for dot product. + for (final (distanceMeasure, distanceThreshold) in [ + (DistanceMeasure.euclidean, 3.5), + (DistanceMeasure.cosine, 0.5), + (DistanceMeasure.dotProduct, 1.5), + ]) { + test( + 'createFrom keeps a ${distanceMeasure.name} distance threshold', + () async { + final vectorQuery = runVectorQuery( + distanceMeasure, + distanceThreshold: distanceThreshold, + ); + + expect(await _vectorQueryIds(vectorQuery), [book1, book2]); + expect( + await _pipelineIds( + ctx.firestore.pipeline().createFrom(vectorQuery), + ), + [book1, book2], + ); + }, + ); + } + + test( + 'createFrom filters a distance threshold on its result field', + () async { + final vectorQuery = runVectorQuery( + DistanceMeasure.euclidean, + distanceThreshold: 3.5, + distanceResultField: 'distance', + ); + + final querySnapshot = await vectorQuery.get(); + final pipelineSnapshot = await ctx.firestore + .pipeline() + .createFrom(vectorQuery) + .execute(); + + // Each result carries its euclidean distance in the result field. + final distances = [isNumber(3), isNumber(math.sqrt(11))]; + expect([for (final doc in querySnapshot.docs) doc.id], [book1, book2]); + expect([ + for (final doc in querySnapshot.docs) doc.data()['distance'], + ], distances); + expect( + [for (final result in pipelineSnapshot.results) result.id], + [book1, book2], + ); + expect([ + for (final result in pipelineSnapshot.results) result.get('distance'), + ], distances); + }, + ); }); } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart index edfdb7d0..03332694 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart @@ -81,6 +81,25 @@ void main() { ]); }); + test('select keeps field names that are not identifiers', () async { + // A String names its output field as given. A PipelineField is keyed by + // its quoted path ("`first-name`"), as in the Node SDK, and the backend + // takes select keys verbatim (Node's system tests read `awards.hugo` + // back as one key), so the field's value is read through whichever key + // comes back. + final data = await _dataOf( + ctx.book1Pipeline().select([field('first-name'), 'last name']), + ); + + expect(data, hasLength(1)); + final result = data.single; + expect(result, hasLength(2)); + expect(result['last name'], 'Lovelace'); + final firstNameKey = result.keys.singleWhere((key) => key != 'last name'); + expect(firstNameKey, anyOf('first-name', '`first-name`')); + expect(result[firstNameKey], 'Ada'); + }); + test('an alias names the field its expression lands on', () async { final aliased = Expression.field('price').as('cost'); @@ -162,6 +181,36 @@ void main() { ); }); + test('an ordering exposes its expression and direction', () async { + final orderings = [ + descending('active'), + Expression.field('price').ascending(), + ]; + + expect( + [for (final ordering in orderings) ordering.direction], + ['descending', 'ascending'], + ); + // A field name given to descending() reads back as a field. + expect( + [ + for (final ordering in orderings) + (ordering.expr as PipelineField).path, + ], + ['active', 'price'], + ); + + // Orderings rebuilt from expr and direction sort the same way: the two + // active books first, by ascending price. + final rebuilt = [ + for (final ordering in orderings) + ordering.direction == 'ascending' + ? ordering.expr.ascending() + : ordering.expr.descending(), + ]; + expect(await ctx.titlesOf(ctx.runPipeline().sort(rebuilt)), _allTitles); + }); + test('offset skips sorted inputs before limit', () async { final sorted = ctx.runPipeline().sort([ Expression.field('price').ascending(), @@ -257,6 +306,27 @@ void main() { ); }); + test('unnest rawOptions take precedence over indexField', () async { + // The raw index_field replaces the typed one, so only rawIndex is set. + final data = await _dataOf( + ctx + .book1Pipeline() + .unnest( + Expression.field('tags').as('tag'), + indexField: 'typedIndex', + rawOptions: {'index_field': Expression.field('rawIndex')}, + ) + .sort([ascending('rawIndex')]), + ); + + expect([for (final result in data) result['tag']], ['dart', 'firebase']); + expect([for (final result in data) result['rawIndex']], [0, 1]); + expect( + [for (final result in data) result.containsKey('typedIndex')], + [false, false], + ); + }); + test('replaceWith a field name promotes that map', () async { expect(await _dataOf(ctx.book1Pipeline().replaceWith('metadata')), [ {'lang': 'dart', 'category': 'sdk'}, @@ -451,6 +521,28 @@ void main() { ); }); + test('findNearest rawOptions take precedence over its options', () async { + // The raw limit replaces the typed one, and the raw distance_field sets + // the result field distanceResultField would. + final snapshot = await ctx + .runPipeline() + .findNearest( + vectorField: 'embedding', + queryVector: const [3.0, 2.0, 1.0], + distanceMeasure: DistanceMeasure.euclidean, + limit: 3, + rawOptions: { + 'limit': 1, + 'distance_field': Expression.field('rawDistance'), + }, + ) + .execute(); + + expect(snapshot.results, hasLength(1)); + expect(snapshot.results.single.get('title'), 'Dart Pipelines'); + expect(snapshot.results.single.get('rawDistance'), isNumber(3)); + }); + test('rawStage sends stages by their backend name', () async { // price > 15 keeps books 2 and 3; descending price puts book 3 first, // and the offset skips it. diff --git a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart index 57ce1db9..7d55e6eb 100644 --- a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart @@ -428,11 +428,45 @@ const _requiredShapes = >{ 'PipelineResult.get(field)': [_Shape.dottedPath, _Shape.fieldPath], }; +/// Why a stage with no backend options has no live `rawOptions` case. +const _noStageOptions = + 'the stage has no backend options (the Node SDK knows none, and no SDK ' + 'system test sends raw ones), so any key would be an unknown option the ' + 'backend may reject, and an empty map equals omitting it; the ' + 'findNearest and unnest cases send real raw options through the same ' + 'encoding'; + +/// Why a collection source has no live `rawOptions` case. +const _forceIndexOnly = + "the stage's only backend option, force_index, needs a named index the " + 'E2E database does not declare (the Node SDK system tests skip forceIndex ' + 'for the same reason), any other key would be an unknown option the ' + 'backend may reject, and an empty map equals omitting it'; + /// Members (`Member`) and parameters (`Member(param)`) that cannot run /// against the E2E database, exempt from every rule. /// /// Keep this minimal: anything the backend can execute gets a live case. const _notExecutable = { + // Raw stage options. Pipeline.findNearest and Pipeline.unnest pass theirs + // live, overriding their typed options. + 'PipelineSource.collection(rawOptions)': _forceIndexOnly, + 'PipelineSource.collectionGroup(rawOptions)': _forceIndexOnly, + 'PipelineSource.collectionReference(rawOptions)': _forceIndexOnly, + 'PipelineSource.database(rawOptions)': _noStageOptions, + 'PipelineSource.documents(rawOptions)': _noStageOptions, + 'Pipeline.addFields(rawOptions)': _noStageOptions, + 'Pipeline.aggregate(rawOptions)': _noStageOptions, + 'Pipeline.distinct(rawOptions)': _noStageOptions, + 'Pipeline.limit(rawOptions)': _noStageOptions, + 'Pipeline.offset(rawOptions)': _noStageOptions, + 'Pipeline.removeFields(rawOptions)': _noStageOptions, + 'Pipeline.replaceWith(rawOptions)': _noStageOptions, + 'Pipeline.sample(rawOptions)': _noStageOptions, + 'Pipeline.select(rawOptions)': _noStageOptions, + 'Pipeline.sort(rawOptions)': _noStageOptions, + 'Pipeline.union(rawOptions)': _noStageOptions, + 'Pipeline.where(rawOptions)': _noStageOptions, 'Pipeline.search': 'needs a full-text search index, which pipeline_e2e_books does not ' 'have; the Node SDK system tests do not cover search either ' From 2478515a6ff77321c8b66eb2e80263e2b9e0a7e5 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 15:06:56 +0200 Subject: [PATCH 22/29] docs(firestore): add changelog entries for the Pipeline parity fixes --- packages/google_cloud_firestore/CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/google_cloud_firestore/CHANGELOG.md b/packages/google_cloud_firestore/CHANGELOG.md index 3a89584f..fd6e915f 100644 --- a/packages/google_cloud_firestore/CHANGELOG.md +++ b/packages/google_cloud_firestore/CHANGELOG.md @@ -22,6 +22,17 @@ - `PipelineFunctions.arraySlice` now takes an optional `length`, like `PipelineExpression.arraySlice` and the Node Admin SDK. - `PipelineFunctions.raw` now accepts `options`, like `Expression.raw`. - `PipelineExpression.arrayContainsAll` and `arrayContainsAny` now also accept an array expression, like their `PipelineFunctions` forms and the Node Admin SDK. +- Fixed Pipeline field names that are not simple identifiers being sent unquoted. They are now backtick-quoted like the Node Admin SDK, so `field('first-name')` sends `` `first-name` ``. `field()` and `Expression.field()` also accept a `FieldPath`. `PipelineField.path` returns the quoted path, and `field('')` and `field('a..b')` now throw. +- Fixed field paths containing a backtick being escaped as a lone backslash, which named a different field, in queries, field masks and Pipelines. +- Fixed `Pipeline.createFrom()` and `DocumentSnapshot` query cursors leaving `!=` and `not-in` fields out of the implicit ordering, and ordering inequality fields by their quoted names instead of segment by segment, unlike the backend and the Node Admin SDK. A snapshot cursor now needs a value for every inequality field. +- Fixed `Pipeline.createFrom()` of a query with a cursor on a descending ordering keeping the wrong side of the bound. +- Fixed `Pipeline.createFrom()` of a `VectorQuery` sending its `distanceThreshold` as an undocumented `find_nearest` option. It is now applied as a filter on the distance. +- Fixed Pipeline results failing to decode when a reference that names no document, such as the database root returned by `parent()`, is nested in a map or array. It now decodes to its resource name at every depth, as it already did for top-level fields. +- Pipeline raw options now match the Node Admin SDK. Every stage and source takes an optional `rawOptions`. Keys in `rawOptions` and `rawStage(options:)` are dot-separated paths merged into the typed options, and keys with an empty segment throw an `ArgumentError`. `rawStage` sends collections nested in a `Map` argument as `map(...)` / `array(...)` functions. +- `PipelineFunctions.add` and `multiply` take an optional trailing list of further operands, and `PipelineExpression.add` and `multiply` take `(second, [others])`, like the variadic Node Admin SDK functions. +- `PipelineSource.documents()` also accepts document paths such as `'books/book1'`, validated like `Firestore.doc()`. +- `PipelineFunctions.map()` also accepts a Dart `Map`, as in `map({'title': field('title')})`. +- Added the `PipelineOrdering.expr` and `PipelineOrdering.direction` getters. ## 0.5.5 From ab3f17e8a540ea1336fa0034a3652e95225bd53e Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 15:17:56 +0200 Subject: [PATCH 23/29] fix(firestore): send variadic add and multiply as nested binary calls The backend's add and multiply take exactly two operands. A live run rejected the single N-operand call PipelineFunctions.add/multiply and their fluent forms sent for further operands: The function 'add[T <: number](x: T, y: T): T' takes [2..2] argument(s), but 4 were provided. Further operands are now folded into left-nested two-operand calls, add(add(add(a, b), c), d). A two-operand call is unchanged. The Node SDK sends the rejected N-operand call from its method form, so the add/multiply variadic golden fixtures become known divergences, checked for the nested shape. --- packages/google_cloud_firestore/CHANGELOG.md | 2 +- packages/google_cloud_firestore/README.md | 5 +- .../lib/src/pipeline.dart | 37 +++++++++++++-- .../pipeline/functions_arithmetic_test.dart | 6 ++- .../test/pipeline_golden_test.dart | 46 +++++++++++++------ .../test/pipeline_test.dart | 35 ++++++++++---- 6 files changed, 96 insertions(+), 35 deletions(-) diff --git a/packages/google_cloud_firestore/CHANGELOG.md b/packages/google_cloud_firestore/CHANGELOG.md index fd6e915f..f28fccf2 100644 --- a/packages/google_cloud_firestore/CHANGELOG.md +++ b/packages/google_cloud_firestore/CHANGELOG.md @@ -29,7 +29,7 @@ - Fixed `Pipeline.createFrom()` of a `VectorQuery` sending its `distanceThreshold` as an undocumented `find_nearest` option. It is now applied as a filter on the distance. - Fixed Pipeline results failing to decode when a reference that names no document, such as the database root returned by `parent()`, is nested in a map or array. It now decodes to its resource name at every depth, as it already did for top-level fields. - Pipeline raw options now match the Node Admin SDK. Every stage and source takes an optional `rawOptions`. Keys in `rawOptions` and `rawStage(options:)` are dot-separated paths merged into the typed options, and keys with an empty segment throw an `ArgumentError`. `rawStage` sends collections nested in a `Map` argument as `map(...)` / `array(...)` functions. -- `PipelineFunctions.add` and `multiply` take an optional trailing list of further operands, and `PipelineExpression.add` and `multiply` take `(second, [others])`, like the variadic Node Admin SDK functions. +- `PipelineFunctions.add` and `multiply` take an optional trailing list of further operands, and `PipelineExpression.add` and `multiply` take `(second, [others])`, like the variadic Node Admin SDK functions. Further operands are sent as nested two-operand calls, `add(add(a, b), c)`, since the backend's `add` and `multiply` take exactly two operands. - `PipelineSource.documents()` also accepts document paths such as `'books/book1'`, validated like `Firestore.doc()`. - `PipelineFunctions.map()` also accepts a Dart `Map`, as in `map({'title': field('title')})`. - Added the `PipelineOrdering.expr` and `PipelineOrdering.direction` getters. diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 1ca7f8a1..c3b5770c 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -441,8 +441,9 @@ maps; `charLength`/`stringReverse`/`stringConcat` and `minimum`/`maximum` are the aggregate forms; use `logicalMinimum` / `logicalMaximum` to compare several operands element-wise. -`add` and `multiply` take extra operands as a trailing list, sent as a single -backend call, and `map` takes a `Map` or a list alternating keys and values: +`add` and `multiply` take extra operands as a trailing list, sent as nested +two-operand calls (the backend's `add` and `multiply` take exactly two), and +`map` takes a `Map` or a list alternating keys and values: ```dart field('price').add(field('tax'), [field('shipping')]); diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 78222508..c692b4c2 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -430,6 +430,23 @@ abstract final class PipelineFunctions { return _PipelineBooleanExpression(name, [...args.map(_valueToDefaultExpr)]); } + /// Folds [first], [second] and [others] into left-nested calls of the + /// two-operand function [name], as in `name(name(first, second), others[0])`. + /// + /// With no [others], this is the single call `name(first, second)`. + static PipelineExpression _leftNested( + String name, + Object? first, + Object? second, + Iterable others, + ) { + var result = _expr(name, [_fieldOrExpression(first), second]); + for (final operand in others) { + result = _expr(name, [result, operand]); + } + return result; + } + /// Tests [target] against the values in [searchSpace]. /// /// Like the Node SDK, a list of plain values is sent as a literal array @@ -553,13 +570,15 @@ abstract final class PipelineFunctions { /// ADD arithmetic function. /// - /// Adds [first], [second] and any [others], sent as a single `add` call. + /// Adds [first], [second] and any [others]. The backend's `add` takes + /// exactly two operands, so further operands are sent as nested + /// two-operand calls: `add(a, b, [c, d])` sends `add(add(add(a, b), c), d)`. static PipelineExpression add( Object? first, Object? second, [ Iterable others = const [], ]) { - return _expr('add', [_fieldOrExpression(first), second, ...others]); + return _leftNested('add', first, second, others); } /// SUBTRACT arithmetic function. @@ -569,14 +588,16 @@ abstract final class PipelineFunctions { /// MULTIPLY arithmetic function. /// - /// Multiplies [first], [second] and any [others], sent as a single - /// `multiply` call. + /// Multiplies [first], [second] and any [others]. The backend's `multiply` + /// takes exactly two operands, so further operands are sent as nested + /// two-operand calls: `multiply(a, b, [c])` sends + /// `multiply(multiply(a, b), c)`. static PipelineExpression multiply( Object? first, Object? second, [ Iterable others = const [], ]) { - return _expr('multiply', [_fieldOrExpression(first), second, ...others]); + return _leftNested('multiply', first, second, others); } /// DIVIDE arithmetic function. @@ -2681,6 +2702,9 @@ sealed class PipelineExpression { } /// Adds [second] and any [others] to this expression. + /// + /// Further operands are sent as nested two-operand `add` calls; see + /// [PipelineFunctions.add]. PipelineExpression add( Object? second, [ Iterable others = const [], @@ -2694,6 +2718,9 @@ sealed class PipelineExpression { } /// Multiplies this expression by [second] and any [others]. + /// + /// Further operands are sent as nested two-operand `multiply` calls; see + /// [PipelineFunctions.multiply]. PipelineExpression multiply( Object? second, [ Iterable others = const [], diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart index f5c65a64..ace0d6c1 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_arithmetic_test.dart @@ -80,7 +80,8 @@ void main() { ), isInt(12), ), - // Every further operand is sent in the same add call: 10 + 1 + 2 + 3. + // Further operands are sent as nested two-operand add calls, since the + // backend's add takes exactly two: ((10 + 1) + 2) + 3. FunctionCase( 'static, further literal operands', PipelineFunctions.add('price', 1, [2, 3]), @@ -163,7 +164,8 @@ void main() { ), isInt(70), ), - // Every further operand is sent in the same multiply call: 5 * 2 * 3. + // Further operands are sent as nested two-operand multiply calls, since + // the backend's multiply takes exactly two: (5 * 2) * 3. FunctionCase( 'static, further literal operands', PipelineFunctions.multiply('rating', 2, [3]), diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index d5e3c51d..f7753993 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -78,13 +78,18 @@ final _knownDivergences = { r'("`\`first-name\``").', _projectionIsQuotedOnce, ), - for (final name in ['add', 'multiply']) - 'functions/$name/static-variadic': const _Divergence( - 'PipelineFunctions.add/multiply send every operand, like the Node ' - 'method form. The Node top-level functions are typed variadic but drop ' - '`...others` at runtime: they only forward `second` to the method.', - _selectsThreeOperands, - ), + for (final (name, operand) in [('add', 1), ('multiply', 2)]) + for (final form in ['static', 'method']) + 'functions/$name/$form-variadic': _Divergence( + 'add and multiply send further operands as nested two-operand calls, ' + '$name($name(rating, $operand), x). The backend takes exactly two ' + 'operands: for add(a, b, c, d) it answers "The function ' + "'add[T <: number](x: T, y: T): T' takes [2..2] argument(s), but 4 " + 'were provided." Node sends a single call with every operand from its ' + 'method form, and its top-level functions drop `...others` at runtime, ' + 'only forwarding `second`.', + (request) => _selectsNestedBinary(request, name, operand), + ), for (final (id, comparisons) in [ ('start-at-descending', ['less_than']), ('end-before-descending', ['greater_than']), @@ -479,25 +484,36 @@ bool _projectionIsQuotedOnce(Object? request) { bool _jsonEquals(Object? a, Object? b) => equals(b).matches(a, {}); -/// Whether the `where` stage's function receives its search space as an -/// `array(...)` function. -/// Whether the selected function receives all three operands of a variadic -/// call. -bool _selectsThreeOperands(Object? request) { +/// Whether the selected expression is the variadic call of `functions/$name` +/// sent as two nested two-operand calls: `name(name(rating, operand), x)`, +/// where `x` is the third operand's field. +bool _selectsNestedBinary(Object? request, String name, int operand) { final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); if (stages is! List || stages.length != 2) return false; - final operands = _path(stages[1], [ + final outer = _path(stages[1], [ 'args', 0, 'mapValue', 'fields', 'result', 'functionValue', - 'args', ]); - return operands is List && operands.length == 3; + final inner = _path(outer, ['args', 0, 'functionValue']); + return _path(outer, ['name']) == name && + _path(outer, ['args']) is List && + (_path(outer, ['args'])! as List).length == 2 && + _path(outer, ['args', 1, 'fieldReferenceValue']) != null && + _jsonEquals(inner, { + 'name': name, + 'args': [ + {'fieldReferenceValue': 'rating'}, + {'integerValue': '$operand'}, + ], + }); } +/// Whether the `where` stage's function receives its search space as an +/// `array(...)` function. bool _searchSpaceIsArrayFunction(Object? request) { final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); if (stages is! List || stages.length != 2) return false; diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 4edf8f1e..5fc17961 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -3212,21 +3212,36 @@ void main() { e.multiply(second, others), ), ]) { - test('$name sends every operand to one $name call', () async { + test('$name nests further operands in two-operand calls', () async { final fields = await select([ static('rating', 2, [field('bonus'), 3]).as('static'), fluent(field('rating'), 2, [field('bonus'), 3]).as('fluent'), ]); + // The backend's add and multiply take exactly two operands, so + // (rating, 2, [bonus, 3]) is sent as name(name(name(rating, 2), + // bonus), 3). + Map call(Object? left, Object? right) => { + 'functionValue': { + 'name': name, + 'args': [left, right], + }, + }; for (final alias in ['static', 'fluent']) { - final function = fields[alias]!.functionValue!; - expect(function.name, name, reason: alias); - expect(function.args.map((arg) => arg.toJson()), [ - {'fieldReferenceValue': 'rating'}, - {'integerValue': '2'}, - {'fieldReferenceValue': 'bonus'}, - {'integerValue': '3'}, - ], reason: alias); + expect( + fields[alias]!.toJson(), + call( + call( + call( + {'fieldReferenceValue': 'rating'}, + {'integerValue': '2'}, + ), + {'fieldReferenceValue': 'bonus'}, + ), + {'integerValue': '3'}, + ), + reason: alias, + ); } }); @@ -3251,7 +3266,7 @@ void main() { ]).as('sum'), ]); - final operand = fields['sum']!.functionValue!.args[2]; + final operand = fields['sum']!.functionValue!.args[1]; expect(operand.functionValue!.name, 'array'); expect(operand.functionValue!.args.single.fieldReferenceValue, 'b'); }); From 172d43f8eb8ad9253b58c51de660a0bb97d08e69 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 15:20:12 +0200 Subject: [PATCH 24/29] fix(firestore): apply findNearest's distanceThreshold as a filter A live run rejected the distance_threshold option Pipeline.findNearest sent: Stage 'find_nearest(field: FieldReference, vector: Vector, distance_measure: DistanceMeasure, limit: Int64?, distance_field: FieldPath?)' does not support option(s) [distance_threshold]. findNearest(distanceThreshold:) now shares createFrom(vectorQuery)'s implementation: a where stage after find_nearest keeps distances at most the threshold for euclidean and cosine, and at least it for dot product, reading the distance result field when there is one and recomputing the distance otherwise. Node has no such option, so the parity entry becomes a known difference. --- packages/google_cloud_firestore/CHANGELOG.md | 1 + .../lib/src/pipeline.dart | 84 +++++++++++-------- .../test/pipeline_api_parity_test.dart | 12 +-- .../test/pipeline_golden_test.dart | 5 +- .../test/pipeline_test.dart | 79 ++++++++++++++++- 5 files changed, 137 insertions(+), 44 deletions(-) diff --git a/packages/google_cloud_firestore/CHANGELOG.md b/packages/google_cloud_firestore/CHANGELOG.md index f28fccf2..093a1b03 100644 --- a/packages/google_cloud_firestore/CHANGELOG.md +++ b/packages/google_cloud_firestore/CHANGELOG.md @@ -27,6 +27,7 @@ - Fixed `Pipeline.createFrom()` and `DocumentSnapshot` query cursors leaving `!=` and `not-in` fields out of the implicit ordering, and ordering inequality fields by their quoted names instead of segment by segment, unlike the backend and the Node Admin SDK. A snapshot cursor now needs a value for every inequality field. - Fixed `Pipeline.createFrom()` of a query with a cursor on a descending ordering keeping the wrong side of the bound. - Fixed `Pipeline.createFrom()` of a `VectorQuery` sending its `distanceThreshold` as an undocumented `find_nearest` option. It is now applied as a filter on the distance. +- Fixed `Pipeline.findNearest(distanceThreshold:)` sending a `distance_threshold` option, which the backend rejects. It now adds a `where` stage after `find_nearest` that keeps the results within the threshold, as `Pipeline.createFrom()` does for a `VectorQuery`. - Fixed Pipeline results failing to decode when a reference that names no document, such as the database root returned by `parent()`, is nested in a map or array. It now decodes to its resource name at every depth, as it already did for top-level fields. - Pipeline raw options now match the Node Admin SDK. Every stage and source takes an optional `rawOptions`. Keys in `rawOptions` and `rawStage(options:)` are dot-separated paths merged into the typed options, and keys with an empty segment throw an `ArgumentError`. `rawStage` sends collections nested in a `Map` argument as `map(...)` / `array(...)` functions. - `PipelineFunctions.add` and `multiply` take an optional trailing list of further operands, and `PipelineExpression.add` and `multiply` take `(second, [others])`, like the variadic Node Admin SDK functions. Further operands are sent as nested two-operand calls, `add(add(a, b), c)`, since the backend's `add` and `multiply` take exactly two operands. diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index c692b4c2..3ac97572 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -1996,6 +1996,16 @@ final class Pipeline { /// [distanceResultField] is the field name, read like [field], that each /// result's distance is written to. /// + /// The backend's `find_nearest` stage takes no distance threshold, so a + /// [distanceThreshold] adds a [where] stage after it, as + /// [PipelineSource.createFrom] does for a [VectorQuery]. It keeps the + /// results whose distance is at most [distanceThreshold] for + /// [DistanceMeasure.euclidean] and [DistanceMeasure.cosine], and at least + /// [distanceThreshold] for [DistanceMeasure.dotProduct], where a larger + /// value means more similar vectors. It compares [distanceResultField] when + /// given, and otherwise computes the distance again. Since it filters the + /// nearest neighbors, [limit] applies first. + /// /// [rawOptions] sets stage options this SDK does not wrap yet, as /// [rawStage]'s `options` do, and takes precedence over the typed options. Pipeline findNearest({ @@ -2022,6 +2032,10 @@ final class Pipeline { /// [findNearest], with the distance field already resolved, so that /// [VectorQuery] conversion can pass a [FieldPath] one. + /// + /// [distanceThreshold] is never sent: the backend rejects a + /// `distance_threshold` option on `find_nearest` ("does not support + /// option(s)"), so it filters the stage's results instead. Pipeline _findNearest({ required Object vectorField, required Object queryVector, @@ -2031,20 +2045,43 @@ final class Pipeline { required double? distanceThreshold, Map rawOptions = const {}, }) { - return _stage( + final vector = _vectorOrExpression(queryVector, 'queryVector'); + final pipeline = _stage( 'find_nearest', - [ - vectorField, - _vectorOrExpression(queryVector, 'queryVector'), - distanceMeasure.value.toLowerCase(), - ], + [vectorField, vector, distanceMeasure.value.toLowerCase()], options: _compactOptions({ 'limit': limit, 'distance_field': distanceField, - 'distance_threshold': distanceThreshold, }), rawOptions: rawOptions, ); + if (distanceThreshold == null) return pipeline; + + // The documents within the threshold are the nearest ones, so filtering + // the `limit` nearest neighbors keeps exactly the documents within the + // threshold, up to `limit`, as a VectorQuery returns. + final distance = + distanceField ?? + switch (distanceMeasure) { + DistanceMeasure.euclidean => PipelineFunctions.euclideanDistance( + vectorField, + vector, + ), + DistanceMeasure.cosine => PipelineFunctions.cosineDistance( + vectorField, + vector, + ), + DistanceMeasure.dotProduct => PipelineFunctions.dotProduct( + vectorField, + vector, + ), + }; + // A larger dot product means more similar vectors. + return pipeline.where( + distanceMeasure == DistanceMeasure.dotProduct + ? distance.greaterThanOrEqual(distanceThreshold) + : distance.lessThanOrEqual(distanceThreshold), + ); } /// Adds a search stage. @@ -2430,46 +2467,21 @@ extension _VectorQueryToPipeline on VectorQuery { Pipeline _toPipeline(Firestore firestore) { // Both fields are a String or a FieldPath; field() reads either. final vectorField = field(_options.vectorField); - final queryVector = FieldValue.vector(_rawQueryVector); final distanceResultField = _options.distanceResultField; - final distanceMeasure = _options.distanceMeasure; - final pipeline = _query + return _query ._toPipeline(firestore) .where(vectorField.exists()) ._findNearest( vectorField: vectorField, - queryVector: queryVector, - distanceMeasure: distanceMeasure, + queryVector: FieldValue.vector(_rawQueryVector), + distanceMeasure: _options.distanceMeasure, limit: _options.limit, distanceField: distanceResultField == null ? null : field(distanceResultField), - distanceThreshold: null, + distanceThreshold: _options.distanceThreshold, ); - - final threshold = _options.distanceThreshold; - if (threshold == null) return pipeline; - - // The find_nearest stage documents no threshold option, so the threshold - // filters the nearest neighbors instead. The documents within the - // threshold are the nearest ones, so filtering the `limit` nearest - // neighbors keeps exactly the documents the query returns. - final distance = distanceResultField != null - ? field(distanceResultField) - : switch (distanceMeasure) { - DistanceMeasure.euclidean => vectorField.euclideanDistance( - queryVector, - ), - DistanceMeasure.cosine => vectorField.cosineDistance(queryVector), - DistanceMeasure.dotProduct => vectorField.dotProduct(queryVector), - }; - // A larger dot product means more similar vectors. - return pipeline.where( - distanceMeasure == DistanceMeasure.dotProduct - ? distance.greaterThanOrEqual(threshold) - : distance.lessThanOrEqual(threshold), - ); } } diff --git a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart index cba0abe7..47cd9de6 100644 --- a/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_api_parity_test.dart @@ -186,6 +186,13 @@ const _knownDifferences = { 'passes a read time internally.', 'Transaction.executePipeline': 'Takes the same execute options as Pipeline.execute; Node takes none.', + 'Pipeline.findNearest#distanceThreshold': + 'Dart-only convenience, mirroring VectorQuery: applied as a where stage ' + 'filtering the distance after find_nearest, as createFrom(vectorQuery) ' + 'does, and never sent as an option. The backend rejects one: "Stage ' + "'find_nearest(field: FieldReference, vector: Vector, distance_measure: " + "DistanceMeasure, limit: Int64?, distance_field: FieldPath?)' does not " + 'support option(s) [distance_threshold]."', 'PipelineValueType#request_timestamp': "The backend's is_type rejects it: its list of accepted types does not " "include 'request_timestamp'.", @@ -199,11 +206,6 @@ const _knownDifferences = { /// /// Remove an entry once its fix lands; the test fails while it is stale. const _pendingFixes = { - // Signatures. - 'Pipeline.findNearest#distanceThreshold': - 'Dart-only option, sent as `distance_threshold`: Node has no such ' - 'find_nearest option and its createFrom(vectorQuery) drops ' - 'distanceThreshold. Confirm the backend accepts it.', // Options. 'PipelineSource.collection#forceIndex': 'Node collection({collection, forceIndex}) can force an index; Dart ' diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index f7753993..201169c7 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -154,8 +154,9 @@ const _distanceThresholdReason = 'createFrom(vectorQuery) applies distanceThreshold as a where stage on ' 'the distance after find_nearest (<= for euclidean and cosine, >= for ' 'dot product), keeping the VectorQuery results. Node drops the threshold ' - 'and returns documents beyond it; find_nearest documents no threshold ' - 'option.'; + 'and returns documents beyond it. find_nearest takes no threshold option: ' + 'the backend rejects one ("does not support option(s) ' + '[distance_threshold]").'; /// Differences from the Node SDK that are not intended, awaiting a fix. /// diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 5fc17961..e478bf56 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -2596,6 +2596,82 @@ void main() { 'target', ); }); + + // The backend rejects a distance_threshold option on find_nearest, + // so the threshold is applied as createFrom(vectorQuery) applies it. + group('applies distanceThreshold as a filter', () { + for (final (measure, comparison, distanceFunction) in [ + ( + DistanceMeasure.euclidean, + 'less_than_or_equal', + 'euclidean_distance', + ), + (DistanceMeasure.cosine, 'less_than_or_equal', 'cosine_distance'), + // A larger dot product means more similar vectors. + ( + DistanceMeasure.dotProduct, + 'greater_than_or_equal', + 'dot_product', + ), + ]) { + test('${measure.name} keeps $comparison the threshold', () async { + await run( + base().findNearest( + vectorField: 'embedding', + queryVector: const [1.0, 2.0], + distanceMeasure: measure, + limit: 3, + distanceThreshold: 0.5, + ), + ); + + final [_, findNearest, where] = stages; + expect(findNearest.name, 'find_nearest'); + expect(findNearest.options.keys, ['limit']); + expect(where.name, 'where'); + final condition = where.args.single.functionValue!; + expect(condition.name, comparison); + expect(condition.args[1].doubleValue, 0.5); + final distance = condition.args[0].functionValue!; + expect(distance.name, distanceFunction); + expect(distance.args[0].fieldReferenceValue, 'embedding'); + expectVector(distance.args[1], [1.0, 2.0]); + }); + } + + test('reads distanceResultField when given', () async { + await run( + base().findNearest( + vectorField: 'embedding', + queryVector: const [1.0, 2.0], + distanceMeasure: DistanceMeasure.euclidean, + distanceResultField: 'distance', + distanceThreshold: 0.5, + ), + ); + + final [_, findNearest, where] = stages; + expect(findNearest.options.keys, ['distance_field']); + final condition = where.args.single.functionValue!; + expect(condition.name, 'less_than_or_equal'); + expect(condition.args[0].fieldReferenceValue, 'distance'); + }); + + test('adds no stage without a threshold', () async { + await run( + base().findNearest( + vectorField: 'embedding', + queryVector: const [1.0, 2.0], + distanceMeasure: DistanceMeasure.euclidean, + ), + ); + + expect(stages.map((stage) => stage.name), [ + 'collection', + 'find_nearest', + ]); + }); + }); }); }); @@ -3844,7 +3920,8 @@ void main() { ), ); - // find_nearest documents no threshold option, so none is sent. + // The backend rejects a threshold option on find_nearest, so none is + // sent. final findNearest = stages[stages.length - 2]; expect(findNearest.name, 'find_nearest'); expect( From bf6e603247144c49e52e0068e00b8a1972dee2d1 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 15:22:56 +0200 Subject: [PATCH 25/29] fix(firestore): stop sending indexMode and deprecate it A live run rejected the index_mode option Pipeline.execute and Transaction.executePipeline sent for indexMode: Unsupported option: index_mode The Node SDK sends it too. Its only value, recommended, lets the backend choose its indexes, which it already does by default, so indexMode is now ignored. The indexMode parameters and PipelineIndexMode are deprecated rather than removed, keeping the change non-breaking. rawOptions can still send an index_mode explicitly. The golden fixtures where Node sends index_mode from indexMode become known divergences, checked for the absence of index_mode. --- packages/google_cloud_firestore/CHANGELOG.md | 1 + packages/google_cloud_firestore/README.md | 1 - .../lib/src/pipeline.dart | 34 ++++++++++++------- .../lib/src/transaction.dart | 8 ++--- .../test/e2e/pipeline/execute_test.dart | 3 ++ .../test/pipeline_golden_test.dart | 23 +++++++++++++ .../test/pipeline_test.dart | 10 +++--- 7 files changed, 59 insertions(+), 21 deletions(-) diff --git a/packages/google_cloud_firestore/CHANGELOG.md b/packages/google_cloud_firestore/CHANGELOG.md index 093a1b03..33a6c881 100644 --- a/packages/google_cloud_firestore/CHANGELOG.md +++ b/packages/google_cloud_firestore/CHANGELOG.md @@ -27,6 +27,7 @@ - Fixed `Pipeline.createFrom()` and `DocumentSnapshot` query cursors leaving `!=` and `not-in` fields out of the implicit ordering, and ordering inequality fields by their quoted names instead of segment by segment, unlike the backend and the Node Admin SDK. A snapshot cursor now needs a value for every inequality field. - Fixed `Pipeline.createFrom()` of a query with a cursor on a descending ordering keeping the wrong side of the bound. - Fixed `Pipeline.createFrom()` of a `VectorQuery` sending its `distanceThreshold` as an undocumented `find_nearest` option. It is now applied as a filter on the distance. +- Fixed `indexMode` being sent as an `index_mode` option the backend rejects; it is deprecated. `Pipeline.execute()` and `Transaction.executePipeline()` now ignore `indexMode`, and `PipelineIndexMode` is deprecated too: its only value, `recommended`, is already the backend's default. - Fixed `Pipeline.findNearest(distanceThreshold:)` sending a `distance_threshold` option, which the backend rejects. It now adds a `where` stage after `find_nearest` that keeps the results within the threshold, as `Pipeline.createFrom()` does for a `VectorQuery`. - Fixed Pipeline results failing to decode when a reference that names no document, such as the database root returned by `parent()`, is nested in a map or array. It now decodes to its resource name at every depth, as it already did for top-level fields. - Pipeline raw options now match the Node Admin SDK. Every stage and source takes an optional `rawOptions`. Keys in `rawOptions` and `rawStage(options:)` are dot-separated paths merged into the typed options, and keys with an empty segment throw an `ArgumentError`. `rawStage` sends collections nested in a `Map` argument as `map(...)` / `array(...)` functions. diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index c3b5770c..c7a6ac0a 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -339,7 +339,6 @@ dotted keys merge into the typed options, so `explain` plus ```dart .execute( - indexMode: PipelineIndexMode.recommended, explain: const PipelineExplainOptions( mode: PipelineExplainMode.analyze, outputFormat: PipelineExplainOutputFormat.text, diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 3ac97572..5979b51c 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -162,8 +162,13 @@ enum PipelineValueType { } /// How the backend should choose indexes when executing a Pipeline. +/// +/// Deprecated: the backend rejects the `index_mode` option this selects +/// ("Unsupported option: index_mode"), and [recommended], letting the backend +/// choose, is already its default. `indexMode` is no longer sent. +@Deprecated(_indexModeDeprecation) enum PipelineIndexMode { - /// Let the backend pick the indexes it recommends. + /// Let the backend pick the indexes it recommends, which it does by default. recommended('recommended'); const PipelineIndexMode(this.value); @@ -172,6 +177,12 @@ enum PipelineIndexMode { final String value; } +/// Why [PipelineIndexMode] and the `indexMode` parameters are deprecated. +const _indexModeDeprecation = + 'The backend rejects the index_mode option ("Unsupported option: ' + 'index_mode"), and recommended is already its default, so indexMode is ' + 'ignored. Omit it.'; + /// Whether the backend should return planning stats alongside the results. enum PipelineExplainMode { /// Execute the Pipeline and return results, without planning stats. @@ -2108,6 +2119,10 @@ final class Pipeline { /// Pass [readTime] to read the database as it was at a past timestamp. To /// read inside a transaction, use [Transaction.executePipeline] instead. /// + /// [indexMode] is deprecated and ignored: the backend rejects the + /// `index_mode` option it used to send, and picks the indexes it recommends + /// by default. + /// /// Pass [explain] to ask the backend for planning stats, then read /// [PipelineSnapshot.explainStats]. [rawOptions] sets options this SDK does /// not wrap yet, keyed by the names the backend expects, and takes @@ -2134,32 +2149,27 @@ final class Pipeline { /// ``` Future execute({ Timestamp? readTime, - PipelineIndexMode? indexMode, + @Deprecated(_indexModeDeprecation) PipelineIndexMode? indexMode, PipelineExplainOptions? explain, Map rawOptions = const {}, }) async { final result = await _execute( readTime: readTime, - options: _executeOptions( - indexMode: indexMode, - explain: explain, - rawOptions: rawOptions, - ), + options: _executeOptions(explain: explain, rawOptions: rawOptions), ); return result.result; } /// Builds the StructuredPipeline options, with [rawOptions] winning. + /// + /// There is no `index_mode`: the backend rejects it ("Unsupported option: + /// index_mode"), so the deprecated `indexMode` parameters are ignored. static _PipelineOptions _executeOptions({ - required PipelineIndexMode? indexMode, required PipelineExplainOptions? explain, required Map rawOptions, }) { return _PipelineOptions( - known: _compactOptions({ - 'index_mode': indexMode?.value, - 'explain_options': explain?._encoded, - }), + known: _compactOptions({'explain_options': explain?._encoded}), raw: rawOptions, ); } diff --git a/packages/google_cloud_firestore/lib/src/transaction.dart b/packages/google_cloud_firestore/lib/src/transaction.dart index c2b9821c..e94c216f 100644 --- a/packages/google_cloud_firestore/lib/src/transaction.dart +++ b/packages/google_cloud_firestore/lib/src/transaction.dart @@ -145,8 +145,9 @@ class Transaction { /// Executes a Pipeline and returns the results as part of this transaction. /// - /// The Pipeline is executed at the transaction's snapshot. [indexMode], - /// [explain] and [rawOptions] behave as they do on [Pipeline.execute]. + /// The Pipeline is executed at the transaction's snapshot. [explain] and + /// [rawOptions] behave as they do on [Pipeline.execute]. [indexMode] is + /// deprecated and ignored, as it is there. /// /// ```dart /// firestore.runTransaction((transaction) async { @@ -164,7 +165,7 @@ class Transaction { /// ``` Future executePipeline( Pipeline pipeline, { - PipelineIndexMode? indexMode, + @Deprecated(_indexModeDeprecation) PipelineIndexMode? indexMode, PipelineExplainOptions? explain, Map rawOptions = const {}, }) async { @@ -182,7 +183,6 @@ class Transaction { ); final options = Pipeline._executeOptions( - indexMode: indexMode, explain: explain, rawOptions: rawOptions, ); diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart index b2a279ce..354d2d80 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/execute_test.dart @@ -117,6 +117,8 @@ void main() { }); test('executes with the recommended index mode', () async { + // indexMode is deprecated and not sent: the backend rejects the + // index_mode option ("Unsupported option: index_mode"). final snapshot = await sortedBooks().execute( indexMode: PipelineIndexMode.recommended, ); @@ -196,6 +198,7 @@ void main() { final snapshot = await pipeline.firestore.runTransaction( (transaction) => transaction.executePipeline( pipeline, + // Deprecated and not sent, as on Pipeline.execute. indexMode: PipelineIndexMode.recommended, // rawOptions win over the typed explain options, so this runs in // the default `execute` mode and returns no stats. diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index 201169c7..78b87229 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -104,6 +104,17 @@ final _knownDivergences = { (request) => _cursorComparisons(request).join(',') == comparisons.join(','), ), + 'options/index-mode/recommended': const _Divergence( + _indexModeReason, + _sendsNoIndexMode, + ), + 'options/combined/index-mode-and-explain': _Divergence( + _indexModeReason, + (request) => + _sendsNoIndexMode(request) && + _path(request, ['structuredPipeline', 'options', 'explain_options']) != + null, + ), 'queries/vector/distance-result-field': _Divergence( _distanceResultFieldReason, (request) => _findNearestDistanceField(request) == 'distance', @@ -145,6 +156,11 @@ final _knownDivergences = { ), }; +const _indexModeReason = + 'The deprecated indexMode is not sent: the backend rejects the ' + 'index_mode option Node sends ("Unsupported option: index_mode"), and ' + "recommended, its only value, is the backend's default."; + const _distanceResultFieldReason = "createFrom(vectorQuery) keeps distanceResultField as find_nearest's " 'distance_field option, so each result carries its distance as it does ' @@ -458,6 +474,13 @@ final class _Divergence { final bool Function(Object? request) check; } +/// Whether the request sends no `index_mode` execute option. +bool _sendsNoIndexMode(Object? request) { + final options = _path(request, ['structuredPipeline', 'options']); + return options == null || + (options is Map && !options.containsKey('index_mode')); +} + /// Whether the `unnest` stage writes each element back to the quoted /// `` `my tags` `` field it reads. bool _unnestTargetIsQuotedOnce(Object? request) { diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index e478bf56..48cc0f71 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -545,9 +545,10 @@ void main() { ), ); + // The backend rejects index_mode ("Unsupported option: index_mode"), so + // the deprecated indexMode is not sent. final options = capturedRequest!.structuredPipeline!.options; - expect(options.keys, unorderedEquals(['index_mode', 'explain_options'])); - expect(options['index_mode']!.stringValue, 'recommended'); + expect(options.keys, ['explain_options']); final explain = options['explain_options']!.mapValue!.fields; expect(explain['mode']!.stringValue, 'analyze'); @@ -741,9 +742,10 @@ void main() { final request = requests.single; // Pipeline options and the transaction live on different parts of the - // request, so neither clobbers the other. + // request, so neither clobbers the other. The deprecated indexMode is + // not sent, as on Pipeline.execute. final options = request.structuredPipeline!.options; - expect(options['index_mode']!.stringValue, 'recommended'); + expect(options.keys, ['explain_options']); expect( options['explain_options']!.mapValue!.fields['mode']!.stringValue, 'analyze', From eda10055d8e06f1f31a6861ef4dc655f05cbb410 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 15:26:28 +0200 Subject: [PATCH 26/29] fix(firestore): key String selections by their quoted path A live run rejected select(['last name']): Invalid property path "last name". Unquoted property paths must match regex ([a-zA-Z_][a-zA-Z_0-9]*), and quoted property paths must match regex (`(?:[^`\\]|(?:\\.))+`) The backend reads projection keys as field paths. A String selection was keyed by the raw string, as Node's selectablesToObject does, while a PipelineField is keyed by its quoted path, which the backend accepted. The shared projection helper behind select, distinct, aggregate groups and addFields now keys a String by the same quoted path, so select(['x-y', field('x-y')]) is reported as a duplicate. Aliases stay as written, as in Node; their dartdoc now says they are read as field paths. The String special-character stage fixtures become known divergences, and the live case still accepts either key back, since the failed run never returned results. --- packages/google_cloud_firestore/CHANGELOG.md | 1 + packages/google_cloud_firestore/README.md | 5 ++- .../lib/src/pipeline.dart | 32 +++++++++----- .../test/e2e/pipeline/stages_test.dart | 23 ++++++---- .../test/pipeline_golden_test.dart | 42 +++++++++++++++---- .../test/pipeline_test.dart | 17 ++++++-- 6 files changed, 87 insertions(+), 33 deletions(-) diff --git a/packages/google_cloud_firestore/CHANGELOG.md b/packages/google_cloud_firestore/CHANGELOG.md index 33a6c881..935f21c7 100644 --- a/packages/google_cloud_firestore/CHANGELOG.md +++ b/packages/google_cloud_firestore/CHANGELOG.md @@ -23,6 +23,7 @@ - `PipelineFunctions.raw` now accepts `options`, like `Expression.raw`. - `PipelineExpression.arrayContainsAll` and `arrayContainsAny` now also accept an array expression, like their `PipelineFunctions` forms and the Node Admin SDK. - Fixed Pipeline field names that are not simple identifiers being sent unquoted. They are now backtick-quoted like the Node Admin SDK, so `field('first-name')` sends `` `first-name` ``. `field()` and `Expression.field()` also accept a `FieldPath`. `PipelineField.path` returns the quoted path, and `field('')` and `field('a..b')` now throw. +- Fixed `Pipeline.select`, `distinct` and the `groups` of `aggregate` keying a `String` field name that is not an identifier, such as `'last name'`, by the raw string, which the backend rejects as an invalid property path. It is now keyed by its quoted path, `` `last name` ``, like a `PipelineField`, so `select(['x-y', field('x-y')])` now throws for a duplicate key. An alias is still keyed as written, and must be a valid field path. - Fixed field paths containing a backtick being escaped as a lone backslash, which named a different field, in queries, field masks and Pipelines. - Fixed `Pipeline.createFrom()` and `DocumentSnapshot` query cursors leaving `!=` and `not-in` fields out of the implicit ordering, and ordering inequality fields by their quoted names instead of segment by segment, unlike the backend and the Node Admin SDK. A snapshot cursor now needs a value for every inequality field. - Fixed `Pipeline.createFrom()` of a query with a cursor on a descending ordering keeping the wrong side of the bound. diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index c7a6ac0a..0bdb6f6e 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -370,7 +370,10 @@ dot-separated path, so `field('address.city')` reads `city` inside the `address` map; use a `FieldPath` for a field whose name contains a dot. Names that are not simple identifiers need no escaping: they are sent backtick-quoted, as the Node.js SDK sends them. The same rules apply to every -`String` field name a stage or function takes. +`String` field name a stage or function takes, and `select`, `distinct` and +`aggregate` key each field by its quoted path. An alias given to `as()` is sent +as written, and the backend reads it as a field path too, so backtick-quote an +alias that is not an identifier. ```dart field('first-name'); // sent as `first-name` diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 5979b51c..5b42f382 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -1762,13 +1762,15 @@ final class Pipeline { /// references, [PipelineExpression] instances, or /// [PipelineAliasedExpression] values. /// - /// The selections are sent as a map keyed by the field they land on: a - /// [String] is keyed by the string itself, a [PipelineField] by its - /// [PipelineField.path] and an aliased expression by its alias, as in the - /// Node SDK. + /// The selections are sent as a map keyed by the field they land on, which + /// the backend reads as a field path. A [String] or [PipelineField] is keyed + /// by its [PipelineField.path], so `'last name'` is keyed by + /// `` `last name` ``. An aliased expression is keyed by its alias as + /// written, as in the Node SDK, so an alias must be a valid field path + /// itself; see [PipelineExpression.as]. /// - /// Throws an [ArgumentError] when two [selections] land on the same field - /// name or alias. + /// Throws an [ArgumentError] when two [selections] land on the same key, as + /// `select(['x-y', field('x-y')])` does. /// /// [rawOptions] sets stage options this SDK does not wrap yet, as /// [rawStage]'s `options` do. @@ -2686,6 +2688,11 @@ sealed class PipelineExpression { firestore_v1.Value _toValue(Firestore firestore); /// Assigns [alias] to this expression for projection-style stages. + /// + /// The backend reads [alias] as a field path, so it must be a valid one: a + /// segment that is not an identifier, such as `total price`, must be + /// backtick-quoted (`` '`total price`' ``), and a dot separates the + /// segments of a nested field. PipelineAliasedExpression as(String alias) { return PipelineAliasedExpression._(this, alias); } @@ -3728,10 +3735,13 @@ Map _compactOptions(Map options) { /// last entry. A [String] or [PipelineField] lands on its own path, so it /// collides with an alias of the same name, matching the Node SDK. /// -/// Like the Node SDK's `selectablesToObject`, a [String] is keyed by the -/// string itself and a [PipelineField] by its canonical [PipelineField.path]: -/// `select(['first-name', field('last name')])` keys `first-name` and -/// `` `last name` ``, both referencing their backtick-quoted path. +/// The backend reads each key as a field path, and rejects one that is not +/// an identifier unless it is quoted ("Invalid property path \"last +/// name\""). So a [String] is keyed by the canonical [PipelineField.path] of +/// [field], as a [PipelineField] is: `select(['first-name', field('last +/// name')])` keys `` `first-name` `` and `` `last name` ``. The Node SDK's +/// `selectablesToObject` keys a string by the string itself. An alias is kept +/// as written, as in Node. Map _projectionMap( Iterable selections, { String argumentName = 'selections', @@ -3775,7 +3785,7 @@ Map _projectionMap( MapEntry _projectionEntry(Object selection) { return switch (selection) { - String() => MapEntry(selection, field(selection)), + String() => _projectionEntry(field(selection)), PipelineField() => MapEntry(selection.path, selection), PipelineAliasedExpression() => MapEntry( selection.name, diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart index 03332694..1685eddd 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart @@ -82,11 +82,11 @@ void main() { }); test('select keeps field names that are not identifiers', () async { - // A String names its output field as given. A PipelineField is keyed by - // its quoted path ("`first-name`"), as in the Node SDK, and the backend - // takes select keys verbatim (Node's system tests read `awards.hugo` - // back as one key), so the field's value is read through whichever key - // comes back. + // The backend reads select keys as field paths and rejects an unquoted + // `last name` ("Invalid property path"), so a String is keyed by its + // quoted path ("`last name`"), like a PipelineField. Whether a result + // comes back under the field name or the quoted path is not pinned + // down yet, so each value is read through whichever key comes back. final data = await _dataOf( ctx.book1Pipeline().select([field('first-name'), 'last name']), ); @@ -94,10 +94,15 @@ void main() { expect(data, hasLength(1)); final result = data.single; expect(result, hasLength(2)); - expect(result['last name'], 'Lovelace'); - final firstNameKey = result.keys.singleWhere((key) => key != 'last name'); - expect(firstNameKey, anyOf('first-name', '`first-name`')); - expect(result[firstNameKey], 'Ada'); + Object? valueOf(String name) { + final key = result.keys.singleWhere( + (key) => key == name || key == '`$name`', + ); + return result[key]; + } + + expect(valueOf('first-name'), 'Ada'); + expect(valueOf('last name'), 'Lovelace'); }); test('an alias names the field its expression lands on', () async { diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index 78b87229..51bb8fa8 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -78,6 +78,23 @@ final _knownDivergences = { r'("`\`first-name\``").', _projectionIsQuotedOnce, ), + for (final (id, stage, argument) in [ + ('select/special-characters', 'select', 0), + ('distinct/special-characters', 'distinct', 0), + ('aggregate/groups-special-characters', 'aggregate', 1), + ]) + 'stages/$id': _Divergence( + "A String selection such as 'first-name' is keyed by its quoted path, " + '`first-name`, like a PipelineField. The backend reads the keys as ' + 'field paths and rejects an unquoted one that is not an identifier: ' + "for select(['last name']) it answers " + r'"Invalid property path \"last name\". Unquoted property paths must ' + r'match regex ([a-zA-Z_][a-zA-Z_0-9]*), and quoted property paths must ' + r'match regex (`(?:[^`\\]|(?:\\.))+`)". Node keys a string by the ' + 'string itself.', + (request) => + _projectionIsQuotedOnce(request, stage: stage, argument: argument), + ), for (final (name, operand) in [('add', 1), ('multiply', 2)]) for (final form in ['static', 'method']) 'functions/$name/$form-variadic': _Divergence( @@ -491,19 +508,26 @@ bool _unnestTargetIsQuotedOnce(Object? request) { _jsonEquals(_path(stages[1], ['args']), [target, target]); } -/// Whether the `select` stage keys `` `first-name` `` and `` `last name` `` -/// by their singly quoted paths. -bool _projectionIsQuotedOnce(Object? request) { +/// Whether the [stage] stage's [argument] keys `` `first-name` `` and +/// `` `last name` `` by their singly quoted paths. +bool _projectionIsQuotedOnce( + Object? request, { + String stage = 'select', + int argument = 0, +}) { final stages = _path(request, ['structuredPipeline', 'pipeline', 'stages']); if (stages is! List) return false; - final select = stages.cast().firstWhere( - (stage) => _path(stage, ['name']) == 'select', + final projection = stages.cast().firstWhere( + (candidate) => _path(candidate, ['name']) == stage, orElse: () => null, ); - return _jsonEquals(_path(select, ['args', 0, 'mapValue', 'fields']), { - for (final path in ['`first-name`', '`last name`']) - path: {'fieldReferenceValue': path}, - }); + return _jsonEquals( + _path(projection, ['args', argument, 'mapValue', 'fields']), + { + for (final path in ['`first-name`', '`last name`']) + path: {'fieldReferenceValue': path}, + }, + ); } bool _jsonEquals(Object? a, Object? b) => equals(b).matches(a, {}); diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 48cc0f71..1750fcb4 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -1972,8 +1972,11 @@ void main() { }, ); + // The backend reads select keys as field paths and rejects an + // unquoted `last name` ("Invalid property path"), so a String is keyed + // by its quoted path, like a PipelineField. test( - 'select keys a String by itself and a PipelineField by its path', + 'select keys a String and a PipelineField by their quoted path', () async { await run( base().select([ @@ -1986,7 +1989,7 @@ void main() { final fields = stages[1].args.single.mapValue!.fields; expect(fields.map((key, value) => MapEntry(key, json(value))), { - 'first-name': ref('`first-name`'), + '`first-name`': ref('`first-name`'), '`last name`': ref('`last name`'), 'metadata.lang': ref('metadata.lang'), '`a.b`': ref('`a.b`'), @@ -2005,7 +2008,7 @@ void main() { ); final expected = { - 'first-name': ref('`first-name`'), + '`first-name`': ref('`first-name`'), '`last name`': ref('`last name`'), }; final distinct = stages[1].args.single.mapValue!.fields; @@ -2175,6 +2178,14 @@ void main() { ); }); + test('select rejects a String and a field with the same quoted path', () { + // Both are keyed by `x-y`. + expect( + () => base().select(['x-y', field('x-y')]), + duplicateError('`x-y`', 'selections'), + ); + }); + test('select rejects an alias that collides with a field name', () { expect( () => base().select(['title', constant('x').as('title')]), From 1b9779cd35cbf788cb75c1340895af47100e1ede Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 15:26:54 +0200 Subject: [PATCH 27/29] test(firestore): fix the expected mapValues of nested.level1 live nested.level1 is {level2: {value: 42}}, so mapValues returns its single value, the level2 map {value: 42}, as the live run did. The case expected the value of nested itself. Its mapKeys, mapValues and mapEntries siblings expect the right values. --- .../test/e2e/pipeline/functions_map_reference_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart index 1ce21d09..159a286e 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/functions_map_reference_test.dart @@ -278,13 +278,13 @@ void main() { PipelineFunctions.mapValues('stats'), unorderedEquals([100, 7]), ), + // `nested.level1` is {level2: {value: 42}}, so its single value is the + // `level2` map. FunctionCase( 'static mapValues on an expression', PipelineFunctions.mapValues(Expression.field('nested.level1')), [ - { - 'level2': {'value': 42}, - }, + {'value': 42}, ], ), FunctionCase( From 35adfcb3e185c886ab6b402ba0fe1ebef3229064 Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 15:35:13 +0200 Subject: [PATCH 28/29] fix(firestore): accept a FieldPath wherever a Pipeline takes a field name field() and Expression.field() take a String or a FieldPath, but the other field-name positions only checked for a String, so a FieldPath fell through to constant(FieldPath) and failed to encode. Every field name is now read through _fieldOrExpression, which takes either: the target of every PipelineFunctions function, ascending() and descending(), the entries of select, distinct, aggregate groups and removeFields, unnest, replaceWith, and findNearest's vectorField and distanceResultField, which now takes a FieldPath like Query.findNearest's. The golden unary and binary function cases, and the stage cases naming fields, gain FieldPath forms, and the coverage guard requires a live FieldPath case for each stage position. --- packages/google_cloud_firestore/CHANGELOG.md | 1 + packages/google_cloud_firestore/README.md | 9 +- .../lib/src/pipeline.dart | 139 ++++++++++-------- .../test/e2e/pipeline/aggregates_test.dart | 22 +++ .../test/e2e/pipeline/stages_test.dart | 119 ++++++++++++--- .../test/pipeline_e2e_coverage_test.dart | 25 +++- .../test/pipeline_golden_test.dart | 72 ++++++++- .../test/pipeline_test.dart | 114 ++++++++++++++ 8 files changed, 408 insertions(+), 93 deletions(-) diff --git a/packages/google_cloud_firestore/CHANGELOG.md b/packages/google_cloud_firestore/CHANGELOG.md index 935f21c7..c76aaef4 100644 --- a/packages/google_cloud_firestore/CHANGELOG.md +++ b/packages/google_cloud_firestore/CHANGELOG.md @@ -24,6 +24,7 @@ - `PipelineExpression.arrayContainsAll` and `arrayContainsAny` now also accept an array expression, like their `PipelineFunctions` forms and the Node Admin SDK. - Fixed Pipeline field names that are not simple identifiers being sent unquoted. They are now backtick-quoted like the Node Admin SDK, so `field('first-name')` sends `` `first-name` ``. `field()` and `Expression.field()` also accept a `FieldPath`. `PipelineField.path` returns the quoted path, and `field('')` and `field('a..b')` now throw. - Fixed `Pipeline.select`, `distinct` and the `groups` of `aggregate` keying a `String` field name that is not an identifier, such as `'last name'`, by the raw string, which the backend rejects as an invalid property path. It is now keyed by its quoted path, `` `last name` ``, like a `PipelineField`, so `select(['x-y', field('x-y')])` now throws for a duplicate key. An alias is still keyed as written, and must be a valid field path. +- Pipeline stages and functions now accept a `FieldPath` wherever they accept a `String` field name, as `field()` does: the target of every `PipelineFunctions` function, `ascending()` and `descending()`, the entries of `select`, `distinct`, `aggregate` groups and `removeFields`, `unnest`, `replaceWith`, and `findNearest`'s `vectorField` and `distanceResultField`. A `FieldPath` there used to be sent as a constant, which failed to encode. - Fixed field paths containing a backtick being escaped as a lone backslash, which named a different field, in queries, field masks and Pipelines. - Fixed `Pipeline.createFrom()` and `DocumentSnapshot` query cursors leaving `!=` and `not-in` fields out of the implicit ordering, and ordering inequality fields by their quoted names instead of segment by segment, unlike the backend and the Node Admin SDK. A snapshot cursor now needs a value for every inequality field. - Fixed `Pipeline.createFrom()` of a query with a cursor on a descending ordering keeping the wrong side of the bound. diff --git a/packages/google_cloud_firestore/README.md b/packages/google_cloud_firestore/README.md index 0bdb6f6e..cf5bff1a 100644 --- a/packages/google_cloud_firestore/README.md +++ b/packages/google_cloud_firestore/README.md @@ -370,10 +370,11 @@ dot-separated path, so `field('address.city')` reads `city` inside the `address` map; use a `FieldPath` for a field whose name contains a dot. Names that are not simple identifiers need no escaping: they are sent backtick-quoted, as the Node.js SDK sends them. The same rules apply to every -`String` field name a stage or function takes, and `select`, `distinct` and -`aggregate` key each field by its quoted path. An alias given to `as()` is sent -as written, and the backend reads it as a field path too, so backtick-quote an -alias that is not an identifier. +field name a stage or function takes, which may be a `FieldPath` wherever it +may be a `String`, and `select`, `distinct` and `aggregate` key each field by +its quoted path. An alias given to `as()` is sent as written, and the backend +reads it as a field path too, so backtick-quote an alias that is not an +identifier. ```dart field('first-name'); // sent as `first-name` diff --git a/packages/google_cloud_firestore/lib/src/pipeline.dart b/packages/google_cloud_firestore/lib/src/pipeline.dart index 5b42f382..0fc729a9 100644 --- a/packages/google_cloud_firestore/lib/src/pipeline.dart +++ b/packages/google_cloud_firestore/lib/src/pipeline.dart @@ -298,14 +298,23 @@ String _canonicalFieldPath(Object fieldPath) { return path._formattedName; } -/// Interprets a [String] in a field position as a field reference. +/// Whether [value] is a field name, as [field] takes one: a [String] (a +/// dot-separated path) or a [FieldPath]. +bool _isFieldName(Object? value) => value is String || value is FieldPath; + +/// Interprets a field name in a field position as a field reference. /// /// Mirrors the Node SDK's `fieldOrExpression`: arguments that name the target /// of a function accept either a field name or an expression, so a bare -/// [String] means [field]. Arguments in a value position keep [String]s as -/// string literals, which is what [_encodePipelineValue] does by default. +/// [String] means [field]. So does a [FieldPath], which [field] takes too but +/// Node only takes inside `field()`. Arguments in a value position keep +/// [String]s as string literals, which is what [_encodePipelineValue] does by +/// default. +/// +/// Every stage and function reads its field names through this, so they all +/// accept the same ones. Object? _fieldOrExpression(Object? value) { - return value is String ? field(value) : value; + return _isFieldName(value) ? field(value!) : value; } /// Applies [_fieldOrExpression] to the first entry of [values]. @@ -419,11 +428,11 @@ PipelineBooleanExpression documentMatches(Object? rquery) { /// Convenience wrappers for the Firestore Pipeline function catalog. /// /// These helpers encode to the backend function names documented in the -/// Firestore Pipeline functions reference. A [String] in a field position -/// (the target of most functions, such as `'title'` in +/// Firestore Pipeline functions reference. A [String] or a [FieldPath] in a +/// field position (the target of most functions, such as `'title'` in /// `startsWith('title', 'Harry')`) is a field name, read like [field]; in a -/// value position it is a string literal. Use [field] to reference a document -/// field in a value position. +/// value position a [String] is a string literal. Use [field] to reference a +/// document field in a value position. /// /// A [List] or [Map] argument may hold expressions, as in `[field('a'), 1]`: /// it is sent as an [array] or [map] function so the backend evaluates them. @@ -1508,24 +1517,20 @@ abstract final class PipelineFunctions { /// Creates an ascending Pipeline ordering. /// -/// [expression] is a field name or an expression, like -/// [PipelineExpression.ascending] on that expression. +/// [expression] is a field name (a [String] or a [FieldPath], read like +/// [field]) or an expression, like [PipelineExpression.ascending] on that +/// expression. PipelineOrdering ascending(Object expression) { - return PipelineOrdering._( - 'ascending', - expression is String ? field(expression) : expression, - ); + return PipelineOrdering._('ascending', _fieldOrExpression(expression)!); } /// Creates a descending Pipeline ordering. /// -/// [expression] is a field name or an expression, like -/// [PipelineExpression.descending] on that expression. +/// [expression] is a field name (a [String] or a [FieldPath], read like +/// [field]) or an expression, like [PipelineExpression.descending] on that +/// expression. PipelineOrdering descending(Object expression) { - return PipelineOrdering._( - 'descending', - expression is String ? field(expression) : expression, - ); + return PipelineOrdering._('descending', _fieldOrExpression(expression)!); } /// The starting point for constructing Firestore Pipeline operations. @@ -1758,13 +1763,13 @@ final class Pipeline { /// Selects or computes fields from the inputs. /// - /// Entries may be [String] field names (read like [field]), [PipelineField] - /// references, [PipelineExpression] instances, or - /// [PipelineAliasedExpression] values. + /// Entries may be field names ([String]s or [FieldPath]s, read like + /// [field]), [PipelineField] references, or [PipelineAliasedExpression] + /// values. /// /// The selections are sent as a map keyed by the field they land on, which - /// the backend reads as a field path. A [String] or [PipelineField] is keyed - /// by its [PipelineField.path], so `'last name'` is keyed by + /// the backend reads as a field path. A field name or [PipelineField] is + /// keyed by its [PipelineField.path], so `'last name'` is keyed by /// `` `last name` ``. An aliased expression is keyed by its alias as /// written, as in the Node SDK, so an alias must be a valid field path /// itself; see [PipelineExpression.as]. @@ -1832,8 +1837,9 @@ final class Pipeline { /// Returns unique combinations of the provided grouping expressions. /// - /// Entries may be [String] field names (read like [field]), [PipelineField] - /// references, or [PipelineAliasedExpression] values, keyed as in [select]. + /// Entries may be field names ([String]s or [FieldPath]s, read like + /// [field]), [PipelineField] references, or [PipelineAliasedExpression] + /// values, keyed as in [select]. /// /// Throws an [ArgumentError] when two [groups] land on the same field name /// or alias. @@ -1855,8 +1861,8 @@ final class Pipeline { /// Removes fields from the inputs. /// - /// Entries may be [String] field names, read like [field], or - /// [PipelineField] references. + /// Entries may be field names ([String]s or [FieldPath]s, read like + /// [field]), or [PipelineField] references. /// /// [rawOptions] sets stage options this SDK does not wrap yet, as /// [rawStage]'s `options` do. @@ -1865,8 +1871,7 @@ final class Pipeline { Map rawOptions = const {}, }) { return _stage('remove_fields', [ - for (final value in fields) - if (value is String) field(value) else value, + for (final value in fields) _fieldOrExpression(value), ], rawOptions: rawOptions); } @@ -1910,10 +1915,11 @@ final class Pipeline { /// Emits a document for each element of the array selected by [selectable]. /// /// Each emitted document has the array element assigned to the selection's - /// alias. [selectable] may be a [String] field name, a [PipelineField], or a - /// [PipelineAliasedExpression] created with [PipelineExpression.as] when the - /// element should land on a different field than the source array. A - /// [String] field name, an alias and [indexField] are all read like [field]. + /// alias. [selectable] may be a field name (a [String] or a [FieldPath]), a + /// [PipelineField], or a [PipelineAliasedExpression] created with + /// [PipelineExpression.as] when the element should land on a different + /// field than the source array. A field name, an alias and [indexField] are + /// all read like [field]. /// /// When [indexField] is given, the element's zero-based index is assigned to /// that field. @@ -2004,10 +2010,11 @@ final class Pipeline { /// Performs vector nearest-neighbor search. /// - /// [vectorField] is a field name, read like [field], or an expression. - /// [queryVector] is a [VectorValue], a list of numbers, or an expression. - /// [distanceResultField] is the field name, read like [field], that each - /// result's distance is written to. + /// [vectorField] is a field name (a [String] or a [FieldPath], read like + /// [field]) or an expression. [queryVector] is a [VectorValue], a list of + /// numbers, or an expression. [distanceResultField] is the field name, a + /// [String] or a [FieldPath] read like [field], that each result's distance + /// is written to, as for [Query.findNearest]. /// /// The backend's `find_nearest` stage takes no distance threshold, so a /// [distanceThreshold] adds a [where] stage after it, as @@ -2026,12 +2033,12 @@ final class Pipeline { required Object queryVector, required DistanceMeasure distanceMeasure, int? limit, - String? distanceResultField, + Object? distanceResultField, double? distanceThreshold, Map rawOptions = const {}, }) { return _findNearest( - vectorField: vectorField is String ? field(vectorField) : vectorField, + vectorField: _fieldOrExpression(vectorField)!, queryVector: queryVector, distanceMeasure: distanceMeasure, limit: limit, @@ -3732,16 +3739,16 @@ Map _compactOptions(Map options) { /// Keys each selection's expression by the field name or alias it lands on. /// /// Throws an [ArgumentError] on a repeated key instead of silently keeping the -/// last entry. A [String] or [PipelineField] lands on its own path, so it +/// last entry. A field name or [PipelineField] lands on its own path, so it /// collides with an alias of the same name, matching the Node SDK. /// /// The backend reads each key as a field path, and rejects one that is not /// an identifier unless it is quoted ("Invalid property path \"last -/// name\""). So a [String] is keyed by the canonical [PipelineField.path] of -/// [field], as a [PipelineField] is: `select(['first-name', field('last -/// name')])` keys `` `first-name` `` and `` `last name` ``. The Node SDK's -/// `selectablesToObject` keys a string by the string itself. An alias is kept -/// as written, as in Node. +/// name\""). So a field name ([String] or [FieldPath]) is keyed by the +/// canonical [PipelineField.path] of [field], as a [PipelineField] is: +/// `select(['first-name', field('last name')])` keys `` `first-name` `` and +/// `` `last name` ``. The Node SDK's `selectablesToObject` keys a string by +/// the string itself. An alias is kept as written, as in Node. Map _projectionMap( Iterable selections, { String argumentName = 'selections', @@ -3760,36 +3767,37 @@ Map _projectionMap( /// Splits a selectable into the expression it computes and the field it lands /// on. /// -/// A [String] or [PipelineField] lands on its own path, matching the Node SDK -/// where `Field._alias` is the field name. An alias is a field path, as in -/// Node's `field(alias)`. +/// A field name ([String] or [FieldPath]) or [PipelineField] lands on its own +/// path, matching the Node SDK where `Field._alias` is the field name. An +/// alias is a field path, as in Node's `field(alias)`. /// /// Node rebuilds the field from `Field._alias`, its already-quoted name, so it /// quotes a field such as `last name` twice. The field is reused as is here. (Object expression, PipelineField target) _selectableParts(Object selectable) { - return switch (selectable) { - String() => (field(selectable), field(selectable)), - PipelineField() => (selectable, selectable), - PipelineAliasedExpression() => ( - selectable.expression, - field(selectable.name), + return switch (_fieldOrExpression(selectable)) { + final PipelineField target => (target, target), + final PipelineAliasedExpression aliased => ( + aliased.expression, + field(aliased.name), ), _ => throw ArgumentError.value( selectable, 'selectable', - 'Expected a String, PipelineField, or PipelineAliasedExpression. ' - 'Computed expressions must be aliased with as().', + 'Expected a String, FieldPath, PipelineField, or ' + 'PipelineAliasedExpression. Computed expressions must be aliased ' + 'with as().', ), }; } +/// The key and the expression of a selection, a field name being read like +/// [field]. MapEntry _projectionEntry(Object selection) { - return switch (selection) { - String() => _projectionEntry(field(selection)), - PipelineField() => MapEntry(selection.path, selection), - PipelineAliasedExpression() => MapEntry( - selection.name, - selection.expression, + return switch (_fieldOrExpression(selection)) { + final PipelineField target => MapEntry(target.path, target), + final PipelineAliasedExpression aliased => MapEntry( + aliased.name, + aliased.expression, ), PipelineExpression() => throw ArgumentError.value( selection, @@ -3799,7 +3807,8 @@ MapEntry _projectionEntry(Object selection) { _ => throw ArgumentError.value( selection, 'selections', - 'Expected a String, PipelineField, or PipelineAliasedExpression.', + 'Expected a String, FieldPath, PipelineField, or ' + 'PipelineAliasedExpression.', ), }; } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart index aaf66d72..f3f11911 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/aggregates_test.dart @@ -239,5 +239,27 @@ void main() { ], ); }); + + test('aggregates per group named by a FieldPath', () async { + // A FieldPath names a group as a String does. Only book 3 is archived. + final snapshot = await ctx + .runPipeline() + .aggregate( + [PipelineFunctions.countAll().as('books')], + groups: [ + FieldPath(const ['archived']), + ], + ) + .sort([ascending('archived')]) + .execute(); + + expect( + [for (final result in snapshot.results) result.data()], + [ + {'archived': false, 'books': 2}, + {'archived': true, 'books': 1}, + ], + ); + }); }); } diff --git a/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart index 1685eddd..31736441 100644 --- a/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart +++ b/packages/google_cloud_firestore/test/e2e/pipeline/stages_test.dart @@ -105,6 +105,39 @@ void main() { expect(valueOf('last name'), 'Lovelace'); }); + test('sort and select take FieldPaths with a dotted segment', () async { + // `dotted.key` is a single field of `nested`, 'dot' in every book, so + // the categories decide the order: sdk, draft, admin. + final snapshot = await ctx + .runPipeline() + .sort([ + ascending(FieldPath(const ['nested', 'dotted.key'])), + descending(FieldPath(const ['metadata', 'category'])), + ]) + .select([ + 'title', + FieldPath(const ['nested', 'dotted.key']), + ]) + .execute(); + + expect( + [for (final result in snapshot.results) result.get('title')], + ['Dart Pipelines', 'Inactive Draft', 'Firestore Admin'], + ); + // The selection is keyed by its quoted path, nested.`dotted.key`. + // Whether the value comes back nested under `nested` or under a single + // key is not pinned down yet, so any of them is accepted. + for (final result in snapshot.results) { + final data = result.data(); + expect( + result.get(FieldPath(const ['nested', 'dotted.key'])) ?? + data['nested.`dotted.key`'] ?? + data['nested.dotted.key'], + 'dot', + ); + } + }); + test('an alias names the field its expression lands on', () async { final aliased = Expression.field('price').as('cost'); @@ -137,21 +170,29 @@ void main() { expect(single.results.single.get('upper'), 'DART PIPELINES'); }); - test('removeFields drops fields named by string or field', () async { - final data = await _dataOf( - ctx.book1Pipeline().removeFields([Expression.field('price'), 'rating']), - ); + test( + 'removeFields drops fields named by string, FieldPath or field', + () async { + final data = await _dataOf( + ctx.book1Pipeline().removeFields([ + Expression.field('price'), + 'rating', + FieldPath(const ['first-name']), + ]), + ); - expect(data, hasLength(1)); - // Every other seeded field is still there. - expect( - data.single.keys, - unorderedEquals( - ctx.seed[0].keys.where((key) => key != 'price' && key != 'rating'), - ), - ); - expect(data.single['title'], 'Dart Pipelines'); - }); + expect(data, hasLength(1)); + // Every other seeded field is still there. + const removed = {'price', 'rating', 'first-name'}; + expect( + data.single.keys, + unorderedEquals( + ctx.seed[0].keys.where((key) => !removed.contains(key)), + ), + ); + expect(data.single['title'], 'Dart Pipelines'); + }, + ); test('sort applies its orderings in sequence', () async { // active descending puts the two active books first (false sorts before @@ -247,6 +288,22 @@ void main() { {'active': true, 'lang': 'dart'}, ], ); + + // A FieldPath names a group as a String does. + expect( + await _dataOf( + ctx + .runPipeline() + .distinct([ + FieldPath(const ['active']), + ]) + .sort([ascending('active')]), + ), + [ + {'active': false}, + {'active': true}, + ], + ); }); test('unnest by field name replaces the array with each element', () async { @@ -263,6 +320,22 @@ void main() { ); }); + test('unnest by FieldPath replaces the array with each element', () async { + expect( + await _dataOf( + ctx + .book1Pipeline() + .unnest(FieldPath(const ['tags'])) + .select(['title', 'tags']) + .sort([ascending('tags')]), + ), + [ + {'title': 'Dart Pipelines', 'tags': 'dart'}, + {'title': 'Dart Pipelines', 'tags': 'firebase'}, + ], + ); + }); + test('unnest an aliased field keeps the source array', () async { expect( await _dataOf( @@ -336,6 +409,18 @@ void main() { expect(await _dataOf(ctx.book1Pipeline().replaceWith('metadata')), [ {'lang': 'dart', 'category': 'sdk'}, ]); + expect( + await _dataOf( + ctx.book1Pipeline().replaceWith( + FieldPath(const ['nested', 'level1']), + ), + ), + [ + { + 'level2': {'value': 42}, + }, + ], + ); }); test('replaceWith an expression uses the map it evaluates to', () async { @@ -484,15 +569,15 @@ void main() { ); }); - test('findNearest by cosine distance', () async { + test('findNearest by cosine distance, with FieldPath fields', () async { final snapshot = await ctx .runPipeline() .findNearest( - vectorField: 'embedding', + vectorField: FieldPath(const ['embedding']), queryVector: const [3.0, 2.0, 1.0], distanceMeasure: DistanceMeasure.cosine, limit: 3, - distanceResultField: 'distance', + distanceResultField: FieldPath(const ['distance']), ) .execute(); diff --git a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart index 7d55e6eb..0b94aa9f 100644 --- a/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_e2e_coverage_test.dart @@ -343,8 +343,9 @@ enum _Shape { /// The argument is a `String` literal containing a dot. dottedPath("a dotted String literal such as 'a.b'"), - /// The argument's static type is `FieldPath`. - fieldPath('a FieldPath'); + /// The argument's static type is `FieldPath`, or it is a list or set + /// literal holding one. + fieldPath('a FieldPath, or a list literal holding one'); const _Shape(this.description); @@ -424,6 +425,17 @@ const _requiredShapes = >{ // Field paths. 'field(fieldPath)': [_Shape.dottedPath, _Shape.fieldPath], 'Expression.field(fieldPath)': [_Shape.fieldPath], + // Field names, which take a FieldPath wherever they take a String. + 'ascending(expression)': [_Shape.fieldPath], + 'descending(expression)': [_Shape.fieldPath], + 'Pipeline.select(selections)': [_Shape.fieldPath], + 'Pipeline.distinct(groups)': [_Shape.fieldPath], + 'Pipeline.aggregate(groups)': [_Shape.fieldPath], + 'Pipeline.removeFields(fields)': [_Shape.fieldPath], + 'Pipeline.unnest(selectable)': [_Shape.fieldPath], + 'Pipeline.replaceWith(expression)': [_Shape.fieldPath], + 'Pipeline.findNearest(vectorField)': [_Shape.fieldPath], + 'Pipeline.findNearest(distanceResultField)': [_Shape.fieldPath], // Result paths. 'PipelineResult.get(field)': [_Shape.dottedPath, _Shape.fieldPath], }; @@ -500,6 +512,9 @@ const _notValuePositions = { 'in its values (see the nestedCollectionWithExpression shape)', 'PipelineResult.get(field)': 'takes a String or a FieldPath; see the dottedPath and fieldPath shapes', + 'Pipeline.findNearest(distanceResultField)': + 'takes a String or a FieldPath naming the field the distance is written ' + 'to; see the fieldPath shape', 'PipelineSource.createFrom(query)': 'takes a Query or a VectorQuery', 'PipelineSource.documents(documents)': 'takes DocumentReferences and document path Strings', @@ -1032,7 +1047,11 @@ final class _Analysis { return value is StringLiteral && (value.stringValue?.contains('.') ?? false); case _Shape.fieldPath: - return _isSubtypeOf(value, _fieldPathType); + return _isSubtypeOf(value, _fieldPathType) || + (_isListOrSetLiteral(value) && + _leaves( + value, + ).any((leaf) => _isSubtypeOf(leaf, _fieldPathType))); } } diff --git a/packages/google_cloud_firestore/test/pipeline_golden_test.dart b/packages/google_cloud_firestore/test/pipeline_golden_test.dart index 51bb8fa8..73e52443 100644 --- a/packages/google_cloud_firestore/test/pipeline_golden_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_golden_test.dart @@ -733,7 +733,9 @@ final class _Registry { String area = 'functions', }) { final register = area == 'aggregates' ? aggregate : expr; - register('$area/$name/static-field-name', () => static(target)); + register('$area/$name/static-field-name', () => static(target), [ + () => static(_fieldPathOf(target)), + ]); register('$area/$name/static-expression', () => static(field(target))); register('$area/$name/method', () => method(field(target))); } @@ -758,6 +760,9 @@ final class _Registry { ) { expr('functions/$name/static-$variant', () => static(target(), op()), [ for (final s in statics.skip(1)) () => s(target(), op()), + // A FieldPath names the target field as its String does. + if (target() case final String name) + for (final s in statics) () => s(_fieldPathOf(name), op()), ]); } @@ -779,6 +784,9 @@ final class _Registry { Pipeline _books(Firestore db) => db.pipeline().collection('books'); +/// The [FieldPath] naming the same field as the dotted String [name]. +FieldPath _fieldPathOf(String name) => FieldPath(name.split('.')); + Pipeline _wrap(Firestore db, PipelineExpression expression) { return switch (expression) { PipelineBooleanExpression() => _books(db).where(expression), @@ -1363,10 +1371,22 @@ void _registerStages(_Registry r) { stage( 'select/nested-field-path', (db) => _books(db).select(['metadata.lang', field('awards.hugo')]), + [ + (db) => _books(db).select([ + FieldPath(const ['metadata', 'lang']), + field('awards.hugo'), + ]), + ], ); stage( 'select/special-characters', (db) => _books(db).select(['first-name', field('last name')]), + [ + (db) => _books(db).select([ + FieldPath(const ['first-name']), + FieldPath(const ['last name']), + ]), + ], ); stage( 'select/options-object', @@ -1413,6 +1433,11 @@ void _registerStages(_Registry r) { stage( 'remove-fields/nested', (db) => _books(db).removeFields(['metadata.lang']), + [ + (db) => _books(db).removeFields([ + FieldPath(const ['metadata', 'lang']), + ]), + ], ); stage( 'remove-fields/raw-options', @@ -1422,6 +1447,12 @@ void _registerStages(_Registry r) { stage( 'remove-fields/special-characters', (db) => _books(db).removeFields(['first-name', field('last name')]), + [ + (db) => _books(db).removeFields([ + FieldPath(const ['first-name']), + FieldPath(const ['last name']), + ]), + ], ); stage( @@ -1464,6 +1495,12 @@ void _registerStages(_Registry r) { (db) => _books( db, ).sort([ascending('first-name'), field('last name').descending()]), + [ + (db) => _books(db).sort([ + ascending(FieldPath(const ['first-name'])), + descending(FieldPath(const ['last name'])), + ]), + ], ); stage('offset/plain', (db) => _books(db).offset(10)); @@ -1495,6 +1532,12 @@ void _registerStages(_Registry r) { stage( 'distinct/special-characters', (db) => _books(db).distinct(['first-name', field('last name')]), + [ + (db) => _books(db).distinct([ + FieldPath(const ['first-name']), + FieldPath(const ['last name']), + ]), + ], ); stage( @@ -1542,6 +1585,15 @@ void _registerStages(_Registry r) { [PipelineFunctions.countAll().as('total')], groups: ['first-name', field('last name')], ), + [ + (db) => _books(db).aggregate( + [PipelineFunctions.countAll().as('total')], + groups: [ + FieldPath(const ['first-name']), + FieldPath(const ['last name']), + ], + ), + ], ); Pipeline findNearest( @@ -1550,7 +1602,7 @@ void _registerStages(_Registry r) { Object queryVector = const [0.1, 0.2, 0.3], DistanceMeasure distanceMeasure = DistanceMeasure.euclidean, int? limit, - String? distanceResultField, + Object? distanceResultField, Map rawOptions = const {}, }) { return _books(db).findNearest( @@ -1609,9 +1661,18 @@ void _registerStages(_Registry r) { vectorField: 'my embedding', distanceResultField: 'my distance', ), + [ + (db) => findNearest( + db, + vectorField: FieldPath(const ['my embedding']), + distanceResultField: FieldPath(const ['my distance']), + ), + ], ); - stage('replace-with/field-name', (db) => _books(db).replaceWith('metadata')); + stage('replace-with/field-name', (db) => _books(db).replaceWith('metadata'), [ + (db) => _books(db).replaceWith(FieldPath(const ['metadata'])), + ]); stage( 'replace-with/field', (db) => _books(db).replaceWith(field('metadata')), @@ -1700,7 +1761,10 @@ void _registerStages(_Registry r) { stage( 'unnest/field-special-characters', (db) => _books(db).unnest(field('my tags')), - [(db) => _books(db).unnest('my tags')], + [ + (db) => _books(db).unnest('my tags'), + (db) => _books(db).unnest(FieldPath(const ['my tags'])), + ], ); stage( diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index 1750fcb4..d85e601d 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -2075,6 +2075,120 @@ void main() { ); }); + // A FieldPath is a field name wherever a String is, as in field(). + group('a FieldPath names a field', () { + final dotted = FieldPath(const ['a.b', 'c']); + final dottedRef = ref('`a.b`.c'); + + test('in a function target', () async { + await run( + base().select([ + PipelineFunctions.toUpper(dotted).as('upper'), + PipelineFunctions.add(dotted, 1, [2]).as('sum'), + PipelineFunctions.equalAny(dotted, [1, 2]).as('any'), + PipelineFunctions.count(dotted).as('count'), + ]), + ); + + final fields = stages[1].args.single.mapValue!.fields; + Object? target(String alias) => + json(fields[alias]!.functionValue!.args.first); + expect(target('upper'), dottedRef); + expect(target('any'), dottedRef); + expect(target('count'), dottedRef); + // add nests its further operands; the field is the innermost one. + final inner = fields['sum']!.functionValue!.args.first; + expect(json(inner.functionValue!.args.first), dottedRef); + }); + + test('in ascending and descending', () async { + await run(base().sort([ascending(dotted), descending(dotted)])); + + expect( + stages[1].args.map( + (ordering) => json(ordering.mapValue!.fields['expression']!), + ), + [dottedRef, dottedRef], + ); + expect(ascending(dotted).expr, isA()); + }); + + test('in select, distinct and aggregate groups', () async { + await run( + base() + .select([ + dotted, + FieldPath(const ['title']), + ]) + .distinct([dotted]) + .aggregate( + [PipelineFunctions.countAll().as('n')], + groups: [dotted], + ), + ); + + Map projection(firestore_v1.Value value) { + return value.mapValue!.fields.map( + (key, value) => MapEntry(key, json(value)), + ); + } + + expect(projection(stages[1].args.single), { + '`a.b`.c': dottedRef, + 'title': ref('title'), + }); + expect(projection(stages[2].args.single), {'`a.b`.c': dottedRef}); + expect(projection(stages[3].args[1]), {'`a.b`.c': dottedRef}); + }); + + test('as the same key as its String and field forms', () { + expect( + () => base().select([ + 'first-name', + FieldPath(const ['first-name']), + ]), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("'`first-name`'"), + ), + ), + ); + }); + + test('in removeFields, unnest and replaceWith', () async { + await run( + base().removeFields([dotted]).unnest(dotted).replaceWith(dotted), + ); + + expect(stages[1].args.map(json), [dottedRef]); + expect(stages[2].args.map(json), [dottedRef, dottedRef]); + expect(json(stages[3].args.first), dottedRef); + }); + + test('in findNearest', () async { + await run( + base().findNearest( + vectorField: dotted, + queryVector: const [1.0, 2.0], + distanceMeasure: DistanceMeasure.euclidean, + distanceResultField: FieldPath(const ['my.distance']), + distanceThreshold: 0.5, + ), + ); + + final [_, nearest, where] = stages; + expect(json(nearest.args[0]), dottedRef); + expect( + json(nearest.options['distance_field']!), + ref('`my.distance`'), + ); + final condition = where.args.single.functionValue!; + expect(json(condition.args[0]), ref('`my.distance`')); + }); + }); + group('createFrom', () { test('quotes FieldPath filters and orderings once', () async { await run( From bb03e1f8e21da0642190078e9e8c40f76cad59ef Mon Sep 17 00:00:00 2001 From: Guillaume Bernos Date: Fri, 2 Oct 2026 15:36:02 +0200 Subject: [PATCH 29/29] test(firestore): check every documents() reference's database PipelineSource.documents() already validates each DocumentReference against the Pipeline's database (since #318), as its dartdoc promises, but the test only covered a single foreign reference. Cover a foreign reference after a path and a same-database reference, and a mixed list from the same database. --- .../test/pipeline_test.dart | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/google_cloud_firestore/test/pipeline_test.dart b/packages/google_cloud_firestore/test/pipeline_test.dart index d85e601d..eaa77a40 100644 --- a/packages/google_cloud_firestore/test/pipeline_test.dart +++ b/packages/google_cloud_firestore/test/pipeline_test.dart @@ -4149,6 +4149,23 @@ void main() { () => firestore.pipeline().documents([other.doc('books/book-1')]), throwsA(_crossDatabaseError), ); + // Every reference is checked, not only the first; paths resolve + // against this Pipeline's database. + expect( + () => firestore.pipeline().documents([ + 'books/book-1', + firestore.doc('books/book-2'), + other.doc('books/book-3'), + ]), + throwsA(_crossDatabaseError), + ); + expect( + () => firestore.pipeline().documents([ + 'books/book-1', + firestore.doc('books/book-2'), + ]), + returnsNormally, + ); }); test('rejects a union with a different database', () {