From bea85c3321af98464084d44e7311dfa9b398a797 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 25 May 2026 10:59:31 +0000 Subject: [PATCH 1/5] feat(firestore): add support for search in firestore pipeline --- .../cloud_firestore/lib/cloud_firestore.dart | 1 + .../cloud_firestore/lib/src/pipeline.dart | 25 ++++ .../lib/src/pipeline_search.dart | 111 ++++++++++++++++++ .../lib/src/pipeline_stage.dart | 18 +++ .../test/pipeline_expression_test.dart | 8 ++ .../test/pipeline_stage_test.dart | 74 ++++++++++++ 6 files changed, 237 insertions(+) create mode 100644 packages/cloud_firestore/cloud_firestore/lib/src/pipeline_search.dart diff --git a/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart b/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart index 69c3a95dce00..1fcccbee21bc 100755 --- a/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/cloud_firestore.dart @@ -65,6 +65,7 @@ part 'src/pipeline_execute_options.dart'; part 'src/pipeline_expression.dart'; part 'src/pipeline_ordering.dart'; part 'src/pipeline_sample.dart'; +part 'src/pipeline_search.dart'; part 'src/pipeline_source.dart'; part 'src/pipeline_stage.dart'; part 'src/query.dart'; diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart index 6e54e253172c..55ca21d79f47 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart @@ -433,6 +433,31 @@ class Pipeline { ); } + /// Adds a search stage to this pipeline. + /// + /// Search stages execute full-text search or geo search operations. A search + /// stage must be the first stage after the pipeline source. + /// + /// Example: + /// ```dart + /// firestore.pipeline().collection('restaurants').search( + /// SearchStage.withQuery('breakfast -diner', limit: 10), + /// ); + /// ``` + Pipeline search(SearchStage searchStage) { + if (_delegate.stages.length != 1) { + throw StateError( + 'A search stage must be the first stage after the pipeline source.', + ); + } + + final stage = _SearchStage(searchStage); + return Pipeline._( + _firestore, + _delegate.addStage(stage.toMap()), + ); + } + /// Limits the maximum number of documents returned by previous stages to /// [limit]. /// diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_search.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_search.dart new file mode 100644 index 000000000000..222ff50f5142 --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_search.dart @@ -0,0 +1,111 @@ +// 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'; + +enum _SearchQueryType { + string, + expression, +} + +/// Specifies how a pipeline search stage is performed. +/// +/// Search stages must be the first stage after a pipeline source. +final class SearchStage implements PipelineSerializable { + final _SearchQueryType _queryType; + final Object _query; + final List? _sort; + final List? _addFields; + final String? _languageCode; + final int? _limit; + final int? _offset; + final int? _retrievalDepth; + + SearchStage._({ + required _SearchQueryType queryType, + required Object query, + List? sort, + List? addFields, + String? languageCode, + int? limit, + int? offset, + int? retrievalDepth, + }) : _queryType = queryType, + _query = query, + _sort = sort, + _addFields = addFields, + _languageCode = languageCode, + _limit = limit, + _offset = offset, + _retrievalDepth = retrievalDepth; + + /// Creates a search stage from a raw query string. + SearchStage.withQuery( + String query, { + List? sort, + List? addFields, + String? languageCode, + int? limit, + int? offset, + int? retrievalDepth, + }) : this._( + queryType: _SearchQueryType.string, + query: query, + sort: sort, + addFields: addFields, + languageCode: languageCode, + limit: limit, + offset: offset, + retrievalDepth: retrievalDepth, + ); + + /// Creates a search stage from a search query expression. + SearchStage.withQueryExpression( + BooleanExpression query, { + List? sort, + List? addFields, + String? languageCode, + int? limit, + int? offset, + int? retrievalDepth, + }) : this._( + queryType: _SearchQueryType.expression, + query: query, + sort: sort, + addFields: addFields, + languageCode: languageCode, + limit: limit, + offset: offset, + retrievalDepth: retrievalDepth, + ); + + @override + Map toMap() { + final args = { + 'query_type': _queryType.name, + 'query': _query is Expression ? (_query as Expression).toMap() : _query, + }; + + if (_sort != null) { + args['sort'] = _sort.map((ordering) => ordering.toMap()).toList(); + } + if (_addFields != null) { + args['add_fields'] = _addFields.map((field) => field.toMap()).toList(); + } + if (_languageCode != null) { + args['language_code'] = _languageCode; + } + if (_limit != null) { + args['limit'] = _limit; + } + if (_offset != null) { + args['offset'] = _offset; + } + if (_retrievalDepth != null) { + args['retrieval_depth'] = _retrievalDepth; + } + + return args; + } +} diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart index abc1555f8854..e6eaf8df20f5 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_stage.dart @@ -207,6 +207,24 @@ final class _FindNearestStage extends PipelineStage { } } +/// Stage for full-text or geo search. +final class _SearchStage extends PipelineStage { + final SearchStage searchStage; + + _SearchStage(this.searchStage); + + @override + String get name => 'search'; + + @override + Map toMap() { + return { + 'stage': name, + 'args': searchStage.toMap(), + }; + } +} + /// Stage for limiting results final class _LimitStage extends PipelineStage { final int limit; diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart index dbd4f1729424..f5a6c6dc1a0e 100644 --- a/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_expression_test.dart @@ -194,6 +194,14 @@ void main() { 'args': {'value': 100}, }); }); + + test('Expression.documentMatches() serializes search query', () { + final expr = Expression.documentMatches('breakfast -diner'); + expect(expr.toMap(), { + 'name': 'document_matches', + 'args': {'query': 'breakfast -diner'}, + }); + }); }); group('BooleanExpression from Field', () { diff --git a/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart b/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart index 876d53e368e2..41a6fba094db 100644 --- a/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart +++ b/packages/cloud_firestore/cloud_firestore/test/pipeline_stage_test.dart @@ -398,6 +398,80 @@ void main() { }); }); + group('_SearchStage', () { + test('serializes search with string query', () { + final pipeline = firestore.pipeline().collection('restaurants').search( + SearchStage.withQuery( + 'breakfast -diner', + limit: 10, + offset: 2, + retrievalDepth: 100, + languageCode: 'en', + ), + ); + final stage = pipeline.stages.last; + expect(stage['stage'], 'search'); + expect(stage['args']['query_type'], 'string'); + expect(stage['args']['query'], 'breakfast -diner'); + expect(stage['args']['limit'], 10); + expect(stage['args']['offset'], 2); + expect(stage['args']['retrieval_depth'], 100); + expect(stage['args']['language_code'], 'en'); + }); + + test('serializes search with query expression', () { + final pipeline = firestore.pipeline().collection('restaurants').search( + SearchStage.withQueryExpression( + Expression.documentMatches('waffles OR pancakes'), + ), + ); + final stage = pipeline.stages.last; + expect(stage['stage'], 'search'); + expect(stage['args']['query_type'], 'expression'); + expect(stage['args']['query'], { + 'name': 'document_matches', + 'args': {'query': 'waffles OR pancakes'}, + }); + }); + + test('serializes search sort and add fields', () { + final pipeline = firestore.pipeline().collection('restaurants').search( + SearchStage.withQuery( + 'breakfast', + sort: [Field('rating').descending()], + addFields: [Field('name')], + ), + ); + final stage = pipeline.stages.last; + expect(stage['args']['sort'], [ + { + 'expression': { + 'name': 'field', + 'args': {'field': 'rating'}, + }, + 'order_direction': 'desc', + }, + ]); + expect(stage['args']['add_fields'], [ + { + 'name': 'field', + 'args': {'field': 'name'}, + }, + ]); + }); + + test('throws if search is not first stage after source', () { + expect( + () => firestore + .pipeline() + .collection('restaurants') + .limit(10) + .search(SearchStage.withQuery('breakfast')), + throwsStateError, + ); + }); + }); + group('_UnionStage', () { test('serializes union stage with nested pipeline stages', () { final innerPipeline = firestore.pipeline().collection('archived_users'); From 74fcec2e091b653cd1008a027212a475277e51a3 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 25 May 2026 12:49:32 +0000 Subject: [PATCH 2/5] chore: add document_matches expression for full document search --- .../lib/src/pipeline_expression.dart | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart index 4f8f85e49c1e..b5f604f45d74 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_expression.dart @@ -846,6 +846,12 @@ abstract class Expression implements PipelineSerializable { /// Creates a null value expression static Expression nullValue() => _NullExpression(); + /// Creates a search expression that matches the whole document against + /// [query]. + static BooleanExpression documentMatches(String query) { + return _DocumentMatchesExpression(query); + } + /// Creates a conditional (ternary) expression static Expression conditional( BooleanExpression condition, @@ -1962,6 +1968,26 @@ class _TrimExpression extends FunctionExpression { /// Base class for boolean expressions used in filtering abstract class BooleanExpression extends Expression {} +/// Represents a document_matches search expression. +class _DocumentMatchesExpression extends BooleanExpression { + final String query; + + _DocumentMatchesExpression(this.query); + + @override + String get name => 'document_matches'; + + @override + Map toMap() { + return { + 'name': name, + 'args': { + 'query': query, + }, + }; + } +} + // ============================================================================ // PATTERN DEMONSTRATION - Concrete Function Expression Classes // ============================================================================ From 2c0d69916bff29f1e22f0f5261bd4b15178ed0ad Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 25 May 2026 13:07:18 +0000 Subject: [PATCH 3/5] fix: correct method call for query serialization in SearchStage --- .../cloud_firestore/lib/src/pipeline_search.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_search.dart b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_search.dart index 222ff50f5142..fc5994a74d1b 100644 --- a/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_search.dart +++ b/packages/cloud_firestore/cloud_firestore/lib/src/pipeline_search.dart @@ -84,7 +84,7 @@ final class SearchStage implements PipelineSerializable { Map toMap() { final args = { 'query_type': _queryType.name, - 'query': _query is Expression ? (_query as Expression).toMap() : _query, + 'query': _query is Expression ? _query.toMap() : _query, }; if (_sort != null) { From 67968a31bdf482e3ab2654ddc3d9b7a966ca4c80 Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Mon, 25 May 2026 15:00:56 +0000 Subject: [PATCH 4/5] chore: implement search functionality in native Android and added e2e tests --- .../firestore/utils/ExpressionParsers.java | 12 +++ .../utils/PipelineStageHandlers.java | 82 +++++++++++++++++++ .../pipeline/pipeline_live_test.dart | 2 + .../pipeline/pipeline_search_e2e.dart | 71 ++++++++++++++++ .../pipeline/pipeline_seed.dart | 14 ++++ 5 files changed, 181 insertions(+) create mode 100644 packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_search_e2e.dart diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java index f27d5fd2a42b..28eb0f68faa0 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/ExpressionParsers.java @@ -423,6 +423,8 @@ Expression parseExpression(@NonNull Map expressionMap) { return parseArrayContainsAll(args); case "array_contains_any": return parseArrayContainsAny(args); + case "document_matches": + return parseDocumentMatches(args); default: Log.w(TAG, "Unsupported expression type: " + name); throw new UnsupportedOperationException("Expression type not yet implemented: " + name); @@ -542,6 +544,8 @@ BooleanExpression parseBooleanExpression(@NonNull Map expression return parseNotEqualAny(args); case "as_boolean": return parseAsBoolean(args); + case "document_matches": + return parseDocumentMatches(args); default: Expression expr = parseExpression(expressionMap); if (expr instanceof BooleanExpression) { @@ -564,6 +568,14 @@ Selectable parseSelectable(@NonNull Map expressionMap) { return (Selectable) expr; } + private BooleanExpression parseDocumentMatches(@NonNull Map args) { + String query = (String) args.get("query"); + if (query == null) { + throw new IllegalArgumentException("document_matches requires a 'query' argument"); + } + return Expression.documentMatches(query); + } + @SuppressWarnings("unchecked") AggregateFunction parseAggregateFunction(@NonNull Map aggregateMap) { String functionName = (String) aggregateMap.get("function"); diff --git a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java index cc0e6b0c35e6..78222ecef7e9 100644 --- a/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java +++ b/packages/cloud_firestore/cloud_firestore/android/src/main/java/io/flutter/plugins/firebase/firestore/utils/PipelineStageHandlers.java @@ -20,6 +20,7 @@ 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.SearchStage; import com.google.firebase.firestore.pipeline.Selectable; import com.google.firebase.firestore.pipeline.UnnestOptions; import java.util.List; @@ -71,6 +72,8 @@ Pipeline applyStage( return handleSample(pipeline, args); case "find_nearest": return handleFindNearest(pipeline, args); + case "search": + return handleSearch(pipeline, args); default: throw new IllegalArgumentException("Unknown pipeline stage: " + stageName); } @@ -335,4 +338,83 @@ private Pipeline handleFindNearest( return pipeline.findNearest(fieldExpr, vectorArray, distanceMeasure); } } + + @SuppressWarnings("unchecked") + private Pipeline handleSearch(@NonNull Pipeline pipeline, @Nullable Map args) { + if (args == null) { + throw new IllegalArgumentException("'search' requires arguments"); + } + + String queryType = (String) args.get("query_type"); + Object query = args.get("query"); + SearchStage searchStage; + if ("string".equals(queryType)) { + searchStage = SearchStage.withQuery((String) query); + } else if ("expression".equals(queryType)) { + BooleanExpression expressionQuery = + parsers.parseBooleanExpression((Map) query); + searchStage = SearchStage.withQuery(expressionQuery); + } else { + throw new IllegalArgumentException( + "'search' requires query_type to be either 'string' or 'expression'"); + } + + List> sortMaps = (List>) args.get("sort"); + if (sortMaps != null && !sortMaps.isEmpty()) { + Ordering firstOrdering = parseOrdering(sortMaps.get(0)); + if (sortMaps.size() == 1) { + searchStage = searchStage.withSort(firstOrdering); + } else { + Ordering[] additionalOrderings = new Ordering[sortMaps.size() - 1]; + for (int i = 1; i < sortMaps.size(); i++) { + additionalOrderings[i - 1] = parseOrdering(sortMaps.get(i)); + } + searchStage = searchStage.withSort(firstOrdering, additionalOrderings); + } + } + + List> addFieldMaps = (List>) args.get("add_fields"); + if (addFieldMaps != null && !addFieldMaps.isEmpty()) { + Selectable firstField = parsers.parseSelectable(addFieldMaps.get(0)); + if (addFieldMaps.size() == 1) { + searchStage = searchStage.withAddFields(firstField); + } else { + Selectable[] additionalFields = new Selectable[addFieldMaps.size() - 1]; + for (int i = 1; i < addFieldMaps.size(); i++) { + additionalFields[i - 1] = parsers.parseSelectable(addFieldMaps.get(i)); + } + searchStage = searchStage.withAddFields(firstField, additionalFields); + } + } + + String languageCode = (String) args.get("language_code"); + if (languageCode != null) { + searchStage = searchStage.withLanguageCode(languageCode); + } + + Number limit = (Number) args.get("limit"); + if (limit != null) { + searchStage = searchStage.withLimit(limit.longValue()); + } + + Number offset = (Number) args.get("offset"); + if (offset != null) { + searchStage = searchStage.withOffset(offset.longValue()); + } + + Number retrievalDepth = (Number) args.get("retrieval_depth"); + if (retrievalDepth != null) { + searchStage = searchStage.withRetrievalDepth(retrievalDepth.longValue()); + } + + return pipeline.search(searchStage); + } + + @SuppressWarnings("unchecked") + private Ordering parseOrdering(@NonNull Map orderingMap) { + Expression expression = + parsers.parseExpression((Map) orderingMap.get("expression")); + String direction = (String) orderingMap.get("order_direction"); + return "asc".equals(direction) ? expression.ascending() : expression.descending(); + } } diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_live_test.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_live_test.dart index b7a977b6e8ad..0caf3d47b194 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_live_test.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_live_test.dart @@ -18,6 +18,7 @@ import 'pipeline_find_nearest_e2e.dart'; import 'pipeline_remove_fields_e2e.dart'; import 'pipeline_replace_with_e2e.dart'; import 'pipeline_sample_e2e.dart'; +import 'pipeline_search_e2e.dart'; import 'pipeline_seed.dart'; import 'pipeline_select_e2e.dart'; import 'pipeline_unnest_union_e2e.dart'; @@ -47,6 +48,7 @@ void main() { runPipelineUnnestUnionTests(); runPipelineSampleTests(); runPipelineFindNearestTests(); + runPipelineSearchTests(); runPipelineExpressionsTests(); }); } diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_search_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_search_e2e.dart new file mode 100644 index 000000000000..6b96f2d52aab --- /dev/null +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_search_e2e.dart @@ -0,0 +1,71 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void runPipelineSearchTests() { + group('Pipeline search', () { + late FirebaseFirestore firestore; + + setUpAll(() { + firestore = FirebaseFirestore.instanceFor( + app: Firebase.app(), + databaseId: 'firestore-pipeline-test', + ); + }); + + test('withQuery returns matching search results', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .search(SearchStage.withQuery('pancakes', limit: 10)) + .where(Expression.field('test').equalValue('search')) + .execute(); + + expect(_resultNames(snapshot), contains('Pancake House')); + }); + + test('withQueryExpression returns matching search results', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .search( + SearchStage.withQueryExpression( + Expression.documentMatches('pancakes'), + limit: 10, + ), + ) + .where(Expression.field('test').equalValue('search')) + .execute(); + + expect(_resultNames(snapshot), contains('Pancake House')); + }); + + test('withQueryExpression supports combined document match queries', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .search( + SearchStage.withQueryExpression( + Expression.and( + Expression.documentMatches('pancakes'), + Expression.documentMatches('breakfast'), + ), + limit: 10, + ), + ) + .where(Expression.field('test').equalValue('search')) + .execute(); + + expect(_resultNames(snapshot), contains('Pancake House')); + }); + }); +} + +List _resultNames(PipelineSnapshot snapshot) { + return snapshot.result.map((result) => result.data()?['name']).toList(); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart index 7df0783a15c4..69167f910f77 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart @@ -98,6 +98,20 @@ Future seedPipelineE2ECollections(FirebaseFirestore firestore) async { 'label': 'far', }, ]), + ..._withTest('search', [ + { + 'name': 'Pancake House', + 'description': 'waffles pancakes breakfast', + }, + { + 'name': 'Burger Diner', + 'description': 'burgers fries lunch', + }, + { + 'name': 'Coffee Bar', + 'description': 'coffee breakfast pastries', + }, + ]), ..._withTest('expressions', [ { 'score': 60, From 5478507e7363a8ac878c12b958f85b823afcdcde Mon Sep 17 00:00:00 2001 From: Jude Kwashie Date: Tue, 26 May 2026 14:21:21 +0000 Subject: [PATCH 5/5] chore: add support for Pipeline's search api on web --- .../pipeline/pipeline_search_e2e.dart | 69 ++++++++++++++--- .../pipeline/pipeline_seed.dart | 15 +--- .../lib/src/interop/firestore_interop.dart | 21 ++++++ .../lib/src/pipeline_builder_web.dart | 2 + .../src/pipeline_expression_parser_web.dart | 75 ++++++++++++++++--- 5 files changed, 149 insertions(+), 33 deletions(-) diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_search_e2e.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_search_e2e.dart index 6b96f2d52aab..aebfaac0ca67 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_search_e2e.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_search_e2e.dart @@ -28,33 +28,41 @@ void runPipelineSearchTests() { expect(_resultNames(snapshot), contains('Pancake House')); }); - test('withQueryExpression returns matching search results', () async { + test('withQuery passes options and returns expected result list', () async { final snapshot = await firestore .pipeline() .collection('pipeline-e2e') .search( - SearchStage.withQueryExpression( - Expression.documentMatches('pancakes'), + SearchStage.withQuery( + 'breakfast', + languageCode: 'en', + retrievalDepth: 10, + offset: 0, limit: 10, + addFields: [Field('name').as('resultName')], ), ) .where(Expression.field('test').equalValue('search')) .execute(); - expect(_resultNames(snapshot), contains('Pancake House')); + expect(_sortedResultValues(snapshot, 'name'), [ + 'Coffee Bar', + 'Pancake House', + ]); + expect(_sortedResultValues(snapshot, 'resultName'), [ + 'Coffee Bar', + 'Pancake House', + ]); + expect(_resultNames(snapshot), isNot(contains('Burger Diner'))); }); - test('withQueryExpression supports combined document match queries', - () async { + test('withQueryExpression returns matching search results', () async { final snapshot = await firestore .pipeline() .collection('pipeline-e2e') .search( SearchStage.withQueryExpression( - Expression.and( - Expression.documentMatches('pancakes'), - Expression.documentMatches('breakfast'), - ), + Expression.documentMatches('pancakes'), limit: 10, ), ) @@ -63,9 +71,50 @@ void runPipelineSearchTests() { expect(_resultNames(snapshot), contains('Pancake House')); }); + + test( + 'withQueryExpression supports combined document match queries', + () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .search( + SearchStage.withQueryExpression( + Expression.and( + Expression.documentMatches('pancakes'), + Expression.documentMatches('breakfast'), + ), + limit: 10, + ), + ) + .where(Expression.field('test').equalValue('search')) + .execute(); + + expect(_resultNames(snapshot), contains('Pancake House')); + }, + ); + + test('withQuery returns empty results when nothing matches', () async { + final snapshot = await firestore + .pipeline() + .collection('pipeline-e2e') + .search(SearchStage.withQuery('No match', limit: 10)) + .where(Expression.field('test').equalValue('search')) + .execute(); + + expect(snapshot.result, isEmpty); + }); }); } List _resultNames(PipelineSnapshot snapshot) { return snapshot.result.map((result) => result.data()?['name']).toList(); } + +List _sortedResultValues(PipelineSnapshot snapshot, String field) { + return snapshot.result + .map((result) => result.data()?[field]) + .whereType() + .toList() + ..sort(); +} diff --git a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart index 69167f910f77..31485942e34c 100644 --- a/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart +++ b/packages/cloud_firestore/cloud_firestore/pipeline_example/integration_test/pipeline/pipeline_seed.dart @@ -99,18 +99,9 @@ Future seedPipelineE2ECollections(FirebaseFirestore firestore) async { }, ]), ..._withTest('search', [ - { - 'name': 'Pancake House', - 'description': 'waffles pancakes breakfast', - }, - { - 'name': 'Burger Diner', - 'description': 'burgers fries lunch', - }, - { - 'name': 'Coffee Bar', - 'description': 'coffee breakfast pastries', - }, + {'name': 'Pancake House', 'description': 'waffles pancakes breakfast'}, + {'name': 'Burger Diner', 'description': 'burgers fries lunch'}, + {'name': 'Coffee Bar', 'description': 'coffee breakfast pastries'}, ]), ..._withTest('expressions', [ { 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 a4069ebab335..7041c3787879 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 @@ -366,6 +366,7 @@ extension type PipelinesJsImpl._(JSObject _) implements JSObject { external JSAny or(JSAny a, JSAny b); external JSAny xor(JSAny a, JSAny b); external JSAny not(JSAny expr); + external JSAny documentMatches(JSString query); // --- Existence / type checks --- external JSAny exists(JSAny expr); @@ -1234,6 +1235,7 @@ extension type PipelineJsImpl._(JSObject _) implements JSObject { external PipelineJsImpl removeFields(JSAny fieldOrOptions); external PipelineJsImpl replaceWith(JSAny fieldNameOrOptions); external PipelineJsImpl findNearest(JSAny options); + external PipelineJsImpl search(JSAny options); external PipelineJsImpl union(JSAny otherOrOptions); external PipelineJsImpl rawStage(JSString name, JSArray params, [JSAny? options]); @@ -1331,6 +1333,25 @@ extension type FindNearestStageOptionsJsImpl._(JSObject _) implements JSObject { external set distanceField(JSString value); } +extension type SearchStageOptionsJsImpl._(JSObject _) implements JSObject { + SearchStageOptionsJsImpl() : this._(JSObject.new()); + + // ignore: avoid_setters_without_getters + external set query(JSAny value); + // ignore: avoid_setters_without_getters + external set languageCode(JSString value); + // ignore: avoid_setters_without_getters + external set retrievalDepth(JSNumber value); + // ignore: avoid_setters_without_getters + external set sort(JSAny value); + // ignore: avoid_setters_without_getters + external set offset(JSNumber value); + // ignore: avoid_setters_without_getters + external set limit(JSNumber value); + // ignore: avoid_setters_without_getters + external set addFields(JSAny value); +} + extension type PipelineExecuteOptionsJsImpl._(JSObject _) implements JSObject { PipelineExecuteOptionsJsImpl() : this._(JSObject.new()); 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 index bc2db69e575d..81e181372491 100644 --- 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 @@ -122,6 +122,8 @@ interop.PipelineJsImpl _applyStage( converter.toReplaceWithOptions(expression as Map)); case 'find_nearest': return pipeline.findNearest(converter.toFindNearestOptions(map)); + case 'search': + return pipeline.search(converter.toSearchOptions(map)); case 'union': final pipelineStages = map['pipeline'] as List>; final otherPipeline = 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 index 722c28095d02..70989d311b84 100644 --- 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 @@ -426,6 +426,12 @@ class PipelineExpressionParserWeb { throw UnsupportedError('not requires a boolean expression'); } return _pipelines.not(boolExpr); + case 'document_matches': + final query = argsMap['query'] as String?; + if (query == null) { + throw UnsupportedError('document_matches requires query'); + } + return _pipelines.documentMatches(query.toJS); case 'exists': return _pipelines.exists(_expr(argsMap, _kExpression)); case 'is_absent': @@ -470,17 +476,7 @@ class PipelineExpressionParserWeb { /// /// 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)); - } + final list = _toOrderingList(orderings); if (list.isEmpty) { throw UnsupportedError( 'Pipeline sort() on web requires the Firebase JS pipeline expression API ' @@ -619,6 +615,48 @@ class PipelineExpressionParserWeb { return opts; } + /// Converts search stage args to JS SearchStageOptions. + interop.SearchStageOptionsJsImpl toSearchOptions(Map map) { + final queryType = map['query_type'] as String?; + final query = map['query']; + final opts = interop.SearchStageOptionsJsImpl(); + + if (queryType == 'string') { + opts.query = (query as String).toJS; + } else if (queryType == 'expression') { + opts.query = toBooleanExpression(query as Map)!; + } else { + throw UnsupportedError( + "Pipeline search() on web requires query_type 'string' or 'expression'.", + ); + } + + final languageCode = map['language_code'] as String?; + if (languageCode != null) opts.languageCode = languageCode.toJS; + + final retrievalDepth = map['retrieval_depth'] as int?; + if (retrievalDepth != null) opts.retrievalDepth = retrievalDepth.toJS; + + final offset = map['offset'] as int?; + if (offset != null) opts.offset = offset.toJS; + + final limit = map['limit'] as int?; + if (limit != null) opts.limit = limit.toJS; + + final sort = map['sort'] as List?; + if (sort != null && sort.isNotEmpty) { + final orderings = _toOrderingList(sort); + if (orderings.isNotEmpty) opts.sort = orderings.toJS; + } + + final addFields = map['add_fields'] as List?; + if (addFields != null && addFields.isNotEmpty) { + opts.addFields = _toSelectableList(addFields).toJS; + } + + return opts; + } + // ── Private helpers ─────────────────────────────────────────────────────── /// Converts a [Constant] value to the correct JS type for the pipelines API. @@ -773,6 +811,21 @@ class PipelineExpressionParserWeb { .whereType() .toList(); + List _toOrderingList(List orderings) { + final list = []; + for (final ordering in orderings) { + final orderingMap = Map.from(ordering as Map); + final expr = orderingMap[_kExpression]; + if (expr == null) continue; + final exprJs = toExpression(expr as Map); + final dir = orderingMap['order_direction'] as String?; + list.add(dir == 'desc' + ? _pipelines.descending(exprJs) + : _pipelines.ascending(exprJs)); + } + return list; + } + interop.AggregateStageOptionsJsImpl _buildAccumulators( List items, { List? groups,