options) {
+ this(new FunctionExpression(name, params, options));
+ }
+
@Override
Value toProto() {
return expr.toProto();
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java
index 695051c208..89cc366b0d 100644
--- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java
@@ -120,7 +120,7 @@ public static Expression constant(Timestamp value) {
*/
@BetaApi
public static BooleanExpression constant(Boolean value) {
- return equal(new Constant(value), true);
+ return new BooleanConstant(new Constant(value));
}
/**
@@ -3390,6 +3390,155 @@ public static BooleanExpression isError(Expression expr) {
return new BooleanFunctionExpression("is_error", expr);
}
+ /**
+ * Evaluates to the distance in meters between the location in the specified field and the query
+ * location.
+ *
+ * This Expression can only be used within a {@code Search} stage.
+ *
+ * @param fieldName Specifies the field in the document which contains the first {@link GeoPoint}
+ * for distance computation.
+ * @param location Compute distance to this {@link GeoPoint}.
+ * @return A new {@link Expression} representing the geoDistance operation.
+ */
+ @BetaApi
+ public static Expression geoDistance(String fieldName, GeoPoint location) {
+ return geoDistance(field(fieldName), location);
+ }
+
+ /**
+ * Evaluates to the distance in meters between the location in the specified field and the query
+ * location.
+ *
+ *
This Expression can only be used within a {@code Search} stage.
+ *
+ * @param field Specifies the field in the document which contains the first {@link GeoPoint} for
+ * distance computation.
+ * @param location Compute distance to this {@link GeoPoint}.
+ * @return A new {@link Expression} representing the geoDistance operation.
+ */
+ @BetaApi
+ public static Expression geoDistance(Field field, GeoPoint location) {
+ return new FunctionExpression("geo_distance", java.util.Arrays.asList(field, constant(location)));
+ }
+
+ /**
+ * Perform a full-text search on all indexed search fields in the document.
+ *
+ *
This Expression can only be used within a {@code Search} stage.
+ *
+ * @param rquery Define the search query using the search DTS.
+ * @return A new {@link BooleanExpression} representing the documentMatches operation.
+ */
+ @BetaApi
+ public static BooleanExpression documentMatches(String rquery) {
+ return new BooleanFunctionExpression("document_matches", constant(rquery));
+ }
+
+ /**
+ * Perform a full-text search on the specified field.
+ *
+ *
This Expression can only be used within a {@code Search} stage.
+ *
+ * @param fieldName Perform search on this field.
+ * @param rquery Define the search query using the rquery DTS.
+ */
+ @InternalApi
+ static BooleanExpression matches(String fieldName, String rquery) {
+ return matches(field(fieldName), rquery);
+ }
+
+ /**
+ * Perform a full-text search on the specified field.
+ *
+ *
This Expression can only be used within a {@code Search} stage.
+ *
+ * @param field Perform search on this field.
+ * @param rquery Define the search query using the rquery DTS.
+ */
+ @InternalApi
+ static BooleanExpression matches(Field field, String rquery) {
+ return new BooleanFunctionExpression("matches", field, constant(rquery));
+ }
+
+ /**
+ * Evaluates to the search score that reflects the topicality of the document to all of the text
+ * predicates ({@code matches} and {@code documentMatches}) in the search query.
+ *
+ *
This Expression can only be used within a {@code Search} stage.
+ *
+ * @return A new {@link Expression} representing the score operation.
+ */
+ @BetaApi
+ public static Expression score() {
+ return new FunctionExpression("score", com.google.common.collect.ImmutableList.of());
+ }
+
+ /**
+ * Evaluates to an HTML-formatted text snippet that highlights terms matching the search query in
+ * {@code bold}.
+ *
+ *
This Expression can only be used within a {@code Search} stage.
+ *
+ * @param fieldName Search the specified field for matching terms.
+ * @param rquery Define the search query using the search DTS.
+ * @return A new {@link Expression} representing the snippet operation.
+ */
+ @BetaApi
+ public static Expression snippet(String fieldName, String rquery) {
+ return new FunctionExpression(
+ "snippet", java.util.Arrays.asList(field(fieldName), constant(rquery)));
+ }
+
+ /**
+ * Evaluates to an HTML-formatted text snippet that highlights terms matching the search query in
+ * {@code bold}.
+ *
+ *
This Expression can only be used within a {@code Search} stage.
+ *
+ * @param rquery Define the search query using the search DTS.
+ * @return A new {@link Expression} representing the snippet operation.
+ */
+ @BetaApi
+ public final Expression snippet(String rquery) {
+ return new FunctionExpression(
+ "snippet",
+ java.util.Arrays.asList(this, constant(rquery)),
+ java.util.Collections.singletonMap(
+ "query", com.google.cloud.firestore.PipelineUtils.encodeValue(rquery)));
+ }
+
+ @InternalApi
+ static BooleanExpression between(String fieldName, Expression lowerBound, Expression upperBound) {
+ return between(field(fieldName), lowerBound, upperBound);
+ }
+
+ @InternalApi
+ static BooleanExpression between(String fieldName, Object lowerBound, Object upperBound) {
+ return between(fieldName, toExprOrConstant(lowerBound), toExprOrConstant(upperBound));
+ }
+
+ @InternalApi
+ static BooleanExpression between(
+ Expression expression, Expression lowerBound, Expression upperBound) {
+ return new BooleanFunctionExpression("between", expression, lowerBound, upperBound);
+ }
+
+ @InternalApi
+ static BooleanExpression between(Expression expression, Object lowerBound, Object upperBound) {
+ return between(expression, toExprOrConstant(lowerBound), toExprOrConstant(upperBound));
+ }
+
+ @InternalApi
+ public final BooleanExpression between(Expression lowerBound, Expression upperBound) {
+ return Expression.between(this, lowerBound, upperBound);
+ }
+
+ @InternalApi
+ public final BooleanExpression between(Object lowerBound, Object upperBound) {
+ return Expression.between(this, lowerBound, upperBound);
+ }
+
// Other Utility Functions
/**
* Creates an expression that returns the document ID from a path.
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Field.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Field.java
index 0b5729040f..77079b1388 100644
--- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Field.java
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Field.java
@@ -90,6 +90,32 @@ public Value toProto() {
return Value.newBuilder().setFieldReferenceValue(path.toString()).build();
}
+ /**
+ * Evaluates to the distance in meters between the location specified by this field and the query
+ * location.
+ *
+ *
This Expression can only be used within a {@code Search} stage.
+ *
+ * @param location Compute distance to this {@link com.google.cloud.firestore.GeoPoint}.
+ * @return A new {@link Expression} representing the geoDistance operation.
+ */
+ @BetaApi
+ public Expression geoDistance(com.google.cloud.firestore.GeoPoint location) {
+ return Expression.geoDistance(this, location);
+ }
+
+ /**
+ * Perform a full-text search on this field.
+ *
+ *
This Expression can only be used within a {@code Search} stage.
+ *
+ * @param rquery Define the search query using the rquery DTS.
+ */
+ @InternalApi
+ BooleanExpression matches(String rquery) {
+ return Expression.matches(this, rquery);
+ }
+
@Override
public boolean equals(Object o) {
if (this == o) {
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/FunctionExpression.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/FunctionExpression.java
index eacbf953e3..b2ad7afbf1 100644
--- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/FunctionExpression.java
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/FunctionExpression.java
@@ -28,10 +28,17 @@
public class FunctionExpression extends Expression {
private final String name;
private final List params;
+ private final java.util.Map options;
FunctionExpression(String name, List extends Expression> params) {
+ this(name, params, java.util.Collections.emptyMap());
+ }
+
+ FunctionExpression(
+ String name, List extends Expression> params, java.util.Map options) {
this.name = name;
- this.params = Collections.unmodifiableList(params);
+ this.params = java.util.Collections.unmodifiableList(params);
+ this.options = java.util.Collections.unmodifiableMap(options);
}
@InternalApi
@@ -44,7 +51,8 @@ Value toProto() {
.addAllArgs(
this.params.stream()
.map(FunctionUtils::exprToValue)
- .collect(Collectors.toList())))
+ .collect(Collectors.toList()))
+ .putAllOptions(this.options))
.build();
}
@@ -53,11 +61,13 @@ public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
FunctionExpression that = (FunctionExpression) o;
- return Objects.equal(name, that.name) && Objects.equal(params, that.params);
+ return java.util.Objects.equals(name, that.name)
+ && java.util.Objects.equals(params, that.params)
+ && java.util.Objects.equals(options, that.options);
}
@Override
public int hashCode() {
- return Objects.hashCode(name, params);
+ return java.util.Objects.hash(name, params, options);
}
}
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Selectable.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Selectable.java
index cf7bc71c05..053a97f174 100644
--- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Selectable.java
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Selectable.java
@@ -19,4 +19,24 @@
import com.google.api.core.BetaApi;
@BetaApi
-public interface Selectable {}
+public interface Selectable {
+ /**
+ * Converts an object to a {@link Selectable}.
+ *
+ * @param o The object to convert. Supported types: {@link Selectable}, {@link String}, {@link
+ * com.google.cloud.firestore.FieldPath}.
+ * @return The converted {@link Selectable}.
+ */
+ @com.google.api.core.InternalApi
+ static Selectable toSelectable(Object o) {
+ if (o instanceof Selectable) {
+ return (Selectable) o;
+ } else if (o instanceof String) {
+ return Expression.field((String) o);
+ } else if (o instanceof com.google.cloud.firestore.FieldPath) {
+ return Expression.field((com.google.cloud.firestore.FieldPath) o);
+ } else {
+ throw new IllegalArgumentException("Unknown Selectable type: " + o.getClass().getName());
+ }
+ }
+}
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java
new file mode 100644
index 0000000000..1e9f96e876
--- /dev/null
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.firestore.pipeline.stages;
+
+import static com.google.cloud.firestore.PipelineUtils.encodeValue;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.documentMatches;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.field;
+
+import com.google.api.core.BetaApi;
+import com.google.api.core.InternalApi;
+import com.google.cloud.firestore.PipelineUtils;
+import com.google.cloud.firestore.pipeline.expressions.BooleanExpression;
+import com.google.cloud.firestore.pipeline.expressions.Ordering;
+import com.google.cloud.firestore.pipeline.expressions.Selectable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.firestore.v1.Value;
+import java.util.Arrays;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * The Search stage executes full-text search or geo search operations.
+ *
+ * The Search stage must be the first stage in a Pipeline.
+ */
+@BetaApi
+public final class Search extends Stage {
+
+ /**
+ * Specifies if the `matches` and `snippet` expressions will enhance the user provided query to
+ * perform matching of synonyms, misspellings, lemmatization, stemming.
+ */
+ public static final class QueryEnhancement {
+ final String protoString;
+
+ private QueryEnhancement(String protoString) {
+ this.protoString = protoString;
+ }
+
+ /**
+ * Search will fall back to the un-enhanced, user provided query, if the query enhancement fails.
+ */
+ public static final QueryEnhancement PREFERRED = new QueryEnhancement("preferred");
+
+ /**
+ * Search will fail if the query enhancement times out or if the query enhancement is not
+ * supported by the project's DRZ compliance requirements.
+ */
+ public static final QueryEnhancement REQUIRED = new QueryEnhancement("required");
+
+ /** Search will use the un-enhanced, user provided query. */
+ public static final QueryEnhancement DISABLED = new QueryEnhancement("disabled");
+
+ Value toProto() {
+ return encodeValue(protoString);
+ }
+ }
+
+ @InternalApi
+ public Search(InternalOptions options) {
+ super("search", options);
+ }
+
+ /**
+ * Create {@link Search} with an expression search query.
+ *
+ *
{@code query} specifies the search query that will be used to query and score documents by
+ * the search stage.
+ */
+ public static Search withQuery(BooleanExpression query) {
+ return new Search(InternalOptions.of("query", encodeValue(query)));
+ }
+
+ /**
+ * Create {@link Search} with an expression search query.
+ *
+ *
{@code query} specifies the search query that will be used to query and score documents by
+ * the search stage.
+ */
+ public static Search withQuery(String rquery) {
+ return withQuery(documentMatches(rquery));
+ }
+
+ /** Specify the fields to add to each document. */
+ public Search withAddFields(Selectable field, Selectable... additionalFields) {
+ Selectable[] allFields = new Selectable[additionalFields.length + 1];
+ allFields[0] = field;
+ System.arraycopy(additionalFields, 0, allFields, 1, additionalFields.length);
+ Map map =
+ PipelineUtils.selectablesToMap(allFields).entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, e -> encodeValue(e.getValue())));
+ return new Search(options.with("add_fields", encodeValue(map)));
+ }
+
+ /** Specify the fields to keep or add to each document. */
+ public Search withSelect(Selectable selection, Object... additionalSelections) {
+ Selectable[] allSelections = new Selectable[additionalSelections.length + 1];
+ allSelections[0] = selection;
+ for (int i = 0; i < additionalSelections.length; i++) {
+ allSelections[i + 1] = Selectable.toSelectable(additionalSelections[i]);
+ }
+ Map map =
+ PipelineUtils.selectablesToMap(allSelections).entrySet().stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, e -> encodeValue(e.getValue())));
+ return new Search(options.with("select", encodeValue(map)));
+ }
+
+ /** Specify the fields to keep or add to each document. */
+ public Search withSelect(String fieldName, Object... additionalSelections) {
+ return withSelect(field(fieldName), additionalSelections);
+ }
+
+ /** Specify how the returned documents are sorted. One or more ordering are required. */
+ public Search withSort(Ordering order, Ordering... additionalOrderings) {
+ Ordering[] allOrderings = new Ordering[additionalOrderings.length + 1];
+ allOrderings[0] = order;
+ System.arraycopy(additionalOrderings, 0, allOrderings, 1, additionalOrderings.length);
+ return new Search(
+ options.with(
+ "sort", Lists.transform(Arrays.asList(allOrderings), Ordering::toProto)));
+ }
+
+ /** Specify the maximum number of documents to return from the Search stage. */
+ public Search withLimit(long limit) {
+ return new Search(options.with("limit", encodeValue(limit)));
+ }
+
+ /**
+ * Specify the maximum number of documents for the search stage to score. Documents will be
+ * processed in the pre-sort order specified by the search index.
+ */
+ public Search withRetrievalDepth(long retrievalDepth) {
+ return new Search(options.with("retrieval_depth", encodeValue(retrievalDepth)));
+ }
+
+ /** Specify the number of documents to skip. */
+ public Search withOffset(long offset) {
+ return new Search(options.with("offset", encodeValue(offset)));
+ }
+
+ /** Specify the BCP-47 language code of text in the search query, such as, “en-US” or “sr-Latn” */
+ public Search withLanguageCode(String value) {
+ return new Search(options.with("language_code", encodeValue(value)));
+ }
+
+ /**
+ * Specify the query expansion behavior used by full-text search expressions in this search stage.
+ * Default: {@code .PREFERRED}
+ */
+ public Search withQueryEnhancement(QueryEnhancement queryEnhancement) {
+ return new Search(options.with("query_enhancement", queryEnhancement.toProto()));
+ }
+
+ @Override
+ Iterable toStageArgs() {
+ return ImmutableList.of();
+ }
+}
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Stage.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Stage.java
index 8f82195f72..820a9f9012 100644
--- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Stage.java
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Stage.java
@@ -31,7 +31,8 @@ public abstract class Stage {
this.options = options;
}
- final Pipeline.Stage toStageProto() {
+ @com.google.api.core.InternalApi
+ public final Pipeline.Stage toStageProto() {
return Pipeline.Stage.newBuilder()
.setName(name)
.addAllArgs(toStageArgs())
diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/PipelineProtoTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/PipelineProtoTest.java
new file mode 100644
index 0000000000..805aa034d9
--- /dev/null
+++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/PipelineProtoTest.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.firestore;
+
+import static com.google.cloud.firestore.pipeline.expressions.Expression.constant;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.field;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.firestore.pipeline.stages.Search;
+import com.google.firestore.v1.Pipeline.Stage;
+import com.google.firestore.v1.Value;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class PipelineProtoTest {
+
+ @Test
+ public void testSearchStageProtoEncoding() {
+ FirestoreOptions options =
+ FirestoreOptions.newBuilder()
+ .setProjectId("new-project")
+ .setDatabaseId("(default)")
+ .build();
+ Firestore firestore = options.getService();
+
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection("foo")
+ .search(
+ Search.withQuery("foo")
+ .withLimit(1)
+ .withRetrievalDepth(2)
+ .withOffset(3)
+ .withQueryEnhancement(Search.QueryEnhancement.REQUIRED)
+ .withLanguageCode("en-US")
+ .withSort(field("foo").ascending())
+ .withAddFields(constant(true).as("bar"))
+ .withSelect(field("id")));
+
+ com.google.firestore.v1.Pipeline protoPipeline = pipeline.toProto();
+ assertThat(protoPipeline.getStagesCount()).isEqualTo(2);
+
+ Stage collectionStage = protoPipeline.getStages(0);
+ assertThat(collectionStage.getName()).isEqualTo("collection");
+ assertThat(collectionStage.getArgs(0).getReferenceValue()).isEqualTo("/foo");
+
+ Stage searchStage = protoPipeline.getStages(1);
+ assertThat(searchStage.getName()).isEqualTo("search");
+
+ java.util.Map optionsMap = searchStage.getOptionsMap();
+
+ // query
+ Value query = optionsMap.get("query");
+ assertThat(query).isNotNull();
+ assertThat(query.getFunctionValue().getName()).isEqualTo("document_matches");
+ assertThat(query.getFunctionValue().getArgs(0).getStringValue()).isEqualTo("foo");
+
+ // limit
+ assertThat(optionsMap.get("limit").getIntegerValue()).isEqualTo(1L);
+
+ // retrieval_depth
+ assertThat(optionsMap.get("retrieval_depth").getIntegerValue()).isEqualTo(2L);
+
+ // offset
+ assertThat(optionsMap.get("offset").getIntegerValue()).isEqualTo(3L);
+
+ // query_enhancement
+ assertThat(optionsMap.get("query_enhancement").getStringValue()).isEqualTo("required");
+
+ // language_code
+ assertThat(optionsMap.get("language_code").getStringValue()).isEqualTo("en-US");
+
+ // select
+ Value select = optionsMap.get("select");
+ assertThat(select.getMapValue().getFieldsMap().get("id").getFieldReferenceValue())
+ .isEqualTo("id");
+
+ // sort
+ Value sort = optionsMap.get("sort");
+ java.util.Map sortEntry =
+ sort.getArrayValue().getValues(0).getMapValue().getFieldsMap();
+ assertThat(sortEntry.get("direction").getStringValue()).isEqualTo("ascending");
+ assertThat(sortEntry.get("expression").getFieldReferenceValue()).isEqualTo("foo");
+
+ // add_fields
+ Value addFields = optionsMap.get("add_fields");
+ assertThat(addFields.getMapValue().getFieldsMap().get("bar").getBooleanValue()).isTrue();
+ }
+}
From b002cebca0c45ab9eb648bd6a2109fb1d62c2f43 Mon Sep 17 00:00:00 2001
From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com>
Date: Mon, 23 Mar 2026 17:07:21 -0600
Subject: [PATCH 2/4] Spotless and tests
---
.../pipeline/expressions/Expression.java | 3 +-
.../expressions/FunctionExpression.java | 2 -
.../firestore/pipeline/stages/Search.java | 6 +-
.../firestore/it/ITPipelineSearchTest.java | 567 ++++++++++++++++++
4 files changed, 572 insertions(+), 6 deletions(-)
create mode 100644 google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java
index 89cc366b0d..caa1fe55ef 100644
--- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Expression.java
@@ -3419,7 +3419,8 @@ public static Expression geoDistance(String fieldName, GeoPoint location) {
*/
@BetaApi
public static Expression geoDistance(Field field, GeoPoint location) {
- return new FunctionExpression("geo_distance", java.util.Arrays.asList(field, constant(location)));
+ return new FunctionExpression(
+ "geo_distance", java.util.Arrays.asList(field, constant(location)));
}
/**
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/FunctionExpression.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/FunctionExpression.java
index b2ad7afbf1..fc01d51398 100644
--- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/FunctionExpression.java
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/FunctionExpression.java
@@ -18,9 +18,7 @@
import com.google.api.core.BetaApi;
import com.google.api.core.InternalApi;
-import com.google.common.base.Objects;
import com.google.firestore.v1.Value;
-import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java
index 1e9f96e876..d69d15bd16 100644
--- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java
@@ -53,7 +53,8 @@ private QueryEnhancement(String protoString) {
}
/**
- * Search will fall back to the un-enhanced, user provided query, if the query enhancement fails.
+ * Search will fall back to the un-enhanced, user provided query, if the query enhancement
+ * fails.
*/
public static final QueryEnhancement PREFERRED = new QueryEnhancement("preferred");
@@ -131,8 +132,7 @@ public Search withSort(Ordering order, Ordering... additionalOrderings) {
allOrderings[0] = order;
System.arraycopy(additionalOrderings, 0, allOrderings, 1, additionalOrderings.length);
return new Search(
- options.with(
- "sort", Lists.transform(Arrays.asList(allOrderings), Ordering::toProto)));
+ options.with("sort", Lists.transform(Arrays.asList(allOrderings), Ordering::toProto)));
}
/** Specify the maximum number of documents to return from the Search stage. */
diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java
new file mode 100644
index 0000000000..c32e74d618
--- /dev/null
+++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java
@@ -0,0 +1,567 @@
+/*
+ * Copyright 2025 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.cloud.firestore.it;
+
+import static com.google.cloud.firestore.it.ITQueryTest.map;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.and;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.concat;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.constant;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.documentMatches;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.field;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.greaterThanOrEqual;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.lessThanOrEqual;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.score;
+import static com.google.cloud.firestore.pipeline.expressions.Expression.snippet;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.firestore.CollectionReference;
+import com.google.cloud.firestore.GeoPoint;
+import com.google.cloud.firestore.LocalFirestoreHelper;
+import com.google.cloud.firestore.Pipeline;
+import com.google.cloud.firestore.PipelineResult;
+import com.google.cloud.firestore.WriteBatch;
+import com.google.cloud.firestore.pipeline.stages.Search;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ITPipelineSearchTest extends ITBaseTest {
+
+ private CollectionReference restaurantsCollection;
+
+ private static final Map> restaurantDocs = new HashMap<>();
+
+ static {
+ restaurantDocs.put(
+ "sunnySideUp",
+ map(
+ "name", "The Sunny Side Up",
+ "description",
+ "A cozy neighborhood diner serving classic breakfast favorites all day long, from fluffy pancakes to savory omelets.",
+ "location", new GeoPoint(39.7541, -105.0002),
+ "menu",
+ "Breakfast Classics
- Denver Omelet - $12
- Buttermilk Pancakes - $10
- Steak and Eggs - $16
Sides
- Hash Browns - $4
- Thick-cut Bacon - $5
- Drip Coffee - $2
",
+ "average_price_per_person", 15));
+ restaurantDocs.put(
+ "goldenWaffle",
+ map(
+ "name", "The Golden Waffle",
+ "description",
+ "Specializing exclusively in Belgian-style waffles. Open daily from 6:00 AM to 11:00 AM.",
+ "location", new GeoPoint(39.7183, -104.9621),
+ "menu",
+ "Signature Waffles
- Strawberry Delight - $11
- Chicken and Waffles - $14
- Chocolate Chip Crunch - $10
Drinks
- Fresh OJ - $4
- Artisan Coffee - $3
",
+ "average_price_per_person", 13));
+ restaurantDocs.put(
+ "lotusBlossomThai",
+ map(
+ "name", "Lotus Blossom Thai",
+ "description",
+ "Authentic Thai cuisine featuring hand-crushed spices and traditional family recipes from the Chiang Mai region.",
+ "location", new GeoPoint(39.7315, -104.9847),
+ "menu",
+ "Appetizers
- Spring Rolls - $7
- Chicken Satay - $9
Main Course
- Pad Thai - $15
- Green Curry - $16
- Drunken Noodles - $15
",
+ "average_price_per_person", 22));
+ restaurantDocs.put(
+ "mileHighCatch",
+ map(
+ "name", "Mile High Catch",
+ "description",
+ "Freshly sourced seafood offering a wide variety of Pacific fish and Atlantic shellfish in an upscale atmosphere.",
+ "location", new GeoPoint(39.7401, -104.9903),
+ "menu",
+ "From the Raw Bar
- Oysters (Half Dozen) - $18
- Lobster Cocktail - $22
Entrees
- Pan-Seared Salmon - $28
- King Crab Legs - $45
- Fish and Chips - $19
",
+ "average_price_per_person", 45));
+ restaurantDocs.put(
+ "peakBurgers",
+ map(
+ "name", "Peak Burgers",
+ "description",
+ "Casual burger joint focused on locally sourced Colorado beef and hand-cut fries.",
+ "location", new GeoPoint(39.7622, -105.0125),
+ "menu",
+ "Burgers
- The Peak Double - $12
- Bison Burger - $15
- Veggie Stack - $11
Sides
- Truffle Fries - $6
- Onion Rings - $5
",
+ "average_price_per_person", 18));
+ restaurantDocs.put(
+ "solTacos",
+ map(
+ "name", "El Sol Tacos",
+ "description",
+ "A vibrant street-side taco stand serving up quick, delicious, and traditional Mexican street food.",
+ "location", new GeoPoint(39.6952, -105.0274),
+ "menu",
+ "Tacos ($3.50 each)
- Al Pastor
- Carne Asada
- Pollo Asado
- Nopales (Cactus)
Beverages
- Horchata - $4
- Mexican Coke - $3
",
+ "average_price_per_person", 12));
+ restaurantDocs.put(
+ "eastsideTacos",
+ map(
+ "name", "Eastside Cantina",
+ "description",
+ "Authentic street tacos and hand-shaken margaritas on the vibrant east side of the city.",
+ "location", new GeoPoint(39.735, -104.885),
+ "menu",
+ "Tacos
- Carnitas Tacos - $4
- Barbacoa Tacos - $4.50
- Shrimp Tacos - $5
Drinks
- House Margarita - $9
- Jarritos - $3
",
+ "average_price_per_person", 18));
+ restaurantDocs.put(
+ "eastsideChicken",
+ map(
+ "name", "Eastside Chicken",
+ "description", "Fried chicken to go - next to Eastside Cantina.",
+ "location", new GeoPoint(39.735, -104.885),
+ "menu",
+ "Fried Chicken
- Drumstick - $4
- Wings - $1
- Sandwich - $9
Drinks
- House Margarita - $9
- Jarritos - $3
",
+ "average_price_per_person", 12));
+ }
+
+ @Override
+ public void primeBackend() throws Exception {
+ // Disable priming as it uses Watch/Listen, which is not supported by the 'enterprise' database.
+ }
+
+ @Before
+ public void setupRestaurantDocs() throws Exception {
+ restaurantsCollection =
+ firestore.collection("SearchIntegrationTests-" + LocalFirestoreHelper.autoId());
+
+ WriteBatch batch = firestore.batch();
+ for (Map.Entry> entry : restaurantDocs.entrySet()) {
+ batch.set(restaurantsCollection.document(entry.getKey()), entry.getValue());
+ }
+ batch.commit().get(10, TimeUnit.SECONDS);
+ }
+
+ private void assertResultIds(Pipeline.Snapshot snapshot, String... ids) {
+ List resultIds =
+ snapshot.getResults().stream()
+ .map(PipelineResult::getId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ assertThat(resultIds).containsExactlyElementsIn(Arrays.asList(ids)).inOrder();
+ }
+
+ // =========================================================================
+ // Search stage
+ // =========================================================================
+
+ // --- DISABLE query expansion ---
+
+ // query
+ @Test
+ public void searchWithLanguageCode() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery("waffles")
+ .withLanguageCode("en")
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "goldenWaffle");
+ }
+
+ @Test
+ public void searchFullDocument() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery("waffles").withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "goldenWaffle");
+ }
+
+ @Test
+ public void searchSpecificField() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("menu:waffles"))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "goldenWaffle");
+ }
+
+ @Test
+ public void geoNearQuery() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(
+ field("location")
+ .geoDistance(new GeoPoint(39.6985, -105.024))
+ .lessThan(1000))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "solTacos");
+ }
+
+ @Test
+ public void conjunctionOfTextSearchPredicates() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(
+ and(documentMatches("menu:waffles"), documentMatches("description:diner")))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
+ }
+
+ @Test
+ public void conjunctionOfTextSearchAndGeoNear() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(
+ and(
+ documentMatches("menu:tacos"),
+ field("location")
+ .geoDistance(new GeoPoint(39.6985, -105.024))
+ .lessThan(10000)))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "solTacos");
+ }
+
+ @Test
+ public void negateMatch() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("menu:-waffles"))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(
+ snapshot,
+ "eastsideTacos",
+ "solTacos",
+ "peakBurgers",
+ "mileHighCatch",
+ "lotusBlossomThai",
+ "sunnySideUp");
+ }
+
+ @Test
+ public void rquerySearchTheDocumentWithConjunctionAndDisjunction() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("(waffles OR pancakes) AND coffee"))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
+ }
+
+ @Test
+ public void rqueryAsQueryParam() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery("(waffles OR pancakes) AND coffee")
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
+ }
+
+ @Test
+ public void rquerySupportsFieldPaths() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery("menu:(waffles OR pancakes) AND description:\"breakfast all day\"")
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "sunnySideUp");
+ }
+
+ @Test
+ public void conjunctionOfRqueryAndExpression() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(
+ and(
+ documentMatches("tacos"),
+ greaterThanOrEqual("average_price_per_person", 8),
+ lessThanOrEqual("average_price_per_person", 15)))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "solTacos");
+ }
+
+ // --- REQUIRE query expansion ---
+
+ @Test
+ public void requireQueryExpansion_searchFullDocument() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("waffles"))
+ .withQueryEnhancement(Search.QueryEnhancement.REQUIRED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
+ }
+
+ @Test
+ public void requireQueryExpansion_searchSpecificField() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("menu:waffles"))
+ .withQueryEnhancement(Search.QueryEnhancement.REQUIRED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
+ }
+
+ // add fields
+ @Test
+ public void addFields_topicalityScoreAndSnippet() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("menu:waffles"))
+ .withAddFields(
+ score().as("searchScore"), snippet("menu", "waffles").as("snippet"))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED))
+ .select("name", "searchScore", "snippet");
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertThat(snapshot.getResults()).hasSize(1);
+ PipelineResult result = snapshot.getResults().get(0);
+ assertThat(result.getData().get("name")).isEqualTo("The Golden Waffle");
+ assertThat((Double) result.getData().get("searchScore")).isGreaterThan(0.0);
+ assertThat(((String) result.getData().get("snippet")).length()).isGreaterThan(0);
+ }
+
+ // select
+ @Test
+ public void select_topicalityScoreAndSnippet() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("menu:waffles"))
+ .withSelect(
+ field("name"),
+ field("location"),
+ score().as("searchScore"),
+ snippet("menu", "waffles").as("snippet"))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertThat(snapshot.getResults()).hasSize(1);
+ PipelineResult result = snapshot.getResults().get(0);
+ assertThat(result.getData().get("name")).isEqualTo("The Golden Waffle");
+ assertThat(result.getData().get("location")).isEqualTo(new GeoPoint(39.7183, -104.9621));
+ assertThat((Double) result.getData().get("searchScore")).isGreaterThan(0.0);
+ assertThat(((String) result.getData().get("snippet")).length()).isGreaterThan(0);
+
+ List sortedKeys =
+ result.getData().keySet().stream().sorted().collect(Collectors.toList());
+ assertThat(sortedKeys).containsExactly("location", "name", "searchScore", "snippet").inOrder();
+ }
+
+ // sort
+ @Test
+ public void sort_byTopicality() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("menu:tacos"))
+ .withSort(score().descending())
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "eastsideTacos", "solTacos");
+ }
+
+ @Test
+ public void sort_byDistance() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("menu:tacos"))
+ .withSort(
+ field("location").geoDistance(new GeoPoint(39.6985, -105.024)).ascending())
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "solTacos", "eastsideTacos");
+ }
+
+ @Test
+ public void sort_byMultipleOrderings() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("menu:tacos OR chicken"))
+ .withSort(
+ field("location").geoDistance(new GeoPoint(39.6985, -105.024)).ascending(),
+ score().descending())
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "solTacos", "eastsideTacos", "eastsideChicken");
+ }
+
+ // limit
+ @Test
+ public void limit_limitsTheNumberOfDocumentsReturned() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(constant(true))
+ .withSort(
+ field("location").geoDistance(new GeoPoint(39.6985, -105.024)).ascending())
+ .withLimit(5)
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "solTacos", "lotusBlossomThai", "goldenWaffle");
+ }
+
+ @Test
+ public void limit_limitsTheNumberOfDocumentsScored() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("menu:chicken OR tacos OR fish OR waffles"))
+ .withRetrievalDepth(6)
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "eastsideChicken", "eastsideTacos", "solTacos", "mileHighCatch");
+ }
+
+ // offset
+ @Test
+ public void offset_skipsNDocuments() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(constant(true))
+ .withLimit(2)
+ .withOffset(2)
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertResultIds(snapshot, "eastsideChicken", "eastsideTacos");
+ }
+
+ // =========================================================================
+ // Snippet
+ // =========================================================================
+
+ @Test
+ public void snippetOnMultipleFields() throws Exception {
+ // Get snippet from 1 field
+ Pipeline pipeline1 =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("waffle"))
+ .withAddFields(snippet("menu", "waffles").as("snippet"))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot1 = pipeline1.execute().get();
+ assertThat(snapshot1.getResults()).hasSize(1);
+ assertThat(snapshot1.getResults().get(0).getData().get("name")).isEqualTo("The Golden Waffle");
+ String snip1 = (String) snapshot1.getResults().get(0).getData().get("snippet");
+ assertThat(snip1.length()).isGreaterThan(0);
+
+ // Get snippet from 2 fields
+ Pipeline pipeline2 =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("waffle"))
+ .withAddFields(
+ concat(field("menu"), field("description"))
+ .snippet("waffles") // Without SnippetOptions in Java
+ .as("snippet"))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+
+ Pipeline.Snapshot snapshot2 = pipeline2.execute().get();
+ assertThat(snapshot2.getResults()).hasSize(1);
+ assertThat(snapshot2.getResults().get(0).getData().get("name")).isEqualTo("The Golden Waffle");
+ String snip2 = (String) snapshot2.getResults().get(0).getData().get("snippet");
+ assertThat(snip2.length()).isGreaterThan(snip1.length());
+ }
+}
From 2e4cdf0ab3b934c07a8cadeb4aa89613b4df69e1 Mon Sep 17 00:00:00 2001
From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com>
Date: Wed, 25 Mar 2026 13:49:17 -0600
Subject: [PATCH 3/4] Skip search tests on Standard edition
---
.../google/cloud/firestore/it/ITPipelineSearchTest.java | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java
index c32e74d618..40ac777238 100644
--- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java
+++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java
@@ -27,6 +27,7 @@
import static com.google.cloud.firestore.pipeline.expressions.Expression.score;
import static com.google.cloud.firestore.pipeline.expressions.Expression.snippet;
import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeFalse;
import com.google.cloud.firestore.CollectionReference;
import com.google.cloud.firestore.GeoPoint;
@@ -138,11 +139,15 @@ public class ITPipelineSearchTest extends ITBaseTest {
@Override
public void primeBackend() throws Exception {
- // Disable priming as it uses Watch/Listen, which is not supported by the 'enterprise' database.
+ // Disable priming as it uses Watch/Listen
}
@Before
public void setupRestaurantDocs() throws Exception {
+ assumeFalse(
+ "This test suite only runs against the Enterprise edition.",
+ !getFirestoreEdition().equals(FirestoreEdition.ENTERPRISE));
+
restaurantsCollection =
firestore.collection("SearchIntegrationTests-" + LocalFirestoreHelper.autoId());
From 56e09f57d1e8352b2fdee7d2c62495f4fea6ff4d Mon Sep 17 00:00:00 2001
From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com>
Date: Wed, 25 Mar 2026 15:14:50 -0600
Subject: [PATCH 4/4] Update copyright. Also fix tests to remove tests of
unsupported features
---
.../firestore/pipeline/stages/Search.java | 2 +-
.../firestore/it/ITPipelineSearchTest.java | 234 +++++++++---------
2 files changed, 121 insertions(+), 115 deletions(-)
diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java
index d69d15bd16..db56233728 100644
--- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java
+++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java
index 40ac777238..671c80911f 100644
--- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java
+++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSearchTest.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 Google LLC
+ * Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -148,8 +148,7 @@ public void setupRestaurantDocs() throws Exception {
"This test suite only runs against the Enterprise edition.",
!getFirestoreEdition().equals(FirestoreEdition.ENTERPRISE));
- restaurantsCollection =
- firestore.collection("SearchIntegrationTests-" + LocalFirestoreHelper.autoId());
+ restaurantsCollection = firestore.collection("SearchIntegrationTests");
WriteBatch batch = firestore.batch();
for (Map.Entry> entry : restaurantDocs.entrySet()) {
@@ -202,19 +201,19 @@ public void searchFullDocument() throws Exception {
assertResultIds(snapshot, "goldenWaffle");
}
- @Test
- public void searchSpecificField() throws Exception {
- Pipeline pipeline =
- firestore
- .pipeline()
- .collection(restaurantsCollection.getId())
- .search(
- Search.withQuery(documentMatches("menu:waffles"))
- .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
-
- Pipeline.Snapshot snapshot = pipeline.execute().get();
- assertResultIds(snapshot, "goldenWaffle");
- }
+ // @Test
+ // public void searchSpecificField() throws Exception {
+ // Pipeline pipeline =
+ // firestore
+ // .pipeline()
+ // .collection(restaurantsCollection.getId())
+ // .search(
+ // Search.withQuery(field("menu").matches("waffles"))
+ // .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+ //
+ // Pipeline.Snapshot snapshot = pipeline.execute().get();
+ // assertResultIds(snapshot, "goldenWaffle");
+ // }
@Test
public void geoNearQuery() throws Exception {
@@ -233,6 +232,7 @@ public void geoNearQuery() throws Exception {
assertResultIds(snapshot, "solTacos");
}
+
@Test
public void conjunctionOfTextSearchPredicates() throws Exception {
Pipeline pipeline =
@@ -241,51 +241,54 @@ public void conjunctionOfTextSearchPredicates() throws Exception {
.collection(restaurantsCollection.getId())
.search(
Search.withQuery(
- and(documentMatches("menu:waffles"), documentMatches("description:diner")))
+ and(documentMatches("waffles"), documentMatches("diner")))
+ // TODO(search) switch back to field matches when supported by the backend
+ // and(field("menu").matches("waffles"), field("description").matches("diner")))
.withQueryEnhancement(Search.QueryEnhancement.DISABLED));
Pipeline.Snapshot snapshot = pipeline.execute().get();
assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
}
- @Test
- public void conjunctionOfTextSearchAndGeoNear() throws Exception {
- Pipeline pipeline =
- firestore
- .pipeline()
- .collection(restaurantsCollection.getId())
- .search(
- Search.withQuery(
- and(
- documentMatches("menu:tacos"),
- field("location")
- .geoDistance(new GeoPoint(39.6985, -105.024))
- .lessThan(10000)))
- .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
-
- Pipeline.Snapshot snapshot = pipeline.execute().get();
- assertResultIds(snapshot, "solTacos");
- }
+ // TODO(search) enable test when geo+text search indexes are supported
+ // @Test
+ // public void conjunctionOfTextSearchAndGeoNear() throws Exception {
+ // Pipeline pipeline =
+ // firestore
+ // .pipeline()
+ // .collection(restaurantsCollection.getId())
+ // .search(
+ // Search.withQuery(
+ // and(
+ // field("menu").matches("tacos"),
+ // field("location")
+ // .geoDistance(new GeoPoint(39.6985, -105.024))
+ // .lessThan(10000)))
+ // .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+ //
+ // Pipeline.Snapshot snapshot = pipeline.execute().get();
+ // assertResultIds(snapshot, "solTacos");
+ // }
@Test
public void negateMatch() throws Exception {
Pipeline pipeline =
- firestore
- .pipeline()
- .collection(restaurantsCollection.getId())
- .search(
- Search.withQuery(documentMatches("menu:-waffles"))
- .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("-waffles"))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
Pipeline.Snapshot snapshot = pipeline.execute().get();
assertResultIds(
- snapshot,
- "eastsideTacos",
- "solTacos",
- "peakBurgers",
- "mileHighCatch",
- "lotusBlossomThai",
- "sunnySideUp");
+ snapshot,
+ "eastsideTacos",
+ "solTacos",
+ "peakBurgers",
+ "mileHighCatch",
+ "lotusBlossomThai",
+ "sunnySideUp");
}
@Test
@@ -316,19 +319,20 @@ public void rqueryAsQueryParam() throws Exception {
assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
}
- @Test
- public void rquerySupportsFieldPaths() throws Exception {
- Pipeline pipeline =
- firestore
- .pipeline()
- .collection(restaurantsCollection.getId())
- .search(
- Search.withQuery("menu:(waffles OR pancakes) AND description:\"breakfast all day\"")
- .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
-
- Pipeline.Snapshot snapshot = pipeline.execute().get();
- assertResultIds(snapshot, "sunnySideUp");
- }
+ // TODO(search) enable when rquery supports field paths
+ //@Test
+ //public void rquerySupportsFieldPaths() throws Exception {
+ // Pipeline pipeline =
+ // firestore
+ // .pipeline()
+ // .collection(restaurantsCollection.getId())
+ // .search(
+ // Search.withQuery("menu:(waffles OR pancakes) AND description:\"breakfast all day\"")
+ // .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+ //
+ // Pipeline.Snapshot snapshot = pipeline.execute().get();
+ // assertResultIds(snapshot, "sunnySideUp");
+ //}
@Test
public void conjunctionOfRqueryAndExpression() throws Exception {
@@ -364,41 +368,42 @@ public void requireQueryExpansion_searchFullDocument() throws Exception {
assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
}
- @Test
- public void requireQueryExpansion_searchSpecificField() throws Exception {
- Pipeline pipeline =
- firestore
- .pipeline()
- .collection(restaurantsCollection.getId())
- .search(
- Search.withQuery(documentMatches("menu:waffles"))
- .withQueryEnhancement(Search.QueryEnhancement.REQUIRED));
-
- Pipeline.Snapshot snapshot = pipeline.execute().get();
- assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
- }
+ // TODO(search) re-enable when backend supports field matches
+ // @Test
+ // public void requireQueryExpansion_searchSpecificField() throws Exception {
+ // Pipeline pipeline =
+ // firestore
+ // .pipeline()
+ // .collection(restaurantsCollection.getId())
+ // .search(
+ // Search.withQuery(field("menu").matches("waffles"))
+ // .withQueryEnhancement(Search.QueryEnhancement.REQUIRED));
+ //
+ // Pipeline.Snapshot snapshot = pipeline.execute().get();
+ // assertResultIds(snapshot, "goldenWaffle", "sunnySideUp");
+ // }
// add fields
- @Test
- public void addFields_topicalityScoreAndSnippet() throws Exception {
- Pipeline pipeline =
- firestore
- .pipeline()
- .collection(restaurantsCollection.getId())
- .search(
- Search.withQuery(documentMatches("menu:waffles"))
- .withAddFields(
- score().as("searchScore"), snippet("menu", "waffles").as("snippet"))
- .withQueryEnhancement(Search.QueryEnhancement.DISABLED))
- .select("name", "searchScore", "snippet");
-
- Pipeline.Snapshot snapshot = pipeline.execute().get();
- assertThat(snapshot.getResults()).hasSize(1);
- PipelineResult result = snapshot.getResults().get(0);
- assertThat(result.getData().get("name")).isEqualTo("The Golden Waffle");
- assertThat((Double) result.getData().get("searchScore")).isGreaterThan(0.0);
- assertThat(((String) result.getData().get("snippet")).length()).isGreaterThan(0);
- }
+ @Test
+ public void addFields_topicalityScoreAndSnippet() throws Exception {
+ Pipeline pipeline =
+ firestore
+ .pipeline()
+ .collection(restaurantsCollection.getId())
+ .search(
+ Search.withQuery(documentMatches("waffles"))
+ .withAddFields(
+ score().as("searchScore"), snippet("menu", "waffles").as("snippet"))
+ .withQueryEnhancement(Search.QueryEnhancement.DISABLED))
+ .select("name", "searchScore", "snippet");
+
+ Pipeline.Snapshot snapshot = pipeline.execute().get();
+ assertThat(snapshot.getResults()).hasSize(1);
+ PipelineResult result = snapshot.getResults().get(0);
+ assertThat(result.getData().get("name")).isEqualTo("The Golden Waffle");
+ assertThat((Double) result.getData().get("searchScore")).isGreaterThan(0.0);
+ assertThat(((String) result.getData().get("snippet")).length()).isGreaterThan(0);
+ }
// select
@Test
@@ -408,7 +413,7 @@ public void select_topicalityScoreAndSnippet() throws Exception {
.pipeline()
.collection(restaurantsCollection.getId())
.search(
- Search.withQuery(documentMatches("menu:waffles"))
+ Search.withQuery(documentMatches("waffles"))
.withSelect(
field("name"),
field("location"),
@@ -437,7 +442,7 @@ public void sort_byTopicality() throws Exception {
.pipeline()
.collection(restaurantsCollection.getId())
.search(
- Search.withQuery(documentMatches("menu:tacos"))
+ Search.withQuery(documentMatches("tacos"))
.withSort(score().descending())
.withQueryEnhancement(Search.QueryEnhancement.DISABLED));
@@ -452,7 +457,7 @@ public void sort_byDistance() throws Exception {
.pipeline()
.collection(restaurantsCollection.getId())
.search(
- Search.withQuery(documentMatches("menu:tacos"))
+ Search.withQuery(constant(true))
.withSort(
field("location").geoDistance(new GeoPoint(39.6985, -105.024)).ascending())
.withQueryEnhancement(Search.QueryEnhancement.DISABLED));
@@ -461,22 +466,23 @@ public void sort_byDistance() throws Exception {
assertResultIds(snapshot, "solTacos", "eastsideTacos");
}
- @Test
- public void sort_byMultipleOrderings() throws Exception {
- Pipeline pipeline =
- firestore
- .pipeline()
- .collection(restaurantsCollection.getId())
- .search(
- Search.withQuery(documentMatches("menu:tacos OR chicken"))
- .withSort(
- field("location").geoDistance(new GeoPoint(39.6985, -105.024)).ascending(),
- score().descending())
- .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
-
- Pipeline.Snapshot snapshot = pipeline.execute().get();
- assertResultIds(snapshot, "solTacos", "eastsideTacos", "eastsideChicken");
- }
+ // TODO(search) re-enable when geo+text search indexes are supported
+ // @Test
+ // public void sort_byMultipleOrderings() throws Exception {
+ // Pipeline pipeline =
+ // firestore
+ // .pipeline()
+ // .collection(restaurantsCollection.getId())
+ // .search(
+ // Search.withQuery(field("menu").matches("tacos OR chicken"))
+ // .withSort(
+ // field("location").geoDistance(new GeoPoint(39.6985, -105.024)).ascending(),
+ // score().descending())
+ // .withQueryEnhancement(Search.QueryEnhancement.DISABLED));
+ //
+ // Pipeline.Snapshot snapshot = pipeline.execute().get();
+ // assertResultIds(snapshot, "solTacos", "eastsideTacos", "eastsideChicken");
+ // }
// limit
@Test
@@ -503,7 +509,7 @@ public void limit_limitsTheNumberOfDocumentsScored() throws Exception {
.pipeline()
.collection(restaurantsCollection.getId())
.search(
- Search.withQuery(documentMatches("menu:chicken OR tacos OR fish OR waffles"))
+ Search.withQuery(documentMatches("chicken OR tacos OR fish OR waffles"))
.withRetrievalDepth(6)
.withQueryEnhancement(Search.QueryEnhancement.DISABLED));