From 87d47b10aa3ce93243f2365020be5ad9715b1ffd Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 9 Mar 2026 13:44:23 +0000 Subject: [PATCH 1/8] feat(firestore): Android implementation for Pipeline APIs --- .../FlutterFirebaseFirestorePlugin.java | 72 +++ .../GeneratedAndroidFirebaseFirestore.java | 254 +++++++- .../firestore/utils/ExpressionHelpers.java | 159 +++++ .../firestore/utils/ExpressionParsers.java | 559 ++++++++++++++++++ .../firestore/utils/PipelineParser.java | 115 ++++ .../utils/PipelineStageHandlers.java | 338 +++++++++++ 6 files changed, 1490 insertions(+), 7 deletions(-) create mode 100644 packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionHelpers.java create mode 100644 packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java create mode 100644 packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineParser.java create mode 100644 packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java 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 4b65130f62a4..4a17da9d532f 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; @@ -983,4 +986,73 @@ 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()); + } + + // Convert timestamps (assuming they're in milliseconds) + if (pipelineResult.getCreateTime() != null) { + resultBuilder.setCreateTime(pipelineResult.getCreateTime().toDate().getTime()); + } else { + resultBuilder.setCreateTime(0L); + } + + if (pipelineResult.getUpdateTime() != null) { + resultBuilder.setUpdateTime(pipelineResult.getUpdateTime().toDate().getTime()); + } else { + resultBuilder.setUpdateTime(0L); + } + + 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 (use current time if not available from snapshot) + if (snapshot.getExecutionTime() != null) { + snapshotBuilder.setExecutionTime(snapshot.getExecutionTime().toDate().getTime()); + } else { + snapshotBuilder.setExecutionTime(System.currentTimeMillis()); + } + + 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 f7d24bc7c7ec..4e6e315d71fe 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..c937008dc3e2 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java @@ -0,0 +1,559 @@ +/* + * 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.HashMap; +import java.util.List; +import java.util.Map; + +/** Handles parsing of all expression types from Dart map representations to Android SDK objects. */ +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); + } + + /** Parses an expression from a map representation. */ + @SuppressWarnings("unchecked") + Expression parseExpression(@NonNull Map expressionMap) { + String name = (String) expressionMap.get("name"); + if (name == null) { + // Might be a field reference directly (legacy format) + if (expressionMap.containsKey("field_name")) { + String fieldName = (String) expressionMap.get("field_name"); + return Expression.field(fieldName); + } + // Check for field in args (current format) + 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 = (Map) expressionMap.get("args"); + if (args == null) { + args = new HashMap<>(); + } + + switch (name) { + 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) { + @SuppressWarnings("unchecked") + 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); + } + // Comparison operations + 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)); + // Arithmetic operations + 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)); + // Logic operations + 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); + } + default: + Log.w(TAG, "Unsupported expression type: " + name); + throw new UnsupportedOperationException("Expression type not yet implemented: " + name); + } + } + + /** Helper to parse binary comparison operations (equal, not_equal, greater_than, etc.). */ + @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); + } + + /** Helper to parse binary arithmetic operations (add, subtract, multiply, etc.). */ + @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); + } + + /** + * Parses a boolean expression from a map representation. Boolean expressions are used in where + * clauses and return BooleanExpression. + */ + @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 = (Map) expressionMap.get("args"); + if (args == null) { + args = new HashMap<>(); + } + + switch (name) { + // Comparison operations - these return BooleanExpression + 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)); + // Logical operations - these return BooleanExpression + case "and": + { + List> exprMaps = (List>) args.get("expressions"); + return ExpressionHelpers.parseAndExpression(exprMaps, this); + } + case "or": + { + List> exprMaps = (List>) args.get("expressions"); + if (exprMaps == null || exprMaps.isEmpty()) { + throw new IllegalArgumentException("'or' requires at least one expression"); + } + if (exprMaps.size() == 1) { + return parseBooleanExpression(exprMaps.get(0)); + } + // BooleanExpression.or() takes exactly 2 parameters, so we chain them + BooleanExpression result = parseBooleanExpression(exprMaps.get(0)); + for (int i = 1; i < exprMaps.size(); i++) { + BooleanExpression next = parseBooleanExpression(exprMaps.get(i)); + result = BooleanExpression.or(result, next); + } + return result; + } + 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(); + } + // Boolean-specific expressions + case "is_absent": + { + Map exprMap = (Map) args.get("expression"); + Expression expr = parseExpression(exprMap); + return expr.isAbsent(); + } + case "is_error": + { + Map exprMap = (Map) args.get("expression"); + Expression expr = parseExpression(exprMap); + return expr.isError(); + } + case "exists": + { + Map exprMap = (Map) args.get("expression"); + Expression expr = parseExpression(exprMap); + return expr.exists(); + } + case "array_contains": + { + Map arrayMap = (Map) args.get("array"); + Map elementMap = (Map) args.get("element"); + Expression array = parseExpression(arrayMap); + Expression element = parseExpression(elementMap); + return array.arrayContains(element); + } + case "array_contains_all": + { + Map arrayMap = (Map) args.get("array"); + Map arrayExprMap = (Map) args.get("array_expression"); + Expression array = parseExpression(arrayMap); + Expression arrayExpr = parseExpression(arrayExprMap); + return array.arrayContainsAll(arrayExpr); + } + case "array_contains_any": + { + Map arrayMap = (Map) args.get("array"); + Map arrayExprMap = (Map) args.get("array_expression"); + Expression array = parseExpression(arrayMap); + Expression arrayExpr = parseExpression(arrayExprMap); + return array.arrayContainsAny(arrayExpr); + } + case "equal_any": + { + Map valueMap = (Map) args.get("value"); + List> valuesMaps = (List>) args.get("values"); + Expression value = parseExpression(valueMap); + Expression[] values = new Expression[valuesMaps.size()]; + for (int i = 0; i < valuesMaps.size(); i++) { + values[i] = parseExpression(valuesMaps.get(i)); + } + return value.equalAny(List.of(values)); + } + case "not_equal_any": + { + Map valueMap = (Map) args.get("value"); + List> valuesMaps = (List>) args.get("values"); + Expression value = parseExpression(valueMap); + Expression[] values = new Expression[valuesMaps.size()]; + for (int i = 0; i < valuesMaps.size(); i++) { + values[i] = parseExpression(valuesMaps.get(i)); + } + return value.notEqualAny(List.of(values)); + } + case "as_boolean": + { + Map exprMap = (Map) args.get("expression"); + Expression expr = parseExpression(exprMap); + return expr.asBoolean(); + } + // Handle filter expressions (PipelineFilter) + case "filter": + return parseFilterExpression(args); + default: + // Try parsing as a regular expression first, then cast to BooleanExpression if possible + 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"); + } + } + + /** + * Parses a filter expression (PipelineFilter) which can have operator-based or field-based forms. + */ + @SuppressWarnings("unchecked") + private BooleanExpression parseFilterExpression(@NonNull Map args) { + // PipelineFilter can have various forms - check for operator-based or field-based + if (args.containsKey("operator")) { + String operator = (String) args.get("operator"); + List> exprMaps = (List>) args.get("expressions"); + if ("and".equals(operator)) { + return ExpressionHelpers.parseAndExpression(exprMaps, this); + } else if ("or".equals(operator)) { + if (exprMaps == null || exprMaps.isEmpty()) { + throw new IllegalArgumentException("'or' requires at least one expression"); + } + if (exprMaps.size() == 1) { + return parseBooleanExpression(exprMaps.get(0)); + } + // BooleanExpression.or() takes exactly 2 parameters, so we chain them + BooleanExpression result = parseBooleanExpression(exprMaps.get(0)); + for (int i = 1; i < exprMaps.size(); i++) { + BooleanExpression next = parseBooleanExpression(exprMaps.get(i)); + result = BooleanExpression.or(result, next); + } + return result; + } + } + // Field-based filter - parse field and create appropriate comparison + String fieldName = (String) args.get("field"); + Expression fieldExpr = Expression.field(fieldName); + + return parseFieldBasedFilter(fieldExpr, args); + } + + /** Parses field-based filter comparisons (isEqualTo, isGreaterThan, etc.). */ + @SuppressWarnings("unchecked") + private BooleanExpression parseFieldBasedFilter( + @NonNull Expression fieldExpr, @NonNull Map args) { + if (args.containsKey("isEqualTo")) { + Object value = args.get("isEqualTo"); + return value instanceof Map + ? fieldExpr.equal(parseExpression((Map) value)) + : fieldExpr.equal(value); + } + if (args.containsKey("isNotEqualTo")) { + Object value = args.get("isNotEqualTo"); + return value instanceof Map + ? fieldExpr.notEqual(parseExpression((Map) value)) + : fieldExpr.notEqual(value); + } + if (args.containsKey("isGreaterThan")) { + Object value = args.get("isGreaterThan"); + return value instanceof Map + ? fieldExpr.greaterThan(parseExpression((Map) value)) + : fieldExpr.greaterThan(value); + } + if (args.containsKey("isGreaterThanOrEqualTo")) { + Object value = args.get("isGreaterThanOrEqualTo"); + return value instanceof Map + ? fieldExpr.greaterThanOrEqual(parseExpression((Map) value)) + : fieldExpr.greaterThanOrEqual(value); + } + if (args.containsKey("isLessThan")) { + Object value = args.get("isLessThan"); + return value instanceof Map + ? fieldExpr.lessThan(parseExpression((Map) value)) + : fieldExpr.lessThan(value); + } + if (args.containsKey("isLessThanOrEqualTo")) { + Object value = args.get("isLessThanOrEqualTo"); + return value instanceof Map + ? fieldExpr.lessThanOrEqual(parseExpression((Map) value)) + : fieldExpr.lessThanOrEqual(value); + } + if (args.containsKey("arrayContains")) { + Object value = args.get("arrayContains"); + return value instanceof Map + ? fieldExpr.arrayContains(parseExpression((Map) value)) + : fieldExpr.arrayContains(value); + } + throw new IllegalArgumentException("Unsupported filter expression format"); + } + + /** + * Parses a Selectable from a map representation. Selectables are Field or AliasedExpression + * types. + */ + @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; + } + + /** Parses an aggregate function from a map representation. */ + @SuppressWarnings("unchecked") + AggregateFunction parseAggregateFunction(@NonNull Map aggregateMap) { + String functionName = (String) aggregateMap.get("function"); + if (functionName == null) { + // Try "name" as fallback + functionName = (String) aggregateMap.get("name"); + } + Map args = (Map) aggregateMap.get("args"); + Map exprMap; + Expression expr = null; + if (args != null) { + 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); + } + } + + /** + * Parses an AliasedAggregate from a Dart AliasedAggregateFunction map representation. Since Dart + * API only accepts AliasedAggregateFunction, we can directly construct AliasedAggregate. + */ + @SuppressWarnings("unchecked") + AliasedAggregate parseAliasedAggregate(@NonNull Map aggregateMap) { + // Check if this is an aliased aggregate function (Dart AliasedAggregateFunction format) + 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"); + + // Parse the underlying aggregate function + AggregateFunction function = parseAggregateFunction(aggregateFunctionMap); + + // Apply the alias to get AliasedAggregate + return function.alias(alias); + } + + // If not in alias format, it might be a direct aggregate function with alias field + // This shouldn't happen with the new Dart API, but handle for backward compatibility + 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."); + } + + /** Parses an AggregateStage from a map representation. */ + @SuppressWarnings("unchecked") + AggregateStage parseAggregateStage(@NonNull Map stageMap) { + // Parse accumulators (required) + List> accumulatorMaps = + (List>) stageMap.get("accumulators"); + if (accumulatorMaps == null || accumulatorMaps.isEmpty()) { + throw new IllegalArgumentException("AggregateStage must have at least one accumulator"); + } + + // Parse accumulators as AliasedAggregate + AliasedAggregate[] accumulators = new AliasedAggregate[accumulatorMaps.size()]; + for (int i = 0; i < accumulatorMaps.size(); i++) { + accumulators[i] = parseAliasedAggregate(accumulatorMaps.get(i)); + } + + // Build AggregateStage with accumulators + 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); + } + + // Parse optional groups and add them using withGroups() + // withGroups(group: Selectable, vararg additionalGroups: Any) + List> groupMaps = (List>) stageMap.get("groups"); + if (groupMaps != null && !groupMaps.isEmpty()) { + // Parse first group as Selectable (required) + Selectable firstGroup = parseSelectable(groupMaps.get(0)); + + if (groupMaps.size() == 1) { + // Only one group + aggregateStage = aggregateStage.withGroups(firstGroup); + } else { + // Multiple groups - parse remaining as Any[] (varargs) + Object[] additionalGroups = new Object[groupMaps.size() - 1]; + for (int i = 1; i < groupMaps.size(); i++) { + // Parse as Expression first, then convert to Object (can be Selectable or Any) + Expression expr = parseExpression(groupMaps.get(i)); + additionalGroups[i - 1] = expr; + } + aggregateStage = aggregateStage.withGroups(firstGroup, additionalGroups); + } + } + + return aggregateStage; + } + + /** Parses AggregateOptions from a map representation. */ + @SuppressWarnings("unchecked") + AggregateOptions parseAggregateOptions(@NonNull Map optionsMap) { + // For now, AggregateOptions is empty, but this method is ready for future options + return new AggregateOptions(); + } + + /** + * Converts a Dart DistanceMeasure enum name to Android FindNearestStage.DistanceMeasure enum. + * Dart enum values: cosine, euclidean, dotProduct Android enum values: COSINE, EUCLIDEAN, + * DOT_PRODUCT + */ + 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..9528a5ad8eb6 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineParser.java @@ -0,0 +1,115 @@ +/* + * 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 = pipeline.execute(); + return Tasks.await(task); + } + + /** + * 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) { + 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) { + 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..a1e5525813cb --- /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 (type == "percentage") { + double value = (double) sampleMap.get("value"); + return pipeline.sample(SampleStage.withPercentage(value)); + } else { + int value = (int) sampleMap.get("value"); + 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); + } + } +} From 71dbb012819df3840475728188773950fcf18051 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Tue, 10 Mar 2026 19:41:17 +0000 Subject: [PATCH 2/8] refactor: improve handling of timestamps and array expressions in pipeline --- .../FlutterFirebaseFirestorePlugin.java | 10 +------ .../firestore/utils/ExpressionParsers.java | 29 ++++++++++++++----- .../firestore/utils/PipelineParser.java | 6 ++++ .../utils/PipelineStageHandlers.java | 6 ++-- 4 files changed, 31 insertions(+), 20 deletions(-) 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 4a17da9d532f..60822225ee56 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 @@ -1016,17 +1016,11 @@ public void executePipeline( resultBuilder.setDocumentPath(pipelineResult.getRef().getPath()); } - // Convert timestamps (assuming they're in milliseconds) if (pipelineResult.getCreateTime() != null) { resultBuilder.setCreateTime(pipelineResult.getCreateTime().toDate().getTime()); - } else { - resultBuilder.setCreateTime(0L); } - if (pipelineResult.getUpdateTime() != null) { resultBuilder.setUpdateTime(pipelineResult.getUpdateTime().toDate().getTime()); - } else { - resultBuilder.setUpdateTime(0L); } Map data = pipelineResult.getData(); @@ -1042,11 +1036,9 @@ public void executePipeline( new GeneratedAndroidFirebaseFirestore.PigeonPipelineSnapshot.Builder(); snapshotBuilder.setResults(pipelineResults); - // Set execution time (use current time if not available from snapshot) + // Set execution time when available. Do not fabricate a value when null. if (snapshot.getExecutionTime() != null) { snapshotBuilder.setExecutionTime(snapshot.getExecutionTime().toDate().getTime()); - } else { - snapshotBuilder.setExecutionTime(System.currentTimeMillis()); } result.success(snapshotBuilder.build()); 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 index c937008dc3e2..b3ac1952fe11 100644 --- 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 @@ -17,6 +17,7 @@ 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; @@ -255,18 +256,30 @@ BooleanExpression parseBooleanExpression(@NonNull Map expression case "array_contains_all": { Map arrayMap = (Map) args.get("array"); - Map arrayExprMap = (Map) args.get("array_expression"); Expression array = parseExpression(arrayMap); - Expression arrayExpr = parseExpression(arrayExprMap); - return array.arrayContainsAll(arrayExpr); + if (args.get("values") != null) { + List> valuesMaps = (List>) args.get("values"); + Expression[] values = new Expression[valuesMaps.size()]; + for (int i = 0; i < valuesMaps.size(); i++) { + values[i] = parseExpression(valuesMaps.get(i)); + } + return array.arrayContainsAll(Arrays.asList(values)); + } else { + Map arrayExprMap = (Map) args.get("array_expression"); + Expression arrayExpr = parseExpression(arrayExprMap); + return array.arrayContainsAll(arrayExpr); + } } case "array_contains_any": { Map arrayMap = (Map) args.get("array"); - Map arrayExprMap = (Map) args.get("array_expression"); + List> valuesMaps = (List>) args.get("values"); Expression array = parseExpression(arrayMap); - Expression arrayExpr = parseExpression(arrayExprMap); - return array.arrayContainsAny(arrayExpr); + Expression[] values = new Expression[valuesMaps.size()]; + for (int i = 0; i < valuesMaps.size(); i++) { + values[i] = parseExpression(valuesMaps.get(i)); + } + return array.arrayContainsAny(Arrays.asList(values)); } case "equal_any": { @@ -277,7 +290,7 @@ BooleanExpression parseBooleanExpression(@NonNull Map expression for (int i = 0; i < valuesMaps.size(); i++) { values[i] = parseExpression(valuesMaps.get(i)); } - return value.equalAny(List.of(values)); + return value.equalAny(Arrays.asList(values)); } case "not_equal_any": { @@ -288,7 +301,7 @@ BooleanExpression parseBooleanExpression(@NonNull Map expression for (int i = 0; i < valuesMaps.size(); i++) { values[i] = parseExpression(valuesMaps.get(i)); } - return value.notEqualAny(List.of(values)); + return value.notEqualAny(Arrays.asList(values)); } case "as_boolean": { 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 index 9528a5ad8eb6..292a5a751dd0 100644 --- 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 @@ -47,6 +47,9 @@ public static Snapshot executePipeline( @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(); @@ -81,6 +84,9 @@ private static Pipeline applySourceStage( @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": { 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 index a1e5525813cb..cc0e6b0c35e6 100644 --- 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 @@ -292,11 +292,11 @@ private Pipeline handleSample(@NonNull Pipeline pipeline, @Nullable Map sampleMap = (Map) args; // Parse sample configuration String type = (String) sampleMap.get("type"); - if (type == "percentage") { - double value = (double) sampleMap.get("value"); + if ("percentage".equals(type)) { + double value = ((Number) sampleMap.get("value")).doubleValue(); return pipeline.sample(SampleStage.withPercentage(value)); } else { - int value = (int) sampleMap.get("value"); + int value = ((Number) sampleMap.get("value")).intValue(); return pipeline.sample(SampleStage.withDocLimit(value)); } } From 72731049692b04a5056def0af11f2ee2982c3673 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 11 Mar 2026 10:20:18 +0000 Subject: [PATCH 3/8] chore: add support for more Expression functions in Android --- .../firestore/utils/ExpressionParsers.java | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) 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 index b3ac1952fe11..8a8693a6e1c4 100644 --- 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 @@ -134,6 +134,273 @@ Expression parseExpression(@NonNull Map expressionMap) { BooleanExpression expr = parseBooleanExpression(exprMap); return Expression.not(expr); } + // String / array value expressions + 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": + { + Map exprMap = (Map) args.get("expression"); + return Expression.length(parseExpression(exprMap)); + } + case "to_lower_case": + { + Map exprMap = (Map) args.get("expression"); + return Expression.toLower(parseExpression(exprMap)); + } + case "to_upper_case": + { + Map exprMap = (Map) args.get("expression"); + return Expression.toUpper(parseExpression(exprMap)); + } + case "trim": + { + Map exprMap = (Map) args.get("expression"); + return Expression.trim(parseExpression(exprMap)); + } + 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); + // Android uses (stringExpression, index, length). Dart uses (expression, start, end). + Expression lengthExpr = Expression.subtract(endExpr, startExpr); + return Expression.substring(stringExpr, startExpr, lengthExpr); + } + case "replace": + throw new UnsupportedOperationException( + "Expression type 'replace' is not supported on Android Firestore pipeline API"); + 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)); + } + // Numeric + case "abs": + { + Map exprMap = (Map) args.get("expression"); + return Expression.abs(parseExpression(exprMap)); + } + case "negate": + { + Map exprMap = (Map) args.get("expression"); + Expression expr = parseExpression(exprMap); + return Expression.subtract(Expression.constant(0), expr); + } + // Array expressions + 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": + { + Map exprMap = (Map) args.get("expression"); + return Expression.arrayLength(parseExpression(exprMap)); + } + case "array_reverse": + { + Map exprMap = (Map) args.get("expression"); + return Expression.arrayReverse(parseExpression(exprMap)); + } + case "array_sum": + { + Map exprMap = (Map) args.get("expression"); + return Expression.arraySum(parseExpression(exprMap)); + } + case "array_slice": + throw new UnsupportedOperationException( + "Expression type 'array_slice' is not supported on Android Firestore pipeline API"); + // Conditional / logic value expressions + 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); + } + // Document / path + case "document_id": + { + Map exprMap = (Map) args.get("expression"); + return Expression.documentId(parseExpression(exprMap)); + } + 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": + { + Map exprMap = (Map) args.get("expression"); + return Expression.collectionId(parseExpression(exprMap)); + } + // Map operations + case "map_get": + { + Map mapMap = (Map) args.get("map"); + Map keyMap = (Map) args.get("key"); + return Expression.mapGet(parseExpression(mapMap), parseExpression(keyMap)); + } + // Timestamp + 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); + } + // Array / map literals + 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); + } + // Bitwise + 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": + { + Map exprMap = (Map) args.get("expression"); + return parseExpression(exprMap).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)); + } default: Log.w(TAG, "Unsupported expression type: " + name); throw new UnsupportedOperationException("Expression type not yet implemented: " + name); From 91d0191865dbfb9f1605e27dd1c194cc3a71ec26 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Fri, 13 Mar 2026 13:36:24 +0000 Subject: [PATCH 4/8] chore: remove unsupported 'replace' expression from Android Firestore pipeline API --- .../plugins/firebase/firestore/utils/ExpressionParsers.java | 3 --- 1 file changed, 3 deletions(-) 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 index 8a8693a6e1c4..19a0bf359574 100644 --- 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 @@ -184,9 +184,6 @@ Expression parseExpression(@NonNull Map expressionMap) { Expression lengthExpr = Expression.subtract(endExpr, startExpr); return Expression.substring(stringExpr, startExpr, lengthExpr); } - case "replace": - throw new UnsupportedOperationException( - "Expression type 'replace' is not supported on Android Firestore pipeline API"); case "split": { Map valueMap = (Map) args.get("expression"); From 54366bf1efcb1892b0cfbd3b5e12ce2e63313d6b Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Tue, 17 Mar 2026 12:50:56 +0000 Subject: [PATCH 5/8] chore: enhance PipelineParser to support execute options for Firestore pipelines --- .../firestore/utils/PipelineParser.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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 index 292a5a751dd0..6029a28f7ef8 100644 --- 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 @@ -36,10 +36,29 @@ public static Snapshot executePipeline( @Nullable Map options) throws Exception { Pipeline pipeline = buildPipeline(firestore, stages); - Task task = pipeline.execute(); + 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. From 0c1025ba51a36e32340eb487ab47765770377677 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Tue, 17 Mar 2026 15:18:05 +0000 Subject: [PATCH 6/8] chore: remove commented-out button for stringReplaceAll in pipeline example --- .../cloud_firestore/pipeline_example/lib/main.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart index 8fcf24602134..bb18fc444e71 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/lib/main.dart @@ -988,7 +988,6 @@ class _PipelineExamplePageState extends State { _btn('24: lower/upper', _runPipeline24), _btn('25: trim', _runPipeline25), _btn('26: substring', _runPipeline26), - // _btn('27: stringReplaceAll', _runPipeline27), _btn('28: split', _runPipeline28), _btn('29: join', _runPipeline29), _btn('30: if_absent', _runPipeline30), From d5eb2043fbbeb19b4000862ad9e615e383d0f735 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 18 Mar 2026 09:27:15 +0000 Subject: [PATCH 7/8] trigger CI From 2031f8defb7f1d36a6679c208a6a7041ad47a873 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Wed, 18 Mar 2026 09:35:11 +0000 Subject: [PATCH 8/8] trigger CI