diff --git a/.github/workflows/e2e_tests_pipeline.yaml b/.github/workflows/e2e_tests_pipeline.yaml new file mode 100644 index 000000000000..277806bbb771 --- /dev/null +++ b/.github/workflows/e2e_tests_pipeline.yaml @@ -0,0 +1,215 @@ +name: e2e-pipeline + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + paths-ignore: + - 'docs/**' + - 'website/**' + - '**/example/**' + - '**.md' + push: + branches: + - main + - firestore-pipelines-dart-api-v2 + - firestore-pipelines-ios + - firestore-pipelines-android + - firestore-pipelines-web + paths-ignore: + - 'docs/**' + - 'website/**' + - '**/example/**' + - '**.md' + +jobs: + pipeline-e2e-android: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + AVD_ARCH: x86_64 + AVD_API_LEVEL: 34 + AVD_TARGET: google_apis + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a + name: Install Node.js 20 + with: + node-version: '20' + - uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b + with: + distribution: 'temurin' + java-version: '21' + - uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff + with: + channel: 'stable' + cache: true + cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:" + pub-cache-key: "flutter-pub-:os:-:channel:-:version:-:arch:-:hash:" + - uses: bluefireteam/melos-action@c7dcb921b23cc520cace360b95d02b37bf09cdaa + with: + run-bootstrap: false + melos-version: '5.3.0' + - name: Inject Firebase config for pipeline E2E + env: + FIREBASE_OPTIONS_DART: ${{ secrets.PIPELINE_E2E_FIREBASE_OPTIONS_DART }} + GOOGLE_SERVICES_JSON: ${{ secrets.PIPELINE_E2E_GOOGLE_SERVICES_JSON }} + GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.PIPELINE_E2E_GOOGLE_SERVICE_INFO_PLIST }} + run: | + echo "$FIREBASE_OPTIONS_DART" > packages/cloud_firestore/cloud_firestore/pipeline_example/lib/firebase_options.dart + echo "$GOOGLE_SERVICES_JSON" > packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/google-services.json + echo "$GOOGLE_SERVICE_INFO_PLIST" > packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/GoogleService-Info.plist + - name: Bootstrap package + run: melos bootstrap --scope "cloud_firestore*" + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Gradle cache + uses: gradle/actions/setup-gradle@v4 + - name: Free Disk Space (Ubuntu) + uses: AdityaGarg8/remove-unwanted-software@90e01b21170618765a73370fcc3abbd1684a7793 + with: + remove-dotnet: true + remove-haskell: true + remove-codeql: true + remove-docker-images: true + remove-large-packages: true + - name: AVD cache + uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 + continue-on-error: true + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ runner.os }}-${{ env.AVD_API_LEVEL }}-${{ env.AVD_TARGET }}-${{ env.AVD_ARCH }} + - name: Start AVD then run pipeline E2E tests + uses: reactivecircus/android-emulator-runner@b530d96654c385303d652368551fb075bc2f0b6b + with: + api-level: ${{ env.AVD_API_LEVEL }} + target: ${{ env.AVD_TARGET }} + arch: ${{ env.AVD_ARCH }} + emulator-build: 14214601 + working-directory: packages/cloud_firestore/cloud_firestore/pipeline_example + script: | + flutter test integration_test/pipeline/pipeline_live_test.dart --timeout 10x --dart-define=CI=true -d emulator-5554 + - name: Ensure Appium is shut down + run: | + pgrep -f appium && pkill -f appium || echo "No Appium process found" + - name: Save Android Emulator Cache + if: github.ref == 'refs/heads/main' + uses: actions/cache/save@1bd1e32a3bdc45362d1e726936510720a7c30a57 + continue-on-error: true + with: + key: ${{ steps.avd-cache.outputs.cache-primary-key }} + path: | + ~/.android/avd/* + ~/.android/adb* + + pipeline-e2e-web: + runs-on: macos-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 + - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a + name: Install Node.js 20 + with: + node-version: '20' + - uses: subosito/flutter-action@f2c4f6686ca8e8d6e6d0f28410eeef506ed66aff + with: + channel: 'stable' + cache: true + cache-key: "flutter-:os:-:channel:-:version:-:arch:-:hash:" + pub-cache-key: "flutter-pub-:os:-:channel:-:version:-:arch:-:hash:" + - uses: bluefireteam/melos-action@c7dcb921b23cc520cace360b95d02b37bf09cdaa + with: + run-bootstrap: false + melos-version: '5.3.0' + - name: Inject Firebase config for pipeline E2E + env: + FIREBASE_OPTIONS_DART: ${{ secrets.PIPELINE_E2E_FIREBASE_OPTIONS_DART }} + GOOGLE_SERVICES_JSON: ${{ secrets.PIPELINE_E2E_GOOGLE_SERVICES_JSON }} + GOOGLE_SERVICE_INFO_PLIST: ${{ secrets.PIPELINE_E2E_GOOGLE_SERVICE_INFO_PLIST }} + run: | + echo "$FIREBASE_OPTIONS_DART" > packages/cloud_firestore/cloud_firestore/pipeline_example/lib/firebase_options.dart + echo "$GOOGLE_SERVICES_JSON" > packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/google-services.json + echo "$GOOGLE_SERVICE_INFO_PLIST" > packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/GoogleService-Info.plist + - name: Bootstrap package + run: melos bootstrap --scope "cloud_firestore*" + - name: Run pipeline E2E tests (Chrome) + working-directory: packages/cloud_firestore/cloud_firestore/pipeline_example + # Web devices are not supported for the `flutter test` command yet. As a + # workaround we use the `flutter drive` command. Tracking issue: + # https://github.com/flutter/flutter/issues/66264 + run: | + chromedriver --port=4444 --trace-buffer-size=100000 & + flutter drive --target=./integration_test/pipeline/pipeline_live_test.dart --driver=./test_driver/integration_test.dart -d chrome --dart-define=CI=true | tee output.log + output=$( packages/cloud_firestore/cloud_firestore/pipeline_example/lib/firebase_options.dart + echo "$GOOGLE_SERVICES_JSON" > packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/google-services.json + echo "$GOOGLE_SERVICE_INFO_PLIST" > packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/GoogleService-Info.plist + - name: Bootstrap package + run: melos bootstrap --scope "cloud_firestore*" + - name: Prepare iOS project for Swift Package Manager + working-directory: packages/cloud_firestore/cloud_firestore/pipeline_example/ios + run: | + if [ -f Podfile ]; then pod deintegrate; fi + rm -f Podfile Podfile.lock + rm -rf Pods + - uses: futureware-tech/simulator-action@e89aa8f93d3aec35083ff49d2854d07f7186f7f5 + id: simulator + with: + model: "iPhone 16" + - name: Build iOS (simulator) + working-directory: packages/cloud_firestore/cloud_firestore/pipeline_example + run: | + flutter build ios --no-codesign --simulator --debug --target=./integration_test/pipeline/pipeline_live_test.dart --dart-define=CI=true + - name: Run pipeline E2E tests (iOS) + working-directory: packages/cloud_firestore/cloud_firestore/pipeline_example + env: + SIMULATOR: ${{ steps.simulator.outputs.udid }} + run: | + flutter test integration_test/pipeline/pipeline_live_test.dart -d "$SIMULATOR" --timeout 10x --dart-define=CI=true diff --git a/melos.yaml b/melos.yaml index c87ba36f5ab7..c6eb07a7633b 100644 --- a/melos.yaml +++ b/melos.yaml @@ -172,6 +172,13 @@ scripts: description: | Run all e2e tests for cloud_firestore. + test:e2e:pipeline: + run: | + cd packages/cloud_firestore/cloud_firestore/pipeline_example + flutter test integration_test/pipeline/pipeline_live_test.dart + description: | + Run pipeline e2e tests. + test:e2e:firebase_performance: run: | cd packages/firebase_performance/firebase_performance/example diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java index fb420c95bee2..e7af885e3454 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/FlutterFirebaseFirestorePlugin.java @@ -28,6 +28,8 @@ import com.google.firebase.firestore.MemoryCacheSettings; import com.google.firebase.firestore.PersistentCacheIndexManager; import com.google.firebase.firestore.PersistentCacheSettings; +import com.google.firebase.firestore.Pipeline; +import com.google.firebase.firestore.PipelineResult; import com.google.firebase.firestore.Query; import com.google.firebase.firestore.QuerySnapshot; import com.google.firebase.firestore.SetOptions; @@ -52,6 +54,7 @@ import io.flutter.plugins.firebase.firestore.streamhandler.TransactionStreamHandler; import io.flutter.plugins.firebase.firestore.utils.ExceptionConverter; import io.flutter.plugins.firebase.firestore.utils.PigeonParser; +import io.flutter.plugins.firebase.firestore.utils.PipelineParser; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -1007,4 +1010,65 @@ public void documentReferenceSnapshot( parameters.getServerTimestampBehavior()), PigeonParser.parseListenSource(source)))); } + + @Override + public void executePipeline( + @NonNull GeneratedAndroidFirebaseFirestore.FirestorePigeonFirebaseApp app, + @NonNull List> stages, + @Nullable Map options, + @NonNull + GeneratedAndroidFirebaseFirestore.Result< + GeneratedAndroidFirebaseFirestore.PigeonPipelineSnapshot> + result) { + cachedThreadPool.execute( + () -> { + try { + FirebaseFirestore firestore = getFirestoreFromPigeon(app); + + // Execute pipeline using Android Firestore SDK + Pipeline.Snapshot snapshot = PipelineParser.executePipeline(firestore, stages, options); + + // Convert Pipeline.Snapshot to PigeonPipelineSnapshot + List pipelineResults = + new ArrayList<>(); + + // Iterate through snapshot results + for (PipelineResult pipelineResult : snapshot.getResults()) { + GeneratedAndroidFirebaseFirestore.PigeonPipelineResult.Builder resultBuilder = + new GeneratedAndroidFirebaseFirestore.PigeonPipelineResult.Builder(); + if (pipelineResult.getRef() != null) { + resultBuilder.setDocumentPath(pipelineResult.getRef().getPath()); + } + + if (pipelineResult.getCreateTime() != null) { + resultBuilder.setCreateTime(pipelineResult.getCreateTime().toDate().getTime()); + } + if (pipelineResult.getUpdateTime() != null) { + resultBuilder.setUpdateTime(pipelineResult.getUpdateTime().toDate().getTime()); + } + + Map data = pipelineResult.getData(); + if (data != null) { + resultBuilder.setData(data); + } + + pipelineResults.add(resultBuilder.build()); + } + + // Build the snapshot + GeneratedAndroidFirebaseFirestore.PigeonPipelineSnapshot.Builder snapshotBuilder = + new GeneratedAndroidFirebaseFirestore.PigeonPipelineSnapshot.Builder(); + snapshotBuilder.setResults(pipelineResults); + + // Set execution time when available. Do not fabricate a value when null. + if (snapshot.getExecutionTime() != null) { + snapshotBuilder.setExecutionTime(snapshot.getExecutionTime().toDate().getTime()); + } + + result.success(snapshotBuilder.build()); + } catch (Exception e) { + ExceptionConverter.sendErrorToFlutter(result, e); + } + }); + } } diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/GeneratedAndroidFirebaseFirestore.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/GeneratedAndroidFirebaseFirestore.java index b67642961626..bf5bb3fff9ef 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/GeneratedAndroidFirebaseFirestore.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/GeneratedAndroidFirebaseFirestore.java @@ -856,6 +856,197 @@ public ArrayList toList() { } } + /** Generated class from Pigeon that represents data sent in messages. */ + public static final class PigeonPipelineResult { + private @Nullable String documentPath; + + public @Nullable String getDocumentPath() { + return documentPath; + } + + public void setDocumentPath(@Nullable String setterArg) { + this.documentPath = setterArg; + } + + private @Nullable Long createTime; + + public @Nullable Long getCreateTime() { + return createTime; + } + + public void setCreateTime(@Nullable Long setterArg) { + this.createTime = setterArg; + } + + private @Nullable Long updateTime; + + public @Nullable Long getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(@Nullable Long setterArg) { + this.updateTime = setterArg; + } + + /** All fields in the result (from PipelineResult.data() on Android). */ + private @Nullable Map data; + + public @Nullable Map getData() { + return data; + } + + public void setData(@Nullable Map setterArg) { + this.data = setterArg; + } + + public static final class Builder { + + private @Nullable String documentPath; + + public @NonNull Builder setDocumentPath(@Nullable String setterArg) { + this.documentPath = setterArg; + return this; + } + + private @Nullable Long createTime; + + public @NonNull Builder setCreateTime(@Nullable Long setterArg) { + this.createTime = setterArg; + return this; + } + + private @Nullable Long updateTime; + + public @NonNull Builder setUpdateTime(@Nullable Long setterArg) { + this.updateTime = setterArg; + return this; + } + + private @Nullable Map data; + + public @NonNull Builder setData(@Nullable Map setterArg) { + this.data = setterArg; + return this; + } + + public @NonNull PigeonPipelineResult build() { + PigeonPipelineResult pigeonReturn = new PigeonPipelineResult(); + pigeonReturn.setDocumentPath(documentPath); + pigeonReturn.setCreateTime(createTime); + pigeonReturn.setUpdateTime(updateTime); + pigeonReturn.setData(data); + return pigeonReturn; + } + } + + @NonNull + public ArrayList toList() { + ArrayList toListResult = new ArrayList(4); + toListResult.add(documentPath); + toListResult.add(createTime); + toListResult.add(updateTime); + toListResult.add(data); + return toListResult; + } + + static @NonNull PigeonPipelineResult fromList(@NonNull ArrayList list) { + PigeonPipelineResult pigeonResult = new PigeonPipelineResult(); + Object documentPath = list.get(0); + pigeonResult.setDocumentPath((String) documentPath); + Object createTime = list.get(1); + pigeonResult.setCreateTime( + (createTime == null) + ? null + : ((createTime instanceof Integer) ? (Integer) createTime : (Long) createTime)); + Object updateTime = list.get(2); + pigeonResult.setUpdateTime( + (updateTime == null) + ? null + : ((updateTime instanceof Integer) ? (Integer) updateTime : (Long) updateTime)); + Object data = list.get(3); + pigeonResult.setData((Map) data); + return pigeonResult; + } + } + + /** Generated class from Pigeon that represents data sent in messages. */ + public static final class PigeonPipelineSnapshot { + private @NonNull List results; + + public @NonNull List getResults() { + return results; + } + + public void setResults(@NonNull List setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"results\" is null."); + } + this.results = setterArg; + } + + private @NonNull Long executionTime; + + public @NonNull Long getExecutionTime() { + return executionTime; + } + + public void setExecutionTime(@NonNull Long setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"executionTime\" is null."); + } + this.executionTime = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + PigeonPipelineSnapshot() {} + + public static final class Builder { + + private @Nullable List results; + + public @NonNull Builder setResults(@NonNull List setterArg) { + this.results = setterArg; + return this; + } + + private @Nullable Long executionTime; + + public @NonNull Builder setExecutionTime(@NonNull Long setterArg) { + this.executionTime = setterArg; + return this; + } + + public @NonNull PigeonPipelineSnapshot build() { + PigeonPipelineSnapshot pigeonReturn = new PigeonPipelineSnapshot(); + pigeonReturn.setResults(results); + pigeonReturn.setExecutionTime(executionTime); + return pigeonReturn; + } + } + + @NonNull + public ArrayList toList() { + ArrayList toListResult = new ArrayList(2); + toListResult.add(results); + toListResult.add(executionTime); + return toListResult; + } + + static @NonNull PigeonPipelineSnapshot fromList(@NonNull ArrayList list) { + PigeonPipelineSnapshot pigeonResult = new PigeonPipelineSnapshot(); + Object results = list.get(0); + pigeonResult.setResults((List) results); + Object executionTime = list.get(1); + pigeonResult.setExecutionTime( + (executionTime == null) + ? null + : ((executionTime instanceof Integer) + ? (Integer) executionTime + : (Long) executionTime)); + return pigeonResult; + } + } + /** Generated class from Pigeon that represents data sent in messages. */ public static final class PigeonGetOptions { private @NonNull Source source; @@ -1660,12 +1851,16 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { case (byte) 136: return PigeonGetOptions.fromList((ArrayList) readValue(buffer)); case (byte) 137: - return PigeonQueryParameters.fromList((ArrayList) readValue(buffer)); + return PigeonPipelineResult.fromList((ArrayList) readValue(buffer)); case (byte) 138: - return PigeonQuerySnapshot.fromList((ArrayList) readValue(buffer)); + return PigeonPipelineSnapshot.fromList((ArrayList) readValue(buffer)); case (byte) 139: - return PigeonSnapshotMetadata.fromList((ArrayList) readValue(buffer)); + return PigeonQueryParameters.fromList((ArrayList) readValue(buffer)); case (byte) 140: + return PigeonQuerySnapshot.fromList((ArrayList) readValue(buffer)); + case (byte) 141: + return PigeonSnapshotMetadata.fromList((ArrayList) readValue(buffer)); + case (byte) 142: return PigeonTransactionCommand.fromList((ArrayList) readValue(buffer)); default: return super.readValueOfType(type, buffer); @@ -1701,17 +1896,23 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { } else if (value instanceof PigeonGetOptions) { stream.write(136); writeValue(stream, ((PigeonGetOptions) value).toList()); - } else if (value instanceof PigeonQueryParameters) { + } else if (value instanceof PigeonPipelineResult) { stream.write(137); + writeValue(stream, ((PigeonPipelineResult) value).toList()); + } else if (value instanceof PigeonPipelineSnapshot) { + stream.write(138); + writeValue(stream, ((PigeonPipelineSnapshot) value).toList()); + } else if (value instanceof PigeonQueryParameters) { + stream.write(139); writeValue(stream, ((PigeonQueryParameters) value).toList()); } else if (value instanceof PigeonQuerySnapshot) { - stream.write(138); + stream.write(140); writeValue(stream, ((PigeonQuerySnapshot) value).toList()); } else if (value instanceof PigeonSnapshotMetadata) { - stream.write(139); + stream.write(141); writeValue(stream, ((PigeonSnapshotMetadata) value).toList()); } else if (value instanceof PigeonTransactionCommand) { - stream.write(140); + stream.write(142); writeValue(stream, ((PigeonTransactionCommand) value).toList()); } else { super.writeValue(stream, value); @@ -1836,6 +2037,12 @@ void persistenceCacheIndexManagerRequest( @NonNull PersistenceCacheIndexManagerRequest request, @NonNull Result result); + void executePipeline( + @NonNull FirestorePigeonFirebaseApp app, + @NonNull List> stages, + @Nullable Map options, + @NonNull Result result); + /** The codec used by FirebaseFirestoreHostApi. */ static @NonNull MessageCodec getCodec() { return FirebaseFirestoreHostApiCodec.INSTANCE; @@ -2624,6 +2831,39 @@ public void error(Throwable error) { channel.setMessageHandler(null); } } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.cloud_firestore_platform_interface.FirebaseFirestoreHostApi.executePipeline", + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList(); + ArrayList args = (ArrayList) message; + FirestorePigeonFirebaseApp appArg = (FirestorePigeonFirebaseApp) args.get(0); + List> stagesArg = (List>) args.get(1); + Map optionsArg = (Map) args.get(2); + Result resultCallback = + new Result() { + public void success(PigeonPipelineSnapshot result) { + wrapped.add(0, result); + reply.reply(wrapped); + } + + public void error(Throwable error) { + ArrayList wrappedError = wrapError(error); + reply.reply(wrappedError); + } + }; + + api.executePipeline(appArg, stagesArg, optionsArg, resultCallback); + }); + } else { + channel.setMessageHandler(null); + } + } } } } diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionHelpers.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionHelpers.java new file mode 100644 index 000000000000..86c0877a902c --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionHelpers.java @@ -0,0 +1,159 @@ +/* + * Copyright 2026, the Chromium project authors. Please see the AUTHORS file + * for details. All rights reserved. Use of this source code is governed by a + * BSD-style license that can be found in the LICENSE file. + */ + +package io.flutter.plugins.firebase.firestore.utils; + +import androidx.annotation.NonNull; +import com.google.firebase.Timestamp; +import com.google.firebase.firestore.Blob; +import com.google.firebase.firestore.DocumentReference; +import com.google.firebase.firestore.GeoPoint; +import com.google.firebase.firestore.VectorValue; +import com.google.firebase.firestore.pipeline.BooleanExpression; +import com.google.firebase.firestore.pipeline.Expression; +import java.util.List; +import java.util.Map; + +/** Helper utilities for parsing expressions and handling common patterns. */ +class ExpressionHelpers { + + /** + * Parses an "and" expression from a list of expression maps. Uses Expression.and() with varargs + * signature. + * + * @param exprMaps List of expression maps to combine with AND + * @param parser Reference to ExpressionParsers for recursive parsing + */ + @SuppressWarnings("unchecked") + static BooleanExpression parseAndExpression( + @NonNull List> exprMaps, @NonNull ExpressionParsers parser) { + if (exprMaps == null || exprMaps.isEmpty()) { + throw new IllegalArgumentException("'and' requires at least one expression"); + } + + BooleanExpression first = parser.parseBooleanExpression(exprMaps.get(0)); + if (exprMaps.size() == 1) { + return first; + } + + BooleanExpression[] rest = new BooleanExpression[exprMaps.size() - 1]; + for (int i = 1; i < exprMaps.size(); i++) { + rest[i - 1] = parser.parseBooleanExpression(exprMaps.get(i)); + } + return Expression.and(first, rest); + } + + /** + * Parses an "or" expression from a list of expression maps. Uses Expression.or() with varargs + * signature. + * + * @param exprMaps List of expression maps to combine with OR + * @param parser Reference to ExpressionParsers for recursive parsing + */ + @SuppressWarnings("unchecked") + static BooleanExpression parseOrExpression( + @NonNull List> exprMaps, @NonNull ExpressionParsers parser) { + if (exprMaps == null || exprMaps.isEmpty()) { + throw new IllegalArgumentException("'or' requires at least one expression"); + } + + BooleanExpression first = parser.parseBooleanExpression(exprMaps.get(0)); + if (exprMaps.size() == 1) { + return first; + } + + BooleanExpression[] rest = new BooleanExpression[exprMaps.size() - 1]; + for (int i = 1; i < exprMaps.size(); i++) { + rest[i - 1] = parser.parseBooleanExpression(exprMaps.get(i)); + } + return Expression.or(first, rest); + } + + /** + * Parses a "xor" expression from a list of expression maps. + * + * @param exprMaps List of expression maps to combine with XOR + * @param parser Reference to ExpressionParsers for recursive parsing + */ + @SuppressWarnings("unchecked") + static BooleanExpression parseXorExpression( + @NonNull List> exprMaps, @NonNull ExpressionParsers parser) { + if (exprMaps == null || exprMaps.isEmpty()) { + throw new IllegalArgumentException("'xor' requires at least one expression"); + } + + BooleanExpression first = parser.parseBooleanExpression(exprMaps.get(0)); + if (exprMaps.size() == 1) { + return first; + } + + BooleanExpression[] rest = new BooleanExpression[exprMaps.size() - 1]; + for (int i = 1; i < exprMaps.size(); i++) { + rest[i - 1] = parser.parseBooleanExpression(exprMaps.get(i)); + } + return Expression.xor(first, rest); + } + + /** + * Parses a constant value based on its type to match Android SDK constant() overloads. Valid + * types: String, Number, Boolean, Date, Timestamp, GeoPoint, byte[], Blob, DocumentReference, + * VectorValue + */ + static Expression parseConstantValue(Object value) { + + if (value == null) { + return Expression.nullValue(); + } + + if (value instanceof String) { + return Expression.constant((String) value); + } else if (value instanceof Number) { + return Expression.constant((Number) value); + } else if (value instanceof Boolean) { + return Expression.constant((Boolean) value); + } else if (value instanceof java.util.Date) { + return Expression.constant((java.util.Date) value); + } else if (value instanceof Timestamp) { + return Expression.constant((Timestamp) value); + } else if (value instanceof GeoPoint) { + return Expression.constant((GeoPoint) value); + } else if (value instanceof byte[]) { + return Expression.constant((byte[]) value); + } else if (value instanceof List) { + // Handle List from Dart which comes as List or List + // This represents byte[] (byte array) for constant expressions + @SuppressWarnings("unchecked") + List list = (List) value; + // Check if all elements are numbers (for byte array) + boolean isByteArray = true; + for (Object item : list) { + if (!(item instanceof Number)) { + isByteArray = false; + break; + } + } + if (isByteArray && !list.isEmpty()) { + byte[] byteArray = new byte[list.size()]; + for (int i = 0; i < list.size(); i++) { + byteArray[i] = ((Number) list.get(i)).byteValue(); + } + return Expression.constant(byteArray); + } + // If not a byte array, fall through to error + } else if (value instanceof Blob) { + return Expression.constant((Blob) value); + } else if (value instanceof DocumentReference) { + return Expression.constant((DocumentReference) value); + } else if (value instanceof VectorValue) { + return Expression.constant((VectorValue) value); + } + + throw new IllegalArgumentException( + "Constant value must be one of: String, Number, Boolean, Date, Timestamp, " + + "GeoPoint, byte[], Blob, DocumentReference, or VectorValue. Got: " + + value.getClass().getName()); + } +} diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java new file mode 100644 index 000000000000..f27d5fd2a42b --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java @@ -0,0 +1,683 @@ +/* + * Copyright 2026, the Chromium project authors. Please see the AUTHORS file + * for details. All rights reserved. Use of this source code is governed by a + * BSD-style license that can be found in the LICENSE file. + */ + +package io.flutter.plugins.firebase.firestore.utils; + +import android.util.Log; +import androidx.annotation.NonNull; +import com.google.firebase.firestore.FirebaseFirestore; +import com.google.firebase.firestore.pipeline.AggregateFunction; +import com.google.firebase.firestore.pipeline.AggregateOptions; +import com.google.firebase.firestore.pipeline.AggregateStage; +import com.google.firebase.firestore.pipeline.AliasedAggregate; +import com.google.firebase.firestore.pipeline.BooleanExpression; +import com.google.firebase.firestore.pipeline.Expression; +import com.google.firebase.firestore.pipeline.FindNearestStage; +import com.google.firebase.firestore.pipeline.Selectable; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Parses Dart pipeline expression maps into Android {@link Expression} / {@link BooleanExpression} + * types. {@link #parseBooleanExpression}'s default delegates to {@link #parseExpression} when the + * name is a value expression that yields a boolean (e.g. aliased comparisons). + */ +class ExpressionParsers { + private static final String TAG = "ExpressionParsers"; + + private final FirebaseFirestore firestore; + + ExpressionParsers(@NonNull FirebaseFirestore firestore) { + this.firestore = firestore; + } + + /** Binary operation on two expressions. Used instead of BiFunction for API 23 compatibility. */ + private interface BinaryExpressionOp { + R apply(Expression left, Expression right); + } + + @SuppressWarnings("unchecked") + private static Map argsOf(@NonNull Map expressionMap) { + Map args = (Map) expressionMap.get("args"); + return args != null ? args : new HashMap<>(); + } + + @SuppressWarnings("unchecked") + private Expression parseChild(@NonNull Map args, @NonNull String key) { + return parseExpression((Map) args.get(key)); + } + + /** Parses a list of nested expression maps (e.g. {@code values}) to {@link Expression}s. */ + private List parseExpressionMaps(@NonNull List> maps) { + Expression[] out = new Expression[maps.size()]; + for (int i = 0; i < maps.size(); i++) { + out[i] = parseExpression(maps.get(i)); + } + return Arrays.asList(out); + } + + private BooleanExpression parseBinaryComparisonNamed( + @NonNull String name, @NonNull Map args) { + switch (name) { + case "equal": + return parseBinaryComparison(args, (left, right) -> left.equal(right)); + case "not_equal": + return parseBinaryComparison(args, (left, right) -> left.notEqual(right)); + case "greater_than": + return parseBinaryComparison(args, (left, right) -> left.greaterThan(right)); + case "greater_than_or_equal": + return parseBinaryComparison(args, (left, right) -> left.greaterThanOrEqual(right)); + case "less_than": + return parseBinaryComparison(args, (left, right) -> left.lessThan(right)); + case "less_than_or_equal": + return parseBinaryComparison(args, (left, right) -> left.lessThanOrEqual(right)); + default: + throw new IllegalArgumentException("Not a binary comparison expression: " + name); + } + } + + @SuppressWarnings("unchecked") + private BooleanExpression parseEqualAny(@NonNull Map args) { + Map valueMap = (Map) args.get("value"); + List> valuesMaps = (List>) args.get("values"); + Expression value = parseExpression(valueMap); + return value.equalAny(parseExpressionMaps(valuesMaps)); + } + + @SuppressWarnings("unchecked") + private BooleanExpression parseNotEqualAny(@NonNull Map args) { + Map valueMap = (Map) args.get("value"); + List> valuesMaps = (List>) args.get("values"); + Expression value = parseExpression(valueMap); + return value.notEqualAny(parseExpressionMaps(valuesMaps)); + } + + @SuppressWarnings("unchecked") + private BooleanExpression parseArrayContainsElement(@NonNull Map args) { + Map arrayMap = (Map) args.get("array"); + Map elementMap = (Map) args.get("element"); + Expression array = parseExpression(arrayMap); + Expression element = parseExpression(elementMap); + return array.arrayContains(element); + } + + /** Parses an expression from a map representation. */ + @SuppressWarnings("unchecked") + Expression parseExpression(@NonNull Map expressionMap) { + String name = (String) expressionMap.get("name"); + if (name == null) { + if (expressionMap.containsKey("field_name")) { + String fieldName = (String) expressionMap.get("field_name"); + return Expression.field(fieldName); + } + Map argsCheck = (Map) expressionMap.get("args"); + if (argsCheck != null && argsCheck.containsKey("field")) { + String fieldName = (String) argsCheck.get("field"); + return Expression.field(fieldName); + } + throw new IllegalArgumentException("Expression must have a 'name' field"); + } + + Map args = argsOf(expressionMap); + + switch (name) { + case "null": + return Expression.nullValue(); + case "field": + { + String fieldName = (String) args.get("field"); + if (fieldName == null) { + throw new IllegalArgumentException("Field expression must have a 'field' argument"); + } + return Expression.field(fieldName); + } + case "constant": + { + Object value = args.get("value"); + if (value instanceof Map) { + Map valueMap = (Map) value; + String path = (String) valueMap.get("path"); + return Expression.constant(firestore.document(path)); + } + return ExpressionHelpers.parseConstantValue(value); + } + case "alias": + { + Map exprMap = (Map) args.get("expression"); + String alias = (String) args.get("alias"); + Expression expr = parseExpression(exprMap); + return expr.alias(alias); + } + case "equal": + case "not_equal": + case "greater_than": + case "greater_than_or_equal": + case "less_than": + case "less_than_or_equal": + return parseBinaryComparisonNamed(name, args); + case "add": + return parseBinaryOperation(args, (left, right) -> left.add(right)); + case "subtract": + return parseBinaryOperation(args, (left, right) -> left.subtract(right)); + case "multiply": + return parseBinaryOperation(args, (left, right) -> left.multiply(right)); + case "divide": + return parseBinaryOperation(args, (left, right) -> left.divide(right)); + case "modulo": + return parseBinaryOperation(args, (left, right) -> left.mod(right)); + case "and": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseAndExpression(exprMaps, this); + } + case "or": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseOrExpression(exprMaps, this); + } + case "xor": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseXorExpression(exprMaps, this); + } + case "not": + { + Map exprMap = (Map) args.get("expression"); + BooleanExpression expr = parseBooleanExpression(exprMap); + return Expression.not(expr); + } + case "concat": + { + List> exprMaps = (List>) args.get("expressions"); + if (exprMaps == null || exprMaps.size() < 2) { + throw new IllegalArgumentException("concat requires at least two expressions"); + } + Expression first = parseExpression(exprMaps.get(0)); + Expression second = parseExpression(exprMaps.get(1)); + if (exprMaps.size() == 2) { + return Expression.concat(first, second); + } + Object[] others = new Object[exprMaps.size() - 2]; + for (int i = 2; i < exprMaps.size(); i++) { + others[i - 2] = parseExpression(exprMaps.get(i)); + } + return Expression.concat(first, second, others); + } + case "length": + return Expression.length(parseChild(args, "expression")); + case "to_lower_case": + return Expression.toLower(parseChild(args, "expression")); + case "to_upper_case": + return Expression.toUpper(parseChild(args, "expression")); + case "trim": + return Expression.trim(parseChild(args, "expression")); + case "substring": + { + Map exprMap = (Map) args.get("expression"); + Map startMap = (Map) args.get("start"); + Map endMap = (Map) args.get("end"); + Expression stringExpr = parseExpression(exprMap); + Expression startExpr = parseExpression(startMap); + Expression endExpr = parseExpression(endMap); + Expression lengthExpr = Expression.subtract(endExpr, startExpr); + return Expression.substring(stringExpr, startExpr, lengthExpr); + } + case "split": + { + Map valueMap = (Map) args.get("expression"); + Map delimiterMap = (Map) args.get("delimiter"); + return Expression.split(parseExpression(valueMap), parseExpression(delimiterMap)); + } + case "join": + { + Map arrayMap = (Map) args.get("expression"); + Map delimiterMap = (Map) args.get("delimiter"); + return Expression.join(parseExpression(arrayMap), parseExpression(delimiterMap)); + } + case "abs": + return Expression.abs(parseChild(args, "expression")); + case "negate": + { + Expression expr = parseChild(args, "expression"); + return Expression.subtract(Expression.constant(0), expr); + } + case "array_concat": + { + Map firstMap = (Map) args.get("first"); + Map secondMap = (Map) args.get("second"); + return Expression.arrayConcat(parseExpression(firstMap), parseExpression(secondMap)); + } + case "array_concat_multiple": + { + List> arrays = (List>) args.get("arrays"); + if (arrays == null || arrays.size() < 2) { + throw new IllegalArgumentException( + "array_concat_multiple requires at least two arrays"); + } + Expression result = + Expression.arrayConcat( + parseExpression(arrays.get(0)), parseExpression(arrays.get(1))); + for (int i = 2; i < arrays.size(); i++) { + result = result.arrayConcat(parseExpression(arrays.get(i))); + } + return result; + } + case "array_length": + return Expression.arrayLength(parseChild(args, "expression")); + case "array_reverse": + return Expression.arrayReverse(parseChild(args, "expression")); + case "array_sum": + return Expression.arraySum(parseChild(args, "expression")); + case "array_slice": + throw new UnsupportedOperationException( + "Expression type 'array_slice' is not supported on Android Firestore pipeline API"); + case "if_absent": + { + Map exprMap = (Map) args.get("expression"); + Map elseMap = (Map) args.get("else"); + return Expression.ifAbsent(parseExpression(exprMap), parseExpression(elseMap)); + } + case "if_error": + { + Map exprMap = (Map) args.get("expression"); + Map catchMap = (Map) args.get("catch"); + return Expression.ifError(parseExpression(exprMap), parseExpression(catchMap)); + } + case "conditional": + { + Map conditionMap = (Map) args.get("condition"); + Map thenMap = (Map) args.get("then"); + Map elseMap = (Map) args.get("else"); + BooleanExpression condition = parseBooleanExpression(conditionMap); + Expression thenExpr = parseExpression(thenMap); + Expression elseExpr = parseExpression(elseMap); + return Expression.conditional(condition, thenExpr, elseExpr); + } + case "document_id": + return Expression.documentId(parseChild(args, "expression")); + case "document_id_from_ref": + { + String path = (String) args.get("doc_ref"); + if (path == null) { + throw new IllegalArgumentException("document_id_from_ref requires 'doc_ref' argument"); + } + return Expression.documentId(firestore.document(path)); + } + case "collection_id": + return Expression.collectionId(parseChild(args, "expression")); + case "map_get": + { + Map mapMap = (Map) args.get("map"); + Map keyMap = (Map) args.get("key"); + return Expression.mapGet(parseExpression(mapMap), parseExpression(keyMap)); + } + case "current_timestamp": + return Expression.currentTimestamp(); + case "timestamp_add": + { + Map timestampMap = (Map) args.get("timestamp"); + String unit = (String) args.get("unit"); + Map amountMap = (Map) args.get("amount"); + if (unit == null || amountMap == null) { + throw new IllegalArgumentException("timestamp_add requires 'unit' and 'amount'"); + } + Expression timestampExpr = parseExpression(timestampMap); + Expression amountExpr = parseExpression(amountMap); + return Expression.timestampAdd(timestampExpr, Expression.constant(unit), amountExpr); + } + case "timestamp_subtract": + { + Map timestampMap = (Map) args.get("timestamp"); + String unit = (String) args.get("unit"); + Map amountMap = (Map) args.get("amount"); + if (unit == null || amountMap == null) { + throw new IllegalArgumentException("timestamp_subtract requires 'unit' and 'amount'"); + } + Expression timestampExpr = parseExpression(timestampMap); + Expression amountExpr = parseExpression(amountMap); + return Expression.timestampSubtract(timestampExpr, Expression.constant(unit), amountExpr); + } + case "timestamp_truncate": + { + Map timestampMap = (Map) args.get("timestamp"); + String unit = (String) args.get("unit"); + if (unit == null) { + throw new IllegalArgumentException("timestamp_truncate requires 'unit'"); + } + return Expression.timestampTruncate(parseExpression(timestampMap), unit); + } + case "array": + { + List elements = (List) args.get("elements"); + if (elements == null) { + throw new IllegalArgumentException("array requires 'elements'"); + } + Object[] parsed = new Object[elements.size()]; + for (int i = 0; i < elements.size(); i++) { + Object el = elements.get(i); + if (el instanceof Map) { + parsed[i] = parseExpression((Map) el); + } else { + parsed[i] = ExpressionHelpers.parseConstantValue(el); + } + } + return Expression.array(Arrays.asList(parsed)); + } + case "map": + { + Map data = (Map) args.get("data"); + if (data == null) { + throw new IllegalArgumentException("map requires 'data'"); + } + Map parsed = new HashMap<>(); + for (Map.Entry e : data.entrySet()) { + Object v = e.getValue(); + if (v instanceof Map) { + @SuppressWarnings("unchecked") + Map nested = (Map) v; + if (nested.containsKey("name") && nested.containsKey("args")) { + parsed.put(e.getKey(), parseExpression(nested)); + } else { + parsed.put(e.getKey(), v); + } + } else { + parsed.put(e.getKey(), ExpressionHelpers.parseConstantValue(v)); + } + } + return Expression.map(parsed); + } + case "bit_and": + return parseBinaryOperation(args, (left, right) -> left.bitAnd(right)); + case "bit_or": + return parseBinaryOperation(args, (left, right) -> left.bitOr(right)); + case "bit_xor": + return parseBinaryOperation(args, (left, right) -> left.bitXor(right)); + case "bit_not": + return parseChild(args, "expression").bitNot(); + case "bit_left_shift": + { + Map exprMap = (Map) args.get("expression"); + Map amountMap = (Map) args.get("amount"); + return parseExpression(exprMap).bitLeftShift(parseExpression(amountMap)); + } + case "bit_right_shift": + { + Map exprMap = (Map) args.get("expression"); + Map amountMap = (Map) args.get("amount"); + return parseExpression(exprMap).bitRightShift(parseExpression(amountMap)); + } + case "is_absent": + return parseIsAbsent(args); + case "is_error": + return parseIsError(args); + case "exists": + return parseExists(args); + case "as_boolean": + return parseAsBoolean(args); + case "array_contains_all": + return parseArrayContainsAll(args); + case "array_contains_any": + return parseArrayContainsAny(args); + default: + Log.w(TAG, "Unsupported expression type: " + name); + throw new UnsupportedOperationException("Expression type not yet implemented: " + name); + } + } + + @SuppressWarnings("unchecked") + private BooleanExpression parseBinaryComparison( + @NonNull Map args, @NonNull BinaryExpressionOp operation) { + Map leftMap = (Map) args.get("left"); + Map rightMap = (Map) args.get("right"); + Expression left = parseExpression(leftMap); + Expression right = parseExpression(rightMap); + return operation.apply(left, right); + } + + @SuppressWarnings("unchecked") + private Expression parseBinaryOperation( + @NonNull Map args, @NonNull BinaryExpressionOp operation) { + Map leftMap = (Map) args.get("left"); + Map rightMap = (Map) args.get("right"); + Expression left = parseExpression(leftMap); + Expression right = parseExpression(rightMap); + return operation.apply(left, right); + } + + private BooleanExpression parseIsAbsent(@NonNull Map args) { + return parseChild(args, "expression").isAbsent(); + } + + private BooleanExpression parseIsError(@NonNull Map args) { + return parseChild(args, "expression").isError(); + } + + private BooleanExpression parseExists(@NonNull Map args) { + return parseChild(args, "expression").exists(); + } + + private BooleanExpression parseAsBoolean(@NonNull Map args) { + return parseChild(args, "expression").asBoolean(); + } + + @SuppressWarnings("unchecked") + private BooleanExpression parseArrayContainsAll(@NonNull Map args) { + Map arrayMap = (Map) args.get("array"); + Expression array = parseExpression(arrayMap); + if (args.get("values") != null) { + List> valuesMaps = (List>) args.get("values"); + return array.arrayContainsAll(parseExpressionMaps(valuesMaps)); + } + Map arrayExprMap = (Map) args.get("array_expression"); + Expression arrayExpr = parseExpression(arrayExprMap); + return array.arrayContainsAll(arrayExpr); + } + + @SuppressWarnings("unchecked") + private BooleanExpression parseArrayContainsAny(@NonNull Map args) { + Map arrayMap = (Map) args.get("array"); + List> valuesMaps = (List>) args.get("values"); + Expression array = parseExpression(arrayMap); + return array.arrayContainsAny(parseExpressionMaps(valuesMaps)); + } + + @SuppressWarnings("unchecked") + BooleanExpression parseBooleanExpression(@NonNull Map expressionMap) { + String name = (String) expressionMap.get("name"); + if (name == null) { + throw new IllegalArgumentException("BooleanExpression must have a 'name' field"); + } + + Map args = argsOf(expressionMap); + + switch (name) { + case "equal": + case "not_equal": + case "greater_than": + case "greater_than_or_equal": + case "less_than": + case "less_than_or_equal": + return parseBinaryComparisonNamed(name, args); + case "and": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseAndExpression(exprMaps, this); + } + case "or": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseOrExpression(exprMaps, this); + } + case "xor": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseXorExpression(exprMaps, this); + } + case "not": + { + Map exprMap = (Map) args.get("expression"); + BooleanExpression expr = parseBooleanExpression(exprMap); + return expr.not(); + } + case "is_absent": + return parseIsAbsent(args); + case "is_error": + return parseIsError(args); + case "exists": + return parseExists(args); + case "array_contains": + return parseArrayContainsElement(args); + case "array_contains_all": + return parseArrayContainsAll(args); + case "array_contains_any": + return parseArrayContainsAny(args); + case "equal_any": + return parseEqualAny(args); + case "not_equal_any": + return parseNotEqualAny(args); + case "as_boolean": + return parseAsBoolean(args); + default: + Expression expr = parseExpression(expressionMap); + if (expr instanceof BooleanExpression) { + return (BooleanExpression) expr; + } + Log.w(TAG, "Expression type '" + name + "' is not a BooleanExpression, attempting cast"); + throw new IllegalArgumentException( + "Expression type '" + name + "' cannot be used as a BooleanExpression"); + } + } + + @SuppressWarnings("unchecked") + Selectable parseSelectable(@NonNull Map expressionMap) { + Expression expr = parseExpression(expressionMap); + if (!(expr instanceof Selectable)) { + throw new IllegalArgumentException( + "Expression must be a Selectable (Field or AliasedExpression). Got: " + + expressionMap.get("name")); + } + return (Selectable) expr; + } + + @SuppressWarnings("unchecked") + AggregateFunction parseAggregateFunction(@NonNull Map aggregateMap) { + String functionName = (String) aggregateMap.get("function"); + if (functionName == null) { + functionName = (String) aggregateMap.get("name"); + } + Map args = (Map) aggregateMap.get("args"); + Expression expr = null; + if (args != null) { + Map exprMap = (Map) args.get("expression"); + expr = parseExpression(exprMap); + } + + switch (functionName) { + case "sum": + return AggregateFunction.sum(expr); + case "average": + return AggregateFunction.average(expr); + case "count": + return AggregateFunction.count(expr); + case "count_distinct": + return AggregateFunction.countDistinct(expr); + case "minimum": + return AggregateFunction.minimum(expr); + case "maximum": + return AggregateFunction.maximum(expr); + case "count_all": + return AggregateFunction.countAll(); + default: + throw new IllegalArgumentException("Unknown aggregate function: " + functionName); + } + } + + @SuppressWarnings("unchecked") + AliasedAggregate parseAliasedAggregate(@NonNull Map aggregateMap) { + String name = (String) aggregateMap.get("name"); + if ("alias".equals(name)) { + Map args = (Map) aggregateMap.get("args"); + String alias = (String) args.get("alias"); + Map aggregateFunctionMap = + (Map) args.get("aggregate_function"); + + AggregateFunction function = parseAggregateFunction(aggregateFunctionMap); + return function.alias(alias); + } + + String alias = (String) aggregateMap.get("alias"); + if (alias != null) { + AggregateFunction function = parseAggregateFunction(aggregateMap); + return function.alias(alias); + } + + throw new IllegalArgumentException( + "Aggregate function must have an alias. Expected AliasedAggregateFunction format."); + } + + @SuppressWarnings("unchecked") + AggregateStage parseAggregateStage(@NonNull Map stageMap) { + List> accumulatorMaps = + (List>) stageMap.get("accumulators"); + if (accumulatorMaps == null || accumulatorMaps.isEmpty()) { + throw new IllegalArgumentException("AggregateStage must have at least one accumulator"); + } + + AliasedAggregate[] accumulators = new AliasedAggregate[accumulatorMaps.size()]; + for (int i = 0; i < accumulatorMaps.size(); i++) { + accumulators[i] = parseAliasedAggregate(accumulatorMaps.get(i)); + } + + AggregateStage aggregateStage; + if (accumulators.length == 1) { + aggregateStage = AggregateStage.withAccumulators(accumulators[0]); + } else { + AliasedAggregate[] rest = new AliasedAggregate[accumulators.length - 1]; + System.arraycopy(accumulators, 1, rest, 0, rest.length); + aggregateStage = AggregateStage.withAccumulators(accumulators[0], rest); + } + + List> groupMaps = (List>) stageMap.get("groups"); + if (groupMaps != null && !groupMaps.isEmpty()) { + Selectable firstGroup = parseSelectable(groupMaps.get(0)); + + if (groupMaps.size() == 1) { + aggregateStage = aggregateStage.withGroups(firstGroup); + } else { + Object[] additionalGroups = new Object[groupMaps.size() - 1]; + for (int i = 1; i < groupMaps.size(); i++) { + Expression groupExpr = parseExpression(groupMaps.get(i)); + additionalGroups[i - 1] = groupExpr; + } + aggregateStage = aggregateStage.withGroups(firstGroup, additionalGroups); + } + } + + return aggregateStage; + } + + AggregateOptions parseAggregateOptions(@NonNull Map optionsMap) { + return new AggregateOptions(); + } + + FindNearestStage.DistanceMeasure parseDistanceMeasure(@NonNull String dartEnumName) { + switch (dartEnumName) { + case "cosine": + return FindNearestStage.DistanceMeasure.COSINE; + case "euclidean": + return FindNearestStage.DistanceMeasure.EUCLIDEAN; + case "dotProduct": + return FindNearestStage.DistanceMeasure.DOT_PRODUCT; + default: + throw new IllegalArgumentException( + "Unknown distance measure: " + + dartEnumName + + ". Expected: cosine, euclidean, or dotProduct"); + } + } +} diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineParser.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineParser.java new file mode 100644 index 000000000000..6029a28f7ef8 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineParser.java @@ -0,0 +1,140 @@ +/* + * Copyright 2026, the Chromium project authors. Please see the AUTHORS file + * for details. All rights reserved. Use of this source code is governed by a + * BSD-style license that can be found in the LICENSE file. + */ + +package io.flutter.plugins.firebase.firestore.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.android.gms.tasks.Task; +import com.google.android.gms.tasks.Tasks; +import com.google.firebase.firestore.DocumentReference; +import com.google.firebase.firestore.FirebaseFirestore; +import com.google.firebase.firestore.Pipeline; +import com.google.firebase.firestore.Pipeline.Snapshot; +import com.google.firebase.firestore.PipelineSource; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class PipelineParser { + private static final String TAG = "PipelineParser"; + + /** + * Executes a pipeline from a list of stage maps. + * + * @param firestore The Firestore instance + * @param stages List of stage maps, each with 'stage' and 'args' fields + * @param options Optional execution options + * @return The pipeline snapshot result + */ + public static Snapshot executePipeline( + @NonNull FirebaseFirestore firestore, + @NonNull List> stages, + @Nullable Map options) + throws Exception { + Pipeline pipeline = buildPipeline(firestore, stages); + Task task; + if (options != null && !options.isEmpty()) { + Pipeline.ExecuteOptions executeOptions = parseExecuteOptions(options); + task = pipeline.execute(executeOptions); + } else { + task = pipeline.execute(); + } + return Tasks.await(task); + } + + private static Pipeline.ExecuteOptions parseExecuteOptions(@NonNull Map options) { + Pipeline.ExecuteOptions executeOptions = new Pipeline.ExecuteOptions(); + Object indexModeObj = options.get("indexMode"); + if (indexModeObj instanceof String) { + String indexModeStr = (String) indexModeObj; + if ("recommended".equalsIgnoreCase(indexModeStr)) { + executeOptions = + executeOptions.withIndexMode(Pipeline.ExecuteOptions.IndexMode.RECOMMENDED); + } + } + return executeOptions; + } + + /** + * Builds a Pipeline from a list of stage maps without executing it. Used when a stage (e.g. + * union) requires another pipeline as an argument. + */ + @SuppressWarnings("unchecked") + public static Pipeline buildPipeline( + @NonNull FirebaseFirestore firestore, @NonNull List> stages) { + if (stages.isEmpty()) { + throw new IllegalArgumentException("Pipeline must have at least one stage (source)."); + } + ExpressionParsers expressionParsers = new ExpressionParsers(firestore); + PipelineStageHandlers stageHandlers = new PipelineStageHandlers(expressionParsers); + PipelineSource pipelineSource = firestore.pipeline(); + Pipeline pipeline = null; + + for (int i = 0; i < stages.size(); i++) { + Map stageMap = stages.get(i); + String stageName = (String) stageMap.get("stage"); + if (stageName == null) { + throw new IllegalArgumentException("Stage must have a 'stage' field"); + } + + Map args = (Map) stageMap.get("args"); + + if (i == 0) { + pipeline = applySourceStage(pipelineSource, stageName, args, firestore); + } else { + pipeline = stageHandlers.applyStage(pipeline, stageName, args, firestore); + } + } + + return pipeline; + } + + /** + * Applies a source stage (collection, collection_group, documents, database) to PipelineSource. + * These are the only stages that can be the first stage and return a Pipeline instance. + */ + @SuppressWarnings("unchecked") + private static Pipeline applySourceStage( + @NonNull PipelineSource pipelineSource, + @NonNull String stageName, + @Nullable Map args, + @NonNull FirebaseFirestore firestore) { + if (args == null && !"database".equals(stageName)) { + throw new IllegalArgumentException("Stage args must not be null for stage: " + stageName); + } + switch (stageName) { + case "collection": + { + String path = (String) args.get("path"); + return pipelineSource.collection(path); + } + case "collection_group": + { + String path = (String) args.get("path"); + return pipelineSource.collectionGroup(path); + } + case "database": + { + return pipelineSource.database(); + } + case "documents": + { + List> docMaps = (List>) args; + List docRefs = new ArrayList<>(); + for (Map docMap : docMaps) { + String docPath = (String) docMap.get("path"); + docRefs.add(firestore.document(docPath)); + } + return pipelineSource.documents(docRefs.toArray(new DocumentReference[0])); + } + default: + throw new IllegalArgumentException( + "First stage must be one of: collection, collection_group, documents, database. Got: " + + stageName); + } + } +} diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java new file mode 100644 index 000000000000..cc0e6b0c35e6 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java @@ -0,0 +1,338 @@ +/* + * Copyright 2026, the Chromium project authors. Please see the AUTHORS file + * for details. All rights reserved. Use of this source code is governed by a + * BSD-style license that can be found in the LICENSE file. + */ + +package io.flutter.plugins.firebase.firestore.utils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.google.firebase.firestore.FirebaseFirestore; +import com.google.firebase.firestore.Pipeline; +import com.google.firebase.firestore.pipeline.AggregateOptions; +import com.google.firebase.firestore.pipeline.AggregateStage; +import com.google.firebase.firestore.pipeline.AliasedAggregate; +import com.google.firebase.firestore.pipeline.BooleanExpression; +import com.google.firebase.firestore.pipeline.Expression; +import com.google.firebase.firestore.pipeline.Field; +import com.google.firebase.firestore.pipeline.FindNearestOptions; +import com.google.firebase.firestore.pipeline.FindNearestStage; +import com.google.firebase.firestore.pipeline.Ordering; +import com.google.firebase.firestore.pipeline.SampleStage; +import com.google.firebase.firestore.pipeline.Selectable; +import com.google.firebase.firestore.pipeline.UnnestOptions; +import java.util.List; +import java.util.Map; + +/** Handles parsing and applying pipeline stages to Pipeline instances. */ +class PipelineStageHandlers { + private final ExpressionParsers parsers; + + PipelineStageHandlers(@NonNull ExpressionParsers parsers) { + this.parsers = parsers; + } + + /** Applies a pipeline stage to a Pipeline instance. */ + @SuppressWarnings("unchecked") + Pipeline applyStage( + @NonNull Pipeline pipeline, + @NonNull String stageName, + @Nullable Map args, + @NonNull FirebaseFirestore firestore) { + switch (stageName) { + case "where": + return handleWhere(pipeline, args); + case "limit": + return handleLimit(pipeline, args); + case "offset": + return handleOffset(pipeline, args); + case "sort": + return handleSort(pipeline, args); + case "select": + return handleSelect(pipeline, args); + case "add_fields": + return handleAddFields(pipeline, args); + case "remove_fields": + return handleRemoveFields(pipeline, args); + case "distinct": + return handleDistinct(pipeline, args); + case "aggregate": + return handleAggregate(pipeline, args); + case "aggregate_with_options": + return handleAggregateWithOptions(pipeline, args); + case "unnest": + return handleUnnest(pipeline, args); + case "replace_with": + return handleReplaceWith(pipeline, args); + case "union": + return handleUnion(pipeline, args, firestore); + case "sample": + return handleSample(pipeline, args); + case "find_nearest": + return handleFindNearest(pipeline, args); + default: + throw new IllegalArgumentException("Unknown pipeline stage: " + stageName); + } + } + + private Pipeline handleWhere(@NonNull Pipeline pipeline, @Nullable Map args) { + Map expressionMap = (Map) args.get("expression"); + BooleanExpression booleanExpression = parsers.parseBooleanExpression(expressionMap); + return pipeline.where(booleanExpression); + } + + private Pipeline handleLimit(@NonNull Pipeline pipeline, @Nullable Map args) { + Number limit = (Number) args.get("limit"); + return pipeline.limit(limit.intValue()); + } + + private Pipeline handleOffset(@NonNull Pipeline pipeline, @Nullable Map args) { + Number offset = (Number) args.get("offset"); + return pipeline.offset(offset.intValue()); + } + + @SuppressWarnings("unchecked") + private Pipeline handleSort(@NonNull Pipeline pipeline, @Nullable Map args) { + List> orderingMaps = (List>) args.get("orderings"); + if (orderingMaps == null || orderingMaps.isEmpty()) { + throw new IllegalArgumentException("'sort' requires at least one ordering"); + } + + Map firstMap = orderingMaps.get(0); + Expression expression = + parsers.parseExpression((Map) firstMap.get("expression")); + String direction = (String) firstMap.get("order_direction"); + Ordering firstOrdering = + "asc".equals(direction) ? expression.ascending() : expression.descending(); + + if (orderingMaps.size() == 1) { + return pipeline.sort(firstOrdering); + } + + Ordering[] additionalOrderings = new Ordering[orderingMaps.size() - 1]; + for (int i = 1; i < orderingMaps.size(); i++) { + Map map = orderingMaps.get(i); + expression = parsers.parseExpression((Map) map.get("expression")); + direction = (String) map.get("order_direction"); + additionalOrderings[i - 1] = + "asc".equals(direction) ? expression.ascending() : expression.descending(); + } + return pipeline.sort(firstOrdering, additionalOrderings); + } + + private Pipeline handleSelect(@NonNull Pipeline pipeline, @Nullable Map args) { + List> expressionMaps = (List>) args.get("expressions"); + + if (expressionMaps == null || expressionMaps.isEmpty()) { + throw new IllegalArgumentException("'select' requires at least one expression"); + } + + // Parse first expression as Selectable + Selectable firstSelection = parsers.parseSelectable(expressionMaps.get(0)); + + // Parse remaining expressions as varargs + if (expressionMaps.size() == 1) { + return pipeline.select(firstSelection); + } + + Object[] additionalSelections = new Object[expressionMaps.size() - 1]; + for (int i = 1; i < expressionMaps.size(); i++) { + Expression expr = parsers.parseExpression(expressionMaps.get(i)); + // Additional selections can be Selectable or any Object + additionalSelections[i - 1] = expr; + } + + return pipeline.select(firstSelection, additionalSelections); + } + + private Pipeline handleAddFields(@NonNull Pipeline pipeline, @Nullable Map args) { + List> expressionMaps = (List>) args.get("expressions"); + + if (expressionMaps == null || expressionMaps.isEmpty()) { + throw new IllegalArgumentException("'add_fields' requires at least one expression"); + } + + // Parse first expression as Selectable + Selectable firstField = parsers.parseSelectable(expressionMaps.get(0)); + + // Parse remaining expressions as Selectable varargs + if (expressionMaps.size() == 1) { + return pipeline.addFields(firstField); + } + + Selectable[] additionalFields = new Selectable[expressionMaps.size() - 1]; + for (int i = 1; i < expressionMaps.size(); i++) { + additionalFields[i - 1] = parsers.parseSelectable(expressionMaps.get(i)); + } + + return pipeline.addFields(firstField, additionalFields); + } + + private Pipeline handleRemoveFields( + @NonNull Pipeline pipeline, @Nullable Map args) { + List fieldPaths = (List) args.get("field_paths"); + + if (fieldPaths == null || fieldPaths.isEmpty()) { + throw new IllegalArgumentException("'remove_fields' requires at least one field path"); + } + + // Convert first field path string to Field + Field firstField = Expression.field(fieldPaths.get(0)); + + // Convert remaining field paths to Field varargs + if (fieldPaths.size() == 1) { + return pipeline.removeFields(firstField); + } + + Field[] additionalFields = new Field[fieldPaths.size() - 1]; + for (int i = 1; i < fieldPaths.size(); i++) { + additionalFields[i - 1] = Expression.field(fieldPaths.get(i)); + } + + return pipeline.removeFields(firstField, additionalFields); + } + + private Pipeline handleDistinct(@NonNull Pipeline pipeline, @Nullable Map args) { + List> expressionMaps = (List>) args.get("expressions"); + + if (expressionMaps == null || expressionMaps.isEmpty()) { + throw new IllegalArgumentException("'distinct' requires at least one expression"); + } + + // Parse first expression as Selectable + Selectable firstGroup = parsers.parseSelectable(expressionMaps.get(0)); + + // Parse remaining expressions as varargs (can be Selectable or Any) + if (expressionMaps.size() == 1) { + return pipeline.distinct(firstGroup); + } + + Object[] additionalGroups = new Object[expressionMaps.size() - 1]; + for (int i = 1; i < expressionMaps.size(); i++) { + Expression expr = parsers.parseExpression(expressionMaps.get(i)); + // Additional groups can be Selectable or any Object + additionalGroups[i - 1] = expr; + } + + return pipeline.distinct(firstGroup, additionalGroups); + } + + @SuppressWarnings("unchecked") + private Pipeline handleAggregate(@NonNull Pipeline pipeline, @Nullable Map args) { + List> aggregateMaps = + (List>) args.get("aggregate_functions"); + + if (aggregateMaps == null || aggregateMaps.isEmpty()) { + throw new IllegalArgumentException("'aggregate' requires at least one aggregate function"); + } + + AliasedAggregate firstAccumulator = parsers.parseAliasedAggregate(aggregateMaps.get(0)); + + if (aggregateMaps.size() == 1) { + return pipeline.aggregate(firstAccumulator); + } + + AliasedAggregate[] additionalAccumulators = new AliasedAggregate[aggregateMaps.size() - 1]; + for (int i = 1; i < aggregateMaps.size(); i++) { + additionalAccumulators[i - 1] = parsers.parseAliasedAggregate(aggregateMaps.get(i)); + } + + return pipeline.aggregate(firstAccumulator, additionalAccumulators); + } + + @SuppressWarnings("unchecked") + private Pipeline handleAggregateWithOptions( + @NonNull Pipeline pipeline, @Nullable Map args) { + Map aggregateStageMap = (Map) args.get("aggregate_stage"); + + AggregateStage aggregateStage = parsers.parseAggregateStage(aggregateStageMap); + + Map optionsMap = (Map) args.get("options"); + if (optionsMap != null && !optionsMap.isEmpty()) { + AggregateOptions options = parsers.parseAggregateOptions(optionsMap); + return pipeline.aggregate(aggregateStage, options); + } + return pipeline.aggregate(aggregateStage); + } + + private Pipeline handleUnnest(@NonNull Pipeline pipeline, @Nullable Map args) { + Map expressionMap = (Map) args.get("expression"); + Selectable expression = parsers.parseSelectable(expressionMap); + String indexField = (String) args.get("index_field"); + if (indexField != null) { + return pipeline.unnest(expression, new UnnestOptions().withIndexField(indexField)); + } else { + return pipeline.unnest(expression); + } + } + + private Pipeline handleReplaceWith( + @NonNull Pipeline pipeline, @Nullable Map args) { + Map expressionMap = (Map) args.get("expression"); + Expression expression = parsers.parseExpression(expressionMap); + return pipeline.replaceWith(expression); + } + + @SuppressWarnings("unchecked") + private Pipeline handleUnion( + @NonNull Pipeline pipeline, + @Nullable Map args, + @NonNull FirebaseFirestore firestore) { + List> nestedStages = (List>) args.get("pipeline"); + if (nestedStages == null || nestedStages.isEmpty()) { + throw new IllegalArgumentException("'union' requires a non-empty 'pipeline' argument"); + } + Pipeline otherPipeline = PipelineParser.buildPipeline(firestore, nestedStages); + return pipeline.union(otherPipeline); + } + + private Pipeline handleSample(@NonNull Pipeline pipeline, @Nullable Map args) { + // Sample stage parsing + Map sampleMap = (Map) args; + // Parse sample configuration + String type = (String) sampleMap.get("type"); + if ("percentage".equals(type)) { + double value = ((Number) sampleMap.get("value")).doubleValue(); + return pipeline.sample(SampleStage.withPercentage(value)); + } else { + int value = ((Number) sampleMap.get("value")).intValue(); + return pipeline.sample(SampleStage.withDocLimit(value)); + } + } + + @SuppressWarnings("unchecked") + private Pipeline handleFindNearest( + @NonNull Pipeline pipeline, @Nullable Map args) { + String vectorField = (String) args.get("vector_field"); + List vectorValue = (List) args.get("vector_value"); + String distanceMeasureStr = (String) args.get("distance_measure"); + Number limitObj = (Number) args.get("limit"); + + if (distanceMeasureStr == null) { + throw new IllegalArgumentException("'find_nearest' requires a 'distance_measure' argument"); + } + + // Convert Dart enum name to Android enum value + FindNearestStage.DistanceMeasure distanceMeasure = + parsers.parseDistanceMeasure(distanceMeasureStr); + + // Convert vector value to double array + double[] vectorArray = new double[vectorValue.size()]; + for (int i = 0; i < vectorValue.size(); i++) { + vectorArray[i] = vectorValue.get(i).doubleValue(); + } + + Field fieldExpr = Expression.field(vectorField); + + if (limitObj != null) { + return pipeline.findNearest( + vectorField, + Expression.vector(vectorArray), + distanceMeasure, + new FindNearestOptions().withLimit(limitObj.intValue())); + } else { + return pipeline.findNearest(fieldExpr, vectorArray, distanceMeasure); + } + } +} diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTFirebaseFirestorePlugin.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTFirebaseFirestorePlugin.m index b7fa740fe68c..ab826056264f 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTFirebaseFirestorePlugin.m +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTFirebaseFirestorePlugin.m @@ -15,6 +15,7 @@ #import "include/cloud_firestore/Private/FLTFirebaseFirestoreReader.h" #import "include/cloud_firestore/Private/FLTFirebaseFirestoreUtils.h" #import "include/cloud_firestore/Private/FLTLoadBundleStreamHandler.h" +#import "include/cloud_firestore/Private/FLTPipelineParser.h" #import "include/cloud_firestore/Private/FLTQuerySnapshotStreamHandler.h" #import "include/cloud_firestore/Private/FLTSnapshotsInSyncStreamHandler.h" #import "include/cloud_firestore/Private/FLTTransactionStreamHandler.h" @@ -73,6 +74,20 @@ - (NSString *)registerEventChannelWithPrefix:(NSString *)prefix static NSCache *_serverTimestampMap; +static id _Nullable FLTPipelineNullSafe(id value) { + return (value == nil || [value isKindOfClass:[NSNull class]]) ? nil : value; +} + +static NSNumber *_Nullable FLTPipelineTimestampToMs(id value) { + if (!value) return nil; + if ([value isKindOfClass:[NSNumber class]]) return value; + if ([value isKindOfClass:[FIRTimestamp class]]) { + FIRTimestamp *ts = value; + return @((int64_t)ts.seconds * 1000 + (int64_t)ts.nanoseconds / 1000000); + } + return nil; +} + @implementation FLTFirebaseFirestorePlugin { NSMutableDictionary *_eventChannels; NSMutableDictionary *> *_streamHandlers; @@ -883,4 +898,66 @@ - (void)aggregateQueryApp:(nonnull FirestorePigeonFirebaseApp *)app }]; } +- (void)executePipelineApp:(nonnull FirestorePigeonFirebaseApp *)app + stages:(nonnull NSArray *> *)stages + options:(nullable NSDictionary *)options + completion:(nonnull void (^)(PigeonPipelineSnapshot *_Nullable, + FlutterError *_Nullable))completion { + FIRFirestore *firestore = [self getFIRFirestoreFromAppNameFromPigeon:app]; + + [FLTPipelineParser + executePipelineWithFirestore:firestore + stages:stages + options:options + completion:^(id _Nullable snapshot, NSError *_Nullable error) { + if (error) { + completion(nil, [self convertToFlutterError:error]); + return; + } + if (snapshot == nil) { + completion( + nil, + [FlutterError errorWithCode:@"error" + message:@"Pipeline execution returned no result" + details:nil]); + return; + } + + NSMutableArray *pigeonResults = + [NSMutableArray array]; + NSArray *results = [snapshot results]; + if ([results isKindOfClass:[NSArray class]]) { + for (id result in results) { + id ref = [result reference]; + NSString *path = (ref && [ref respondsToSelector:@selector(path)]) + ? [ref path] + : FLTPipelineNullSafe([result documentID]); + NSNumber *createTime = + FLTPipelineTimestampToMs([result valueForKey:@"create_time"]); + NSNumber *updateTime = + FLTPipelineTimestampToMs([result valueForKey:@"update_time"]); + NSDictionary *data = FLTPipelineNullSafe([result data]); + PigeonPipelineResult *pigeonResult = + [PigeonPipelineResult makeWithDocumentPath:path + createTime:createTime + updateTime:updateTime + data:data]; + [pigeonResults addObject:pigeonResult]; + } + } + + NSNumber *executionTime = + FLTPipelineTimestampToMs([snapshot execution_time]); + if (executionTime == nil) { + executionTime = + @((int64_t)([[NSDate date] timeIntervalSince1970] * 1000)); + } + + PigeonPipelineSnapshot *pigeonSnapshot = + [PigeonPipelineSnapshot makeWithResults:pigeonResults + executionTime:executionTime]; + completion(pigeonSnapshot, nil); + }]; +} + @end diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTFirebaseFirestoreUtils.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTFirebaseFirestoreUtils.m index d49fbc7f4a79..db06c89e52a0 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTFirebaseFirestoreUtils.m +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTFirebaseFirestoreUtils.m @@ -21,6 +21,8 @@ - (FlutterStandardReader *_Nonnull)readerWithData:(NSData *)data { NSMutableDictionary *firestoreInstanceCache; +const NSInteger FLTFirebaseFirestoreErrorCodePipelineParse = -1; + @implementation FLTFirebaseFirestoreUtils + (NSString *)generateKeyForAppName:(NSString *)appName andDatabaseURL:(NSString *)databaseURL { @@ -240,6 +242,11 @@ + (NSArray *)ErrorCodeAndMessageFromNSError:(NSError *)error { code = @"unknown"; message = @"Unknown error or an error from a different error domain."; break; + case FLTFirebaseFirestoreErrorCodePipelineParse: + code = @"parse-error"; + message = (error.localizedDescription.length > 0) ? error.localizedDescription + : @"An unknown error occurred."; + break; default: code = @"unknown"; message = @"An unknown error occurred."; diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m new file mode 100644 index 000000000000..b89a98b5215b --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m @@ -0,0 +1,1329 @@ +/* + * Copyright 2026, the Chromium project authors. Please see the AUTHORS file + * for details. All rights reserved. Use of this source code is governed by a + * BSD-style license that can be found in the LICENSE file. + */ + +#import "include/cloud_firestore/Private/FLTPipelineParser.h" +#import "include/cloud_firestore/Private/FLTFirebaseFirestoreUtils.h" + +#if TARGET_OS_OSX +#import +#import "FirebaseFirestoreInternal/FIRPipelineBridge.h" +#else +@import FirebaseFirestore; +#if __has_include("FirebaseFirestoreInternal/FIRPipelineBridge.h") +#import "FirebaseFirestoreInternal/FIRPipelineBridge.h" +#elif __has_include("FIRPipelineBridge.h") +#import "FIRPipelineBridge.h" +#endif +#endif + +#import + +static NSString *const kPipelineNotAvailable = + @"Pipeline API is not available. Firestore Pipelines require Firebase iOS SDK with pipeline " + "support."; + +static NSError *pipelineUnavailableError(void) { + return [NSError errorWithDomain:@"FLTFirebaseFirestore" + code:FLTFirebaseFirestoreErrorCodePipelineParse + userInfo:@{NSLocalizedDescriptionKey : kPipelineNotAvailable}]; +} + +#if TARGET_OS_OSX +#if __has_include("FirebaseFirestoreInternal/FIRPipelineBridge.h") +#define FLT_PIPELINE_AVAILABLE 1 +#endif +#else +#if __has_include("FirebaseFirestoreInternal/FIRPipelineBridge.h") || \ + __has_include("FIRPipelineBridge.h") +#define FLT_PIPELINE_AVAILABLE 1 +#endif +#endif + +#if FLT_PIPELINE_AVAILABLE + +static NSError *parseError(NSString *message) { + return [NSError errorWithDomain:@"FLTFirebaseFirestore" + code:FLTFirebaseFirestoreErrorCodePipelineParse + userInfo:@{NSLocalizedDescriptionKey : message}]; +} + +@interface FLTPipelineExpressionParser : NSObject +@property(nonatomic, strong) FIRFirestore *firestore; +- (instancetype)initWithFirestore:(FIRFirestore *)firestore; +- (FIRExprBridge *)parseExpression:(NSDictionary *)map error:(NSError **)error; +- (FIRExprBridge *)parseBooleanExpression:(NSDictionary *)map + error:(NSError **)error; +- (FIRExprBridge *)rightExprFromValue:(id)value error:(NSError **)error; +@end + +@implementation FLTPipelineExpressionParser + +- (instancetype)initWithFirestore:(FIRFirestore *)firestore { + self = [super init]; + if (self) { + _firestore = firestore; + } + return self; +} + +- (FIRExprBridge *)parseExpression:(NSDictionary *)map error:(NSError **)error { + NSString *name = map[@"name"]; + if (!name) { + NSDictionary *args = map[@"args"]; + if ([args isKindOfClass:[NSDictionary class]] && args[@"field"]) { + return [[FIRFieldBridge alloc] initWithName:args[@"field"]]; + } + if (error) *error = parseError(@"Expression must have a 'name' field"); + return nil; + } + + NSDictionary *args = map[@"args"]; + if (![args isKindOfClass:[NSDictionary class]]) args = @{}; + + if ([name isEqualToString:@"field"]) { + NSString *field = args[@"field"]; + if (!field) { + if (error) *error = parseError(@"Field expression requires 'field' argument"); + return nil; + } + return [[FIRFieldBridge alloc] initWithName:field]; + } + + if ([name isEqualToString:@"constant"]) { + id value = args[@"value"]; + if (value == nil) { + if (error) *error = parseError(@"Constant requires 'value' argument"); + return nil; + } + if ([value isKindOfClass:[NSDictionary class]]) { + NSString *path = ((NSDictionary *)value)[@"path"]; + if ([path isKindOfClass:[NSString class]] && self.firestore) { + FIRDocumentReference *docRef = [self.firestore documentWithPath:path]; + return [[FIRConstantBridge alloc] init:docRef]; + } + } + return [[FIRConstantBridge alloc] init:value]; + } + + if ([name isEqualToString:@"alias"]) { + id exprMap = args[@"expression"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"Alias requires 'expression'"); + return nil; + } + // No explicit AliasedExpression type in ObjC; aliases are dict keys when building stages. + // Parse and return the inner expression; the caller uses args[@"alias"] as the dict key. + return [self parseExpression:exprMap error:error]; + } + + if ([name isEqualToString:@"null"]) { + return [[FIRConstantBridge alloc] init:[NSNull null]]; + } + + if ([name isEqualToString:@"document_id_from_ref"]) { + NSString *path = args[@"doc_ref"]; + if (![path isKindOfClass:[NSString class]] || path.length == 0) { + if (error) *error = parseError(@"document_id_from_ref requires doc_ref path"); + return nil; + } + if (!self.firestore) { + if (error) *error = parseError(@"document_id_from_ref requires firestore"); + return nil; + } + FIRDocumentReference *docRef = [self.firestore documentWithPath:path]; + FIRExprBridge *refExpr = [[FIRConstantBridge alloc] init:docRef]; + return [[FIRFunctionExprBridge alloc] initWithName:@"document_id" Args:@[ refExpr ]]; + } + + // Swift asBoolean() is a type coercion, not a pipeline function named "as_boolean". + // Dart still sends as_boolean + expression; unwrap to the inner FIRExprBridge. + if ([name isEqualToString:@"as_boolean"]) { + id exprMap = args[@"expression"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"as_boolean requires expression"); + return nil; + } + return [self parseExpression:(NSDictionary *)exprMap error:error]; + } + + // Map Dart names to iOS SDK names where they differ + NSString *sdkName = name; + if ([name isEqualToString:@"bit_xor"]) sdkName = @"xor"; + if ([name isEqualToString:@"modulo"]) sdkName = @"mod"; + + // ------------------------------------------------------------------------- + // Binary expressions (left + right): comparisons, arithmetic, bitwise + // ------------------------------------------------------------------------- + static NSArray *binaryNames = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + binaryNames = @[ + @"equal", @"not_equal", @"greater_than", @"greater_than_or_equal", @"less_than", + @"less_than_or_equal", @"add", @"subtract", @"multiply", @"divide", @"mod", @"bit_and", + @"bit_or", @"bit_left_shift", @"bit_right_shift" + ]; + }); + if ([binaryNames containsObject:sdkName] || [name isEqualToString:@"bit_xor"]) { + id leftMap = args[@"left"]; + id rightMap = args[@"right"]; + if (![leftMap isKindOfClass:[NSDictionary class]] || + ![rightMap isKindOfClass:[NSDictionary class]]) { + if (error) + *error = + parseError([NSString stringWithFormat:@"%@ requires left and right expressions", name]); + return nil; + } + FIRExprBridge *left = [self parseExpression:leftMap error:error]; + FIRExprBridge *right = [self parseExpression:rightMap error:error]; + if (!left || !right) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:sdkName Args:@[ left, right ]]; + } + + // ------------------------------------------------------------------------- + // Unary expressions (single expression): exists, is_error, is_absent, not + // (as_boolean is handled above — unwrap only, not a pipeline function.) + // ------------------------------------------------------------------------- + NSArray *unaryNames = @[ @"exists", @"is_error", @"is_absent", @"not" ]; + if ([unaryNames containsObject:name]) { + id exprMap = args[@"expression"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError([NSString stringWithFormat:@"%@ requires expression", name]); + return nil; + } + FIRExprBridge *expr = [name isEqualToString:@"not"] + ? [self parseBooleanExpression:exprMap error:error] + : [self parseExpression:exprMap error:error]; + if (!expr) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:name Args:@[ expr ]]; + } + + // ------------------------------------------------------------------------- + // Unary with optional SDK name mapping: length, to_lower, to_upper, trim, + // abs, array_length, array_reverse, bit_not, document_id, collection_id + // ------------------------------------------------------------------------- + NSArray *unaryWithSdkName = @[ + @"length", @"to_lower_case", @"to_upper_case", @"trim", @"abs", @"array_length", + @"array_reverse", @"bit_not", @"document_id", @"collection_id" + ]; + if ([unaryWithSdkName containsObject:name]) { + id exprMap = args[@"expression"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError([NSString stringWithFormat:@"%@ requires expression", name]); + return nil; + } + FIRExprBridge *expr = [self parseExpression:exprMap error:error]; + if (!expr) return nil; + NSString *unarySdkName = name; + if ([name isEqualToString:@"to_lower_case"]) unarySdkName = @"to_lower"; + if ([name isEqualToString:@"to_upper_case"]) unarySdkName = @"to_upper"; + return [[FIRFunctionExprBridge alloc] initWithName:unarySdkName Args:@[ expr ]]; + } + + // ------------------------------------------------------------------------- + // N-ary logical (expressions array): and, or, xor + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"and"] || [name isEqualToString:@"or"] || + [name isEqualToString:@"xor"]) { + NSArray *exprMaps = args[@"expressions"]; + if (![exprMaps isKindOfClass:[NSArray class]] || exprMaps.count == 0) { + if (error) + *error = + parseError([NSString stringWithFormat:@"%@ requires at least one expression", name]); + return nil; + } + NSMutableArray *all = [NSMutableArray array]; + for (id em in exprMaps) { + if (![em isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *e = [self parseBooleanExpression:em error:error]; + if (!e) return nil; + [all addObject:e]; + } + if (all.count == 0) { + if (error) + *error = + parseError([NSString stringWithFormat:@"%@ requires at least one expression", name]); + return nil; + } + return [[FIRFunctionExprBridge alloc] initWithName:name Args:all]; + } + + // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"equal_any"] || [name isEqualToString:@"not_equal_any"]) { + id valueMap = args[@"value"]; + NSArray *valuesMaps = args[@"values"]; + if (![valueMap isKindOfClass:[NSDictionary class]] || + ![valuesMaps isKindOfClass:[NSArray class]] || valuesMaps.count == 0) { + if (error) + *error = + parseError([NSString stringWithFormat:@"%@ requires value and non-empty values", name]); + return nil; + } + FIRExprBridge *valueExpr = [self parseExpression:valueMap error:error]; + if (!valueExpr) return nil; + NSMutableArray *valueExprs = [NSMutableArray array]; + for (id vm in valuesMaps) { + if (![vm isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *ve = [self parseExpression:vm error:error]; + if (!ve) return nil; + [valueExprs addObject:ve]; + } + if (valueExprs.count == 0) { + if (error) + *error = parseError([NSString stringWithFormat:@"%@ requires at least one value", name]); + return nil; + } + FIRExprBridge *valuesArrayExpr = [[FIRFunctionExprBridge alloc] initWithName:@"array" + Args:valueExprs]; + return [[FIRFunctionExprBridge alloc] initWithName:name Args:@[ valueExpr, valuesArrayExpr ]]; + } + + // ------------------------------------------------------------------------- + // array + element: array_contains + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"array_contains"]) { + id arrayMap = args[@"array"]; + id elementMap = args[@"element"]; + if (![arrayMap isKindOfClass:[NSDictionary class]] || + ![elementMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"array_contains requires array and element"); + return nil; + } + FIRExprBridge *arrayExpr = [self parseExpression:arrayMap error:error]; + FIRExprBridge *elementExpr = [self parseExpression:elementMap error:error]; + if (!arrayExpr || !elementExpr) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:name Args:@[ arrayExpr, elementExpr ]]; + } + + // ------------------------------------------------------------------------- + // array + values[]: array_contains_all, array_contains_any + // SDK expects: array_contains_any(field, array(val1, val2, ...)) — two args. + // Reuse the "array" expression parser to build the values array. + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"array_contains_all"] || + [name isEqualToString:@"array_contains_any"]) { + id arrayMap = args[@"array"]; + if (![arrayMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError([NSString stringWithFormat:@"%@ requires array", name]); + return nil; + } + FIRExprBridge *arrayExpr = [self parseExpression:arrayMap error:error]; + if (!arrayExpr) return nil; + + NSArray *valuesMaps = args[@"values"]; + if (![valuesMaps isKindOfClass:[NSArray class]]) valuesMaps = args[@"elements"]; + BOOL hasValues = [valuesMaps isKindOfClass:[NSArray class]] && valuesMaps.count > 0; + + if (hasValues) { + NSDictionary *arrayExprMap = @{@"name" : @"array", @"args" : @{@"elements" : valuesMaps}}; + FIRExprBridge *valuesArrayExpr = [self parseExpression:arrayExprMap error:error]; + if (!valuesArrayExpr) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:name Args:@[ arrayExpr, valuesArrayExpr ]]; + } + + if ([name isEqualToString:@"array_contains_all"]) { + id arrayExpressionMap = args[@"array_expression"]; + if ([arrayExpressionMap isKindOfClass:[NSDictionary class]]) { + FIRExprBridge *requiredArrayExpr = [self parseExpression:arrayExpressionMap error:error]; + if (!requiredArrayExpr) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:name + Args:@[ arrayExpr, requiredArrayExpr ]]; + } + } + + if (error) + *error = parseError([NSString + stringWithFormat: + @"%@ requires array and values/elements, or array_contains_all with array_expression", + name]); + return nil; + } + + // ------------------------------------------------------------------------- + // expressions[]: concat (SDK: concat) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"concat"]) { + NSArray *exprMaps = args[@"expressions"]; + if (![exprMaps isKindOfClass:[NSArray class]] || exprMaps.count == 0) { + if (error) *error = parseError(@"concat requires non-empty expressions"); + return nil; + } + NSMutableArray *all = [NSMutableArray array]; + for (id em in exprMaps) { + if (![em isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *e = [self parseExpression:em error:error]; + if (!e) return nil; + [all addObject:e]; + } + if (all.count == 0) { + if (error) *error = parseError(@"concat requires at least one expression"); + return nil; + } + return [[FIRFunctionExprBridge alloc] initWithName:@"concat" Args:all]; + } + + // ------------------------------------------------------------------------- + // expression + start + end: substring (SDK: substring) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"substring"]) { + id exprMap = args[@"expression"]; + id startMap = args[@"start"]; + id endMap = args[@"end"]; + if (![exprMap isKindOfClass:[NSDictionary class]] || + ![startMap isKindOfClass:[NSDictionary class]] || + ![endMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"substring requires expression, start, and end"); + return nil; + } + FIRExprBridge *expr = [self parseExpression:exprMap error:error]; + FIRExprBridge *start = [self parseExpression:startMap error:error]; + FIRExprBridge *end = [self parseExpression:endMap error:error]; + if (!expr || !start || !end) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"substring" Args:@[ expr, start, end ]]; + } + + // ------------------------------------------------------------------------- + // expression + find + replacement: replace (SDK: string_replace) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"replace"]) { + id exprMap = args[@"expression"]; + id findMap = args[@"find"]; + id replacementMap = args[@"replacement"]; + if (![exprMap isKindOfClass:[NSDictionary class]] || + ![findMap isKindOfClass:[NSDictionary class]] || + ![replacementMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"replace requires expression, find, and replacement"); + return nil; + } + FIRExprBridge *expr = [self parseExpression:exprMap error:error]; + FIRExprBridge *find = [self parseExpression:findMap error:error]; + FIRExprBridge *replacement = [self parseExpression:replacementMap error:error]; + if (!expr || !find || !replacement) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"string_replace" + Args:@[ expr, find, replacement ]]; + } + + // ------------------------------------------------------------------------- + // expression + delimiter: split, join (SDK: split, join) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"split"] || [name isEqualToString:@"join"]) { + id exprMap = args[@"expression"]; + id delimiterMap = args[@"delimiter"]; + if (![exprMap isKindOfClass:[NSDictionary class]] || + ![delimiterMap isKindOfClass:[NSDictionary class]]) { + if (error) + *error = + parseError([NSString stringWithFormat:@"%@ requires expression and delimiter", name]); + return nil; + } + FIRExprBridge *expr = [self parseExpression:exprMap error:error]; + FIRExprBridge *delimiter = [self parseExpression:delimiterMap error:error]; + if (!expr || !delimiter) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:name Args:@[ expr, delimiter ]]; + } + + // ------------------------------------------------------------------------- + // first + second: array_concat (SDK: array_concat) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"array_concat"]) { + id firstMap = args[@"first"]; + id secondMap = args[@"second"]; + if (![firstMap isKindOfClass:[NSDictionary class]] || + ![secondMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"array_concat requires first and second"); + return nil; + } + FIRExprBridge *first = [self parseExpression:firstMap error:error]; + FIRExprBridge *second = [self parseExpression:secondMap error:error]; + if (!first || !second) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"array_concat" Args:@[ first, second ]]; + } + + // ------------------------------------------------------------------------- + // arrays[]: array_concat_multiple (SDK: array_concat) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"array_concat_multiple"]) { + NSArray *arraysMaps = args[@"arrays"]; + if (![arraysMaps isKindOfClass:[NSArray class]] || arraysMaps.count == 0) { + if (error) *error = parseError(@"array_concat_multiple requires non-empty arrays"); + return nil; + } + NSMutableArray *all = [NSMutableArray array]; + for (id am in arraysMaps) { + if (![am isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *e = [self parseExpression:am error:error]; + if (!e) return nil; + [all addObject:e]; + } + if (all.count == 0) { + if (error) *error = parseError(@"array_concat_multiple requires at least one array"); + return nil; + } + return [[FIRFunctionExprBridge alloc] initWithName:@"array_concat" Args:all]; + } + + // ------------------------------------------------------------------------- + // elements[]: array (construct) — Expression.array([...]) from Dart + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"array"]) { + NSArray *elementsMaps = args[@"elements"]; + if (![elementsMaps isKindOfClass:[NSArray class]] || elementsMaps.count == 0) { + if (error) *error = parseError(@"array requires non-empty elements"); + return nil; + } + NSMutableArray *elementExprs = [NSMutableArray array]; + for (id em in elementsMaps) { + if (![em isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *e = [self parseExpression:em error:error]; + if (!e) return nil; + [elementExprs addObject:e]; + } + if (elementExprs.count == 0) { + if (error) *error = parseError(@"array requires at least one element"); + return nil; + } + return [[FIRFunctionExprBridge alloc] initWithName:@"array" Args:elementExprs]; + } + + // ------------------------------------------------------------------------- + // data: map (construct) — Expression.map({ "k": expr, ... }) from Dart + // SDK expects Args as alternating key (constant), value (expression). + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"map"]) { + NSDictionary *dataMap = args[@"data"]; + if (![dataMap isKindOfClass:[NSDictionary class]] || dataMap.count == 0) { + if (error) *error = parseError(@"map requires non-empty data"); + return nil; + } + NSMutableArray *mapArgs = [NSMutableArray array]; + for (NSString *key in dataMap) { + id valueMap = dataMap[key]; + if (![valueMap isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *keyExpr = [[FIRConstantBridge alloc] init:key]; + FIRExprBridge *valueExpr = [self parseExpression:valueMap error:error]; + if (!valueExpr) return nil; + [mapArgs addObject:keyExpr]; + [mapArgs addObject:valueExpr]; + } + if (mapArgs.count == 0) { + if (error) *error = parseError(@"map requires at least one key-value pair"); + return nil; + } + return [[FIRFunctionExprBridge alloc] initWithName:@"map" Args:mapArgs]; + } + + // ------------------------------------------------------------------------- + // map + key: map_get (SDK: map_get) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"map_get"]) { + id mapMap = args[@"map"]; + id keyMap = args[@"key"]; + if (![mapMap isKindOfClass:[NSDictionary class]] || + ![keyMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"map_get requires map and key"); + return nil; + } + FIRExprBridge *mapExpr = [self parseExpression:mapMap error:error]; + FIRExprBridge *keyExpr = [self parseExpression:keyMap error:error]; + if (!mapExpr || !keyExpr) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"map_get" Args:@[ mapExpr, keyExpr ]]; + } + + // ------------------------------------------------------------------------- + // expression + else: if_absent (SDK: if_absent) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"if_absent"]) { + id exprMap = args[@"expression"]; + id elseMap = args[@"else"]; + if (![exprMap isKindOfClass:[NSDictionary class]] || + ![elseMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"if_absent requires expression and else"); + return nil; + } + FIRExprBridge *expr = [self parseExpression:exprMap error:error]; + FIRExprBridge *elseExpr = [self parseExpression:elseMap error:error]; + if (!expr || !elseExpr) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"if_absent" Args:@[ expr, elseExpr ]]; + } + + // ------------------------------------------------------------------------- + // expression + catch: if_error (SDK: if_error) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"if_error"]) { + id exprMap = args[@"expression"]; + id catchMap = args[@"catch"]; + if (![exprMap isKindOfClass:[NSDictionary class]] || + ![catchMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"if_error requires expression and catch"); + return nil; + } + FIRExprBridge *expr = [self parseExpression:exprMap error:error]; + FIRExprBridge *catchExpr = [self parseExpression:catchMap error:error]; + if (!expr || !catchExpr) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"if_error" Args:@[ expr, catchExpr ]]; + } + + // ------------------------------------------------------------------------- + // condition + then + else: conditional (SDK: conditional) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"conditional"]) { + id conditionMap = args[@"condition"]; + id thenMap = args[@"then"]; + id elseMap = args[@"else"]; + if (![conditionMap isKindOfClass:[NSDictionary class]] || + ![thenMap isKindOfClass:[NSDictionary class]] || + ![elseMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"conditional requires condition, then, and else"); + return nil; + } + FIRExprBridge *condition = [self parseBooleanExpression:conditionMap error:error]; + FIRExprBridge *thenExpr = [self parseExpression:thenMap error:error]; + FIRExprBridge *elseExpr = [self parseExpression:elseMap error:error]; + if (!condition || !thenExpr || !elseExpr) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"conditional" + Args:@[ condition, thenExpr, elseExpr ]]; + } + + // ------------------------------------------------------------------------- + // timestamp + amount + unit: timestamp_add, timestamp_subtract (SDK: Args ts, amount, unit) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"timestamp_add"] || [name isEqualToString:@"timestamp_subtract"]) { + id timestampMap = args[@"timestamp"]; + id unitVal = args[@"unit"]; + id amountMap = args[@"amount"]; + if (![timestampMap isKindOfClass:[NSDictionary class]] || !unitVal || + ![amountMap isKindOfClass:[NSDictionary class]]) { + if (error) + *error = parseError( + [NSString stringWithFormat:@"%@ requires timestamp, unit, and amount", name]); + return nil; + } + FIRExprBridge *timestampExpr = [self parseExpression:timestampMap error:error]; + FIRExprBridge *amountExpr = [self parseExpression:amountMap error:error]; + if (!timestampExpr || !amountExpr) return nil; + FIRExprBridge *unitExpr = [[FIRConstantBridge alloc] init:unitVal]; + return [[FIRFunctionExprBridge alloc] initWithName:name + Args:@[ timestampExpr, unitExpr, amountExpr ]]; + } + + // ------------------------------------------------------------------------- + // No args: current_timestamp (SDK: current_timestamp with empty Args) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"current_timestamp"]) { + return [[FIRFunctionExprBridge alloc] initWithName:@"current_timestamp" Args:@[]]; + } + + // ------------------------------------------------------------------------- + // timestamp + unit: timestamp_truncate (SDK: timestamp_trunc) + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"timestamp_truncate"]) { + id timestampMap = args[@"timestamp"]; + id unitVal = args[@"unit"]; + if (![timestampMap isKindOfClass:[NSDictionary class]] || !unitVal) { + if (error) *error = parseError(@"timestamp_truncate requires timestamp and unit"); + return nil; + } + FIRExprBridge *timestampExpr = [self parseExpression:timestampMap error:error]; + if (!timestampExpr) return nil; + FIRExprBridge *unitExpr = [[FIRConstantBridge alloc] init:unitVal]; + return [[FIRFunctionExprBridge alloc] initWithName:@"timestamp_trunc" + Args:@[ timestampExpr, unitExpr ]]; + } + + // ------------------------------------------------------------------------- + // PipelineFilter (name "filter"): operator-based (and/or) or field-based + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"filter"]) { + return [self parseFilterExpressionWithArgs:args error:error]; + } + + if (error) *error = parseError([NSString stringWithFormat:@"Unsupported expression: %@", name]); + return nil; +} + +- (FIRExprBridge *)rightExprFromValue:(id)value error:(NSError **)error { + if ([value isKindOfClass:[NSDictionary class]]) { + return [self parseExpression:(NSDictionary *)value error:error]; + } + return [[FIRConstantBridge alloc] init:value]; +} + +- (FIRExprBridge *)parseFilterExpressionWithArgs:(NSDictionary *)args error:(NSError **)error { + // Operator-based: and/or with expressions array (from PipelineFilter.and / .or) + NSString *operator= args[@"operator"]; + NSArray *exprMaps = args[@"expressions"]; + if ([operator isKindOfClass:[NSString class]] && [exprMaps isKindOfClass:[NSArray class]]) { + if (exprMaps.count == 0) { + if (error) *error = parseError(@"filter with operator requires at least one expression"); + return nil; + } + if (exprMaps.count == 1) { + id em = exprMaps[0]; + if (![em isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"filter expressions must be maps"); + return nil; + } + return [self parseBooleanExpression:(NSDictionary *)em error:error]; + } + NSMutableArray *all = [NSMutableArray array]; + for (id em in exprMaps) { + if (![em isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *e = [self parseBooleanExpression:(NSDictionary *)em error:error]; + if (!e) return nil; + [all addObject:e]; + } + if (all.count == 0) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:operator Args:all]; + } + + // Field-based: field + isEqualTo, isGreaterThan, etc. + NSString *fieldName = args[@"field"]; + if (![fieldName isKindOfClass:[NSString class]]) { + if (error) *error = parseError(@"filter requires operator+expressions or field"); + return nil; + } + FIRExprBridge *fieldExpr = [[FIRFieldBridge alloc] initWithName:fieldName]; + + static NSArray *filterComparisonKeys = nil; + static dispatch_once_t filterOnce; + dispatch_once(&filterOnce, ^{ + filterComparisonKeys = @[ + @"isEqualTo", @"isNotEqualTo", @"isGreaterThan", @"isGreaterThanOrEqualTo", @"isLessThan", + @"isLessThanOrEqualTo", @"arrayContains", @"arrayContainsAny", @"whereIn", @"whereNotIn", + @"isNull", @"isNotNull" + ]; + }); + for (NSString *key in filterComparisonKeys) { + id value = args[key]; + if (value == nil) continue; + + if ([key isEqualToString:@"isEqualTo"]) { + FIRExprBridge *right = [self rightExprFromValue:value error:error]; + if (!right) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"equal" Args:@[ fieldExpr, right ]]; + } + if ([key isEqualToString:@"isNotEqualTo"]) { + FIRExprBridge *right = [self rightExprFromValue:value error:error]; + if (!right) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"not_equal" Args:@[ fieldExpr, right ]]; + } + if ([key isEqualToString:@"isGreaterThan"]) { + FIRExprBridge *right = [self rightExprFromValue:value error:error]; + if (!right) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"greater_than" + Args:@[ fieldExpr, right ]]; + } + if ([key isEqualToString:@"isGreaterThanOrEqualTo"]) { + FIRExprBridge *right = [self rightExprFromValue:value error:error]; + if (!right) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"greater_than_or_equal" + Args:@[ fieldExpr, right ]]; + } + if ([key isEqualToString:@"isLessThan"]) { + FIRExprBridge *right = [self rightExprFromValue:value error:error]; + if (!right) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"less_than" Args:@[ fieldExpr, right ]]; + } + if ([key isEqualToString:@"isLessThanOrEqualTo"]) { + FIRExprBridge *right = [self rightExprFromValue:value error:error]; + if (!right) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"less_than_or_equal" + Args:@[ fieldExpr, right ]]; + } + if ([key isEqualToString:@"arrayContains"]) { + FIRExprBridge *right = [self rightExprFromValue:value error:error]; + if (!right) return nil; + return [[FIRFunctionExprBridge alloc] initWithName:@"array_contains" + Args:@[ fieldExpr, right ]]; + } + if ([key isEqualToString:@"arrayContainsAny"] || [key isEqualToString:@"whereIn"]) { + NSArray *valuesList = [value isKindOfClass:[NSArray class]] ? value : @[]; + NSMutableArray *valueExprs = [NSMutableArray array]; + for (id v in valuesList) { + FIRExprBridge *ve = [self rightExprFromValue:v error:error]; + if (!ve) return nil; + [valueExprs addObject:ve]; + } + if (valueExprs.count == 0) { + if (error) *error = parseError(@"arrayContainsAny/whereIn requires non-empty list"); + return nil; + } + // SDK expects (value, array) not (value, v1, v2, ...); wrap in "array" expr. + FIRExprBridge *valuesArrayExpr = [[FIRFunctionExprBridge alloc] initWithName:@"array" + Args:valueExprs]; + return [[FIRFunctionExprBridge alloc] initWithName:@"equal_any" + Args:@[ fieldExpr, valuesArrayExpr ]]; + } + if ([key isEqualToString:@"whereNotIn"]) { + NSArray *valuesList = [value isKindOfClass:[NSArray class]] ? value : @[]; + NSMutableArray *valueExprs = [NSMutableArray array]; + for (id v in valuesList) { + FIRExprBridge *ve = [self rightExprFromValue:v error:error]; + if (!ve) return nil; + [valueExprs addObject:ve]; + } + if (valueExprs.count == 0) { + if (error) *error = parseError(@"whereNotIn requires non-empty list"); + return nil; + } + // SDK expects (value, array) not (value, v1, v2, ...); wrap in "array" expr. + FIRExprBridge *valuesArrayExpr = [[FIRFunctionExprBridge alloc] initWithName:@"array" + Args:valueExprs]; + return [[FIRFunctionExprBridge alloc] initWithName:@"not_equal_any" + Args:@[ fieldExpr, valuesArrayExpr ]]; + } + if ([key isEqualToString:@"isNull"]) { + FIRExprBridge *right = [[FIRConstantBridge alloc] init:[NSNull null]]; + return [[FIRFunctionExprBridge alloc] initWithName:@"equal" Args:@[ fieldExpr, right ]]; + } + if ([key isEqualToString:@"isNotNull"]) { + FIRExprBridge *right = [[FIRConstantBridge alloc] init:[NSNull null]]; + return [[FIRFunctionExprBridge alloc] initWithName:@"not_equal" Args:@[ fieldExpr, right ]]; + } + } + + if (error) + *error = + parseError(@"filter requires at least one comparison (isEqualTo, isGreaterThan, etc.)"); + return nil; +} + +- (FIRExprBridge *)parseBooleanExpression:(NSDictionary *)map + error:(NSError **)error { + return [self parseExpression:map error:error]; +} + +@end + +@implementation FLTPipelineParser + +/// Returns the key (alias or field name) for an expression map in select/distinct stages. +/// Uses args.alias if present; otherwise for "field" expressions uses args.field. Returns nil if +/// no key can be determined (caller should error). ++ (NSString *)keyForExpressionMap:(NSDictionary *)em error:(NSError **)error { + NSString *alias = [em valueForKeyPath:@"args.alias"]; + if ([alias isKindOfClass:[NSString class]] && alias.length > 0) { + return alias; + } + if ([em[@"name"] isEqualToString:@"field"]) { + NSString *field = [em valueForKeyPath:@"args.field"]; + if ([field isKindOfClass:[NSString class]]) return field; + if (error) *error = parseError(@"field expression must have args.field"); + return nil; + } + if (error) *error = parseError(@"expression must have alias or be a field reference"); + return nil; +} + ++ (NSArray *) + parseStagesWithFirestore:(FIRFirestore *)firestore + stages:(NSArray *> *)stages + error:(NSError **)error { + FLTPipelineExpressionParser *exprParser = + [[FLTPipelineExpressionParser alloc] initWithFirestore:firestore]; + NSMutableArray *stageBridges = [NSMutableArray array]; + NSError *parseErr = nil; + + for (NSUInteger i = 0; i < stages.count; i++) { + NSDictionary *stageMap = stages[i]; + if (![stageMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"Stage must be a map"); + return nil; + } + NSString *stageName = stageMap[@"stage"]; + if (![stageName isKindOfClass:[NSString class]]) { + if (error) *error = parseError(@"Stage must have a 'stage' field"); + return nil; + } + id argsObj = stageMap[@"args"]; + NSDictionary *args = [argsObj isKindOfClass:[NSDictionary class]] ? argsObj : @{}; + NSArray *argsArray = [argsObj isKindOfClass:[NSArray class]] ? argsObj : nil; + + FIRStageBridge *stage = nil; + + if (i == 0) { + if ([stageName isEqualToString:@"collection"]) { + NSString *path = args[@"path"]; + if (!path) { + if (error) *error = parseError(@"collection requires 'path'"); + return nil; + } + FIRCollectionReference *ref = [firestore collectionWithPath:path]; + stage = [[FIRCollectionSourceStageBridge alloc] initWithRef:ref firestore:firestore]; + } else if ([stageName isEqualToString:@"collection_group"]) { + NSString *path = args[@"path"]; + if (!path) { + if (error) *error = parseError(@"collection_group requires 'path'"); + return nil; + } + stage = [[FIRCollectionGroupSourceStageBridge alloc] initWithCollectionId:path]; + } else if ([stageName isEqualToString:@"database"]) { + stage = [[FIRDatabaseSourceStageBridge alloc] init]; + } else if ([stageName isEqualToString:@"documents"]) { + NSArray *docMaps = argsArray; + if (!docMaps || docMaps.count == 0) { + if (error) *error = parseError(@"documents requires array of document refs"); + return nil; + } + NSMutableArray *refs = [NSMutableArray array]; + for (id docMap in docMaps) { + if (![docMap isKindOfClass:[NSDictionary class]]) continue; + NSString *path = ((NSDictionary *)docMap)[@"path"]; + if (path) [refs addObject:[firestore documentWithPath:path]]; + } + stage = [[FIRDocumentsSourceStageBridge alloc] initWithDocuments:refs firestore:firestore]; + } else { + if (error) + *error = parseError( + [NSString stringWithFormat:@"First stage must be collection, collection_group, " + @"documents, or database. Got: %@", + stageName]); + return nil; + } + } else { + if ([stageName isEqualToString:@"where"]) { + id exprMap = args[@"expression"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"where requires expression"); + return nil; + } + FIRExprBridge *expr = [exprParser parseBooleanExpression:exprMap error:&parseErr]; + if (!expr) { + if (error) *error = parseErr; + return nil; + } + stage = [[FIRWhereStageBridge alloc] initWithExpr:expr]; + } else if ([stageName isEqualToString:@"limit"]) { + NSNumber *limit = args[@"limit"]; + if (![limit isKindOfClass:[NSNumber class]]) { + if (error) *error = parseError(@"limit requires numeric limit"); + return nil; + } + stage = [[FIRLimitStageBridge alloc] initWithLimit:limit.intValue]; + } else if ([stageName isEqualToString:@"offset"]) { + NSNumber *offset = args[@"offset"]; + if (![offset isKindOfClass:[NSNumber class]]) { + if (error) *error = parseError(@"offset requires numeric offset"); + return nil; + } + stage = [[FIROffsetStageBridge alloc] initWithOffset:offset.intValue]; + } else if ([stageName isEqualToString:@"sort"]) { + NSArray *orderingMaps = args[@"orderings"]; + if (![orderingMaps isKindOfClass:[NSArray class]] || orderingMaps.count == 0) { + if (error) *error = parseError(@"sort requires at least one ordering"); + return nil; + } + NSMutableArray *orderings = [NSMutableArray array]; + for (id om in orderingMaps) { + if (![om isKindOfClass:[NSDictionary class]]) continue; + id exprMap = ((NSDictionary *)om)[@"expression"]; + NSString *dir = ((NSDictionary *)om)[@"order_direction"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *expr = [exprParser parseExpression:exprMap error:&parseErr]; + if (!expr) { + if (error) *error = parseErr; + return nil; + } + NSString *direction = [dir isEqualToString:@"asc"] ? @"ascending" : @"descending"; + FIROrderingBridge *ordering = [[FIROrderingBridge alloc] initWithExpr:expr + Direction:direction]; + [orderings addObject:ordering]; + } + if (orderings.count == 0) { + if (error) *error = parseError(@"sort requires at least one ordering"); + return nil; + } + stage = [[FIRSorStageBridge alloc] initWithOrderings:orderings]; + } else if ([stageName isEqualToString:@"select"]) { + NSArray *exprMaps = args[@"expressions"]; + if (![exprMaps isKindOfClass:[NSArray class]] || exprMaps.count == 0) { + if (error) *error = parseError(@"select requires at least one expression"); + return nil; + } + NSMutableDictionary *fields = [NSMutableDictionary dictionary]; + for (id em in exprMaps) { + if (![em isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *expr = [exprParser parseExpression:em error:&parseErr]; + if (!expr) { + if (error) *error = parseErr; + return nil; + } + NSString *key = [self keyForExpressionMap:em error:error]; + if (!key) return nil; + fields[key] = expr; + } + stage = [[FIRSelectStageBridge alloc] initWithSelections:fields]; + } else if ([stageName isEqualToString:@"add_fields"]) { + NSArray *exprMaps = args[@"expressions"]; + if (![exprMaps isKindOfClass:[NSArray class]] || exprMaps.count == 0) { + if (error) *error = parseError(@"add_fields requires at least one expression"); + return nil; + } + NSMutableDictionary *fields = [NSMutableDictionary dictionary]; + for (id em in exprMaps) { + if (![em isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *expr = [exprParser parseExpression:em error:&parseErr]; + if (!expr) { + if (error) *error = parseErr; + return nil; + } + NSString *alias = [em valueForKeyPath:@"args.alias"]; + if (!alias) { + if (error) *error = parseError(@"add_fields expressions must have alias"); + return nil; + } + fields[alias] = expr; + } + stage = [[FIRAddFieldsStageBridge alloc] initWithFields:fields]; + } else if ([stageName isEqualToString:@"remove_fields"]) { + NSArray *paths = args[@"field_paths"]; + if (![paths isKindOfClass:[NSArray class]] || paths.count == 0) { + if (error) *error = parseError(@"remove_fields requires field_paths"); + return nil; + } + stage = [[FIRRemoveFieldsStageBridge alloc] initWithFields:paths]; + } else if ([stageName isEqualToString:@"distinct"]) { + NSArray *exprMaps = args[@"expressions"]; + if (![exprMaps isKindOfClass:[NSArray class]] || exprMaps.count == 0) { + if (error) *error = parseError(@"distinct requires at least one expression"); + return nil; + } + NSMutableDictionary *fields = [NSMutableDictionary dictionary]; + for (id em in exprMaps) { + if (![em isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *expr = [exprParser parseExpression:em error:&parseErr]; + if (!expr) { + if (error) *error = parseErr; + return nil; + } + NSString *key = [self keyForExpressionMap:em error:error]; + if (!key) return nil; + fields[key] = expr; + } + stage = [[FIRDistinctStageBridge alloc] initWithGroups:fields]; + } else if ([stageName isEqualToString:@"replace_with"]) { + id exprMap = args[@"expression"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"replace_with requires expression"); + return nil; + } + FIRExprBridge *expr = [exprParser parseExpression:exprMap error:&parseErr]; + if (!expr) { + if (error) *error = parseErr; + return nil; + } + stage = [[FIRReplaceWithStageBridge alloc] initWithExpr:expr]; + } else if ([stageName isEqualToString:@"union"]) { + NSArray *nestedStages = args[@"pipeline"]; + if (![nestedStages isKindOfClass:[NSArray class]] || nestedStages.count == 0) { + if (error) *error = parseError(@"union requires non-empty pipeline"); + return nil; + } + id otherPipeline = [self buildPipelineWithFirestore:firestore + stages:nestedStages + error:&parseErr]; + if (!otherPipeline) { + if (error) *error = parseErr; + return nil; + } + stage = [[FIRUnionStageBridge alloc] initWithOther:otherPipeline]; + } else if ([stageName isEqualToString:@"sample"]) { + NSString *type = args[@"type"]; + id val = args[@"value"]; + if ([type isEqualToString:@"percentage"]) { + double v = [val isKindOfClass:[NSNumber class]] ? [(NSNumber *)val doubleValue] : 0; + stage = [[FIRSampleStageBridge alloc] initWithPercentage:v]; + } else { + int v = [val isKindOfClass:[NSNumber class]] ? [(NSNumber *)val intValue] : 0; + stage = [[FIRSampleStageBridge alloc] initWithCount:v]; + } + } else if ([stageName isEqualToString:@"aggregate"]) { + stage = [self parseAggregateStageWithArgs:args exprParser:exprParser error:error]; + } else if ([stageName isEqualToString:@"aggregate_with_options"]) { + stage = [self parseAggregateStageWithOptionsArgs:args exprParser:exprParser error:error]; + } else if ([stageName isEqualToString:@"unnest"]) { + id exprMap = args[@"expression"]; + if (![exprMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"unnest requires expression"); + return nil; + } + FIRExprBridge *fieldExpr = nil; + FIRExprBridge *aliasExpr = nil; + NSDictionary *exprDict = (NSDictionary *)exprMap; + NSString *aliasStr = nil; + if ([exprDict[@"name"] isEqualToString:@"alias"]) { + NSDictionary *aliasArgs = exprDict[@"args"]; + if ([aliasArgs isKindOfClass:[NSDictionary class]] && aliasArgs[@"expression"]) { + fieldExpr = [exprParser parseExpression:aliasArgs[@"expression"] error:&parseErr]; + if (!fieldExpr) { + if (error) *error = parseErr; + return nil; + } + aliasStr = + [aliasArgs[@"alias"] isKindOfClass:[NSString class]] ? aliasArgs[@"alias"] : nil; + } + } + if (!fieldExpr) { + fieldExpr = [exprParser parseExpression:exprMap error:&parseErr]; + if (!fieldExpr) { + if (error) *error = parseErr; + return nil; + } + if (!aliasStr && [exprDict[@"name"] isEqualToString:@"field"]) { + NSDictionary *fieldArgs = exprDict[@"args"]; + aliasStr = + [fieldArgs[@"field"] isKindOfClass:[NSString class]] ? fieldArgs[@"field"] : @"_"; + } + } + if (!aliasStr) aliasStr = @"_"; + aliasExpr = [[FIRFieldBridge alloc] initWithName:aliasStr]; + NSString *indexFieldStr = + [args[@"index_field"] isKindOfClass:[NSString class]] ? args[@"index_field"] : nil; + FIRExprBridge *indexFieldExpr = + (indexFieldStr.length > 0) ? [[FIRFieldBridge alloc] initWithName:indexFieldStr] : nil; + stage = [[FIRUnnestStageBridge alloc] initWithField:fieldExpr + alias:aliasExpr + indexField:indexFieldExpr]; + } else if ([stageName isEqualToString:@"find_nearest"]) { + NSString *vectorFieldName = args[@"vector_field"]; + NSArray *vectorValueArray = args[@"vector_value"]; + NSString *distanceMeasure = args[@"distance_measure"]; + NSNumber *limit = [args[@"limit"] isKindOfClass:[NSNumber class]] ? args[@"limit"] : nil; + NSString *distanceField = [args[@"distance_field"] isKindOfClass:[NSString class]] + ? args[@"distance_field"] + : nil; + if (![vectorFieldName isKindOfClass:[NSString class]] || vectorFieldName.length == 0) { + if (error) *error = parseError(@"find_nearest requires 'vector_field'"); + return nil; + } + if (![vectorValueArray isKindOfClass:[NSArray class]] || vectorValueArray.count == 0) { + if (error) *error = parseError(@"find_nearest requires non-empty 'vector_value'"); + return nil; + } + if (![distanceMeasure isKindOfClass:[NSString class]] || distanceMeasure.length == 0) { + if (error) *error = parseError(@"find_nearest requires 'distance_measure'"); + return nil; + } + FIRFieldBridge *embeddingField = [[FIRFieldBridge alloc] initWithName:vectorFieldName]; + NSMutableArray *numbers = + [NSMutableArray arrayWithCapacity:vectorValueArray.count]; + for (id v in vectorValueArray) { + if ([v isKindOfClass:[NSNumber class]]) { + [numbers addObject:(NSNumber *)v]; + } + } + if (numbers.count != (NSUInteger)vectorValueArray.count) { + if (error) *error = parseError(@"find_nearest vector_value must be an array of numbers"); + return nil; + } + FIRVectorValue *queryVector = [[FIRVectorValue alloc] initWithArray:numbers]; + stage = [[FIRFindNearestStageBridge alloc] initWithField:embeddingField + vectorValue:queryVector + distanceMeasure:distanceMeasure + limit:limit + distanceField:distanceField]; + } else { + if (error) + *error = parseError([NSString stringWithFormat:@"Unknown pipeline stage: %@", stageName]); + return nil; + } + } + + if (stage) [stageBridges addObject:stage]; + } + + if (stageBridges.count == 0) { + if (error && !*error) *error = parseError(@"No valid stages"); + return nil; + } + + return stageBridges; +} + ++ (FIRAggregateFunctionBridge *)aggregateFunctionFromMap:(NSDictionary *)funcMap + exprParser:(FLTPipelineExpressionParser *)exprParser + error:(NSError **)error { + NSString *name = funcMap[@"name"]; + if (![name isKindOfClass:[NSString class]]) { + if (error) *error = parseError(@"Aggregate function must have a 'name'"); + return nil; + } + // Map Dart aggregate function names to iOS SDK names (count_all -> count with no args; minimum -> + // min; maximum -> max) + NSString *iosName = name; + if ([name isEqualToString:@"count_all"]) { + iosName = @"count"; + } else if ([name isEqualToString:@"minimum"]) { + iosName = @"min"; + } else if ([name isEqualToString:@"maximum"]) { + iosName = @"max"; + } + NSDictionary *argsDict = funcMap[@"args"]; + NSMutableArray *argsArray = [NSMutableArray array]; + if ([argsDict isKindOfClass:[NSDictionary class]]) { + id exprMap = argsDict[@"expression"]; + if ([exprMap isKindOfClass:[NSDictionary class]]) { + FIRExprBridge *expr = [exprParser parseExpression:exprMap error:error]; + if (!expr) return nil; + [argsArray addObject:expr]; + } + } + return [[FIRAggregateFunctionBridge alloc] initWithName:iosName Args:argsArray]; +} + ++ (FIRStageBridge *)parseAggregateStageWithArgs:(NSDictionary *)args + exprParser:(FLTPipelineExpressionParser *)exprParser + error:(NSError **)error { + NSArray *accumulatorMaps = args[@"aggregate_functions"]; + if (![accumulatorMaps isKindOfClass:[NSArray class]] || accumulatorMaps.count == 0) { + if (error) *error = parseError(@"aggregate requires aggregate_functions"); + return nil; + } + return [self parseAggregateStageWithAccumulatorMaps:accumulatorMaps + groupMaps:nil + exprParser:exprParser + error:error]; +} + ++ (FIRStageBridge *)parseAggregateStageWithOptionsArgs:(NSDictionary *)args + exprParser:(FLTPipelineExpressionParser *)exprParser + error:(NSError **)error { + NSDictionary *stageMap = args[@"aggregate_stage"]; + if (![stageMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"aggregate_with_options requires aggregate_stage"); + return nil; + } + NSArray *accumulatorMaps = stageMap[@"accumulators"]; + if (![accumulatorMaps isKindOfClass:[NSArray class]] || accumulatorMaps.count == 0) { + accumulatorMaps = stageMap[@"aggregate_functions"]; + } + if (![accumulatorMaps isKindOfClass:[NSArray class]] || accumulatorMaps.count == 0) { + if (error) *error = parseError(@"aggregate_stage requires accumulators or aggregate_functions"); + return nil; + } + NSArray *groupMaps = stageMap[@"groups"]; + return [self parseAggregateStageWithAccumulatorMaps:accumulatorMaps + groupMaps:groupMaps + exprParser:exprParser + error:error]; +} + ++ (FIRStageBridge *)parseAggregateStageWithAccumulatorMaps:(NSArray *)accumulatorMaps + groupMaps:(nullable NSArray *)groupMaps + exprParser:(FLTPipelineExpressionParser *)exprParser + error:(NSError **)error { + NSError *parseErr = nil; + NSMutableDictionary *accumulators = + [NSMutableDictionary dictionary]; + for (id accMap in accumulatorMaps) { + if (![accMap isKindOfClass:[NSDictionary class]]) continue; + NSString *alias = nil; + NSDictionary *funcMap = nil; + if ([accMap[@"name"] isEqualToString:@"alias"]) { + NSDictionary *accArgs = accMap[@"args"]; + if (![accArgs isKindOfClass:[NSDictionary class]]) continue; + alias = accArgs[@"alias"]; + funcMap = accArgs[@"aggregate_function"]; + } + if (![alias isKindOfClass:[NSString class]] || ![funcMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"Each accumulator must have alias and aggregate_function"); + return nil; + } + FIRAggregateFunctionBridge *func = [self aggregateFunctionFromMap:funcMap + exprParser:exprParser + error:&parseErr]; + if (!func) { + if (error) *error = parseErr; + return nil; + } + accumulators[alias] = func; + } + if (accumulators.count == 0) { + if (error) *error = parseError(@"aggregate requires at least one valid accumulator"); + return nil; + } + + NSMutableDictionary *groups = [NSMutableDictionary dictionary]; + if ([groupMaps isKindOfClass:[NSArray class]] && groupMaps.count > 0) { + for (NSUInteger g = 0; g < groupMaps.count; g++) { + id gm = groupMaps[g]; + if (![gm isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *expr = [exprParser parseExpression:gm error:&parseErr]; + if (!expr) continue; + NSError *groupKeyError = nil; + NSString *groupKey = [self keyForExpressionMap:gm error:&groupKeyError]; + if (![groupKey isKindOfClass:[NSString class]] || groupKey.length == 0) { + if (error) + *error = + groupKeyError + ?: parseError( + @"aggregate group expression must be a field reference or have an alias"); + return nil; + } + groups[groupKey] = expr; + } + } + + return [[FIRAggregateStageBridge alloc] initWithAccumulators:accumulators groups:groups]; +} + ++ (void)executePipelineWithFirestore:(FIRFirestore *)firestore + stages:(NSArray *> *)stages + options:(nullable NSDictionary *)options + completion:(void (^)(id _Nullable snapshot, + NSError *_Nullable error))completion { + if (!stages || stages.count == 0) { + completion(nil, parseError(@"Pipeline requires at least one stage")); + return; + } + + NSError *parseErr = nil; + NSArray *stageBridges = [self parseStagesWithFirestore:firestore + stages:stages + error:&parseErr]; + if (!stageBridges) { + completion(nil, parseErr); + return; + } + + FIRPipelineBridge *pipeline = [[FIRPipelineBridge alloc] initWithStages:stageBridges + db:firestore]; + [pipeline executeWithCompletion:^(id snapshot, NSError *execError) { + if (execError) { + completion(nil, execError); + return; + } + completion(snapshot, nil); + }]; +} + ++ (id)buildPipelineWithFirestore:(FIRFirestore *)firestore + stages:(NSArray *> *)stages + error:(NSError **)error { + NSArray *stageBridges = [self parseStagesWithFirestore:firestore + stages:stages + error:error]; + if (!stageBridges) return nil; + return [[FIRPipelineBridge alloc] initWithStages:stageBridges db:firestore]; +} + +@end + +#else + +@implementation FLTPipelineParser + ++ (void)executePipelineWithFirestore:(FIRFirestore *)firestore + stages:(NSArray *> *)stages + options:(nullable NSDictionary *)options + completion:(void (^)(id _Nullable snapshot, + NSError *_Nullable error))completion { + completion(nil, pipelineUnavailableError()); +} + +@end + +#endif diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FirestoreMessages.g.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FirestoreMessages.g.m index 55ad066b2f16..891377333685 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FirestoreMessages.g.m +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FirestoreMessages.g.m @@ -168,6 +168,18 @@ + (nullable PigeonQuerySnapshot *)nullableFromList:(NSArray *)list; - (NSArray *)toList; @end +@interface PigeonPipelineResult () ++ (PigeonPipelineResult *)fromList:(NSArray *)list; ++ (nullable PigeonPipelineResult *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@interface PigeonPipelineSnapshot () ++ (PigeonPipelineSnapshot *)fromList:(NSArray *)list; ++ (nullable PigeonPipelineSnapshot *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + @interface PigeonGetOptions () + (PigeonGetOptions *)fromList:(NSArray *)list; + (nullable PigeonGetOptions *)nullableFromList:(NSArray *)list; @@ -410,6 +422,66 @@ - (NSArray *)toList { } @end +@implementation PigeonPipelineResult ++ (instancetype)makeWithDocumentPath:(nullable NSString *)documentPath + createTime:(nullable NSNumber *)createTime + updateTime:(nullable NSNumber *)updateTime + data:(nullable NSDictionary *)data { + PigeonPipelineResult *pigeonResult = [[PigeonPipelineResult alloc] init]; + pigeonResult.documentPath = documentPath; + pigeonResult.createTime = createTime; + pigeonResult.updateTime = updateTime; + pigeonResult.data = data; + return pigeonResult; +} ++ (PigeonPipelineResult *)fromList:(NSArray *)list { + PigeonPipelineResult *pigeonResult = [[PigeonPipelineResult alloc] init]; + pigeonResult.documentPath = GetNullableObjectAtIndex(list, 0); + pigeonResult.createTime = GetNullableObjectAtIndex(list, 1); + pigeonResult.updateTime = GetNullableObjectAtIndex(list, 2); + pigeonResult.data = GetNullableObjectAtIndex(list, 3); + return pigeonResult; +} ++ (nullable PigeonPipelineResult *)nullableFromList:(NSArray *)list { + return (list) ? [PigeonPipelineResult fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + (self.documentPath ?: [NSNull null]), + (self.createTime ?: [NSNull null]), + (self.updateTime ?: [NSNull null]), + (self.data ?: [NSNull null]), + ]; +} +@end + +@implementation PigeonPipelineSnapshot ++ (instancetype)makeWithResults:(NSArray *)results + executionTime:(NSNumber *)executionTime { + PigeonPipelineSnapshot *pigeonResult = [[PigeonPipelineSnapshot alloc] init]; + pigeonResult.results = results; + pigeonResult.executionTime = executionTime; + return pigeonResult; +} ++ (PigeonPipelineSnapshot *)fromList:(NSArray *)list { + PigeonPipelineSnapshot *pigeonResult = [[PigeonPipelineSnapshot alloc] init]; + pigeonResult.results = GetNullableObjectAtIndex(list, 0); + NSAssert(pigeonResult.results != nil, @""); + pigeonResult.executionTime = GetNullableObjectAtIndex(list, 1); + NSAssert(pigeonResult.executionTime != nil, @""); + return pigeonResult; +} ++ (nullable PigeonPipelineSnapshot *)nullableFromList:(NSArray *)list { + return (list) ? [PigeonPipelineSnapshot fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + (self.results ?: [NSNull null]), + (self.executionTime ?: [NSNull null]), + ]; +} +@end + @implementation PigeonGetOptions + (instancetype)makeWithSource:(Source)source serverTimestampBehavior:(ServerTimestampBehavior)serverTimestampBehavior { @@ -673,12 +745,16 @@ - (nullable id)readValueOfType:(UInt8)type { case 136: return [PigeonGetOptions fromList:[self readValue]]; case 137: - return [PigeonQueryParameters fromList:[self readValue]]; + return [PigeonPipelineResult fromList:[self readValue]]; case 138: - return [PigeonQuerySnapshot fromList:[self readValue]]; + return [PigeonPipelineSnapshot fromList:[self readValue]]; case 139: - return [PigeonSnapshotMetadata fromList:[self readValue]]; + return [PigeonQueryParameters fromList:[self readValue]]; case 140: + return [PigeonQuerySnapshot fromList:[self readValue]]; + case 141: + return [PigeonSnapshotMetadata fromList:[self readValue]]; + case 142: return [PigeonTransactionCommand fromList:[self readValue]]; default: return [super readValueOfType:type]; @@ -717,18 +793,24 @@ - (void)writeValue:(id)value { } else if ([value isKindOfClass:[PigeonGetOptions class]]) { [self writeByte:136]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[PigeonQueryParameters class]]) { + } else if ([value isKindOfClass:[PigeonPipelineResult class]]) { [self writeByte:137]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[PigeonQuerySnapshot class]]) { + } else if ([value isKindOfClass:[PigeonPipelineSnapshot class]]) { [self writeByte:138]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[PigeonSnapshotMetadata class]]) { + } else if ([value isKindOfClass:[PigeonQueryParameters class]]) { [self writeByte:139]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[PigeonTransactionCommand class]]) { + } else if ([value isKindOfClass:[PigeonQuerySnapshot class]]) { [self writeByte:140]; [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[PigeonSnapshotMetadata class]]) { + [self writeByte:141]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[PigeonTransactionCommand class]]) { + [self writeByte:142]; + [self writeValue:[value toList]]; } else { [super writeValue:value]; } @@ -1378,4 +1460,32 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, [channel setMessageHandler:nil]; } } + { + FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] + initWithName:@"dev.flutter.pigeon.cloud_firestore_platform_interface." + @"FirebaseFirestoreHostApi.executePipeline" + binaryMessenger:binaryMessenger + codec:FirebaseFirestoreHostApiGetCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(executePipelineApp:stages:options:completion:)], + @"FirebaseFirestoreHostApi api (%@) doesn't respond to " + @"@selector(executePipelineApp:stages:options:completion:)", + api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + NSArray *args = message; + FirestorePigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); + NSArray *> *arg_stages = GetNullableObjectAtIndex(args, 1); + NSDictionary *arg_options = GetNullableObjectAtIndex(args, 2); + [api executePipelineApp:arg_app + stages:arg_stages + options:arg_options + completion:^(PigeonPipelineSnapshot *_Nullable output, + FlutterError *_Nullable error) { + callback(wrapResult(output, error)); + }]; + }]; + } else { + [channel setMessageHandler:nil]; + } + } } diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTFirebaseFirestoreUtils.h b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTFirebaseFirestoreUtils.h index b6fed78e7795..e04b30b5483e 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTFirebaseFirestoreUtils.h +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTFirebaseFirestoreUtils.h @@ -18,6 +18,10 @@ #import #import "FLTFirebaseFirestoreExtension.h" +/// Error code used by the pipeline parser for parse/unsupported expression errors. +/// Handled in ErrorCodeAndMessageFromNSError to return code "parse-error" and the error message. +FOUNDATION_EXPORT const NSInteger FLTFirebaseFirestoreErrorCodePipelineParse; + typedef NS_ENUM(UInt8, FirestoreDataType) { FirestoreDataTypeDateTime = 180, FirestoreDataTypeGeoPoint = 181, diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTPipelineParser.h b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTPipelineParser.h new file mode 100644 index 000000000000..97c77f0e2a88 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTPipelineParser.h @@ -0,0 +1,23 @@ +/* + * Copyright 2026, the Chromium project authors. Please see the AUTHORS file + * for details. All rights reserved. Use of this source code is governed by a + * BSD-style license that can be found in the LICENSE file. + */ + +#import + +@class FIRFirestore; + +NS_ASSUME_NONNULL_BEGIN + +@interface FLTPipelineParser : NSObject + ++ (void)executePipelineWithFirestore:(FIRFirestore *)firestore + stages:(NSArray *> *)stages + options:(nullable NSDictionary *)options + completion: + (void (^)(id _Nullable snapshot, NSError *_Nullable error))completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Public/FirestoreMessages.g.h b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Public/FirestoreMessages.g.h index 79c560b14e79..f3955862ae04 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Public/FirestoreMessages.g.h +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Public/FirestoreMessages.g.h @@ -165,6 +165,8 @@ typedef NS_ENUM(NSUInteger, AggregateType) { @class PigeonDocumentSnapshot; @class PigeonDocumentChange; @class PigeonQuerySnapshot; +@class PigeonPipelineResult; +@class PigeonPipelineSnapshot; @class PigeonGetOptions; @class PigeonDocumentOption; @class PigeonTransactionCommand; @@ -243,6 +245,27 @@ typedef NS_ENUM(NSUInteger, AggregateType) { @property(nonatomic, strong) PigeonSnapshotMetadata *metadata; @end +@interface PigeonPipelineResult : NSObject ++ (instancetype)makeWithDocumentPath:(nullable NSString *)documentPath + createTime:(nullable NSNumber *)createTime + updateTime:(nullable NSNumber *)updateTime + data:(nullable NSDictionary *)data; +@property(nonatomic, copy, nullable) NSString *documentPath; +@property(nonatomic, strong, nullable) NSNumber *createTime; +@property(nonatomic, strong, nullable) NSNumber *updateTime; +/// All fields in the result (from PipelineResult.data() on Android). +@property(nonatomic, strong, nullable) NSDictionary *data; +@end + +@interface PigeonPipelineSnapshot : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithResults:(NSArray *)results + executionTime:(NSNumber *)executionTime; +@property(nonatomic, strong) NSArray *results; +@property(nonatomic, strong) NSNumber *executionTime; +@end + @interface PigeonGetOptions : NSObject /// `init` unavailable to enforce nonnull fields, see the `make` class method. - (instancetype)init NS_UNAVAILABLE; @@ -416,6 +439,11 @@ NSObject *FirebaseFirestoreHostApiGetCodec(void); - (void)persistenceCacheIndexManagerRequestApp:(FirestorePigeonFirebaseApp *)app request:(PersistenceCacheIndexManagerRequest)request completion:(void (^)(FlutterError *_Nullable))completion; +- (void)executePipelineApp:(FirestorePigeonFirebaseApp *)app + stages:(NSArray *> *)stages + options:(nullable NSDictionary *)options + completion:(void (^)(PigeonPipelineSnapshot *_Nullable, + FlutterError *_Nullable))completion; @end extern void FirebaseFirestoreHostApiSetup(id binaryMessenger, diff --git a/packages/cloud_firestore/cloud_firestore/ios/generated_firebase_sdk_version.txt b/packages/cloud_firestore/cloud_firestore/ios/generated_firebase_sdk_version.txt index 3eb7353bcd3e..a54ec1fce4a4 100644 --- a/packages/cloud_firestore/cloud_firestore/ios/generated_firebase_sdk_version.txt +++ b/packages/cloud_firestore/cloud_firestore/ios/generated_firebase_sdk_version.txt @@ -1 +1 @@ -12.9.0 \ No newline at end of file +12.8.0 \ No newline at end of file diff --git a/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart b/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart index 8ec159100581..69c3a95dce00 100755 --- a/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart @@ -56,7 +56,17 @@ part 'src/filters.dart'; part 'src/firestore.dart'; part 'src/load_bundle_task.dart'; part 'src/load_bundle_task_snapshot.dart'; +part 'src/pipeline_snapshot.dart'; part 'src/persistent_cache_index_manager.dart'; +part 'src/pipeline.dart'; +part 'src/pipeline_aggregate.dart'; +part 'src/pipeline_distance.dart'; +part 'src/pipeline_execute_options.dart'; +part 'src/pipeline_expression.dart'; +part 'src/pipeline_ordering.dart'; +part 'src/pipeline_sample.dart'; +part 'src/pipeline_source.dart'; +part 'src/pipeline_stage.dart'; part 'src/query.dart'; part 'src/query_document_snapshot.dart'; part 'src/query_snapshot.dart'; diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart b/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart index 3e11563bb332..35b09f3f5d3b 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart @@ -340,6 +340,26 @@ class FirebaseFirestore extends FirebasePluginPlatform { return null; } + /// Returns a [PipelineSource] for creating and executing pipelines. + /// + /// Pipelines allow you to perform complex queries and transformations on + /// Firestore data using a fluent API. + /// + /// Example: + /// ```dart + /// final snapshot = await FirebaseFirestore.instance + /// .pipeline() + /// .collection('users') + /// .where(Field('age').greaterThan(Constant(18))) + /// .sort(Field('name').ascending(), Field('age').descending()) + /// .limit(10) + /// .execute(); + /// ``` + // ignore: use_to_and_as_if_applicable + PipelineSource pipeline() { + return PipelineSource._(this); + } + /// Configures indexing for local query execution. Any previous index configuration is overridden. /// /// The index entries themselves are created asynchronously. You can continue to use queries that diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart new file mode 100644 index 000000000000..6e54e253172c --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart @@ -0,0 +1,827 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// A pipeline for querying and transforming Firestore data. +/// +/// A [Pipeline] is composed of a sequence of stages. Each stage processes the +/// output from the previous one, and the final stage's output is the result of +/// the pipeline's execution. +/// +/// Start with [FirebaseFirestore.pipeline] and a source stage (e.g. +/// [PipelineSource.collection]), then add transformation and filtering stages. +/// Call [execute] to run the pipeline. +/// +/// Example: +/// ```dart +/// final snapshot = await FirebaseFirestore.instance +/// .pipeline() +/// .collection('users') +/// .where(Expression.field('active').equal(true)) +/// .limit(10) +/// .execute(); +/// for (final result in snapshot.result) { +/// print(result.data()); +/// } +/// ``` +/// +/// **Note on execution:** The stages are conceptual. The Firestore backend may +/// optimize execution (e.g., reordering or merging stages) as long as the final +/// result remains the same. +/// +/// **Important limitations:** Pipelines operate on a request/response basis only. +/// They do not utilize or update the local SDK cache, and they do not support +/// realtime snapshot listeners. +class Pipeline { + final FirebaseFirestore _firestore; + final PipelinePlatform _delegate; + + Pipeline._(this._firestore, this._delegate) { + PipelinePlatform.verify(_delegate); + } + + /// Exposes the [stages] on the pipeline delegate. + /// + /// This should only be used for testing to ensure that all + /// pipeline stages are correctly set on the underlying delegate + /// when being tested from a different package. + @visibleForTesting + List> get stages { + return _delegate.stages; + } + + /// Executes this pipeline and returns the results as a [PipelineSnapshot]. + /// + /// Example: + /// ```dart + /// final snapshot = await firestore + /// .pipeline() + /// .collection('products') + /// .limit(5) + /// .execute(); + /// print('Got ${snapshot.result.length} documents'); + /// ``` + Future execute({ExecuteOptions? options}) async { + final optionsMap = options != null + ? { + 'indexMode': options.indexMode.name, + } + : null; + final platformSnapshot = await _delegate.execute(options: optionsMap); + return _convertPlatformSnapshot(platformSnapshot); + } + + /// Converts platform snapshot to public snapshot + PipelineSnapshot _convertPlatformSnapshot( + PipelineSnapshotPlatform platformSnapshot, + ) { + final results = platformSnapshot.results.map((platformResult) { + return PipelineResult( + document: platformResult.document != null + ? _JsonDocumentReference(_firestore, platformResult.document!) + : null, + createTime: platformResult.createTime, + updateTime: platformResult.updateTime, + data: platformResult.data, + ); + }).toList(); + + return PipelineSnapshot._(results, platformSnapshot.executionTime); + } + + // Pipeline Actions + + /// Adds new fields to outputs from previous stages. + /// + /// This stage allows you to compute values on-the-fly based on existing data + /// from previous stages or constants. You can create new fields or overwrite + /// existing ones. The added fields are defined using [Selectable]s, which can + /// be a [Field] (from [Expression.field]) or an expression with an alias via + /// [Expression.as]. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books').addFields( + /// Expression.field('rating').as('bookRating'), + /// Expression.field('title').stringReplaceAllLiteral('Item', 'Doc').as('title_replaced'), + /// Expression.field('score').abs().as('abs_score'), + /// ); + /// ``` + Pipeline addFields( + Selectable selectable1, [ + Selectable? selectable2, + Selectable? selectable3, + Selectable? selectable4, + Selectable? selectable5, + Selectable? selectable6, + Selectable? selectable7, + Selectable? selectable8, + Selectable? selectable9, + Selectable? selectable10, + Selectable? selectable11, + Selectable? selectable12, + Selectable? selectable13, + Selectable? selectable14, + Selectable? selectable15, + Selectable? selectable16, + Selectable? selectable17, + Selectable? selectable18, + Selectable? selectable19, + Selectable? selectable20, + Selectable? selectable21, + Selectable? selectable22, + Selectable? selectable23, + Selectable? selectable24, + Selectable? selectable25, + Selectable? selectable26, + Selectable? selectable27, + Selectable? selectable28, + Selectable? selectable29, + Selectable? selectable30, + ]) { + final selectables = [selectable1]; + if (selectable2 != null) selectables.add(selectable2); + if (selectable3 != null) selectables.add(selectable3); + if (selectable4 != null) selectables.add(selectable4); + if (selectable5 != null) selectables.add(selectable5); + if (selectable6 != null) selectables.add(selectable6); + if (selectable7 != null) selectables.add(selectable7); + if (selectable8 != null) selectables.add(selectable8); + if (selectable9 != null) selectables.add(selectable9); + if (selectable10 != null) selectables.add(selectable10); + if (selectable11 != null) selectables.add(selectable11); + if (selectable12 != null) selectables.add(selectable12); + if (selectable13 != null) selectables.add(selectable13); + if (selectable14 != null) selectables.add(selectable14); + if (selectable15 != null) selectables.add(selectable15); + if (selectable16 != null) selectables.add(selectable16); + if (selectable17 != null) selectables.add(selectable17); + if (selectable18 != null) selectables.add(selectable18); + if (selectable19 != null) selectables.add(selectable19); + if (selectable20 != null) selectables.add(selectable20); + if (selectable21 != null) selectables.add(selectable21); + if (selectable22 != null) selectables.add(selectable22); + if (selectable23 != null) selectables.add(selectable23); + if (selectable24 != null) selectables.add(selectable24); + if (selectable25 != null) selectables.add(selectable25); + if (selectable26 != null) selectables.add(selectable26); + if (selectable27 != null) selectables.add(selectable27); + if (selectable28 != null) selectables.add(selectable28); + if (selectable29 != null) selectables.add(selectable29); + if (selectable30 != null) selectables.add(selectable30); + final stage = _AddFieldsStage(selectables); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Performs aggregation operations on the documents from previous stages. + /// + /// This stage allows you to calculate aggregate values over a set of documents. + /// Define aggregations using [AliasedAggregateFunction]s, typically by calling + /// [PipelineAggregateFunction.as] on [PipelineAggregateFunction] instances + /// such as [Expression.field] with [Expression.sum], [Expression.average], + /// or [CountAll]. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books').aggregate( + /// Expression.field('score').sum().as('total_score'), + /// Expression.field('score').average().as('avg_score'), + /// CountAll().as('count'), + /// ); + /// ``` + Pipeline aggregate( + AliasedAggregateFunction aggregateFunction1, [ + AliasedAggregateFunction? aggregateFunction2, + AliasedAggregateFunction? aggregateFunction3, + AliasedAggregateFunction? aggregateFunction4, + AliasedAggregateFunction? aggregateFunction5, + AliasedAggregateFunction? aggregateFunction6, + AliasedAggregateFunction? aggregateFunction7, + AliasedAggregateFunction? aggregateFunction8, + AliasedAggregateFunction? aggregateFunction9, + AliasedAggregateFunction? aggregateFunction10, + AliasedAggregateFunction? aggregateFunction11, + AliasedAggregateFunction? aggregateFunction12, + AliasedAggregateFunction? aggregateFunction13, + AliasedAggregateFunction? aggregateFunction14, + AliasedAggregateFunction? aggregateFunction15, + AliasedAggregateFunction? aggregateFunction16, + AliasedAggregateFunction? aggregateFunction17, + AliasedAggregateFunction? aggregateFunction18, + AliasedAggregateFunction? aggregateFunction19, + AliasedAggregateFunction? aggregateFunction20, + AliasedAggregateFunction? aggregateFunction21, + AliasedAggregateFunction? aggregateFunction22, + AliasedAggregateFunction? aggregateFunction23, + AliasedAggregateFunction? aggregateFunction24, + AliasedAggregateFunction? aggregateFunction25, + AliasedAggregateFunction? aggregateFunction26, + AliasedAggregateFunction? aggregateFunction27, + AliasedAggregateFunction? aggregateFunction28, + AliasedAggregateFunction? aggregateFunction29, + AliasedAggregateFunction? aggregateFunction30, + ]) { + final functions = [aggregateFunction1]; + if (aggregateFunction2 != null) functions.add(aggregateFunction2); + if (aggregateFunction3 != null) functions.add(aggregateFunction3); + if (aggregateFunction4 != null) functions.add(aggregateFunction4); + if (aggregateFunction5 != null) functions.add(aggregateFunction5); + if (aggregateFunction6 != null) functions.add(aggregateFunction6); + if (aggregateFunction7 != null) functions.add(aggregateFunction7); + if (aggregateFunction8 != null) functions.add(aggregateFunction8); + if (aggregateFunction9 != null) functions.add(aggregateFunction9); + if (aggregateFunction10 != null) functions.add(aggregateFunction10); + if (aggregateFunction11 != null) functions.add(aggregateFunction11); + if (aggregateFunction12 != null) functions.add(aggregateFunction12); + if (aggregateFunction13 != null) functions.add(aggregateFunction13); + if (aggregateFunction14 != null) functions.add(aggregateFunction14); + if (aggregateFunction15 != null) functions.add(aggregateFunction15); + if (aggregateFunction16 != null) functions.add(aggregateFunction16); + if (aggregateFunction17 != null) functions.add(aggregateFunction17); + if (aggregateFunction18 != null) functions.add(aggregateFunction18); + if (aggregateFunction19 != null) functions.add(aggregateFunction19); + if (aggregateFunction20 != null) functions.add(aggregateFunction20); + if (aggregateFunction21 != null) functions.add(aggregateFunction21); + if (aggregateFunction22 != null) functions.add(aggregateFunction22); + if (aggregateFunction23 != null) functions.add(aggregateFunction23); + if (aggregateFunction24 != null) functions.add(aggregateFunction24); + if (aggregateFunction25 != null) functions.add(aggregateFunction25); + if (aggregateFunction26 != null) functions.add(aggregateFunction26); + if (aggregateFunction27 != null) functions.add(aggregateFunction27); + if (aggregateFunction28 != null) functions.add(aggregateFunction28); + if (aggregateFunction29 != null) functions.add(aggregateFunction29); + if (aggregateFunction30 != null) functions.add(aggregateFunction30); + + final stage = _AggregateStage(functions); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Performs optionally grouped aggregation operations on the documents from previous stages. + /// + /// This method allows you to calculate aggregate values over a set of documents, optionally + /// grouped by one or more fields or expressions. You can specify: + /// + /// - **Grouping Fields or Expressions**: One or more fields or functions to group the documents by. + /// For each distinct combination of values in these fields, a separate group is created. + /// If no grouping fields are provided, a single group containing all documents is used. + /// + /// - **Aggregate Functions**: One or more accumulation operations to perform within each group. + /// These are defined using [AliasedAggregateFunction] expressions, which are typically created + /// by calling `.as('alias')` on [PipelineAggregateFunction] instances. Each aggregation calculates + /// a value (e.g., sum, average, count) based on the documents within its group. + /// + /// Example: + /// ```dart + /// pipeline.aggregateWithOptions( + /// AggregateStageOptions( + /// accumulators: [ + /// Expression.field('likes').sum().as('total_likes'), + /// Expression.field('likes').average().as('avg_likes'), + /// ], + /// groups: [Expression.field('category')], + /// ), + /// ); + /// ``` + /// + /// With options: + /// ```dart + /// pipeline.aggregateWithOptions( + /// AggregateStageOptions( + /// accumulators: [ + /// Expression.field('likes').sum().as('total_likes'), + /// ], + /// ), + /// options: AggregateOptions(), + /// ); + /// ``` + Pipeline aggregateWithOptions( + AggregateStageOptions aggregateStage, { + AggregateOptions? options, + }) { + final stage = _AggregateStageWithOptions( + aggregateStage, + options ?? AggregateOptions(), + ); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Returns a set of distinct values from the inputs to this stage. + /// + /// This stage runs through the results from previous stages to include only + /// results with unique combinations of the given [Selectable] expressions + /// (e.g. [Expression.field], or expressions with [Expression.as]). + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books').distinct( + /// Expression.field('category').as('category'), + /// ); + /// ``` + Pipeline distinct( + Selectable expression1, [ + Selectable? expression2, + Selectable? expression3, + Selectable? expression4, + Selectable? expression5, + Selectable? expression6, + Selectable? expression7, + Selectable? expression8, + Selectable? expression9, + Selectable? expression10, + Selectable? expression11, + Selectable? expression12, + Selectable? expression13, + Selectable? expression14, + Selectable? expression15, + Selectable? expression16, + Selectable? expression17, + Selectable? expression18, + Selectable? expression19, + Selectable? expression20, + Selectable? expression21, + Selectable? expression22, + Selectable? expression23, + Selectable? expression24, + Selectable? expression25, + Selectable? expression26, + Selectable? expression27, + Selectable? expression28, + Selectable? expression29, + Selectable? expression30, + ]) { + final expressions = [expression1]; + if (expression2 != null) expressions.add(expression2); + if (expression3 != null) expressions.add(expression3); + if (expression4 != null) expressions.add(expression4); + if (expression5 != null) expressions.add(expression5); + if (expression6 != null) expressions.add(expression6); + if (expression7 != null) expressions.add(expression7); + if (expression8 != null) expressions.add(expression8); + if (expression9 != null) expressions.add(expression9); + if (expression10 != null) expressions.add(expression10); + if (expression11 != null) expressions.add(expression11); + if (expression12 != null) expressions.add(expression12); + if (expression13 != null) expressions.add(expression13); + if (expression14 != null) expressions.add(expression14); + if (expression15 != null) expressions.add(expression15); + if (expression16 != null) expressions.add(expression16); + if (expression17 != null) expressions.add(expression17); + if (expression18 != null) expressions.add(expression18); + if (expression19 != null) expressions.add(expression19); + if (expression20 != null) expressions.add(expression20); + if (expression21 != null) expressions.add(expression21); + if (expression22 != null) expressions.add(expression22); + if (expression23 != null) expressions.add(expression23); + if (expression24 != null) expressions.add(expression24); + if (expression25 != null) expressions.add(expression25); + if (expression26 != null) expressions.add(expression26); + if (expression27 != null) expressions.add(expression27); + if (expression28 != null) expressions.add(expression28); + if (expression29 != null) expressions.add(expression29); + if (expression30 != null) expressions.add(expression30); + + final stage = _DistinctStage(expressions); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Performs a vector similarity search. + /// + /// Orders the result set by most similar to least similar and returns + /// documents in that order. Requires a vector index on [vectorField]. + /// [vectorValue] is the query embedding; [distanceMeasure] specifies how to + /// compare vectors (e.g. [DistanceMeasure.cosine]). Use [limit] to return + /// only the first N documents. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books').findNearest( + /// Field('embedding'), + /// [0.1, 0.2, 0.3], + /// DistanceMeasure.cosine, + /// limit: 10, + /// ); + /// ``` + Pipeline findNearest( + Field vectorField, + List vectorValue, + DistanceMeasure distanceMeasure, { + int? limit, + }) { + final stage = _FindNearestStage( + vectorField, + vectorValue, + distanceMeasure, + limit: limit, + ); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Limits the maximum number of documents returned by previous stages to + /// [limit]. + /// + /// Useful for pagination (with [offset]) or to cap data retrieval and + /// improve performance. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books') + /// .sort(Expression.field('rating').descending()) + /// .limit(10); + /// ``` + Pipeline limit(int limit) { + final stage = _LimitStage(limit); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Skips the first [offset] documents from the results of previous stages. + /// + /// Useful for implementing pagination; typically used with [limit] to control + /// the size of each page. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books') + /// .sort(Expression.field('published').descending()) + /// .offset(20) + /// .limit(20); + /// ``` + Pipeline offset(int offset) { + final stage = _OffsetStage(offset); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Removes fields from outputs of previous stages. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books') + /// .removeFields('rating', 'cost'); + /// ``` + Pipeline removeFields( + String fieldPath1, [ + String? fieldPath2, + String? fieldPath3, + String? fieldPath4, + String? fieldPath5, + String? fieldPath6, + String? fieldPath7, + String? fieldPath8, + String? fieldPath9, + String? fieldPath10, + String? fieldPath11, + String? fieldPath12, + String? fieldPath13, + String? fieldPath14, + String? fieldPath15, + String? fieldPath16, + String? fieldPath17, + String? fieldPath18, + String? fieldPath19, + String? fieldPath20, + String? fieldPath21, + String? fieldPath22, + String? fieldPath23, + String? fieldPath24, + String? fieldPath25, + String? fieldPath26, + String? fieldPath27, + String? fieldPath28, + String? fieldPath29, + String? fieldPath30, + ]) { + final fieldPaths = [fieldPath1]; + if (fieldPath2 != null) fieldPaths.add(fieldPath2); + if (fieldPath3 != null) fieldPaths.add(fieldPath3); + if (fieldPath4 != null) fieldPaths.add(fieldPath4); + if (fieldPath5 != null) fieldPaths.add(fieldPath5); + if (fieldPath6 != null) fieldPaths.add(fieldPath6); + if (fieldPath7 != null) fieldPaths.add(fieldPath7); + if (fieldPath8 != null) fieldPaths.add(fieldPath8); + if (fieldPath9 != null) fieldPaths.add(fieldPath9); + if (fieldPath10 != null) fieldPaths.add(fieldPath10); + if (fieldPath11 != null) fieldPaths.add(fieldPath11); + if (fieldPath12 != null) fieldPaths.add(fieldPath12); + if (fieldPath13 != null) fieldPaths.add(fieldPath13); + if (fieldPath14 != null) fieldPaths.add(fieldPath14); + if (fieldPath15 != null) fieldPaths.add(fieldPath15); + if (fieldPath16 != null) fieldPaths.add(fieldPath16); + if (fieldPath17 != null) fieldPaths.add(fieldPath17); + if (fieldPath18 != null) fieldPaths.add(fieldPath18); + if (fieldPath19 != null) fieldPaths.add(fieldPath19); + if (fieldPath20 != null) fieldPaths.add(fieldPath20); + if (fieldPath21 != null) fieldPaths.add(fieldPath21); + if (fieldPath22 != null) fieldPaths.add(fieldPath22); + if (fieldPath23 != null) fieldPaths.add(fieldPath23); + if (fieldPath24 != null) fieldPaths.add(fieldPath24); + if (fieldPath25 != null) fieldPaths.add(fieldPath25); + if (fieldPath26 != null) fieldPaths.add(fieldPath26); + if (fieldPath27 != null) fieldPaths.add(fieldPath27); + if (fieldPath28 != null) fieldPaths.add(fieldPath28); + if (fieldPath29 != null) fieldPaths.add(fieldPath29); + if (fieldPath30 != null) fieldPaths.add(fieldPath30); + + final stage = _RemoveFieldsStage(fieldPaths); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Fully overwrites each document with the value of the given expression. + /// + /// This stage allows you to emit a nested map or array as the document: each + /// document from the previous stage is replaced by the evaluated [expression] + /// (e.g. [Expression.field] referencing a nested map or array). + /// + /// Example: + /// ```dart + /// // Emit the 'parents' map as the document. + /// firestore.pipeline().collection('people') + /// .replaceWith(Expression.field('parents')); + /// ``` + Pipeline replaceWith(Expression expression) { + final stage = _ReplaceWithStage(expression); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Performs a pseudo-random sampling of the input documents. + /// + /// Use [PipelineSample.withSize] for a fixed number of documents, or + /// [PipelineSample.withPercentage] for a fraction of the result set. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books') + /// .sample(PipelineSample.withSize(10)); + /// firestore.pipeline().collection('books') + /// .sample(PipelineSample.withPercentage(0.5)); + /// ``` + Pipeline sample(PipelineSample sample) { + final stage = _SampleStage(sample); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Selects or creates a set of fields from the outputs of previous stages. + /// + /// Only the selected fields (with their aliases) appear in the output. Use + /// [Expression.field] with [Expression.as] for expressions. Use [addFields] + /// if you only want to add or overwrite fields while keeping the rest. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books').select( + /// Expression.field('name').as('name'), + /// Expression.field('address').toUpperCase().as('upperAddress'), + /// ); + /// ``` + Pipeline select( + Selectable expression1, [ + Selectable? expression2, + Selectable? expression3, + Selectable? expression4, + Selectable? expression5, + Selectable? expression6, + Selectable? expression7, + Selectable? expression8, + Selectable? expression9, + Selectable? expression10, + Selectable? expression11, + Selectable? expression12, + Selectable? expression13, + Selectable? expression14, + Selectable? expression15, + Selectable? expression16, + Selectable? expression17, + Selectable? expression18, + Selectable? expression19, + Selectable? expression20, + Selectable? expression21, + Selectable? expression22, + Selectable? expression23, + Selectable? expression24, + Selectable? expression25, + Selectable? expression26, + Selectable? expression27, + Selectable? expression28, + Selectable? expression29, + Selectable? expression30, + ]) { + final expressions = [expression1]; + if (expression2 != null) expressions.add(expression2); + if (expression3 != null) expressions.add(expression3); + if (expression4 != null) expressions.add(expression4); + if (expression5 != null) expressions.add(expression5); + if (expression6 != null) expressions.add(expression6); + if (expression7 != null) expressions.add(expression7); + if (expression8 != null) expressions.add(expression8); + if (expression9 != null) expressions.add(expression9); + if (expression10 != null) expressions.add(expression10); + if (expression11 != null) expressions.add(expression11); + if (expression12 != null) expressions.add(expression12); + if (expression13 != null) expressions.add(expression13); + if (expression14 != null) expressions.add(expression14); + if (expression15 != null) expressions.add(expression15); + if (expression16 != null) expressions.add(expression16); + if (expression17 != null) expressions.add(expression17); + if (expression18 != null) expressions.add(expression18); + if (expression19 != null) expressions.add(expression19); + if (expression20 != null) expressions.add(expression20); + if (expression21 != null) expressions.add(expression21); + if (expression22 != null) expressions.add(expression22); + if (expression23 != null) expressions.add(expression23); + if (expression24 != null) expressions.add(expression24); + if (expression25 != null) expressions.add(expression25); + if (expression26 != null) expressions.add(expression26); + if (expression27 != null) expressions.add(expression27); + if (expression28 != null) expressions.add(expression28); + if (expression29 != null) expressions.add(expression29); + if (expression30 != null) expressions.add(expression30); + + final stage = _SelectStage(expressions); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Sorts the documents from previous stages based on one or more orderings. + /// + /// Orderings are applied in sequence (primary sort by the first, then by the + /// second, etc.). If documents have the same value for a field used for + /// sorting, the next ordering is used. Use [Expression.field] with + /// [Expression.descending] and [Expression.ascending]. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books').sort( + /// Expression.field('rating').descending(), + /// Expression.field('title').ascending(), + /// ); + /// ``` + Pipeline sort( + Ordering order, [ + Ordering? order2, + Ordering? order3, + Ordering? order4, + Ordering? order5, + Ordering? order6, + Ordering? order7, + Ordering? order8, + Ordering? order9, + Ordering? order10, + Ordering? order11, + Ordering? order12, + Ordering? order13, + Ordering? order14, + Ordering? order15, + Ordering? order16, + Ordering? order17, + Ordering? order18, + Ordering? order19, + Ordering? order20, + Ordering? order21, + Ordering? order22, + Ordering? order23, + Ordering? order24, + Ordering? order25, + Ordering? order26, + Ordering? order27, + Ordering? order28, + Ordering? order29, + Ordering? order30, + ]) { + final orderings = [order]; + if (order2 != null) orderings.add(order2); + if (order3 != null) orderings.add(order3); + if (order4 != null) orderings.add(order4); + if (order5 != null) orderings.add(order5); + if (order6 != null) orderings.add(order6); + if (order7 != null) orderings.add(order7); + if (order8 != null) orderings.add(order8); + if (order9 != null) orderings.add(order9); + if (order10 != null) orderings.add(order10); + if (order11 != null) orderings.add(order11); + if (order12 != null) orderings.add(order12); + if (order13 != null) orderings.add(order13); + if (order14 != null) orderings.add(order14); + if (order15 != null) orderings.add(order15); + if (order16 != null) orderings.add(order16); + if (order17 != null) orderings.add(order17); + if (order18 != null) orderings.add(order18); + if (order19 != null) orderings.add(order19); + if (order20 != null) orderings.add(order20); + if (order21 != null) orderings.add(order21); + if (order22 != null) orderings.add(order22); + if (order23 != null) orderings.add(order23); + if (order24 != null) orderings.add(order24); + if (order25 != null) orderings.add(order25); + if (order26 != null) orderings.add(order26); + if (order27 != null) orderings.add(order27); + if (order28 != null) orderings.add(order28); + if (order29 != null) orderings.add(order29); + if (order30 != null) orderings.add(order30); + + final stage = _SortStage(orderings); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Takes a specified array from the input documents and outputs a document + /// for each element, with the element stored in a field named by the alias. + /// + /// For each document from the previous stage, this stage emits zero or more + /// documents (one per array element). Use [Expression.field].as() to specify + /// the array and the output field name. Optionally use [indexField] to add + /// the array index to each emitted document. + /// + /// Example: + /// ```dart + /// // Input: { "title": "Guide", "tags": ["comedy", "space"] } + /// // Output: one doc per tag with field "tag". + /// firestore.pipeline().collection('books') + /// .unnest(Expression.field('tags').as('tag')); + /// ``` + Pipeline unnest(Selectable expression, [String? indexField]) { + final stage = _UnnestStage(expression, indexField); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Performs a union of all documents from this pipeline and [pipeline], + /// including duplicates. + /// + /// Documents from the previous stage and from [pipeline] are combined. The + /// order of documents emitted from this stage is undefined. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books').union( + /// firestore.pipeline().collection('magazines'), + /// ); + /// ``` + Pipeline union(Pipeline pipeline) { + final stage = _UnionStage(pipeline); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Filters the documents from previous stages to only include those matching + /// the specified [BooleanExpression]. + /// + /// This stage applies conditions to the data, similar to a WHERE clause. You + /// can use [Expression.field] with [Expression.equal], [Expression.greaterThan], + /// [Expression.lessThan], etc., and combine conditions with [Expression.and] + /// and [Expression.or]. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('books').where( + /// Expression.and( + /// Expression.field('rating').greaterThan(4.0), + /// Expression.field('genre').equal('Science Fiction'), + /// ), + /// ); + /// ``` + Pipeline where(BooleanExpression expression) { + final stage = _WhereStage(expression); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_aggregate.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_aggregate.dart new file mode 100644 index 000000000000..175195dc7f08 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_aggregate.dart @@ -0,0 +1,206 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// Base class for aggregate functions used in pipelines +abstract class PipelineAggregateFunction implements PipelineSerializable { + /// Assigns an alias to this aggregate function + AliasedAggregateFunction as(String alias) { + return AliasedAggregateFunction( + alias: alias, + aggregateFunction: this, + ); + } + + String get name; + + @override + Map toMap() { + return { + 'name': name, + }; + } +} + +/// Represents an aggregate function with an alias +class AliasedAggregateFunction implements PipelineSerializable { + final String _alias; + final PipelineAggregateFunction aggregateFunction; + + AliasedAggregateFunction({ + required String alias, + required this.aggregateFunction, + }) : _alias = alias; + + String get alias => _alias; + + @override + Map toMap() { + return { + 'name': 'alias', + 'args': { + 'alias': _alias, + 'aggregate_function': aggregateFunction.toMap(), + }, + }; + } +} + +/// Counts all documents in the pipeline result +class CountAll extends PipelineAggregateFunction { + CountAll(); + + @override + String get name => 'count_all'; +} + +/// Counts non-null values of the specified expression +class Count extends PipelineAggregateFunction { + final Expression expression; + + Count(this.expression); + + @override + String get name => 'count'; + + @override + Map toMap() { + final map = super.toMap(); + map['args'] = { + 'expression': expression.toMap(), + }; + return map; + } +} + +/// Sums numeric values of the specified expression +class Sum extends PipelineAggregateFunction { + final Expression expression; + + Sum(this.expression); + + @override + String get name => 'sum'; + + @override + Map toMap() { + final map = super.toMap(); + map['args'] = { + 'expression': expression.toMap(), + }; + return map; + } +} + +/// Calculates average of numeric values of the specified expression +class Average extends PipelineAggregateFunction { + final Expression expression; + + Average(this.expression); + + @override + String get name => 'average'; + + @override + Map toMap() { + final map = super.toMap(); + map['args'] = { + 'expression': expression.toMap(), + }; + return map; + } +} + +/// Counts distinct values of the specified expression +class CountDistinct extends PipelineAggregateFunction { + final Expression expression; + + CountDistinct(this.expression); + + @override + String get name => 'count_distinct'; + + @override + Map toMap() { + final map = super.toMap(); + map['args'] = { + 'expression': expression.toMap(), + }; + return map; + } +} + +/// Finds minimum value of the specified expression +class Minimum extends PipelineAggregateFunction { + final Expression expression; + + Minimum(this.expression); + + @override + String get name => 'minimum'; + + @override + Map toMap() { + final map = super.toMap(); + map['args'] = { + 'expression': expression.toMap(), + }; + return map; + } +} + +/// Finds maximum value of the specified expression +class Maximum extends PipelineAggregateFunction { + final Expression expression; + + Maximum(this.expression); + + @override + String get name => 'maximum'; + + @override + Map toMap() { + final map = super.toMap(); + map['args'] = { + 'expression': expression.toMap(), + }; + return map; + } +} + +/// Represents an aggregate stage with functions and optional grouping +class AggregateStageOptions implements PipelineSerializable { + final List accumulators; + final List? groups; + + AggregateStageOptions({ + required this.accumulators, + this.groups, + }); + + @override + Map toMap() { + final map = { + 'accumulators': accumulators.map((acc) => acc.toMap()).toList(), + }; + if (groups != null && groups!.isNotEmpty) { + map['groups'] = groups!.map((group) => group.toMap()).toList(); + } + return map; + } +} + +/// Options for aggregate operations +class AggregateOptions implements PipelineSerializable { + // Add any aggregate-specific options here as needed + // For now, this is a placeholder for future options + + AggregateOptions(); + + @override + Map toMap() { + return {}; + } +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_distance.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_distance.dart new file mode 100644 index 000000000000..8f01b16de52e --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_distance.dart @@ -0,0 +1,17 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// Distance measure algorithms for vector similarity search +enum DistanceMeasure { + /// Cosine similarity + cosine, + + /// Euclidean distance + euclidean, + + /// Dot product + dotProduct, +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_execute_options.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_execute_options.dart new file mode 100644 index 000000000000..ee8b8e2eb42d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_execute_options.dart @@ -0,0 +1,20 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// Index mode for pipeline execution +enum IndexMode { + /// Use recommended index mode + recommended, +} + +/// Options for executing a pipeline +class ExecuteOptions { + final IndexMode indexMode; + + const ExecuteOptions({ + this.indexMode = IndexMode.recommended, + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart new file mode 100644 index 000000000000..325685caf83b --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart @@ -0,0 +1,2820 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// Base interface for pipeline serialization +mixin PipelineSerializable { + Map toMap(); +} + +/// Helper function to convert values to Expression (wraps in Constant if needed) +Expression _toExpression(Object? value) { + if (value == null) return Constant(null); + if (value is Expression) return value; + if (value is List) return Expression.array(value.cast()); + if (value is Map) { + return Expression.map(value.cast()); + } + return Constant(value); +} + +/// Valid unit strings for timestamp add/subtract/truncate expressions. +const Set _timestampUnits = { + 'microsecond', + 'millisecond', + 'second', + 'minute', + 'hour', + 'day', +}; + +void _validateTimestampUnit(String unit) { + if (!_timestampUnits.contains(unit)) { + throw ArgumentError( + "Timestamp unit must be one of: 'microsecond', 'millisecond', 'second', " + "'minute', 'hour', 'day'. Got: '$unit'", + ); + } +} + +/// Base class for all pipeline expressions +abstract class Expression implements PipelineSerializable { + /// Creates an aliased expression + AliasedExpression as(String alias) { + return AliasedExpression( + alias: alias, + expression: this, + ); + } + + /// Creates a descending ordering for this expression + Ordering descending() { + return Ordering(this, OrderDirection.desc); + } + + /// Creates an ascending ordering for this expression + Ordering ascending() { + return Ordering(this, OrderDirection.asc); + } + + // ============================================================================ + // CONDITIONAL / LOGIC OPERATIONS + // ============================================================================ + + /// Returns an alternative expression if this expression is absent + Expression ifAbsent(Expression elseExpr) { + return _IfAbsentExpression(this, elseExpr); + } + + /// Returns an alternative value if this expression is absent + Expression ifAbsentValue(Object? elseValue) { + return _IfAbsentExpression(this, _toExpression(elseValue)); + } + + /// Returns an alternative expression if this expression errors + Expression ifError(Expression catchExpr) { + return _IfErrorExpression(this, catchExpr); + } + + /// Returns an alternative value if this expression errors + Expression ifErrorValue(Object? catchValue) { + return _IfErrorExpression(this, _toExpression(catchValue)); + } + + /// Checks if this expression is absent (null/undefined) + // ignore: use_to_and_as_if_applicable + BooleanExpression isAbsent() { + return _IsAbsentExpression(this); + } + + /// Checks if this expression produces an error + // ignore: use_to_and_as_if_applicable + BooleanExpression isError() { + return _IsErrorExpression(this); + } + + /// Checks if this field expression exists in the document + // ignore: use_to_and_as_if_applicable + BooleanExpression exists() { + return _ExistsExpression(this); + } + + // ============================================================================ + // TYPE CONVERSION + // ============================================================================ + + /// Casts this expression to a boolean expression + BooleanExpression asBoolean() { + return _AsBooleanExpression(this); + } + + // ============================================================================ + // BITWISE OPERATIONS + // ============================================================================ + + /// Performs bitwise AND with another expression + Expression bitAnd(Expression bitsOther) { + return _BitAndExpression(this, bitsOther); + } + + /// Performs bitwise AND with byte array + Expression bitAndBytes(List bitsOther) { + return _BitAndExpression(this, Constant(bitsOther)); + } + + /// Performs bitwise OR with another expression + Expression bitOr(Expression bitsOther) { + return _BitOrExpression(this, bitsOther); + } + + /// Performs bitwise OR with byte array + Expression bitOrBytes(List bitsOther) { + return _BitOrExpression(this, Constant(bitsOther)); + } + + /// Performs bitwise XOR with another expression + Expression bitXor(Expression bitsOther) { + return _BitXorExpression(this, bitsOther); + } + + /// Performs bitwise XOR with byte array + Expression bitXorBytes(List bitsOther) { + return _BitXorExpression(this, Constant(bitsOther)); + } + + /// Performs bitwise NOT on this expression + // ignore: use_to_and_as_if_applicable + Expression bitNot() { + return _BitNotExpression(this); + } + + /// Shifts bits left by an expression amount + Expression bitLeftShift(Expression numberExpr) { + return _BitLeftShiftExpression(this, numberExpr); + } + + /// Shifts bits left by a literal amount + Expression bitLeftShiftLiteral(int number) { + return _BitLeftShiftExpression(this, Constant(number)); + } + + /// Shifts bits right by an expression amount + Expression bitRightShift(Expression numberExpr) { + return _BitRightShiftExpression(this, numberExpr); + } + + /// Shifts bits right by a literal amount + Expression bitRightShiftLiteral(int number) { + return _BitRightShiftExpression(this, Constant(number)); + } + + // ============================================================================ + // DOCUMENT / PATH OPERATIONS + // ============================================================================ + + /// Returns the document ID from this path expression + // ignore: use_to_and_as_if_applicable + Expression documentId() { + return _DocumentIdExpression(this); + } + + /// Returns the collection ID from this path expression + // ignore: use_to_and_as_if_applicable + Expression collectionId() { + return _CollectionIdExpression(this); + } + + // ============================================================================ + // MAP OPERATIONS + // ============================================================================ + + /// Gets a value from this map expression by key expression + Expression mapGet(Expression key) { + return _MapGetExpression(this, key); + } + + /// Gets a value from this map expression by literal key + Expression mapGetLiteral(String key) { + return _MapGetExpression(this, Constant(key)); + } + // ============================================================================ + // ALIASING + // ============================================================================ + + /// Assigns an alias to this expression for use in output + Selectable alias(String alias) { + return AliasedExpression(alias: alias, expression: this); + } + + // ============================================================================ + // ARITHMETIC OPERATIONS + // ============================================================================ + + /// Adds this expression to another expression + Expression add(Expression other) { + return _AddExpression(this, other); + } + + /// Adds a number to this expression + Expression addNumber(num other) { + return _AddExpression(this, Constant(other)); + } + + /// Subtracts another expression from this expression + Expression subtract(Expression other) { + return _SubtractExpression(this, other); + } + + /// Subtracts a number from this expression + Expression subtractNumber(num other) { + return _SubtractExpression(this, Constant(other)); + } + + /// Multiplies this expression by another expression + Expression multiply(Expression other) { + return _MultiplyExpression(this, other); + } + + /// Multiplies this expression by a number + Expression multiplyNumber(num other) { + return _MultiplyExpression(this, Constant(other)); + } + + /// Divides this expression by another expression + Expression divide(Expression other) { + return _DivideExpression(this, other); + } + + /// Divides this expression by a number + Expression divideNumber(num other) { + return _DivideExpression(this, Constant(other)); + } + + /// Returns the remainder of dividing this expression by another + Expression modulo(Expression other) { + return _ModuloExpression(this, other); + } + + /// Returns the remainder of dividing this expression by a number + Expression moduloNumber(num other) { + return _ModuloExpression(this, Constant(other)); + } + + /// Returns the absolute value of this expression + // ignore: use_to_and_as_if_applicable + Expression abs() { + return _AbsExpression(this); + } + + // ============================================================================ + // COMPARISON OPERATIONS (return BooleanExpression) + // ============================================================================ + + /// Checks if this expression equals another expression + BooleanExpression equal(Expression other) { + return _EqualExpression(this, other); + } + + /// Checks if this expression equals a value + BooleanExpression equalValue(Object? value) { + return _EqualExpression(this, _toExpression(value)); + } + + /// Checks if this expression does not equal another expression + BooleanExpression notEqual(Expression other) { + return _NotEqualExpression(this, other); + } + + /// Checks if this expression does not equal a value + BooleanExpression notEqualValue(Object? value) { + return _NotEqualExpression(this, _toExpression(value)); + } + + /// Checks if this expression is greater than another expression + BooleanExpression greaterThan(Expression other) { + return _GreaterThanExpression(this, other); + } + + /// Checks if this expression is greater than a value + BooleanExpression greaterThanValue(Object? value) { + return _GreaterThanExpression(this, _toExpression(value)); + } + + /// Checks if this expression is greater than or equal to another expression + BooleanExpression greaterThanOrEqual(Expression other) { + return _GreaterThanOrEqualExpression(this, other); + } + + /// Checks if this expression is greater than or equal to a value + BooleanExpression greaterThanOrEqualValue(Object? value) { + return _GreaterThanOrEqualExpression(this, _toExpression(value)); + } + + /// Checks if this expression is less than another expression + BooleanExpression lessThan(Expression other) { + return _LessThanExpression(this, other); + } + + /// Checks if this expression is less than a value + BooleanExpression lessThanValue(Object? value) { + return _LessThanExpression(this, _toExpression(value)); + } + + /// Checks if this expression is less than or equal to another expression + BooleanExpression lessThanOrEqual(Expression other) { + return _LessThanOrEqualExpression(this, other); + } + + /// Checks if this expression is less than or equal to a value + BooleanExpression lessThanOrEqualValue(Object? value) { + return _LessThanOrEqualExpression(this, _toExpression(value)); + } + + // ============================================================================ + // STRING OPERATIONS + // ============================================================================ + + /// Returns the length of this string expression + // ignore: use_to_and_as_if_applicable + Expression length() { + return _LengthExpression(this); + } + + /// Concatenates this expression with other expressions/values + Expression concat(List others) { + final expressions = [this]; + for (final other in others) { + expressions.add(_toExpression(other)); + } + return _ConcatExpression(expressions); + } + + /// Converts this string expression to lowercase + Expression toLowerCase() { + return _ToLowerCaseExpression(this); + } + + /// Converts this string expression to uppercase + Expression toUpperCase() { + return _ToUpperCaseExpression(this); + } + + /// Extracts a substring from this string expression + Expression substring(Expression start, Expression end) { + return _SubstringExpression(this, start, end); + } + + /// Extracts a substring using literal indices + Expression substringLiteral(int start, int end) { + return _SubstringExpression(this, Constant(start), Constant(end)); + } + + /// Replaces all occurrences of a pattern in this string (stringReplaceAll) + Expression stringReplaceAll(Expression find, Expression replacement) { + return _StringReplaceAllExpression(this, find, replacement); + } + + /// Replaces all occurrences of a string literal + Expression stringReplaceAllLiteral(String find, String replacement) { + return _StringReplaceAllExpression( + this, + Constant(find), + Constant(replacement), + ); + } + + /// Splits this string expression by a delimiter + Expression split(Expression delimiter) { + return _SplitExpression(this, delimiter); + } + + /// Splits this string by a literal delimiter + Expression splitLiteral(String delimiter) { + return _SplitExpression(this, Constant(delimiter)); + } + + /// Joins array elements with a delimiter + Expression join(Expression delimiter) { + return _JoinExpression(this, delimiter); + } + + /// Joins array elements with a literal delimiter + Expression joinLiteral(String delimiter) { + return _JoinExpression(this, Constant(delimiter)); + } + + /// Trims whitespace from this string expression + // ignore: use_to_and_as_if_applicable + Expression trim() { + return _TrimExpression(this); + } + + // ============================================================================ + // ARRAY OPERATIONS + // ============================================================================ + + /// Concatenates this array with another array expression + Expression arrayConcat(Object? secondArray) { + return _ArrayConcatExpression(this, _toExpression(secondArray)); + } + + /// Concatenates this array with multiple arrays/values + Expression arrayConcatMultiple(List otherArrays) { + final expressions = [this]; + for (final other in otherArrays) { + expressions.add(_toExpression(other)); + } + return _ArrayConcatMultipleExpression(expressions); + } + + /// Checks if this array contains an element expression + BooleanExpression arrayContainsElement(Expression element) { + return _ArrayContainsExpression(this, element); + } + + /// Checks if this array contains a value + BooleanExpression arrayContainsValue(Object? element) { + return _ArrayContainsExpression(this, _toExpression(element)); + } + + /// Checks if this array contains any of the given values or expressions + BooleanExpression arrayContainsAny(List values) { + return _ArrayContainsAnyExpression( + this, + values.map(_toExpression).toList(), + ); + } + + /// Checks if this array contains all of the given values or expressions + BooleanExpression arrayContainsAll(List values) { + return _ArrayContainsAllValuesExpression( + this, + values.map(_toExpression).toList(), + ); + } + + /// Checks if this array contains all elements of [arrayExpression] + /// (e.g. [arrayExpression] can be [Expression.array] of fields/literals). + BooleanExpression arrayContainsAllFrom(Expression arrayExpression) { + return _ArrayContainsAllExpression(this, arrayExpression); + } + + /// Returns the length of this array expression + // ignore: use_to_and_as_if_applicable + Expression arrayLength() { + return _ArrayLengthExpression(this); + } + + /// Reverses this array expression + // ignore: use_to_and_as_if_applicable + Expression arrayReverse() { + return _ArrayReverseExpression(this); + } + + /// Returns the sum of numeric elements in this array + // ignore: use_to_and_as_if_applicable + Expression arraySum() { + return _ArraySumExpression(this); + } + + // ============================================================================ + // AGGREGATE FUNCTIONS + // ============================================================================ + + /// Creates a sum aggregation function from this expression + // ignore: use_to_and_as_if_applicable + PipelineAggregateFunction sum() { + return Sum(this); + } + + /// Creates an average aggregation function from this expression + // ignore: use_to_and_as_if_applicable + PipelineAggregateFunction average() { + return Average(this); + } + + /// Creates a count aggregation function from this expression + // ignore: use_to_and_as_if_applicable + PipelineAggregateFunction count() { + return Count(this); + } + + /// Creates a count distinct aggregation function from this expression + // ignore: use_to_and_as_if_applicable + PipelineAggregateFunction countDistinct() { + return CountDistinct(this); + } + + /// Creates a minimum aggregation function from this expression + // ignore: use_to_and_as_if_applicable + PipelineAggregateFunction minimum() { + return Minimum(this); + } + + /// Creates a maximum aggregation function from this expression + // ignore: use_to_and_as_if_applicable + PipelineAggregateFunction maximum() { + return Maximum(this); + } + + String get name; + + @override + Map toMap() { + return { + 'name': name, + }; + } + + // ============================================================================ + // STATIC FACTORY METHODS + // ============================================================================ + + /// Creates a constant expression from a string value + static Expression constantString(String value) => Constant(value); + + /// Creates a constant expression from a number value + static Expression constantNumber(num value) => Constant(value); + + /// Creates a constant expression from a boolean value + static Expression constantBoolean(bool value) => Constant(value); + + /// Creates a constant expression from a DateTime value + static Expression constantDateTime(DateTime value) => Constant(value); + + /// Creates a constant expression from a Timestamp value + static Expression constantTimestamp(Timestamp value) => Constant(value); + + /// Creates a constant expression from a GeoPoint value + static Expression constantGeoPoint(GeoPoint value) => Constant(value); + + /// Creates a constant expression from a Blob value + static Expression constantBlob(Blob value) => Constant(value); + + /// Creates a constant expression from a DocumentReference value + static Expression constantDocumentReference(DocumentReference value) => + Constant(value); + + /// Creates a constant expression from a byte array value + static Expression constantBytes(List value) => Constant(value); + + /// Creates a constant expression from a VectorValue value + static Expression constantVector(VectorValue value) => Constant(value); + + /// Creates a constant expression from any value (convenience) + /// + /// Valid types: String, num, bool, DateTime, Timestamp, GeoPoint, List (byte[]), + /// Blob, DocumentReference, VectorValue + static Expression constant(Object? value) { + if (value == null) { + return Constant(null); + } + // Validate that the value is one of the accepted types + if (value is! String && + value is! num && + value is! bool && + value is! DateTime && + value is! Timestamp && + value is! GeoPoint && + value is! List && + value is! Blob && + value is! DocumentReference && + value is! VectorValue) { + throw ArgumentError( + 'Constant value must be one of: String, num, bool, DateTime, Timestamp, ' + 'GeoPoint, List (byte[]), Blob, DocumentReference, or VectorValue. ' + 'Got: ${value.runtimeType}', + ); + } + return Constant(value); + } + + /// Creates a field reference expression from a field path string + static Field field(String fieldPath) => Field(fieldPath); + + /// Creates a field reference expression from a FieldPath object + static Field fieldPath(FieldPath fieldPath) => Field(fieldPath.toString()); + + /// Creates a null value expression + static Expression nullValue() => _NullExpression(); + + /// Creates a conditional (ternary) expression + static Expression conditional( + BooleanExpression condition, + Expression thenExpr, + Expression elseExpr, + ) { + return _ConditionalExpression(condition, thenExpr, elseExpr); + } + + /// Creates a conditional expression with literal values + static Expression conditionalValues( + BooleanExpression condition, + Object? thenValue, + Object? elseValue, + ) { + return _ConditionalExpression( + condition, + _toExpression(thenValue), + _toExpression(elseValue), + ); + } + + /// Creates an array expression from elements + static Expression array(List elements) { + return _ArrayExpression( + elements.map(_toExpression).toList(), + ); + } + + /// Creates a map expression from key-value pairs + static Expression map(Map data) { + return _MapExpression(data.map((k, v) => MapEntry(k, _toExpression(v)))); + } + + /// Returns the current timestamp + static Expression currentTimestamp() { + return _CurrentTimestampExpression(); + } + + /// Adds time to a timestamp expression. + /// + /// [unit] must be one of: `microsecond`, `millisecond`, `second`, `minute`, + /// `hour`, `day`. + static Expression timestampAdd( + Expression timestamp, + String unit, + Expression amount, + ) { + _validateTimestampUnit(unit); + return _TimestampAddExpression(timestamp, unit, amount); + } + + /// Adds time to a timestamp with a literal amount. + /// + /// [unit] must be one of: `microsecond`, `millisecond`, `second`, `minute`, + /// `hour`, `day`. + static Expression timestampAddLiteral( + Expression timestamp, + String unit, + int amount, + ) { + _validateTimestampUnit(unit); + return _TimestampAddExpression(timestamp, unit, Constant(amount)); + } + + /// Subtracts time from a timestamp expression. + /// + /// [unit] must be one of: `microsecond`, `millisecond`, `second`, `minute`, + /// `hour`, `day`. + static Expression timestampSubtract( + Expression timestamp, + String unit, + Expression amount, + ) { + _validateTimestampUnit(unit); + return _TimestampSubtractExpression(timestamp, unit, amount); + } + + /// Subtracts time from a timestamp with a literal amount. + /// + /// [unit] must be one of: `microsecond`, `millisecond`, `second`, `minute`, + /// `hour`, `day`. + static Expression timestampSubtractLiteral( + Expression timestamp, + String unit, + int amount, + ) { + _validateTimestampUnit(unit); + return _TimestampSubtractExpression(timestamp, unit, Constant(amount)); + } + + /// Truncates a timestamp to a specific unit. + /// + /// [unit] must be one of: `microsecond`, `millisecond`, `second`, `minute`, + /// `hour`, `day`. + static Expression timestampTruncate( + Expression timestamp, + String unit, + ) { + _validateTimestampUnit(unit); + return _TimestampTruncateExpression(timestamp, unit); + } + + /// Creates a document ID expression from a DocumentReference + static Expression documentIdFromRef(DocumentReference docRef) { + return _DocumentIdFromRefExpression(docRef); + } + + /// Checks if a value is in a list (IN operator) + static BooleanExpression equalAny( + Expression value, + List values, + ) { + return _EqualAnyExpression(value, values.map(_toExpression).toList()); + } + + /// Checks if a value is not in a list (NOT IN operator) + static BooleanExpression notEqualAny( + Expression value, + List values, + ) { + return _NotEqualAnyExpression(value, values.map(_toExpression).toList()); + } + + /// Checks if a field exists in the document + static BooleanExpression existsField(String fieldName) { + return _ExistsExpression(Field(fieldName)); + } + + /// Returns an expression if another is absent + static Expression ifAbsentStatic( + Expression ifExpr, + Expression elseExpr, + ) { + return _IfAbsentExpression(ifExpr, elseExpr); + } + + /// Returns a value if an expression is absent + static Expression ifAbsentValueStatic( + Expression ifExpr, + Object? elseValue, + ) { + return _IfAbsentExpression(ifExpr, _toExpression(elseValue)); + } + + /// Checks if an expression is absent + static BooleanExpression isAbsentStatic(Expression value) { + return _IsAbsentExpression(value); + } + + /// Checks if a field is absent + static BooleanExpression isAbsentField(String fieldName) { + return _IsAbsentExpression(Field(fieldName)); + } + + /// Returns an expression if another errors + static Expression ifErrorStatic( + Expression tryExpr, + Expression catchExpr, + ) { + return _IfErrorExpression(tryExpr, catchExpr); + } + + /// Checks if an expression produces an error + static BooleanExpression isErrorStatic(Expression expr) { + return _IsErrorExpression(expr); + } + + /// Negates a boolean expression + static BooleanExpression not(BooleanExpression expression) { + return _NotExpression(expression); + } + + /// Combines boolean expressions with a logical XOR + static BooleanExpression xor( + BooleanExpression expression1, [ + BooleanExpression? expression2, + BooleanExpression? expression3, + BooleanExpression? expression4, + BooleanExpression? expression5, + BooleanExpression? expression6, + BooleanExpression? expression7, + BooleanExpression? expression8, + BooleanExpression? expression9, + BooleanExpression? expression10, + BooleanExpression? expression11, + BooleanExpression? expression12, + BooleanExpression? expression13, + BooleanExpression? expression14, + BooleanExpression? expression15, + BooleanExpression? expression16, + BooleanExpression? expression17, + BooleanExpression? expression18, + BooleanExpression? expression19, + BooleanExpression? expression20, + BooleanExpression? expression21, + BooleanExpression? expression22, + BooleanExpression? expression23, + BooleanExpression? expression24, + BooleanExpression? expression25, + BooleanExpression? expression26, + BooleanExpression? expression27, + BooleanExpression? expression28, + BooleanExpression? expression29, + BooleanExpression? expression30, + ]) { + final expressions = [expression1]; + if (expression2 != null) expressions.add(expression2); + if (expression3 != null) expressions.add(expression3); + if (expression4 != null) expressions.add(expression4); + if (expression5 != null) expressions.add(expression5); + if (expression6 != null) expressions.add(expression6); + if (expression7 != null) expressions.add(expression7); + if (expression8 != null) expressions.add(expression8); + if (expression9 != null) expressions.add(expression9); + if (expression10 != null) expressions.add(expression10); + if (expression11 != null) expressions.add(expression11); + if (expression12 != null) expressions.add(expression12); + if (expression13 != null) expressions.add(expression13); + if (expression14 != null) expressions.add(expression14); + if (expression15 != null) expressions.add(expression15); + if (expression16 != null) expressions.add(expression16); + if (expression17 != null) expressions.add(expression17); + if (expression18 != null) expressions.add(expression18); + if (expression19 != null) expressions.add(expression19); + if (expression20 != null) expressions.add(expression20); + if (expression21 != null) expressions.add(expression21); + if (expression22 != null) expressions.add(expression22); + if (expression23 != null) expressions.add(expression23); + if (expression24 != null) expressions.add(expression24); + if (expression25 != null) expressions.add(expression25); + if (expression26 != null) expressions.add(expression26); + if (expression27 != null) expressions.add(expression27); + if (expression28 != null) expressions.add(expression28); + if (expression29 != null) expressions.add(expression29); + if (expression30 != null) expressions.add(expression30); + return _XorExpression(expressions); + } + + /// Combines boolean expressions with a logical AND + static BooleanExpression and( + BooleanExpression expression1, [ + BooleanExpression? expression2, + BooleanExpression? expression3, + BooleanExpression? expression4, + BooleanExpression? expression5, + BooleanExpression? expression6, + BooleanExpression? expression7, + BooleanExpression? expression8, + BooleanExpression? expression9, + BooleanExpression? expression10, + BooleanExpression? expression11, + BooleanExpression? expression12, + BooleanExpression? expression13, + BooleanExpression? expression14, + BooleanExpression? expression15, + BooleanExpression? expression16, + BooleanExpression? expression17, + BooleanExpression? expression18, + BooleanExpression? expression19, + BooleanExpression? expression20, + BooleanExpression? expression21, + BooleanExpression? expression22, + BooleanExpression? expression23, + BooleanExpression? expression24, + BooleanExpression? expression25, + BooleanExpression? expression26, + BooleanExpression? expression27, + BooleanExpression? expression28, + BooleanExpression? expression29, + BooleanExpression? expression30, + ]) { + final expressions = [expression1]; + if (expression2 != null) expressions.add(expression2); + if (expression3 != null) expressions.add(expression3); + if (expression4 != null) expressions.add(expression4); + if (expression5 != null) expressions.add(expression5); + if (expression6 != null) expressions.add(expression6); + if (expression7 != null) expressions.add(expression7); + if (expression8 != null) expressions.add(expression8); + if (expression9 != null) expressions.add(expression9); + if (expression10 != null) expressions.add(expression10); + if (expression11 != null) expressions.add(expression11); + if (expression12 != null) expressions.add(expression12); + if (expression13 != null) expressions.add(expression13); + if (expression14 != null) expressions.add(expression14); + if (expression15 != null) expressions.add(expression15); + if (expression16 != null) expressions.add(expression16); + if (expression17 != null) expressions.add(expression17); + if (expression18 != null) expressions.add(expression18); + if (expression19 != null) expressions.add(expression19); + if (expression20 != null) expressions.add(expression20); + if (expression21 != null) expressions.add(expression21); + if (expression22 != null) expressions.add(expression22); + if (expression23 != null) expressions.add(expression23); + if (expression24 != null) expressions.add(expression24); + if (expression25 != null) expressions.add(expression25); + if (expression26 != null) expressions.add(expression26); + if (expression27 != null) expressions.add(expression27); + if (expression28 != null) expressions.add(expression28); + if (expression29 != null) expressions.add(expression29); + if (expression30 != null) expressions.add(expression30); + return _AndExpression(expressions); + } + + /// Combines boolean expressions with a logical OR + static BooleanExpression or( + BooleanExpression expression1, [ + BooleanExpression? expression2, + BooleanExpression? expression3, + BooleanExpression? expression4, + BooleanExpression? expression5, + BooleanExpression? expression6, + BooleanExpression? expression7, + BooleanExpression? expression8, + BooleanExpression? expression9, + BooleanExpression? expression10, + BooleanExpression? expression11, + BooleanExpression? expression12, + BooleanExpression? expression13, + BooleanExpression? expression14, + BooleanExpression? expression15, + BooleanExpression? expression16, + BooleanExpression? expression17, + BooleanExpression? expression18, + BooleanExpression? expression19, + BooleanExpression? expression20, + BooleanExpression? expression21, + BooleanExpression? expression22, + BooleanExpression? expression23, + BooleanExpression? expression24, + BooleanExpression? expression25, + BooleanExpression? expression26, + BooleanExpression? expression27, + BooleanExpression? expression28, + BooleanExpression? expression29, + BooleanExpression? expression30, + ]) { + final expressions = [expression1]; + if (expression2 != null) expressions.add(expression2); + if (expression3 != null) expressions.add(expression3); + if (expression4 != null) expressions.add(expression4); + if (expression5 != null) expressions.add(expression5); + if (expression6 != null) expressions.add(expression6); + if (expression7 != null) expressions.add(expression7); + if (expression8 != null) expressions.add(expression8); + if (expression9 != null) expressions.add(expression9); + if (expression10 != null) expressions.add(expression10); + if (expression11 != null) expressions.add(expression11); + if (expression12 != null) expressions.add(expression12); + if (expression13 != null) expressions.add(expression13); + if (expression14 != null) expressions.add(expression14); + if (expression15 != null) expressions.add(expression15); + if (expression16 != null) expressions.add(expression16); + if (expression17 != null) expressions.add(expression17); + if (expression18 != null) expressions.add(expression18); + if (expression19 != null) expressions.add(expression19); + if (expression20 != null) expressions.add(expression20); + if (expression21 != null) expressions.add(expression21); + if (expression22 != null) expressions.add(expression22); + if (expression23 != null) expressions.add(expression23); + if (expression24 != null) expressions.add(expression24); + if (expression25 != null) expressions.add(expression25); + if (expression26 != null) expressions.add(expression26); + if (expression27 != null) expressions.add(expression27); + if (expression28 != null) expressions.add(expression28); + if (expression29 != null) expressions.add(expression29); + if (expression30 != null) expressions.add(expression30); + return _OrExpression(expressions); + } + + /// Joins array elements with a delimiter + static Expression joinStatic( + Expression arrayExpression, + Expression delimiterExpression, + ) { + return _JoinExpression(arrayExpression, delimiterExpression); + } + + /// Joins array elements with a literal delimiter + static Expression joinStaticLiteral( + Expression arrayExpression, + String delimiter, + ) { + return _JoinExpression(arrayExpression, Constant(delimiter)); + } + + /// Joins a field's array with a delimiter + static Expression joinField( + String arrayFieldName, + String delimiter, + ) { + return _JoinExpression(Field(arrayFieldName), Constant(delimiter)); + } + + /// Concatenates arrays + static Expression arrayConcatStatic( + Expression firstArray, + Expression secondArray, + List? otherArrays, + ) { + final expressions = [firstArray, secondArray]; + if (otherArrays != null) { + for (final other in otherArrays) { + expressions.add(_toExpression(other)); + } + } + return _ArrayConcatMultipleExpression(expressions); + } + + /// Returns the length of an expression + static Expression lengthStatic(Expression expr) { + return _LengthExpression(expr); + } + + /// Returns the length of a field + static Expression lengthField(String fieldName) { + return _LengthExpression(Field(fieldName)); + } + + /// Returns the absolute value of an expression + static Expression absStatic(Expression numericExpr) { + return _AbsExpression(numericExpr); + } + + /// Returns the absolute value of a field + static Expression absField(String numericField) { + return _AbsExpression(Field(numericField)); + } + + /// Adds two expressions + static Expression addStatic( + Expression first, + Expression second, + ) { + return _AddExpression(first, second); + } + + /// Adds an expression and a number + static Expression addStaticNumber( + Expression first, + num second, + ) { + return _AddExpression(first, Constant(second)); + } + + /// Adds a field and an expression + static Expression addField( + String numericFieldName, + Expression second, + ) { + return _AddExpression(Field(numericFieldName), second); + } + + /// Adds a field and a number + static Expression addFieldNumber( + String numericFieldName, + num second, + ) { + return _AddExpression(Field(numericFieldName), Constant(second)); + } + + /// Subtracts two expressions + static Expression subtractStatic( + Expression minuend, + Expression subtrahend, + ) { + return _SubtractExpression(minuend, subtrahend); + } + + /// Multiplies two expressions + static Expression multiplyStatic( + Expression multiplicand, + Expression multiplier, + ) { + return _MultiplyExpression(multiplicand, multiplier); + } + + /// Divides two expressions + static Expression divideStatic( + Expression dividend, + Expression divisor, + ) { + return _DivideExpression(dividend, divisor); + } + + /// Returns modulo of two expressions + static Expression moduloStatic( + Expression dividend, + Expression divisor, + ) { + return _ModuloExpression(dividend, divisor); + } + + /// Compares two expressions for equality + static BooleanExpression equalStatic( + Expression left, + Expression right, + ) { + return _EqualExpression(left, right); + } + + /// Compares expression with value for equality + static BooleanExpression equalStaticValue( + Expression left, + Object? right, + ) { + return _EqualExpression(left, _toExpression(right)); + } + + /// Compares field with value for equality + static BooleanExpression equalField( + String fieldName, + Object? value, + ) { + return _EqualExpression(Field(fieldName), _toExpression(value)); + } + + /// Compares two expressions for inequality + static BooleanExpression notEqualStatic( + Expression left, + Expression right, + ) { + return _NotEqualExpression(left, right); + } + + /// Compares expression with value for inequality + static BooleanExpression notEqualStaticValue( + Expression left, + Object? right, + ) { + return _NotEqualExpression(left, _toExpression(right)); + } + + /// Greater than comparison + static BooleanExpression greaterThanStatic( + Expression left, + Expression right, + ) { + return _GreaterThanExpression(left, right); + } + + /// Greater than comparison with value + static BooleanExpression greaterThanStaticValue( + Expression left, + Object? right, + ) { + return _GreaterThanExpression(left, _toExpression(right)); + } + + /// Greater than comparison for field + static BooleanExpression greaterThanField( + String fieldName, + Object? value, + ) { + return _GreaterThanExpression(Field(fieldName), _toExpression(value)); + } + + /// Greater than or equal comparison + static BooleanExpression greaterThanOrEqualStatic( + Expression left, + Expression right, + ) { + return _GreaterThanOrEqualExpression(left, right); + } + + /// Less than comparison + static BooleanExpression lessThanStatic( + Expression left, + Expression right, + ) { + return _LessThanExpression(left, right); + } + + /// Less than comparison with value + static BooleanExpression lessThanStaticValue( + Expression left, + Object? right, + ) { + return _LessThanExpression(left, _toExpression(right)); + } + + /// Less than comparison for field + static BooleanExpression lessThanField( + String fieldName, + Object? value, + ) { + return _LessThanExpression(Field(fieldName), _toExpression(value)); + } + + /// Less than or equal comparison + static BooleanExpression lessThanOrEqualStatic( + Expression left, + Expression right, + ) { + return _LessThanOrEqualExpression(left, right); + } + + /// Concatenates expressions + static Expression concatStatic( + Expression first, + Expression second, + List? others, + ) { + final expressions = [first, second]; + if (others != null) { + for (final other in others) { + expressions.add(_toExpression(other)); + } + } + return _ConcatExpression(expressions); + } + + /// Converts to lowercase + static Expression toLowerCaseStatic(Expression stringExpr) { + return _ToLowerCaseExpression(stringExpr); + } + + /// Converts field to lowercase + static Expression toLowerCaseField(String stringField) { + return _ToLowerCaseExpression(Field(stringField)); + } + + /// Converts to uppercase + static Expression toUpperCaseStatic(Expression stringExpr) { + return _ToUpperCaseExpression(stringExpr); + } + + /// Converts field to uppercase + static Expression toUpperCaseField(String stringField) { + return _ToUpperCaseExpression(Field(stringField)); + } + + /// Trims whitespace + static Expression trimStatic(Expression stringExpr) { + return _TrimExpression(stringExpr); + } + + /// Trims field whitespace + static Expression trimField(String stringField) { + return _TrimExpression(Field(stringField)); + } + + /// Extracts substring + static Expression substringStatic( + Expression stringExpr, + Expression start, + Expression end, + ) { + return _SubstringExpression(stringExpr, start, end); + } + + /// Replaces all occurrences in string (stringReplaceAll) + static Expression stringReplaceAllStatic( + Expression stringExpr, + Expression find, + Expression replacement, + ) { + return _StringReplaceAllExpression(stringExpr, find, replacement); + } + + /// Splits string + static Expression splitStatic( + Expression stringExpr, + Expression delimiter, + ) { + return _SplitExpression(stringExpr, delimiter); + } + + /// Reverses array + static Expression arrayReverseStatic(Expression array) { + return _ArrayReverseExpression(array); + } + + /// Reverses field array + static Expression arrayReverseField(String arrayFieldName) { + return _ArrayReverseExpression(Field(arrayFieldName)); + } + + /// Sums array + static Expression arraySumStatic(Expression array) { + return _ArraySumExpression(array); + } + + /// Sums field array + static Expression arraySumField(String arrayFieldName) { + return _ArraySumExpression(Field(arrayFieldName)); + } + + /// Gets array length + static Expression arrayLengthStatic(Expression array) { + return _ArrayLengthExpression(array); + } + + /// Gets field array length + static Expression arrayLengthField(String arrayFieldName) { + return _ArrayLengthExpression(Field(arrayFieldName)); + } + + /// Checks array contains + static BooleanExpression arrayContainsElementStatic( + Expression array, + Expression element, + ) { + return _ArrayContainsExpression(array, element); + } + + /// Checks field array contains + static BooleanExpression arrayContainsField( + String arrayFieldName, + Object? element, + ) { + return _ArrayContainsExpression( + Field(arrayFieldName), + _toExpression(element), + ); + } + + /// Checks if [array] contains all elements of [arrayExpression] + /// (e.g. [arrayExpression] can be [Expression.array] of fields/literals). + static BooleanExpression arrayContainsAllWithExpression( + Expression array, + Expression arrayExpression, + ) { + return _ArrayContainsAllExpression(array, arrayExpression); + } + + /// Checks if [array] contains all of the specified [values]. + static BooleanExpression arrayContainsAllValues( + Expression array, + List values, + ) { + return _ArrayContainsAllValuesExpression( + array, + values.map(_toExpression).toList(), + ); + } + + /// Checks if the array field [arrayFieldName] contains all elements of + /// [arrayExpression]. + static BooleanExpression arrayContainsAllField( + String arrayFieldName, + Expression arrayExpression, + ) { + return arrayContainsAllWithExpression( + Field(arrayFieldName), + arrayExpression, + ); + } + + /// Creates a raw/custom function expression + static Expression rawFunction( + String name, + List args, + ) { + return _RawFunctionExpression(name, args); + } +} + +/// Base class for function expressions +abstract class FunctionExpression extends Expression {} + +/// Base class for selectable expressions (can be used in select stage) +abstract class Selectable extends Expression { + String get aliasName; + Expression get expression; +} + +/// Represents an aliased expression wrapper +class AliasedExpression extends Selectable { + final String _alias; + + @override + String get aliasName => _alias; + + @override + final Expression expression; + + AliasedExpression({ + required String alias, + required this.expression, + }) : _alias = alias; + + @override + String get name => 'alias'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'alias': _alias, + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a field reference in a pipeline expression +class Field extends Selectable { + final String fieldName; + + Field(this.fieldName); + + @override + String get name => 'field'; + + @override + String get aliasName => fieldName; + + @override + Expression get expression => this; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'field': fieldName, + }, + }; + } +} + +/// Represents a null value expression +class _NullExpression extends Expression { + _NullExpression(); + + @override + String get name => 'null'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'value': null, + }, + }; + } +} + +/// Represents a constant value in a pipeline expression +/// +/// Valid types: String, num, bool, DateTime, Timestamp, GeoPoint, List (byte[]), +/// Blob, DocumentReference, VectorValue, or null +class Constant extends Expression { + final Object? value; + + Constant(this.value) { + if (value != null) { + // Validate that the value is one of the accepted types + if (value is! String && + value is! num && + value is! bool && + value is! DateTime && + value is! Timestamp && + value is! GeoPoint && + value is! List && + value is! Blob && + value is! DocumentReference && + value is! VectorValue) { + throw ArgumentError( + 'Constant value must be one of: String, num, bool, DateTime, Timestamp, ' + 'GeoPoint, List (byte[]), Blob, DocumentReference, or VectorValue. ' + 'Got: ${value.runtimeType}', + ); + } + } + } + + @override + String get name => 'constant'; + + @override + Map toMap() { + Object? serializedValue = value; + if (value is DocumentReference) { + serializedValue = {'path': (value! as DocumentReference).path}; + } + return { + 'name': name, + 'args': {'value': serializedValue}, + }; + } +} + +/// Represents a concatenation function expression +class Concat extends FunctionExpression { + final List expressions; + + Concat(this.expressions); + + @override + String get name => 'concat'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expressions': expressions.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// Represents a concat function expression (internal) +class _ConcatExpression extends FunctionExpression { + final List expressions; + + _ConcatExpression(this.expressions); + + @override + String get name => 'concat'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expressions': expressions.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// Represents a length function expression +class _LengthExpression extends FunctionExpression { + final Expression expression; + + _LengthExpression(this.expression); + + @override + String get name => 'length'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a toLowerCase function expression +class _ToLowerCaseExpression extends FunctionExpression { + final Expression expression; + + _ToLowerCaseExpression(this.expression); + + @override + String get name => 'to_lower_case'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a toUpperCase function expression +class _ToUpperCaseExpression extends FunctionExpression { + final Expression expression; + + _ToUpperCaseExpression(this.expression); + + @override + String get name => 'to_upper_case'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a substring function expression +class _SubstringExpression extends FunctionExpression { + final Expression expression; + final Expression start; + final Expression end; + + _SubstringExpression(this.expression, this.start, this.end); + + @override + String get name => 'substring'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'start': start.toMap(), + 'end': end.toMap(), + }, + }; + } +} + +/// Represents a string_replace_all function expression +class _StringReplaceAllExpression extends FunctionExpression { + final Expression expression; + final Expression find; + final Expression replacement; + + _StringReplaceAllExpression(this.expression, this.find, this.replacement); + + @override + String get name => 'string_replace_all'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'find': find.toMap(), + 'replacement': replacement.toMap(), + }, + }; + } +} + +/// Represents a split function expression +class _SplitExpression extends FunctionExpression { + final Expression expression; + final Expression delimiter; + + _SplitExpression(this.expression, this.delimiter); + + @override + String get name => 'split'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'delimiter': delimiter.toMap(), + }, + }; + } +} + +/// Represents a join function expression +class _JoinExpression extends FunctionExpression { + final Expression expression; + final Expression delimiter; + + _JoinExpression(this.expression, this.delimiter); + + @override + String get name => 'join'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'delimiter': delimiter.toMap(), + }, + }; + } +} + +/// Represents a trim function expression +class _TrimExpression extends FunctionExpression { + final Expression expression; + + _TrimExpression(this.expression); + + @override + String get name => 'trim'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Base class for boolean expressions used in filtering +abstract class BooleanExpression extends Expression {} + +// ============================================================================ +// PATTERN DEMONSTRATION - Concrete Function Expression Classes +// ============================================================================ + +/// Represents an addition function expression +class _AddExpression extends FunctionExpression { + final Expression left; + final Expression right; + + _AddExpression(this.left, this.right); + + @override + String get name => 'add'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a subtraction function expression +class _SubtractExpression extends FunctionExpression { + final Expression left; + final Expression right; + + _SubtractExpression(this.left, this.right); + + @override + String get name => 'subtract'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents an equality comparison function expression +class _EqualExpression extends BooleanExpression { + final Expression left; + final Expression right; + + _EqualExpression(this.left, this.right); + + @override + String get name => 'equal'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a greater-than comparison function expression +class _GreaterThanExpression extends BooleanExpression { + final Expression left; + final Expression right; + + _GreaterThanExpression(this.left, this.right); + + @override + String get name => 'greater_than'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a multiply function expression +class _MultiplyExpression extends FunctionExpression { + final Expression left; + final Expression right; + + _MultiplyExpression(this.left, this.right); + + @override + String get name => 'multiply'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a divide function expression +class _DivideExpression extends FunctionExpression { + final Expression left; + final Expression right; + + _DivideExpression(this.left, this.right); + + @override + String get name => 'divide'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a modulo function expression +class _ModuloExpression extends FunctionExpression { + final Expression left; + final Expression right; + + _ModuloExpression(this.left, this.right); + + @override + String get name => 'modulo'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents an absolute value function expression +class _AbsExpression extends FunctionExpression { + final Expression expression; + + _AbsExpression(this.expression); + + @override + String get name => 'abs'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a negation function expression +/// Represents a not-equal comparison function expression +class _NotEqualExpression extends BooleanExpression { + final Expression left; + final Expression right; + + _NotEqualExpression(this.left, this.right); + + @override + String get name => 'not_equal'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a greater-than-or-equal comparison function expression +class _GreaterThanOrEqualExpression extends BooleanExpression { + final Expression left; + final Expression right; + + _GreaterThanOrEqualExpression(this.left, this.right); + + @override + String get name => 'greater_than_or_equal'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a less-than comparison function expression +class _LessThanExpression extends BooleanExpression { + final Expression left; + final Expression right; + + _LessThanExpression(this.left, this.right); + + @override + String get name => 'less_than'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a less-than-or-equal comparison function expression +class _LessThanOrEqualExpression extends BooleanExpression { + final Expression left; + final Expression right; + + _LessThanOrEqualExpression(this.left, this.right); + + @override + String get name => 'less_than_or_equal'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +// ============================================================================ +// ARRAY OPERATION EXPRESSION CLASSES +// ============================================================================ + +/// Represents an array concat function expression +class _ArrayConcatExpression extends FunctionExpression { + final Expression firstArray; + final Expression secondArray; + + _ArrayConcatExpression(this.firstArray, this.secondArray); + + @override + String get name => 'array_concat'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'first': firstArray.toMap(), + 'second': secondArray.toMap(), + }, + }; + } +} + +/// Represents an array concat multiple function expression +class _ArrayConcatMultipleExpression extends FunctionExpression { + final List arrays; + + _ArrayConcatMultipleExpression(this.arrays); + + @override + String get name => 'array_concat_multiple'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'arrays': arrays.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// Represents an array contains function expression +class _ArrayContainsExpression extends BooleanExpression { + final Expression array; + final Expression element; + + _ArrayContainsExpression(this.array, this.element); + + @override + String get name => 'array_contains'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'array': array.toMap(), + 'element': element.toMap(), + }, + }; + } +} + +/// Represents an arrayContainsAny function expression +class _ArrayContainsAnyExpression extends BooleanExpression { + final Expression array; + final List values; + + _ArrayContainsAnyExpression(this.array, this.values); + + @override + String get name => 'array_contains_any'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'array': array.toMap(), + 'values': values.map((v) => v.toMap()).toList(), + }, + }; + } +} + +/// Represents an arrayContainsAll function expression (array + list of values) +class _ArrayContainsAllValuesExpression extends BooleanExpression { + final Expression array; + final List values; + + _ArrayContainsAllValuesExpression(this.array, this.values); + + @override + String get name => 'array_contains_all'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'array': array.toMap(), + 'values': values.map((v) => v.toMap()).toList(), + }, + }; + } +} + +/// Represents an arrayContainsAll function expression (array + array expression) +class _ArrayContainsAllExpression extends BooleanExpression { + final Expression array; + final Expression arrayExpression; + + _ArrayContainsAllExpression(this.array, this.arrayExpression); + + @override + String get name => 'array_contains_all'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'array': array.toMap(), + 'array_expression': arrayExpression.toMap(), + }, + }; + } +} + +/// Represents an array length function expression +class _ArrayLengthExpression extends FunctionExpression { + final Expression expression; + + _ArrayLengthExpression(this.expression); + + @override + String get name => 'array_length'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents an array reverse function expression +class _ArrayReverseExpression extends FunctionExpression { + final Expression expression; + + _ArrayReverseExpression(this.expression); + + @override + String get name => 'array_reverse'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents an array sum function expression +class _ArraySumExpression extends FunctionExpression { + final Expression expression; + + _ArraySumExpression(this.expression); + + @override + String get name => 'array_sum'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +// ============================================================================ +// CONDITIONAL / LOGIC OPERATION EXPRESSION CLASSES +// ============================================================================ + +/// Represents an ifAbsent function expression +class _IfAbsentExpression extends FunctionExpression { + final Expression expression; + final Expression elseExpr; + + _IfAbsentExpression(this.expression, this.elseExpr); + + @override + String get name => 'if_absent'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'else': elseExpr.toMap(), + }, + }; + } +} + +/// Represents an ifError function expression +class _IfErrorExpression extends Expression { + final Expression expression; + final Expression catchExpr; + + _IfErrorExpression(this.expression, this.catchExpr); + + @override + String get name => 'if_error'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'catch': catchExpr.toMap(), + }, + }; + } +} + +/// Represents an isAbsent function expression +class _IsAbsentExpression extends BooleanExpression { + final Expression expression; + + _IsAbsentExpression(this.expression); + + @override + String get name => 'is_absent'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents an isError function expression +class _IsErrorExpression extends BooleanExpression { + final Expression expression; + + _IsErrorExpression(this.expression); + + @override + String get name => 'is_error'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents an exists function expression +class _ExistsExpression extends BooleanExpression { + final Expression expression; + + _ExistsExpression(this.expression); + + @override + String get name => 'exists'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a not (negation) function expression +class _NotExpression extends BooleanExpression { + final BooleanExpression expression; + + _NotExpression(this.expression); + + @override + String get name => 'not'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +class _XorExpression extends BooleanExpression { + final List expressions; + + _XorExpression(this.expressions); + + @override + String get name => 'xor'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expressions': expressions.map((e) => e.toMap()).toList(), + }, + }; + } +} + +class _AndExpression extends BooleanExpression { + final List expressions; + + _AndExpression(this.expressions); + + @override + String get name => 'and'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expressions': expressions.map((e) => e.toMap()).toList(), + }, + }; + } +} + +class _OrExpression extends BooleanExpression { + final List expressions; + + _OrExpression(this.expressions); + + @override + String get name => 'or'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expressions': expressions.map((e) => e.toMap()).toList(), + }, + }; + } +} + +/// Represents a conditional (ternary) function expression +class _ConditionalExpression extends FunctionExpression { + final BooleanExpression condition; + final Expression thenExpr; + final Expression elseExpr; + + _ConditionalExpression(this.condition, this.thenExpr, this.elseExpr); + + @override + String get name => 'conditional'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'condition': condition.toMap(), + 'then': thenExpr.toMap(), + 'else': elseExpr.toMap(), + }, + }; + } +} + +// ============================================================================ +// TYPE CONVERSION EXPRESSION CLASSES +// ============================================================================ + +/// Represents an asBoolean function expression +class _AsBooleanExpression extends BooleanExpression { + final Expression expression; + + _AsBooleanExpression(this.expression); + + @override + String get name => 'as_boolean'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +// ============================================================================ +// BITWISE OPERATION EXPRESSION CLASSES +// ============================================================================ + +/// Represents a bitAnd function expression +class _BitAndExpression extends FunctionExpression { + final Expression left; + final Expression right; + + _BitAndExpression(this.left, this.right); + + @override + String get name => 'bit_and'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a bitOr function expression +class _BitOrExpression extends FunctionExpression { + final Expression left; + final Expression right; + + _BitOrExpression(this.left, this.right); + + @override + String get name => 'bit_or'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a bitXor function expression +class _BitXorExpression extends FunctionExpression { + final Expression left; + final Expression right; + + _BitXorExpression(this.left, this.right); + + @override + String get name => 'bit_xor'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'left': left.toMap(), + 'right': right.toMap(), + }, + }; + } +} + +/// Represents a bitNot function expression +class _BitNotExpression extends FunctionExpression { + final Expression expression; + + _BitNotExpression(this.expression); + + @override + String get name => 'bit_not'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a bitLeftShift function expression +class _BitLeftShiftExpression extends FunctionExpression { + final Expression expression; + final Expression amount; + + _BitLeftShiftExpression(this.expression, this.amount); + + @override + String get name => 'bit_left_shift'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'amount': amount.toMap(), + }, + }; + } +} + +/// Represents a bitRightShift function expression +class _BitRightShiftExpression extends FunctionExpression { + final Expression expression; + final Expression amount; + + _BitRightShiftExpression(this.expression, this.amount); + + @override + String get name => 'bit_right_shift'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'amount': amount.toMap(), + }, + }; + } +} + +// ============================================================================ +// DOCUMENT / PATH OPERATION EXPRESSION CLASSES +// ============================================================================ + +/// Represents a documentId function expression +class _DocumentIdExpression extends FunctionExpression { + final Expression expression; + + _DocumentIdExpression(this.expression); + + @override + String get name => 'document_id'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a collectionId function expression +class _CollectionIdExpression extends FunctionExpression { + final Expression expression; + + _CollectionIdExpression(this.expression); + + @override + String get name => 'collection_id'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a documentIdFromRef function expression +class _DocumentIdFromRefExpression extends FunctionExpression { + final DocumentReference docRef; + + _DocumentIdFromRefExpression(this.docRef); + + @override + String get name => 'document_id_from_ref'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'doc_ref': docRef.path, + }, + }; + } +} + +// ============================================================================ +// MAP OPERATION EXPRESSION CLASSES +// ============================================================================ + +/// Represents a mapGet function expression +class _MapGetExpression extends FunctionExpression { + final Expression map; + final Expression key; + + _MapGetExpression(this.map, this.key); + + @override + String get name => 'map_get'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'map': map.toMap(), + 'key': key.toMap(), + }, + }; + } +} + +// ============================================================================ +// TIMESTAMP OPERATION EXPRESSION CLASSES +// ============================================================================ + +/// Represents a currentTimestamp function expression +class _CurrentTimestampExpression extends FunctionExpression { + _CurrentTimestampExpression(); + + @override + String get name => 'current_timestamp'; + + @override + Map toMap() { + return { + 'name': name, + }; + } +} + +/// Represents a timestamp_add function expression. +/// Unit must be one of: microsecond, millisecond, second, minute, hour, day. +class _TimestampAddExpression extends FunctionExpression { + final Expression timestamp; + final String unit; + final Expression amount; + + _TimestampAddExpression(this.timestamp, this.unit, this.amount); + + @override + String get name => 'timestamp_add'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'timestamp': timestamp.toMap(), + 'unit': unit, + 'amount': amount.toMap(), + }, + }; + } +} + +/// Represents a timestamp_subtract function expression. +/// Unit must be one of: microsecond, millisecond, second, minute, hour, day. +class _TimestampSubtractExpression extends FunctionExpression { + final Expression timestamp; + final String unit; + final Expression amount; + + _TimestampSubtractExpression(this.timestamp, this.unit, this.amount); + + @override + String get name => 'timestamp_subtract'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'timestamp': timestamp.toMap(), + 'unit': unit, + 'amount': amount.toMap(), + }, + }; + } +} + +/// Represents a timestamp_truncate function expression. +/// Unit must be one of: microsecond, millisecond, second, minute, hour, day. +class _TimestampTruncateExpression extends FunctionExpression { + final Expression timestamp; + final String unit; + + _TimestampTruncateExpression(this.timestamp, this.unit); + + @override + String get name => 'timestamp_truncate'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'timestamp': timestamp.toMap(), + 'unit': unit, + }, + }; + } +} + +// ============================================================================ +// SPECIAL OPERATION EXPRESSION CLASSES +// ============================================================================ + +/// Represents an equalAny (IN) function expression +class _EqualAnyExpression extends BooleanExpression { + final Expression value; + final List values; + + _EqualAnyExpression(this.value, this.values); + + @override + String get name => 'equal_any'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'value': value.toMap(), + 'values': values.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// Represents a notEqualAny (NOT IN) function expression +class _NotEqualAnyExpression extends BooleanExpression { + final Expression value; + final List values; + + _NotEqualAnyExpression(this.value, this.values); + + @override + String get name => 'not_equal_any'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'value': value.toMap(), + 'values': values.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// Represents an array expression +class _ArrayExpression extends FunctionExpression { + final List elements; + + _ArrayExpression(this.elements); + + @override + String get name => 'array'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'elements': elements.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// Represents a map expression +class _MapExpression extends FunctionExpression { + final Map data; + + _MapExpression(this.data); + + @override + String get name => 'map'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'data': data.map((k, v) => MapEntry(k, v.toMap())), + }, + }; + } +} + +/// Represents a raw function expression +class _RawFunctionExpression extends FunctionExpression { + final String functionName; + final List args; + + _RawFunctionExpression(this.functionName, this.args); + + @override + String get name => functionName; + + @override + Map toMap() { + return { + 'name': name, + 'args': args.map((expr) => expr.toMap()).toList(), + }; + } +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_ordering.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_ordering.dart new file mode 100644 index 000000000000..b55687fa5d8b --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_ordering.dart @@ -0,0 +1,30 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// Direction for ordering results +enum OrderDirection { + /// Ascending order + asc, + + /// Descending order + desc, +} + +/// Represents an ordering specification for pipeline sorting +class Ordering implements PipelineSerializable { + final Expression expression; + final OrderDirection direction; + + Ordering(this.expression, this.direction); + + @override + Map toMap() { + return { + 'expression': expression.toMap(), + 'order_direction': direction == OrderDirection.asc ? 'asc' : 'desc', + }; + } +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_sample.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_sample.dart new file mode 100644 index 000000000000..4136c9922872 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_sample.dart @@ -0,0 +1,43 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// Base class for pipeline sampling strategies +abstract class PipelineSample implements PipelineSerializable { + const PipelineSample(); + + /// Creates a sample with a fixed size + factory PipelineSample.withSize(int size) = _PipelineSampleSize; + + /// Creates a sample with a percentage + factory PipelineSample.withPercentage(double percentage) = + _PipelineSamplePercentage; +} + +/// Sample stage with a fixed size +class _PipelineSampleSize extends PipelineSample { + final int size; + + const _PipelineSampleSize(this.size); + + @override + Map toMap() => { + 'type': 'size', + 'value': size, + }; +} + +/// Sample stage with a percentage +class _PipelineSamplePercentage extends PipelineSample { + final double percentage; + + const _PipelineSamplePercentage(this.percentage); + + @override + Map toMap() => { + 'type': 'percentage', + 'value': percentage, + }; +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_snapshot.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_snapshot.dart new file mode 100644 index 000000000000..1a078d31945e --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_snapshot.dart @@ -0,0 +1,41 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// Represents the results of a Pipeline query, including the data and metadata. +class PipelineResult { + /// The document reference, or null if no document was returned. + final DocumentReference>? document; + + /// The time the document was created. + final DateTime? createTime; + + /// The time the document was last updated (at the time the snapshot was generated). + final DateTime? updateTime; + + /// Retrieves all fields in the result as a map. + final Map? _data; + + PipelineResult({ + this.document, + this.createTime, + this.updateTime, + Map? data, + }) : _data = data; + + /// Retrieves all fields in the result as a map. + Map? data() => _data; +} + +/// A [PipelineSnapshot] contains the results of a pipeline execution. It can be iterated to retrieve the individual [PipelineResult] objects. +class PipelineSnapshot { + /// List of all the results + final List result; + + /// The time at which the pipeline producing this result is executed. + final DateTime executionTime; + + PipelineSnapshot._(this.result, this.executionTime); +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_source.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_source.dart new file mode 100644 index 000000000000..69d4be991a5d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_source.dart @@ -0,0 +1,67 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// Provides methods to create pipelines from different sources +class PipelineSource { + final FirebaseFirestore _firestore; + + PipelineSource._(this._firestore); + + /// Creates a pipeline from a collection path + Pipeline collection(String collectionPath) { + if (collectionPath.isEmpty) { + throw ArgumentError('A collection path must be a non-empty string.'); + } else if (collectionPath.contains('//')) { + throw ArgumentError('A collection path must not contain "//".'); + } + + final stage = _CollectionPipelineStage(collectionPath); + final delegate = _firestore._delegate.pipeline([stage.toMap()]); + return Pipeline._(_firestore, delegate); + } + + /// Creates a pipeline from a collection reference + Pipeline collectionReference( + CollectionReference> collectionReference, + ) { + final stage = _CollectionPipelineStage(collectionReference.path); + final delegate = _firestore._delegate.pipeline([stage.toMap()]); + return Pipeline._(_firestore, delegate); + } + + /// Creates a pipeline from a collection group + Pipeline collectionGroup(String collectionId) { + if (collectionId.isEmpty) { + throw ArgumentError('A collection ID must be a non-empty string.'); + } else if (collectionId.contains('/')) { + throw ArgumentError( + 'A collection ID passed to collectionGroup() cannot contain "/".', + ); + } + + final stage = _CollectionGroupPipelineStage(collectionId); + final delegate = _firestore._delegate.pipeline([stage.toMap()]); + return Pipeline._(_firestore, delegate); + } + + /// Creates a pipeline from a list of document references + Pipeline documents(List>> documents) { + if (documents.isEmpty) { + throw ArgumentError('Documents list must not be empty.'); + } + + final stage = _DocumentsPipelineStage(documents); + final delegate = _firestore._delegate.pipeline([stage.toMap()]); + return Pipeline._(_firestore, delegate); + } + + /// Creates a pipeline from the entire database + Pipeline database() { + final stage = _DatabasePipelineStage(); + final delegate = _firestore._delegate.pipeline([stage.toMap()]); + return Pipeline._(_firestore, delegate); + } +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart new file mode 100644 index 000000000000..abc1555f8854 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart @@ -0,0 +1,419 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of '../cloud_firestore.dart'; + +/// Base sealed class for all pipeline stages +sealed class PipelineStage implements PipelineSerializable { + String get name; +} + +/// Stage representing a collection source +final class _CollectionPipelineStage extends PipelineStage { + final String collectionPath; + + _CollectionPipelineStage(this.collectionPath); + + @override + String get name => 'collection'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'path': collectionPath, + }, + }; + } +} + +/// Stage representing a documents source +final class _DocumentsPipelineStage extends PipelineStage { + final List>> documents; + + _DocumentsPipelineStage(this.documents); + + @override + String get name => 'documents'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': documents + .map( + (doc) => { + 'path': doc.path, + }, + ) + .toList(), + }; + } +} + +/// Stage representing a database source +final class _DatabasePipelineStage extends PipelineStage { + _DatabasePipelineStage(); + + @override + String get name => 'database'; + + @override + Map toMap() { + return { + 'stage': name, + }; + } +} + +/// Stage representing a collection group source +final class _CollectionGroupPipelineStage extends PipelineStage { + final String collectionPath; + + _CollectionGroupPipelineStage(this.collectionPath); + + @override + String get name => 'collection_group'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'path': collectionPath, + }, + }; + } +} + +/// Stage for adding fields to documents +final class _AddFieldsStage extends PipelineStage { + final List expressions; + + _AddFieldsStage(this.expressions); + + @override + String get name => 'add_fields'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'expressions': expressions.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// Stage for aggregating data +final class _AggregateStage extends PipelineStage { + final List aggregateFunctions; + + _AggregateStage(this.aggregateFunctions); + + @override + String get name => 'aggregate'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'aggregate_functions': + aggregateFunctions.map((func) => func.toMap()).toList(), + }, + }; + } +} + +/// Stage for aggregating data with options and grouping +final class _AggregateStageWithOptions extends PipelineStage { + final AggregateStageOptions aggregateStage; + final AggregateOptions? options; + + _AggregateStageWithOptions(this.aggregateStage, this.options); + + @override + String get name => 'aggregate_with_options'; + + @override + Map toMap() { + final map = aggregateStage.toMap(); + final optionsMap = options?.toMap(); + return { + 'stage': name, + 'args': { + 'aggregate_stage': map, + 'options': optionsMap, + }, + }; + } +} + +/// Stage for getting distinct values +final class _DistinctStage extends PipelineStage { + final List expressions; + + _DistinctStage(this.expressions); + + @override + String get name => 'distinct'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'expressions': expressions.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// Stage for finding nearest vectors +final class _FindNearestStage extends PipelineStage { + final Field vectorField; + final List vectorValue; + final DistanceMeasure distanceMeasure; + final int? limit; + + _FindNearestStage( + this.vectorField, + this.vectorValue, + this.distanceMeasure, { + this.limit, + }); + + @override + String get name => 'find_nearest'; + + @override + Map toMap() { + final map = { + 'stage': name, + 'args': { + 'vector_field': vectorField.fieldName, + 'vector_value': vectorValue, + 'distance_measure': distanceMeasure.name, + }, + }; + if (limit != null) { + map['args']['limit'] = limit; + } + return map; + } +} + +/// Stage for limiting results +final class _LimitStage extends PipelineStage { + final int limit; + + _LimitStage(this.limit); + + @override + String get name => 'limit'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'limit': limit, + }, + }; + } +} + +/// Stage for offsetting results +final class _OffsetStage extends PipelineStage { + final int offset; + + _OffsetStage(this.offset); + + @override + String get name => 'offset'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'offset': offset, + }, + }; + } +} + +/// Stage for removing fields +final class _RemoveFieldsStage extends PipelineStage { + final List fieldPaths; + + _RemoveFieldsStage(this.fieldPaths); + + @override + String get name => 'remove_fields'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'field_paths': fieldPaths, + }, + }; + } +} + +/// Stage for replacing documents +final class _ReplaceWithStage extends PipelineStage { + final Expression expression; + + _ReplaceWithStage(this.expression); + + @override + String get name => 'replace_with'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Stage for sampling documents +final class _SampleStage extends PipelineStage { + final PipelineSample sample; + + _SampleStage(this.sample); + + @override + String get name => 'sample'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': sample.toMap(), + }; + } +} + +/// Stage for selecting specific fields +final class _SelectStage extends PipelineStage { + final List expressions; + + _SelectStage(this.expressions); + + @override + String get name => 'select'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'expressions': expressions.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// Stage for sorting results +final class _SortStage extends PipelineStage { + final List orderings; + + _SortStage(this.orderings); + + @override + String get name => 'sort'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'orderings': orderings + .map( + (o) => { + 'expression': o.expression.toMap(), + 'order_direction': + o.direction == OrderDirection.asc ? 'asc' : 'desc', + }, + ) + .toList(), + }, + }; + } +} + +/// Stage for unnesting arrays +final class _UnnestStage extends PipelineStage { + final Selectable expression; + final String? indexField; + + _UnnestStage(this.expression, this.indexField); + + @override + String get name => 'unnest'; + + @override + Map toMap() { + final map = { + 'stage': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + if (indexField != null) { + map['args']['index_field'] = indexField; + } + return map; + } +} + +/// Stage for union with another pipeline +final class _UnionStage extends PipelineStage { + final Pipeline pipeline; + + _UnionStage(this.pipeline); + + @override + String get name => 'union'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'pipeline': pipeline.stages, + }, + }; + } +} + +/// Stage for filtering documents +final class _WhereStage extends PipelineStage { + final BooleanExpression expression; + + _WhereStage(this.expression); + + @override + String get name => 'where'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} diff --git a/packages/cloud_firestore/cloud_firestore/macos/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m b/packages/cloud_firestore/cloud_firestore/macos/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m new file mode 120000 index 000000000000..0ab2d722c4a0 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/macos/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m @@ -0,0 +1 @@ +../../../../ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m \ No newline at end of file diff --git a/packages/cloud_firestore/cloud_firestore/macos/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTPipelineParser.h b/packages/cloud_firestore/cloud_firestore/macos/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTPipelineParser.h new file mode 120000 index 000000000000..fb086ff6b78c --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/macos/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTPipelineParser.h @@ -0,0 +1 @@ +../../../../../../../ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Private/FLTPipelineParser.h \ No newline at end of file diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/.gitignore b/packages/cloud_firestore/cloud_firestore/pipeline_example/.gitignore new file mode 100644 index 000000000000..3820a95c65c3 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/.metadata b/packages/cloud_firestore/cloud_firestore/pipeline_example/.metadata new file mode 100644 index 000000000000..2f3a59454284 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + - platform: android + create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + - platform: ios + create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + - platform: linux + create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + - platform: macos + create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + - platform: web + create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + - platform: windows + create_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + base_revision: a0e9b9dbf78c8a5ef39b45a7efd40ed2de19c1a7 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/README.md b/packages/cloud_firestore/cloud_firestore/pipeline_example/README.md new file mode 100644 index 000000000000..115008c73129 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/README.md @@ -0,0 +1,16 @@ +# pipeline_example + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/analysis_options.yaml b/packages/cloud_firestore/cloud_firestore/pipeline_example/analysis_options.yaml new file mode 100644 index 000000000000..2f61fa3242f7 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/analysis_options.yaml @@ -0,0 +1,34 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + # Pipeline e2e tests uses firebase_options.dart injected in CI; exclude integration tests + # from analyze so analyze-ci passes. + exclude: + - integration_test/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/.gitignore b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/.gitignore new file mode 100644 index 000000000000..be3943c96d8e --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/build.gradle.kts b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/build.gradle.kts new file mode 100644 index 000000000000..9300930bc97a --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // END: FlutterFire Configuration + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.pipeline_example" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.pipeline_example" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/debug/AndroidManifest.xml b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..399f6981d5d3 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/AndroidManifest.xml b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..8a1e2a2fd055 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/kotlin/com/example/pipeline_example/MainActivity.kt b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/kotlin/com/example/pipeline_example/MainActivity.kt new file mode 100644 index 000000000000..205920783386 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/kotlin/com/example/pipeline_example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.pipeline_example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 000000000000..f74085f3f6a2 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/drawable/launch_background.xml b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000000..304732f88420 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000000..db77bb4b7b09 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000000..17987b79bb8a Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000000..09d4391482be Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000000..d5f1c8d34e7a Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000000..4d6372eebdb2 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/values-night/styles.xml b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000000..06952be745f9 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/values/styles.xml b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000000..cb1ef88056ed --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/profile/AndroidManifest.xml b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 000000000000..399f6981d5d3 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/build.gradle.kts b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/build.gradle.kts new file mode 100644 index 000000000000..dbee657bb5b9 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/gradle.properties b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/gradle.properties new file mode 100644 index 000000000000..fbee1d8cdafc --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/gradle/wrapper/gradle-wrapper.properties b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..e4ef43fb98df --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/android/settings.gradle.kts b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/settings.gradle.kts new file mode 100644 index 000000000000..174f408284fe --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/android/settings.gradle.kts @@ -0,0 +1,29 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_add_fields_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_add_fields_e2e.dart new file mode 100644 index 000000000000..323e30163e35 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_add_fields_e2e.dart @@ -0,0 +1,42 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:firebase_core/firebase_core.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineAddFieldsTests() { + group('Pipeline addFields', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test( + 'addFields with expression returns expected transformed data', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('add-fields')) + .sort(Expression.field('title').ascending()) + .addFields(Expression.field('score').abs().as('abs_score')) + .limit(3) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'title': 'alpha', 'score': -7, 'abs_score': 7}, + {'title': 'beta', 'score': 42, 'abs_score': 42}, + {'title': 'gamma', 'score': 0, 'abs_score': 0}, + ]); + }, + ); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_aggregate_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_aggregate_e2e.dart new file mode 100644 index 000000000000..3eb9121638a7 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_aggregate_e2e.dart @@ -0,0 +1,70 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineAggregateTests() { + group('Pipeline aggregate', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test('aggregate count and sum returns expected single result', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('aggregate')) + .aggregate( + CountAll().as('total'), + Expression.field('score').sum().as('total_score'), + ) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'total': 4, 'total_score': 100}, + ]); + }); + + test( + 'aggregateWithOptions with groups returns one row per group', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('aggregate')) + .aggregateWithOptions( + AggregateStageOptions( + accumulators: [ + Expression.field('score').sum().as('total_score'), + CountAll().as('count'), + ], + groups: [Expression.field('category')], + ), + ) + .execute(); + expectResultCount(snapshot, 2); + final results = snapshot.result.map((r) => r.data()!).toList(); + results.sort( + (a, b) => + (a['category'] as String).compareTo(b['category'] as String), + ); + expect(results[0]['category'], 'x'); + expect(results[0]['total_score'], 30); + expect(results[0]['count'], 2); + expect(results[1]['category'], 'y'); + expect(results[1]['total_score'], 70); + expect(results[1]['count'], 2); + }, + ); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_execute_options_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_execute_options_e2e.dart new file mode 100644 index 000000000000..4968b3aba36a --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_execute_options_e2e.dart @@ -0,0 +1,37 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineExecuteOptionsTests() { + group('Pipeline execute with options', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test('execute with ExecuteOptions returns expected results', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('add-fields')) + .sort(Expression.field('title').ascending()) + .limit(2) + .execute( + options: const ExecuteOptions(indexMode: IndexMode.recommended), + ); + expectResultCount(snapshot, 2); + expect(snapshot.result[0].data()!['title'], 'alpha'); + expect(snapshot.result[1].data()!['title'], 'beta'); + }, skip: true); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_expressions_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_expressions_e2e.dart new file mode 100644 index 000000000000..40bd7f41a3fe --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_expressions_e2e.dart @@ -0,0 +1,862 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, TargetPlatform, kIsWeb; +import 'package:flutter_test/flutter_test.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineExpressionsTests() { + group('Pipeline expressions in where and addFields', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + /// Single doc: `test==expressions`, `score==50` (see pipeline seed). + Future expressionsDocScore50(Selectable field) { + return firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(50)) + .addFields(field) + .limit(1) + .execute(); + } + + test('where with greaterThan filters and returns expected docs', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').greaterThan(Expression.constant(50))) + .sort(Expression.field('score').ascending()) + .limit(5) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'score': 60, 'a': 1, 'b': 2}, + {'score': 70, 'a': 10, 'b': 20}, + {'score': 80, 'a': 0, 'b': 100}, + ]); + }); + + test('addFields with add expression returns expected values', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .sort(Expression.field('score').ascending()) + .addFields( + Expression.field('a').add(Expression.field('b')).as('sum_ab'), + ) + .limit(5) + .execute(); + expectResultCount(snapshot, 5); + expectResultsData(snapshot, [ + {'score': 40, 'a': 5, 'b': 5, 'sum_ab': 10}, + {'score': 50, 'a': 1, 'b': 2, 'sum_ab': 3}, + {'score': 60, 'a': 1, 'b': 2, 'sum_ab': 3}, + {'score': 70, 'a': 10, 'b': 20, 'sum_ab': 30}, + {'score': 80, 'a': 0, 'b': 100, 'sum_ab': 100}, + ]); + }); + + test('addFields with conditional returns expected band', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .sort(Expression.field('score').ascending()) + .addFields( + Expression.conditional( + Expression.field('score').greaterThan(Expression.constant(50)), + Expression.constant('high'), + Expression.constant('low'), + ).as('band'), + ) + .limit(5) + .execute(); + expectResultCount(snapshot, 5); + expectResultsData(snapshot, [ + {'score': 40, 'band': 'low'}, + {'score': 50, 'band': 'low'}, + {'score': 60, 'band': 'high'}, + {'score': 70, 'band': 'high'}, + {'score': 80, 'band': 'high'}, + ]); + }); + + test('addFields with arrayLength returns length for array field', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .sort(Expression.field('score').ascending()) + .addFields(Expression.field('tags').arrayLength().as('tags_len')) + .limit(5) + .execute(); + expectResultCount(snapshot, 5); + final withTags = snapshot.result + .where((r) => r.data()!['tags_len'] == 2) + .toList(); + expect(withTags.length, 1); + expect(withTags.first.data()!['score'], 50); + }); + + test('where with lessThan filters correctly', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').lessThan(Expression.constant(60))) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 2); + expectResultsData(snapshot, [ + {'score': 40, 'a': 5, 'b': 5}, + {'score': 50, 'a': 1, 'b': 2}, + ]); + }); + + test('where with equalValue filters correctly', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(50)) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'score': 50, 'a': 1, 'b': 2}, + ]); + }); + + test('where with equal(Expression) filters correctly', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equal(Expression.constant(50))) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'score': 50, 'a': 1, 'b': 2}, + ]); + }); + + test('where with notEqualValue filters correctly', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').notEqualValue(50)) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 4); + expectResultsData(snapshot, [ + {'score': 40, 'a': 5, 'b': 5}, + {'score': 60, 'a': 1, 'b': 2}, + {'score': 70, 'a': 10, 'b': 20}, + {'score': 80, 'a': 0, 'b': 100}, + ]); + }); + + test('where with notEqual(Expression) filters correctly', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').notEqual(Expression.constant(50))) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 4); + expectResultsData(snapshot, [ + {'score': 40, 'a': 5, 'b': 5}, + {'score': 60, 'a': 1, 'b': 2}, + {'score': 70, 'a': 10, 'b': 20}, + {'score': 80, 'a': 0, 'b': 100}, + ]); + }); + + test( + 'where with greaterThanValue and lessThanValue filter correctly', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').greaterThanValue(40)) + .where(Expression.field('score').lessThanValue(80)) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'score': 50, 'a': 1, 'b': 2}, + {'score': 60, 'a': 1, 'b': 2}, + {'score': 70, 'a': 10, 'b': 20}, + ]); + }, + ); + + test( + 'where with greaterThanOrEqual and lessThanOrEqual filter correctly', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where( + Expression.field( + 'score', + ).greaterThanOrEqual(Expression.constant(50)), + ) + .where( + Expression.field( + 'score', + ).lessThanOrEqual(Expression.constant(70)), + ) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'score': 50, 'a': 1, 'b': 2}, + {'score': 60, 'a': 1, 'b': 2}, + {'score': 70, 'a': 10, 'b': 20}, + ]); + }, + ); + + test( + 'addFields with subtract, multiply, divide, modulo return expected values', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .sort(Expression.field('score').ascending()) + .addFields( + Expression.field('a').subtract(Expression.field('b')).as('diff'), + Expression.field( + 'a', + ).multiply(Expression.field('b')).as('product'), + Expression.field( + 'score', + ).divide(Expression.constant(10)).as('score_div_10'), + Expression.field( + 'score', + ).modulo(Expression.constant(30)).as('score_mod_30'), + ) + .limit(3) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + { + 'score': 40, + 'a': 5, + 'b': 5, + 'diff': 0, + 'product': 25, + 'score_div_10': 4, + 'score_mod_30': 10, + }, + { + 'score': 50, + 'a': 1, + 'b': 2, + 'diff': -1, + 'product': 2, + 'score_div_10': 5, + 'score_mod_30': 20, + }, + { + 'score': 60, + 'a': 1, + 'b': 2, + 'diff': -1, + 'product': 2, + 'score_div_10': 6, + 'score_mod_30': 0, + }, + ]); + }, + ); + + test('where with and returns intersection', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where( + Expression.and( + Expression.field('score').greaterThan(Expression.constant(40)), + Expression.field('score').lessThan(Expression.constant(80)), + ), + ) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'score': 50, 'a': 1, 'b': 2}, + {'score': 60, 'a': 1, 'b': 2}, + {'score': 70, 'a': 10, 'b': 20}, + ]); + }); + + test('where with or returns union', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where( + Expression.or( + Expression.field('score').equalValue(40), + Expression.field('score').equalValue(80), + ), + ) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 2); + expectResultsData(snapshot, [ + {'score': 40, 'a': 5, 'b': 5}, + {'score': 80, 'a': 0, 'b': 100}, + ]); + }); + + test('where with not inverts condition', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where( + Expression.not( + Expression.field( + 'score', + ).greaterThanOrEqual(Expression.constant(60)), + ), + ) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 2); + expectResultsData(snapshot, [ + {'score': 40, 'a': 5, 'b': 5}, + {'score': 50, 'a': 1, 'b': 2}, + ]); + }); + + test( + 'addFields with ifAbsentValue uses default when field missing', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .sort(Expression.field('score').ascending()) + .addFields( + Expression.field( + 'tags', + ).ifAbsentValue('default_value').as('tags_or_empty'), + ) + .limit(2) + .execute(); + expectResultCount(snapshot, 2); + expect(snapshot.result[0].data()!['tags_or_empty'], 'default_value'); + expect(snapshot.result[1].data()!['tags_or_empty'], ['p', 'q']); + }, + ); + + test( + 'addFields with ifAbsent(Expression) uses else expression when field missing', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .sort(Expression.field('score').ascending()) + .addFields( + Expression.field('tags') + .ifAbsent(Expression.constant('default_value')) + .as('tags_or_default'), + ) + .limit(2) + .execute(); + expectResultCount(snapshot, 2); + expect(snapshot.result[0].data()!['tags_or_default'], 'default_value'); + expect(snapshot.result[1].data()!['tags_or_default'], ['p', 'q']); + }, + ); + + test( + 'where arrayContainsValue filters docs with array containing value', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('tags').arrayContainsValue('p')) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + { + 'score': 50, + 'a': 1, + 'b': 2, + 'tags': ['p', 'q'], + }, + ]); + }, + ); + + test( + 'where arrayContainsElement(Expression) filters docs with array containing element', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where( + Expression.field( + 'tags', + ).arrayContainsElement(Expression.constant('q')), + ) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + { + 'score': 50, + 'a': 1, + 'b': 2, + 'tags': ['p', 'q'], + }, + ]); + }, + ); + + test( + 'addFields with string expressions (concat, length, toLower, toUpper, trim)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('s').concat(['!']).as('s_concat'), + Expression.field('s').length().as('s_len'), + Expression.field('s').toLowerCase().as('s_lower'), + Expression.field('s').toUpperCase().as('s_upper'), + Expression.field('s').trim().as('s_trim'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + { + 'score': 60, + 's_concat': ' AbC !', + 's_len': 7, + 's_lower': ' abc ', + 's_upper': ' ABC ', + 's_trim': 'AbC', + }, + ]); + }, + ); + + test('addFields with substring returns expected slice', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(70)) + .addFields( + Expression.field('s') + .substring(Expression.constant(0), Expression.constant(1)) + .as('s_first'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['s_first'], 'x'); + }); + + test('addFields with map_get returns nested value', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('m').mapGet(Expression.constant('x')).as('m_x'), + ) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['m_x'], 10); + }); + + test('where with exists filters docs where tags exists', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('tags').exists()) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['score'], 50); + }); + + test( + 'where with xor returns docs matching exactly one condition', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where( + Expression.xor( + Expression.field('score').equalValue(40), + Expression.field('score').equalValue(50), + ), + ) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(snapshot, 2); + expectResultsData(snapshot, [ + {'score': 40}, + {'score': 50}, + ]); + }, + ); + + test('where with equalAny and notEqualAny filters correctly', () async { + final inSnapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.equalAny(Expression.field('score'), [40, 70])) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(inSnapshot, 2); + expectResultsData(inSnapshot, [ + {'score': 40}, + {'score': 70}, + ]); + + final notInSnapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.notEqualAny(Expression.field('score'), [40, 70])) + .sort(Expression.field('score').ascending()) + .execute(); + expectResultCount(notInSnapshot, 3); + expectResultsData(notInSnapshot, [ + {'score': 50}, + {'score': 60}, + {'score': 80}, + ]); + }); + + test('addFields split', () async { + final snapshot = await expressionsDocScore50( + Expression.field('s').splitLiteral('-').as('s_split'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['s_split'], ['a', 'b', 'c']); + }); + + test('addFields join', () async { + final snapshot = await expressionsDocScore50( + Expression.field('tags').joinLiteral('|').as('tags_joined'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['tags_joined'], 'p|q'); + }); + + test('addFields arrayConcat', () async { + final snapshot = await expressionsDocScore50( + Expression.field('arr').arrayConcat([9]).as('arr_concat'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['arr_concat'], [2, 4, 6, 9]); + }); + + test('addFields arrayConcatMultiple', () async { + final snapshot = await expressionsDocScore50( + Expression.field('arr') + .arrayConcatMultiple([ + [10], + [11], + ]) + .as('arr_concat_multi'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['arr_concat_multi'], [2, 4, 6, 10, 11]); + }); + + test('addFields arrayContainsAny', () async { + final snapshot = await expressionsDocScore50( + Expression.field('arr').arrayContainsAny([2, 99]).as('arr_has_any'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['arr_has_any'], true); + }); + + test('addFields arrayContainsAll values', () async { + final snapshot = await expressionsDocScore50( + Expression.field( + 'arr', + ).arrayContainsAll([2, 4]).as('arr_has_all_values'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['arr_has_all_values'], true); + }); + + test('addFields arrayContainsAllFrom expression', () async { + final snapshot = await expressionsDocScore50( + Expression.field('arr') + .arrayContainsAllFrom(Expression.array([Expression.constant(2), 4])) + .as('arr_has_all_expr'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['arr_has_all_expr'], true); + }); + + test('addFields map constructor', () async { + final snapshot = await expressionsDocScore50( + Expression.map({ + 'left': Expression.field('a'), + 'right': Expression.field('b'), + }).as('mapped'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['mapped'], {'left': 1, 'right': 2}); + }); + + test('addFields nullValue', () async { + final snapshot = await expressionsDocScore50( + Expression.nullValue().as('explicit_null'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['explicit_null'], null); + }); + + test('addFields ifError uses catch when expression errors', () async { + final snapshot = await expressionsDocScore50( + Expression.field( + 'score', + ).divide(Expression.constant(0)).ifErrorValue('safe').as('safe_div'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['safe_div'], 'safe'); + }); + + test('addFields isAbsent true for missing field', () async { + final snapshot = await expressionsDocScore50( + Expression.field('missing_field').isAbsent().as('missing_absent'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['missing_absent'], true); + }); + + test('addFields isError false for missing field', () async { + final snapshot = await expressionsDocScore50( + Expression.field('missing_field').isError().as('missing_error'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['missing_error'], false); + }); + + test('addFields asBoolean coerces numeric field', () async { + final snapshot = await expressionsDocScore50( + Expression.field('a').asBoolean().as('a_bool'), + ); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['a_bool'], 1); + }); + + test( + 'addFields bitwise expressions (bitAnd/Or/Xor/Not/shifts)', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field( + 'bit_a', + ).bitAnd(Expression.constant(3)).as('b_and'), + Expression.field( + 'bit_a', + ).bitOr(Expression.constant(1)).as('b_or'), + Expression.field( + 'bit_a', + ).bitXor(Expression.constant(7)).as('b_xor'), + Expression.field('bit_a').bitNot().as('b_not'), + Expression.field('bit_a').bitLeftShiftLiteral(1).as('b_lsh'), + Expression.field('bit_a').bitRightShiftLiteral(1).as('b_rsh'), + ) + .limit(1) + .execute(); + + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'b_and': 2, 'b_or': 7, 'b_xor': 1, 'b_lsh': 12, 'b_rsh': 3}, + ]); + expect(snapshot.result[0].data()!['b_not'], isNotNull); + }, + // Bitwise pipeline addFields not supported on Android native SDK yet. + skip: true, + ); + + test('addFields documentId, collectionId and documentIdFromRef', () async { + final col = firestore.collection('pipeline-e2e'); + final seedQuery = await col + .where('test', isEqualTo: 'expressions') + .where('score', isEqualTo: 60) + .limit(1) + .get(); + expect(seedQuery.docs, isNotEmpty); + final docRef = seedQuery.docs.first.reference; + final expectedDocId = seedQuery.docs.first.id; + + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.field('__name__').documentId().as('doc_id'), + Expression.field('__name__').collectionId().as('coll_id'), + Expression.documentIdFromRef(docRef).as('doc_id_from_ref'), + ) + .limit(1) + .execute(); + + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + { + 'coll_id': 'pipeline-e2e', + 'doc_id': expectedDocId, + 'doc_id_from_ref': expectedDocId, + }, + ]); + }, skip: kIsWeb); + + test( + 'addFields with currentTimestamp, timestampAdd/Subtract/Truncate', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .addFields( + Expression.currentTimestamp().as('now'), + Expression.timestampAddLiteral( + Expression.field('ts'), + 'day', + 1, + ).as('ts_plus_1d'), + Expression.timestampSubtractLiteral( + Expression.field('ts'), + 'hour', + 1, + ).as('ts_minus_1h'), + Expression.timestampTruncate( + Expression.field('ts'), + 'day', + ).as('ts_day'), + ) + .limit(1) + .execute(); + + expectResultCount(snapshot, 1); + final data = snapshot.result[0].data()!; + expect(data['now'], isNotNull); + expect(data['ts_plus_1d'], isNotNull); + expect(data['ts_minus_1h'], isNotNull); + expect(data['ts_day'], isNotNull); + }, + skip: kIsWeb, + ); + + test('select with alias() returns renamed field', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(60)) + .select(Expression.field('score').alias('renamed_score')) + .limit(1) + .execute(); + + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['renamed_score'], 60); + }); + + test('addFields with array_reverse returns reversed array', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .where(Expression.field('score').equalValue(50)) + .addFields(Expression.field('tags').arrayReverse().as('tags_rev')) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['tags_rev'], ['q', 'p']); + }); + + test( + 'arraySum addFields succeeds on Android', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .addFields(Expression.array([1, 2, 3]).arraySum().as('x')) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expect(snapshot.result[0].data()!['x'], 6); + }, + skip: defaultTargetPlatform != TargetPlatform.android, + ); + + test( + 'unsupported expression returns parse-error with informative message', + () async { + try { + await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('expressions')) + .addFields(Expression.array([1, 2, 3]).arraySum().as('x')) + .limit(1) + .execute(); + return; + } on FirebaseException catch (e) { + expect(e.code, 'parse-error'); + expect(e.message, isNotNull); + expect(e.message!, contains('Unsupported expression')); + } + }, + skip: + defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.macOS, + ); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart new file mode 100644 index 000000000000..95e52f52e5b4 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_filter_sort_e2e.dart @@ -0,0 +1,89 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:firebase_core/firebase_core.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineFilterSortTests() { + group('Pipeline where, sort, limit, offset, distinct', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test('where + limit returns expected count and data', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('filter-sort')) + .where(Expression.field('active').equalValue(true)) + .sort(Expression.field('score').ascending()) + .limit(5) + .execute(); + expectResultCount(snapshot, 4); + expectResultsData(snapshot, [ + {'active': true, 'score': 10, 'category': 'a'}, + {'active': true, 'score': 15, 'category': 'b'}, + {'active': true, 'score': 20, 'category': 'b'}, + {'active': true, 'score': 30, 'category': 'c'}, + ]); + }); + + test('sort + limit returns expected order', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('filter-sort')) + .sort(Expression.field('score').descending()) + .limit(3) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'score': 30, 'category': 'c'}, + {'score': 20, 'category': 'b'}, + {'score': 15, 'category': 'b'}, + ]); + }); + + test('offset + limit returns expected slice', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('filter-sort')) + .sort(Expression.field('score').ascending()) + .offset(2) + .limit(5) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'score': 15, 'category': 'b'}, + {'score': 20, 'category': 'b'}, + {'score': 30, 'category': 'c'}, + ]); + }); + + test('distinct returns unique category values', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('filter-sort')) + .distinct(Expression.field('category').as('category')) + .sort(Expression.field('category').ascending()) + .limit(10) + .execute(); + expectResultCount(snapshot, 3); + final categories = snapshot.result + .map((r) => r.data()!['category']) + .toList(); + expect(categories..sort(), ['a', 'b', 'c']); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart new file mode 100644 index 000000000000..35d5b6e75062 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_find_nearest_e2e.dart @@ -0,0 +1,45 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void runPipelineFindNearestTests() { + group('Pipeline findNearest', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test( + 'findNearest returns results ordered by distance when vector index exists', + () async { + final pipeline = firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('find-nearest')) + .findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + limit: 5, + ); + final snapshot = await pipeline.execute(); + expect(snapshot, isNotNull); + expect(snapshot.result, isA>()); + if (snapshot.result.isNotEmpty) { + final first = snapshot.result.first.data(); + if (first != null && first.containsKey('label')) { + expect(first['label'], 'near'); + } + } + }, + ); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_live_test.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_live_test.dart new file mode 100644 index 000000000000..b7a977b6e8ad --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_live_test.dart @@ -0,0 +1,52 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// Pipeline E2E runs against live Firebase (do not use emulator). + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:pipeline_example/firebase_options.dart'; + +import 'pipeline_add_fields_e2e.dart'; +import 'pipeline_aggregate_e2e.dart'; +import 'pipeline_expressions_e2e.dart'; +import 'pipeline_filter_sort_e2e.dart'; +import 'pipeline_find_nearest_e2e.dart'; +import 'pipeline_remove_fields_e2e.dart'; +import 'pipeline_replace_with_e2e.dart'; +import 'pipeline_sample_e2e.dart'; +import 'pipeline_seed.dart'; +import 'pipeline_select_e2e.dart'; +import 'pipeline_unnest_union_e2e.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('pipeline (live)', () { + setUpAll(() async { + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + final firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + firestore.settings = const Settings(persistenceEnabled: true); + await seedPipelineE2ECollections(firestore); + }); + + runPipelineFilterSortTests(); + runPipelineAddFieldsTests(); + runPipelineSelectTests(); + runPipelineRemoveFieldsTests(); + runPipelineReplaceWithTests(); + runPipelineAggregateTests(); + runPipelineUnnestUnionTests(); + runPipelineSampleTests(); + runPipelineFindNearestTests(); + runPipelineExpressionsTests(); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_remove_fields_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_remove_fields_e2e.dart new file mode 100644 index 000000000000..51840cbd4691 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_remove_fields_e2e.dart @@ -0,0 +1,44 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineRemoveFieldsTests() { + group('Pipeline removeFields', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test('removeFields drops specified fields only', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('remove-fields')) + .sort(Expression.field('keep').ascending()) + .removeFields('internal_id', 'debug_flag') + .limit(3) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'keep': 'x'}, + {'keep': 'y'}, + {'keep': 'z'}, + ]); + for (final r in snapshot.result) { + final data = r.data()!; + expect(data.containsKey('internal_id'), false); + expect(data.containsKey('debug_flag'), false); + } + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_replace_with_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_replace_with_e2e.dart new file mode 100644 index 000000000000..68f3730180f5 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_replace_with_e2e.dart @@ -0,0 +1,53 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineReplaceWithTests() { + group('Pipeline replaceWith', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test('replaceWith emits nested map as document', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('replace-with')) + .sort(Expression.field('name').ascending()) + .replaceWith(Expression.field('nested')) + .limit(1) + .execute(); + expectResultCount(snapshot, 1); + expectResultsData(snapshot, [ + {'father': 'John Doe Sr.', 'mother': 'Jane Doe'}, + ]); + }); + + test('replaceWith emits each nested map as a result document', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('replace-with')) + .sort(Expression.field('name').ascending()) + .replaceWith(Expression.field('nested')) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'father': 'John Doe Sr.', 'mother': 'Jane Doe'}, + {'a': 1, 'b': 2}, + {'x': 'foo', 'y': 'bar'}, + ]); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_sample_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_sample_e2e.dart new file mode 100644 index 000000000000..4357d8c23df6 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_sample_e2e.dart @@ -0,0 +1,47 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineSampleTests() { + group('Pipeline sample', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test('sample withSize returns exactly requested count', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('sample')) + .sample(PipelineSample.withSize(5)) + .execute(); + expectResultCount(snapshot, 5); + for (final r in snapshot.result) { + expect(r.data(), isNotNull); + expect(r.data()!.containsKey('n'), true); + } + }); + + test('sample withPercentage returns results in expected range', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('sample')) + .sample(PipelineSample.withPercentage(0.2)) + .execute(); + expect(snapshot.result.length, greaterThanOrEqualTo(0)); + expect(snapshot.result.length, lessThanOrEqualTo(10)); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart new file mode 100644 index 000000000000..d3eb9a410b06 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart @@ -0,0 +1,180 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; + +const String _col = 'pipeline-e2e'; +const int _maxBatchSize = 500; + +Future seedPipelineE2ECollections(FirebaseFirestore firestore) async { + final docs = >[ + ..._withTest('filter-sort', [ + {'active': true, 'score': 10, 'category': 'a'}, + {'active': true, 'score': 20, 'category': 'b'}, + {'active': false, 'score': 5, 'category': 'a'}, + {'active': true, 'score': 30, 'category': 'c'}, + {'active': true, 'score': 15, 'category': 'b'}, + ]), + ..._withTest('add-fields', [ + {'title': 'alpha', 'score': -7}, + {'title': 'beta', 'score': 42}, + {'title': 'gamma', 'score': 0}, + ]), + ..._withTest('select', [ + {'name': 'doc1', 'score': 1}, + {'name': 'doc2', 'score': 2}, + {'name': 'doc3', 'score': 3}, + ]), + ..._withTest('remove-fields', [ + {'keep': 'x', 'internal_id': 'id1', 'debug_flag': true}, + {'keep': 'y', 'internal_id': 'id2', 'debug_flag': false}, + {'keep': 'z', 'internal_id': 'id3', 'debug_flag': true}, + ]), + ..._withTest('replace-with', [ + { + 'name': 'Doc 1', + 'nested': {'father': 'John Doe Sr.', 'mother': 'Jane Doe'}, + }, + { + 'name': 'Doc 2', + 'nested': {'a': 1, 'b': 2}, + }, + { + 'name': 'Doc 3', + 'nested': {'x': 'foo', 'y': 'bar'}, + }, + ]), + ..._withTest('aggregate', [ + {'score': 10, 'category': 'x'}, + {'score': 20, 'category': 'x'}, + {'score': 30, 'category': 'y'}, + {'score': 40, 'category': 'y'}, + ]), + ..._withTest('unnest', [ + { + 'tags': ['dart', 'flutter'], + }, + { + 'tags': ['firestore'], + }, + { + 'tags': ['dart', 'firestore'], + }, + ]), + ..._withTest('union-a', [ + {'id': 'a1'}, + {'id': 'a2'}, + {'id': 'a3'}, + ]), + ..._withTest('union-b', [ + {'id': 'b1'}, + {'id': 'b2'}, + {'id': 'b3'}, + ]), + ..._withTest('sample', [ + {'n': 1}, + {'n': 2}, + {'n': 3}, + {'n': 4}, + {'n': 5}, + {'n': 6}, + {'n': 7}, + {'n': 8}, + {'n': 9}, + {'n': 10}, + ]), + ..._withTest('find-nearest', [ + { + 'embedding': VectorValue([0.1, 0.2, 0.3]), + 'label': 'near', + }, + { + 'embedding': VectorValue([0.15, 0.25, 0.35]), + 'label': 'near2', + }, + { + 'embedding': VectorValue([1.0, 0.0, 0.0]), + 'label': 'far', + }, + ]), + ..._withTest('expressions', [ + { + 'score': 60, + 'a': 1, + 'b': 2, + 's': ' AbC ', + 'm': {'x': 10, 'y': 20}, + 'bit_a': 6, + 'bit_b': 3, + 'arr': [1, 2, 3], + 'arr_b': [4, 5], + 'maybe_null': null, + 'ts': Timestamp.fromMillisecondsSinceEpoch(1700000000000), + }, + { + 'score': 70, + 'a': 10, + 'b': 20, + 's': 'xy', + 'm': {'x': 1}, + 'bit_a': 5, + 'bit_b': 1, + 'arr': [7, 8, 9], + 'arr_b': [10], + 'ts': Timestamp.fromMillisecondsSinceEpoch(1700003600000), + }, + {'score': 40, 'a': 5, 'b': 5}, + {'score': 80, 'a': 0, 'b': 100, 's': 'Hi'}, + { + 'score': 50, + 'a': 1, + 'b': 2, + 'tags': ['p', 'q'], + 'arr': [2, 4, 6], + 'arr_b': [8], + 's': 'a-b-c', + 'ts': Timestamp.fromMillisecondsSinceEpoch(1700007200000), + }, + ]), + ]; + await _clearAndSeed(firestore, _col, docs); +} + +List> _withTest( + String test, + List> maps, +) { + return maps.map((m) => {'test': test, ...m}).toList(); +} + +Future _clearAndSeed( + FirebaseFirestore firestore, + String collectionPath, + List> docs, +) async { + final col = firestore.collection(collectionPath); + final snapshot = await col.get(); + for (final chunk in _chunk(snapshot.docs, _maxBatchSize)) { + final batch = firestore.batch(); + for (final doc in chunk) { + batch.delete(doc.reference); + } + await batch.commit(); + } + var docIndex = 0; + for (final chunk in _chunk(docs, _maxBatchSize)) { + final batch = firestore.batch(); + for (final data in chunk) { + batch.set(col.doc('seed_$docIndex'), data); + docIndex++; + } + await batch.commit(); + } +} + +Iterable> _chunk(List list, int size) sync* { + for (var i = 0; i < list.length; i += size) { + yield list.sublist(i, i + size > list.length ? list.length : i + size); + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_select_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_select_e2e.dart new file mode 100644 index 000000000000..4b14ad5864c8 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_select_e2e.dart @@ -0,0 +1,42 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineSelectTests() { + group('Pipeline select', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test('select returns only selected fields in expected order', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('select')) + .sort(Expression.field('score').ascending()) + .select( + Expression.field('name').as('name'), + Expression.field('score').as('score'), + ) + .limit(3) + .execute(); + expectResultCount(snapshot, 3); + expectResultsData(snapshot, [ + {'name': 'doc1', 'score': 1}, + {'name': 'doc2', 'score': 2}, + {'name': 'doc3', 'score': 3}, + ]); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_test_helpers.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_test_helpers.dart new file mode 100644 index 000000000000..34a0cef69cfd --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_test_helpers.dart @@ -0,0 +1,50 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Asserts that [snapshot] has exactly [count] results. +void expectResultCount(PipelineSnapshot snapshot, int count) { + expect( + snapshot.result.length, + count, + reason: 'Expected $count pipeline results', + ); +} + +/// Asserts that [snapshot] has the same number of results as [expectedData] +/// and each result's data() deep-equals the corresponding expected map. +/// Keys in [expectedData] are checked; extra keys in actual data are ignored +/// when [exactMatch] is false (default). Set [exactMatch] to true to require +/// identical keys. +void expectResultsData( + PipelineSnapshot snapshot, + List> expectedData, { + bool exactMatch = false, +}) { + expect( + snapshot.result.length, + expectedData.length, + reason: 'Result count mismatch', + ); + for (var i = 0; i < expectedData.length; i++) { + final result = snapshot.result[i]; + final data = result.data(); + expect(data, isNotNull, reason: 'Result $i has null data'); + final actual = data!; + final expected = expectedData[i]; + if (exactMatch) { + expect(actual, expected, reason: 'Result $i data mismatch'); + } else { + for (final entry in expected.entries) { + expect( + actual[entry.key], + entry.value, + reason: 'Result $i field ${entry.key}', + ); + } + } + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart new file mode 100644 index 000000000000..dac760d19043 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_unnest_union_e2e.dart @@ -0,0 +1,63 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'pipeline_test_helpers.dart'; + +void runPipelineUnnestUnionTests() { + group('Pipeline unnest and union', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test('unnest produces one row per array element with tag field', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('unnest')) + .unnest(Expression.field('tags').as('tag')) + .sort(Expression.field('tag').ascending()) + .limit(10) + .execute(); + expectResultCount(snapshot, 5); + final tags = snapshot.result + .map((r) => r.data()!['tag'] as String) + .toList(); + expect(tags..sort(), [ + 'dart', + 'dart', + 'firestore', + 'firestore', + 'flutter', + ]); + }); + + test( + 'union concatenates both pipelines with deterministic total count', + () async { + final other = firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('union-b')) + .limit(5); + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .where(Expression.field('test').equalValue('union-a')) + .limit(5) + .union(other) + .execute(); + expectResultCount(snapshot, 6); + }, + ); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/.gitignore b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/.gitignore new file mode 100644 index 000000000000..7a7f9873ad7d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Flutter/AppFrameworkInfo.plist b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 000000000000..1dc6cf7652ba --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Flutter/Debug.xcconfig b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000000..ec97fc6f3021 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Flutter/Release.xcconfig b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000000..c4855bfe2000 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Podfile b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Podfile new file mode 100644 index 000000000000..6649374d4c19 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '15.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.pbxproj b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..238fb056204e --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,741 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2B45BBBA04F26970F9DC2428 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D9D2411717BE352C92EC6263 /* Pods_RunnerTests.framework */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + D3B189D93111F21B593E07CE /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = FC9AF0E7C3463CF0E820724D /* GoogleService-Info.plist */; }; + ED8175A9DDB4B6F569867736 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 344F3B3BBCDBB7DF16FCD695 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 060D24293863437D08A9EF6D /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 0A1494B03E7E4E5E8FCD37E4 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 24A69F3F28EEBABDA3E3C6E5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 344F3B3BBCDBB7DF16FCD695 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9A6E43430478085B3F4B8179 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 9B52183BF5A24CC0FD358BC5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + D44D6373BC6CC1B25B52520C /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + D9D2411717BE352C92EC6263 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FC9AF0E7C3463CF0E820724D /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7A5921923875F445DD42E396 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2B45BBBA04F26970F9DC2428 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ED8175A9DDB4B6F569867736 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33AEEB36D1DBFD083FE19F82 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 344F3B3BBCDBB7DF16FCD695 /* Pods_Runner.framework */, + D9D2411717BE352C92EC6263 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 726BAFFB79E9610C171E5B05 /* Pods */ = { + isa = PBXGroup; + children = ( + 9B52183BF5A24CC0FD358BC5 /* Pods-Runner.debug.xcconfig */, + D44D6373BC6CC1B25B52520C /* Pods-Runner.release.xcconfig */, + 0A1494B03E7E4E5E8FCD37E4 /* Pods-Runner.profile.xcconfig */, + 060D24293863437D08A9EF6D /* Pods-RunnerTests.debug.xcconfig */, + 24A69F3F28EEBABDA3E3C6E5 /* Pods-RunnerTests.release.xcconfig */, + 9A6E43430478085B3F4B8179 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + FC9AF0E7C3463CF0E820724D /* GoogleService-Info.plist */, + 726BAFFB79E9610C171E5B05 /* Pods */, + 33AEEB36D1DBFD083FE19F82 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 11FC74FA85B59C7E267B4550 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 7A5921923875F445DD42E396 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 6F498101593ECA79F30C31E4 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + B4380A12E6068F3FEA627E84 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + D3B189D93111F21B593E07CE /* GoogleService-Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 11FC74FA85B59C7E267B4550 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 6F498101593ECA79F30C31E4 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + B4380A12E6068F3FEA627E84 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YYX2P3XVJ7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 060D24293863437D08A9EF6D /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 24A69F3F28EEBABDA3E3C6E5 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9A6E43430478085B3F4B8179 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YYX2P3XVJ7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YYX2P3XVJ7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..919434a6254f --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000000..f9b0d7c5ea15 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000000..e3773d42e24c --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..21a3cc14c74e --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000000..f9b0d7c5ea15 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/AppDelegate.swift b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/AppDelegate.swift new file mode 100644 index 000000000000..b6363034812b --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..d36b1fab2d9d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 000000000000..dc9ada4725e9 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 000000000000..28c6bf03016f Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 000000000000..2ccbfd967d96 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 000000000000..f091b6b0bca8 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 000000000000..4cde12118dda Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 000000000000..d0ef06e7edb8 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 000000000000..dcdc2306c285 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 000000000000..2ccbfd967d96 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 000000000000..c8f9ed8f5cee Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 000000000000..a6d6b8609df0 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 000000000000..a6d6b8609df0 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 000000000000..75b2d164a5a9 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 000000000000..c4df70d39da7 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 000000000000..6a84f41e14e2 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 000000000000..d0e1f5853602 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 000000000000..0bedcf2fd467 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 000000000000..89c2725b70f1 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000000..f2e259c7c939 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Base.lproj/Main.storyboard b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000000..f3c28516fb38 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Info.plist b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Info.plist new file mode 100644 index 000000000000..68b861da4bf1 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Info.plist @@ -0,0 +1,72 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Pipeline Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + pipeline_example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneDelegateClassName + FlutterSceneDelegate + UISceneConfigurationName + flutter + UISceneStoryboardFile + Main + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Runner-Bridging-Header.h b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 000000000000..308a2a560b42 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/RunnerTests/RunnerTests.swift b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000000..b0d10f2e4304 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,10 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart new file mode 100644 index 000000000000..9a1dc163097e --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart @@ -0,0 +1,1116 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; + +// Import after file is generated through flutterfire_cli. +// import 'firebase_options.dart'; + +const String _collectionId = 'pipeline_test_2'; + +bool shouldUseFirestoreEmulator = false; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + // Enable this line instead once you have the firebase_options.dart generated and + // imported through flutterfire_cli. + // await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + if (shouldUseFirestoreEmulator) { + FirebaseFirestore.instance.useFirestoreEmulator('localhost', 8080); + } + runApp(const PipelineExampleApp()); +} + +class PipelineExampleApp extends StatelessWidget { + const PipelineExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Pipeline Example', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const PipelineExamplePage(), + ); + } +} + +class _PipelineExamplePageState extends State { + final List _log = []; + bool _loading = false; + List>> _seedDocRefs = []; + + final _firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + + void _logMessage(String message) { + setState(() { + _log.insert( + 0, + '[${DateTime.now().toString().substring(11, 19)}] $message', + ); + }); + } + + void _logError(String context, Object error, StackTrace? st) { + debugPrint('$context: $error'); + if (st != null) debugPrintStack(stackTrace: st); + } + + Future _seedCollection() async { + if (_loading) return; + setState(() { + _loading = true; + _log.clear(); + }); + _logMessage('Seeding collection "$_collectionId"...'); + + try { + final col = _firestore.collection(_collectionId); + + final batch = _firestore.batch(); + _seedDocRefs = []; + + final items = [ + {'title': 'Item A', 'score': 10, 'year': 2022, 'category': 'tech'}, + {'title': 'Item B', 'score': 25, 'year': 2023, 'category': 'tech'}, + {'title': 'Item C', 'score': 5, 'year': 2021, 'category': 'news'}, + {'title': 'Item D', 'score': 40, 'year': 2023, 'category': 'news'}, + {'title': 'Item E', 'score': 15, 'year': 2022, 'category': 'tech'}, + {'title': 'Item F', 'score': 30, 'year': 2024, 'category': 'news'}, + { + 'title': 'Item G', + 'score': 20, + 'year': 2023, + 'tags': ['x', 'y', 'z'], + }, + { + 'title': 'Item H', + 'score': 20, + 'year': 2023, + 'items': {'a': 'b', 'c': 'd'}, + }, + // For string expressions: trim, substring, stringReplaceAll, split, join + {'title': ' Padded ', 'score': 7, 'year': 2022, 'category': 'tech'}, + // For abs and conditional + { + 'title': 'Item Negative', + 'score': -12, + 'year': 2023, + 'category': 'news', + }, + // For array_length, array_sum, array_slice, array_concat + { + 'title': 'Item With Arrays', + 'score': 50, + 'scores': [10, 20, 30], + 'tags': ['p', 'q', 'r'], + 'year': 2024, + }, + // For if_absent (missing optional_field) + {'title': 'Item No Optional', 'score': 11, 'year': 2022}, + // Matches integration_test pipeline_seed "expressions" (for NOT / OR tests). + { + 'test': 'expressions', + 'score': 60, + 'a': 1, + 'b': 2, + 's': ' AbC ', + 'm': {'x': 10, 'y': 20}, + }, + { + 'test': 'expressions', + 'score': 70, + 'a': 10, + 'b': 20, + 's': 'xy', + 'm': {'x': 1}, + }, + {'test': 'expressions', 'score': 40, 'a': 5, 'b': 5}, + {'test': 'expressions', 'score': 80, 'a': 0, 'b': 100, 's': 'Hi'}, + { + 'test': 'expressions', + 'score': 50, + 'a': 1, + 'b': 2, + 'tags': ['p', 'q'], + }, + ]; + + for (final item in items) { + final ref = col.doc(); + batch.set(ref, item); + if (_seedDocRefs.length < 2) _seedDocRefs.add(ref); + } + await batch.commit(); + _logMessage('Seeded ${items.length} documents.'); + } catch (e, st) { + _logError('Seed error', e, st); + } finally { + setState(() => _loading = false); + } + } + + /// Seeds the same data using individual set() calls (no batch). Useful for + /// debugging emulator issues where batch writes might behave differently. + Future _seedCollectionNoBatch() async { + if (_loading) return; + setState(() { + _loading = true; + _log.clear(); + }); + _logMessage('Seeding collection "$_collectionId" (no batch)...'); + + try { + final col = _firestore.collection(_collectionId); + _seedDocRefs = []; + + final items = [ + {'title': 'Item A', 'score': 10, 'year': 2022, 'category': 'tech'}, + {'title': 'Item B', 'score': 25, 'year': 2023, 'category': 'tech'}, + {'title': 'Item C', 'score': 5, 'year': 2021, 'category': 'news'}, + {'title': 'Item D', 'score': 40, 'year': 2023, 'category': 'news'}, + {'title': 'Item E', 'score': 15, 'year': 2022, 'category': 'tech'}, + {'title': 'Item F', 'score': 30, 'year': 2024, 'category': 'news'}, + { + 'title': 'Item G', + 'score': 20, + 'year': 2023, + 'tags': ['x', 'y', 'z'], + }, + { + 'title': 'Item H', + 'score': 20, + 'year': 2023, + 'items': {'a': 'b', 'c': 'd'}, + }, + {'title': ' Padded ', 'score': 7, 'year': 2022, 'category': 'tech'}, + { + 'title': 'Item Negative', + 'score': -12, + 'year': 2023, + 'category': 'news', + }, + { + 'title': 'Item With Arrays', + 'score': 50, + 'scores': [10, 20, 30], + 'tags': ['p', 'q', 'r'], + 'year': 2024, + }, + {'title': 'Item No Optional', 'score': 11, 'year': 2022}, + { + 'test': 'expressions', + 'score': 60, + 'a': 1, + 'b': 2, + 's': ' AbC ', + 'm': {'x': 10, 'y': 20}, + }, + { + 'test': 'expressions', + 'score': 70, + 'a': 10, + 'b': 20, + 's': 'xy', + 'm': {'x': 1}, + }, + {'test': 'expressions', 'score': 40, 'a': 5, 'b': 5}, + {'test': 'expressions', 'score': 80, 'a': 0, 'b': 100, 's': 'Hi'}, + { + 'test': 'expressions', + 'score': 50, + 'a': 1, + 'b': 2, + 'tags': ['p', 'q'], + }, + ]; + + for (final item in items) { + final ref = col.doc(); + await ref.set(item); + if (_seedDocRefs.length < 2) _seedDocRefs.add(ref); + } + _logMessage('Seeded ${items.length} documents (no batch).'); + } catch (e, st) { + _logError('Seed (no batch) error', e, st); + } finally { + setState(() => _loading = false); + } + } + + Future _runPipeline( + String description, + Future Function() run, + ) async { + if (_loading) return; + setState(() { + _loading = true; + _log.clear(); + }); + _logMessage(description); + + try { + final snapshot = await run(); + _logMessage( + 'Found ${snapshot.result.length} result(s). Execution: ${snapshot.executionTime}', + ); + for (final r in snapshot.result) { + _logMessage(' doc: ${r.data()?.toString()}'); + } + } catch (e, st) { + _logError(description, e, st); + } finally { + setState(() => _loading = false); + } + } + + // 1: where + limit + Future _runPipeline1() => _runPipeline( + 'Pipeline 1: collection → where(score > 10) → limit(3)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('score').greaterThan(Expression.constant(10))) + .limit(3) + .execute(), + ); + + // 1b: execute with ExecuteOptions (indexMode: recommended) + Future _runPipelineExecuteOptions() => _runPipeline( + 'Pipeline 1b: same as 1 but execute(options: ExecuteOptions(indexMode: recommended))', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('score').greaterThan(Expression.constant(10))) + .limit(3) + .execute( + options: const ExecuteOptions(indexMode: IndexMode.recommended), + ), + ); + + // 2: select + Future _runPipeline2() => _runPipeline( + 'Pipeline 2: collection → where(year > 2022) → select(title, score, year) → limit(4)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('year').greaterThan(Expression.constant(2022))) + .select( + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ) + .limit(4) + .execute(), + ); + + // 3: aggregate + Future _runPipeline3() => _runPipeline( + 'Pipeline 3: collection → aggregate(sum, avg, count_all)', + () => _firestore + .pipeline() + .collection(_collectionId) + .aggregate( + Expression.field('score').sum().as('total_score'), + Expression.field('score').average().as('avg_score'), + CountAll().as('doc_count'), + ) + .execute(), + ); + + // 4: addFields + Future _runPipeline4() => _runPipeline( + 'Pipeline 4: collection → addFields(score+100 as bonus) → limit(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'score', + ).add(Expression.constant(100)).as('bonus_score'), + ) + .limit(2) + .execute(), + ); + + // 5: distinct + Future _runPipeline5() => _runPipeline( + 'Pipeline 5: collection → distinct(category) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .distinct(Expression.field('category')) + .limit(5) + .execute(), + ); + + // 6: offset + Future _runPipeline6() => _runPipeline( + 'Pipeline 6: collection → limit(4) → offset(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(4) + .offset(2) + .execute(), + ); + + // 7: removeFields + Future _runPipeline7() => _runPipeline( + 'Pipeline 7: collection → removeFields(category) → limit(2)', + () => _firestore + .pipeline() + .collection(_collectionId) + .removeFields('category') + .limit(2) + .execute(), + ); + + // 8: replaceWith + Future _runPipeline8() => _runPipeline( + 'Pipeline 8: collection → replaceWith(constant) → limit(1)', + () => _firestore + .pipeline() + .collection(_collectionId) + .replaceWith(Expression.field('items')) + // .limit(1) + .execute(), + ); + + // 9: sample + Future _runPipeline9() => _runPipeline( + 'Pipeline 9: collection → sample(size: 3)', + () => _firestore + .pipeline() + .collection(_collectionId) + .sample(PipelineSample.withSize(3)) + .execute(), + ); + + // 10: sort + Future _runPipeline10() => _runPipeline( + 'Pipeline 10: collection → sort(score desc) → limit(3)', + () => _firestore + .pipeline() + .collection(_collectionId) + .sort(Expression.field('score').descending()) + .limit(3) + .execute(), + ); + + // 11: aggregateStage with groups + Future _runPipeline11() => _runPipeline( + 'Pipeline 11: collection → aggregateStage(groups: category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .aggregateWithOptions( + AggregateStageOptions( + accumulators: [ + Expression.field('score').sum().as('total'), + CountAll().as('count'), + ], + groups: [Expression.field('category')], + ), + ) + .execute(), + ); + + // 12: collectionGroup + Future _runPipeline12() => _runPipeline( + 'Pipeline 12: collectionGroup → limit(2)', + () => + _firestore.pipeline().collectionGroup(_collectionId).limit(2).execute(), + ); + + // 13: documents + Future _runPipeline13() async { + final col = _firestore.collection(_collectionId); + final ref1 = col.doc(); + final ref2 = col.doc(); + final refs = [ref1, ref2]; + await ref1.set({'title': 'Pipeline 13 doc 1', 'n': 1}); + await ref2.set({'title': 'Pipeline 13 doc 2', 'n': 2}); + return _runPipeline( + 'Pipeline 13: documents(ref1, ref2) → addFields(extra)', + () => _firestore + .pipeline() + .documents(refs) + .addFields(Expression.constant(1).as('extra')) + .execute(), + ); + } + + // 14: database + Future _runPipeline14() => _runPipeline( + 'Pipeline 14: database() → limit(2)', + () => _firestore.pipeline().database().execute(), + ); + + // 15: findNearest (may fail without vector index) + Future _runPipeline15() => _runPipeline( + 'Pipeline 15: collection → findNearest (needs vector index)', + () => _firestore + .pipeline() + .collection(_collectionId) + .findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + limit: 2, + ) + .execute(), + ); + + // 16: unnest + Future _runPipeline16() => _runPipeline( + 'Pipeline 16: collection → where(has tags) → unnest(tags) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .unnest(Expression.field('tags'), 'index') + .limit(5) + .execute(), + ); + + // 17: union + Future _runPipeline17() => _runPipeline( + 'Pipeline 17: collection limit 2 → union(collection offset 2 limit 2)', + () { + final p2 = _firestore + .pipeline() + .collection(_collectionId) + .offset(2) + .limit(2); + return _firestore + .pipeline() + .collection(_collectionId) + .limit(2) + .union(p2) + .execute(); + }, + ); + + // 18: Constant — one addFields field per supported constant type + Future _runPipeline18() { + final docRef = _firestore.collection(_collectionId).doc('constant-test'); + // _firestore.doc() + return _runPipeline( + 'Pipeline 18: constant types — null, String, int, double, bool, ' + 'DateTime, Timestamp, GeoPoint, List, Blob, DocumentReference, VectorValue', + () => _firestore + .pipeline() + .collection(_collectionId) + .limit(1) + .addFields( + // VectorValue + Constant(VectorValue([1.0, 2.0, 3.0])).as('c_vector'), + + Constant(null).as('c_null'), + // String + Constant('hello').as('c_string'), + // int + Constant(42).as('c_int'), + // double + Constant(3.14).as('c_double'), + // bool + Constant(true).as('c_bool'), + // DateTime + Constant(DateTime.utc(2024, 6, 15, 12, 0, 0)).as('c_date_time'), + // Timestamp + Constant(Timestamp(1718449200, 0)).as('c_timestamp'), + // GeoPoint + Constant(const GeoPoint(37.7749, -122.4194)).as('c_geo_point'), + // List (raw bytes) + Constant([72, 101, 108, 108, 111]).as('c_bytes'), + // // Blob + // Constant(Blob(Uint8List.fromList([1, 2, 3, 4, 5]))).as('c_blob'), + // DocumentReference + Constant(docRef).as('c_doc_ref'), + ) + .execute(), + ); + } + + // 19: Expression.and + Future _runPipeline19() => _runPipeline( + 'Pipeline 19: collection → where(and(score > 50, year >= 2022)) → select(title, score, year) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.and( + Expression.field('score').greaterThan(Expression.constant(20)), + Expression.field( + 'year', + ).greaterThanOrEqual(Expression.constant(2022)), + ), + ) + .select( + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ) + .limit(5) + .execute(), + ); + + // 20: Expression.or + Future _runPipeline20() => _runPipeline( + 'Pipeline 20: collection → where(or(score > 80, year < 2021)) → select(title, score, year) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where( + Expression.or( + Expression.field('score').greaterThan(Expression.constant(30)), + Expression.field('year').lessThan(Expression.constant(2022)), + ), + ) + .select( + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ) + .limit(5) + .execute(), + ); + + // 20b: Expression.not (same pattern as pipeline_expressions_e2e "where with not") + Future _runPipeline20b() => _runPipeline( + 'Pipeline 20b: where(test=expressions) + NOT(score>=60) + sort(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('test').equalValue('expressions')) + .where( + Expression.not( + Expression.field( + 'score', + ).greaterThanOrEqual(Expression.constant(60)), + ), + ) + .sort(Expression.field('score').ascending()) + .execute(), + ); + + // 21: arrayContainsAny + Future _runPipeline21() => _runPipeline( + 'Pipeline 21: collection → where(tags arrayContainsAny [x, z]) → select(title, tags) → limit(5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').arrayContainsAny(['x', 'z'])) + .select(Expression.field('title'), Expression.field('tags')) + .limit(5) + .execute(), + ); + + // ── New expression examples (22+) ───────────────────────────────────── + + // 22: concat + Future _runPipeline22() => _runPipeline( + 'Pipeline 22: addFields concat(title, " | ", category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'title', + ).concat([' | ', Expression.field('category')]).as('title_category'), + ) + .limit(3) + .execute(), + ); + + // 23: length (string) + Future _runPipeline23() => _runPipeline( + 'Pipeline 23: addFields title.length()', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('title').length().as('title_len')) + .limit(4) + .execute(), + ); + + // 24: toLowerCase / toUpperCase + Future _runPipeline24() => _runPipeline( + 'Pipeline 24: addFields toLowerCase(title), toUpperCase(category)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('title').toLowerCase().as('title_lower'), + Expression.field('category').toUpperCase().as('category_upper'), + ) + .limit(3) + .execute(), + ); + + // 25: trim + Future _runPipeline25() => _runPipeline( + 'Pipeline 25: where(has title) → addFields trim(title)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('title').exists()) + .addFields(Expression.field('title').trim().as('title_trimmed')) + .limit(5) + .execute(), + ); + + // 26: substring + Future _runPipeline26() => _runPipeline( + 'Pipeline 26: addFields substring(title, 0, 5)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('title').substringLiteral(0, 5).as('title_prefix'), + ) + .limit(4) + .execute(), + ); + + // 27: stringReplaceAll + // Future _runPipeline27() => _runPipeline( + // 'Pipeline 27: addFields stringReplaceAll(title, "Item", "Doc")', + // () => _firestore + // .pipeline() + // .collection(_collectionId) + // .addFields( + // Expression.field('title') + // .stringReplaceAllLiteral('Item', 'Doc') + // .as('title_replaced'), + // ) + // .limit(3) + // .execute(), + // ); + + // 28: split + Future _runPipeline28() => _runPipeline( + 'Pipeline 28: addFields split(title, " ")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('title').splitLiteral(' ').as('title_parts'), + ) + .limit(3) + .execute(), + ); + + // 29: join + Future _runPipeline29() => _runPipeline( + 'Pipeline 29: where(has tags) → addFields join(tags, "-")', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .addFields(Expression.field('tags').joinLiteral('-').as('tags_joined')) + .limit(3) + .execute(), + ); + + // 30: if_absent + Future _runPipeline30() => _runPipeline( + 'Pipeline 30: addFields if_absent(optional_field, "default")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'optional_field', + ).ifAbsentValue('default').as('opt_or_default'), + ) + .limit(4) + .execute(), + ); + + // 30b: if_error (e.g. safe divide) + Future _runPipeline30b() => _runPipeline( + 'Pipeline 30b: addFields score/0 with ifError("N/A")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field( + 'score', + ).divide(Expression.constant(0)).ifErrorValue('N/A').as('safe_ratio'), + ) + .limit(2) + .execute(), + ); + + // 31: conditional + Future _runPipeline31() => _runPipeline( + 'Pipeline 31: addFields conditional(score > 20, "high", "low")', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.conditionalValues( + Expression.field('score').greaterThan(Expression.constant(20)), + 'high', + 'low', + ).as('score_tier'), + ) + .limit(5) + .execute(), + ); + + // 32: document_id (current document ID) + Future _runPipeline32() => _runPipeline( + 'Pipeline 32: addFields documentId()', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('__path__').documentId().as('doc_id')) + .limit(3) + .execute(), + ); + + // 33: collection_id (current collection ID) + Future _runPipeline33() => _runPipeline( + 'Pipeline 33: addFields collectionId()', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('__path__').collectionId().as('coll_id')) + .limit(2) + .execute(), + ); + + // 34: map_get, map_keys, map_values + Future _runPipeline34() => _runPipeline( + 'Pipeline 34: where(has items) → addFields mapGet, mapKeys, mapValues', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('items').exists()) + .addFields(Expression.field('items').mapGetLiteral('a').as('items_a')) + .limit(2) + .execute(), + ); + + // 35: current_timestamp, timestamp_add, timestamp_subtract, timestamp_truncate + Future _runPipeline35() => _runPipeline( + 'Pipeline 35: addFields currentTimestamp, timestampAdd(1 day)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.currentTimestamp().as('now'), + Expression.timestampAddLiteral( + Expression.currentTimestamp(), + 'day', + 1, + ).as('tomorrow'), + ) + .limit(1) + .execute(), + ); + + // 36: abs + Future _runPipeline36() => _runPipeline( + 'Pipeline 36: addFields abs(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('score').abs().as('score_abs')) + .limit(5) + .execute(), + ); + + // 37: array_length + Future _runPipeline37() => _runPipeline( + 'Pipeline 37: where(has tags) → addFields arrayLength(tags)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .addFields(Expression.field('tags').arrayLength().as('tags_len')) + .limit(5) + .execute(), + ); + + Future _runPipeline37b() => _runPipeline( + 'Pipeline 37b: where(has scores) → addFields arraySum(scores)', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('scores').exists()) + .addFields(Expression.field('scores').arraySum().as('scores_total')) + .limit(3) + .execute(), + ); + + // 38: array_concat + Future _runPipeline38() => _runPipeline( + 'Pipeline 38: where(has tags) → addFields arrayConcat(tags, [extra])', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').exists()) + .addFields( + Expression.field('tags').arrayConcat(['extra']).as('tags_extended'), + ) + .limit(2) + .execute(), + ); + + // 40: array (construct) + Future _runPipeline40() => _runPipeline( + 'Pipeline 40: addFields array([title, score, year])', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.array([ + Expression.field('title'), + Expression.field('score'), + Expression.field('year'), + ]).as('tuple'), + ) + .limit(2) + .execute(), + ); + + // 41: map (construct) + Future _runPipeline41() => _runPipeline( + 'Pipeline 41: addFields map({ t: title, s: score })', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.map({ + 't': Expression.field('title'), + 's': Expression.field('score'), + }).as('mini_map'), + ) + .limit(2) + .execute(), + ); + + // 42: array_contains_all (values list) + Future _runPipeline42() => _runPipeline( + 'Pipeline 42: where(tags arrayContainsAll [x, y]) → select title, tags', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.field('tags').arrayContainsAll(['x', 'y'])) + .select(Expression.field('title'), Expression.field('tags')) + .limit(5) + .execute(), + ); + + // 43: equal_any (IN) + Future _runPipeline43() => _runPipeline( + 'Pipeline 43: where(score equalAny [10, 25, 40]) → select title, score', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.equalAny(Expression.field('score'), [10, 25, 40])) + .select(Expression.field('title'), Expression.field('score')) + .limit(5) + .execute(), + ); + + // 44: not_equal_any (NOT IN) + Future _runPipeline44() => _runPipeline( + 'Pipeline 44: where(category notEqualAny [news]) → select title, category', + () => _firestore + .pipeline() + .collection(_collectionId) + .where(Expression.notEqualAny(Expression.field('category'), ['news'])) + .select(Expression.field('title'), Expression.field('category')) + .limit(5) + .execute(), + ); + + // 45: asBoolean (coerce numeric to boolean) + Future _runPipeline45() => _runPipeline( + 'Pipeline 45: addFields asBoolean(score)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields(Expression.field('score').asBoolean().as('score_bool')) + .limit(4) + .execute(), + ); + + // 46: isError (missing field vs divide-by-zero) + Future _runPipeline46() => _runPipeline( + 'Pipeline 46: addFields isError(missing field), isError(score/0)', + () => _firestore + .pipeline() + .collection(_collectionId) + .addFields( + Expression.field('missing_field').isError().as('missing_is_err'), + Expression.field( + 'score', + ).divide(Expression.constant(0)).isError().as('div0_is_err'), + ) + .limit(3) + .execute(), + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Pipeline Example'), + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '1. Seed data, then run pipeline queries. Errors go to console only.', + style: TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: FilledButton( + onPressed: _loading ? null : _seedCollection, + child: const Text('Seed collection'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: _loading ? null : _seedCollectionNoBatch, + child: const Text('Seed (no batch)'), + ), + ), + ], + ), + if (_loading) const LinearProgressIndicator(), + ], + ), + ), + const SizedBox(height: 8), + Expanded( + flex: 1, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _btn('1: where+limit', _runPipeline1), + _btn('1b: execute(options)', _runPipelineExecuteOptions), + _btn('2: select', _runPipeline2), + _btn('3: aggregate', _runPipeline3), + _btn('4: addFields', _runPipeline4), + _btn('5: distinct', _runPipeline5), + _btn('6: offset', _runPipeline6), + _btn('7: removeFields', _runPipeline7), + _btn('8: replaceWith', _runPipeline8), + _btn('9: sample', _runPipeline9), + _btn('10: sort', _runPipeline10), + _btn('11: aggregateStage', _runPipeline11), + _btn('12: collectionGroup', _runPipeline12), + _btn('13: documents', _runPipeline13), + _btn('14: database', _runPipeline14), + _btn('15: findNearest', _runPipeline15), + _btn('16: unnest', _runPipeline16), + _btn('17: union', _runPipeline17), + _btn('18: constants', _runPipeline18), + _btn('19: and', _runPipeline19), + _btn('20: or', _runPipeline20), + _btn('20b: NOT (score≥60)', _runPipeline20b), + _btn('21: arrayContainsAny', _runPipeline21), + _btn('22: concat', _runPipeline22), + _btn('23: length', _runPipeline23), + _btn('24: lower/upper', _runPipeline24), + _btn('25: trim', _runPipeline25), + _btn('26: substring', _runPipeline26), + // test comment + _btn('28: split', _runPipeline28), + _btn('29: join', _runPipeline29), + _btn('30: if_absent', _runPipeline30), + _btn('30b: if_error', _runPipeline30b), + _btn('31: conditional', _runPipeline31), + _btn('32: documentId', _runPipeline32), + _btn('33: collectionId', _runPipeline33), + _btn('34: mapGet/Keys/Vals', _runPipeline34), + _btn('35: timestamp', _runPipeline35), + _btn('36: abs', _runPipeline36), + _btn('37: arrayLen', _runPipeline37), + _btn('37b: arraySum', _runPipeline37b), + _btn('38: arrayConcat', _runPipeline38), + _btn('40: array()', _runPipeline40), + _btn('41: map()', _runPipeline41), + _btn('42: arrayContainsAll', _runPipeline42), + _btn('43: equalAny', _runPipeline43), + _btn('44: notEqualAny', _runPipeline44), + _btn('45: asBoolean', _runPipeline45), + _btn('46: isError', _runPipeline46), + ], + ), + ), + ), + const Divider(height: 1), + Expanded( + flex: 2, + child: _log.isEmpty + ? const Center( + child: Text( + 'Log output will appear here.\nTap "Seed collection" then run a pipeline.', + textAlign: TextAlign.center, + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: _log.length, + itemBuilder: (context, i) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: SelectableText( + _log[i], + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _btn(String label, VoidCallback onPressed) { + return FilledButton.tonal( + onPressed: _loading ? null : onPressed, + child: Text(label), + ); + } +} + +class PipelineExamplePage extends StatefulWidget { + const PipelineExamplePage({super.key}); + + @override + State createState() => _PipelineExamplePageState(); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/.gitignore b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/.gitignore new file mode 100644 index 000000000000..746adbb6b9e1 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Flutter/Flutter-Debug.xcconfig b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 000000000000..4b81f9b2d200 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Flutter/Flutter-Release.xcconfig b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 000000000000..5caa9d1579e4 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Podfile b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Podfile new file mode 100644 index 000000000000..ff5ddb3b8bdc --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcodeproj/project.pbxproj b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..0d3da44c98d4 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,805 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 4D21D57616F29244D9E0BEDC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92A5244FE5D2154A5DCF67B1 /* Pods_Runner.framework */; }; + 64BEAD2D383FBE055A9293F9 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD0A02E97A5F21BBF52ECDE7 /* Pods_RunnerTests.framework */; }; + C00753EECC149CA245836275 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 68F38BAC755C5AD9D75FABDC /* GoogleService-Info.plist */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* pipeline_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = pipeline_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 450310D9B717CF9798DF9983 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 4C6E70E606BD35A2DDE28810 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 68F38BAC755C5AD9D75FABDC /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 80B3CDA44BB01B7665B13AC4 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 80C48A48FDB72942482DF20F /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 92A5244FE5D2154A5DCF67B1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + BD0A02E97A5F21BBF52ECDE7 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C6DA53DEF649E7FAF765B034 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + EAA74D9E6B471BF39D05E2D5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 64BEAD2D383FBE055A9293F9 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D21D57616F29244D9E0BEDC /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 2ACCD496E1849E7F631515BA /* Pods */ = { + isa = PBXGroup; + children = ( + 80B3CDA44BB01B7665B13AC4 /* Pods-Runner.debug.xcconfig */, + EAA74D9E6B471BF39D05E2D5 /* Pods-Runner.release.xcconfig */, + 4C6E70E606BD35A2DDE28810 /* Pods-Runner.profile.xcconfig */, + 80C48A48FDB72942482DF20F /* Pods-RunnerTests.debug.xcconfig */, + C6DA53DEF649E7FAF765B034 /* Pods-RunnerTests.release.xcconfig */, + 450310D9B717CF9798DF9983 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 68F38BAC755C5AD9D75FABDC /* GoogleService-Info.plist */, + 2ACCD496E1849E7F631515BA /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* pipeline_example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 92A5244FE5D2154A5DCF67B1 /* Pods_Runner.framework */, + BD0A02E97A5F21BBF52ECDE7 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 99A85E0C4CFD12F263C48149 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 8E689660D67B59A471E12131 /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + AFC4EC65143ED624B99B07E7 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* pipeline_example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + C00753EECC149CA245836275 /* GoogleService-Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 8E689660D67B59A471E12131 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 99A85E0C4CFD12F263C48149 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + AFC4EC65143ED624B99B07E7 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 80C48A48FDB72942482DF20F /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pipeline_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pipeline_example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = C6DA53DEF649E7FAF765B034 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pipeline_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pipeline_example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 450310D9B717CF9798DF9983 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/pipeline_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/pipeline_example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000000..75fc171f713c --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..21a3cc14c74e --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/AppDelegate.swift b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/AppDelegate.swift new file mode 100644 index 000000000000..b3c176141221 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..a2ec33f19f11 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 000000000000..82b6f9d9a33e Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 000000000000..13b35eba55c6 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 000000000000..0a3f5fa40fb3 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 000000000000..bdb57226d5f2 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 000000000000..f083318e09ca Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 000000000000..326c0e72c9d8 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 000000000000..2f1632cfddf3 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Base.lproj/MainMenu.xib b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 000000000000..80e867a4e06b --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/AppInfo.xcconfig b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 000000000000..62772134902e --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = pipeline_example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.pipelineExample + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/Debug.xcconfig b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 000000000000..36b0fd9464f4 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/Release.xcconfig b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 000000000000..dff4f49561c8 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/Warnings.xcconfig b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 000000000000..42bcbf4780b1 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/DebugProfile.entitlements b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 000000000000..dddb8a30c851 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Info.plist b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Info.plist new file mode 100644 index 000000000000..4789daa6a443 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/MainFlutterWindow.swift b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 000000000000..3cc05eb23491 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Release.entitlements b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Release.entitlements new file mode 100644 index 000000000000..852fa1a4728a --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/RunnerTests/RunnerTests.swift b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000000..4fc85d1c0db6 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,10 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml b/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml new file mode 100644 index 000000000000..9aea3d7d2c58 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/pubspec.yaml @@ -0,0 +1,111 @@ +name: pipeline_example +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.10.0-290.4.beta + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + cloud_firestore: + path: .. + firebase_core: ^4.4.0 + flutter: + sdk: flutter + http: ^1.0.0 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + +dependency_overrides: + _flutterfire_internals: + path: ../../../_flutterfire_internals + cloud_firestore: + path: .. + cloud_firestore_platform_interface: + path: ../../cloud_firestore_platform_interface + cloud_firestore_web: + path: ../../cloud_firestore_web + firebase_core: + path: ../../../firebase_core/firebase_core + firebase_core_platform_interface: + path: ../../../firebase_core/firebase_core_platform_interface + firebase_core_web: + path: ../../../firebase_core/firebase_core_web + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/test/widget_test.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/test/widget_test.dart new file mode 100644 index 000000000000..7cae948eade1 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/test/widget_test.dart @@ -0,0 +1,34 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:pipeline_example/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const PipelineExampleApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/test_driver/integration_test.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/test_driver/integration_test.dart new file mode 100644 index 000000000000..5366642ffab1 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/web/favicon.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/favicon.png new file mode 100644 index 000000000000..8aaa46ac1ae2 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/favicon.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-192.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-192.png new file mode 100644 index 000000000000..b749bfef0747 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-192.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-512.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-512.png new file mode 100644 index 000000000000..88cfd48dff11 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-512.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-maskable-192.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-maskable-192.png new file mode 100644 index 000000000000..eb9b4d76e525 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-maskable-192.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-maskable-512.png b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-maskable-512.png new file mode 100644 index 000000000000..d69c56691fbd Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/icons/Icon-maskable-512.png differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/web/index.html b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/index.html new file mode 100644 index 000000000000..1088bdfe5d11 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + pipeline_example + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/web/manifest.json b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/manifest.json new file mode 100644 index 000000000000..17f17dafb5ed --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "pipeline_example", + "short_name": "pipeline_example", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/.gitignore b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/.gitignore new file mode 100644 index 000000000000..d492d0d98c8f --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/CMakeLists.txt b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/CMakeLists.txt new file mode 100644 index 000000000000..0b27cba15e3a --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(pipeline_example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "pipeline_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/flutter/CMakeLists.txt b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/flutter/CMakeLists.txt new file mode 100644 index 000000000000..903f4899d6fc --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/CMakeLists.txt b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/CMakeLists.txt new file mode 100644 index 000000000000..394917c053a0 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/Runner.rc b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/Runner.rc new file mode 100644 index 000000000000..c687adb36fc4 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "pipeline_example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "pipeline_example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "pipeline_example.exe" "\0" + VALUE "ProductName", "pipeline_example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/flutter_window.cpp b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/flutter_window.cpp new file mode 100644 index 000000000000..5ebed39dfc68 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/flutter_window.cpp @@ -0,0 +1,73 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { this->Show(); }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/flutter_window.h b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/flutter_window.h new file mode 100644 index 000000000000..7f8ccba43748 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/flutter_window.h @@ -0,0 +1,37 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/main.cpp b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/main.cpp new file mode 100644 index 000000000000..f465371f6806 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/main.cpp @@ -0,0 +1,46 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t* command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"pipeline_example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/resource.h b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/resource.h new file mode 100644 index 000000000000..212b07c88aed --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/resource.h @@ -0,0 +1,20 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/resources/app_icon.ico b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/resources/app_icon.ico new file mode 100644 index 000000000000..c04e20caf637 Binary files /dev/null and b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/resources/app_icon.ico differ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/runner.exe.manifest b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/runner.exe.manifest new file mode 100644 index 000000000000..153653e8d67f --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/utils.cpp b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/utils.cpp new file mode 100644 index 000000000000..f3c6a5e94098 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE* unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = + ::WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, + nullptr, 0, nullptr, nullptr) - + 1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, input_length, + utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/utils.h b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/utils.h new file mode 100644 index 000000000000..b2cf607427d7 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/utils.h @@ -0,0 +1,23 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/win32_window.cpp b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/win32_window.cpp new file mode 100644 index 000000000000..e25d13f9d076 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/win32_window.cpp @@ -0,0 +1,284 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: +/// https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = + L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { ++g_active_window_count; } + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { return ShowWindow(window_handle_, SW_SHOWNORMAL); } + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { return window_handle_; } + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = + RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr, + &light_mode, &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/win32_window.h b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/win32_window.h new file mode 100644 index 000000000000..190ad00d51e4 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/windows/runner/win32_window.h @@ -0,0 +1,104 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_aggregate_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_aggregate_test.dart new file mode 100644 index 000000000000..bb0c1346bd90 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_aggregate_test.dart @@ -0,0 +1,149 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './mock.dart'; + +void main() { + setupCloudFirestoreMocks(); + + setUpAll(() async { + await Firebase.initializeApp(); + }); + + group('PipelineAggregateFunction', () { + test('CountAll has name count_all and simple toMap', () { + final fn = CountAll(); + expect(fn.name, 'count_all'); + expect(fn.toMap(), {'name': 'count_all'}); + }); + + test('Count serializes with expression', () { + final fn = Count(Field('amount')); + expect(fn.toMap(), { + 'name': 'count', + 'args': { + 'expression': { + 'name': 'field', + 'args': {'field': 'amount'}, + }, + }, + }); + }); + + test('Sum serializes with expression', () { + final fn = Sum(Field('total')); + expect(fn.toMap(), { + 'name': 'sum', + 'args': { + 'expression': { + 'name': 'field', + 'args': {'field': 'total'}, + }, + }, + }); + }); + + test('Average serializes with expression', () { + final fn = Average(Field('score')); + expect(fn.toMap(), { + 'name': 'average', + 'args': { + 'expression': { + 'name': 'field', + 'args': {'field': 'score'}, + }, + }, + }); + }); + + test('CountDistinct serializes with expression', () { + final fn = CountDistinct(Field('category')); + expect(fn.toMap(), { + 'name': 'count_distinct', + 'args': { + 'expression': { + 'name': 'field', + 'args': {'field': 'category'}, + }, + }, + }); + }); + + test('Minimum serializes with expression', () { + final fn = Minimum(Field('price')); + expect(fn.toMap(), { + 'name': 'minimum', + 'args': { + 'expression': { + 'name': 'field', + 'args': {'field': 'price'}, + }, + }, + }); + }); + + test('Maximum serializes with expression', () { + final fn = Maximum(Field('price')); + expect(fn.toMap(), { + 'name': 'maximum', + 'args': { + 'expression': { + 'name': 'field', + 'args': {'field': 'price'}, + }, + }, + }); + }); + }); + + group('AliasedAggregateFunction', () { + test('toMap includes alias and aggregate_function', () { + final aliased = CountAll().as('totalCount'); + expect(aliased.alias, 'totalCount'); + final map = aliased.toMap(); + expect(map['name'], 'alias'); + expect(map['args']['alias'], 'totalCount'); + expect(map['args']['aggregate_function'], {'name': 'count_all'}); + }); + + test('wraps expression-based aggregate', () { + final aliased = Sum(Field('amount')).as('total'); + expect(aliased.alias, 'total'); + final map = aliased.toMap(); + expect(map['args']['aggregate_function']['name'], 'sum'); + }); + }); + + group('AggregateStageOptions', () { + test('toMap with accumulators only', () { + final options = AggregateStageOptions( + accumulators: [CountAll().as('count')], + ); + final map = options.toMap(); + expect(map['accumulators'], hasLength(1)); + expect(map.containsKey('groups'), isFalse); + }); + + test('toMap with accumulators and groups', () { + final options = AggregateStageOptions( + accumulators: [Sum(Field('x')).as('sumX')], + groups: [Field('category')], + ); + final map = options.toMap(); + expect(map['accumulators'], hasLength(1)); + expect(map['groups'], hasLength(1)); + }); + }); + + group('AggregateOptions', () { + test('toMap returns empty map', () { + final options = AggregateOptions(); + expect(options.toMap(), isEmpty); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_distance_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_distance_test.dart new file mode 100644 index 000000000000..b4d85b489ee5 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_distance_test.dart @@ -0,0 +1,29 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DistanceMeasure', () { + test('cosine has expected name', () { + expect(DistanceMeasure.cosine.name, 'cosine'); + }); + + test('euclidean has expected name', () { + expect(DistanceMeasure.euclidean.name, 'euclidean'); + }); + + test('dotProduct has expected name', () { + expect(DistanceMeasure.dotProduct.name, 'dotProduct'); + }); + + test('has exactly three values', () { + expect(DistanceMeasure.values, hasLength(3)); + expect(DistanceMeasure.values, contains(DistanceMeasure.cosine)); + expect(DistanceMeasure.values, contains(DistanceMeasure.euclidean)); + expect(DistanceMeasure.values, contains(DistanceMeasure.dotProduct)); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_execute_options_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_execute_options_test.dart new file mode 100644 index 000000000000..cdd91f7b32c7 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_execute_options_test.dart @@ -0,0 +1,21 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('IndexMode', () { + test('recommended has expected name', () { + expect(IndexMode.recommended.name, 'recommended'); + }); + }); + + group('ExecuteOptions', () { + test('default indexMode is recommended', () { + const options = ExecuteOptions(); + expect(options.indexMode, IndexMode.recommended); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart new file mode 100644 index 000000000000..6bac97d8db08 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart @@ -0,0 +1,805 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:typed_data'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './mock.dart'; + +void main() { + setupCloudFirestoreMocks(); + + late FirebaseFirestore firestore; + + setUpAll(() async { + await Firebase.initializeApp(); + firestore = FirebaseFirestore.instance; + }); + + group('Field', () { + test('toMap returns field name structure', () { + final expr = Field('name'); + expect(expr.toMap(), { + 'name': 'field', + 'args': {'field': 'name'}, + }); + }); + + test('nested field path serializes correctly', () { + final expr = Field('user.profile.displayName'); + expect(expr.toMap()['args']['field'], 'user.profile.displayName'); + }); + }); + + group('Constant', () { + test('toMap for null', () { + final expr = Constant(null); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': null}, + }); + }); + + test('toMap for number', () { + final expr = Constant(42); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': 42}, + }); + }); + + test('toMap for double', () { + final expr = Constant(3.14); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': 3.14}, + }); + }); + + test('toMap for string', () { + final expr = Constant('hello'); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': 'hello'}, + }); + }); + + test('toMap for bool', () { + final expr = Constant(true); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': true}, + }); + }); + + test('toMap for DateTime', () { + final dt = DateTime.utc(2025, 1, 15); + final expr = Constant(dt); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': dt}, + }); + }); + + test('toMap for Timestamp', () { + final ts = Timestamp.fromDate(DateTime.utc(2025, 3, 10)); + final expr = Constant(ts); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': ts}, + }); + }); + + test('toMap for GeoPoint', () { + const gp = GeoPoint(52, 4); + final expr = Constant(gp); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': gp}, + }); + }); + + test('toMap for List (bytes)', () { + final bytes = [1, 2, 3]; + final expr = Constant(bytes); + expect( + expr.toMap(), + { + 'name': 'constant', + 'args': { + 'value': [1, 2, 3], + }, + }, + ); + }); + + test('toMap for Blob', () { + final blob = Blob(Uint8List.fromList([1, 2, 3])); + final expr = Constant(blob); + expect( + expr.toMap(), + { + 'name': 'constant', + 'args': { + 'value': blob, + }, + }, + ); + }); + + test('toMap for DocumentReference serializes path', () { + final ref = firestore.collection('users').doc('alice'); + final expr = Constant(ref); + expect(expr.toMap(), { + 'name': 'constant', + 'args': { + 'value': { + 'path': 'users/alice', + }, + }, + }); + }); + + test('toMap for VectorValue', () { + const vec = VectorValue([1.0, 2.0, 3.0]); + final expr = Constant(vec); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': vec}, + }); + }); + + test('throws ArgumentError for invalid value type', () { + expect( + () => Constant({'key': 'value'}), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('Constant value must be'), contains('Got:')), + ), + ), + ); + }); + + test('throws ArgumentError for List (not List)', () { + expect( + () => Constant(['a', 'b']), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Got:'), + ), + ), + ); + }); + }); + + group('Expression static constructors', () { + test('Expression.field() returns Field with path', () { + final expr = Expression.field('amount'); + expect(expr, isA()); + expect(expr.toMap()['args']['field'], 'amount'); + }); + + test('Expression.constant() wraps value', () { + final expr = Expression.constant(100); + expect(expr.toMap(), { + 'name': 'constant', + 'args': {'value': 100}, + }); + }); + }); + + group('BooleanExpression from Field', () { + test('equal serializes correctly', () { + final expr = Field('age').equal(Constant(18)); + expect(expr.toMap(), { + 'name': 'equal', + 'args': { + 'left': { + 'name': 'field', + 'args': {'field': 'age'}, + }, + 'right': { + 'name': 'constant', + 'args': {'value': 18}, + }, + }, + }); + }); + + test('greaterThan serializes correctly', () { + final expr = Field('score').greaterThan(Constant(0)); + expect(expr.toMap(), { + 'name': 'greater_than', + 'args': { + 'left': { + 'name': 'field', + 'args': {'field': 'score'}, + }, + 'right': { + 'name': 'constant', + 'args': {'value': 0}, + }, + }, + }); + }); + + test('exists serializes correctly', () { + final expr = Field('email').exists(); + expect(expr.toMap(), { + 'name': 'exists', + 'args': { + 'expression': { + 'name': 'field', + 'args': {'field': 'email'}, + }, + }, + }); + }); + + test('notEqual serializes correctly', () { + final expr = Field('x').notEqual(Constant(0)); + expect(expr.toMap(), { + 'name': 'not_equal', + 'args': { + 'left': { + 'name': 'field', + 'args': {'field': 'x'}, + }, + 'right': { + 'name': 'constant', + 'args': {'value': 0}, + }, + }, + }); + }); + + test('lessThan serializes correctly', () { + final expr = Field('n').lessThan(Constant(10)); + expect(expr.toMap()['name'], 'less_than'); + expect(expr.toMap()['args']['left']['args']['field'], 'n'); + expect(expr.toMap()['args']['right']['args']['value'], 10); + }); + + test('lessThanOrEqual serializes correctly', () { + final expr = Field('n').lessThanOrEqual(Constant(5)); + expect(expr.toMap()['name'], 'less_than_or_equal'); + }); + + test('greaterThanOrEqual serializes correctly', () { + final expr = Field('n').greaterThanOrEqual(Constant(1)); + expect(expr.toMap()['name'], 'greater_than_or_equal'); + }); + }); + + group('Ordering from Expression', () { + test('ascending() returns Ordering with asc', () { + final ordering = Field('name').ascending(); + expect(ordering.direction, OrderDirection.asc); + expect(ordering.toMap()['order_direction'], 'asc'); + }); + + test('descending() returns Ordering with desc', () { + final ordering = Field('created').descending(); + expect(ordering.direction, OrderDirection.desc); + expect(ordering.toMap()['order_direction'], 'desc'); + }); + }); + + group('Aliased expression', () { + test('as() wraps expression with alias', () { + final aliased = Field('total').as('sumTotal'); + expect(aliased.toMap(), { + 'name': 'alias', + 'args': { + 'alias': 'sumTotal', + 'expression': { + 'name': 'field', + 'args': {'field': 'total'}, + }, + }, + }); + }); + }); + + group('Expression static boolean helpers', () { + test('Expression.equalStatic produces equal expression', () { + final expr = Expression.equalStatic( + Field('a'), + Constant(1), + ); + expect(expr.toMap()['name'], 'equal'); + }); + + test('Expression.not inverts boolean expression', () { + final inner = Field('active').equal(Constant(true)); + final expr = Expression.not(inner); + expect(expr.toMap(), { + 'name': 'not', + 'args': { + 'expression': { + 'name': 'equal', + 'args': { + 'left': { + 'name': 'field', + 'args': {'field': 'active'}, + }, + 'right': { + 'name': 'constant', + 'args': {'value': true}, + }, + }, + }, + }, + }); + }); + }); + + group('Logic expressions (and, or, xor)', () { + test('Expression.and serializes correctly', () { + final a = Field('a').equal(Constant(1)); + final b = Field('b').equal(Constant(2)); + final expr = Expression.and(a, b); + expect(expr.toMap()['name'], 'and'); + expect(expr.toMap()['args']['expressions'], hasLength(2)); + }); + + test('Expression.or serializes correctly', () { + final a = Field('x').greaterThan(Constant(0)); + final b = Field('y').lessThan(Constant(0)); + final expr = Expression.or(a, b); + expect(expr.toMap()['name'], 'or'); + expect(expr.toMap()['args']['expressions'], hasLength(2)); + }); + + test('Expression.xor serializes correctly', () { + final a = Field('p').equal(Constant(true)); + final b = Field('q').equal(Constant(true)); + final expr = Expression.xor(a, b); + expect(expr.toMap()['name'], 'xor'); + expect(expr.toMap()['args']['expressions'], hasLength(2)); + }); + }); + + group('Conditional expression', () { + test('Expression.conditional serializes correctly', () { + final cond = Field('flag').equal(Constant(true)); + final thenExpr = Constant('yes'); + final elseExpr = Constant('no'); + final expr = Expression.conditional(cond, thenExpr, elseExpr); + expect(expr.toMap(), { + 'name': 'conditional', + 'args': { + 'condition': cond.toMap(), + 'then': thenExpr.toMap(), + 'else': elseExpr.toMap(), + }, + }); + }); + }); + + group('ifAbsent and ifError', () { + test('ifAbsent serializes correctly', () { + final base = Field('optional'); + final fallback = Constant('default'); + final expr = base.ifAbsent(fallback); + expect(expr.toMap(), { + 'name': 'if_absent', + 'args': { + 'expression': base.toMap(), + 'else': fallback.toMap(), + }, + }); + }); + + test('Expression.ifAbsentValueStatic serializes correctly', () { + final expr = Expression.ifAbsentValueStatic(Field('a'), 0); + expect(expr.toMap()['name'], 'if_absent'); + }); + + test('ifError serializes correctly', () { + final base = Field('risky'); + final catchExpr = Constant('error'); + final expr = base.ifError(catchExpr); + expect(expr.toMap(), { + 'name': 'if_error', + 'args': { + 'expression': base.toMap(), + 'catch': catchExpr.toMap(), + }, + }); + }); + }); + + group('Presence and error checks', () { + test('Expression.isAbsentStatic serializes correctly', () { + final expr = Expression.isAbsentStatic(Field('maybe')); + expect(expr.toMap(), { + 'name': 'is_absent', + 'args': {'expression': Field('maybe').toMap()}, + }); + }); + + test('Expression.isErrorStatic serializes correctly', () { + final expr = Expression.isErrorStatic(Field('x')); + expect(expr.toMap(), { + 'name': 'is_error', + 'args': {'expression': Field('x').toMap()}, + }); + }); + + test('Expression.existsField serializes correctly', () { + final expr = Expression.existsField('email'); + expect(expr.toMap()['name'], 'exists'); + }); + }); + + group('String expressions', () { + test('concat serializes correctly', () { + final expr = Field('first').concat([Constant(' '), Field('last')]); + expect(expr.toMap(), { + 'name': 'concat', + 'args': { + 'expressions': [ + Field('first').toMap(), + Constant(' ').toMap(), + Field('last').toMap(), + ], + }, + }); + }); + + test('length serializes correctly', () { + final expr = Field('title').length(); + expect(expr.toMap(), { + 'name': 'length', + 'args': {'expression': Field('title').toMap()}, + }); + }); + + test('toLowerCase serializes correctly', () { + final expr = Field('name').toLowerCase(); + expect(expr.toMap()['name'], 'to_lower_case'); + expect(expr.toMap()['args']['expression']['args']['field'], 'name'); + }); + + test('toUpperCase serializes correctly', () { + final expr = Field('code').toUpperCase(); + expect(expr.toMap()['name'], 'to_upper_case'); + }); + + test('trim serializes correctly', () { + final expr = Field('input').trim(); + expect(expr.toMap(), { + 'name': 'trim', + 'args': {'expression': Field('input').toMap()}, + }); + }); + + test('substring serializes correctly', () { + final expr = Field('text').substring(Constant(0), Constant(5)); + expect(expr.toMap(), { + 'name': 'substring', + 'args': { + 'expression': Field('text').toMap(), + 'start': Constant(0).toMap(), + 'end': Constant(5).toMap(), + }, + }); + }); + + test('stringReplaceAll serializes correctly', () { + final expr = + Field('s').stringReplaceAll(Constant('old'), Constant('new')); + expect(expr.toMap(), { + 'name': 'string_replace_all', + 'args': { + 'expression': Field('s').toMap(), + 'find': Constant('old').toMap(), + 'replacement': Constant('new').toMap(), + }, + }); + }); + + test('split serializes correctly', () { + final expr = Field('csv').split(Constant(',')); + expect(expr.toMap()['name'], 'split'); + expect(expr.toMap()['args']['expression']['args']['field'], 'csv'); + expect(expr.toMap()['args']['delimiter']['args']['value'], ','); + }); + + test('join serializes correctly', () { + final arr = Expression.array([Field('a'), Field('b')]); + final expr = arr.join(Constant('-')); + expect(expr.toMap(), { + 'name': 'join', + 'args': { + 'expression': arr.toMap(), + 'delimiter': Constant('-').toMap(), + }, + }); + }); + }); + + group('Array expressions', () { + test('Expression.array serializes correctly', () { + final expr = Expression.array([Constant(1), Constant(2), Field('x')]); + expect(expr.toMap(), { + 'name': 'array', + 'args': { + 'elements': [ + Constant(1).toMap(), + Constant(2).toMap(), + Field('x').toMap(), + ], + }, + }); + }); + + test('arrayContainsValue serializes correctly', () { + final expr = Field('tags').arrayContainsValue(Constant('flutter')); + expect(expr.toMap(), { + 'name': 'array_contains', + 'args': { + 'array': Field('tags').toMap(), + 'element': Constant('flutter').toMap(), + }, + }); + }); + + test('arrayContainsAny serializes correctly', () { + final expr = + Field('tags').arrayContainsAny([Constant('a'), Constant('b')]); + expect(expr.toMap()['name'], 'array_contains_any'); + expect(expr.toMap()['args']['array']['args']['field'], 'tags'); + expect(expr.toMap()['args']['values'], hasLength(2)); + }); + + test('arrayContainsAll with list serializes correctly', () { + final expr = Field('tags').arrayContainsAll(['a', Constant('b')]); + expect(expr.toMap(), { + 'name': 'array_contains_all', + 'args': { + 'array': Field('tags').toMap(), + 'values': [ + Constant('a').toMap(), + Constant('b').toMap(), + ], + }, + }); + }); + + test('arrayContainsAllFrom with array expression serializes correctly', () { + final elements = Expression.array([Field('tag1'), Constant('tag2')]); + final expr = Field('tags').arrayContainsAllFrom(elements); + expect(expr.toMap(), { + 'name': 'array_contains_all', + 'args': { + 'array': Field('tags').toMap(), + 'array_expression': elements.toMap(), + }, + }); + }); + + test( + 'Expression.arrayContainsAllWithExpression(array, arrayExpression) serializes correctly', + () { + final arrayExpr = + Expression.array([Field('required'), Constant('admin')]); + final expr = Expression.arrayContainsAllWithExpression( + Field('permissions'), + arrayExpr, + ); + expect(expr.toMap(), { + 'name': 'array_contains_all', + 'args': { + 'array': Field('permissions').toMap(), + 'array_expression': arrayExpr.toMap(), + }, + }); + }); + + test('Expression.arrayContainsAllValues(array, list) serializes correctly', + () { + final expr = Expression.arrayContainsAllValues( + Field('tags'), + [Constant('flutter'), Constant('dart')], + ); + expect(expr.toMap()['name'], 'array_contains_all'); + expect(expr.toMap()['args']['values'], hasLength(2)); + }); + + test('Expression.arrayContainsAllField serializes correctly', () { + final required = Expression.array([Field('requiredPermissions')]); + final expr = Expression.arrayContainsAllField('permissions', required); + expect(expr.toMap(), { + 'name': 'array_contains_all', + 'args': { + 'array': Field('permissions').toMap(), + 'array_expression': required.toMap(), + }, + }); + }); + + test('arrayLength serializes correctly', () { + final expr = Field('items').arrayLength(); + expect(expr.toMap(), { + 'name': 'array_length', + 'args': {'expression': Field('items').toMap()}, + }); + }); + + test('arrayConcat serializes correctly', () { + final a = Expression.array([Constant(1)]); + final b = Expression.array([Constant(2)]); + final expr = a.arrayConcat(b); + expect(expr.toMap(), { + 'name': 'array_concat', + 'args': { + 'first': a.toMap(), + 'second': b.toMap(), + }, + }); + }); + + test('arraySum serializes correctly', () { + final expr = Field('values').arraySum(); + expect(expr.toMap()['name'], 'array_sum'); + expect(expr.toMap()['args']['expression']['args']['field'], 'values'); + }); + + test('arrayReverse serializes correctly', () { + final expr = Field('order').arrayReverse(); + expect(expr.toMap()['name'], 'array_reverse'); + expect(expr.toMap()['args']['expression']['args']['field'], 'order'); + }); + }); + + group('Numeric expressions', () { + test('add serializes correctly', () { + final expr = Field('a').add(Field('b')); + expect(expr.toMap(), { + 'name': 'add', + 'args': { + 'left': Field('a').toMap(), + 'right': Field('b').toMap(), + }, + }); + }); + + test('subtract serializes correctly', () { + final expr = Field('x').subtract(Constant(1)); + expect(expr.toMap()['name'], 'subtract'); + expect(expr.toMap()['args']['left']['args']['field'], 'x'); + expect(expr.toMap()['args']['right']['args']['value'], 1); + }); + + test('multiply serializes correctly', () { + final expr = Field('qty').multiply(Field('price')); + expect(expr.toMap()['name'], 'multiply'); + }); + + test('divide serializes correctly', () { + final expr = Field('total').divide(Constant(2)); + expect(expr.toMap()['name'], 'divide'); + }); + + test('modulo serializes correctly', () { + final expr = Field('n').modulo(Constant(10)); + expect(expr.toMap()['name'], 'modulo'); + }); + + test('abs serializes correctly', () { + final expr = Field('diff').abs(); + expect(expr.toMap()['name'], 'abs'); + }); + }); + + group('Structure (nullValue, map)', () { + test('Expression.nullValue serializes correctly', () { + final expr = Expression.nullValue(); + expect(expr.toMap(), { + 'name': 'null', + 'args': {'value': null}, + }); + }); + + test('Expression.map serializes correctly', () { + final expr = Expression.map({ + 'k1': Constant(1), + 'k2': Field('v'), + }); + expect(expr.toMap(), { + 'name': 'map', + 'args': { + 'data': { + 'k1': Constant(1).toMap(), + 'k2': Field('v').toMap(), + }, + }, + }); + }); + }); + + group('Timestamp expressions', () { + test('Expression.currentTimestamp serializes correctly', () { + final expr = Expression.currentTimestamp(); + final map = expr.toMap(); + expect(map['name'], 'current_timestamp'); + }); + + test('timestampAddLiteral serializes correctly', () { + final ts = Field('created'); + final expr = Expression.timestampAddLiteral(ts, 'day', 1); + expect(expr.toMap(), { + 'name': 'timestamp_add', + 'args': { + 'timestamp': ts.toMap(), + 'unit': 'day', + 'amount': Constant(1).toMap(), + }, + }); + }); + + test('timestampTruncate serializes correctly', () { + final expr = Expression.timestampTruncate(Field('ts'), 'day'); + expect(expr.toMap(), { + 'name': 'timestamp_truncate', + 'args': { + 'timestamp': Field('ts').toMap(), + 'unit': 'day', + }, + }); + }); + }); + + group('Document and equality helpers', () { + test('Expression.documentIdFromRef serializes correctly', () { + final ref = firestore.collection('users').doc('alice'); + final expr = Expression.documentIdFromRef(ref); + expect(expr.toMap(), { + 'name': 'document_id_from_ref', + 'args': {'doc_ref': 'users/alice'}, + }); + }); + + test('equalAny serializes correctly', () { + final expr = Expression.equalAny(Field('status'), ['a', 'b']); + expect(expr.toMap(), { + 'name': 'equal_any', + 'args': { + 'value': Field('status').toMap(), + 'values': [Constant('a').toMap(), Constant('b').toMap()], + }, + }); + }); + + test('notEqualAny serializes correctly', () { + final expr = Expression.notEqualAny(Field('role'), ['admin']); + expect(expr.toMap(), { + 'name': 'not_equal_any', + 'args': { + 'value': Field('role').toMap(), + 'values': [Constant('admin').toMap()], + }, + }); + }); + }); + + group('asBoolean', () { + test('asBoolean serializes correctly', () { + final expr = Field('flag').asBoolean(); + expect(expr.toMap(), { + 'name': 'as_boolean', + 'args': {'expression': Field('flag').toMap()}, + }); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_ordering_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_ordering_test.dart new file mode 100644 index 000000000000..ccfeac4b922d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_ordering_test.dart @@ -0,0 +1,62 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './mock.dart'; + +void main() { + setupCloudFirestoreMocks(); + + setUpAll(() async { + await Firebase.initializeApp(); + }); + + group('OrderDirection', () { + test('asc has expected name', () { + expect(OrderDirection.asc.name, 'asc'); + }); + + test('desc has expected name', () { + expect(OrderDirection.desc.name, 'desc'); + }); + }); + + group('Ordering', () { + test('toMap() serializes ascending order', () { + final ordering = Ordering(Field('name'), OrderDirection.asc); + expect(ordering.toMap(), { + 'expression': { + 'name': 'field', + 'args': {'field': 'name'}, + }, + 'order_direction': 'asc', + }); + }); + + test('toMap() serializes descending order', () { + final ordering = Ordering(Field('score'), OrderDirection.desc); + expect(ordering.toMap(), { + 'expression': { + 'name': 'field', + 'args': {'field': 'score'}, + }, + 'order_direction': 'desc', + }); + }); + + test('toMap() includes expression toMap() result', () { + final ordering = Ordering(Constant(42), OrderDirection.asc); + expect(ordering.toMap(), { + 'expression': { + 'name': 'constant', + 'args': {'value': 42}, + }, + 'order_direction': 'asc', + }); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_sample_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_sample_test.dart new file mode 100644 index 000000000000..6f4a9c13d927 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_sample_test.dart @@ -0,0 +1,56 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './mock.dart'; + +void main() { + setupCloudFirestoreMocks(); + + late FirebaseFirestore firestore; + + setUpAll(() async { + await Firebase.initializeApp(); + firestore = FirebaseFirestore.instance; + }); + + group('PipelineSample', () { + group('withSize()', () { + test('serializes as type size with value', () { + final pipeline = firestore + .pipeline() + .collection('users') + .sample(PipelineSample.withSize(100)); + final stage = pipeline.stages.last; + expect(stage['stage'], 'sample'); + expect(stage['args']['type'], 'size'); + expect(stage['args']['value'], 100); + }); + + test('accepts zero size', () { + final pipeline = firestore + .pipeline() + .collection('users') + .sample(PipelineSample.withSize(0)); + expect(pipeline.stages.last['args']['value'], 0); + }); + }); + + group('withPercentage()', () { + test('serializes as type percentage with value', () { + final pipeline = firestore + .pipeline() + .collection('users') + .sample(PipelineSample.withPercentage(0.6)); + final stage = pipeline.stages.last; + expect(stage['stage'], 'sample'); + expect(stage['args']['type'], 'percentage'); + expect(stage['args']['value'], 0.6); + }); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_snapshot_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_snapshot_test.dart new file mode 100644 index 000000000000..5d727129ab44 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_snapshot_test.dart @@ -0,0 +1,80 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './mock.dart'; + +void main() { + setupCloudFirestoreMocks(); + + late FirebaseFirestore firestore; + + setUpAll(() async { + await Firebase.initializeApp(); + firestore = FirebaseFirestore.instance; + }); + + group('PipelineResult', () { + test('stores and returns data via data()', () { + final data = {'name': 'Alice', 'score': 100}; + final result = PipelineResult(data: data); + expect(result.data(), data); + }); + + test('data() returns null when data is null', () { + final result = PipelineResult(); + expect(result.data(), isNull); + }); + + test('stores document reference when provided', () { + final docRef = firestore.collection('users').doc('123'); + final result = PipelineResult(document: docRef); + expect(result.document, docRef); + }); + + test('document is null for aggregate-only result', () { + final result = PipelineResult( + data: {'count': 42}, + ); + expect(result.document, isNull); + }); + + test('stores createTime and updateTime', () { + final create = DateTime(2026); + final update = DateTime(2026, 1, 2); + final result = PipelineResult( + createTime: create, + updateTime: update, + ); + expect(result.createTime, create); + expect(result.updateTime, update); + }); + + test('stores empty data map', () { + final result = PipelineResult(data: {}); + expect(result.data(), isEmpty); + expect(result.data(), isNotNull); + }); + + test('stores all fields together', () { + final docRef = firestore.collection('orders').doc('o1'); + final dateTime1 = DateTime(2026, 2); + final dateTime2 = DateTime(2026, 2, 2); + final data = {'total': 99.99}; + final result = PipelineResult( + document: docRef, + createTime: dateTime1, + updateTime: dateTime2, + data: data, + ); + expect(result.document, docRef); + expect(result.createTime, dateTime1); + expect(result.updateTime, dateTime2); + expect(result.data(), data); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_source_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_source_test.dart new file mode 100644 index 000000000000..a7279c1b1896 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_source_test.dart @@ -0,0 +1,155 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './mock.dart'; + +void main() { + setupCloudFirestoreMocks(); + + late FirebaseFirestore firestore; + + setUpAll(() async { + await Firebase.initializeApp(); + firestore = FirebaseFirestore.instance; + }); + + group('PipelineSource', () { + test('pipeline() returns a PipelineSource', () { + final source = firestore.pipeline(); + expect(source, isA()); + }); + + group('collection()', () { + test('creates pipeline with collection stage', () { + final pipeline = firestore.pipeline().collection('users'); + expect(pipeline.stages, hasLength(1)); + expect(pipeline.stages.first, { + 'stage': 'collection', + 'args': {'path': 'users'}, + }); + }); + + test('accepts nested collection path', () { + final pipeline = firestore.pipeline().collection('users/abc123/orders'); + expect(pipeline.stages.first['args'], {'path': 'users/abc123/orders'}); + }); + + test('throws on empty path', () { + expect( + () => firestore.pipeline().collection(''), + throwsArgumentError, + ); + }); + + test('throws on path containing double slash', () { + expect( + () => firestore.pipeline().collection('users//posts'), + throwsArgumentError, + ); + }); + }); + + group('collectionReference()', () { + test('creates pipeline from collection reference path', () { + final colRef = firestore.collection('products'); + final pipeline = firestore.pipeline().collectionReference(colRef); + expect(pipeline.stages, hasLength(1)); + expect(pipeline.stages.first, { + 'stage': 'collection', + 'args': {'path': 'products'}, + }); + }); + + test('uses path from nested collection reference', () { + final colRef = + firestore.collection('users').doc('u1').collection('posts'); + final pipeline = firestore.pipeline().collectionReference(colRef); + expect(pipeline.stages.first['args'], { + 'path': 'users/u1/posts', + }); + }); + }); + + group('collectionGroup()', () { + test('creates pipeline with collection_group stage', () { + final pipeline = firestore.pipeline().collectionGroup('posts'); + expect(pipeline.stages, hasLength(1)); + expect(pipeline.stages.first, { + 'stage': 'collection_group', + 'args': {'path': 'posts'}, + }); + }); + + test('throws on empty collection id', () { + expect( + () => firestore.pipeline().collectionGroup(''), + throwsArgumentError, + ); + }); + + test('throws when collection id contains slash', () { + expect( + () => firestore.pipeline().collectionGroup('users/posts'), + throwsArgumentError, + ); + }); + }); + + group('documents()', () { + test('creates pipeline with documents stage', () { + final docRef = firestore.collection('users').doc('123'); + final pipeline = firestore.pipeline().documents([docRef]); + expect(pipeline.stages, hasLength(1)); + expect(pipeline.stages.first['stage'], 'documents'); + expect( + (pipeline.stages.first['args'] as List).first, + {'path': 'users/123'}, + ); + }); + + test('supports multiple document references', () { + final refs = [ + firestore.collection('c').doc('1'), + firestore.collection('c').doc('2'), + ]; + final pipeline = firestore.pipeline().documents(refs); + final args = pipeline.stages.first['args'] as List; + expect(args, hasLength(2)); + }); + + test('throws on empty list', () { + expect( + () => firestore.pipeline().documents([]), + throwsArgumentError, + ); + }); + }); + + group('database()', () { + test('creates pipeline with database stage only', () { + final pipeline = firestore.pipeline().database(); + expect(pipeline.stages, hasLength(1)); + expect(pipeline.stages.first, {'stage': 'database'}); + }); + }); + + group('chaining from source', () { + test('returned pipeline accepts stage methods', () { + final pipeline = firestore + .pipeline() + .collection('users') + .where(Field('active').equal(Constant(true))) + .limit(5); + expect(pipeline.stages, hasLength(3)); + expect(pipeline.stages[0]['stage'], 'collection'); + expect(pipeline.stages[1]['stage'], 'where'); + expect(pipeline.stages[2]['stage'], 'limit'); + }); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart new file mode 100644 index 000000000000..876d53e368e2 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart @@ -0,0 +1,457 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import './mock.dart'; + +void main() { + setupCloudFirestoreMocks(); + + late FirebaseFirestore firestore; + + setUpAll(() async { + await Firebase.initializeApp(); + firestore = FirebaseFirestore.instance; + }); + + group('Pipeline stages serialization', () { + group('_CollectionPipelineStage', () { + test('serializes collection path correctly', () { + final pipeline = firestore.pipeline().collection('users'); + expect(pipeline.stages.first, { + 'stage': 'collection', + 'args': {'path': 'users'}, + }); + }); + + test('serializes nested collection path', () { + final pipeline = firestore.pipeline().collection('users/123/posts'); + expect(pipeline.stages.first, { + 'stage': 'collection', + 'args': {'path': 'users/123/posts'}, + }); + }); + + test('throws on empty collection path', () { + expect( + () => firestore.pipeline().collection(''), + throwsArgumentError, + ); + }); + + test('throws on collection path with double slashes', () { + expect( + () => firestore.pipeline().collection('users//posts'), + throwsArgumentError, + ); + }); + }); + + group('_CollectionGroupPipelineStage', () { + test('serializes collection group path correctly', () { + final pipeline = firestore.pipeline().collectionGroup('posts'); + expect(pipeline.stages.first, { + 'stage': 'collection_group', + 'args': {'path': 'posts'}, + }); + }); + + test('throws on empty collection group id', () { + expect( + () => firestore.pipeline().collectionGroup(''), + throwsArgumentError, + ); + }); + + test('throws on collection group id containing slash', () { + expect( + () => firestore.pipeline().collectionGroup('users/posts'), + throwsArgumentError, + ); + }); + }); + + group('_DatabasePipelineStage', () { + test('serializes database stage correctly', () { + final pipeline = firestore.pipeline().database(); + expect(pipeline.stages.first, {'stage': 'database'}); + }); + }); + + group('_DocumentsPipelineStage', () { + test('serializes document references', () { + final docRef = firestore.collection('users').doc('123'); + final pipeline = firestore.pipeline().documents([docRef]); + expect(pipeline.stages.first['stage'], 'documents'); + final args = pipeline.stages.first['args'] as List; + expect(args, hasLength(1)); + expect(args.first, {'path': 'users/123'}); + }); + + test('serializes multiple document references', () { + final ref1 = firestore.collection('users').doc('1'); + final ref2 = firestore.collection('users').doc('2'); + final pipeline = firestore.pipeline().documents([ref1, ref2]); + final args = pipeline.stages.first['args'] as List; + expect(args, hasLength(2)); + }); + + test('throws on empty documents list', () { + expect( + () => firestore.pipeline().documents([]), + throwsArgumentError, + ); + }); + }); + + group('_WhereStage', () { + test('serializes where stage with a field filter', () { + final pipeline = firestore + .pipeline() + .collection('users') + .where(Field('age').greaterThan(Constant(18))); + final whereStage = pipeline.stages.last; + expect(whereStage['stage'], 'where'); + expect(whereStage['args'], isA()); + expect(whereStage['args']['expression'], isNotNull); + }); + }); + + group('_SelectStage', () { + test('serializes select stage with fields', () { + final pipeline = firestore + .pipeline() + .collection('users') + .select(Field('name'), Field('age')); + final selectStage = pipeline.stages.last; + expect(selectStage['stage'], 'select'); + expect(selectStage['args']['expressions'], hasLength(2)); + }); + + test('serializes select stage with alias', () { + final pipeline = firestore + .pipeline() + .collection('users') + .select(Field('name').as('userName')); + final selectStage = pipeline.stages.last; + expect(selectStage['stage'], 'select'); + expect(selectStage['args']['expressions'], hasLength(1)); + }); + }); + + group('_AddFieldsStage', () { + test('serializes addFields stage', () { + final pipeline = firestore + .pipeline() + .collection('users') + .addFields(Field('score').as('totalScore')); + final stage = pipeline.stages.last; + expect(stage['stage'], 'add_fields'); + expect(stage['args']['expressions'], hasLength(1)); + }); + + test('serializes addFields with multiple fields', () { + final pipeline = firestore.pipeline().collection('users').addFields( + Field('a').as('x'), + Field('b').as('y'), + Field('c').as('z'), + ); + final stage = pipeline.stages.last; + expect(stage['stage'], 'add_fields'); + expect(stage['args']['expressions'], hasLength(3)); + }); + }); + + group('_LimitStage', () { + test('serializes limit stage', () { + final pipeline = firestore.pipeline().collection('users').limit(10); + final stage = pipeline.stages.last; + expect(stage, { + 'stage': 'limit', + 'args': {'limit': 10}, + }); + }); + }); + + group('_OffsetStage', () { + test('serializes offset stage', () { + final pipeline = firestore.pipeline().collection('users').offset(5); + final stage = pipeline.stages.last; + expect(stage, { + 'stage': 'offset', + 'args': {'offset': 5}, + }); + }); + }); + + group('_SortStage', () { + test('serializes sort stage ascending', () { + final pipeline = firestore + .pipeline() + .collection('users') + .sort(Ordering(Field('name'), OrderDirection.asc)); + final stage = pipeline.stages.last; + expect(stage['stage'], 'sort'); + final orderings = stage['args']['orderings'] as List; + expect(orderings, hasLength(1)); + expect(orderings.first['order_direction'], 'asc'); + }); + + test('serializes sort stage descending', () { + final pipeline = firestore + .pipeline() + .collection('users') + .sort(Ordering(Field('score'), OrderDirection.desc)); + final stage = pipeline.stages.last; + final orderings = stage['args']['orderings'] as List; + expect(orderings.first['order_direction'], 'desc'); + }); + + test('serializes multiple orderings', () { + final pipeline = firestore.pipeline().collection('users').sort( + Ordering(Field('lastName'), OrderDirection.asc), + Ordering(Field('firstName'), OrderDirection.asc), + ); + final stage = pipeline.stages.last; + expect( + stage['args']['orderings'] as List, + hasLength(2), + ); + }); + }); + + group('_AggregateStage', () { + test('serializes aggregate stage', () { + final pipeline = firestore + .pipeline() + .collection('orders') + .aggregate(CountAll().as('totalCount')); + final stage = pipeline.stages.last; + expect(stage['stage'], 'aggregate'); + expect( + stage['args']['aggregate_functions'], + hasLength(1), + ); + }); + + test('serializes multiple aggregate functions', () { + final pipeline = firestore.pipeline().collection('orders').aggregate( + CountAll().as('count'), + Sum(Field('amount')).as('total'), + ); + final stage = pipeline.stages.last; + expect( + stage['args']['aggregate_functions'] as List, + hasLength(2), + ); + }); + }); + + group('_AggregateStageWithOptions', () { + test('serializes aggregate stage with accumulators only', () { + final pipeline = + firestore.pipeline().collection('orders').aggregateWithOptions( + AggregateStageOptions( + accumulators: [CountAll().as('count')], + ), + ); + final stage = pipeline.stages.last; + expect(stage['stage'], 'aggregate_with_options'); + final aggregateStage = + stage['args']['aggregate_stage'] as Map; + expect(aggregateStage['accumulators'], hasLength(1)); + expect(aggregateStage.containsKey('groups'), isFalse); + }); + + test('serializes aggregate stage with accumulators and groups', () { + final pipeline = + firestore.pipeline().collection('orders').aggregateWithOptions( + AggregateStageOptions( + accumulators: [ + Sum(Field('amount')).as('total'), + CountAll().as('count'), + ], + groups: [Field('category')], + ), + ); + final stage = pipeline.stages.last; + expect(stage['stage'], 'aggregate_with_options'); + final aggregateStage = + stage['args']['aggregate_stage'] as Map; + expect(aggregateStage['accumulators'], hasLength(2)); + expect(aggregateStage['groups'], hasLength(1)); + }); + + test('includes options map in args', () { + final pipeline = + firestore.pipeline().collection('orders').aggregateWithOptions( + AggregateStageOptions( + accumulators: [CountAll().as('count')], + ), + ); + final stage = pipeline.stages.last; + expect(stage['args'].containsKey('options'), isTrue); + }); + }); + + group('_DistinctStage', () { + test('serializes distinct stage', () { + final pipeline = + firestore.pipeline().collection('users').distinct(Field('country')); + final stage = pipeline.stages.last; + expect(stage['stage'], 'distinct'); + expect(stage['args']['expressions'], hasLength(1)); + }); + }); + + group('_RemoveFieldsStage', () { + test('serializes removeFields stage', () { + final pipeline = firestore + .pipeline() + .collection('users') + .removeFields('password', 'secretToken'); + final stage = pipeline.stages.last; + expect(stage, { + 'stage': 'remove_fields', + 'args': { + 'field_paths': ['password', 'secretToken'], + }, + }); + }); + }); + + group('_ReplaceWithStage', () { + test('serializes replaceWith stage', () { + final pipeline = firestore + .pipeline() + .collection('users') + .replaceWith(Field('profile')); + final stage = pipeline.stages.last; + expect(stage['stage'], 'replace_with'); + expect(stage['args']['expression'], isNotNull); + }); + }); + + group('_SampleStage', () { + test('serializes sample with size', () { + final pipeline = firestore + .pipeline() + .collection('users') + .sample(PipelineSample.withSize(100)); + final stage = pipeline.stages.last; + expect(stage['stage'], 'sample'); + expect(stage['args']['type'], 'size'); + expect(stage['args']['value'], 100); + }); + + test('serializes sample with percentage', () { + final pipeline = firestore + .pipeline() + .collection('users') + .sample(PipelineSample.withPercentage(0.1)); + final stage = pipeline.stages.last; + expect(stage['stage'], 'sample'); + expect(stage['args']['type'], 'percentage'); + expect(stage['args']['value'], 0.1); + }); + }); + + group('_FindNearestStage', () { + test('serializes findNearest without limit', () { + final pipeline = firestore.pipeline().collection('items').findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.cosine, + ); + final stage = pipeline.stages.last; + expect(stage['stage'], 'find_nearest'); + expect(stage['args']['vector_field'], 'embedding'); + expect(stage['args']['vector_value'], [0.1, 0.2, 0.3]); + expect(stage['args']['distance_measure'], 'cosine'); + expect(stage['args'].containsKey('limit'), isFalse); + }); + + test('serializes findNearest with limit', () { + final pipeline = firestore.pipeline().collection('items').findNearest( + Field('embedding'), + [0.1, 0.2, 0.3], + DistanceMeasure.euclidean, + limit: 5, + ); + final stage = pipeline.stages.last; + expect(stage['args']['limit'], 5); + expect(stage['args']['distance_measure'], 'euclidean'); + }); + + test('serializes findNearest with dotProduct distance', () { + final pipeline = firestore.pipeline().collection('items').findNearest( + Field('embedding'), + [1.0, 0.0], + DistanceMeasure.dotProduct, + ); + final stage = pipeline.stages.last; + expect(stage['args']['distance_measure'], 'dotProduct'); + }); + }); + + group('_UnionStage', () { + test('serializes union stage with nested pipeline stages', () { + final innerPipeline = firestore.pipeline().collection('archived_users'); + final pipeline = + firestore.pipeline().collection('users').union(innerPipeline); + final stage = pipeline.stages.last; + expect(stage['stage'], 'union'); + expect(stage['args']['pipeline'], isA()); + expect( + stage['args']['pipeline'] as List, + hasLength(1), + ); + }); + }); + + group('_UnnestStage', () { + test('serializes unnest stage without indexField', () { + final pipeline = firestore + .pipeline() + .collection('users') + .unnest(Field('tags').as('tag')); + final stage = pipeline.stages.last; + expect(stage['stage'], 'unnest'); + expect(stage['args']['expression'], isNotNull); + expect(stage['args'].containsKey('index_field'), isFalse); + }); + + test('serializes unnest stage with indexField', () { + final pipeline = firestore + .pipeline() + .collection('users') + .unnest(Field('tags').as('tag'), 'idx'); + final stage = pipeline.stages.last; + expect(stage['args']['index_field'], 'idx'); + }); + }); + + group('Stage chaining', () { + test('accumulates stages in order', () { + final pipeline = firestore + .pipeline() + .collection('users') + .where(Field('age').greaterThan(Constant(18))) + .select(Field('name')) + .limit(10) + .offset(0); + + expect(pipeline.stages, hasLength(5)); + expect(pipeline.stages[0]['stage'], 'collection'); + expect(pipeline.stages[1]['stage'], 'where'); + expect(pipeline.stages[2]['stage'], 'select'); + expect(pipeline.stages[3]['stage'], 'limit'); + expect(pipeline.stages[4]['stage'], 'offset'); + }); + }); + }); +} diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/cloud_firestore_platform_interface.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/cloud_firestore_platform_interface.dart index 9f9b8e7866e0..2e7e97bec179 100644 --- a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/cloud_firestore_platform_interface.dart +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/cloud_firestore_platform_interface.dart @@ -29,6 +29,8 @@ export 'src/platform_interface/platform_interface_index_definitions.dart'; export 'src/platform_interface/platform_interface_load_bundle_task.dart'; export 'src/platform_interface/platform_interface_load_bundle_task_snapshot.dart'; export 'src/platform_interface/platform_interface_persistent_cache_index_manager.dart'; +export 'src/platform_interface/platform_interface_pipeline.dart'; +export 'src/platform_interface/platform_interface_pipeline_snapshot.dart'; export 'src/platform_interface/platform_interface_query.dart'; export 'src/platform_interface/platform_interface_query_snapshot.dart'; export 'src/platform_interface/platform_interface_transaction.dart'; diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_firestore.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_firestore.dart index 573e5a3c91b9..889ea4c5351b 100644 --- a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_firestore.dart +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_firestore.dart @@ -15,6 +15,8 @@ import 'package:flutter/services.dart'; import 'method_channel_collection_reference.dart'; import 'method_channel_document_reference.dart'; +import 'method_channel_pipeline.dart'; +import 'method_channel_pipeline_snapshot.dart'; import 'method_channel_query.dart'; import 'method_channel_transaction.dart'; import 'method_channel_write_batch.dart'; @@ -350,4 +352,37 @@ class MethodChannelFirebaseFirestore extends FirebaseFirestorePlatform { convertPlatformException(e, stack); } } + + @override + PipelinePlatform pipeline(List> initialStages) { + return MethodChannelPipeline(this, pigeonApp, stages: initialStages); + } + + @override + Future executePipeline( + List> stages, { + Map? options, + }) async { + try { + // Convert stages to Pigeon format (List?>) + final List?> pigeonStages = stages.map((stage) { + return stage.map(MapEntry.new); + }).toList(); + + // Convert options to Pigeon format (Map?) + final Map? pigeonOptions = options?.map( + MapEntry.new, + ); + + final PigeonPipelineSnapshot result = await pigeonChannel.executePipeline( + pigeonApp, + pigeonStages, + pigeonOptions, + ); + + return MethodChannelPipelineSnapshot(this, pigeonApp, result); + } catch (e, stack) { + convertPlatformException(e, stack); + } + } } diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_pipeline.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_pipeline.dart new file mode 100644 index 000000000000..7e0b65e43c7d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_pipeline.dart @@ -0,0 +1,51 @@ +// ignore_for_file: require_trailing_commas, unnecessary_lambdas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart'; +import 'package:cloud_firestore_platform_interface/src/platform_interface/platform_interface_pipeline.dart' + as pipeline; + +/// An implementation of [PipelinePlatform] that uses [MethodChannel] to +/// communicate with Firebase plugins. +class MethodChannelPipeline extends pipeline.PipelinePlatform { + /// Create a [MethodChannelPipeline] from [stages] + MethodChannelPipeline( + FirebaseFirestorePlatform _firestore, + this.pigeonApp, { + List>? stages, + }) : super(_firestore, stages); + + final FirestorePigeonFirebaseApp pigeonApp; + + /// Creates a new instance of [MethodChannelPipeline], however overrides + /// any existing [stages]. + /// + /// This is in place to ensure that changes to a pipeline don't mutate + /// other pipelines. + MethodChannelPipeline _copyWithStages(List> newStages) { + return MethodChannelPipeline( + firestore, + pigeonApp, + stages: List.unmodifiable([ + ...stages, + ...newStages, + ]), + ); + } + + @override + pipeline.PipelinePlatform addStage(Map serializedStage) { + return _copyWithStages([serializedStage]); + } + + @override + Future execute({ + Map? options, + }) async { + return firestore.executePipeline(stages, options: options); + } +} diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_pipeline_snapshot.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_pipeline_snapshot.dart new file mode 100644 index 000000000000..c5f351ef88ed --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_pipeline_snapshot.dart @@ -0,0 +1,83 @@ +// ignore_for_file: require_trailing_commas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart'; +import 'package:cloud_firestore_platform_interface/src/method_channel/method_channel_document_reference.dart'; + +/// An implementation of [PipelineSnapshotPlatform] that uses [MethodChannel] to +/// communicate with Firebase plugins. +class MethodChannelPipelineSnapshot extends PipelineSnapshotPlatform { + final List _results; + final DateTime _executionTime; + + /// Creates a [MethodChannelPipelineSnapshot] from the given [pigeonSnapshot] + MethodChannelPipelineSnapshot( + FirebaseFirestorePlatform firestore, + FirestorePigeonFirebaseApp pigeonApp, + PigeonPipelineSnapshot pigeonSnapshot, + ) : _results = pigeonSnapshot.results + .whereType() + .map((result) => MethodChannelPipelineResult( + firestore, + pigeonApp, + result.documentPath, + result.createTime != null + ? DateTime.fromMillisecondsSinceEpoch(result.createTime!) + : null, + result.updateTime != null + ? DateTime.fromMillisecondsSinceEpoch(result.updateTime!) + : null, + result.data?.cast(), + )) + .toList(), + _executionTime = DateTime.fromMillisecondsSinceEpoch( + pigeonSnapshot.executionTime, + ), + super(); + + @override + List get results => _results; + + @override + DateTime get executionTime => _executionTime; +} + +/// An implementation of [PipelineResultPlatform] that uses [MethodChannel] to +/// communicate with Firebase plugins. +class MethodChannelPipelineResult extends PipelineResultPlatform { + final DocumentReferencePlatform? _document; + final DateTime? _createTime; + final DateTime? _updateTime; + final Map? _data; + + MethodChannelPipelineResult( + FirebaseFirestorePlatform firestore, + FirestorePigeonFirebaseApp pigeonApp, + String? documentPath, + this._createTime, + this._updateTime, + Map? data, + ) : _document = (documentPath != null && documentPath.isNotEmpty) + ? MethodChannelDocumentReference( + firestore, + documentPath, + pigeonApp, + ) + : null, + _data = data, + super(); + + @override + DocumentReferencePlatform? get document => _document; + + @override + DateTime? get createTime => _createTime; + + @override + DateTime? get updateTime => _updateTime; + + @override + Map? get data => _data; +} diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/pigeon/messages.pigeon.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/pigeon/messages.pigeon.dart index 2420b2cfc4b4..662ed9358d9f 100644 --- a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/pigeon/messages.pigeon.dart +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/pigeon/messages.pigeon.dart @@ -301,6 +301,69 @@ class PigeonQuerySnapshot { } } +class PigeonPipelineResult { + PigeonPipelineResult({ + this.documentPath, + this.createTime, + this.updateTime, + this.data, + }); + + String? documentPath; + + int? createTime; + + int? updateTime; + + /// All fields in the result (from PipelineResult.data() on Android). + Map? data; + + Object encode() { + return [ + documentPath, + createTime, + updateTime, + data, + ]; + } + + static PigeonPipelineResult decode(Object result) { + result as List; + return PigeonPipelineResult( + documentPath: result[0] as String?, + createTime: result[1] as int?, + updateTime: result[2] as int?, + data: (result[3] as Map?)?.cast(), + ); + } +} + +class PigeonPipelineSnapshot { + PigeonPipelineSnapshot({ + required this.results, + required this.executionTime, + }); + + List results; + + int executionTime; + + Object encode() { + return [ + results, + executionTime, + ]; + } + + static PigeonPipelineSnapshot decode(Object result) { + result as List; + return PigeonPipelineSnapshot( + results: (result[0] as List?)!.cast(), + executionTime: result[1]! as int, + ); + } +} + class PigeonGetOptions { PigeonGetOptions({ required this.source, @@ -586,18 +649,24 @@ class _FirebaseFirestoreHostApiCodec extends FirestoreMessageCodec { } else if (value is PigeonGetOptions) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is PigeonQueryParameters) { + } else if (value is PigeonPipelineResult) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is PigeonQuerySnapshot) { + } else if (value is PigeonPipelineSnapshot) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is PigeonSnapshotMetadata) { + } else if (value is PigeonQueryParameters) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is PigeonTransactionCommand) { + } else if (value is PigeonQuerySnapshot) { buffer.putUint8(140); writeValue(buffer, value.encode()); + } else if (value is PigeonSnapshotMetadata) { + buffer.putUint8(141); + writeValue(buffer, value.encode()); + } else if (value is PigeonTransactionCommand) { + buffer.putUint8(142); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -625,12 +694,16 @@ class _FirebaseFirestoreHostApiCodec extends FirestoreMessageCodec { case 136: return PigeonGetOptions.decode(readValue(buffer)!); case 137: - return PigeonQueryParameters.decode(readValue(buffer)!); + return PigeonPipelineResult.decode(readValue(buffer)!); case 138: - return PigeonQuerySnapshot.decode(readValue(buffer)!); + return PigeonPipelineSnapshot.decode(readValue(buffer)!); case 139: - return PigeonSnapshotMetadata.decode(readValue(buffer)!); + return PigeonQueryParameters.decode(readValue(buffer)!); case 140: + return PigeonQuerySnapshot.decode(readValue(buffer)!); + case 141: + return PigeonSnapshotMetadata.decode(readValue(buffer)!); + case 142: return PigeonTransactionCommand.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -1342,4 +1415,37 @@ class FirebaseFirestoreHostApi { return; } } + + Future executePipeline( + FirestorePigeonFirebaseApp arg_app, + List?> arg_stages, + Map? arg_options, + ) async { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.cloud_firestore_platform_interface.FirebaseFirestoreHostApi.executePipeline', + codec, + binaryMessenger: _binaryMessenger, + ); + final List? replyList = await channel + .send([arg_app, arg_stages, arg_options]) as List?; + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel.', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (replyList[0] as PigeonPipelineSnapshot?)!; + } + } } diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_firestore.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_firestore.dart index 3faffc0a3778..ee9cd3a32478 100644 --- a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_firestore.dart +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_firestore.dart @@ -243,6 +243,21 @@ abstract class FirebaseFirestorePlatform extends PlatformInterface { throw UnimplementedError('setLoggingEnabled() is not implemented'); } + /// Creates a pipeline platform instance with initial stages. + PipelinePlatform pipeline(List> initialStages) { + throw UnimplementedError('pipeline() is not implemented'); + } + + /// Executes a pipeline and returns the results. + /// + /// The [stages] parameter contains the serialized pipeline stages. + Future executePipeline( + List> stages, { + Map? options, + }) { + throw UnimplementedError('executePipeline() is not implemented'); + } + @override //ignore: avoid_equals_and_hash_code_on_mutable_classes bool operator ==(Object other) => diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_pipeline.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_pipeline.dart new file mode 100644 index 000000000000..2d0449ee4aef --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_pipeline.dart @@ -0,0 +1,54 @@ +// ignore_for_file: require_trailing_commas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart'; +import 'package:meta/meta.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +/// Represents a pipeline for querying and transforming Firestore data. +@immutable +abstract class PipelinePlatform extends PlatformInterface { + /// Create a [PipelinePlatform] instance + PipelinePlatform(this.firestore, List>? stages) + : _stages = stages ?? [], + super(token: _token); + + static final Object _token = Object(); + + /// Throws an [AssertionError] if [instance] does not extend + /// [PipelinePlatform]. + /// + /// This is used by the app-facing [Pipeline] to ensure that + /// the object in which it's going to delegate calls has been + /// constructed properly. + static void verify(PipelinePlatform instance) { + PlatformInterface.verify(instance, _token); + } + + /// The [FirebaseFirestorePlatform] interface for this current pipeline. + final FirebaseFirestorePlatform firestore; + + /// Stores the pipeline stages. + final List> _stages; + + /// Exposes the [stages] on the pipeline delegate. + /// + /// This should only be used for testing to ensure that all + /// pipeline stages are correctly set on the underlying delegate + /// when being tested from a different package. + List> get stages { + return List.unmodifiable(_stages); + } + + /// Adds a serialized stage to the pipeline + PipelinePlatform addStage(Map serializedStage); + + /// Executes the pipeline and returns a snapshot of the results + Future execute({ + Map? options, + }); +} diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_pipeline_snapshot.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_pipeline_snapshot.dart new file mode 100644 index 000000000000..1946ce740712 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/platform_interface/platform_interface_pipeline_snapshot.dart @@ -0,0 +1,44 @@ +// ignore_for_file: require_trailing_commas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import '../../cloud_firestore_platform_interface.dart'; +import 'platform_interface_document_reference.dart'; + +/// Platform interface for [PipelineSnapshot]. +abstract class PipelineSnapshotPlatform extends PlatformInterface { + /// Create an instance of [PipelineSnapshotPlatform]. + PipelineSnapshotPlatform() : super(token: _token); + + static final Object _token = Object(); + + /// The results of the pipeline execution + List get results; + + /// The execution time of the pipeline + DateTime get executionTime; +} + +/// Platform interface for [PipelineResult]. +abstract class PipelineResultPlatform extends PlatformInterface { + /// Create an instance of [PipelineResultPlatform]. + PipelineResultPlatform() : super(token: _token); + + static final Object _token = Object(); + + /// The document reference. Null for aggregate-only results (no document row). + DocumentReferencePlatform? get document; + + /// The creation time of the document + DateTime? get createTime; + + /// The update time of the document + DateTime? get updateTime; + + /// All fields in the result (from PipelineResult.data() on the native SDK). + /// Returns null if the result has no data. + Map? get data; +} diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/pigeons/generate_pigeon.sh b/packages/cloud_firestore/cloud_firestore_platform_interface/pigeons/generate_pigeon.sh index f6f0a2d4aef5..e8950e42ef53 100755 --- a/packages/cloud_firestore/cloud_firestore_platform_interface/pigeons/generate_pigeon.sh +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/pigeons/generate_pigeon.sh @@ -15,7 +15,7 @@ sed -i '' 's/private static class FirebaseFirestoreHostApiCodec extends Standard echo "Android modification complete." # Fix iOS files -FILE_NAME="../../cloud_firestore/ios/Classes/FirestoreMessages.g.m" +FILE_NAME="../../cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FirestoreMessages.g.m" sed -i '' '/#import "FirestoreMessages.g.h"/a\ #import "FLTFirebaseFirestoreReader.h"\ #import "FLTFirebaseFirestoreWriter.h" @@ -26,7 +26,7 @@ sed -i '' 's/(self\.newIndex \?: \[NSNull null\]),/(self.index ?: [NSNull null]) sed -i '' 's/@interface FirebaseFirestoreHostApiCodecReader : FlutterStandardReader/@interface FirebaseFirestoreHostApiCodecReader : FLTFirebaseFirestoreReader/' $FILE_NAME sed -i '' 's/@interface FirebaseFirestoreHostApiCodecWriter : FlutterStandardWriter/@interface FirebaseFirestoreHostApiCodecWriter : FLTFirebaseFirestoreWriter/' $FILE_NAME -FILE_NAME="../../cloud_firestore/ios/Classes/Public/FirestoreMessages.g.h" +FILE_NAME="../../cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/include/cloud_firestore/Public/FirestoreMessages.g.h" sed -i '' 's/@property(nonatomic, strong) NSNumber \*newIndex;/@property(nonatomic, strong) NSNumber \*index;/' $FILE_NAME echo "iOS modification complete." diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/pigeons/messages.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/pigeons/messages.dart index 1c1bda0a1177..a84c58f7af56 100644 --- a/packages/cloud_firestore/cloud_firestore_platform_interface/pigeons/messages.dart +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/pigeons/messages.dart @@ -118,6 +118,31 @@ class PigeonQuerySnapshot { final PigeonSnapshotMetadata metadata; } +class PigeonPipelineResult { + const PigeonPipelineResult({ + this.documentPath, + this.createTime, + this.updateTime, + this.data, + }); + + final String? documentPath; + final int? createTime; // Timestamp in milliseconds since epoch + final int? updateTime; // Timestamp in milliseconds since epoch + /// All fields in the result (from PipelineResult.data() on Android). + final Map? data; +} + +class PigeonPipelineSnapshot { + const PigeonPipelineSnapshot({ + required this.results, + required this.executionTime, + }); + + final List results; + final int executionTime; // Timestamp in milliseconds since epoch +} + /// An enumeration of firestore source types. enum Source { /// Causes Firestore to try to retrieve an up-to-date (server-retrieved) snapshot, but fall back to @@ -442,4 +467,11 @@ abstract class FirebaseFirestoreHostApi { FirestorePigeonFirebaseApp app, PersistenceCacheIndexManagerRequest request, ); + + @async + PigeonPipelineSnapshot executePipeline( + FirestorePigeonFirebaseApp app, + List?> stages, + Map? options, + ); } diff --git a/packages/cloud_firestore/cloud_firestore_platform_interface/test/pigeon/test_api.dart b/packages/cloud_firestore/cloud_firestore_platform_interface/test/pigeon/test_api.dart index 32d57ebdcb48..e9ed58eafc5c 100644 --- a/packages/cloud_firestore/cloud_firestore_platform_interface/test/pigeon/test_api.dart +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/test/pigeon/test_api.dart @@ -7,12 +7,12 @@ // ignore_for_file: avoid_relative_lib_imports import 'dart:async'; import 'dart:typed_data' show Uint8List; - -import 'package:cloud_firestore_platform_interface/src/pigeon/messages.pigeon.dart'; import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:cloud_firestore_platform_interface/src/pigeon/messages.pigeon.dart'; + class _TestFirebaseFirestoreHostApiCodec extends StandardMessageCodec { const _TestFirebaseFirestoreHostApiCodec(); @override @@ -44,18 +44,24 @@ class _TestFirebaseFirestoreHostApiCodec extends StandardMessageCodec { } else if (value is PigeonGetOptions) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is PigeonQueryParameters) { + } else if (value is PigeonPipelineResult) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is PigeonQuerySnapshot) { + } else if (value is PigeonPipelineSnapshot) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is PigeonSnapshotMetadata) { + } else if (value is PigeonQueryParameters) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is PigeonTransactionCommand) { + } else if (value is PigeonQuerySnapshot) { buffer.putUint8(140); writeValue(buffer, value.encode()); + } else if (value is PigeonSnapshotMetadata) { + buffer.putUint8(141); + writeValue(buffer, value.encode()); + } else if (value is PigeonTransactionCommand) { + buffer.putUint8(142); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -63,22 +69,40 @@ class _TestFirebaseFirestoreHostApiCodec extends StandardMessageCodec { @override Object? readValueOfType(int type, ReadBuffer buffer) { - return switch (type) { - 128 => AggregateQuery.decode(readValue(buffer)!), - 129 => AggregateQueryResponse.decode(readValue(buffer)!), - 130 => DocumentReferenceRequest.decode(readValue(buffer)!), - 131 => FirestorePigeonFirebaseApp.decode(readValue(buffer)!), - 132 => PigeonDocumentChange.decode(readValue(buffer)!), - 133 => PigeonDocumentOption.decode(readValue(buffer)!), - 134 => PigeonDocumentSnapshot.decode(readValue(buffer)!), - 135 => PigeonFirebaseSettings.decode(readValue(buffer)!), - 136 => PigeonGetOptions.decode(readValue(buffer)!), - 137 => PigeonQueryParameters.decode(readValue(buffer)!), - 138 => PigeonQuerySnapshot.decode(readValue(buffer)!), - 139 => PigeonSnapshotMetadata.decode(readValue(buffer)!), - 140 => PigeonTransactionCommand.decode(readValue(buffer)!), - _ => super.readValueOfType(type, buffer) - }; + switch (type) { + case 128: + return AggregateQuery.decode(readValue(buffer)!); + case 129: + return AggregateQueryResponse.decode(readValue(buffer)!); + case 130: + return DocumentReferenceRequest.decode(readValue(buffer)!); + case 131: + return FirestorePigeonFirebaseApp.decode(readValue(buffer)!); + case 132: + return PigeonDocumentChange.decode(readValue(buffer)!); + case 133: + return PigeonDocumentOption.decode(readValue(buffer)!); + case 134: + return PigeonDocumentSnapshot.decode(readValue(buffer)!); + case 135: + return PigeonFirebaseSettings.decode(readValue(buffer)!); + case 136: + return PigeonGetOptions.decode(readValue(buffer)!); + case 137: + return PigeonPipelineResult.decode(readValue(buffer)!); + case 138: + return PigeonPipelineSnapshot.decode(readValue(buffer)!); + case 139: + return PigeonQueryParameters.decode(readValue(buffer)!); + case 140: + return PigeonQuerySnapshot.decode(readValue(buffer)!); + case 141: + return PigeonSnapshotMetadata.decode(readValue(buffer)!); + case 142: + return PigeonTransactionCommand.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } } } @@ -197,6 +221,12 @@ abstract class TestFirebaseFirestoreHostApi { PersistenceCacheIndexManagerRequest request, ); + Future executePipeline( + FirestorePigeonFirebaseApp app, + List?> stages, + Map? options, + ); + static void setup( TestFirebaseFirestoreHostApi? api, { BinaryMessenger? binaryMessenger, @@ -1088,5 +1118,43 @@ abstract class TestFirebaseFirestoreHostApi { }); } } + { + final BasicMessageChannel channel = BasicMessageChannel( + 'dev.flutter.pigeon.cloud_firestore_platform_interface.FirebaseFirestoreHostApi.executePipeline', + codec, + binaryMessenger: binaryMessenger, + ); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(channel, + (Object? message) async { + assert( + message != null, + 'Argument for dev.flutter.pigeon.cloud_firestore_platform_interface.FirebaseFirestoreHostApi.executePipeline was null.', + ); + final List args = (message as List?)!; + final FirestorePigeonFirebaseApp? arg_app = + (args[0] as FirestorePigeonFirebaseApp?); + assert( + arg_app != null, + 'Argument for dev.flutter.pigeon.cloud_firestore_platform_interface.FirebaseFirestoreHostApi.executePipeline was null, expected non-null FirestorePigeonFirebaseApp.', + ); + final List?>? arg_stages = + (args[1] as List?)?.cast?>(); + assert( + arg_stages != null, + 'Argument for dev.flutter.pigeon.cloud_firestore_platform_interface.FirebaseFirestoreHostApi.executePipeline was null, expected non-null List?>.', + ); + final Map? arg_options = + (args[2] as Map?)?.cast(); + final PigeonPipelineSnapshot output = + await api.executePipeline(arg_app!, arg_stages!, arg_options); + return [output]; + }); + } + } } } diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/cloud_firestore_web.dart b/packages/cloud_firestore/cloud_firestore_web/lib/cloud_firestore_web.dart index ff68541be65a..04d722e3decf 100644 --- a/packages/cloud_firestore/cloud_firestore_web/lib/cloud_firestore_web.dart +++ b/packages/cloud_firestore/cloud_firestore_web/lib/cloud_firestore_web.dart @@ -21,6 +21,9 @@ import 'src/collection_reference_web.dart'; import 'src/document_reference_web.dart'; import 'src/field_value_factory_web.dart'; import 'src/interop/firestore.dart' as firestore_interop; +import 'src/interop/firestore_interop.dart' as firestore_interop_js; +import 'src/pipeline_builder_web.dart'; +import 'src/pipeline_web.dart'; import 'src/query_web.dart'; import 'src/transaction_web.dart'; import 'src/write_batch_web.dart'; @@ -271,4 +274,48 @@ class FirebaseFirestoreWeb extends FirebaseFirestorePlatform { } _delegate.setLoggingEnabled(value); } + + @override + PipelinePlatform pipeline(List> initialStages) { + return PipelineWeb(this, _delegate, initialStages); + } + + @override + Future executePipeline( + List> stages, { + Map? options, + }) async { + final jsFirestore = _delegate.jsObject; + firestore_interop_js.PipelineJsImpl jsPipeline; + try { + jsPipeline = buildPipelineFromStages(jsFirestore, stages); + } catch (e, stack) { + // Let our Dart FirebaseException (e.g. unsupported expression) propagate + // so the user sees a clear message; the guard would cast it to JSError. + if (e is FirebaseException) { + Error.throwWithStackTrace(e, stack); + } + // JS or other errors: run through the guard to convert to FirebaseException. + return convertWebExceptions(() async { + Error.throwWithStackTrace(e, stack); + }); + } + + final dartPipeline = firestore_interop.Pipeline.getInstance(jsPipeline); + return convertWebExceptions(() async { + String? executeOptions; + if (options != null) { + executeOptions = options['indexMode'] as String; + } + final snapshot = await dartPipeline.execute(executeOptions); + + final results = snapshot.results + .map((r) => PipelineResultWeb(this, _delegate, r.jsObject)) + .toList(); + + final executionTime = snapshot.executionTime ?? DateTime.now(); + + return PipelineSnapshotWeb(results, executionTime); + }); + } } diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore.dart index 6703d7738f14..9ed2cf332707 100644 --- a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore.dart +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore.dart @@ -1089,3 +1089,122 @@ class AggregateQuerySnapshot } } } + +// ============================================================================= +// Pipeline (global execute + snapshot/result wrappers) +// ============================================================================= + +/// Single result in a pipeline snapshot (document + data). +class PipelineResult + extends JsObjectWrapper { + static final _expando = Expando(); + + late final DocumentReference? _ref; + late final Map? _data; + late final DateTime? _createTime; + late final DateTime? _updateTime; + + static PipelineResult getInstance( + firestore_interop.PipelineResultJsImpl jsObject) { + return _expando[jsObject] ??= PipelineResult._fromJsObject(jsObject); + } + + PipelineResult._fromJsObject(firestore_interop.PipelineResultJsImpl jsObject) + : _ref = jsObject.ref != null + ? DocumentReference.getInstance(jsObject.ref!) + : null, + _data = _dataFromResult(jsObject), + _createTime = _timestampToDateTime(jsObject.createTime), + _updateTime = _timestampToDateTime(jsObject.updateTime), + super.fromJsObject(jsObject); + + static Map? _dataFromResult( + firestore_interop.PipelineResultJsImpl jsResult) { + final d = jsResult.data(); + if (d == null) return null; + final parsed = dartify(d); + return parsed != null + ? Map.from(parsed as Map) + : null; + } + + static DateTime? _timestampToDateTime(dynamic value) { + if (value == null) return null; + final d = dartify(value); + if (d == null) return null; + if (d is DateTime) return d; + if (d is Timestamp) return d.toDate(); + if (d is int) return DateTime.fromMillisecondsSinceEpoch(d); + return null; + } + + DocumentReference? get ref => _ref; + Map? get data => _data; + DateTime? get createTime => _createTime; + DateTime? get updateTime => _updateTime; +} + +/// Snapshot of pipeline execution results. +class PipelineSnapshot + extends JsObjectWrapper { + static final _expando = Expando(); + + late final List _results; + late final DateTime? _executionTime; + + static PipelineSnapshot getInstance( + firestore_interop.PipelineSnapshotJsImpl jsObject) { + return _expando[jsObject] ??= PipelineSnapshot._fromJsObject(jsObject); + } + + static List _buildResults( + firestore_interop.PipelineSnapshotJsImpl jsObject) { + final rawResults = jsObject.results.toDart; + return rawResults + .cast() + .map(PipelineResult.getInstance) + .toList(); + } + + PipelineSnapshot._fromJsObject( + firestore_interop.PipelineSnapshotJsImpl jsObject) + : _results = _buildResults(jsObject), + _executionTime = _executionTimeFromJs(jsObject.executionTime), + super.fromJsObject(jsObject); + + static DateTime? _executionTimeFromJs(dynamic value) { + if (value == null) return null; + final d = dartify(value); + if (d == null) return null; + if (d is DateTime) return d; + if (d is int) return DateTime.fromMillisecondsSinceEpoch(d); + return null; + } + + List get results => _results; + DateTime? get executionTime => _executionTime; +} + +/// Wraps a JS pipeline; use [execute] to run it via the global execute function. +class Pipeline extends JsObjectWrapper { + static final _expando = Expando(); + + static Pipeline getInstance(firestore_interop.PipelineJsImpl jsObject) { + return _expando[jsObject] ??= Pipeline._fromJsObject(jsObject); + } + + Pipeline._fromJsObject(firestore_interop.PipelineJsImpl jsObject) + : super.fromJsObject(jsObject); + + /// Runs this pipeline using the global JS SDK execute function. + Future execute(String? executeOptions) async { + final executeOptionsJs = firestore_interop.PipelineExecuteOptionsJsImpl(); + if (executeOptions != null) { + executeOptionsJs.indexMode = executeOptions.toJS; + } + executeOptionsJs.pipeline = jsObject as JSAny; + final snapshot = + await firestore_interop.pipelines.execute(executeOptionsJs).toDart; + return PipelineSnapshot.getInstance(snapshot); + } +} diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart index 85cc51fbffde..bf6d39f3b244 100644 --- a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart @@ -333,13 +333,169 @@ external JSObject get and; @staticInterop external WriteBatchJsImpl writeBatch(FirestoreJsImpl firestore); -@JS('Firestore') -@staticInterop -abstract class FirestoreJsImpl {} - -extension FirestoreJsImplExtension on FirestoreJsImpl { +extension type FirestoreJsImpl._(JSObject _) implements JSObject { external AppJsImpl get app; external JSString get type; + + /// Returns the pipeline source for building and executing pipelines. + external JSAny pipeline(); +} + +@JS() +@staticInterop +external PipelinesJsImpl get pipelines; + +/// Pipeline expression API — mirrors the Firebase JS SDK pipelines module. +/// Use these to build expressions for where(), sort(), addFields(), aggregate(), etc. +extension type PipelinesJsImpl._(JSObject _) implements JSObject { + external JSPromise execute( + PipelineExecuteOptionsJsImpl pipeline); + + // --- Expression builders --- + external ExpressionJsImpl field(JSString path); + external ExpressionJsImpl constant(JSAny? value); + + // --- Boolean / comparison --- + external JSAny equal(JSAny left, JSAny right); + external JSAny notEqual(JSAny left, JSAny right); + external JSAny greaterThan(JSAny left, JSAny right); + external JSAny greaterThanOrEqual(JSAny left, JSAny right); + external JSAny lessThan(JSAny left, JSAny right); + external JSAny lessThanOrEqual(JSAny left, JSAny right); + external JSAny and(JSAny a, JSAny b); + external JSAny or(JSAny a, JSAny b); + external JSAny xor(JSAny a, JSAny b); + external JSAny not(JSAny expr); + + // --- Existence / type checks --- + external JSAny exists(JSAny expr); + external JSAny isAbsent(JSAny expr); + external JSAny isError(JSAny expr); + + // --- Array --- + external JSAny arrayContains(JSAny array, JSAny element); + external JSAny arrayContainsAny(JSAny array, JSArray values); + external JSAny arrayContainsAll(JSAny array, JSAny valuesOrArray); + + // --- IN / NOT IN (boolean) --- + external JSAny equalAny(JSAny element, JSAny valuesArray); + external JSAny notEqualAny(JSAny element, JSAny valuesArray); + + // --- String / value expressions (global) --- + external ExpressionJsImpl split(JSAny expression, JSAny delimiter); + external ExpressionJsImpl join(JSAny arrayExpression, JSAny delimiter); + external ExpressionJsImpl substring( + JSAny input, JSAny position, JSAny length); + external ExpressionJsImpl stringReplaceAll( + JSAny expression, JSAny find, JSAny replacement); + external ExpressionJsImpl ifAbsent(JSAny expression, JSAny elseExpr); + external ExpressionJsImpl ifError(JSAny expression, JSAny catchExpr); + external ExpressionJsImpl conditional( + JSAny condition, JSAny thenExpr, JSAny elseExpr); + external ExpressionJsImpl documentId(JSAny path); + external ExpressionJsImpl collectionId(JSAny expression); + external ExpressionJsImpl mapGet(JSAny mapExpr, JSString key); + external ExpressionJsImpl mapKeys(JSAny mapExpr); + external ExpressionJsImpl mapValues(JSAny mapExpr); + external ExpressionJsImpl currentTimestamp(); + external ExpressionJsImpl timestampAdd( + JSAny timestamp, JSString unit, JSAny amount); + external ExpressionJsImpl timestampSubtract( + JSAny timestamp, JSString unit, JSAny amount); + external ExpressionJsImpl timestampTruncate(JSAny timestamp, JSString unit, + [JSString? timezone]); + external ExpressionJsImpl abs(JSAny expr); + external ExpressionJsImpl arrayLength(JSAny array); + external ExpressionJsImpl arraySum(JSAny expression); + external ExpressionJsImpl arrayConcat(JSAny first, JSAny second); + external ExpressionJsImpl array(JSArray elements); + external ExpressionJsImpl map(JSObject keyValuePairs); + + // --- Ordering (for sort stage) --- + external JSAny ascending(JSAny expr); + external JSAny descending(JSAny expr); + + // --- Aggregates --- + external AggregateFunctionJsImpl sum(JSAny expr); + external AggregateFunctionJsImpl average(JSAny expr); + external AggregateFunctionJsImpl count(JSAny expr); + external AggregateFunctionJsImpl countDistinct(JSAny expr); + external AggregateFunctionJsImpl minimum(JSAny expr); + external AggregateFunctionJsImpl maximum(JSAny expr); + external AggregateFunctionJsImpl countAll(); + + // --- Aliased (for select/addFields/aggregate output names) --- + external JSAny aliased(JSAny expr, JSString alias); +} + +/// Aggregate function (result of sum(), average(), count(), etc. on pipelines). +/// Has .as(alias) to create an aliased aggregate for accumulators. +extension type AggregateFunctionJsImpl._(JSObject _) implements JSObject { + @JS('as') + external JSAny asAlias(JSString alias); +} + +extension type ExpressionJsImpl._(JSObject _) implements JSObject { + @JS('as') + external JSAny asAlias(JSString alias); + + external ExpressionJsImpl add(JSAny right); + external ExpressionJsImpl subtract(JSAny right); + external ExpressionJsImpl multiply(JSAny right); + external ExpressionJsImpl divide(JSAny right); + @JS('mod') + external ExpressionJsImpl modulo(JSAny right); + external ExpressionJsImpl length(); + external ExpressionJsImpl concat(JSAny right); + external ExpressionJsImpl toLower(); + external ExpressionJsImpl toUpper(); + external ExpressionJsImpl trim(); + external ExpressionJsImpl arrayReverse(); + external ExpressionJsImpl asBoolean(); + external ExpressionJsImpl isError(); + external ExpressionJsImpl isAbsent(); +} + +extension type SelectableJsImpl._(JSObject _) implements JSObject { + @JS('as') + external JSAny asAlias(JSString alias); +} + +/// Aliased aggregate for use in aggregate() stage accumulators. +/// Mirrors Firebase JS SDK: constructor(aggregate, alias, _methodName?). +@JS('AliasedAggregate') +@staticInterop +abstract class AliasedAggregateJsImpl { + external factory AliasedAggregateJsImpl( + JSAny aggregate, + JSString alias, [ + JSString? methodName, + ]); +} + +/// Options for the aggregate() pipeline stage. +/// Mirrors Firebase JS SDK AggregateStageOptions: { accumulators, groups? }. +extension type AggregateStageOptionsJsImpl._(JSObject _) implements JSObject { + AggregateStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set accumulators(JSAny value); + // ignore: avoid_setters_without_getters + external set groups(JSAny value); +} + +extension type SelectStageOptionsJsImpl._(JSObject _) implements JSObject { + SelectStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set selections(JSArray value); +} + +extension type AddFieldsOptionsJsImpl._(JSObject _) implements JSObject { + AddFieldsOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set fields(JSAny value); } extension type WriteBatchJsImpl._(JSObject _) implements JSObject { @@ -1014,3 +1170,141 @@ extension type AggregateQuerySnapshotJsImpl._(JSObject _) implements JSObject { @JS() @staticInterop abstract class PersistentCacheIndexManager {} + +/// Entry point for defining the data source of a Firestore Pipeline. +/// Use .collection(), .collectionGroup(), .database(), or .documents(). +extension type PipelineSourceJsImpl._(JSObject _) implements JSObject { + /// Returns all documents from the entire collection (can be nested). + external PipelineJsImpl collection(JSString collectionPath); + + /// Returns all documents from a collection ID regardless of parent. + external PipelineJsImpl collectionGroup(JSString collectionId); + + /// Returns all documents from the entire database. + external PipelineJsImpl database(); + + /// Sets the pipeline source to the given document paths or references. + external PipelineJsImpl documents(JSArray docs); +} + +/// Pipeline returned by PipelineSource methods; chain stages and call execute(). +/// See: https://firebase.google.com/docs/reference/js/firestore_pipelines.pipeline +extension type PipelineJsImpl._(JSObject _) implements JSObject { + external PipelineJsImpl limit(JSNumber limit); + external PipelineJsImpl offset(JSNumber offset); + external PipelineJsImpl where(JSAny condition); + external PipelineJsImpl sort(JSAny orderingOrOptions); + external PipelineJsImpl addFields(JSAny fieldOrOptions); + external PipelineJsImpl select(JSAny selectionOrOptions); + external PipelineJsImpl distinct(JSAny groupOrOptions); + external PipelineJsImpl aggregate(AggregateStageOptionsJsImpl options); + external PipelineJsImpl sample(JSAny documentsOrOptions); + external PipelineJsImpl unnest(JSAny selectableOrOptions); + external PipelineJsImpl removeFields(JSAny fieldOrOptions); + external PipelineJsImpl replaceWith(JSAny fieldNameOrOptions); + external PipelineJsImpl findNearest(JSAny options); + external PipelineJsImpl union(JSAny otherOrOptions); + external PipelineJsImpl rawStage(JSString name, JSArray params, + [JSAny? options]); +} + +/// Options for pipeline execution (e.g. index mode). +@anonymous +@JS() +@staticInterop +abstract class PipelineExecuteOptions { + external factory PipelineExecuteOptions({JSString? indexMode}); +} + +extension PipelineExecuteOptionsExtension on PipelineExecuteOptions { + external JSString? get indexMode; + external set indexMode(JSString? v); +} + +/// Snapshot of pipeline execution results. +extension type PipelineSnapshotJsImpl._(JSObject _) implements JSObject { + /// Array of [PipelineResultJsImpl]. + external JSArray get results; + + /// Execution time (if provided by SDK). + external JSAny? get executionTime; +} + +/// Single result in a pipeline snapshot (document + data). +extension type PipelineResultJsImpl._(JSObject _) implements JSObject { + external DocumentReferenceJsImpl? get ref; + external JSObject? data(); + external JSAny? get createTime; + external JSAny? get updateTime; +} + +extension type SampleStageOptionsJsImpl._(JSObject _) implements JSObject { + SampleStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set documents(JSAny value); + // ignore: avoid_setters_without_getters + external set percentage(JSAny value); +} + +extension type SortStageOptionsJsImpl._(JSObject _) implements JSObject { + SortStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set orderings(JSAny value); +} + +extension type DistinctStageOptionsJsImpl._(JSObject _) implements JSObject { + DistinctStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set groups(JSAny value); +} + +extension type UnnestStageOptionsJsImpl._(JSObject _) implements JSObject { + UnnestStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set selectable(JSAny value); + // ignore: avoid_setters_without_getters + external set indexField(JSString? value); +} + +extension type RemoveFieldsStageOptionsJsImpl._(JSObject _) + implements JSObject { + RemoveFieldsStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set fields(JSArray value); +} + +extension type ReplaceWithStageOptionsJsImpl._(JSObject _) implements JSObject { + ReplaceWithStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set map(JSAny value); +} + +extension type FindNearestStageOptionsJsImpl._(JSObject _) implements JSObject { + FindNearestStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set field(JSAny value); + // ignore: avoid_setters_without_getters + external set vectorValue(JSAny value); + // ignore: avoid_setters_without_getters + external set distanceMeasure(JSString value); + // ignore: avoid_setters_without_getters + external set limit(JSNumber value); + // ignore: avoid_setters_without_getters + external set distanceField(JSString value); +} + +extension type PipelineExecuteOptionsJsImpl._(JSObject _) implements JSObject { + PipelineExecuteOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set indexMode(JSString value); + // ignore: avoid_setters_without_getters + external set pipeline(JSAny value); +} diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_builder_web.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_builder_web.dart new file mode 100644 index 000000000000..bc2db69e575d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_builder_web.dart @@ -0,0 +1,137 @@ +// ignore_for_file: require_trailing_commas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; + +import 'package:cloud_firestore_web/src/interop/firestore_interop.dart' + as interop; +import 'package:cloud_firestore_web/src/pipeline_expression_parser_web.dart'; +import 'package:firebase_core/firebase_core.dart'; + +/// Builds a JS Pipeline from serialized [stages] and returns it ready to execute. +/// Keeps [executePipeline] thin: build → execute → convert. +interop.PipelineJsImpl buildPipelineFromStages( + interop.FirestoreJsImpl jsFirestore, + List> stages, +) { + final source = jsFirestore.pipeline(); + final first = stages.first; + final stageName = first['stage'] as String?; + + // Build source stage + interop.PipelineJsImpl pipeline = _applySourceStage( + source as interop.PipelineSourceJsImpl, jsFirestore, stageName, first); + + final converter = PipelineExpressionParserWeb(interop.pipelines, jsFirestore); + + // Apply remaining stages + for (var i = 1; i < stages.length; i++) { + pipeline = _applyStage(pipeline, stages[i], converter, jsFirestore); + } + return pipeline; +} + +interop.PipelineJsImpl _applySourceStage( + interop.PipelineSourceJsImpl source, + interop.FirestoreJsImpl jsFirestore, + String? stageName, + Map first, +) { + final args = first['args']; + return switch (stageName) { + 'collection' => source + .collection(((args as Map)['path']! as String).toJS), + 'collection_group' => source.collectionGroup( + ((args as Map)['path']! as String).toJS), + 'database' => source.database(), + 'documents' => source.documents( + (args as List) + .map((e) => (e as Map)['path']! as String) + .map((p) => interop.doc(jsFirestore as JSAny, p.toJS)) + .toList() + .toJS, + ), + _ => throw UnsupportedError( + 'Pipeline source stage "$stageName" is not supported on web.'), + }; +} + +interop.PipelineJsImpl _applyStage( + interop.PipelineJsImpl pipeline, + Map stage, + PipelineExpressionParserWeb converter, + interop.FirestoreJsImpl jsFirestore, +) { + final name = stage['stage'] as String?; + final args = stage['args']; + final map = args is Map ? args : {}; + + switch (name) { + case 'limit': + final limit = map['limit'] as int; + return pipeline.limit(limit.toJS); + case 'offset': + final offset = map['offset'] as int; + return pipeline.offset(offset.toJS); + case 'where': + final expression = map['expression']; + if (expression == null) return pipeline; + final condition = + converter.toBooleanExpression(expression as Map); + if (condition == null) { + throw UnsupportedError( + 'Pipeline where() on web: could not parse the condition expression.', + ); + } + return pipeline.where(condition); + case 'sort': + final orderings = map['orderings'] as List?; + if (orderings == null || orderings.isEmpty) return pipeline; + return pipeline.sort(converter.toSortOptions(orderings)); + case 'add_fields': + final expressions = map['expressions'] as List?; + if (expressions == null || expressions.isEmpty) return pipeline; + return pipeline.addFields(converter.toAddFieldsOptions(expressions)); + case 'select': + final expressions = map['expressions'] as List?; + if (expressions == null || expressions.isEmpty) return pipeline; + return pipeline.select(converter.toSelectOptions(expressions)); + case 'distinct': + final expressions = map['expressions'] as List?; + if (expressions == null || expressions.isEmpty) return pipeline; + return pipeline.distinct(converter.toDistinctOptions(expressions)); + case 'aggregate': + return pipeline.aggregate(converter.toAggregateOptionsFromFunctions(map)); + case 'aggregate_with_options': + return pipeline + .aggregate(converter.toAggregateOptionsFromStageAndOptions(map)); + case 'sample': + return pipeline.sample(converter.toSampleOptions(args)); + case 'unnest': + return pipeline.unnest(converter.toUnnestOptions(map)); + case 'remove_fields': + final fieldPaths = map['field_paths'] as List?; + if (fieldPaths == null || fieldPaths.isEmpty) return pipeline; + return pipeline.removeFields(converter.toRemoveFieldsOptions(fieldPaths)); + case 'replace_with': + final expression = map['expression']; + if (expression == null) return pipeline; + return pipeline.replaceWith( + converter.toReplaceWithOptions(expression as Map)); + case 'find_nearest': + return pipeline.findNearest(converter.toFindNearestOptions(map)); + case 'union': + final pipelineStages = map['pipeline'] as List>; + final otherPipeline = + buildPipelineFromStages(jsFirestore, pipelineStages); + return pipeline.union(otherPipeline); + default: + throw FirebaseException( + plugin: 'cloud_firestore', + code: 'unknown-pipeline-stage', + message: 'Unknown pipeline stage: $name', + ); + } +} diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart new file mode 100644 index 000000000000..1ca64cc16083 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart @@ -0,0 +1,721 @@ +// ignore_for_file: require_trailing_commas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart' + show Blob, GeoPoint, Timestamp, VectorValue; +import 'package:cloud_firestore_web/src/interop/firestore_interop.dart' + as interop; +import 'package:cloud_firestore_web/src/interop/utils/utils.dart'; +import 'package:firebase_core/firebase_core.dart'; + +/// Converts Dart serialized pipeline expressions/stage args into JS pipeline +/// types by calling the pipelines interop API (field, constant, equal, and, +/// ascending, etc.) that mirrors the Firebase JS SDK. +class PipelineExpressionParserWeb { + PipelineExpressionParserWeb(this._pipelines, this._jsFirestore); + + final interop.PipelinesJsImpl _pipelines; + final interop.FirestoreJsImpl _jsFirestore; + + static const _kName = 'name'; + static const _kArgs = 'args'; + static const _kLeft = 'left'; + static const _kRight = 'right'; + static const _kExpression = 'expression'; + static const _kField = 'field'; + static const _kAlias = 'alias'; + static const _kValue = 'value'; + + // ── Value expressions ───────────────────────────────────────────────────── + + /// Converts a serialized value expression to a JS Expression. + interop.ExpressionJsImpl toExpression(Map map) { + final name = map[_kName] as String?; + final argsMap = _argsOf(map); + switch (name) { + case 'field': + return _pipelines.field(((argsMap[_kField] as String?) ?? '').toJS); + case 'add': + return _binaryArithmetic(argsMap, (l, r) => l.add(r)); + case 'subtract': + return _binaryArithmetic(argsMap, (l, r) => l.subtract(r)); + case 'multiply': + return _binaryArithmetic(argsMap, (l, r) => l.multiply(r)); + case 'divide': + return _binaryArithmetic(argsMap, (l, r) => l.divide(r)); + case 'modulo': + return _binaryArithmetic(argsMap, (l, r) => l.modulo(r)); + case 'constant': + case 'null': + return _pipelines.constant(_constantValueToJs(argsMap[_kValue])); + case 'concat': + final expressions = argsMap['expressions'] as List?; + if (expressions == null || expressions.isEmpty) { + throw UnsupportedError('concat requires at least one expression'); + } + interop.ExpressionJsImpl result = + toExpression(expressions[0] as Map); + for (var i = 1; i < expressions.length; i++) { + result = result + .concat(toExpression(expressions[i] as Map)); + } + return result; + case 'length': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .length(); + case 'to_lower_case': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .toLower(); + case 'to_upper_case': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .toUpper(); + case 'trim': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .trim(); + case 'as_boolean': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .asBoolean(); + case 'is_error': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .isError(); + case 'is_absent': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .isAbsent(); + case 'substring': + return _pipelines.substring( + _expr(argsMap, _kExpression), + _expr(argsMap, 'start'), + _expr(argsMap, 'end'), + ); + case 'string_replace_all': + return _pipelines.stringReplaceAll( + _expr(argsMap, _kExpression), + _expr(argsMap, 'find'), + _expr(argsMap, 'replacement'), + ); + case 'split': + return _pipelines.split( + _expr(argsMap, _kExpression), + _expr(argsMap, 'delimiter'), + ); + case 'join': + return _pipelines.join( + _expr(argsMap, _kExpression), + _expr(argsMap, 'delimiter'), + ); + case 'if_absent': + return _pipelines.ifAbsent( + _expr(argsMap, _kExpression), + _expr(argsMap, 'else'), + ); + case 'if_error': + return _pipelines.ifError( + _expr(argsMap, _kExpression), + _expr(argsMap, 'catch'), + ); + case 'conditional': + return _pipelines.conditional( + toBooleanExpression(argsMap['condition'] as Map)!, + _expr(argsMap, 'then'), + _expr(argsMap, 'else'), + ); + case 'document_id': + final pathArg = argsMap[_kExpression]; + return _pipelines + .documentId(toExpression(pathArg as Map)); + case 'document_id_from_ref': + final path = argsMap['doc_ref'] as String?; + if (path == null || path.isEmpty) { + throw ArgumentError( + "document_id_from_ref requires a non-empty 'doc_ref' path"); + } + final docRef = interop.doc(_jsFirestore as JSAny, path.toJS); + return _pipelines.documentId(docRef); + case 'collection_id': + return _pipelines.collectionId(_expr(argsMap, _kExpression)); + case 'map_get': + { + final keyExprMap = argsMap['key'] as Map?; + if (keyExprMap == null) { + throw ArgumentError("map_get requires a 'key' argument"); + } + final keyString = _constantStringFromExpression(keyExprMap); + if (keyString == null) { + throw UnsupportedError( + 'mapGet on web only supports a constant string key '); + } + return _pipelines.mapGet( + _expr(argsMap, 'map'), + keyString.toJS, + ); + } + case 'map_keys': + return _pipelines.mapKeys(_expr(argsMap, _kExpression)); + case 'map_values': + return _pipelines.mapValues(_expr(argsMap, _kExpression)); + case 'current_timestamp': + return _pipelines.currentTimestamp(); + case 'timestamp_add': + return _pipelines.timestampAdd( + _expr(argsMap, 'timestamp'), + (argsMap['unit'] as String).toJS, + _expr(argsMap, 'amount'), + ); + case 'timestamp_subtract': + return _pipelines.timestampSubtract( + _expr(argsMap, 'timestamp'), + (argsMap['unit'] as String).toJS, + _expr(argsMap, 'amount'), + ); + case 'timestamp_truncate': + final tz = argsMap['timezone'] as String?; + return _pipelines.timestampTruncate( + _expr(argsMap, 'timestamp'), + (argsMap['unit'] as String).toJS, + tz?.toJS, + ); + case 'abs': + return _pipelines.abs(_expr(argsMap, _kExpression)); + case 'array_length': + return _pipelines.arrayLength(_expr(argsMap, _kExpression)); + case 'array_sum': + return _pipelines.arraySum(_expr(argsMap, _kExpression)); + case 'array_concat': + return _pipelines.arrayConcat( + _expr(argsMap, 'first'), + _expr(argsMap, 'second'), + ); + case 'array_concat_multiple': + final arrays = argsMap['arrays'] as List?; + if (arrays == null || arrays.length < 2) { + throw UnsupportedError( + 'array_concat_multiple requires at least two arrays'); + } + var arrResult = _pipelines.arrayConcat( + toExpression(arrays[0] as Map), + toExpression(arrays[1] as Map), + ); + for (var i = 2; i < arrays.length; i++) { + arrResult = _pipelines.arrayConcat( + arrResult, + toExpression(arrays[i] as Map), + ); + } + return arrResult; + case 'array_reverse': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .arrayReverse(); + case 'array': + final elements = argsMap['elements'] as List?; + if (elements == null) { + throw UnsupportedError('array requires elements'); + } + final jsElements = elements + .map((e) => toExpression(e as Map)) + .toList() + .toJS; + return _pipelines.array(jsElements); + case 'array_contains': + return _jsArrayContains(argsMap) as interop.ExpressionJsImpl; + case 'array_contains_any': + final js = _jsArrayContainsAny(argsMap); + if (js == null) { + throw UnsupportedError('array_contains_any requires values'); + } + return js as interop.ExpressionJsImpl; + case 'array_contains_all': + final js = _jsArrayContainsAll(argsMap); + if (js == null) { + throw UnsupportedError( + 'array_contains_all requires values or array_expression', + ); + } + return js as interop.ExpressionJsImpl; + case 'equal_any': + final js = _jsEqualAny(argsMap); + if (js == null) throw UnsupportedError('equal_any requires values'); + return js as interop.ExpressionJsImpl; + case 'not_equal_any': + final js = _jsNotEqualAny(argsMap); + if (js == null) { + throw UnsupportedError('not_equal_any requires values'); + } + return js as interop.ExpressionJsImpl; + case 'map': + final data = argsMap['data'] as Map?; + if (data == null) { + throw UnsupportedError('map requires data'); + } + final m = {}; + for (final entry in data.entries) { + m[entry.key] = toExpression(entry.value as Map); + } + return _pipelines.map(jsify(m)! as JSObject); + default: + throw FirebaseException( + plugin: 'cloud_firestore', + code: 'unsupported-expression', + message: + "The pipeline expression '$name' is not supported on the web " + 'platform. The Firebase JS SDK may not expose this expression.', + ); + } + } + + // ── Boolean expressions ─────────────────────────────────────────────────── + + /// Converts a serialized boolean expression to a JS BooleanExpression. + /// + /// Returns null if [map] is not a recognized boolean expression. + JSAny? toBooleanExpression(Map map) { + final name = map[_kName] as String?; + final argsMap = _argsOf(map); + switch (name) { + case 'equal': + return _pipelines.equal( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'not_equal': + return _pipelines.notEqual( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'greater_than': + return _pipelines.greaterThan( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'greater_than_or_equal': + return _pipelines.greaterThanOrEqual( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'less_than': + return _pipelines.lessThan( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'less_than_or_equal': + return _pipelines.lessThanOrEqual( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'and': + case 'or': + case 'xor': + final exprMaps = argsMap['expressions'] as List?; + if (exprMaps == null || exprMaps.isEmpty) return null; + final exprs = exprMaps + .map((e) => toBooleanExpression(e as Map)) + .whereType() + .toList(); + if (exprs.isEmpty) return null; + var result = exprs.first; + for (var i = 1; i < exprs.length; i++) { + if (name == 'and') { + result = _pipelines.and(result, exprs[i]); + } else if (name == 'or') { + result = _pipelines.or(result, exprs[i]); + } else { + result = _pipelines.xor(result, exprs[i]); + } + } + return result; + case 'not': + final expr = argsMap[_kExpression] as Map; + final boolExpr = toBooleanExpression(expr); + if (boolExpr == null) { + throw UnsupportedError('not requires a boolean expression'); + } + return _pipelines.not(boolExpr); + case 'exists': + return _pipelines.exists(_expr(argsMap, _kExpression)); + case 'is_absent': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .isAbsent(); + case 'is_error': + return _pipelines.isError(_expr(argsMap, _kExpression)); + case 'array_contains': + return _jsArrayContains(argsMap); + case 'array_contains_any': + return _jsArrayContainsAny(argsMap); + case 'array_contains_all': + return _jsArrayContainsAll(argsMap); + case 'equal_any': + return _jsEqualAny(argsMap); + case 'not_equal_any': + return _jsNotEqualAny(argsMap); + case 'filter': + return _buildFilterExpression(argsMap); + default: + throw FirebaseException( + plugin: 'cloud_firestore', + code: 'unsupported-boolean-expression', + message: "The boolean expression '$name' is not supported on the web " + 'platform. The Firebase JS SDK may not expose this expression.', + ); + } + } + + // ── Stage options ───────────────────────────────────────────────────────── + + /// Converts orderings list to JS SortStageOptions. + /// + /// Each item shape: `{ expression: Map, order_direction: 'asc' | 'desc' }`. + JSAny toSortOptions(List orderings) { + final list = []; + for (final o in orderings) { + final m = o is Map ? o : {}; + final expr = m[_kExpression]; + if (expr == null) continue; + final exprJs = toExpression(expr as Map); + final dir = m['order_direction'] as String?; + list.add(dir == 'desc' + ? _pipelines.descending(exprJs) + : _pipelines.ascending(exprJs)); + } + if (list.isEmpty) { + throw UnsupportedError( + 'Pipeline sort() on web requires the Firebase JS pipeline expression API ' + '(ascending, descending). Ensure the pipelines module is loaded.', + ); + } + return interop.SortStageOptionsJsImpl()..orderings = list.toJS; + } + + /// Converts a single expression map to a JS Selectable (field or aliased). + JSAny toSelectable(Map map) { + final name = map[_kName] as String?; + final argsMap = _argsOf(map); + if (name == 'field') { + return _pipelines.field(((argsMap[_kField] as String?) ?? '').toJS); + } + if (name == _kAlias) { + final alias = argsMap[_kAlias] as String; + final expression = argsMap[_kExpression]; + return toExpression(expression as Map) + .asAlias(alias.toJS); + } + return toExpression(map); + } + + /// Converts add_fields expressions to JS AddFieldsStageOptions. + JSAny toAddFieldsOptions(List expressions) => + interop.AddFieldsOptionsJsImpl() + ..fields = _toSelectableList(expressions).toJS; + + /// Converts select stage expressions to JS SelectStageOptions. + JSAny toSelectOptions(List expressions) => + interop.SelectStageOptionsJsImpl() + ..selections = _toSelectableList(expressions).toJS; + + /// Converts distinct stage groups to JS DistinctStageOptions. + JSAny toDistinctOptions(List expressions) { + final list = _toSelectableList(expressions); + if (list.isEmpty) { + throw UnsupportedError( + 'Pipeline distinct() on web requires the Firebase JS pipeline expression API.', + ); + } + return interop.DistinctStageOptionsJsImpl()..groups = list.toJS; + } + + // ── Aggregate ───────────────────────────────────────────────────────────── + + /// Converts args for the 'aggregate' stage to JS AggregateStageOptions. + /// + /// Expects [map] to contain an [aggregate_functions] list. + interop.AggregateStageOptionsJsImpl toAggregateOptionsFromFunctions( + Map map) { + final list = map['aggregate_functions'] as List; + return _buildAccumulators(list); + } + + /// Converts args for the 'aggregate_with_options' stage to JS AggregateStageOptions. + /// + /// Expects [map] to contain an [aggregate_stage] map with [accumulators] + /// and optionally [groups]. + interop.AggregateStageOptionsJsImpl toAggregateOptionsFromStageAndOptions( + Map map) { + final stage = map['aggregate_stage'] as Map; + final list = stage['accumulators'] as List; + final groups = stage['groups'] as List?; + return _buildAccumulators(list, groups: groups); + } + + // ── Other stage options ─────────────────────────────────────────────────── + + /// Converts sample stage args to JS (integer count or SampleStageOptions). + /// + /// Dart serializes as `{ type: 'size', value: n }` or a raw number. + JSAny toSampleOptions(Map map) { + final args = map['type'] as String; + if (args == 'size') { + final value = map['value'] as num; + return interop.SampleStageOptionsJsImpl()..documents = value.toInt().toJS; + } else { + final value = map['value'] as num; + return interop.SampleStageOptionsJsImpl() + ..percentage = value.toDouble().toJS; + } + } + + /// Converts unnest stage args to JS UnnestStageOptions. + JSAny toUnnestOptions(Map map) { + final expression = map[_kExpression] as Map; + final indexField = map['index_field'] as String?; + final sel = toSelectable(expression); + return interop.UnnestStageOptionsJsImpl() + ..selectable = sel + ..indexField = indexField?.toJS; + } + + /// Converts remove_fields field paths to JS RemoveFieldsStageOptions. + JSAny toRemoveFieldsOptions(List fieldPaths) { + final paths = []; + for (final e in fieldPaths) { + final s = e is String + ? e + : (e is Map ? e[_kField] ?? e['path'] : null)?.toString(); + if (s != null) paths.add(s.toJS); + } + return interop.RemoveFieldsStageOptionsJsImpl()..fields = paths.toJS; + } + + /// Converts replace_with expression to JS ReplaceWithStageOptions. + JSAny toReplaceWithOptions(Map expression) { + return interop.ReplaceWithStageOptionsJsImpl() + ..map = toExpression(expression); + } + + /// Converts find_nearest args to JS FindNearestStageOptions. + interop.FindNearestStageOptionsJsImpl toFindNearestOptions( + Map map) { + final vectorField = + (map['vector_field'] as String?) ?? (map[_kField] as String?); + final vectorValue = map['vector_value'] as List?; + final distanceMeasure = (map['distance_measure'] as String?) ?? 'cosine'; + final limit = map['limit'] as int?; + final distanceField = map['distance_field'] as String?; + if (vectorField == null || vectorValue == null) { + throw UnsupportedError( + 'Pipeline findNearest() on web requires vector_field and vector_value.', + ); + } + final doubles = vectorValue.map((e) => (e as num).toDouble()).toList(); + final opts = interop.FindNearestStageOptionsJsImpl() + ..field = _pipelines.field(vectorField.toJS) + ..vectorValue = interop.vector(doubles.jsify()! as JSArray) + ..distanceMeasure = distanceMeasure.toJS; + if (limit != null) opts.limit = limit.toJS; + if (distanceField != null) opts.distanceField = distanceField.toJS; + return opts; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /// Converts a [Constant] value to the correct JS type for the pipelines API. + /// + /// Each Dart type accepted by [Constant] is mapped to the corresponding + /// Firestore JS SDK interop type so that the JS SDK receives a properly typed + /// value (e.g. a JS `Timestamp`, `GeoPoint`, or `Bytes` object) rather than + /// a plain JS primitive or an unrecognised object. + JSAny? _constantValueToJs(Object? value) { + if (value == null) return null; + if (value is String) return value.toJS; + if (value is bool) return value.toJS; + if (value is int) return value.toJS; + if (value is double) return value.toJS; + if (value is DateTime) { + return interop.TimestampJsImpl.fromMillis( + value.millisecondsSinceEpoch.toJS) as JSAny; + } + + if (value is Timestamp) { + // Use seconds + nanoseconds directly to preserve sub-millisecond precision. + return interop.TimestampJsImpl(value.seconds.toJS, value.nanoseconds.toJS) + as JSAny; + } + if (value is GeoPoint) { + return interop.GeoPointJsImpl(value.latitude.toJS, value.longitude.toJS) + as JSAny; + } + if (value is Blob) { + return interop.BytesJsImpl.fromUint8Array(value.bytes.toJS) as JSAny; + } + if (value is List) { + return interop.BytesJsImpl.fromUint8Array(Uint8List.fromList(value).toJS) + as JSAny; + } + if (value is VectorValue) { + return interop.vector(value.toArray().jsify()! as JSArray) as JSAny; + } + if (value is Map) { + final path = value['path'] as String; + return interop.doc(_jsFirestore as JSAny, path.toJS) as JSAny; + } + return jsify(value); + } + + /// Extracts and safe-casts the 'args' sub-map from an expression map. + static Map _argsOf(Map map) { + final a = map[_kArgs]; + return a is Map ? a : const {}; + } + + /// Returns the string value if [exprMap] is a constant string expression; + /// otherwise null. + String? _constantStringFromExpression(Map exprMap) { + if ((exprMap[_kName] as String?) != 'constant') return null; + final args = _argsOf(exprMap); + final value = args[_kValue]; + return value is String ? value : null; + } + + /// Resolves [key] from [argsMap] as a value expression. + JSAny _expr(Map argsMap, String key) => + toExpression(argsMap[key] as Map); + + JSAny _jsArrayContains(Map argsMap) => + _pipelines.arrayContains( + _expr(argsMap, 'array'), + _expr(argsMap, 'element'), + ); + + JSAny? _jsArrayContainsAny(Map argsMap) { + final valuesMaps = argsMap['values'] as List?; + if (valuesMaps == null || valuesMaps.isEmpty) return null; + final valuesJs = valuesMaps + .map((v) => toExpression(v as Map)) + .toList() + .toJS; + return _pipelines.arrayContainsAny(_expr(argsMap, 'array'), valuesJs); + } + + JSAny? _jsArrayContainsAll(Map argsMap) { + final arrayExpr = _expr(argsMap, 'array'); + final valuesArg = argsMap['values'] as List?; + final arrayExpressionArg = argsMap['array_expression']; + if (valuesArg != null && valuesArg.isNotEmpty) { + final valuesJs = valuesArg + .map((v) => toExpression(v as Map)) + .toList() + .toJS; + return _pipelines.arrayContainsAll(arrayExpr, valuesJs); + } + if (arrayExpressionArg != null) { + return _pipelines.arrayContainsAll( + arrayExpr, + toExpression(arrayExpressionArg as Map), + ); + } + return null; + } + + JSAny? _jsEqualAny(Map argsMap) { + final valuesMaps = argsMap['values'] as List?; + if (valuesMaps == null || valuesMaps.isEmpty) return null; + final valuesJs = valuesMaps + .map((v) => toExpression(v as Map)) + .toList() + .toJS; + return _pipelines.equalAny(_expr(argsMap, 'value'), valuesJs); + } + + JSAny? _jsNotEqualAny(Map argsMap) { + final valuesMaps = argsMap['values'] as List?; + if (valuesMaps == null || valuesMaps.isEmpty) return null; + final valuesJs = valuesMaps + .map((v) => toExpression(v as Map)) + .toList() + .toJS; + return _pipelines.notEqualAny(_expr(argsMap, 'value'), valuesJs); + } + + interop.ExpressionJsImpl _binaryArithmetic( + Map argsMap, + interop.ExpressionJsImpl Function( + interop.ExpressionJsImpl left, interop.ExpressionJsImpl right) + op, + ) => + op( + toExpression(argsMap[_kLeft] as Map), + toExpression(argsMap[_kRight] as Map), + ); + + JSAny? _buildFilterExpression(Map argsMap) { + final operator = argsMap['operator'] as String?; + final expressions = argsMap['expressions'] as List?; + if (expressions == null || expressions.isEmpty) return null; + final jsList = expressions + .map((e) => toExpression(e as Map)) + .toList(); + if (jsList.length == 1) return jsList.single; + JSAny result = jsList[0]; + for (var i = 1; i < jsList.length; i++) { + result = operator == 'and' + ? _pipelines.and(result, jsList[i]) + : _pipelines.or(result, jsList[i]); + } + return result; + } + + List _toSelectableList(List expressions) => expressions + .map((e) => + toSelectable(e is Map ? e : {})) + .whereType() + .toList(); + + interop.AggregateStageOptionsJsImpl _buildAccumulators( + List items, { + List? groups, + }) { + final accumulators = items + .map((item) => _parseAccumulator(item as Map)) + .whereType() + .toList(); + + if (accumulators.isEmpty) { + throw UnsupportedError( + 'Pipeline aggregate() on web requires at least one valid accumulator.', + ); + } + + final opts = interop.AggregateStageOptionsJsImpl() + ..accumulators = accumulators.toJS; + if (groups != null && groups.isNotEmpty) { + opts.groups = _toSelectableList(groups).toJS; + } + return opts; + } + + JSAny? _parseAccumulator(Map item) { + final args = item[_kArgs] as Map; + final alias = args[_kAlias] as String?; + final aggregateFn = args['aggregate_function'] as Map?; + if (alias == null || aggregateFn == null) return null; + final fnName = aggregateFn[_kName] as String?; + if (fnName == null) return null; + final expressionMap = (aggregateFn[_kArgs] + as Map?)?[_kExpression] as Map?; + final exprJs = expressionMap != null ? toExpression(expressionMap) : null; + return _buildAggregateFunction(fnName, exprJs)?.asAlias(alias.toJS); + } + + /// Builds one JS aggregate function from a serialized [name] and optional [exprJs]. + interop.AggregateFunctionJsImpl? _buildAggregateFunction( + String name, JSAny? exprJs) { + switch (name) { + case 'count_all': + return _pipelines.countAll(); + case 'sum': + return exprJs != null ? _pipelines.sum(exprJs) : null; + case 'average': + return exprJs != null ? _pipelines.average(exprJs) : null; + case 'count': + return exprJs != null ? _pipelines.count(exprJs) : null; + case 'count_distinct': + return exprJs != null ? _pipelines.countDistinct(exprJs) : null; + case 'minimum': + return exprJs != null ? _pipelines.minimum(exprJs) : null; + case 'maximum': + return exprJs != null ? _pipelines.maximum(exprJs) : null; + default: + return null; + } + } +} diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_web.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_web.dart new file mode 100644 index 000000000000..5872ce7c5aaa --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_web.dart @@ -0,0 +1,110 @@ +// ignore_for_file: require_trailing_commas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; + +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart'; +import 'package:cloud_firestore_web/src/interop/utils/utils.dart'; + +import 'document_reference_web.dart'; +import 'interop/firestore.dart' as firestore_interop; +import 'interop/firestore_interop.dart' as interop; + +/// Web implementation of [PipelinePlatform]. +class PipelineWeb extends PipelinePlatform { + final firestore_interop.Firestore _firestoreWeb; + + PipelineWeb( + FirebaseFirestorePlatform firestore, + this._firestoreWeb, + List>? stages, + ) : super(firestore, stages); + + @override + PipelinePlatform addStage(Map serializedStage) { + return PipelineWeb( + firestore, + _firestoreWeb, + [...stages, serializedStage], + ); + } + + @override + Future execute({ + Map? options, + }) async { + return firestore.executePipeline(stages, options: options); + } +} + +/// Web implementation of [PipelineSnapshotPlatform]. +class PipelineSnapshotWeb extends PipelineSnapshotPlatform { + PipelineSnapshotWeb(this._results, this._executionTime) : super(); + + final List _results; + final DateTime _executionTime; + + @override + List get results => _results; + + @override + DateTime get executionTime => _executionTime; +} + +/// Web implementation of [PipelineResultPlatform]. +class PipelineResultWeb extends PipelineResultPlatform { + PipelineResultWeb( + FirebaseFirestorePlatform firestore, + firestore_interop.Firestore firestoreWeb, + interop.PipelineResultJsImpl jsResult, + ) : _document = jsResult.ref != null + ? DocumentReferenceWeb( + firestore, + firestoreWeb, + jsResult.ref!.path.toDart, + ) + : null, + _createTime = _timestampToDateTime(jsResult.createTime), + _updateTime = _timestampToDateTime(jsResult.updateTime), + _data = _dataFromResult(jsResult), + super(); + + final DocumentReferencePlatform? _document; + final DateTime? _createTime; + final DateTime? _updateTime; + final Map? _data; + + static Map? _dataFromResult( + interop.PipelineResultJsImpl jsResult) { + final d = jsResult.data(); + if (d == null) return null; + final parsed = dartify(d); + return parsed != null + ? Map.from(parsed as Map) + : null; + } + + static DateTime? _timestampToDateTime(JSAny? value) { + if (value == null) return null; + final d = dartify(value); + if (d == null) return null; + if (d is DateTime) return d; + if (d is Timestamp) return d.toDate(); + if (d is int) return DateTime.fromMillisecondsSinceEpoch(d); + return null; + } + + @override + DocumentReferencePlatform? get document => _document; + + @override + DateTime? get createTime => _createTime; + + @override + DateTime? get updateTime => _updateTime; + + @override + Map? get data => _data; +} diff --git a/packages/firebase_core/firebase_core_web/lib/src/firebase_core_web.dart b/packages/firebase_core/firebase_core_web/lib/src/firebase_core_web.dart index b70f503d9ff3..a3ae6f933db6 100644 --- a/packages/firebase_core/firebase_core_web/lib/src/firebase_core_web.dart +++ b/packages/firebase_core/firebase_core_web/lib/src/firebase_core_web.dart @@ -214,6 +214,17 @@ class FirebaseCoreWeb extends FirebasePlatform { return Future.value(); } + const firestoreServiceName = 'firestore'; + + if (service.name == firestoreServiceName) { + // Inject the Firestore Pipelines script. This bundle supports both + // Pipeline operations (Enterprise edition) and standard Firestore queries. + return injectSrcScript( + 'https://www.gstatic.com/firebasejs/$version/firebase-firestore-pipelines.js', + 'firebase_$firestoreServiceName', + ); + } + return injectSrcScript( 'https://www.gstatic.com/firebasejs/$version/firebase-${service.name}.js', 'firebase_${service.override ?? service.name}',