diff --git a/.github/workflows/pr_title.yaml b/.github/workflows/pr_title.yaml index 317338e169a3..453064704526 100644 --- a/.github/workflows/pr_title.yaml +++ b/.github/workflows/pr_title.yaml @@ -9,8 +9,10 @@ on: jobs: validate: + permissions: + pull-requests: read runs-on: ubuntu-latest steps: - uses: amannn/action-semantic-pull-request@0723387faaf9b38adef4775cd42cfd5155ed6017 env: - GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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..7e2c4020aa6d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionHelpers.java @@ -0,0 +1,134 @@ +/* + * 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 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..1f80f671a97b --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java @@ -0,0 +1,536 @@ +/* + * 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.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"; + + /** 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"); + 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 "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 "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..2e0296d92f74 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineParser.java @@ -0,0 +1,116 @@ +/* + * 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"; + private static final ExpressionParsers expressionParsers = new ExpressionParsers(); + private static final PipelineStageHandlers stageHandlers = + new PipelineStageHandlers(expressionParsers); + + /** + * 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) { + 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..78ef835e3006 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java @@ -0,0 +1,341 @@ +/* + * 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 "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) { + // Check if this is using aggregate_stage (new API) or aggregate_functions (legacy API) + if (args.containsKey("aggregate_stage")) { + // New API: aggregateStage with optional options + Map aggregateStageMap = (Map) args.get("aggregate_stage"); + AggregateStage aggregateStage = parsers.parseAggregateStage(aggregateStageMap); + + // Parse optional options + Map optionsMap = (Map) args.get("options"); + if (optionsMap != null && !optionsMap.isEmpty()) { + AggregateOptions options = parsers.parseAggregateOptions(optionsMap); + return pipeline.aggregate(aggregateStage, options); + } else { + return pipeline.aggregate(aggregateStage); + } + } else { + // Legacy API: aggregate_functions (varargs) + List> aggregateMaps = + (List>) args.get("aggregate_functions"); + + if (aggregateMaps == null || aggregateMaps.isEmpty()) { + throw new IllegalArgumentException( + "'aggregate' requires at least one aggregate function or an aggregate_stage"); + } + + // Parse first aggregate function as AliasedAggregate + AliasedAggregate firstAccumulator = parsers.parseAliasedAggregate(aggregateMaps.get(0)); + + // Parse remaining aggregate functions as AliasedAggregate varargs + 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); + } + } + + 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); + } + } +} 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/FLTPipelineParser.m b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m new file mode 100644 index 000000000000..3018ce5820fa --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/ios/cloud_firestore/Sources/cloud_firestore/FLTPipelineParser.m @@ -0,0 +1,866 @@ +/* + * 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" + +#if TARGET_OS_OSX +#import +#else +@import FirebaseFirestore; +#import "FIRPipelineBridge.h" +#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:-1 + userInfo:@{NSLocalizedDescriptionKey : kPipelineNotAvailable}]; +} + +#if __has_include("FIRPipelineBridge.h") +#define FLT_PIPELINE_AVAILABLE 1 + +static NSError *parseError(NSString *message) { + return [NSError errorWithDomain:@"FLTFirebaseFirestore" + code:-1 + userInfo:@{NSLocalizedDescriptionKey : message}]; +} + +@interface FLTPipelineExpressionParser : NSObject +- (FIRExprBridge *)parseExpression:(NSDictionary *)map error:(NSError **)error; +- (FIRExprBridge *)parseBooleanExpression:(NSDictionary *)map + error:(NSError **)error; +- (FIRExprBridge *)rightExprFromValue:(id)value error:(NSError **)error; +@end + +@implementation FLTPipelineExpressionParser + +- (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; + } + 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]; + } + + // Map Dart names to iOS SDK names where they differ + NSString *sdkName = name; + if ([name isEqualToString:@"bit_xor"]) sdkName = @"xor"; + + // ------------------------------------------------------------------------- + // Binary expressions (left + right): comparisons, arithmetic, xor + // ------------------------------------------------------------------------- + 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", @"modulo", @"xor" + ]; + }); + 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 + // ------------------------------------------------------------------------- + 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 ]]; + } + + // ------------------------------------------------------------------------- + // N-ary logical (expressions array): and, or + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"and"] || [name isEqualToString:@"or"]) { + 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]; + } + + // ------------------------------------------------------------------------- + // value + values[]: equal_any, not_equal_any + // ------------------------------------------------------------------------- + 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; + } + NSMutableArray *argsArray = [NSMutableArray arrayWithObject:valueExpr]; + [argsArray addObjectsFromArray:valueExprs]; + return [[FIRFunctionExprBridge alloc] initWithName:name Args:argsArray]; + } + + // ------------------------------------------------------------------------- + // 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 + // ------------------------------------------------------------------------- + if ([name isEqualToString:@"array_contains_all"] || + [name isEqualToString:@"array_contains_any"]) { + id arrayMap = args[@"array"]; + NSArray *valuesMaps = args[@"values"]; + if (![valuesMaps isKindOfClass:[NSArray class]]) valuesMaps = args[@"elements"]; + 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; + NSMutableArray *argsArray = [NSMutableArray arrayWithObject:arrayExpr]; + if ([valuesMaps isKindOfClass:[NSArray class]]) { + for (id vm in valuesMaps) { + if (![vm isKindOfClass:[NSDictionary class]]) continue; + FIRExprBridge *ve = [self parseExpression:vm error:error]; + if (!ve) return nil; + [argsArray addObject:ve]; + } + } + if (argsArray.count < 2) { + if (error) + *error = parseError( + [NSString stringWithFormat:@"%@ requires array and at least one value", name]); + return nil; + } + return [[FIRFunctionExprBridge alloc] initWithName:name Args:argsArray]; + } + + // ------------------------------------------------------------------------- + // 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; + } + NSMutableArray *argsArray = [NSMutableArray arrayWithObject:fieldExpr]; + [argsArray addObjectsFromArray:valueExprs]; + return [[FIRFunctionExprBridge alloc] initWithName:@"equal_any" Args:argsArray]; + } + 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; + } + NSMutableArray *argsArray = [NSMutableArray arrayWithObject:fieldExpr]; + [argsArray addObjectsFromArray:valueExprs]; + return [[FIRFunctionExprBridge alloc] initWithName:@"not_equal_any" Args:argsArray]; + } + 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(@"select/distinct 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] init]; + 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:@"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 (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 { + NSError *parseErr = nil; + NSArray *accumulatorMaps = nil; + NSArray *groupMaps = nil; + + if (args[@"aggregate_stage"]) { + NSDictionary *stageMap = args[@"aggregate_stage"]; + if (![stageMap isKindOfClass:[NSDictionary class]]) { + if (error) *error = parseError(@"aggregate_stage must be a map"); + return nil; + } + accumulatorMaps = stageMap[@"accumulators"]; + groupMaps = stageMap[@"groups"]; + } + if (!accumulatorMaps || ![accumulatorMaps isKindOfClass:[NSArray class]]) { + accumulatorMaps = args[@"aggregate_functions"]; + } + if (![accumulatorMaps isKindOfClass:[NSArray class]] || accumulatorMaps.count == 0) { + if (error) *error = parseError(@"aggregate requires accumulators or aggregate_functions"); + return 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; + groups[[NSString stringWithFormat:@"_%lu", (unsigned long)g]] = 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 34e717b694a7..3a6b44eaad00 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]; } @@ -934,11 +1016,12 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, binaryMessenger:binaryMessenger codec:FirebaseFirestoreHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(setIndexConfigurationApp: - indexConfiguration:completion:)], - @"FirebaseFirestoreHostApi api (%@) doesn't respond to " - @"@selector(setIndexConfigurationApp:indexConfiguration:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector( + setIndexConfigurationApp:indexConfiguration:completion:)], + @"FirebaseFirestoreHostApi api (%@) doesn't respond to " + @"@selector(setIndexConfigurationApp:indexConfiguration:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; FirestorePigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1006,11 +1089,11 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, binaryMessenger:binaryMessenger codec:FirebaseFirestoreHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(transactionCreateApp: - timeout:maxAttempts:completion:)], - @"FirebaseFirestoreHostApi api (%@) doesn't respond to " - @"@selector(transactionCreateApp:timeout:maxAttempts:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(transactionCreateApp:timeout:maxAttempts:completion:)], + @"FirebaseFirestoreHostApi api (%@) doesn't respond to " + @"@selector(transactionCreateApp:timeout:maxAttempts:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; FirestorePigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1034,8 +1117,8 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, binaryMessenger:binaryMessenger codec:FirebaseFirestoreHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (transactionStoreResultTransactionId:resultType:commands:completion:)], + NSCAssert([api respondsToSelector:@selector(transactionStoreResultTransactionId:resultType: + commands:completion:)], @"FirebaseFirestoreHostApi api (%@) doesn't respond to " @"@selector(transactionStoreResultTransactionId:resultType:commands:completion:)", api); @@ -1062,11 +1145,11 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, binaryMessenger:binaryMessenger codec:FirebaseFirestoreHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(transactionGetApp: - transactionId:path:completion:)], - @"FirebaseFirestoreHostApi api (%@) doesn't respond to " - @"@selector(transactionGetApp:transactionId:path:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(transactionGetApp:transactionId:path:completion:)], + @"FirebaseFirestoreHostApi api (%@) doesn't respond to " + @"@selector(transactionGetApp:transactionId:path:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; FirestorePigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1192,8 +1275,8 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, binaryMessenger:binaryMessenger codec:FirebaseFirestoreHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (queryGetApp:path:isCollectionGroup:parameters:options:completion:)], + NSCAssert([api respondsToSelector:@selector(queryGetApp:path:isCollectionGroup:parameters: + options:completion:)], @"FirebaseFirestoreHostApi api (%@) doesn't respond to " @"@selector(queryGetApp:path:isCollectionGroup:parameters:options:completion:)", api); @@ -1225,9 +1308,8 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, binaryMessenger:binaryMessenger codec:FirebaseFirestoreHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (aggregateQueryApp: - path:parameters:source:queries:isCollectionGroup:completion:)], + NSCAssert([api respondsToSelector:@selector(aggregateQueryApp:path:parameters:source:queries: + isCollectionGroup:completion:)], @"FirebaseFirestoreHostApi api (%@) doesn't respond to " @"@selector(aggregateQueryApp:path:parameters:source:queries:isCollectionGroup:" @"completion:)", @@ -1287,14 +1369,13 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, binaryMessenger:binaryMessenger codec:FirebaseFirestoreHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (querySnapshotApp: - path:isCollectionGroup:parameters:options:includeMetadataChanges - :source:completion:)], - @"FirebaseFirestoreHostApi api (%@) doesn't respond to " - @"@selector(querySnapshotApp:path:isCollectionGroup:parameters:options:" - @"includeMetadataChanges:source:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(querySnapshotApp:path:isCollectionGroup:parameters: + options:includeMetadataChanges:source:completion:)], + @"FirebaseFirestoreHostApi api (%@) doesn't respond to " + @"@selector(querySnapshotApp:path:isCollectionGroup:parameters:options:" + @"includeMetadataChanges:source:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; FirestorePigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1326,9 +1407,8 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, binaryMessenger:binaryMessenger codec:FirebaseFirestoreHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (documentReferenceSnapshotApp: - parameters:includeMetadataChanges:source:completion:)], + NSCAssert([api respondsToSelector:@selector(documentReferenceSnapshotApp:parameters: + includeMetadataChanges:source:completion:)], @"FirebaseFirestoreHostApi api (%@) doesn't respond to " @"@selector(documentReferenceSnapshotApp:parameters:includeMetadataChanges:source:" @"completion:)", @@ -1359,11 +1439,12 @@ void FirebaseFirestoreHostApiSetup(id binaryMessenger, binaryMessenger:binaryMessenger codec:FirebaseFirestoreHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (persistenceCacheIndexManagerRequestApp:request:completion:)], - @"FirebaseFirestoreHostApi api (%@) doesn't respond to " - @"@selector(persistenceCacheIndexManagerRequestApp:request:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector( + persistenceCacheIndexManagerRequestApp:request:completion:)], + @"FirebaseFirestoreHostApi api (%@) doesn't respond to " + @"@selector(persistenceCacheIndexManagerRequestApp:request:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; FirestorePigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1379,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/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 2eabaeaef25f..a435fda97bbd 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/lib/cloud_firestore.dart b/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart index 6efbb19345ce..b0febde386b2 100755 --- a/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart @@ -53,7 +53,17 @@ part 'src/filters.dart'; part 'src/firestore.dart'; part 'src/load_bundle_task.dart'; part 'src/load_bundle_task_snapshot.dart'; +part '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/pipeline_snapshot.dart b/packages/cloud_firestore/cloud_firestore/lib/pipeline_snapshot.dart new file mode 100644 index 000000000000..ac1f287acb18 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/pipeline_snapshot.dart @@ -0,0 +1,32 @@ +// 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'; + +/// Result of executing a pipeline +class PipelineResult { + final DocumentReference> document; + final DateTime? createTime; + final DateTime? updateTime; + final Map? _data; + + PipelineResult({ + required this.document, + this.createTime, + this.updateTime, + Map? data, + }) : _data = data; + + /// Retrieves all fields in the result as a map. + /// Returns null if the result has no data. + Map? data() => _data; +} + +/// Snapshot containing pipeline execution results +class PipelineSnapshot { + final List result; + final DateTime executionTime; + + PipelineSnapshot._(this.result, this.executionTime); +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart b/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart index 4f735629aee1..e5fcc3264cac 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/firestore.dart @@ -339,6 +339,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(PipelineFilter(Field('age'), isGreaterThan: 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..3565b835bbc9 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart @@ -0,0 +1,603 @@ +// 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 +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 the pipeline and returns a snapshot of the results + 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: _JsonDocumentReference( + _firestore, + platformResult.document, + ), + createTime: platformResult.createTime, + updateTime: platformResult.updateTime, + data: platformResult.data, + ); + }).toList(); + + return PipelineSnapshot._(results, platformSnapshot.executionTime); + } + + // Pipeline Actions + + /// Adds fields to documents using expressions + 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 (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()), + ); + } + + /// Aggregates data using aggregate functions + 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.aggregateStage( + /// AggregateStage( + /// accumulators: [ + /// Expression.field('likes').sum().as('total_likes'), + /// Expression.field('likes').average().as('avg_likes'), + /// ], + /// groups: [Expression.field('category')], + /// ), + /// ); + /// ``` + /// + /// With options: + /// ```dart + /// pipeline.aggregateStage( + /// AggregateStage( + /// accumulators: [ + /// Expression.field('likes').sum().as('total_likes'), + /// ], + /// ), + /// options: AggregateOptions(), + /// ); + /// ``` + Pipeline aggregateStage( + AggregateStage aggregateStage, { + AggregateOptions? options, + }) { + final stage = _AggregateStageWithOptions( + aggregateStage, + options ?? AggregateOptions(), + ); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Gets distinct values based on expressions + 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()), + ); + } + + /// Finds nearest vectors using vector similarity search + 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 number of results + Pipeline limit(int limit) { + final stage = _LimitStage(limit); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Offsets the results + Pipeline offset(int offset) { + final stage = _OffsetStage(offset); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Removes specified fields from documents + 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()), + ); + } + + /// Replaces documents with the result of an expression + Pipeline replaceWith(Expression expression) { + final stage = _ReplaceWithStage(expression); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Samples documents using a sampling strategy + Pipeline sample(PipelineSample sample) { + final stage = _SampleStage(sample); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Selects specific fields using selectable expressions + 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 results using one or more ordering specifications. + /// Orderings are applied in sequence (e.g. primary sort by first, then by second, etc.). + 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()), + ); + } + + /// Unnests arrays into separate documents + Pipeline unnest(Selectable expression, [String? indexField]) { + final stage = _UnnestStage(expression, indexField); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Unions results with another pipeline + Pipeline union(Pipeline pipeline) { + final stage = _UnionStage(pipeline); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + + /// Filters documents using a boolean expression + 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..c3ab5a96de41 --- /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 AggregateStage implements PipelineSerializable { + final List accumulators; + final List? groups; + + AggregateStage({ + 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..6f740f548c54 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart @@ -0,0 +1,2907 @@ +// 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 is Expression) return value; + return Constant(value!); +} + +/// 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); + } + + /// Converts this expression to a string with a format + Expression toStringWithFormat(Expression format) { + return _ToStringWithFormatExpression(this, format); + } + + /// Converts this expression to a string with a literal format + Expression toStringWithFormatLiteral(String format) { + return _ToStringWithFormatExpression(this, Constant(format)); + } + + // ============================================================================ + // 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)); + } + + /// Returns the keys from this map expression + // ignore: use_to_and_as_if_applicable + Expression mapKeys() { + return _MapKeysExpression(this); + } + + /// Returns the values from this map expression + // ignore: use_to_and_as_if_applicable + Expression mapValues() { + return _MapValuesExpression(this); + } + + // ============================================================================ + // 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); + } + + /// Returns the negation of this expression + // ignore: use_to_and_as_if_applicable + Expression negate() { + return _NegateExpression(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 occurrences of a pattern in this string + Expression replace(Expression find, Expression replacement) { + return _ReplaceExpression(this, find, replacement); + } + + /// Replaces occurrences of a string literal + Expression replaceLiteral(String find, String replacement) { + return _ReplaceExpression(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(Expression secondArray) { + return _ArrayConcatExpression(this, 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)); + } + + /// 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); + } + + /// Extracts a slice from this array + Expression arraySlice(Expression start, Expression end) { + return _ArraySliceExpression(this, start, end); + } + + /// Extracts a slice using literal indices + Expression arraySliceLiteral(int start, int end) { + return _ArraySliceExpression(this, Constant(start), Constant(end)); + } + + // ============================================================================ + // 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)))); + } + + /// Creates a map expression from alternating key-value expressions + static Expression mapFromPairs(List keyValuePairs) { + return _MapFromPairsExpression(keyValuePairs); + } + + /// Returns the current timestamp + static Expression currentTimestamp() { + return _CurrentTimestampExpression(); + } + + /// Adds time to a timestamp expression + static Expression timestampAdd( + Expression timestamp, + String unit, + Expression amount, + ) { + return _TimestampAddExpression(timestamp, unit, amount); + } + + /// Adds time to a timestamp with a literal amount + static Expression timestampAddLiteral( + Expression timestamp, + String unit, + int amount, + ) { + return _TimestampAddExpression(timestamp, unit, Constant(amount)); + } + + /// Subtracts time from a timestamp expression + static Expression timestampSubtract( + Expression timestamp, + String unit, + Expression amount, + ) { + return _TimestampSubtractExpression(timestamp, unit, amount); + } + + /// Subtracts time from a timestamp with a literal amount + static Expression timestampSubtractLiteral( + Expression timestamp, + String unit, + int amount, + ) { + return _TimestampSubtractExpression(timestamp, unit, Constant(amount)); + } + + /// Calculates the difference between two timestamps + static Expression timestampDiff( + Expression timestamp1, + Expression timestamp2, + String unit, + ) { + return _TimestampDiffExpression(timestamp1, timestamp2, unit); + } + + /// Truncates a timestamp to a specific unit + static Expression timestampTruncate( + Expression timestamp, + String unit, + ) { + return _TimestampTruncateExpression(timestamp, unit); + } + + /// Calculates the distance between two GeoPoint expressions + static Expression distance( + Expression geoPoint1, + Expression geoPoint2, + ) { + return _DistanceExpression(geoPoint1, geoPoint2); + } + + /// 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 BooleanExpression ifErrorStatic( + BooleanExpression tryExpr, + BooleanExpression catchExpr, + ) { + return _IfErrorExpression(tryExpr, catchExpr) as BooleanExpression; + } + + /// 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); + } + + /// 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)); + } + + /// Negates an expression + static Expression negateStatic(Expression numericExpr) { + return _NegateExpression(numericExpr); + } + + /// Negates a field + static Expression negateField(String numericField) { + return _NegateExpression(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 in string + static Expression replaceStatic( + Expression stringExpr, + Expression find, + Expression replacement, + ) { + return _ReplaceExpression(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)); + } + + /// Slices array + static Expression arraySliceStatic( + Expression array, + Expression start, + Expression end, + ) { + return _ArraySliceExpression(array, start, end); + } + + /// 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)); + } + + /// 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() { + return { + 'name': name, + 'args': { + 'value': value, + }, + }; + } +} + +/// 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 replace function expression +class _ReplaceExpression extends FunctionExpression { + final Expression expression; + final Expression find; + final Expression replacement; + + _ReplaceExpression(this.expression, this.find, this.replacement); + + @override + String get name => 'replace'; + + @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 {} + +/// Represents a filter expression for pipeline where clauses +class PipelineFilter extends BooleanExpression { + final Object field; + final Object? isEqualTo; + final Object? isNotEqualTo; + final Object? isLessThan; + final Object? isLessThanOrEqualTo; + final Object? isGreaterThan; + final Object? isGreaterThanOrEqualTo; + final Object? arrayContains; + final List? arrayContainsAny; + final List? whereIn; + final List? whereNotIn; + final bool? isNull; + final bool? isNotNull; + final BooleanExpression? _andExpression; + final BooleanExpression? _orExpression; + + PipelineFilter( + this.field, { + this.isEqualTo, + this.isNotEqualTo, + this.isLessThan, + this.isLessThanOrEqualTo, + this.isGreaterThan, + this.isGreaterThanOrEqualTo, + this.arrayContains, + this.arrayContainsAny, + this.whereIn, + this.whereNotIn, + this.isNull, + this.isNotNull, + }) : _andExpression = null, + _orExpression = null; + + PipelineFilter._internal({ + required BooleanExpression? andExpression, + required BooleanExpression? orExpression, + }) : field = '', + isEqualTo = null, + isNotEqualTo = null, + isLessThan = null, + isLessThanOrEqualTo = null, + isGreaterThan = null, + isGreaterThanOrEqualTo = null, + arrayContains = null, + arrayContainsAny = null, + whereIn = null, + whereNotIn = null, + isNull = null, + isNotNull = null, + _andExpression = andExpression, + _orExpression = orExpression; + + /// Creates an OR filter combining multiple boolean expressions + static PipelineFilter 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 PipelineFilter._internal( + andExpression: null, + orExpression: _combineExpressions(expressions, 'or'), + ); + } + + /// Creates an AND filter combining multiple boolean expressions + static PipelineFilter 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 PipelineFilter._internal( + andExpression: _combineExpressions(expressions, 'and'), + orExpression: null, + ); + } + + static BooleanExpression _combineExpressions( + List expressions, + String operator, + ) { + if (expressions.length == 1) return expressions.first; + + // Create a nested structure for multiple expressions + BooleanExpression result = expressions.first; + for (int i = 1; i < expressions.length; i++) { + if (operator == 'and') { + result = PipelineFilter.and(result, expressions[i]); + } else { + result = PipelineFilter.or(result, expressions[i]); + } + } + return result; + } + + @override + String get name => 'filter'; + + @override + Map toMap() { + final map = super.toMap(); + + if (_andExpression != null) { + map['args'] = { + 'operator': 'and', + 'expressions': [_andExpression.toMap()], + }; + return map; + } + + if (_orExpression != null) { + map['args'] = { + 'operator': 'or', + 'expressions': [_orExpression.toMap()], + }; + return map; + } + + final args = {}; + if (field is String) { + args['field'] = field; + } else if (field is Field) { + args['field'] = (field as Field).fieldName; + } + + if (isEqualTo != null) args['isEqualTo'] = isEqualTo; + if (isNotEqualTo != null) args['isNotEqualTo'] = isNotEqualTo; + if (isLessThan != null) args['isLessThan'] = isLessThan; + if (isLessThanOrEqualTo != null) { + args['isLessThanOrEqualTo'] = isLessThanOrEqualTo; + } + if (isGreaterThan != null) args['isGreaterThan'] = isGreaterThan; + if (isGreaterThanOrEqualTo != null) { + args['isGreaterThanOrEqualTo'] = isGreaterThanOrEqualTo; + } + if (arrayContains != null) args['arrayContains'] = arrayContains; + if (arrayContainsAny != null) { + args['arrayContainsAny'] = arrayContainsAny; + } + if (whereIn != null) args['whereIn'] = whereIn; + if (whereNotIn != null) args['whereNotIn'] = whereNotIn; + if (isNull != null) args['isNull'] = isNull; + if (isNotNull != null) args['isNotNull'] = isNotNull; + + map['args'] = args; + return map; + } +} + +// ============================================================================ +// 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 +class _NegateExpression extends FunctionExpression { + final Expression expression; + + _NegateExpression(this.expression); + + @override + String get name => 'negate'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// 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 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(), + }, + }; + } +} + +/// Represents an array slice function expression +class _ArraySliceExpression extends FunctionExpression { + final Expression array; + final Expression start; + final Expression end; + + _ArraySliceExpression(this.array, this.start, this.end); + + @override + String get name => 'array_slice'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'array': array.toMap(), + 'start': start.toMap(), + 'end': end.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 FunctionExpression { + 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(), + }, + }; + } +} + +/// 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(), + }, + }; + } +} + +/// Represents a toStringWithFormat function expression +class _ToStringWithFormatExpression extends FunctionExpression { + final Expression expression; + final Expression format; + + _ToStringWithFormatExpression(this.expression, this.format); + + @override + String get name => 'to_string_with_format'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + 'format': format.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(), + }, + }; + } +} + +/// Represents a mapKeys function expression +class _MapKeysExpression extends FunctionExpression { + final Expression expression; + + _MapKeysExpression(this.expression); + + @override + String get name => 'map_keys'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.toMap(), + }, + }; + } +} + +/// Represents a mapValues function expression +class _MapValuesExpression extends FunctionExpression { + final Expression expression; + + _MapValuesExpression(this.expression); + + @override + String get name => 'map_values'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'expression': expression.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 timestampAdd function expression +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 timestampSubtract function expression +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 timestampDiff function expression +class _TimestampDiffExpression extends FunctionExpression { + final Expression timestamp1; + final Expression timestamp2; + final String unit; + + _TimestampDiffExpression(this.timestamp1, this.timestamp2, this.unit); + + @override + String get name => 'timestamp_diff'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'timestamp1': timestamp1.toMap(), + 'timestamp2': timestamp2.toMap(), + 'unit': unit, + }, + }; + } +} + +/// Represents a timestampTruncate function expression +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, + }, + }; + } +} + +/// Represents a distance function expression +class _DistanceExpression extends FunctionExpression { + final Expression geoPoint1; + final Expression geoPoint2; + + _DistanceExpression(this.geoPoint1, this.geoPoint2); + + @override + String get name => 'distance'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'geo_point1': geoPoint1.toMap(), + 'geo_point2': geoPoint2.toMap(), + }, + }; + } +} + +// ============================================================================ +// 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 mapFromPairs expression +class _MapFromPairsExpression extends FunctionExpression { + final List keyValuePairs; + + _MapFromPairsExpression(this.keyValuePairs); + + @override + String get name => 'map_from_pairs'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'pairs': keyValuePairs.map((expr) => expr.toMap()).toList(), + }, + }; + } +} + +/// 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_source.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_source.dart new file mode 100644 index 000000000000..c981d53cae0d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_source.dart @@ -0,0 +1,66 @@ +// 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..ab06a32dc51e --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart @@ -0,0 +1,415 @@ +// 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 AggregateStage aggregateStage; + final AggregateOptions? options; + + _AggregateStageWithOptions(this.aggregateStage, this.options); + + @override + String get name => 'aggregate'; + + @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/windows/messages.g.cpp b/packages/cloud_firestore/cloud_firestore/windows/messages.g.cpp index bb9f58d5775d..10175dffd191 100644 --- a/packages/cloud_firestore/cloud_firestore/windows/messages.g.cpp +++ b/packages/cloud_firestore/cloud_firestore/windows/messages.g.cpp @@ -397,6 +397,144 @@ PigeonQuerySnapshot PigeonQuerySnapshot::FromEncodableList( return decoded; } +// PigeonPipelineResult + +PigeonPipelineResult::PigeonPipelineResult() {} + +PigeonPipelineResult::PigeonPipelineResult(const std::string* document_path, + const int64_t* create_time, + const int64_t* update_time, + const EncodableMap* data) + : document_path_(document_path ? std::optional(*document_path) + : std::nullopt), + create_time_(create_time ? std::optional(*create_time) + : std::nullopt), + update_time_(update_time ? std::optional(*update_time) + : std::nullopt), + data_(data ? std::optional(*data) : std::nullopt) {} + +const std::string* PigeonPipelineResult::document_path() const { + return document_path_ ? &(*document_path_) : nullptr; +} + +void PigeonPipelineResult::set_document_path( + const std::string_view* value_arg) { + document_path_ = + value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void PigeonPipelineResult::set_document_path(std::string_view value_arg) { + document_path_ = value_arg; +} + +const int64_t* PigeonPipelineResult::create_time() const { + return create_time_ ? &(*create_time_) : nullptr; +} + +void PigeonPipelineResult::set_create_time(const int64_t* value_arg) { + create_time_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void PigeonPipelineResult::set_create_time(int64_t value_arg) { + create_time_ = value_arg; +} + +const int64_t* PigeonPipelineResult::update_time() const { + return update_time_ ? &(*update_time_) : nullptr; +} + +void PigeonPipelineResult::set_update_time(const int64_t* value_arg) { + update_time_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void PigeonPipelineResult::set_update_time(int64_t value_arg) { + update_time_ = value_arg; +} + +const EncodableMap* PigeonPipelineResult::data() const { + return data_ ? &(*data_) : nullptr; +} + +void PigeonPipelineResult::set_data(const EncodableMap* value_arg) { + data_ = value_arg ? std::optional(*value_arg) : std::nullopt; +} + +void PigeonPipelineResult::set_data(const EncodableMap& value_arg) { + data_ = value_arg; +} + +EncodableList PigeonPipelineResult::ToEncodableList() const { + EncodableList list; + list.reserve(4); + list.push_back(document_path_ ? EncodableValue(*document_path_) + : EncodableValue()); + list.push_back(create_time_ ? EncodableValue(*create_time_) + : EncodableValue()); + list.push_back(update_time_ ? EncodableValue(*update_time_) + : EncodableValue()); + list.push_back(data_ ? EncodableValue(*data_) : EncodableValue()); + return list; +} + +PigeonPipelineResult PigeonPipelineResult::FromEncodableList( + const EncodableList& list) { + PigeonPipelineResult decoded; + auto& encodable_document_path = list[0]; + if (!encodable_document_path.IsNull()) { + decoded.set_document_path(std::get(encodable_document_path)); + } + auto& encodable_create_time = list[1]; + if (!encodable_create_time.IsNull()) { + decoded.set_create_time(encodable_create_time.LongValue()); + } + auto& encodable_update_time = list[2]; + if (!encodable_update_time.IsNull()) { + decoded.set_update_time(encodable_update_time.LongValue()); + } + auto& encodable_data = list[3]; + if (!encodable_data.IsNull()) { + decoded.set_data(std::get(encodable_data)); + } + return decoded; +} + +// PigeonPipelineSnapshot + +PigeonPipelineSnapshot::PigeonPipelineSnapshot(const EncodableList& results, + int64_t execution_time) + : results_(results), execution_time_(execution_time) {} + +const EncodableList& PigeonPipelineSnapshot::results() const { + return results_; +} + +void PigeonPipelineSnapshot::set_results(const EncodableList& value_arg) { + results_ = value_arg; +} + +int64_t PigeonPipelineSnapshot::execution_time() const { + return execution_time_; +} + +void PigeonPipelineSnapshot::set_execution_time(int64_t value_arg) { + execution_time_ = value_arg; +} + +EncodableList PigeonPipelineSnapshot::ToEncodableList() const { + EncodableList list; + list.reserve(2); + list.push_back(EncodableValue(results_)); + list.push_back(EncodableValue(execution_time_)); + return list; +} + +PigeonPipelineSnapshot PigeonPipelineSnapshot::FromEncodableList( + const EncodableList& list) { + PigeonPipelineSnapshot decoded(std::get(list[0]), + list[1].LongValue()); + return decoded; +} + // PigeonGetOptions PigeonGetOptions::PigeonGetOptions( @@ -1034,15 +1172,21 @@ EncodableValue FirebaseFirestoreHostApiCodecSerializer::ReadValueOfType( return CustomEncodableValue(PigeonGetOptions::FromEncodableList( std::get(ReadValue(stream)))); case 137: - return CustomEncodableValue(PigeonQueryParameters::FromEncodableList( + return CustomEncodableValue(PigeonPipelineResult::FromEncodableList( std::get(ReadValue(stream)))); case 138: - return CustomEncodableValue(PigeonQuerySnapshot::FromEncodableList( + return CustomEncodableValue(PigeonPipelineSnapshot::FromEncodableList( std::get(ReadValue(stream)))); case 139: - return CustomEncodableValue(PigeonSnapshotMetadata::FromEncodableList( + return CustomEncodableValue(PigeonQueryParameters::FromEncodableList( std::get(ReadValue(stream)))); case 140: + return CustomEncodableValue(PigeonQuerySnapshot::FromEncodableList( + std::get(ReadValue(stream)))); + case 141: + return CustomEncodableValue(PigeonSnapshotMetadata::FromEncodableList( + std::get(ReadValue(stream)))); + case 142: return CustomEncodableValue(PigeonTransactionCommand::FromEncodableList( std::get(ReadValue(stream)))); default: @@ -1127,8 +1271,24 @@ void FirebaseFirestoreHostApiCodecSerializer::WriteValue( stream); return; } - if (custom_value->type() == typeid(PigeonQueryParameters)) { + if (custom_value->type() == typeid(PigeonPipelineResult)) { stream->WriteByte(137); + WriteValue( + EncodableValue(std::any_cast(*custom_value) + .ToEncodableList()), + stream); + return; + } + if (custom_value->type() == typeid(PigeonPipelineSnapshot)) { + stream->WriteByte(138); + WriteValue( + EncodableValue(std::any_cast(*custom_value) + .ToEncodableList()), + stream); + return; + } + if (custom_value->type() == typeid(PigeonQueryParameters)) { + stream->WriteByte(139); WriteValue( EncodableValue(std::any_cast(*custom_value) .ToEncodableList()), @@ -1136,7 +1296,7 @@ void FirebaseFirestoreHostApiCodecSerializer::WriteValue( return; } if (custom_value->type() == typeid(PigeonQuerySnapshot)) { - stream->WriteByte(138); + stream->WriteByte(140); WriteValue( EncodableValue(std::any_cast(*custom_value) .ToEncodableList()), @@ -1144,7 +1304,7 @@ void FirebaseFirestoreHostApiCodecSerializer::WriteValue( return; } if (custom_value->type() == typeid(PigeonSnapshotMetadata)) { - stream->WriteByte(139); + stream->WriteByte(141); WriteValue( EncodableValue(std::any_cast(*custom_value) .ToEncodableList()), @@ -1152,7 +1312,7 @@ void FirebaseFirestoreHostApiCodecSerializer::WriteValue( return; } if (custom_value->type() == typeid(PigeonTransactionCommand)) { - stream->WriteByte(140); + stream->WriteByte(142); WriteValue( EncodableValue(std::any_cast(*custom_value) .ToEncodableList()), @@ -2290,8 +2450,8 @@ void FirebaseFirestoreHostApi::SetUp(flutter::BinaryMessenger* binary_messenger, reply(WrapError("request_arg unexpectedly null.")); return; } - const PersistenceCacheIndexManagerRequestEnum& request_arg = - (PersistenceCacheIndexManagerRequestEnum) + const PersistenceCacheIndexManagerRequest& request_arg = + (PersistenceCacheIndexManagerRequest) encodable_request_arg.LongValue(); api->PersistenceCacheIndexManagerRequest( app_arg, request_arg, @@ -2312,6 +2472,56 @@ void FirebaseFirestoreHostApi::SetUp(flutter::BinaryMessenger* binary_messenger, channel->SetMessageHandler(nullptr); } } + { + auto channel = std::make_unique>( + binary_messenger, + "dev.flutter.pigeon.cloud_firestore_platform_interface." + "FirebaseFirestoreHostApi.executePipeline", + &GetCodec()); + if (api != nullptr) { + channel->SetMessageHandler( + [api](const EncodableValue& message, + const flutter::MessageReply& reply) { + try { + const auto& args = std::get(message); + const auto& encodable_app_arg = args.at(0); + if (encodable_app_arg.IsNull()) { + reply(WrapError("app_arg unexpectedly null.")); + return; + } + const auto& app_arg = + std::any_cast( + std::get(encodable_app_arg)); + const auto& encodable_stages_arg = args.at(1); + if (encodable_stages_arg.IsNull()) { + reply(WrapError("stages_arg unexpectedly null.")); + return; + } + const auto& stages_arg = + std::get(encodable_stages_arg); + const auto& encodable_options_arg = args.at(2); + const auto* options_arg = + std::get_if(&encodable_options_arg); + api->ExecutePipeline( + app_arg, stages_arg, options_arg, + [reply](ErrorOr&& output) { + if (output.has_error()) { + reply(WrapError(output.error())); + return; + } + EncodableList wrapped; + wrapped.push_back( + CustomEncodableValue(std::move(output).TakeValue())); + reply(EncodableValue(std::move(wrapped))); + }); + } catch (const std::exception& exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel->SetMessageHandler(nullptr); + } + } } EncodableValue FirebaseFirestoreHostApi::WrapError( diff --git a/packages/cloud_firestore/cloud_firestore/windows/messages.g.h b/packages/cloud_firestore/cloud_firestore/windows/messages.g.h index 8aecb887facc..739c8b2486ae 100644 --- a/packages/cloud_firestore/cloud_firestore/windows/messages.g.h +++ b/packages/cloud_firestore/cloud_firestore/windows/messages.g.h @@ -136,7 +136,7 @@ enum class AggregateSource { // [PersistenceCacheIndexManagerRequest] represents the request types for the // persistence cache index manager. -enum class PersistenceCacheIndexManagerRequestEnum { +enum class PersistenceCacheIndexManagerRequest { enableIndexAutoCreation = 0, disableIndexAutoCreation = 1, deleteAllIndexes = 2 @@ -348,6 +348,70 @@ class PigeonQuerySnapshot { PigeonSnapshotMetadata metadata_; }; +// Generated class from Pigeon that represents data sent in messages. +class PigeonPipelineResult { + public: + // Constructs an object setting all non-nullable fields. + PigeonPipelineResult(); + + // Constructs an object setting all fields. + explicit PigeonPipelineResult(const std::string* document_path, + const int64_t* create_time, + const int64_t* update_time, + const flutter::EncodableMap* data); + + const std::string* document_path() const; + void set_document_path(const std::string_view* value_arg); + void set_document_path(std::string_view value_arg); + + const int64_t* create_time() const; + void set_create_time(const int64_t* value_arg); + void set_create_time(int64_t value_arg); + + const int64_t* update_time() const; + void set_update_time(const int64_t* value_arg); + void set_update_time(int64_t value_arg); + + // All fields in the result (from PipelineResult.data() on Android). + const flutter::EncodableMap* data() const; + void set_data(const flutter::EncodableMap* value_arg); + void set_data(const flutter::EncodableMap& value_arg); + + private: + static PigeonPipelineResult FromEncodableList( + const flutter::EncodableList& list); + flutter::EncodableList ToEncodableList() const; + friend class FirebaseFirestoreHostApi; + friend class FirebaseFirestoreHostApiCodecSerializer; + std::optional document_path_; + std::optional create_time_; + std::optional update_time_; + std::optional data_; +}; + +// Generated class from Pigeon that represents data sent in messages. +class PigeonPipelineSnapshot { + public: + // Constructs an object setting all fields. + explicit PigeonPipelineSnapshot(const flutter::EncodableList& results, + int64_t execution_time); + + const flutter::EncodableList& results() const; + void set_results(const flutter::EncodableList& value_arg); + + int64_t execution_time() const; + void set_execution_time(int64_t value_arg); + + private: + static PigeonPipelineSnapshot FromEncodableList( + const flutter::EncodableList& list); + flutter::EncodableList ToEncodableList() const; + friend class FirebaseFirestoreHostApi; + friend class FirebaseFirestoreHostApiCodecSerializer; + flutter::EncodableList results_; + int64_t execution_time_; +}; + // Generated class from Pigeon that represents data sent in messages. class PigeonGetOptions { public: @@ -724,8 +788,13 @@ class FirebaseFirestoreHostApi { std::function reply)> result) = 0; virtual void PersistenceCacheIndexManagerRequest( const FirestorePigeonFirebaseApp& app, - const PersistenceCacheIndexManagerRequestEnum& request, + const PersistenceCacheIndexManagerRequest& request, std::function reply)> result) = 0; + virtual void ExecutePipeline( + const FirestorePigeonFirebaseApp& app, + const flutter::EncodableList& stages, + const flutter::EncodableMap* options, + std::function reply)> result) = 0; // The codec used by FirebaseFirestoreHostApi. static const flutter::StandardMessageCodec& GetCodec(); 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..2566fd79cf10 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_platform_interface/lib/src/method_channel/method_channel_pipeline_snapshot.dart @@ -0,0 +1,81 @@ +// 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 = MethodChannelDocumentReference( + firestore, + documentPath ?? '', + pigeonApp, + ), + _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 85af6edc5260..224ded3d6bf6 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..d4760207a80d --- /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 + 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 19a5ddfdc4ee..a71ecdec270c 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/firebase_auth/firebase_auth/ios/firebase_auth/Sources/firebase_auth/firebase_auth_messages.g.m b/packages/firebase_auth/firebase_auth/ios/firebase_auth/Sources/firebase_auth/firebase_auth_messages.g.m index 8a32f1224ae6..365ff70d690c 100644 --- a/packages/firebase_auth/firebase_auth/ios/firebase_auth/Sources/firebase_auth/firebase_auth_messages.g.m +++ b/packages/firebase_auth/firebase_auth/ios/firebase_auth/Sources/firebase_auth/firebase_auth_messages.g.m @@ -1065,11 +1065,11 @@ void SetUpFirebaseAuthHostApiWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FirebaseAuthHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(confirmPasswordResetApp: - code:newPassword:completion:)], - @"FirebaseAuthHostApi api (%@) doesn't respond to " - @"@selector(confirmPasswordResetApp:code:newPassword:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(confirmPasswordResetApp:code:newPassword:completion:)], + @"FirebaseAuthHostApi api (%@) doesn't respond to " + @"@selector(confirmPasswordResetApp:code:newPassword:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1096,11 +1096,13 @@ void SetUpFirebaseAuthHostApiWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FirebaseAuthHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (createUserWithEmailAndPasswordApp:email:password:completion:)], - @"FirebaseAuthHostApi api (%@) doesn't respond to " - @"@selector(createUserWithEmailAndPasswordApp:email:password:completion:)", - api); + NSCAssert( + [api + respondsToSelector:@selector( + createUserWithEmailAndPasswordApp:email:password:completion:)], + @"FirebaseAuthHostApi api (%@) doesn't respond to " + @"@selector(createUserWithEmailAndPasswordApp:email:password:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1213,11 +1215,12 @@ void SetUpFirebaseAuthHostApiWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FirebaseAuthHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (signInWithEmailAndPasswordApp:email:password:completion:)], - @"FirebaseAuthHostApi api (%@) doesn't respond to " - @"@selector(signInWithEmailAndPasswordApp:email:password:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector( + signInWithEmailAndPasswordApp:email:password:completion:)], + @"FirebaseAuthHostApi api (%@) doesn't respond to " + @"@selector(signInWithEmailAndPasswordApp:email:password:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1245,11 +1248,11 @@ void SetUpFirebaseAuthHostApiWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FirebaseAuthHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(signInWithEmailLinkApp: - email:emailLink:completion:)], - @"FirebaseAuthHostApi api (%@) doesn't respond to " - @"@selector(signInWithEmailLinkApp:email:emailLink:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(signInWithEmailLinkApp:email:emailLink:completion:)], + @"FirebaseAuthHostApi api (%@) doesn't respond to " + @"@selector(signInWithEmailLinkApp:email:emailLink:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1277,11 +1280,11 @@ void SetUpFirebaseAuthHostApiWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FirebaseAuthHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(signInWithProviderApp: - signInProvider:completion:)], - @"FirebaseAuthHostApi api (%@) doesn't respond to " - @"@selector(signInWithProviderApp:signInProvider:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(signInWithProviderApp:signInProvider:completion:)], + @"FirebaseAuthHostApi api (%@) doesn't respond to " + @"@selector(signInWithProviderApp:signInProvider:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1361,8 +1364,8 @@ void SetUpFirebaseAuthHostApiWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FirebaseAuthHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (sendPasswordResetEmailApp:email:actionCodeSettings:completion:)], + NSCAssert([api respondsToSelector: + @selector(sendPasswordResetEmailApp:email:actionCodeSettings:completion:)], @"FirebaseAuthHostApi api (%@) doesn't respond to " @"@selector(sendPasswordResetEmailApp:email:actionCodeSettings:completion:)", api); @@ -1392,8 +1395,8 @@ void SetUpFirebaseAuthHostApiWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:FirebaseAuthHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (sendSignInLinkToEmailApp:email:actionCodeSettings:completion:)], + NSCAssert([api respondsToSelector: + @selector(sendSignInLinkToEmailApp:email:actionCodeSettings:completion:)], @"FirebaseAuthHostApi api (%@) doesn't respond to " @"@selector(sendSignInLinkToEmailApp:email:actionCodeSettings:completion:)", api); @@ -1535,7 +1538,7 @@ void SetUpFirebaseAuthHostApiWithSuffix(id binaryMesseng codec:FirebaseAuthHostApiGetCodec()]; if (api) { NSCAssert([api respondsToSelector:@selector(revokeTokenWithAuthorizationCodeApp: - authorizationCode:completion:)], + authorizationCode:completion:)], @"FirebaseAuthHostApi api (%@) doesn't respond to " @"@selector(revokeTokenWithAuthorizationCodeApp:authorizationCode:completion:)", api); @@ -1844,11 +1847,11 @@ void SetUpFirebaseAuthUserHostApiWithSuffix(id binaryMes binaryMessenger:binaryMessenger codec:FirebaseAuthUserHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(reauthenticateWithCredentialApp: - input:completion:)], - @"FirebaseAuthUserHostApi api (%@) doesn't respond to " - @"@selector(reauthenticateWithCredentialApp:input:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(reauthenticateWithCredentialApp:input:completion:)], + @"FirebaseAuthUserHostApi api (%@) doesn't respond to " + @"@selector(reauthenticateWithCredentialApp:input:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1874,11 +1877,12 @@ void SetUpFirebaseAuthUserHostApiWithSuffix(id binaryMes binaryMessenger:binaryMessenger codec:FirebaseAuthUserHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(reauthenticateWithProviderApp: - signInProvider:completion:)], - @"FirebaseAuthUserHostApi api (%@) doesn't respond to " - @"@selector(reauthenticateWithProviderApp:signInProvider:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector( + reauthenticateWithProviderApp:signInProvider:completion:)], + @"FirebaseAuthUserHostApi api (%@) doesn't respond to " + @"@selector(reauthenticateWithProviderApp:signInProvider:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -1929,11 +1933,12 @@ void SetUpFirebaseAuthUserHostApiWithSuffix(id binaryMes binaryMessenger:binaryMessenger codec:FirebaseAuthUserHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(sendEmailVerificationApp: - actionCodeSettings:completion:)], - @"FirebaseAuthUserHostApi api (%@) doesn't respond to " - @"@selector(sendEmailVerificationApp:actionCodeSettings:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector( + sendEmailVerificationApp:actionCodeSettings:completion:)], + @"FirebaseAuthUserHostApi api (%@) doesn't respond to " + @"@selector(sendEmailVerificationApp:actionCodeSettings:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -2099,8 +2104,8 @@ void SetUpFirebaseAuthUserHostApiWithSuffix(id binaryMes binaryMessenger:binaryMessenger codec:FirebaseAuthUserHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (verifyBeforeUpdateEmailApp:newEmail:actionCodeSettings:completion:)], + NSCAssert([api respondsToSelector:@selector(verifyBeforeUpdateEmailApp:newEmail: + actionCodeSettings:completion:)], @"FirebaseAuthUserHostApi api (%@) doesn't respond to " @"@selector(verifyBeforeUpdateEmailApp:newEmail:actionCodeSettings:completion:)", api); @@ -2204,11 +2209,11 @@ void SetUpMultiFactorUserHostApiWithSuffix(id binaryMess binaryMessenger:binaryMessenger codec:MultiFactorUserHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(enrollPhoneApp: - assertion:displayName:completion:)], - @"MultiFactorUserHostApi api (%@) doesn't respond to " - @"@selector(enrollPhoneApp:assertion:displayName:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(enrollPhoneApp:assertion:displayName:completion:)], + @"MultiFactorUserHostApi api (%@) doesn't respond to " + @"@selector(enrollPhoneApp:assertion:displayName:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -2234,11 +2239,11 @@ void SetUpMultiFactorUserHostApiWithSuffix(id binaryMess binaryMessenger:binaryMessenger codec:MultiFactorUserHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(enrollTotpApp: - assertionId:displayName:completion:)], - @"MultiFactorUserHostApi api (%@) doesn't respond to " - @"@selector(enrollTotpApp:assertionId:displayName:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(enrollTotpApp:assertionId:displayName:completion:)], + @"MultiFactorUserHostApi api (%@) doesn't respond to " + @"@selector(enrollTotpApp:assertionId:displayName:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; AuthPigeonFirebaseApp *arg_app = GetNullableObjectAtIndex(args, 0); @@ -2430,8 +2435,8 @@ void SetUpMultiFactoResolverHostApiWithSuffix(id binaryM binaryMessenger:binaryMessenger codec:MultiFactoResolverHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (resolveSignInResolverId:assertion:totpAssertionId:completion:)], + NSCAssert([api respondsToSelector: + @selector(resolveSignInResolverId:assertion:totpAssertionId:completion:)], @"MultiFactoResolverHostApi api (%@) doesn't respond to " @"@selector(resolveSignInResolverId:assertion:totpAssertionId:completion:)", api); @@ -2549,8 +2554,8 @@ void SetUpMultiFactorTotpHostApiWithSuffix(id binaryMess binaryMessenger:binaryMessenger codec:MultiFactorTotpHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(getAssertionForEnrollmentSecretKey: - oneTimePassword:completion:)], + NSCAssert([api respondsToSelector: + @selector(getAssertionForEnrollmentSecretKey:oneTimePassword:completion:)], @"MultiFactorTotpHostApi api (%@) doesn't respond to " @"@selector(getAssertionForEnrollmentSecretKey:oneTimePassword:completion:)", api); @@ -2579,8 +2584,8 @@ void SetUpMultiFactorTotpHostApiWithSuffix(id binaryMess binaryMessenger:binaryMessenger codec:MultiFactorTotpHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(getAssertionForSignInEnrollmentId: - oneTimePassword:completion:)], + NSCAssert([api respondsToSelector: + @selector(getAssertionForSignInEnrollmentId:oneTimePassword:completion:)], @"MultiFactorTotpHostApi api (%@) doesn't respond to " @"@selector(getAssertionForSignInEnrollmentId:oneTimePassword:completion:)", api); @@ -2627,11 +2632,12 @@ void SetUpMultiFactorTotpSecretHostApiWithSuffix(id bina binaryMessenger:binaryMessenger codec:MultiFactorTotpSecretHostApiGetCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(generateQrCodeUrlSecretKey: - accountName:issuer:completion:)], - @"MultiFactorTotpSecretHostApi api (%@) doesn't respond to " - @"@selector(generateQrCodeUrlSecretKey:accountName:issuer:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector( + generateQrCodeUrlSecretKey:accountName:issuer:completion:)], + @"MultiFactorTotpSecretHostApi api (%@) doesn't respond to " + @"@selector(generateQrCodeUrlSecretKey:accountName:issuer:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSString *arg_secretKey = GetNullableObjectAtIndex(args, 0); diff --git a/packages/firebase_core/firebase_core/ios/firebase_core/Sources/firebase_core/messages.g.m b/packages/firebase_core/firebase_core/ios/firebase_core/Sources/firebase_core/messages.g.m index ff9ac9933bc0..cf3e439b92ac 100644 --- a/packages/firebase_core/firebase_core/ios/firebase_core/Sources/firebase_core/messages.g.m +++ b/packages/firebase_core/firebase_core/ios/firebase_core/Sources/firebase_core/messages.g.m @@ -224,11 +224,11 @@ void SetUpFirebaseCoreHostApiWithSuffix(id binaryMesseng binaryMessenger:binaryMessenger codec:nullGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(initializeAppAppName: - initializeAppRequest:completion:)], - @"FirebaseCoreHostApi api (%@) doesn't respond to " - @"@selector(initializeAppAppName:initializeAppRequest:completion:)", - api); + NSCAssert( + [api respondsToSelector:@selector(initializeAppAppName:initializeAppRequest:completion:)], + @"FirebaseCoreHostApi api (%@) doesn't respond to " + @"@selector(initializeAppAppName:initializeAppRequest:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSString *arg_appName = GetNullableObjectAtIndex(args, 0); @@ -313,11 +313,13 @@ void SetUpFirebaseAppHostApiWithSuffix(id binaryMessenge binaryMessenger:binaryMessenger codec:nullGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (setAutomaticDataCollectionEnabledAppName:enabled:completion:)], - @"FirebaseAppHostApi api (%@) doesn't respond to " - @"@selector(setAutomaticDataCollectionEnabledAppName:enabled:completion:)", - api); + NSCAssert( + [api + respondsToSelector:@selector( + setAutomaticDataCollectionEnabledAppName:enabled:completion:)], + @"FirebaseAppHostApi api (%@) doesn't respond to " + @"@selector(setAutomaticDataCollectionEnabledAppName:enabled:completion:)", + api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSString *arg_appName = GetNullableObjectAtIndex(args, 0); @@ -342,8 +344,8 @@ void SetUpFirebaseAppHostApiWithSuffix(id binaryMessenge binaryMessenger:binaryMessenger codec:nullGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector - (setAutomaticResourceManagementEnabledAppName:enabled:completion:)], + NSCAssert([api respondsToSelector:@selector(setAutomaticResourceManagementEnabledAppName: + enabled:completion:)], @"FirebaseAppHostApi api (%@) doesn't respond to " @"@selector(setAutomaticResourceManagementEnabledAppName:enabled:completion:)", api); diff --git a/packages/firebase_core/firebase_core_web/lib/src/firebase_sdk_version.dart b/packages/firebase_core/firebase_core_web/lib/src/firebase_sdk_version.dart index 6a43bfd5f478..3e04c6928134 100644 --- a/packages/firebase_core/firebase_core_web/lib/src/firebase_sdk_version.dart +++ b/packages/firebase_core/firebase_core_web/lib/src/firebase_sdk_version.dart @@ -6,4 +6,4 @@ part of '../firebase_core_web.dart'; /// The currently supported Firebase JS SDK version. -const String supportedFirebaseJsSdkVersion = '12.7.0'; +const String supportedFirebaseJsSdkVersion = '12.9.0'; diff --git a/packages/firebase_data_connect/firebase_data_connect/generate_proto.sh b/packages/firebase_data_connect/firebase_data_connect/generate_proto.sh index b8d3232eef71..5b3fc45dcaed 100755 --- a/packages/firebase_data_connect/firebase_data_connect/generate_proto.sh +++ b/packages/firebase_data_connect/firebase_data_connect/generate_proto.sh @@ -1,4 +1,9 @@ #!/bin/bash + +# Uses dart protoc_plugin version 21.1.2. There are compilation issues with newer plugin versions. +# https://github.com/google/protobuf.dart/releases/tag/protoc_plugin-v21.1.2 +# Run `pub global activate protoc_plugin 21.1.2` + rm -rf lib/src/generated mkdir lib/src/generated -protoc --dart_out=grpc:lib/src/generated -I./protos/firebase -I./protos/google connector_service.proto google/protobuf/struct.proto graphql_error.proto --proto_path=./protos +protoc --dart_out=grpc:lib/src/generated -I./protos/firebase -I./protos/google connector_service.proto google/protobuf/struct.proto google/protobuf/duration.proto graphql_error.proto graphql_response_extensions.proto --proto_path=./protos diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_manager.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache.dart similarity index 80% rename from packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_manager.dart rename to packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache.dart index b2cc1506161f..3983df6c5842 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_manager.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache.dart @@ -50,9 +50,13 @@ class Cache { Stream> get impactedQueries => _impactedQueryController.stream; String _constructCacheIdentifier() { - final rawIdentifier = - '${_settings.storage}-${dataConnect.app.options.projectId}-${dataConnect.app.name}-${dataConnect.connectorConfig.serviceId}-${dataConnect.connectorConfig.connector}-${dataConnect.connectorConfig.location}-${dataConnect.auth?.currentUser?.uid ?? 'anon'}-${dataConnect.transport.transportOptions.host}'; - return convertToSha256(rawIdentifier); + final rawPrefix = + '${_settings.storage}-${dataConnect.app.options.projectId}-${dataConnect.app.name}-${dataConnect.connectorConfig.serviceId}-${dataConnect.connectorConfig.connector}-${dataConnect.connectorConfig.location}-${dataConnect.transport.transportOptions.host}'; + final prefixSha = convertToSha256(rawPrefix); + final rawSuffix = dataConnect.auth?.currentUser?.uid ?? 'anon'; + final suffixSha = convertToSha256(rawSuffix); + + return '$prefixSha-$suffixSha'; } void _initializeProvider() { @@ -92,23 +96,32 @@ class Cache { return; } - final dehydrationResult = await _resultTreeProcessor.dehydrate( - queryId, serverResponse.data, _cacheProvider!); + final Map paths = + serverResponse.extensions != null + ? ExtensionResponse.fromJson(serverResponse.extensions!) + .flattenPathMetadata() + : {}; + + final dehydrationResult = await _resultTreeProcessor.dehydrateResults( + queryId, serverResponse.data, _cacheProvider!, paths); EntityNode rootNode = dehydrationResult.dehydratedTree; Map dehydratedMap = rootNode.toJson(mode: EncodingMode.dehydrated); // if we have server ttl, that overrides maxAge from cacheSettings - Duration ttl = - serverResponse.ttl != null ? serverResponse.ttl! : _settings.maxAge; + Duration ttl = serverResponse.extensions != null && + serverResponse.extensions!['ttl'] != null + ? Duration(seconds: serverResponse.extensions!['ttl'] as int) + : (serverResponse.ttl ?? _settings.maxAge); + final resultTree = ResultTree( data: dehydratedMap, ttl: ttl, cachedAt: DateTime.now(), lastAccessed: DateTime.now()); - _cacheProvider!.saveResultTree(queryId, resultTree); + _cacheProvider!.setResultTree(queryId, resultTree); Set impactedQueryIds = dehydrationResult.impactedQueryIds; impactedQueryIds.remove(queryId); // remove query being cached @@ -116,7 +129,8 @@ class Cache { } /// Fetches a cached result. - Future?> get(String queryId, bool allowStale) async { + Future?> resultTree( + String queryId, bool allowStale) async { if (_cacheProvider == null) { return null; } @@ -137,23 +151,20 @@ class Cache { } resultTree.lastAccessed = DateTime.now(); - _cacheProvider!.saveResultTree(queryId, resultTree); + _cacheProvider!.setResultTree(queryId, resultTree); EntityNode rootNode = EntityNode.fromJson(resultTree.data, _cacheProvider!); + Map hydratedJson = - rootNode.toJson(); //default mode for toJson is hydrate + await _resultTreeProcessor.hydrateResults(rootNode, _cacheProvider!); + return hydratedJson; } return null; } - /// Invalidates the cache. - Future invalidate() async { - _cacheProvider?.clear(); - } - void dispose() { _impactedQueryController.close(); } diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_data_types.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_data_types.dart index ae80c237817a..0768da15232c 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_data_types.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_data_types.dart @@ -15,41 +15,144 @@ import 'dart:convert'; import 'package:firebase_data_connect/src/cache/cache_provider.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:firebase_data_connect/src/common/common_library.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart' show kIsWeb, listEquals; /// Type of storage to use for the cache enum CacheStorage { persistent, memory } -const String kGlobalIDKey = 'cacheId'; +const String kGlobalIDKey = 'guid'; + +@immutable +class DataConnectPath { + final List components; + + DataConnectPath([List? components]) + : components = components ?? []; + + DataConnectPath appending(DataConnectPathSegment segment) { + return DataConnectPath([...components, segment]); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DataConnectPath && + runtimeType == other.runtimeType && + listEquals(components, other.components); + + @override + int get hashCode => Object.hashAll(components); + + @override + String toString() => 'DataConnectPath($components)'; +} + +/// Additional information about object / field identified by a path +class PathMetadata { + final DataConnectPath path; + final String? entityId; + + PathMetadata({required this.path, this.entityId}); + + @override + String toString() { + return '$path : ${entityId ?? "null"}'; + } +} + +/// Represents the server response contained within the extension response +class PathMetadataResponse { + final List path; + final String? entityId; + final List? entityIds; + + PathMetadataResponse({required this.path, this.entityId, this.entityIds}); + + factory PathMetadataResponse.fromJson(Map json) { + return PathMetadataResponse( + path: (json['path'] as List).map(_parsePathSegment).toList(), + entityId: json['entityId'] as String?, + entityIds: (json['entityIds'] as List?)?.cast(), + ); + } +} + +DataConnectPathSegment _parsePathSegment(dynamic segment) { + if (segment is String) { + return DataConnectFieldPathSegment(segment); + } else if (segment is double || segment is int) { + int index = (segment is double) ? segment.toInt() : segment; + return DataConnectListIndexPathSegment(index); + } + throw ArgumentError('Invalid path segment type: ${segment.runtimeType}'); +} + +/// Represents the extension section within the server response +class ExtensionResponse { + final Duration? maxAge; + final List dataConnect; + + ExtensionResponse({this.maxAge, required this.dataConnect}); + + factory ExtensionResponse.fromJson(Map json) { + return ExtensionResponse( + maxAge: + json['ttl'] != null ? Duration(seconds: json['ttl'] as int) : null, + dataConnect: (json['dataConnect'] as List?) + ?.map((e) => + PathMetadataResponse.fromJson(e as Map)) + .toList() ?? + [], + ); + } + + Map flattenPathMetadata() { + final Map result = {}; + for (final pmr in dataConnect) { + if (pmr.entityId != null) { + final pm = PathMetadata( + path: DataConnectPath(pmr.path), entityId: pmr.entityId); + result[pm.path] = pm; + } + + if (pmr.entityIds != null) { + for (var i = 0; i < pmr.entityIds!.length; i++) { + final entityId = pmr.entityIds![i]; + final indexPath = DataConnectPath(pmr.path) + .appending(DataConnectListIndexPathSegment(i)); + final pm = PathMetadata(path: indexPath, entityId: entityId); + result[pm.path] = pm; + } + } + } + return result; + } +} /// Configuration for the cache class CacheSettings { /// The type of storage to use (e.g., "persistent", "memory") final CacheStorage storage; - /// The maximum size of the cache in bytes - final int maxSizeBytes; - /// Duration for which cache is used before revalidation with server final Duration maxAge; // Internal const constructor const CacheSettings._internal({ required this.storage, - required this.maxSizeBytes, required this.maxAge, }); // Factory constructor to handle the logic factory CacheSettings({ CacheStorage? storage, - int? maxSizeBytes, Duration maxAge = Duration.zero, }) { return CacheSettings._internal( storage: storage ?? (kIsWeb ? CacheStorage.memory : CacheStorage.persistent), - maxSizeBytes: maxSizeBytes ?? (kIsWeb ? 40000000 : 100000000), maxAge: maxAge, ); } @@ -203,7 +306,7 @@ class EntityNode { Map json, CacheProvider cacheProvider) { EntityDataObject? entity; if (json[kGlobalIDKey] != null) { - entity = cacheProvider.getEntityDataObject(json[kGlobalIDKey]); + entity = cacheProvider.getEntityData(json[kGlobalIDKey]); } Map? scalars; diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_provider.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_provider.dart index 9f65aa127820..484e1e390a45 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_provider.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/cache_provider.dart @@ -25,19 +25,16 @@ abstract class CacheProvider { Future initialize(); /// Stores a `ResultTree` object. - void saveResultTree(String queryId, ResultTree resultTree); + void setResultTree(String queryId, ResultTree resultTree); /// Retrieves a `ResultTree` object. ResultTree? getResultTree(String queryId); /// Stores an `EntityDataObject` object. - void saveEntityDataObject(EntityDataObject edo); + void updateEntityData(EntityDataObject edo); /// Retrieves an `EntityDataObject` object. - EntityDataObject getEntityDataObject(String guid); - - /// Manages the cache size and eviction policies. - void manageCacheSize(); + EntityDataObject getEntityData(String guid); /// Clears all data from the cache. void clear(); diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/in_memory_cache_provider.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/in_memory_cache_provider.dart index b625921bbe05..cd44d4e25ac5 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/in_memory_cache_provider.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/in_memory_cache_provider.dart @@ -16,6 +16,7 @@ import 'cache_data_types.dart'; import 'cache_provider.dart'; /// An in-memory implementation of the `CacheProvider`. +/// This is used for the web platform class InMemoryCacheProvider implements CacheProvider { final Map _resultTrees = {}; final Map _edos = {}; @@ -31,12 +32,12 @@ class InMemoryCacheProvider implements CacheProvider { @override Future initialize() async { - // nothing to be intialized. + // nothing to be intialized return true; } @override - void saveResultTree(String queryId, ResultTree resultTree) { + void setResultTree(String queryId, ResultTree resultTree) { _resultTrees[queryId] = resultTree; } @@ -46,20 +47,15 @@ class InMemoryCacheProvider implements CacheProvider { } @override - void saveEntityDataObject(EntityDataObject edo) { + void updateEntityData(EntityDataObject edo) { _edos[edo.guid] = edo; } @override - EntityDataObject getEntityDataObject(String guid) { + EntityDataObject getEntityData(String guid) { return _edos.putIfAbsent(guid, () => EntityDataObject(guid: guid)); } - @override - void manageCacheSize() { - // In-memory cache doesn't have a size limit in this implementation. - } - @override void clear() { _resultTrees.clear(); diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/result_tree_processor.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/result_tree_processor.dart index 34b557c31d71..ce4bf1bad24a 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/result_tree_processor.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/result_tree_processor.dart @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'dart:developer' as developer; + import '../common/common_library.dart'; import 'cache_data_types.dart'; import 'cache_provider.dart'; @@ -27,76 +29,109 @@ class DehydrationResult { class ResultTreeProcessor { /// Takes a server response, traverses the data, creates or updates `EntityDataObject`s, /// and builds a dehydrated `EntityNode` tree. - Future dehydrate(String queryId, - Map serverResponse, CacheProvider cacheProvider) async { + Future dehydrateResults( + String queryId, + Map serverResponse, + CacheProvider cacheProvider, + Map paths) async { final impactedQueryIds = {}; Map jsonData = serverResponse; if (serverResponse.containsKey('data')) { jsonData = serverResponse['data']; } - final rootNode = - _dehydrateNode(queryId, jsonData, cacheProvider, impactedQueryIds); + final rootNode = _dehydrateNode(queryId, jsonData, cacheProvider, + impactedQueryIds, DataConnectPath(), paths); return DehydrationResult(rootNode, impactedQueryIds); } - EntityNode _dehydrateNode(String queryId, dynamic data, - CacheProvider cacheProvider, Set impactedQueryIds) { + EntityNode _dehydrateNode( + String queryId, + dynamic data, + CacheProvider cacheProvider, + Set impactedQueryIds, + DataConnectPath path, + Map paths) { if (data is Map) { - // data contains a unique entity id. we can normalize - final guid = data[kGlobalIDKey] as String?; + // Look up entityId for current path + String? guid; + if (paths.containsKey(path)) { + guid = paths[path]?.entityId; + } - final serverValues = {}; + final scalarValues = {}; // scalars final nestedObjects = {}; final nestedObjectLists = >{}; for (final entry in data.entries) { final key = entry.key; final value = entry.value; - if (value is Map) { - EntityNode en = - _dehydrateNode(queryId, value, cacheProvider, impactedQueryIds); + //developer.log('detected Map for $key'); + EntityNode en = _dehydrateNode( + queryId, + value, + cacheProvider, + impactedQueryIds, + path.appending(DataConnectFieldPathSegment(key)), + paths); nestedObjects[key] = en; } else if (value is List) { + //developer.log('detected List for $key'); final nodeList = []; final scalarValueList = []; - for (final item in value) { + for (var i = 0; i < value.length; i++) { + final item = value[i]; if (item is Map) { nodeList.add(_dehydrateNode( - queryId, item, cacheProvider, impactedQueryIds)); + queryId, + item, + cacheProvider, + impactedQueryIds, + path + .appending(DataConnectFieldPathSegment(key)) + .appending(DataConnectListIndexPathSegment(i)), + paths)); } else { // assuming scalar - we don't handle array of arrays scalarValueList.add(item); } } - - // we either do object lists or scalar lists stored with scalars - // we don't handle mixed lists. - if (nodeList.isNotEmpty) { + // we either normalize object lists or scalar lists stored with scalars + // we don't normalize mixed lists. We store them as-is for reconstruction from cache. + if (nodeList.isNotEmpty && scalarValueList.isNotEmpty) { + // mixed type array - we directly store the json as-is + developer + .log('detected mixed type array for key $key. storing as-is'); + scalarValues[key] = value; + } else if (nodeList.isNotEmpty) { nestedObjectLists[key] = nodeList; + } else if (scalarValueList.isNotEmpty) { + scalarValues[key] = scalarValueList; } else { - serverValues[key] = scalarValueList; + // we have empty array. save key as scalar since we can't determine type + scalarValues[key] = value; } + // end list handling } else { - serverValues[key] = value; + //developer.log('detected Scalar for $key'); + scalarValues[key] = value; } } if (guid != null) { - final existingEdo = cacheProvider.getEntityDataObject(guid); - existingEdo.setServerValues(serverValues, queryId); - cacheProvider.saveEntityDataObject(existingEdo); + final existingEdo = cacheProvider.getEntityData(guid); + existingEdo.setServerValues(scalarValues, queryId); + cacheProvider.updateEntityData(existingEdo); impactedQueryIds.addAll(existingEdo.referencedFrom); - return EntityNode( entity: existingEdo, nestedObjects: nestedObjects, nestedObjectLists: nestedObjectLists); } else { return EntityNode( - scalarValues: serverValues, + scalarValues: scalarValues, nestedObjects: nestedObjects, nestedObjectLists: nestedObjectLists); } @@ -108,40 +143,8 @@ class ResultTreeProcessor { /// Takes a dehydrated `EntityNode` tree, fetches the corresponding `EntityDataObject`s /// from the `CacheProvider`, and reconstructs the original data structure. - Future> hydrate( + Future> hydrateResults( EntityNode dehydratedTree, CacheProvider cacheProvider) async { - return await _hydrateNode(dehydratedTree, cacheProvider) - as Map; - } - - Future _hydrateNode( - EntityNode node, CacheProvider cacheProvider) async { - final Map data = {}; - if (node.entity != null) { - final edo = cacheProvider.getEntityDataObject(node.entity!.guid); - data.addAll(edo.fields()); - } - - if (node.scalarValues != null) { - data.addAll(node.scalarValues!); - } - - if (node.nestedObjects != null) { - for (final entry in node.nestedObjects!.entries) { - data[entry.key] = await _hydrateNode(entry.value, cacheProvider); - } - } - - if (node.nestedObjectLists != null) { - for (final entry in node.nestedObjectLists!.entries) { - final list = []; - for (final item in entry.value) { - list.add(await _hydrateNode(item, cacheProvider)); - } - data[entry.key] = list; - } - } - - return data; + return dehydratedTree.toJson(); //default mode for toJson is hydrate } } diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/sqlite_cache_provider.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/sqlite_cache_provider.dart index 3de7c1b44c95..1493f32a05ac 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/sqlite_cache_provider.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/cache/sqlite_cache_provider.dart @@ -39,7 +39,19 @@ class SQLite3CacheProvider implements CacheProvider { final path = join(dbPath.path, '$_identifier.db'); _db = sqlite3.open(path); } - _createTables(); + + int curVersion = _getDatabaseVersion(); + if (curVersion == 0) { + _createTables(); + } else { + int major = curVersion ~/ 1000000; + if (major != 1) { + developer.log( + 'Unsupported schema major version $major detected. Expected 1'); + return false; + } + } + return true; } catch (e) { developer.log('Error initializing SQLiteProvider $e'); @@ -47,19 +59,37 @@ class SQLite3CacheProvider implements CacheProvider { } } + int _getDatabaseVersion() { + final resultSet = _db.select('PRAGMA user_version;'); + return resultSet.first.columnAt(0) as int; + } + + void _setDatabaseVersion(int version) { + _db.execute('PRAGMA user_version = $version;'); + } + void _createTables() { - _db.execute(''' - CREATE TABLE IF NOT EXISTS $resultTreeTable ( - query_id TEXT PRIMARY KEY, - result_tree TEXT - ); - '''); - _db.execute(''' - CREATE TABLE IF NOT EXISTS $entityDataTable ( - guid TEXT PRIMARY KEY, - entity_data_object TEXT - ); - '''); + _db.execute('BEGIN TRANSACTION'); + try { + _db.execute(''' + CREATE TABLE IF NOT EXISTS $resultTreeTable ( + query_id TEXT PRIMARY KEY NOT NULL, + last_accessed REAL NOT NULL, + data TEXT NOT NULL + ); + '''); + _db.execute(''' + CREATE TABLE IF NOT EXISTS $entityDataTable ( + entity_guid TEXT PRIMARY KEY NOT NULL, + data TEXT NOT NULL + ); + '''); + _setDatabaseVersion(1000000); // 1.0.0 + _db.execute('COMMIT'); + } catch (_) { + _db.execute('ROLLBACK'); + rethrow; + } } @override @@ -69,57 +99,84 @@ class SQLite3CacheProvider implements CacheProvider { @override void clear() { - _db.execute('DELETE FROM $resultTreeTable'); - _db.execute('DELETE FROM $entityDataTable'); + _db.execute('BEGIN TRANSACTION'); + try { + _db.execute('DELETE FROM $resultTreeTable'); + _db.execute('DELETE FROM $entityDataTable'); + _db.execute('COMMIT'); + } catch (_) { + _db.execute('ROLLBACK'); + rethrow; + } } @override - EntityDataObject getEntityDataObject(String guid) { + EntityDataObject getEntityData(String guid) { final resultSet = _db.select( - 'SELECT entity_data_object FROM $entityDataTable WHERE guid = ?', + 'SELECT data FROM $entityDataTable WHERE entity_guid = ?', [guid], ); if (resultSet.isEmpty) { - // not found lets create an empty one. + // not found lets create an empty one EntityDataObject edo = EntityDataObject(guid: guid); return edo; } - return EntityDataObject.fromRawJson( - resultSet.first['entity_data_object'] as String); + return EntityDataObject.fromRawJson(resultSet.first['data'] as String); } @override ResultTree? getResultTree(String queryId) { final resultSet = _db.select( - 'SELECT result_tree FROM $resultTreeTable WHERE query_id = ?', + 'SELECT data FROM $resultTreeTable WHERE query_id = ?', [queryId], ); if (resultSet.isEmpty) { return null; } - return ResultTree.fromRawJson(resultSet.first['result_tree'] as String); + _updateLastAccessedTime(queryId); + return ResultTree.fromRawJson(resultSet.first['data'] as String); } - @override - void manageCacheSize() { - // TODO: implement manageCacheSize + void _updateLastAccessedTime(String queryId) { + _db.execute( + 'UPDATE $resultTreeTable SET last_accessed = ? WHERE query_id = ?', + [DateTime.now().millisecondsSinceEpoch / 1000.0, queryId], + ); } @override - void saveEntityDataObject(EntityDataObject edo) { + void updateEntityData(EntityDataObject edo) { String rawJson = edo.toRawJson(); - _db.execute( - 'INSERT OR REPLACE INTO $entityDataTable (guid, entity_data_object) VALUES (?, ?)', - [edo.guid, rawJson], - ); + _db.execute('BEGIN TRANSACTION'); + try { + _db.execute( + 'INSERT OR REPLACE INTO $entityDataTable (entity_guid, data) VALUES (?, ?)', + [edo.guid, rawJson], + ); + _db.execute('COMMIT'); + } catch (_) { + _db.execute('ROLLBACK'); + rethrow; + } } @override - void saveResultTree(String queryId, ResultTree resultTree) { - _db.execute( - 'INSERT OR REPLACE INTO $resultTreeTable (query_id, result_tree) VALUES (?, ?)', - [queryId, resultTree.toRawJson()], - ); + void setResultTree(String queryId, ResultTree resultTree) { + _db.execute('BEGIN TRANSACTION'); + try { + _db.execute( + 'INSERT OR REPLACE INTO $resultTreeTable (query_id, last_accessed, data) VALUES (?, ?, ?)', + [ + queryId, + DateTime.now().millisecondsSinceEpoch / 1000.0, + resultTree.toRawJson() + ], + ); + _db.execute('COMMIT'); + } catch (_) { + _db.execute('ROLLBACK'); + rethrow; + } } } diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/common/common_library.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/common/common_library.dart index e24e7a7e3b89..9247287f5adf 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/common/common_library.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/common/common_library.dart @@ -61,11 +61,18 @@ class TransportOptions { bool? isSecure; } +/// Encapsulates the response from server class ServerResponse { + /// Data returned from server final Map data; + + /// duration for which the results are considered not stale Duration? ttl; - ServerResponse(this.data); + /// Additional data provided in extensions + final Map? extensions; + + ServerResponse(this.data, {this.extensions}); } /// Interface for transports connecting to the DataConnect backend. diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/common/dataconnect_error.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/common/dataconnect_error.dart index 3928a9706536..43b7fd964418 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/common/dataconnect_error.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/common/dataconnect_error.dart @@ -58,16 +58,43 @@ class DataConnectOperationFailureResponseErrorInfo { } /// Path where error occurred. +@immutable sealed class DataConnectPathSegment {} class DataConnectFieldPathSegment extends DataConnectPathSegment { final String field; DataConnectFieldPathSegment(this.field); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DataConnectFieldPathSegment && + runtimeType == other.runtimeType && + field == other.field; + + @override + int get hashCode => field.hashCode; + + @override + String toString() => field; } class DataConnectListIndexPathSegment extends DataConnectPathSegment { final int index; DataConnectListIndexPathSegment(this.index); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DataConnectListIndexPathSegment && + runtimeType == other.runtimeType && + index == other.index; + + @override + int get hashCode => index.hashCode; + + @override + String toString() => index.toString(); } typedef Serializer = String Function(Variables vars); diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/core/ref.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/core/ref.dart index e23a00134b70..5b359f6a15b3 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/core/ref.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/core/ref.dart @@ -251,7 +251,7 @@ class QueryRef extends OperationRef { final cacheManager = dataConnect.cacheManager!; bool allowStale = fetchPolicy == QueryFetchPolicy.cacheOnly; //if its cache only, we always allow stale - final cachedData = await cacheManager.get(_queryId, allowStale); + final cachedData = await cacheManager.resultTree(_queryId, allowStale); if (cachedData != null) { try { diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/firebase_data_connect.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/firebase_data_connect.dart index 43cc4fb63e1a..1684e88ed8b8 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/firebase_data_connect.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/firebase_data_connect.dart @@ -25,7 +25,7 @@ import './network/transport_library.dart' if (dart.library.html) './network/rest_library.dart'; import 'cache/cache_data_types.dart'; -import 'cache/cache_manager.dart'; +import 'cache/cache.dart'; /// DataConnect class class FirebaseDataConnect extends FirebasePluginPlatform { diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pb.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pb.dart index 54aed178ad40..1f38718bd4c9 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pb.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pb.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: connector_service.proto @@ -27,7 +14,8 @@ import 'dart:core' as $core; import 'package:protobuf/protobuf.dart' as $pb; import 'google/protobuf/struct.pb.dart' as $1; -import 'graphql_error.pb.dart' as $2; +import 'graphql_error.pb.dart' as $3; +import 'graphql_response_extensions.pb.dart' as $4; /// The ExecuteQuery request to Firebase Data Connect. class ExecuteQueryRequest extends $pb.GeneratedMessage { @@ -257,7 +245,8 @@ class ExecuteMutationRequest extends $pb.GeneratedMessage { class ExecuteQueryResponse extends $pb.GeneratedMessage { factory ExecuteQueryResponse({ $1.Struct? data, - $core.Iterable<$2.GraphqlError>? errors, + $core.Iterable<$3.GraphqlError>? errors, + $4.GraphqlResponseExtensions? extensions, }) { final $result = create(); if (data != null) { @@ -266,6 +255,9 @@ class ExecuteQueryResponse extends $pb.GeneratedMessage { if (errors != null) { $result.errors.addAll(errors); } + if (extensions != null) { + $result.extensions = extensions; + } return $result; } ExecuteQueryResponse._() : super(); @@ -283,9 +275,11 @@ class ExecuteQueryResponse extends $pb.GeneratedMessage { createEmptyInstance: create) ..aOM<$1.Struct>(1, _omitFieldNames ? '' : 'data', subBuilder: $1.Struct.create) - ..pc<$2.GraphqlError>( + ..pc<$3.GraphqlError>( 2, _omitFieldNames ? '' : 'errors', $pb.PbFieldType.PM, - subBuilder: $2.GraphqlError.create) + subBuilder: $3.GraphqlError.create) + ..aOM<$4.GraphqlResponseExtensions>(3, _omitFieldNames ? '' : 'extensions', + subBuilder: $4.GraphqlResponseExtensions.create) ..hasRequiredFields = false; @$core.Deprecated('Using this can add significant overhead to your binary. ' @@ -329,14 +323,30 @@ class ExecuteQueryResponse extends $pb.GeneratedMessage { /// Errors of this response. @$pb.TagNumber(2) - $core.List<$2.GraphqlError> get errors => $_getList(1); + $core.List<$3.GraphqlError> get errors => $_getList(1); + + /// Additional response information. + @$pb.TagNumber(3) + $4.GraphqlResponseExtensions get extensions => $_getN(2); + @$pb.TagNumber(3) + set extensions($4.GraphqlResponseExtensions v) { + setField(3, v); + } + + @$pb.TagNumber(3) + $core.bool hasExtensions() => $_has(2); + @$pb.TagNumber(3) + void clearExtensions() => clearField(3); + @$pb.TagNumber(3) + $4.GraphqlResponseExtensions ensureExtensions() => $_ensure(2); } /// The ExecuteMutation response from Firebase Data Connect. class ExecuteMutationResponse extends $pb.GeneratedMessage { factory ExecuteMutationResponse({ $1.Struct? data, - $core.Iterable<$2.GraphqlError>? errors, + $core.Iterable<$3.GraphqlError>? errors, + $4.GraphqlResponseExtensions? extensions, }) { final $result = create(); if (data != null) { @@ -345,6 +355,9 @@ class ExecuteMutationResponse extends $pb.GeneratedMessage { if (errors != null) { $result.errors.addAll(errors); } + if (extensions != null) { + $result.extensions = extensions; + } return $result; } ExecuteMutationResponse._() : super(); @@ -362,9 +375,11 @@ class ExecuteMutationResponse extends $pb.GeneratedMessage { createEmptyInstance: create) ..aOM<$1.Struct>(1, _omitFieldNames ? '' : 'data', subBuilder: $1.Struct.create) - ..pc<$2.GraphqlError>( + ..pc<$3.GraphqlError>( 2, _omitFieldNames ? '' : 'errors', $pb.PbFieldType.PM, - subBuilder: $2.GraphqlError.create) + subBuilder: $3.GraphqlError.create) + ..aOM<$4.GraphqlResponseExtensions>(3, _omitFieldNames ? '' : 'extensions', + subBuilder: $4.GraphqlResponseExtensions.create) ..hasRequiredFields = false; @$core.Deprecated('Using this can add significant overhead to your binary. ' @@ -409,7 +424,22 @@ class ExecuteMutationResponse extends $pb.GeneratedMessage { /// Errors of this response. @$pb.TagNumber(2) - $core.List<$2.GraphqlError> get errors => $_getList(1); + $core.List<$3.GraphqlError> get errors => $_getList(1); + + /// Additional response information. + @$pb.TagNumber(3) + $4.GraphqlResponseExtensions get extensions => $_getN(2); + @$pb.TagNumber(3) + set extensions($4.GraphqlResponseExtensions v) { + setField(3, v); + } + + @$pb.TagNumber(3) + $core.bool hasExtensions() => $_has(2); + @$pb.TagNumber(3) + void clearExtensions() => clearField(3); + @$pb.TagNumber(3) + $4.GraphqlResponseExtensions ensureExtensions() => $_ensure(2); } const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbenum.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbenum.dart index d53ea6876082..aabd1a01d514 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbenum.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbenum.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: connector_service.proto diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbgrpc.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbgrpc.dart index b6704e57cca1..8b1732a117ae 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbgrpc.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbgrpc.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: connector_service.proto diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbjson.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbjson.dart index 834e9aad147e..03a497345922 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbjson.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/connector_service.pbjson.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: connector_service.proto @@ -108,6 +95,14 @@ const ExecuteQueryResponse$json = { '6': '.google.firebase.dataconnect.v1.GraphqlError', '10': 'errors' }, + { + '1': 'extensions', + '3': 3, + '4': 1, + '5': 11, + '6': '.google.firebase.dataconnect.v1.GraphqlResponseExtensions', + '10': 'extensions' + }, ], }; @@ -115,7 +110,9 @@ const ExecuteQueryResponse$json = { final $typed_data.Uint8List executeQueryResponseDescriptor = $convert.base64Decode( 'ChRFeGVjdXRlUXVlcnlSZXNwb25zZRIrCgRkYXRhGAEgASgLMhcuZ29vZ2xlLnByb3RvYnVmLl' 'N0cnVjdFIEZGF0YRJECgZlcnJvcnMYAiADKAsyLC5nb29nbGUuZmlyZWJhc2UuZGF0YWNvbm5l' - 'Y3QudjEuR3JhcGhxbEVycm9yUgZlcnJvcnM='); + 'Y3QudjEuR3JhcGhxbEVycm9yUgZlcnJvcnMSWQoKZXh0ZW5zaW9ucxgDIAEoCzI5Lmdvb2dsZS' + '5maXJlYmFzZS5kYXRhY29ubmVjdC52MS5HcmFwaHFsUmVzcG9uc2VFeHRlbnNpb25zUgpleHRl' + 'bnNpb25z'); @$core.Deprecated('Use executeMutationResponseDescriptor instead') const ExecuteMutationResponse$json = { @@ -137,6 +134,14 @@ const ExecuteMutationResponse$json = { '6': '.google.firebase.dataconnect.v1.GraphqlError', '10': 'errors' }, + { + '1': 'extensions', + '3': 3, + '4': 1, + '5': 11, + '6': '.google.firebase.dataconnect.v1.GraphqlResponseExtensions', + '10': 'extensions' + }, ], }; @@ -144,4 +149,6 @@ const ExecuteMutationResponse$json = { final $typed_data.Uint8List executeMutationResponseDescriptor = $convert.base64Decode( 'ChdFeGVjdXRlTXV0YXRpb25SZXNwb25zZRIrCgRkYXRhGAEgASgLMhcuZ29vZ2xlLnByb3RvYn' 'VmLlN0cnVjdFIEZGF0YRJECgZlcnJvcnMYAiADKAsyLC5nb29nbGUuZmlyZWJhc2UuZGF0YWNv' - 'bm5lY3QudjEuR3JhcGhxbEVycm9yUgZlcnJvcnM='); + 'bm5lY3QudjEuR3JhcGhxbEVycm9yUgZlcnJvcnMSWQoKZXh0ZW5zaW9ucxgDIAEoCzI5Lmdvb2' + 'dsZS5maXJlYmFzZS5kYXRhY29ubmVjdC52MS5HcmFwaHFsUmVzcG9uc2VFeHRlbnNpb25zUgpl' + 'eHRlbnNpb25z'); diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/duration.pb.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/duration.pb.dart new file mode 100644 index 000000000000..4bcbcd32a4c2 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/duration.pb.dart @@ -0,0 +1,166 @@ +// +// Generated code. Do not modify. +// source: google/protobuf/duration.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + +import 'dart:core' as $core; + +import 'package:fixnum/fixnum.dart' as $fixnum; +import 'package:protobuf/protobuf.dart' as $pb; +import 'package:protobuf/src/protobuf/mixins/well_known.dart' as $mixin; + +/// A Duration represents a signed, fixed-length span of time represented +/// as a count of seconds and fractions of seconds at nanosecond +/// resolution. It is independent of any calendar and concepts like "day" +/// or "month". It is related to Timestamp in that the difference between +/// two Timestamp values is a Duration and it can be added or subtracted +/// from a Timestamp. Range is approximately +-10,000 years. +/// +/// # Examples +/// +/// Example 1: Compute Duration from two Timestamps in pseudo code. +/// +/// Timestamp start = ...; +/// Timestamp end = ...; +/// Duration duration = ...; +/// +/// duration.seconds = end.seconds - start.seconds; +/// duration.nanos = end.nanos - start.nanos; +/// +/// if (duration.seconds < 0 && duration.nanos > 0) { +/// duration.seconds += 1; +/// duration.nanos -= 1000000000; +/// } else if (duration.seconds > 0 && duration.nanos < 0) { +/// duration.seconds -= 1; +/// duration.nanos += 1000000000; +/// } +/// +/// Example 2: Compute Timestamp from Timestamp + Duration in pseudo code. +/// +/// Timestamp start = ...; +/// Duration duration = ...; +/// Timestamp end = ...; +/// +/// end.seconds = start.seconds + duration.seconds; +/// end.nanos = start.nanos + duration.nanos; +/// +/// if (end.nanos < 0) { +/// end.seconds -= 1; +/// end.nanos += 1000000000; +/// } else if (end.nanos >= 1000000000) { +/// end.seconds += 1; +/// end.nanos -= 1000000000; +/// } +/// +/// Example 3: Compute Duration from datetime.timedelta in Python. +/// +/// td = datetime.timedelta(days=3, minutes=10) +/// duration = Duration() +/// duration.FromTimedelta(td) +/// +/// # JSON Mapping +/// +/// In JSON format, the Duration type is encoded as a string rather than an +/// object, where the string ends in the suffix "s" (indicating seconds) and +/// is preceded by the number of seconds, with nanoseconds expressed as +/// fractional seconds. For example, 3 seconds with 0 nanoseconds should be +/// encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should +/// be expressed in JSON format as "3.000000001s", and 3 seconds and 1 +/// microsecond should be expressed in JSON format as "3.000001s". +class Duration extends $pb.GeneratedMessage with $mixin.DurationMixin { + factory Duration({ + $fixnum.Int64? seconds, + $core.int? nanos, + }) { + final $result = create(); + if (seconds != null) { + $result.seconds = seconds; + } + if (nanos != null) { + $result.nanos = nanos; + } + return $result; + } + Duration._() : super(); + factory Duration.fromBuffer($core.List<$core.int> i, + [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(i, r); + factory Duration.fromJson($core.String i, + [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'Duration', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'google.protobuf'), + createEmptyInstance: create, + toProto3Json: $mixin.DurationMixin.toProto3JsonHelper, + fromProto3Json: $mixin.DurationMixin.fromProto3JsonHelper) + ..aInt64(1, _omitFieldNames ? '' : 'seconds') + ..a<$core.int>(2, _omitFieldNames ? '' : 'nanos', $pb.PbFieldType.O3) + ..hasRequiredFields = false; + + @$core.Deprecated('Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + Duration clone() => Duration()..mergeFromMessage(this); + @$core.Deprecated('Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + Duration copyWith(void Function(Duration) updates) => + super.copyWith((message) => updates(message as Duration)) as Duration; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static Duration create() => Duration._(); + Duration createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static Duration getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static Duration? _defaultInstance; + + /// Signed seconds of the span of time. Must be from -315,576,000,000 + /// to +315,576,000,000 inclusive. Note: these bounds are computed from: + /// 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years + @$pb.TagNumber(1) + $fixnum.Int64 get seconds => $_getI64(0); + @$pb.TagNumber(1) + set seconds($fixnum.Int64 v) { + $_setInt64(0, v); + } + + @$pb.TagNumber(1) + $core.bool hasSeconds() => $_has(0); + @$pb.TagNumber(1) + void clearSeconds() => clearField(1); + + /// Signed fractions of a second at nanosecond resolution of the span + /// of time. Durations less than one second are represented with a 0 + /// `seconds` field and a positive or negative `nanos` field. For durations + /// of one second or more, a non-zero value for the `nanos` field must be + /// of the same sign as the `seconds` field. Must be from -999,999,999 + /// to +999,999,999 inclusive. + @$pb.TagNumber(2) + $core.int get nanos => $_getIZ(1); + @$pb.TagNumber(2) + set nanos($core.int v) { + $_setSignedInt32(1, v); + } + + @$pb.TagNumber(2) + $core.bool hasNanos() => $_has(1); + @$pb.TagNumber(2) + void clearNanos() => clearField(2); +} + +const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); +const _omitMessageNames = + $core.bool.fromEnvironment('protobuf.omit_message_names'); diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/duration.pbenum.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/duration.pbenum.dart new file mode 100644 index 000000000000..1a2c58d81056 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/duration.pbenum.dart @@ -0,0 +1,10 @@ +// +// Generated code. Do not modify. +// source: google/protobuf/duration.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/duration.pbjson.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/duration.pbjson.dart new file mode 100644 index 000000000000..5847acb2d458 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/duration.pbjson.dart @@ -0,0 +1,28 @@ +// +// Generated code. Do not modify. +// source: google/protobuf/duration.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + +import 'dart:convert' as $convert; +import 'dart:core' as $core; +import 'dart:typed_data' as $typed_data; + +@$core.Deprecated('Use durationDescriptor instead') +const Duration$json = { + '1': 'Duration', + '2': [ + {'1': 'seconds', '3': 1, '4': 1, '5': 3, '10': 'seconds'}, + {'1': 'nanos', '3': 2, '4': 1, '5': 5, '10': 'nanos'}, + ], +}; + +/// Descriptor for `Duration`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List durationDescriptor = $convert.base64Decode( + 'CghEdXJhdGlvbhIYCgdzZWNvbmRzGAEgASgDUgdzZWNvbmRzEhQKBW5hbm9zGAIgASgFUgVuYW' + '5vcw=='); diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pb.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pb.dart index 7b9093d681ff..42d55e426602 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pb.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pb.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: google/protobuf/struct.proto diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pbenum.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pbenum.dart index b5acd2512df2..7f9bf0cbf322 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pbenum.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pbenum.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: google/protobuf/struct.proto diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pbjson.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pbjson.dart index 3f53dbf0a988..c0693f570058 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pbjson.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/google/protobuf/struct.pbjson.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: google/protobuf/struct.proto diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pb.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pb.dart index a50398e29488..2def4cc62994 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pb.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pb.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: graphql_error.proto diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pbenum.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pbenum.dart index 9f28e16d3c23..53454c94a217 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pbenum.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pbenum.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: graphql_error.proto diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pbjson.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pbjson.dart index ae48a28388dc..9a90ffc79685 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pbjson.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_error.pbjson.dart @@ -1,16 +1,3 @@ -// Copyright 2024 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. // // Generated code. Do not modify. // source: graphql_error.proto diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_response_extensions.pb.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_response_extensions.pb.dart new file mode 100644 index 000000000000..c86ba89dbd75 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_response_extensions.pb.dart @@ -0,0 +1,222 @@ +// +// Generated code. Do not modify. +// source: graphql_response_extensions.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; + +import 'google/protobuf/duration.pb.dart' as $2; +import 'google/protobuf/struct.pb.dart' as $1; + +/// Data Connect specific properties for a path under response.data. +/// (-- Design doc: http://go/fdc-caching-wire-protocol --) +class GraphqlResponseExtensions_DataConnectProperties + extends $pb.GeneratedMessage { + factory GraphqlResponseExtensions_DataConnectProperties({ + $1.ListValue? path, + $core.String? entityId, + $core.Iterable<$core.String>? entityIds, + $2.Duration? maxAge, + }) { + final $result = create(); + if (path != null) { + $result.path = path; + } + if (entityId != null) { + $result.entityId = entityId; + } + if (entityIds != null) { + $result.entityIds.addAll(entityIds); + } + if (maxAge != null) { + $result.maxAge = maxAge; + } + return $result; + } + GraphqlResponseExtensions_DataConnectProperties._() : super(); + factory GraphqlResponseExtensions_DataConnectProperties.fromBuffer( + $core.List<$core.int> i, + [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(i, r); + factory GraphqlResponseExtensions_DataConnectProperties.fromJson( + $core.String i, + [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames + ? '' + : 'GraphqlResponseExtensions.DataConnectProperties', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'google.firebase.dataconnect.v1'), + createEmptyInstance: create) + ..aOM<$1.ListValue>(1, _omitFieldNames ? '' : 'path', + subBuilder: $1.ListValue.create) + ..aOS(2, _omitFieldNames ? '' : 'entityId') + ..pPS(3, _omitFieldNames ? '' : 'entityIds') + ..aOM<$2.Duration>(4, _omitFieldNames ? '' : 'maxAge', + subBuilder: $2.Duration.create) + ..hasRequiredFields = false; + + @$core.Deprecated('Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + GraphqlResponseExtensions_DataConnectProperties clone() => + GraphqlResponseExtensions_DataConnectProperties()..mergeFromMessage(this); + @$core.Deprecated('Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + GraphqlResponseExtensions_DataConnectProperties copyWith( + void Function(GraphqlResponseExtensions_DataConnectProperties) + updates) => + super.copyWith((message) => updates( + message as GraphqlResponseExtensions_DataConnectProperties)) + as GraphqlResponseExtensions_DataConnectProperties; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GraphqlResponseExtensions_DataConnectProperties create() => + GraphqlResponseExtensions_DataConnectProperties._(); + GraphqlResponseExtensions_DataConnectProperties createEmptyInstance() => + create(); + static $pb.PbList + createRepeated() => + $pb.PbList(); + @$core.pragma('dart2js:noInline') + static GraphqlResponseExtensions_DataConnectProperties getDefault() => + _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor< + GraphqlResponseExtensions_DataConnectProperties>(create); + static GraphqlResponseExtensions_DataConnectProperties? _defaultInstance; + + /// The path under response.data where the rest of the fields apply. + /// Each element may be a string (field name) or number (array index). + /// The root of response.data is denoted by the empty list `[]`. + /// (-- To simplify client logic, the server should never set this to null. + /// i.e. Use `[]` if the properties below apply to everything in data. --) + @$pb.TagNumber(1) + $1.ListValue get path => $_getN(0); + @$pb.TagNumber(1) + set path($1.ListValue v) { + setField(1, v); + } + + @$pb.TagNumber(1) + $core.bool hasPath() => $_has(0); + @$pb.TagNumber(1) + void clearPath() => clearField(1); + @$pb.TagNumber(1) + $1.ListValue ensurePath() => $_ensure(0); + + /// A single Entity ID. Set if the path points to a single entity. + @$pb.TagNumber(2) + $core.String get entityId => $_getSZ(1); + @$pb.TagNumber(2) + set entityId($core.String v) { + $_setString(1, v); + } + + @$pb.TagNumber(2) + $core.bool hasEntityId() => $_has(1); + @$pb.TagNumber(2) + void clearEntityId() => clearField(2); + + /// A list of Entity IDs. Set if the path points to an array of entities. An + /// ID is present for each element of the array at the corresponding index. + @$pb.TagNumber(3) + $core.List<$core.String> get entityIds => $_getList(2); + + /// The server-suggested duration before data under path is considered stale. + /// (-- Right now, this field is never set. For future plans, see + /// http://go/fdc-sdk-caching-config#heading=h.rmvncy2rao3g --) + @$pb.TagNumber(4) + $2.Duration get maxAge => $_getN(3); + @$pb.TagNumber(4) + set maxAge($2.Duration v) { + setField(4, v); + } + + @$pb.TagNumber(4) + $core.bool hasMaxAge() => $_has(3); + @$pb.TagNumber(4) + void clearMaxAge() => clearField(4); + @$pb.TagNumber(4) + $2.Duration ensureMaxAge() => $_ensure(3); +} + +/// GraphqlResponseExtensions contains additional information of +/// `GraphqlResponse` or `ExecuteQueryResponse`. +class GraphqlResponseExtensions extends $pb.GeneratedMessage { + factory GraphqlResponseExtensions({ + $core.Iterable? + dataConnect, + }) { + final $result = create(); + if (dataConnect != null) { + $result.dataConnect.addAll(dataConnect); + } + return $result; + } + GraphqlResponseExtensions._() : super(); + factory GraphqlResponseExtensions.fromBuffer($core.List<$core.int> i, + [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(i, r); + factory GraphqlResponseExtensions.fromJson($core.String i, + [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'GraphqlResponseExtensions', + package: const $pb.PackageName( + _omitMessageNames ? '' : 'google.firebase.dataconnect.v1'), + createEmptyInstance: create) + ..pc( + 1, _omitFieldNames ? '' : 'dataConnect', $pb.PbFieldType.PM, + subBuilder: GraphqlResponseExtensions_DataConnectProperties.create) + ..hasRequiredFields = false; + + @$core.Deprecated('Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + GraphqlResponseExtensions clone() => + GraphqlResponseExtensions()..mergeFromMessage(this); + @$core.Deprecated('Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + GraphqlResponseExtensions copyWith( + void Function(GraphqlResponseExtensions) updates) => + super.copyWith((message) => updates(message as GraphqlResponseExtensions)) + as GraphqlResponseExtensions; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static GraphqlResponseExtensions create() => GraphqlResponseExtensions._(); + GraphqlResponseExtensions createEmptyInstance() => create(); + static $pb.PbList createRepeated() => + $pb.PbList(); + @$core.pragma('dart2js:noInline') + static GraphqlResponseExtensions getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static GraphqlResponseExtensions? _defaultInstance; + + /// Data Connect specific GraphQL extension, a list of paths and properties. + /// (-- Future fields should go inside to avoid name conflicts with other GQL + /// extensions in the wild unless we're implementing a common 3P pattern in + /// extensions such as versioning and telemetry. --) + @$pb.TagNumber(1) + $core.List get dataConnect => + $_getList(0); +} + +const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); +const _omitMessageNames = + $core.bool.fromEnvironment('protobuf.omit_message_names'); diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_response_extensions.pbenum.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_response_extensions.pbenum.dart new file mode 100644 index 000000000000..924da2c849bb --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_response_extensions.pbenum.dart @@ -0,0 +1,10 @@ +// +// Generated code. Do not modify. +// source: graphql_response_extensions.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_response_extensions.pbjson.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_response_extensions.pbjson.dart new file mode 100644 index 000000000000..a1022d1267e2 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/generated/graphql_response_extensions.pbjson.dart @@ -0,0 +1,65 @@ +// +// Generated code. Do not modify. +// source: graphql_response_extensions.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + +import 'dart:convert' as $convert; +import 'dart:core' as $core; +import 'dart:typed_data' as $typed_data; + +@$core.Deprecated('Use graphqlResponseExtensionsDescriptor instead') +const GraphqlResponseExtensions$json = { + '1': 'GraphqlResponseExtensions', + '2': [ + { + '1': 'data_connect', + '3': 1, + '4': 3, + '5': 11, + '6': + '.google.firebase.dataconnect.v1.GraphqlResponseExtensions.DataConnectProperties', + '10': 'dataConnect' + }, + ], + '3': [GraphqlResponseExtensions_DataConnectProperties$json], +}; + +@$core.Deprecated('Use graphqlResponseExtensionsDescriptor instead') +const GraphqlResponseExtensions_DataConnectProperties$json = { + '1': 'DataConnectProperties', + '2': [ + { + '1': 'path', + '3': 1, + '4': 1, + '5': 11, + '6': '.google.protobuf.ListValue', + '10': 'path' + }, + {'1': 'entity_id', '3': 2, '4': 1, '5': 9, '10': 'entityId'}, + {'1': 'entity_ids', '3': 3, '4': 3, '5': 9, '10': 'entityIds'}, + { + '1': 'max_age', + '3': 4, + '4': 1, + '5': 11, + '6': '.google.protobuf.Duration', + '10': 'maxAge' + }, + ], +}; + +/// Descriptor for `GraphqlResponseExtensions`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List graphqlResponseExtensionsDescriptor = $convert.base64Decode( + 'ChlHcmFwaHFsUmVzcG9uc2VFeHRlbnNpb25zEnIKDGRhdGFfY29ubmVjdBgBIAMoCzJPLmdvb2' + 'dsZS5maXJlYmFzZS5kYXRhY29ubmVjdC52MS5HcmFwaHFsUmVzcG9uc2VFeHRlbnNpb25zLkRh' + 'dGFDb25uZWN0UHJvcGVydGllc1ILZGF0YUNvbm5lY3QatwEKFURhdGFDb25uZWN0UHJvcGVydG' + 'llcxIuCgRwYXRoGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLkxpc3RWYWx1ZVIEcGF0aBIbCgll' + 'bnRpdHlfaWQYAiABKAlSCGVudGl0eUlkEh0KCmVudGl0eV9pZHMYAyADKAlSCWVudGl0eUlkcx' + 'IyCgdtYXhfYWdlGAQgASgLMhkuZ29vZ2xlLnByb3RvYnVmLkR1cmF0aW9uUgZtYXhBZ2U='); diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/grpc_transport.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/grpc_transport.dart index e0c3e660333b..180bb209168b 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/grpc_transport.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/grpc_transport.dart @@ -173,6 +173,9 @@ ServerResponse handleResponse(CommonResponse commonResponse) { Map? jsond = commonResponse.data as Map?; String jsonEncoded = jsonEncode(commonResponse.data); + Map? jsonExt = + commonResponse.extensions as Map?; + if (commonResponse.errors.isNotEmpty) { Map? data = jsonDecode(jsonEncoded) as Map?; @@ -202,7 +205,7 @@ ServerResponse handleResponse(CommonResponse commonResponse) { // no errors - return a standard response if (jsond != null) { - return ServerResponse(jsond); + return ServerResponse(jsond, extensions: jsonExt); } else { return ServerResponse({}); } @@ -219,20 +222,21 @@ DataConnectTransport getTransport( GRPCTransport(transportOptions, options, appId, sdkType, appCheck); class CommonResponse { - CommonResponse(this.deserializer, this.data, this.errors); + CommonResponse(this.deserializer, this.data, this.errors, this.extensions); static CommonResponse fromExecuteMutation( Deserializer deserializer, ExecuteMutationResponse response) { return CommonResponse( - deserializer, response.data.toProto3Json(), response.errors); + deserializer, response.data.toProto3Json(), response.errors, null); } static CommonResponse fromExecuteQuery( Deserializer deserializer, ExecuteQueryResponse response) { - return CommonResponse( - deserializer, response.data.toProto3Json(), response.errors); + return CommonResponse(deserializer, response.data.toProto3Json(), + response.errors, response.extensions.toProto3Json()); } final Deserializer deserializer; final Object? data; final List errors; + final Object? extensions; } diff --git a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/rest_transport.dart b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/rest_transport.dart index 0ee037744ffd..708d6fa02010 100644 --- a/packages/firebase_data_connect/firebase_data_connect/lib/src/network/rest_transport.dart +++ b/packages/firebase_data_connect/firebase_data_connect/lib/src/network/rest_transport.dart @@ -117,6 +117,7 @@ class RestTransport implements DataConnectTransport { ); Map bodyJson = jsonDecode(r.body) as Map; + if (r.statusCode != 200) { String message = bodyJson.containsKey('message') ? bodyJson['message']! : r.body; @@ -127,7 +128,13 @@ class RestTransport implements DataConnectTransport { "Received a status code of ${r.statusCode} with a message '$message'", ); } - return ServerResponse(bodyJson); + final Map? extensions = + bodyJson['extensions'] as Map?; + final serverResponse = ServerResponse(bodyJson, extensions: extensions); + if (extensions != null && extensions.containsKey('ttl')) { + serverResponse.ttl = Duration(seconds: extensions['ttl'] as int); + } + return serverResponse; } on Exception catch (e) { if (e is DataConnectError) { rethrow; diff --git a/packages/firebase_data_connect/firebase_data_connect/protos/connector_service.proto b/packages/firebase_data_connect/firebase_data_connect/protos/connector_service.proto index a93b79234362..0d8e28c5221a 100644 --- a/packages/firebase_data_connect/firebase_data_connect/protos/connector_service.proto +++ b/packages/firebase_data_connect/firebase_data_connect/protos/connector_service.proto @@ -1,30 +1,29 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. -// Adapted from http://google3/google/firebase/dataconnect/v1main/connector_service.proto;rcl=596717236 +// Adapted from third_party/firebase/dataconnect/emulator/server/api/connector_service.proto syntax = "proto3"; package google.firebase.dataconnect.v1; import "google/api/field_behavior.proto"; -import "graphql_error.proto"; import "google/protobuf/struct.proto"; +import "graphql_error.proto"; +import "graphql_response_extensions.proto"; -option java_package = "google.firebase.dataconnect.proto"; +option java_package = "com.google.firebase.dataconnect.api"; option java_multiple_files = true; // Firebase Data Connect provides means to deploy a set of predefined GraphQL @@ -40,12 +39,11 @@ option java_multiple_files = true; // token. service ConnectorService { // Execute a predefined query in a Connector. - rpc ExecuteQuery(ExecuteQueryRequest) returns (ExecuteQueryResponse) { - } + rpc ExecuteQuery(ExecuteQueryRequest) returns (ExecuteQueryResponse) {} // Execute a predefined mutation in a Connector. - rpc ExecuteMutation(ExecuteMutationRequest) returns (ExecuteMutationResponse) { - } + rpc ExecuteMutation(ExecuteMutationRequest) + returns (ExecuteMutationResponse) {} } // The ExecuteQuery request to Firebase Data Connect. @@ -94,6 +92,8 @@ message ExecuteQueryResponse { google.protobuf.Struct data = 1; // Errors of this response. repeated GraphqlError errors = 2; + // Additional response information. + GraphqlResponseExtensions extensions = 3; } // The ExecuteMutation response from Firebase Data Connect. @@ -102,4 +102,6 @@ message ExecuteMutationResponse { google.protobuf.Struct data = 1; // Errors of this response. repeated GraphqlError errors = 2; -} \ No newline at end of file + // Additional response information. + GraphqlResponseExtensions extensions = 3; +} diff --git a/packages/firebase_data_connect/firebase_data_connect/protos/firebase/graphql_response_extensions.proto b/packages/firebase_data_connect/firebase_data_connect/protos/firebase/graphql_response_extensions.proto new file mode 100644 index 000000000000..e04e1927ada4 --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/protos/firebase/graphql_response_extensions.proto @@ -0,0 +1,59 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Adapted from third_party/firebase/dataconnect/emulator/server/api/graphql_response_extensions.proto + +syntax = "proto3"; + +package google.firebase.dataconnect.v1; + +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; + +option java_multiple_files = true; +option java_outer_classname = "GraphqlResponseExtensionsProto"; +option java_package = "com.google.firebase.dataconnect.api"; + +// GraphqlResponseExtensions contains additional information of +// `GraphqlResponse` or `ExecuteQueryResponse`. +message GraphqlResponseExtensions { + // Data Connect specific properties for a path under response.data. + // (-- Design doc: http://go/fdc-caching-wire-protocol --) + message DataConnectProperties { + // The path under response.data where the rest of the fields apply. + // Each element may be a string (field name) or number (array index). + // The root of response.data is denoted by the empty list `[]`. + // (-- To simplify client logic, the server should never set this to null. + // i.e. Use `[]` if the properties below apply to everything in data. --) + google.protobuf.ListValue path = 1; + + // A single Entity ID. Set if the path points to a single entity. + string entity_id = 2; + + // A list of Entity IDs. Set if the path points to an array of entities. An + // ID is present for each element of the array at the corresponding index. + repeated string entity_ids = 3; + + // The server-suggested duration before data under path is considered stale. + // (-- Right now, this field is never set. For future plans, see + // http://go/fdc-sdk-caching-config#heading=h.rmvncy2rao3g --) + google.protobuf.Duration max_age = 4; + } + // Data Connect specific GraphQL extension, a list of paths and properties. + // (-- Future fields should go inside to avoid name conflicts with other GQL + // extensions in the wild unless we're implementing a common 3P pattern in + // extensions such as versioning and telemetry. --) + repeated DataConnectProperties data_connect = 1; +} + diff --git a/packages/firebase_data_connect/firebase_data_connect/protos/google/duration.proto b/packages/firebase_data_connect/firebase_data_connect/protos/google/duration.proto new file mode 100644 index 000000000000..cb7cf0e926cc --- /dev/null +++ b/packages/firebase_data_connect/firebase_data_connect/protos/google/duration.proto @@ -0,0 +1,116 @@ +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +syntax = "proto3"; + +package google.protobuf; + +option csharp_namespace = "Google.Protobuf.WellKnownTypes"; +option cc_enable_arenas = true; +option go_package = "google.golang.org/protobuf/types/known/durationpb"; +option java_package = "com.google.protobuf"; +option java_outer_classname = "DurationProto"; +option java_multiple_files = true; +option objc_class_prefix = "GPB"; + +// A Duration represents a signed, fixed-length span of time represented +// as a count of seconds and fractions of seconds at nanosecond +// resolution. It is independent of any calendar and concepts like "day" +// or "month". It is related to Timestamp in that the difference between +// two Timestamp values is a Duration and it can be added or subtracted +// from a Timestamp. Range is approximately +-10,000 years. +// +// # Examples +// +// Example 1: Compute Duration from two Timestamps in pseudo code. +// +// Timestamp start = ...; +// Timestamp end = ...; +// Duration duration = ...; +// +// duration.seconds = end.seconds - start.seconds; +// duration.nanos = end.nanos - start.nanos; +// +// if (duration.seconds < 0 && duration.nanos > 0) { +// duration.seconds += 1; +// duration.nanos -= 1000000000; +// } else if (duration.seconds > 0 && duration.nanos < 0) { +// duration.seconds -= 1; +// duration.nanos += 1000000000; +// } +// +// Example 2: Compute Timestamp from Timestamp + Duration in pseudo code. +// +// Timestamp start = ...; +// Duration duration = ...; +// Timestamp end = ...; +// +// end.seconds = start.seconds + duration.seconds; +// end.nanos = start.nanos + duration.nanos; +// +// if (end.nanos < 0) { +// end.seconds -= 1; +// end.nanos += 1000000000; +// } else if (end.nanos >= 1000000000) { +// end.seconds += 1; +// end.nanos -= 1000000000; +// } +// +// Example 3: Compute Duration from datetime.timedelta in Python. +// +// td = datetime.timedelta(days=3, minutes=10) +// duration = Duration() +// duration.FromTimedelta(td) +// +// # JSON Mapping +// +// In JSON format, the Duration type is encoded as a string rather than an +// object, where the string ends in the suffix "s" (indicating seconds) and +// is preceded by the number of seconds, with nanoseconds expressed as +// fractional seconds. For example, 3 seconds with 0 nanoseconds should be +// encoded in JSON format as "3s", while 3 seconds and 1 nanosecond should +// be expressed in JSON format as "3.000000001s", and 3 seconds and 1 +// microsecond should be expressed in JSON format as "3.000001s". +// +// +message Duration { + // Signed seconds of the span of time. Must be from -315,576,000,000 + // to +315,576,000,000 inclusive. Note: these bounds are computed from: + // 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years + int64 seconds = 1; + + // Signed fractions of a second at nanosecond resolution of the span + // of time. Durations less than one second are represented with a 0 + // `seconds` field and a positive or negative `nanos` field. For durations + // of one second or more, a non-zero value for the `nanos` field must be + // of the same sign as the `seconds` field. Must be from -999,999,999 + // to +999,999,999 inclusive. + int32 nanos = 2; +} \ No newline at end of file diff --git a/packages/firebase_data_connect/firebase_data_connect/pubspec.yaml b/packages/firebase_data_connect/firebase_data_connect/pubspec.yaml index e71288430a1e..971425f165dc 100644 --- a/packages/firebase_data_connect/firebase_data_connect/pubspec.yaml +++ b/packages/firebase_data_connect/firebase_data_connect/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: firebase_auth: ^6.1.3 firebase_core: ^4.3.0 firebase_core_platform_interface: ^6.0.2 + fixnum: ^1.1.1 flutter: sdk: flutter grpc: ^3.2.4 diff --git a/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.dart b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.dart index db40791ad2a0..dec53744b7e3 100644 --- a/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.dart +++ b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/cache_manager_test.dart @@ -18,7 +18,7 @@ import 'package:firebase_data_connect/src/core/ref.dart'; import 'package:firebase_data_connect/src/network/rest_library.dart'; import 'package:firebase_data_connect/src/common/common_library.dart'; import 'package:firebase_data_connect/src/cache/cache_data_types.dart'; -import 'package:firebase_data_connect/src/cache/cache_manager.dart'; +import 'package:firebase_data_connect/src/cache/cache.dart'; import 'package:firebase_data_connect/src/cache/cache_provider.dart'; import 'package:firebase_data_connect/src/cache/in_memory_cache_provider.dart'; import 'package:firebase_data_connect/src/cache/sqlite_cache_provider.dart'; @@ -51,18 +51,27 @@ void main() { const String simpleQueryResponse = ''' {"data": {"items":[ - {"desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":4}, - {"desc":"itemDesc2","name":"itemTwo", "cacheId":"345","price":7} + {"desc":"itemDesc1","name":"itemOne","price":4}, + {"desc":"itemDesc2","name":"itemTwo","price":7} ]}} '''; + final Map simpleQueryExtensions = { + 'dataConnect': [ + { + 'path': ['items'], + 'entityIds': ['123', '345'] + } + ] + }; + // query that updates the price for cacheId 123 to 11 const String simpleQueryResponseUpdate = ''' {"data": {"items":[ - {"desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":11}, - {"desc":"itemDesc2","name":"itemTwo", "cacheId":"345","price":7} + {"desc":"itemDesc1","name":"itemOne","price":11}, + {"desc":"itemDesc2","name":"itemTwo","price":7} ]}} '''; @@ -70,10 +79,19 @@ void main() { // query two has same object as query one so should refer to same Entity. const String simpleQueryTwoResponse = ''' {"data": { - "item": { "desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":4 } + "item": { "desc":"itemDesc1","name":"itemOne","price":4 } }} '''; + final Map simpleQueryTwoExtensions = { + 'dataConnect': [ + { + 'path': ['item'], + 'entityId': '123' + } + ] + }; + group('Cache Provider Tests', () { setUp(() async { mockApp = MockFirebaseApp(); @@ -126,9 +144,11 @@ void main() { Map jsonData = jsonDecode(simpleQueryResponse) as Map; - await cache.update('itemsSimple', ServerResponse(jsonData)); + await cache.update('itemsSimple', + ServerResponse(jsonData, extensions: simpleQueryExtensions)); - Map? cachedData = await cache.get('itemsSimple', true); + Map? cachedData = + await cache.resultTree('itemsSimple', true); expect(jsonData['data'], cachedData); }); // test set get @@ -144,8 +164,8 @@ void main() { edo.updateServerValue('name', 'test', null); edo.updateServerValue('desc', 'testDesc', null); - cp.saveEntityDataObject(edo); - EntityDataObject edo2 = cp.getEntityDataObject('1234'); + cp.updateEntityData(edo); + EntityDataObject edo2 = cp.getEntityData('1234'); expect(edo.fields().length, edo2.fields().length); expect(edo.fields()['name'], edo2.fields()['name']); @@ -162,21 +182,24 @@ void main() { Map jsonDataOne = jsonDecode(simpleQueryResponse) as Map; - await cache.update(queryOneId, ServerResponse(jsonDataOne)); + await cache.update(queryOneId, + ServerResponse(jsonDataOne, extensions: simpleQueryExtensions)); Map jsonDataTwo = jsonDecode(simpleQueryTwoResponse) as Map; - await cache.update(queryTwoId, ServerResponse(jsonDataTwo)); + await cache.update(queryTwoId, + ServerResponse(jsonDataTwo, extensions: simpleQueryTwoExtensions)); Map jsonDataOneUpdate = jsonDecode(simpleQueryResponseUpdate) as Map; - await cache.update(queryOneId, ServerResponse(jsonDataOneUpdate)); + await cache.update(queryOneId, + ServerResponse(jsonDataOneUpdate, extensions: simpleQueryExtensions)); // shared object should be updated. // now reload query two from cache and check object value. // it should be updated Map? jsonDataTwoUpdated = - await cache.get(queryTwoId, true); + await cache.resultTree(queryTwoId, true); if (jsonDataTwoUpdated == null) { fail('No query two found in cache'); } @@ -194,16 +217,16 @@ void main() { await cp.initialize(); String oid = '1234'; - EntityDataObject edo = cp.getEntityDataObject(oid); + EntityDataObject edo = cp.getEntityData(oid); String testValue = 'testValue'; String testProp = 'testProp'; edo.updateServerValue(testProp, testValue, null); - cp.saveEntityDataObject(edo); + cp.updateEntityData(edo); - EntityDataObject edo2 = cp.getEntityDataObject(oid); + EntityDataObject edo2 = cp.getEntityData(oid); String value = edo2.fields()[testProp]; expect(testValue, value); @@ -221,7 +244,8 @@ void main() { Map jsonData = jsonDecode(simpleQueryResponse) as Map; - await cache.update('itemsSimple', ServerResponse(jsonData)); + await cache.update('itemsSimple', + ServerResponse(jsonData, extensions: simpleQueryExtensions)); QueryRef ref = QueryRef( dataConnect, @@ -254,5 +278,47 @@ void main() { expect(resultDelayed.source, DataSource.server); }); }); + + test('Test AnyValue Caching', () async { + if (dataConnect.cacheManager == null) { + fail('No cache available'); + } + + Cache cache = dataConnect.cacheManager!; + + const String anyValueSingleData = ''' + {"data": {"anyValueItem": + { "name": "AnyItem B", + "blob": {"values":["A", 45, {"embedKey": "embedVal"}, ["A", "AA"]]} + } + }} + '''; + + final Map anyValueSingleExt = { + 'dataConnect': [ + { + 'path': ['anyValueItem'], + 'entityId': 'AnyValueItemSingle_ID' + } + ] + }; + + Map jsonData = + jsonDecode(anyValueSingleData) as Map; + + await cache.update('queryAnyValue', + ServerResponse(jsonData, extensions: anyValueSingleExt)); + + Map? cachedData = + await cache.resultTree('queryAnyValue', true); + + expect(cachedData?['anyValueItem']?['name'], 'AnyItem B'); + List values = cachedData?['anyValueItem']?['blob']?['values']; + expect(values.length, 4); + expect(values[0], 'A'); + expect(values[1], 45); + expect(values[2], {'embedKey': 'embedVal'}); + expect(values[3], ['A', 'AA']); + }); }); // test group } //main diff --git a/packages/firebase_data_connect/firebase_data_connect/test/src/cache/result_tree_processor_test.dart b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/result_tree_processor_test.dart index 6d3c71d09a17..9b347425c7dc 100644 --- a/packages/firebase_data_connect/firebase_data_connect/test/src/cache/result_tree_processor_test.dart +++ b/packages/firebase_data_connect/firebase_data_connect/test/src/cache/result_tree_processor_test.dart @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:firebase_data_connect/src/cache/cache_data_types.dart'; import 'package:firebase_data_connect/src/cache/result_tree_processor.dart'; +import 'package:firebase_data_connect/src/common/common_library.dart'; import 'package:flutter_test/flutter_test.dart'; import 'dart:convert'; @@ -23,19 +25,46 @@ void main() { const String simpleQueryResponse = ''' {"data": {"items":[ - {"desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":4}, - {"desc":"itemDesc2","name":"itemTwo", "cacheId":"345","price":7} + {"desc":"itemDesc1","name":"itemOne","price":4}, + {"desc":"itemDesc2","name":"itemTwo","price":7} ]}} '''; + final Map simpleQueryPaths = { + DataConnectPath([ + DataConnectFieldPathSegment('items'), + DataConnectListIndexPathSegment(0) + ]): PathMetadata( + path: DataConnectPath([ + DataConnectFieldPathSegment('items'), + DataConnectListIndexPathSegment(0) + ]), + entityId: '123'), + DataConnectPath([ + DataConnectFieldPathSegment('items'), + DataConnectListIndexPathSegment(1) + ]): PathMetadata( + path: DataConnectPath([ + DataConnectFieldPathSegment('items'), + DataConnectListIndexPathSegment(1) + ]), + entityId: '345'), + }; + // query two has same object as query one so should refer to same Entity. const String simpleQueryResponseTwo = ''' {"data": { - "item": { "desc":"itemDesc1","name":"itemOne", "cacheId":"123","price":4 } + "item": { "desc":"itemDesc1","name":"itemOne","price":4 } }} '''; + final Map simpleQueryTwoPaths = { + DataConnectPath([DataConnectFieldPathSegment('item')]): PathMetadata( + path: DataConnectPath([DataConnectFieldPathSegment('item')]), + entityId: '123'), + }; + group('CacheProviderTests', () { // Dehydrate two queries sharing a single object. // Confirm that same EntityDataObject is present in both the dehydrated queries @@ -45,8 +74,8 @@ void main() { Map jsonData = jsonDecode(simpleQueryResponse) as Map; - DehydrationResult result = - await rp.dehydrate('itemsSimple', jsonData['data'], cp); + DehydrationResult result = await rp.dehydrateResults( + 'itemsSimple', jsonData['data'], cp, simpleQueryPaths); expect(result.dehydratedTree.nestedObjectLists?.length, 1); expect(result.dehydratedTree.nestedObjectLists?['items']?.length, 2); expect(result.dehydratedTree.nestedObjectLists?['items']?.first.entity, @@ -54,8 +83,8 @@ void main() { Map jsonDataTwo = jsonDecode(simpleQueryResponseTwo) as Map; - DehydrationResult resultTwo = - await rp.dehydrate('itemsSimpleTwo', jsonDataTwo, cp); + DehydrationResult resultTwo = await rp.dehydrateResults( + 'itemsSimpleTwo', jsonDataTwo['data'], cp, simpleQueryTwoPaths); List? guids = result.dehydratedTree.nestedObjectLists?['items'] ?.map((item) => item.entity?.guid) diff --git a/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m b/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m index 3d8c9298a4e9..76879a85c526 100644 --- a/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m +++ b/packages/firebase_messaging/firebase_messaging/ios/firebase_messaging/Sources/firebase_messaging/FLTFirebaseMessagingPlugin.m @@ -299,12 +299,12 @@ - (void)setupNotificationHandlingWithRemoteNotification: respondsToSelector:@selector(userNotificationCenter:openSettingsForNotification:)]; _originalNotificationCenterDelegateRespondsTo.willPresentNotification = (unsigned int)[_originalNotificationCenterDelegate - respondsToSelector:@selector(userNotificationCenter: - willPresentNotification:withCompletionHandler:)]; + respondsToSelector:@selector(userNotificationCenter:willPresentNotification: + withCompletionHandler:)]; _originalNotificationCenterDelegateRespondsTo.didReceiveNotificationResponse = (unsigned int)[_originalNotificationCenterDelegate - respondsToSelector:@selector(userNotificationCenter: - didReceiveNotificationResponse:withCompletionHandler:)]; + respondsToSelector:@selector(userNotificationCenter:didReceiveNotificationResponse: + withCompletionHandler:)]; } }