From 4cf8b9d525a341030d134f7f83dc9ebfb3788e17 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:08:55 -0600 Subject: [PATCH 1/4] Initial implementation of search API --- .../com/google/cloud/firestore/Pipeline.java | 20 +- .../BooleanFunctionExpression.java | 5 + .../pipeline/expressions/Expression.java | 151 ++++++++++++++- .../firestore/pipeline/expressions/Field.java | 26 +++ .../expressions/FunctionExpression.java | 18 +- .../pipeline/expressions/Selectable.java | 22 ++- .../firestore/pipeline/stages/Search.java | 173 ++++++++++++++++++ .../firestore/pipeline/stages/Stage.java | 3 +- .../cloud/firestore/PipelineProtoTest.java | 106 +++++++++++ 9 files changed, 515 insertions(+), 9 deletions(-) create mode 100644 google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Search.java create mode 100644 google-cloud-firestore/src/test/java/com/google/cloud/firestore/PipelineProtoTest.java diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Pipeline.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Pipeline.java index c0f4c1c461..140dcd03f0 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Pipeline.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Pipeline.java @@ -51,6 +51,7 @@ import com.google.cloud.firestore.pipeline.stages.RemoveFields; import com.google.cloud.firestore.pipeline.stages.ReplaceWith; import com.google.cloud.firestore.pipeline.stages.Sample; +import com.google.cloud.firestore.pipeline.stages.Search; import com.google.cloud.firestore.pipeline.stages.Select; import com.google.cloud.firestore.pipeline.stages.Sort; import com.google.cloud.firestore.pipeline.stages.Stage; @@ -219,6 +220,21 @@ private Pipeline append(Stage stage) { return new Pipeline(this.rpcContext, stages.append(stage)); } + /** + * Adds a search stage to the Pipeline. + * + *

This must be the first stage of the pipeline. + * + *

A limited set of expressions are supported in the search stage. + * + * @param searchStage An object that specifies how search is performed. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + @BetaApi + public Pipeline search(Search searchStage) { + return append(searchStage); + } + /** * Adds new fields to outputs from previous stages. * @@ -1237,9 +1253,9 @@ public void onError(Throwable t) { } @InternalApi - private com.google.firestore.v1.Pipeline toProto() { + public com.google.firestore.v1.Pipeline toProto() { return com.google.firestore.v1.Pipeline.newBuilder() - .addAllStages(stages.transform(StageUtils::toStageProto)) + .addAllStages(stages.transform(Stage::toStageProto)) .build(); } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/BooleanFunctionExpression.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/BooleanFunctionExpression.java index e1a876c061..f320d44269 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/BooleanFunctionExpression.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/BooleanFunctionExpression.java @@ -36,6 +36,11 @@ class BooleanFunctionExpression extends BooleanExpression { this(new FunctionExpression(name, java.util.Arrays.asList(params))); } + BooleanFunctionExpression( + String name, List params, java.util.Map 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 params) { + this(name, params, java.util.Collections.emptyMap()); + } + + FunctionExpression( + String name, List 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

Sides

", + "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

Drinks

", + "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

Main Course

", + "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

Entrees

", + "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

Sides

", + "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)

Beverages

", + "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

Drinks

", + "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

Drinks

", + "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));