Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,8 @@ Expression parseExpression(@NonNull Map<String, Object> 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);
Expand Down Expand Up @@ -542,6 +544,8 @@ BooleanExpression parseBooleanExpression(@NonNull Map<String, Object> 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) {
Expand All @@ -564,6 +568,14 @@ Selectable parseSelectable(@NonNull Map<String, Object> expressionMap) {
return (Selectable) expr;
}

private BooleanExpression parseDocumentMatches(@NonNull Map<String, Object> 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<String, Object> aggregateMap) {
String functionName = (String) aggregateMap.get("function");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -335,4 +338,83 @@ private Pipeline handleFindNearest(
return pipeline.findNearest(fieldExpr, vectorArray, distanceMeasure);
}
}

@SuppressWarnings("unchecked")
private Pipeline handleSearch(@NonNull Pipeline pipeline, @Nullable Map<String, Object> 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<String, Object>) query);
searchStage = SearchStage.withQuery(expressionQuery);
} else {
throw new IllegalArgumentException(
"'search' requires query_type to be either 'string' or 'expression'");
}

List<Map<String, Object>> sortMaps = (List<Map<String, Object>>) 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<Map<String, Object>> addFieldMaps = (List<Map<String, Object>>) 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<String, Object> orderingMap) {
Expression expression =
parsers.parseExpression((Map<String, Object>) orderingMap.get("expression"));
String direction = (String) orderingMap.get("order_direction");
return "asc".equals(direction) ? expression.ascending() : expression.descending();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
25 changes: 25 additions & 0 deletions packages/cloud_firestore/cloud_firestore/lib/src/pipeline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String, dynamic> toMap() {
return {
'name': name,
'args': {
'query': query,
},
};
}
}

// ============================================================================
// PATTERN DEMONSTRATION - Concrete Function Expression Classes
// ============================================================================
Expand Down
111 changes: 111 additions & 0 deletions packages/cloud_firestore/cloud_firestore/lib/src/pipeline_search.dart
Original file line number Diff line number Diff line change
@@ -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<Ordering>? _sort;
final List<Selectable>? _addFields;
final String? _languageCode;
final int? _limit;
final int? _offset;
final int? _retrievalDepth;

SearchStage._({
required _SearchQueryType queryType,
required Object query,
List<Ordering>? sort,
List<Selectable>? 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<Ordering>? sort,
List<Selectable>? 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<Ordering>? sort,
List<Selectable>? 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<String, dynamic> toMap() {
final args = <String, dynamic>{
'query_type': _queryType.name,
'query': _query is Expression ? _query.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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic> toMap() {
return {
'stage': name,
'args': searchStage.toMap(),
};
}
}

/// Stage for limiting results
final class _LimitStage extends PipelineStage {
final int limit;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +48,7 @@ void main() {
runPipelineUnnestUnionTests();
runPipelineSampleTests();
runPipelineFindNearestTests();
runPipelineSearchTests();
runPipelineExpressionsTests();
});
}
Loading
Loading