diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/cloud_firestore_web.dart b/packages/cloud_firestore/cloud_firestore_web/lib/cloud_firestore_web.dart index ff68541be65a..04d722e3decf 100644 --- a/packages/cloud_firestore/cloud_firestore_web/lib/cloud_firestore_web.dart +++ b/packages/cloud_firestore/cloud_firestore_web/lib/cloud_firestore_web.dart @@ -21,6 +21,9 @@ import 'src/collection_reference_web.dart'; import 'src/document_reference_web.dart'; import 'src/field_value_factory_web.dart'; import 'src/interop/firestore.dart' as firestore_interop; +import 'src/interop/firestore_interop.dart' as firestore_interop_js; +import 'src/pipeline_builder_web.dart'; +import 'src/pipeline_web.dart'; import 'src/query_web.dart'; import 'src/transaction_web.dart'; import 'src/write_batch_web.dart'; @@ -271,4 +274,48 @@ class FirebaseFirestoreWeb extends FirebaseFirestorePlatform { } _delegate.setLoggingEnabled(value); } + + @override + PipelinePlatform pipeline(List> initialStages) { + return PipelineWeb(this, _delegate, initialStages); + } + + @override + Future executePipeline( + List> stages, { + Map? options, + }) async { + final jsFirestore = _delegate.jsObject; + firestore_interop_js.PipelineJsImpl jsPipeline; + try { + jsPipeline = buildPipelineFromStages(jsFirestore, stages); + } catch (e, stack) { + // Let our Dart FirebaseException (e.g. unsupported expression) propagate + // so the user sees a clear message; the guard would cast it to JSError. + if (e is FirebaseException) { + Error.throwWithStackTrace(e, stack); + } + // JS or other errors: run through the guard to convert to FirebaseException. + return convertWebExceptions(() async { + Error.throwWithStackTrace(e, stack); + }); + } + + final dartPipeline = firestore_interop.Pipeline.getInstance(jsPipeline); + return convertWebExceptions(() async { + String? executeOptions; + if (options != null) { + executeOptions = options['indexMode'] as String; + } + final snapshot = await dartPipeline.execute(executeOptions); + + final results = snapshot.results + .map((r) => PipelineResultWeb(this, _delegate, r.jsObject)) + .toList(); + + final executionTime = snapshot.executionTime ?? DateTime.now(); + + return PipelineSnapshotWeb(results, executionTime); + }); + } } diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore.dart index 6703d7738f14..9ed2cf332707 100644 --- a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore.dart +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore.dart @@ -1089,3 +1089,122 @@ class AggregateQuerySnapshot } } } + +// ============================================================================= +// Pipeline (global execute + snapshot/result wrappers) +// ============================================================================= + +/// Single result in a pipeline snapshot (document + data). +class PipelineResult + extends JsObjectWrapper { + static final _expando = Expando(); + + late final DocumentReference? _ref; + late final Map? _data; + late final DateTime? _createTime; + late final DateTime? _updateTime; + + static PipelineResult getInstance( + firestore_interop.PipelineResultJsImpl jsObject) { + return _expando[jsObject] ??= PipelineResult._fromJsObject(jsObject); + } + + PipelineResult._fromJsObject(firestore_interop.PipelineResultJsImpl jsObject) + : _ref = jsObject.ref != null + ? DocumentReference.getInstance(jsObject.ref!) + : null, + _data = _dataFromResult(jsObject), + _createTime = _timestampToDateTime(jsObject.createTime), + _updateTime = _timestampToDateTime(jsObject.updateTime), + super.fromJsObject(jsObject); + + static Map? _dataFromResult( + firestore_interop.PipelineResultJsImpl jsResult) { + final d = jsResult.data(); + if (d == null) return null; + final parsed = dartify(d); + return parsed != null + ? Map.from(parsed as Map) + : null; + } + + static DateTime? _timestampToDateTime(dynamic value) { + if (value == null) return null; + final d = dartify(value); + if (d == null) return null; + if (d is DateTime) return d; + if (d is Timestamp) return d.toDate(); + if (d is int) return DateTime.fromMillisecondsSinceEpoch(d); + return null; + } + + DocumentReference? get ref => _ref; + Map? get data => _data; + DateTime? get createTime => _createTime; + DateTime? get updateTime => _updateTime; +} + +/// Snapshot of pipeline execution results. +class PipelineSnapshot + extends JsObjectWrapper { + static final _expando = Expando(); + + late final List _results; + late final DateTime? _executionTime; + + static PipelineSnapshot getInstance( + firestore_interop.PipelineSnapshotJsImpl jsObject) { + return _expando[jsObject] ??= PipelineSnapshot._fromJsObject(jsObject); + } + + static List _buildResults( + firestore_interop.PipelineSnapshotJsImpl jsObject) { + final rawResults = jsObject.results.toDart; + return rawResults + .cast() + .map(PipelineResult.getInstance) + .toList(); + } + + PipelineSnapshot._fromJsObject( + firestore_interop.PipelineSnapshotJsImpl jsObject) + : _results = _buildResults(jsObject), + _executionTime = _executionTimeFromJs(jsObject.executionTime), + super.fromJsObject(jsObject); + + static DateTime? _executionTimeFromJs(dynamic value) { + if (value == null) return null; + final d = dartify(value); + if (d == null) return null; + if (d is DateTime) return d; + if (d is int) return DateTime.fromMillisecondsSinceEpoch(d); + return null; + } + + List get results => _results; + DateTime? get executionTime => _executionTime; +} + +/// Wraps a JS pipeline; use [execute] to run it via the global execute function. +class Pipeline extends JsObjectWrapper { + static final _expando = Expando(); + + static Pipeline getInstance(firestore_interop.PipelineJsImpl jsObject) { + return _expando[jsObject] ??= Pipeline._fromJsObject(jsObject); + } + + Pipeline._fromJsObject(firestore_interop.PipelineJsImpl jsObject) + : super.fromJsObject(jsObject); + + /// Runs this pipeline using the global JS SDK execute function. + Future execute(String? executeOptions) async { + final executeOptionsJs = firestore_interop.PipelineExecuteOptionsJsImpl(); + if (executeOptions != null) { + executeOptionsJs.indexMode = executeOptions.toJS; + } + executeOptionsJs.pipeline = jsObject as JSAny; + final snapshot = + await firestore_interop.pipelines.execute(executeOptionsJs).toDart; + return PipelineSnapshot.getInstance(snapshot); + } +} diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart index 85cc51fbffde..12bd9326586a 100644 --- a/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/interop/firestore_interop.dart @@ -333,13 +333,166 @@ external JSObject get and; @staticInterop external WriteBatchJsImpl writeBatch(FirestoreJsImpl firestore); -@JS('Firestore') -@staticInterop -abstract class FirestoreJsImpl {} - -extension FirestoreJsImplExtension on FirestoreJsImpl { +extension type FirestoreJsImpl._(JSObject _) implements JSObject { external AppJsImpl get app; external JSString get type; + + /// Returns the pipeline source for building and executing pipelines. + external JSAny pipeline(); +} + +@JS() +@staticInterop +external PipelinesJsImpl get pipelines; + +/// Pipeline expression API — mirrors the Firebase JS SDK pipelines module. +/// Use these to build expressions for where(), sort(), addFields(), aggregate(), etc. +extension type PipelinesJsImpl._(JSObject _) implements JSObject { + external JSPromise execute( + PipelineExecuteOptionsJsImpl pipeline); + + // --- Expression builders --- + external ExpressionJsImpl field(JSString path); + external ExpressionJsImpl constant(JSAny? value); + + // --- Boolean / comparison --- + external JSAny equal(JSAny left, JSAny right); + external JSAny notEqual(JSAny left, JSAny right); + external JSAny greaterThan(JSAny left, JSAny right); + external JSAny greaterThanOrEqual(JSAny left, JSAny right); + external JSAny lessThan(JSAny left, JSAny right); + external JSAny lessThanOrEqual(JSAny left, JSAny right); + external JSAny and(JSAny a, JSAny b); + external JSAny or(JSAny a, JSAny b); + external JSAny xor(JSAny a, JSAny b); + external JSAny not(JSAny expr); + + // --- Existence / type checks --- + external JSAny exists(JSAny expr); + external JSAny isAbsent(JSAny expr); + external JSAny isError(JSAny expr); + + // --- Array --- + external JSAny arrayContains(JSAny array, JSAny element); + external JSAny arrayContainsAny(JSAny array, JSArray values); + external JSAny arrayContainsAll(JSAny array, JSAny valuesOrArray); + + // --- IN / NOT IN (boolean) --- + external JSAny equalAny(JSAny element, JSAny valuesArray); + external JSAny notEqualAny(JSAny element, JSAny valuesArray); + + // --- String / value expressions (global) --- + external ExpressionJsImpl split(JSAny expression, JSAny delimiter); + external ExpressionJsImpl join(JSAny arrayExpression, JSAny delimiter); + external ExpressionJsImpl substring( + JSAny input, JSAny position, JSAny length); + external ExpressionJsImpl stringReplaceAll( + JSAny expression, JSAny find, JSAny replacement); + external ExpressionJsImpl ifAbsent(JSAny expression, JSAny elseExpr); + external ExpressionJsImpl ifError(JSAny expression, JSAny catchExpr); + external ExpressionJsImpl conditional( + JSAny condition, JSAny thenExpr, JSAny elseExpr); + external ExpressionJsImpl documentId(JSAny path); + external ExpressionJsImpl collectionId(JSAny expression); + external ExpressionJsImpl mapGet(JSAny mapExpr, JSString key); + external ExpressionJsImpl mapKeys(JSAny mapExpr); + external ExpressionJsImpl mapValues(JSAny mapExpr); + external ExpressionJsImpl currentTimestamp(); + external ExpressionJsImpl timestampAdd( + JSAny timestamp, JSString unit, JSAny amount); + external ExpressionJsImpl timestampSubtract( + JSAny timestamp, JSString unit, JSAny amount); + external ExpressionJsImpl timestampTruncate(JSAny timestamp, JSString unit, + [JSString? timezone]); + external ExpressionJsImpl abs(JSAny expr); + external ExpressionJsImpl arrayLength(JSAny array); + external ExpressionJsImpl arraySum(JSAny expression); + external ExpressionJsImpl arrayConcat(JSAny first, JSAny second); + external ExpressionJsImpl array(JSArray elements); + external ExpressionJsImpl map(JSObject keyValuePairs); + + // --- Ordering (for sort stage) --- + external JSAny ascending(JSAny expr); + external JSAny descending(JSAny expr); + + // --- Aggregates --- + external AggregateFunctionJsImpl sum(JSAny expr); + external AggregateFunctionJsImpl average(JSAny expr); + external AggregateFunctionJsImpl count(JSAny expr); + external AggregateFunctionJsImpl countDistinct(JSAny expr); + external AggregateFunctionJsImpl minimum(JSAny expr); + external AggregateFunctionJsImpl maximum(JSAny expr); + external AggregateFunctionJsImpl countAll(); + + // --- Aliased (for select/addFields/aggregate output names) --- + external JSAny aliased(JSAny expr, JSString alias); +} + +/// Aggregate function (result of sum(), average(), count(), etc. on pipelines). +/// Has .as(alias) to create an aliased aggregate for accumulators. +extension type AggregateFunctionJsImpl._(JSObject _) implements JSObject { + @JS('as') + external JSAny asAlias(JSString alias); +} + +extension type ExpressionJsImpl._(JSObject _) implements JSObject { + @JS('as') + external JSAny asAlias(JSString alias); + + external ExpressionJsImpl add(JSAny right); + external ExpressionJsImpl subtract(JSAny right); + external ExpressionJsImpl multiply(JSAny right); + external ExpressionJsImpl divide(JSAny right); + @JS('mod') + external ExpressionJsImpl modulo(JSAny right); + external ExpressionJsImpl length(); + external ExpressionJsImpl concat(JSAny right); + external ExpressionJsImpl toLower(); + external ExpressionJsImpl toUpper(); + external ExpressionJsImpl trim(); + external ExpressionJsImpl arrayReverse(); +} + +extension type SelectableJsImpl._(JSObject _) implements JSObject { + @JS('as') + external JSAny asAlias(JSString alias); +} + +/// Aliased aggregate for use in aggregate() stage accumulators. +/// Mirrors Firebase JS SDK: constructor(aggregate, alias, _methodName?). +@JS('AliasedAggregate') +@staticInterop +abstract class AliasedAggregateJsImpl { + external factory AliasedAggregateJsImpl( + JSAny aggregate, + JSString alias, [ + JSString? methodName, + ]); +} + +/// Options for the aggregate() pipeline stage. +/// Mirrors Firebase JS SDK AggregateStageOptions: { accumulators, groups? }. +extension type AggregateStageOptionsJsImpl._(JSObject _) implements JSObject { + AggregateStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set accumulators(JSAny value); + // ignore: avoid_setters_without_getters + external set groups(JSAny value); +} + +extension type SelectStageOptionsJsImpl._(JSObject _) implements JSObject { + SelectStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set selections(JSArray value); +} + +extension type AddFieldsOptionsJsImpl._(JSObject _) implements JSObject { + AddFieldsOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set fields(JSAny value); } extension type WriteBatchJsImpl._(JSObject _) implements JSObject { @@ -1014,3 +1167,141 @@ extension type AggregateQuerySnapshotJsImpl._(JSObject _) implements JSObject { @JS() @staticInterop abstract class PersistentCacheIndexManager {} + +/// Entry point for defining the data source of a Firestore Pipeline. +/// Use .collection(), .collectionGroup(), .database(), or .documents(). +extension type PipelineSourceJsImpl._(JSObject _) implements JSObject { + /// Returns all documents from the entire collection (can be nested). + external PipelineJsImpl collection(JSString collectionPath); + + /// Returns all documents from a collection ID regardless of parent. + external PipelineJsImpl collectionGroup(JSString collectionId); + + /// Returns all documents from the entire database. + external PipelineJsImpl database(); + + /// Sets the pipeline source to the given document paths or references. + external PipelineJsImpl documents(JSArray docs); +} + +/// Pipeline returned by PipelineSource methods; chain stages and call execute(). +/// See: https://firebase.google.com/docs/reference/js/firestore_pipelines.pipeline +extension type PipelineJsImpl._(JSObject _) implements JSObject { + external PipelineJsImpl limit(JSNumber limit); + external PipelineJsImpl offset(JSNumber offset); + external PipelineJsImpl where(JSAny condition); + external PipelineJsImpl sort(JSAny orderingOrOptions); + external PipelineJsImpl addFields(JSAny fieldOrOptions); + external PipelineJsImpl select(JSAny selectionOrOptions); + external PipelineJsImpl distinct(JSAny groupOrOptions); + external PipelineJsImpl aggregate(AggregateStageOptionsJsImpl options); + external PipelineJsImpl sample(JSAny documentsOrOptions); + external PipelineJsImpl unnest(JSAny selectableOrOptions); + external PipelineJsImpl removeFields(JSAny fieldOrOptions); + external PipelineJsImpl replaceWith(JSAny fieldNameOrOptions); + external PipelineJsImpl findNearest(JSAny options); + external PipelineJsImpl union(JSAny otherOrOptions); + external PipelineJsImpl rawStage(JSString name, JSArray params, + [JSAny? options]); +} + +/// Options for pipeline execution (e.g. index mode). +@anonymous +@JS() +@staticInterop +abstract class PipelineExecuteOptions { + external factory PipelineExecuteOptions({JSString? indexMode}); +} + +extension PipelineExecuteOptionsExtension on PipelineExecuteOptions { + external JSString? get indexMode; + external set indexMode(JSString? v); +} + +/// Snapshot of pipeline execution results. +extension type PipelineSnapshotJsImpl._(JSObject _) implements JSObject { + /// Array of [PipelineResultJsImpl]. + external JSArray get results; + + /// Execution time (if provided by SDK). + external JSAny? get executionTime; +} + +/// Single result in a pipeline snapshot (document + data). +extension type PipelineResultJsImpl._(JSObject _) implements JSObject { + external DocumentReferenceJsImpl? get ref; + external JSObject? data(); + external JSAny? get createTime; + external JSAny? get updateTime; +} + +extension type SampleStageOptionsJsImpl._(JSObject _) implements JSObject { + SampleStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set documents(JSAny value); + // ignore: avoid_setters_without_getters + external set percentage(JSAny value); +} + +extension type SortStageOptionsJsImpl._(JSObject _) implements JSObject { + SortStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set orderings(JSAny value); +} + +extension type DistinctStageOptionsJsImpl._(JSObject _) implements JSObject { + DistinctStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set groups(JSAny value); +} + +extension type UnnestStageOptionsJsImpl._(JSObject _) implements JSObject { + UnnestStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set selectable(JSAny value); + // ignore: avoid_setters_without_getters + external set indexField(JSString? value); +} + +extension type RemoveFieldsStageOptionsJsImpl._(JSObject _) + implements JSObject { + RemoveFieldsStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set fields(JSArray value); +} + +extension type ReplaceWithStageOptionsJsImpl._(JSObject _) implements JSObject { + ReplaceWithStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set map(JSAny value); +} + +extension type FindNearestStageOptionsJsImpl._(JSObject _) implements JSObject { + FindNearestStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set field(JSAny value); + // ignore: avoid_setters_without_getters + external set vectorValue(JSAny value); + // ignore: avoid_setters_without_getters + external set distanceMeasure(JSString value); + // ignore: avoid_setters_without_getters + external set limit(JSNumber value); + // ignore: avoid_setters_without_getters + external set distanceField(JSString value); +} + +extension type PipelineExecuteOptionsJsImpl._(JSObject _) implements JSObject { + PipelineExecuteOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set indexMode(JSString value); + // ignore: avoid_setters_without_getters + external set pipeline(JSAny value); +} diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_builder_web.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_builder_web.dart new file mode 100644 index 000000000000..bc2db69e575d --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_builder_web.dart @@ -0,0 +1,137 @@ +// ignore_for_file: require_trailing_commas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; + +import 'package:cloud_firestore_web/src/interop/firestore_interop.dart' + as interop; +import 'package:cloud_firestore_web/src/pipeline_expression_parser_web.dart'; +import 'package:firebase_core/firebase_core.dart'; + +/// Builds a JS Pipeline from serialized [stages] and returns it ready to execute. +/// Keeps [executePipeline] thin: build → execute → convert. +interop.PipelineJsImpl buildPipelineFromStages( + interop.FirestoreJsImpl jsFirestore, + List> stages, +) { + final source = jsFirestore.pipeline(); + final first = stages.first; + final stageName = first['stage'] as String?; + + // Build source stage + interop.PipelineJsImpl pipeline = _applySourceStage( + source as interop.PipelineSourceJsImpl, jsFirestore, stageName, first); + + final converter = PipelineExpressionParserWeb(interop.pipelines, jsFirestore); + + // Apply remaining stages + for (var i = 1; i < stages.length; i++) { + pipeline = _applyStage(pipeline, stages[i], converter, jsFirestore); + } + return pipeline; +} + +interop.PipelineJsImpl _applySourceStage( + interop.PipelineSourceJsImpl source, + interop.FirestoreJsImpl jsFirestore, + String? stageName, + Map first, +) { + final args = first['args']; + return switch (stageName) { + 'collection' => source + .collection(((args as Map)['path']! as String).toJS), + 'collection_group' => source.collectionGroup( + ((args as Map)['path']! as String).toJS), + 'database' => source.database(), + 'documents' => source.documents( + (args as List) + .map((e) => (e as Map)['path']! as String) + .map((p) => interop.doc(jsFirestore as JSAny, p.toJS)) + .toList() + .toJS, + ), + _ => throw UnsupportedError( + 'Pipeline source stage "$stageName" is not supported on web.'), + }; +} + +interop.PipelineJsImpl _applyStage( + interop.PipelineJsImpl pipeline, + Map stage, + PipelineExpressionParserWeb converter, + interop.FirestoreJsImpl jsFirestore, +) { + final name = stage['stage'] as String?; + final args = stage['args']; + final map = args is Map ? args : {}; + + switch (name) { + case 'limit': + final limit = map['limit'] as int; + return pipeline.limit(limit.toJS); + case 'offset': + final offset = map['offset'] as int; + return pipeline.offset(offset.toJS); + case 'where': + final expression = map['expression']; + if (expression == null) return pipeline; + final condition = + converter.toBooleanExpression(expression as Map); + if (condition == null) { + throw UnsupportedError( + 'Pipeline where() on web: could not parse the condition expression.', + ); + } + return pipeline.where(condition); + case 'sort': + final orderings = map['orderings'] as List?; + if (orderings == null || orderings.isEmpty) return pipeline; + return pipeline.sort(converter.toSortOptions(orderings)); + case 'add_fields': + final expressions = map['expressions'] as List?; + if (expressions == null || expressions.isEmpty) return pipeline; + return pipeline.addFields(converter.toAddFieldsOptions(expressions)); + case 'select': + final expressions = map['expressions'] as List?; + if (expressions == null || expressions.isEmpty) return pipeline; + return pipeline.select(converter.toSelectOptions(expressions)); + case 'distinct': + final expressions = map['expressions'] as List?; + if (expressions == null || expressions.isEmpty) return pipeline; + return pipeline.distinct(converter.toDistinctOptions(expressions)); + case 'aggregate': + return pipeline.aggregate(converter.toAggregateOptionsFromFunctions(map)); + case 'aggregate_with_options': + return pipeline + .aggregate(converter.toAggregateOptionsFromStageAndOptions(map)); + case 'sample': + return pipeline.sample(converter.toSampleOptions(args)); + case 'unnest': + return pipeline.unnest(converter.toUnnestOptions(map)); + case 'remove_fields': + final fieldPaths = map['field_paths'] as List?; + if (fieldPaths == null || fieldPaths.isEmpty) return pipeline; + return pipeline.removeFields(converter.toRemoveFieldsOptions(fieldPaths)); + case 'replace_with': + final expression = map['expression']; + if (expression == null) return pipeline; + return pipeline.replaceWith( + converter.toReplaceWithOptions(expression as Map)); + case 'find_nearest': + return pipeline.findNearest(converter.toFindNearestOptions(map)); + case 'union': + final pipelineStages = map['pipeline'] as List>; + final otherPipeline = + buildPipelineFromStages(jsFirestore, pipelineStages); + return pipeline.union(otherPipeline); + default: + throw FirebaseException( + plugin: 'cloud_firestore', + code: 'unknown-pipeline-stage', + message: 'Unknown pipeline stage: $name', + ); + } +} diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart new file mode 100644 index 000000000000..2be532475506 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_expression_parser_web.dart @@ -0,0 +1,661 @@ +// ignore_for_file: require_trailing_commas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart' + show Blob, GeoPoint, Timestamp, VectorValue; +import 'package:cloud_firestore_web/src/interop/firestore_interop.dart' + as interop; +import 'package:cloud_firestore_web/src/interop/utils/utils.dart'; +import 'package:firebase_core/firebase_core.dart'; + +/// Converts Dart serialized pipeline expressions/stage args into JS pipeline +/// types by calling the pipelines interop API (field, constant, equal, and, +/// ascending, etc.) that mirrors the Firebase JS SDK. +class PipelineExpressionParserWeb { + PipelineExpressionParserWeb(this._pipelines, this._jsFirestore); + + final interop.PipelinesJsImpl _pipelines; + final interop.FirestoreJsImpl _jsFirestore; + + static const _kName = 'name'; + static const _kArgs = 'args'; + static const _kLeft = 'left'; + static const _kRight = 'right'; + static const _kExpression = 'expression'; + static const _kField = 'field'; + static const _kAlias = 'alias'; + static const _kValue = 'value'; + + // ── Value expressions ───────────────────────────────────────────────────── + + /// Converts a serialized value expression to a JS Expression. + interop.ExpressionJsImpl toExpression(Map map) { + final name = map[_kName] as String?; + final argsMap = _argsOf(map); + switch (name) { + case 'field': + return _pipelines.field(((argsMap[_kField] as String?) ?? '').toJS); + case 'add': + return _binaryArithmetic(argsMap, (l, r) => l.add(r)); + case 'subtract': + return _binaryArithmetic(argsMap, (l, r) => l.subtract(r)); + case 'multiply': + return _binaryArithmetic(argsMap, (l, r) => l.multiply(r)); + case 'divide': + return _binaryArithmetic(argsMap, (l, r) => l.divide(r)); + case 'modulo': + return _binaryArithmetic(argsMap, (l, r) => l.modulo(r)); + case 'constant': + case 'null': + return _pipelines.constant(_constantValueToJs(argsMap[_kValue])); + case 'concat': + final expressions = argsMap['expressions'] as List?; + if (expressions == null || expressions.isEmpty) { + throw UnsupportedError('concat requires at least one expression'); + } + interop.ExpressionJsImpl result = + toExpression(expressions[0] as Map); + for (var i = 1; i < expressions.length; i++) { + result = result + .concat(toExpression(expressions[i] as Map)); + } + return result; + case 'length': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .length(); + case 'to_lower_case': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .toLower(); + case 'to_upper_case': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .toUpper(); + case 'trim': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .trim(); + case 'substring': + return _pipelines.substring( + _expr(argsMap, _kExpression), + _expr(argsMap, 'start'), + _expr(argsMap, 'end'), + ); + case 'string_replace_all': + return _pipelines.stringReplaceAll( + _expr(argsMap, _kExpression), + _expr(argsMap, 'find'), + _expr(argsMap, 'replacement'), + ); + case 'split': + return _pipelines.split( + _expr(argsMap, _kExpression), + _expr(argsMap, 'delimiter'), + ); + case 'join': + return _pipelines.join( + _expr(argsMap, _kExpression), + _expr(argsMap, 'delimiter'), + ); + case 'if_absent': + return _pipelines.ifAbsent( + _expr(argsMap, _kExpression), + _expr(argsMap, 'else'), + ); + case 'if_error': + return _pipelines.ifError( + _expr(argsMap, _kExpression), + _expr(argsMap, 'catch'), + ); + case 'conditional': + return _pipelines.conditional( + toBooleanExpression(argsMap['condition'] as Map)!, + _expr(argsMap, 'then'), + _expr(argsMap, 'else'), + ); + case 'document_id': + final pathArg = argsMap[_kExpression]; + return _pipelines + .documentId(toExpression(pathArg as Map)); + case 'document_id_from_ref': + final path = argsMap['doc_ref'] as String?; + if (path == null || path.isEmpty) { + throw ArgumentError( + "document_id_from_ref requires a non-empty 'doc_ref' path"); + } + final docRef = interop.doc(_jsFirestore as JSAny, path.toJS); + return _pipelines.documentId(docRef); + case 'collection_id': + return _pipelines.collectionId(_expr(argsMap, _kExpression)); + case 'map_get': + { + final keyExprMap = argsMap['key'] as Map?; + if (keyExprMap == null) { + throw ArgumentError("map_get requires a 'key' argument"); + } + final keyString = _constantStringFromExpression(keyExprMap); + if (keyString == null) { + throw UnsupportedError( + 'mapGet on web only supports a constant string key '); + } + return _pipelines.mapGet( + _expr(argsMap, 'map'), + keyString.toJS, + ); + } + case 'map_keys': + return _pipelines.mapKeys(_expr(argsMap, _kExpression)); + case 'map_values': + return _pipelines.mapValues(_expr(argsMap, _kExpression)); + case 'current_timestamp': + return _pipelines.currentTimestamp(); + case 'timestamp_add': + return _pipelines.timestampAdd( + _expr(argsMap, 'timestamp'), + (argsMap['unit'] as String).toJS, + _expr(argsMap, 'amount'), + ); + case 'timestamp_subtract': + return _pipelines.timestampSubtract( + _expr(argsMap, 'timestamp'), + (argsMap['unit'] as String).toJS, + _expr(argsMap, 'amount'), + ); + case 'timestamp_truncate': + final tz = argsMap['timezone'] as String?; + return _pipelines.timestampTruncate( + _expr(argsMap, 'timestamp'), + (argsMap['unit'] as String).toJS, + tz?.toJS, + ); + case 'abs': + return _pipelines.abs(_expr(argsMap, _kExpression)); + case 'array_length': + return _pipelines.arrayLength(_expr(argsMap, _kExpression)); + case 'array_sum': + return _pipelines.arraySum(_expr(argsMap, _kExpression)); + case 'array_concat': + return _pipelines.arrayConcat( + _expr(argsMap, 'first'), + _expr(argsMap, 'second'), + ); + case 'array_concat_multiple': + final arrays = argsMap['arrays'] as List?; + if (arrays == null || arrays.length < 2) { + throw UnsupportedError( + 'array_concat_multiple requires at least two arrays'); + } + var arrResult = _pipelines.arrayConcat( + toExpression(arrays[0] as Map), + toExpression(arrays[1] as Map), + ); + for (var i = 2; i < arrays.length; i++) { + arrResult = _pipelines.arrayConcat( + arrResult, + toExpression(arrays[i] as Map), + ); + } + return arrResult; + case 'array_reverse': + return (_expr(argsMap, _kExpression) as interop.ExpressionJsImpl) + .arrayReverse(); + case 'array': + final elements = argsMap['elements'] as List?; + if (elements == null) { + throw UnsupportedError('array requires elements'); + } + final jsElements = elements + .map((e) => toExpression(e as Map)) + .toList() + .toJS; + return _pipelines.array(jsElements); + case 'map': + final data = argsMap['data'] as Map?; + if (data == null) { + throw UnsupportedError('map requires data'); + } + final m = {}; + for (final entry in data.entries) { + m[entry.key] = toExpression(entry.value as Map); + } + return _pipelines.map(jsify(m)! as JSObject); + default: + throw FirebaseException( + plugin: 'cloud_firestore', + code: 'unsupported-expression', + message: + "The pipeline expression '$name' is not supported on the web " + 'platform. The Firebase JS SDK may not expose this expression.', + ); + } + } + + // ── Boolean expressions ─────────────────────────────────────────────────── + + /// Converts a serialized boolean expression to a JS BooleanExpression. + /// + /// Returns null if [map] is not a recognized boolean expression. + JSAny? toBooleanExpression(Map map) { + final name = map[_kName] as String?; + final argsMap = _argsOf(map); + switch (name) { + case 'equal': + return _pipelines.equal( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'not_equal': + return _pipelines.notEqual( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'greater_than': + return _pipelines.greaterThan( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'greater_than_or_equal': + return _pipelines.greaterThanOrEqual( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'less_than': + return _pipelines.lessThan( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'less_than_or_equal': + return _pipelines.lessThanOrEqual( + _expr(argsMap, _kLeft), _expr(argsMap, _kRight)); + case 'and': + case 'or': + case 'xor': + final exprMaps = argsMap['expressions'] as List?; + if (exprMaps == null || exprMaps.isEmpty) return null; + final exprs = exprMaps + .map((e) => toBooleanExpression(e as Map)) + .whereType() + .toList(); + if (exprs.isEmpty) return null; + var result = exprs.first; + for (var i = 1; i < exprs.length; i++) { + if (name == 'and') { + result = _pipelines.and(result, exprs[i]); + } else if (name == 'or') { + result = _pipelines.or(result, exprs[i]); + } else { + result = _pipelines.xor(result, exprs[i]); + } + } + return result; + case 'not': + final expr = argsMap[_kExpression] as Map; + final boolExpr = toBooleanExpression(expr) as JSAny; + return _pipelines.not(boolExpr); + case 'exists': + return _pipelines.exists(_expr(argsMap, _kExpression)); + case 'is_absent': + return _pipelines.isAbsent(_expr(argsMap, _kExpression)); + case 'is_error': + return _pipelines.isError(_expr(argsMap, _kExpression)); + case 'array_contains': + return _pipelines.arrayContains( + _expr(argsMap, 'array'), _expr(argsMap, 'element')); + case 'array_contains_any': + final valuesMaps = argsMap['values'] as List?; + if (valuesMaps == null || valuesMaps.isEmpty) return null; + final valuesJs = valuesMaps + .map((v) => toExpression(v as Map)) + .toList() + .toJS; + return _pipelines.arrayContainsAny(_expr(argsMap, 'array'), valuesJs); + case 'array_contains_all': + final arrayExpr = _expr(argsMap, 'array'); + final valuesArg = argsMap['values'] as List?; + final arrayExpressionArg = argsMap['array_expression']; + if (valuesArg != null && valuesArg.isNotEmpty) { + final valuesJs = valuesArg + .map((v) => toExpression(v as Map)) + .toList() + .toJS; + return _pipelines.arrayContainsAll(arrayExpr, valuesJs); + } + if (arrayExpressionArg != null) { + return _pipelines.arrayContainsAll(arrayExpr, + toExpression(arrayExpressionArg as Map)); + } + return null; + case 'equal_any': + final valueExpr = _expr(argsMap, 'value'); + final valuesMaps = argsMap['values'] as List?; + if (valuesMaps == null || valuesMaps.isEmpty) return null; + final valuesJs = valuesMaps + .map((v) => toExpression(v as Map)) + .toList() + .toJS; + return _pipelines.equalAny(valueExpr, valuesJs); + case 'not_equal_any': + final valueExpr = _expr(argsMap, 'value'); + final valuesMaps = argsMap['values'] as List?; + if (valuesMaps == null || valuesMaps.isEmpty) return null; + final valuesJs = valuesMaps + .map((v) => toExpression(v as Map)) + .toList() + .toJS; + return _pipelines.notEqualAny(valueExpr, valuesJs); + case 'filter': + return _buildFilterExpression(argsMap); + default: + throw FirebaseException( + plugin: 'cloud_firestore', + code: 'unsupported-boolean-expression', + message: "The boolean expression '$name' is not supported on the web " + 'platform. The Firebase JS SDK may not expose this expression.', + ); + } + } + + // ── Stage options ───────────────────────────────────────────────────────── + + /// Converts orderings list to JS SortStageOptions. + /// + /// Each item shape: `{ expression: Map, order_direction: 'asc' | 'desc' }`. + JSAny toSortOptions(List orderings) { + final list = []; + for (final o in orderings) { + final m = o is Map ? o : {}; + final expr = m[_kExpression]; + if (expr == null) continue; + final exprJs = toExpression(expr as Map); + final dir = m['order_direction'] as String?; + list.add(dir == 'desc' + ? _pipelines.descending(exprJs) + : _pipelines.ascending(exprJs)); + } + if (list.isEmpty) { + throw UnsupportedError( + 'Pipeline sort() on web requires the Firebase JS pipeline expression API ' + '(ascending, descending). Ensure the pipelines module is loaded.', + ); + } + return interop.SortStageOptionsJsImpl()..orderings = list.toJS; + } + + /// Converts a single expression map to a JS Selectable (field or aliased). + JSAny toSelectable(Map map) { + final name = map[_kName] as String?; + final argsMap = _argsOf(map); + if (name == 'field') { + return _pipelines.field(((argsMap[_kField] as String?) ?? '').toJS); + } + if (name == _kAlias) { + final alias = argsMap[_kAlias] as String; + final expression = argsMap[_kExpression]; + return toExpression(expression as Map) + .asAlias(alias.toJS); + } + return toExpression(map); + } + + /// Converts add_fields expressions to JS AddFieldsStageOptions. + JSAny toAddFieldsOptions(List expressions) => + interop.AddFieldsOptionsJsImpl() + ..fields = _toSelectableList(expressions).toJS; + + /// Converts select stage expressions to JS SelectStageOptions. + JSAny toSelectOptions(List expressions) => + interop.SelectStageOptionsJsImpl() + ..selections = _toSelectableList(expressions).toJS; + + /// Converts distinct stage groups to JS DistinctStageOptions. + JSAny toDistinctOptions(List expressions) { + final list = _toSelectableList(expressions); + if (list.isEmpty) { + throw UnsupportedError( + 'Pipeline distinct() on web requires the Firebase JS pipeline expression API.', + ); + } + return interop.DistinctStageOptionsJsImpl()..groups = list.toJS; + } + + // ── Aggregate ───────────────────────────────────────────────────────────── + + /// Converts args for the 'aggregate' stage to JS AggregateStageOptions. + /// + /// Expects [map] to contain an [aggregate_functions] list. + interop.AggregateStageOptionsJsImpl toAggregateOptionsFromFunctions( + Map map) { + final list = map['aggregate_functions'] as List; + return _buildAccumulators(list); + } + + /// Converts args for the 'aggregate_with_options' stage to JS AggregateStageOptions. + /// + /// Expects [map] to contain an [aggregate_stage] map with [accumulators] + /// and optionally [groups]. + interop.AggregateStageOptionsJsImpl toAggregateOptionsFromStageAndOptions( + Map map) { + final stage = map['aggregate_stage'] as Map; + final list = stage['accumulators'] as List; + final groups = stage['groups'] as List?; + return _buildAccumulators(list, groups: groups); + } + + // ── Other stage options ─────────────────────────────────────────────────── + + /// Converts sample stage args to JS (integer count or SampleStageOptions). + /// + /// Dart serializes as `{ type: 'size', value: n }` or a raw number. + JSAny toSampleOptions(Map map) { + final args = map['type'] as String; + if (args == 'size') { + final value = map['value'] as num; + return interop.SampleStageOptionsJsImpl()..documents = value.toInt().toJS; + } else { + final value = map['value'] as num; + return interop.SampleStageOptionsJsImpl() + ..percentage = value.toDouble().toJS; + } + } + + /// Converts unnest stage args to JS UnnestStageOptions. + JSAny toUnnestOptions(Map map) { + final expression = map[_kExpression] as Map; + final indexField = map['index_field'] as String?; + final sel = toSelectable(expression); + return interop.UnnestStageOptionsJsImpl() + ..selectable = sel + ..indexField = indexField?.toJS; + } + + /// Converts remove_fields field paths to JS RemoveFieldsStageOptions. + JSAny toRemoveFieldsOptions(List fieldPaths) { + final paths = []; + for (final e in fieldPaths) { + final s = e is String + ? e + : (e is Map ? e[_kField] ?? e['path'] : null)?.toString(); + if (s != null) paths.add(s.toJS); + } + return interop.RemoveFieldsStageOptionsJsImpl()..fields = paths.toJS; + } + + /// Converts replace_with expression to JS ReplaceWithStageOptions. + JSAny toReplaceWithOptions(Map expression) { + return interop.ReplaceWithStageOptionsJsImpl() + ..map = toExpression(expression); + } + + /// Converts find_nearest args to JS FindNearestStageOptions. + interop.FindNearestStageOptionsJsImpl toFindNearestOptions( + Map map) { + final vectorField = + (map['vector_field'] as String?) ?? (map[_kField] as String?); + final vectorValue = map['vector_value'] as List?; + final distanceMeasure = (map['distance_measure'] as String?) ?? 'cosine'; + final limit = map['limit'] as int?; + final distanceField = map['distance_field'] as String?; + if (vectorField == null || vectorValue == null) { + throw UnsupportedError( + 'Pipeline findNearest() on web requires vector_field and vector_value.', + ); + } + final doubles = vectorValue.map((e) => (e as num).toDouble()).toList(); + final opts = interop.FindNearestStageOptionsJsImpl() + ..field = _pipelines.field(vectorField.toJS) + ..vectorValue = interop.vector(doubles.jsify()! as JSArray) + ..distanceMeasure = distanceMeasure.toJS; + if (limit != null) opts.limit = limit.toJS; + if (distanceField != null) opts.distanceField = distanceField.toJS; + return opts; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /// Converts a [Constant] value to the correct JS type for the pipelines API. + /// + /// Each Dart type accepted by [Constant] is mapped to the corresponding + /// Firestore JS SDK interop type so that the JS SDK receives a properly typed + /// value (e.g. a JS `Timestamp`, `GeoPoint`, or `Bytes` object) rather than + /// a plain JS primitive or an unrecognised object. + JSAny? _constantValueToJs(Object? value) { + if (value == null) return null; + if (value is String) return value.toJS; + if (value is bool) return value.toJS; + if (value is int) return value.toJS; + if (value is double) return value.toJS; + if (value is DateTime) { + return interop.TimestampJsImpl.fromMillis( + value.millisecondsSinceEpoch.toJS) as JSAny; + } + + if (value is Timestamp) { + // Use seconds + nanoseconds directly to preserve sub-millisecond precision. + return interop.TimestampJsImpl(value.seconds.toJS, value.nanoseconds.toJS) + as JSAny; + } + if (value is GeoPoint) { + return interop.GeoPointJsImpl(value.latitude.toJS, value.longitude.toJS) + as JSAny; + } + if (value is Blob) { + return interop.BytesJsImpl.fromUint8Array(value.bytes.toJS) as JSAny; + } + if (value is List) { + return interop.BytesJsImpl.fromUint8Array(Uint8List.fromList(value).toJS) + as JSAny; + } + if (value is VectorValue) { + return interop.vector(value.toArray().jsify()! as JSArray) as JSAny; + } + if (value is Map) { + final path = value['path'] as String; + return interop.doc(_jsFirestore as JSAny, path.toJS) as JSAny; + } + return jsify(value); + } + + /// Extracts and safe-casts the 'args' sub-map from an expression map. + static Map _argsOf(Map map) { + final a = map[_kArgs]; + return a is Map ? a : const {}; + } + + /// Returns the string value if [exprMap] is a constant string expression; + /// otherwise null. + String? _constantStringFromExpression(Map exprMap) { + if ((exprMap[_kName] as String?) != 'constant') return null; + final args = _argsOf(exprMap); + final value = args[_kValue]; + return value is String ? value : null; + } + + /// Resolves [key] from [argsMap] as a value expression. + JSAny _expr(Map argsMap, String key) => + toExpression(argsMap[key] as Map); + + interop.ExpressionJsImpl _binaryArithmetic( + Map argsMap, + interop.ExpressionJsImpl Function( + interop.ExpressionJsImpl left, interop.ExpressionJsImpl right) + op, + ) => + op( + toExpression(argsMap[_kLeft] as Map), + toExpression(argsMap[_kRight] as Map), + ); + + JSAny? _buildFilterExpression(Map argsMap) { + final operator = argsMap['operator'] as String?; + final expressions = argsMap['expressions'] as List?; + if (expressions == null || expressions.isEmpty) return null; + final jsList = expressions + .map((e) => toExpression(e as Map)) + .toList(); + if (jsList.length == 1) return jsList.single; + JSAny result = jsList[0]; + for (var i = 1; i < jsList.length; i++) { + result = operator == 'and' + ? _pipelines.and(result, jsList[i]) + : _pipelines.or(result, jsList[i]); + } + return result; + } + + List _toSelectableList(List expressions) => expressions + .map((e) => + toSelectable(e is Map ? e : {})) + .whereType() + .toList(); + + interop.AggregateStageOptionsJsImpl _buildAccumulators( + List items, { + List? groups, + }) { + final accumulators = items + .map((item) => _parseAccumulator(item as Map)) + .whereType() + .toList(); + + if (accumulators.isEmpty) { + throw UnsupportedError( + 'Pipeline aggregate() on web requires at least one valid accumulator.', + ); + } + + final opts = interop.AggregateStageOptionsJsImpl() + ..accumulators = accumulators.toJS; + if (groups != null && groups.isNotEmpty) { + opts.groups = _toSelectableList(groups).toJS; + } + return opts; + } + + JSAny? _parseAccumulator(Map item) { + final args = item[_kArgs] as Map; + final alias = args[_kAlias] as String?; + final aggregateFn = args['aggregate_function'] as Map?; + if (alias == null || aggregateFn == null) return null; + final fnName = aggregateFn[_kName] as String?; + if (fnName == null) return null; + final expressionMap = (aggregateFn[_kArgs] + as Map?)?[_kExpression] as Map?; + final exprJs = expressionMap != null ? toExpression(expressionMap) : null; + return _buildAggregateFunction(fnName, exprJs)?.asAlias(alias.toJS); + } + + /// Builds one JS aggregate function from a serialized [name] and optional [exprJs]. + interop.AggregateFunctionJsImpl? _buildAggregateFunction( + String name, JSAny? exprJs) { + switch (name) { + case 'count_all': + return _pipelines.countAll(); + case 'sum': + return exprJs != null ? _pipelines.sum(exprJs) : null; + case 'average': + return exprJs != null ? _pipelines.average(exprJs) : null; + case 'count': + return exprJs != null ? _pipelines.count(exprJs) : null; + case 'count_distinct': + return exprJs != null ? _pipelines.countDistinct(exprJs) : null; + case 'minimum': + return exprJs != null ? _pipelines.minimum(exprJs) : null; + case 'maximum': + return exprJs != null ? _pipelines.maximum(exprJs) : null; + default: + return null; + } + } +} diff --git a/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_web.dart b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_web.dart new file mode 100644 index 000000000000..5872ce7c5aaa --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore_web/lib/src/pipeline_web.dart @@ -0,0 +1,110 @@ +// ignore_for_file: require_trailing_commas +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:js_interop'; + +import 'package:cloud_firestore_platform_interface/cloud_firestore_platform_interface.dart'; +import 'package:cloud_firestore_web/src/interop/utils/utils.dart'; + +import 'document_reference_web.dart'; +import 'interop/firestore.dart' as firestore_interop; +import 'interop/firestore_interop.dart' as interop; + +/// Web implementation of [PipelinePlatform]. +class PipelineWeb extends PipelinePlatform { + final firestore_interop.Firestore _firestoreWeb; + + PipelineWeb( + FirebaseFirestorePlatform firestore, + this._firestoreWeb, + List>? stages, + ) : super(firestore, stages); + + @override + PipelinePlatform addStage(Map serializedStage) { + return PipelineWeb( + firestore, + _firestoreWeb, + [...stages, serializedStage], + ); + } + + @override + Future execute({ + Map? options, + }) async { + return firestore.executePipeline(stages, options: options); + } +} + +/// Web implementation of [PipelineSnapshotPlatform]. +class PipelineSnapshotWeb extends PipelineSnapshotPlatform { + PipelineSnapshotWeb(this._results, this._executionTime) : super(); + + final List _results; + final DateTime _executionTime; + + @override + List get results => _results; + + @override + DateTime get executionTime => _executionTime; +} + +/// Web implementation of [PipelineResultPlatform]. +class PipelineResultWeb extends PipelineResultPlatform { + PipelineResultWeb( + FirebaseFirestorePlatform firestore, + firestore_interop.Firestore firestoreWeb, + interop.PipelineResultJsImpl jsResult, + ) : _document = jsResult.ref != null + ? DocumentReferenceWeb( + firestore, + firestoreWeb, + jsResult.ref!.path.toDart, + ) + : null, + _createTime = _timestampToDateTime(jsResult.createTime), + _updateTime = _timestampToDateTime(jsResult.updateTime), + _data = _dataFromResult(jsResult), + super(); + + final DocumentReferencePlatform? _document; + final DateTime? _createTime; + final DateTime? _updateTime; + final Map? _data; + + static Map? _dataFromResult( + interop.PipelineResultJsImpl jsResult) { + final d = jsResult.data(); + if (d == null) return null; + final parsed = dartify(d); + return parsed != null + ? Map.from(parsed as Map) + : null; + } + + static DateTime? _timestampToDateTime(JSAny? value) { + if (value == null) return null; + final d = dartify(value); + if (d == null) return null; + if (d is DateTime) return d; + if (d is Timestamp) return d.toDate(); + if (d is int) return DateTime.fromMillisecondsSinceEpoch(d); + return null; + } + + @override + DocumentReferencePlatform? get document => _document; + + @override + DateTime? get createTime => _createTime; + + @override + DateTime? get updateTime => _updateTime; + + @override + Map? get data => _data; +} diff --git a/packages/firebase_core/firebase_core_web/lib/src/firebase_core_web.dart b/packages/firebase_core/firebase_core_web/lib/src/firebase_core_web.dart index b70f503d9ff3..a3ae6f933db6 100644 --- a/packages/firebase_core/firebase_core_web/lib/src/firebase_core_web.dart +++ b/packages/firebase_core/firebase_core_web/lib/src/firebase_core_web.dart @@ -214,6 +214,17 @@ class FirebaseCoreWeb extends FirebasePlatform { return Future.value(); } + const firestoreServiceName = 'firestore'; + + if (service.name == firestoreServiceName) { + // Inject the Firestore Pipelines script. This bundle supports both + // Pipeline operations (Enterprise edition) and standard Firestore queries. + return injectSrcScript( + 'https://www.gstatic.com/firebasejs/$version/firebase-firestore-pipelines.js', + 'firebase_$firestoreServiceName', + ); + } + return injectSrcScript( 'https://www.gstatic.com/firebasejs/$version/firebase-${service.name}.js', 'firebase_${service.override ?? service.name}',