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 c0f4c1c46..9c5a1e6ce 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 @@ -37,10 +37,12 @@ import com.google.cloud.firestore.pipeline.expressions.Field; import com.google.cloud.firestore.pipeline.expressions.FunctionExpression; import com.google.cloud.firestore.pipeline.expressions.Ordering; +import com.google.cloud.firestore.pipeline.expressions.PipelineValueExpression; import com.google.cloud.firestore.pipeline.expressions.Selectable; import com.google.cloud.firestore.pipeline.stages.AddFields; import com.google.cloud.firestore.pipeline.stages.Aggregate; import com.google.cloud.firestore.pipeline.stages.AggregateOptions; +import com.google.cloud.firestore.pipeline.stages.Define; import com.google.cloud.firestore.pipeline.stages.Distinct; import com.google.cloud.firestore.pipeline.stages.FindNearest; import com.google.cloud.firestore.pipeline.stages.FindNearestOptions; @@ -55,6 +57,7 @@ import com.google.cloud.firestore.pipeline.stages.Sort; import com.google.cloud.firestore.pipeline.stages.Stage; import com.google.cloud.firestore.pipeline.stages.StageUtils; +import com.google.cloud.firestore.pipeline.stages.Subcollection; import com.google.cloud.firestore.pipeline.stages.Union; import com.google.cloud.firestore.pipeline.stages.Unnest; import com.google.cloud.firestore.pipeline.stages.UnnestOptions; @@ -261,6 +264,243 @@ public Pipeline addFields(Selectable field, Selectable... additionalFields) { .toArray(new Selectable[0])))); } + /** + * Initializes a pipeline scoped to a subcollection. + * + *

This method allows you to start a new pipeline that operates on a subcollection of the + * current document. It is intended to be used as a subquery. + * + *

Note: A pipeline created with `subcollection` cannot be executed directly using + * {@link #execute()}. It must be used within a parent pipeline. + * + *

Example: + * + *

{@code
+   * firestore.pipeline().collection("books")
+   *     .addFields(
+   *         Pipeline.subcollection("reviews")
+   *             .aggregate(AggregateFunction.average("rating").as("avg_rating"))
+   *             .toScalarExpression().as("average_rating"));
+   * }
+ * + * @param path The path of the subcollection. + * @return A new {@code Pipeline} instance scoped to the subcollection. + */ + @BetaApi + public static Pipeline subcollection(String path) { + return new Pipeline(null, new Subcollection(path)); + } + + /** + * Defines one or more variables in the pipeline's scope. `define` is used to bind a value to a + * variable for internal reuse within the pipeline body (accessed via the {@link + * Expression#variable(String)} function). + * + *

This stage is useful for declaring reusable values or intermediate calculations that can be + * referenced multiple times in later parts of the pipeline, improving readability and + * maintainability. + * + *

Each variable is defined using an {@link AliasedExpression}, which pairs an expression with + * a name (alias). + * + *

Example: + * + *

{@code
+   * firestore.pipeline().collection("products")
+   *     .define(
+   *         multiply(field("price"), 0.9).as("discountedPrice"),
+   *         add(field("stock"), 10).as("newStock"))
+   *     .where(lessThan(variable("discountedPrice"), 100))
+   *     .select(field("name"), variable("newStock"));
+   * }
+ * + * @param expression The expression to define using {@link AliasedExpression}. + * @param additionalExpressions Additional expressions to define using {@link AliasedExpression}. + * @return A new Pipeline object with this stage appended to the stage list. + */ + @BetaApi + public Pipeline define(AliasedExpression expression, AliasedExpression... additionalExpressions) { + return append( + new Define( + PipelineUtils.selectablesToMap( + ImmutableList.builder() + .add(expression) + .add(additionalExpressions) + .build() + .toArray(new AliasedExpression[0])))); + } + + /** + * Converts the pipeline into an array expression. + * + *

Result Unwrapping: For simpler access, subqueries producing a single field + * automatically unwrap that value to the top level, ignoring the inner alias. If the subquery + * returns multiple fields, they are preserved as a map. + * + *

Example 1: Single field unwrapping + * + *

{@code
+   * // Get a list of all reviewer names for each book
+   * db.pipeline().collection("books")
+   *     .define(field("id").as("book_id"))
+   *     .addFields(
+   *         db.pipeline().collection("reviews")
+   *             .where(field("book_id").equal(variable("book_id")))
+   *             .select(field("reviewer").as("name"))
+   *             .toArrayExpression()
+   *             .as("reviewers"))
+   * }
+ * + *

The result set is unwrapped from {@code "reviewers": [{ "name": "Alice" }, { "name": + * "Bob" }]} to {@code "reviewers": ["Alice", "Bob"]}. + * + *

{@code
+   * // Output Document:
+   * [
+   *   {
+   *     "id": "1",
+   *     "title": "1984",
+   *     "reviewers": ["Alice", "Bob"]
+   *   }
+   * ]
+   * }
+ * + *

Example 2: Multiple fields (Map) + * + *

{@code
+   * // Get a list of reviews (reviewer and rating) for each book
+   * db.pipeline().collection("books")
+   *     .define(field("id").as("book_id"))
+   *     .addFields(
+   *         db.pipeline().collection("reviews")
+   *             .where(field("book_id").equal(variable("book_id")))
+   *             .select(field("reviewer"), field("rating"))
+   *             .toArrayExpression()
+   *             .as("reviews"))
+   * }
+ * + *

When the subquery produces multiple fields, they are kept as objects in the array: + * + *

{@code
+   * // Output Document:
+   * [
+   *   {
+   *     "id": "1",
+   *     "title": "1984",
+   *     "reviews": [
+   *       { "reviewer": "Alice", "rating": 5 },
+   *       { "reviewer": "Bob", "rating": 4 }
+   *     ]
+   *   }
+   * ]
+   * }
+ * + * @return A new {@link Expression} representing the pipeline as an array. + */ + @BetaApi + public Expression toArrayExpression() { + return new FunctionExpression("array", ImmutableList.of(new PipelineValueExpression(this))); + } + + /** + * Converts this Pipeline into an expression that evaluates to a single scalar result. Used for + * 1:1 lookups or Aggregations when the subquery is expected to return a single value or object. + * + *

Runtime Validation: The runtime will validate that the result set contains exactly + * one item. It throws a runtime error if the result has more than one item, and evaluates to + * {@code null} if the pipeline has zero results. + * + *

Result Unwrapping: For simpler access, subqueries producing a single field + * automatically unwrap that value to the top level, ignoring the inner alias. If the subquery + * returns multiple fields, they are preserved as a map. + * + *

Example 1: Single field unwrapping + * + *

{@code
+   * // Calculate average rating for each restaurant using a subquery
+   * db.pipeline().collection("restaurants")
+   *     .define(field("id").as("rid"))
+   *     .addFields(
+   *         db.pipeline().collection("reviews")
+   *             .where(field("restaurant_id").equal(variable("rid")))
+   *             // Inner aggregation returns a single document
+   *             .aggregate(AggregateFunction.average("rating").as("value"))
+   *             // Convert Pipeline -> Scalar Expression (validates result is 1 item)
+   *             .toScalarExpression()
+   *             .as("average_rating"))
+   * }
+ * + *

The result set is unwrapped twice: from {@code "average_rating": [{ "value": 4.5 }]} to + * {@code "average_rating": { "value": 4.5 }}, and finally to {@code "average_rating": 4.5}. + * + *

{@code
+   * // Output Document:
+   * [
+   *   {
+   *     "id": "123",
+   *     "name": "The Burger Joint",
+   *     "cuisine": "American",
+   *     "average_rating": 4.5
+   *   },
+   *   {
+   *     "id": "456",
+   *     "name": "Sushi World",
+   *     "cuisine": "Japanese",
+   *     "average_rating": 4.8
+   *   }
+   * ]
+   * }
+ * + *

Example 2: Multiple fields (Map) + * + *

{@code
+   * // For each restaurant, calculate review statistics (average rating AND total
+   * // count)
+   * db.pipeline().collection("restaurants")
+   *     .define(field("id").as("rid"))
+   *     .addFields(
+   *         db.pipeline().collection("reviews")
+   *             .where(field("restaurant_id").equal(variable("rid")))
+   *             .aggregate(
+   *                 AggregateFunction.average("rating").as("avg_score"),
+   *                 AggregateFunction.countAll().as("review_count"))
+   *             .toScalarExpression()
+   *             .as("stats"))
+   * }
+ * + *

When the subquery produces multiple fields, they are wrapped in a map: + * + *

{@code
+   * // Output Document:
+   * [
+   *   {
+   *     "id": "123",
+   *     "name": "The Burger Joint",
+   *     "cuisine": "American",
+   *     "stats": {
+   *       "avg_score": 4.0,
+   *       "review_count": 3
+   *     }
+   *   },
+   *   {
+   *     "id": "456",
+   *     "name": "Sushi World",
+   *     "cuisine": "Japanese",
+   *     "stats": {
+   *       "avg_score": 4.8,
+   *       "review_count": 120
+   *     }
+   *   }
+   * ]
+   * }
+ * + * @return A new {@link Expression} representing the pipeline as a scalar. + */ + @BetaApi + public Expression toScalarExpression() { + return new FunctionExpression("scalar", ImmutableList.of(new PipelineValueExpression(this))); + } + /** * Remove fields from outputs of previous stages. * @@ -1113,6 +1353,9 @@ MetricsContext createMetricsContext(String methodName) { */ @BetaApi public void execute(ApiStreamObserver observer) { + if (this.rpcContext == null) { + throw new IllegalStateException("Cannot execute a relative subcollection pipeline directly"); + } MetricsContext metricsContext = createMetricsContext(TelemetryConstants.METHOD_NAME_EXECUTE_PIPELINE_EXECUTE); @@ -1143,6 +1386,10 @@ ApiFuture execute( @Nonnull PipelineExecuteOptions options, @Nullable final ByteString transactionId, @Nullable com.google.protobuf.Timestamp readTime) { + if (this.rpcContext == null) { + throw new IllegalStateException("Cannot execute a relative subcollection pipeline directly"); + } + TraceUtil.Span span = rpcContext .getFirestore() 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 363046c39..37639e07b 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 @@ -24,6 +24,7 @@ import com.google.cloud.firestore.FieldPath; import com.google.cloud.firestore.FieldValue; import com.google.cloud.firestore.GeoPoint; +import com.google.cloud.firestore.Pipeline; import com.google.cloud.firestore.VectorValue; import com.google.common.collect.ImmutableList; import com.google.firestore.v1.Value; @@ -4336,7 +4337,7 @@ public final Ordering descending() { * expression and associates it with the provided alias. */ @BetaApi - public Selectable as(String alias) { + public AliasedExpression as(String alias) { return new AliasedExpression(this, alias); } @@ -4796,4 +4797,118 @@ public final Expression collectionId() { public final Expression type() { return type(this); } + + /** + * Creates an expression that represents the current document being processed. + * + *

This expression is useful when you need to access the entire document as a map, or pass the + * document itself to a function or subquery. + * + *

Example: + * + *

{@code
+   * // Define the current document as a variable "doc"
+   * firestore.pipeline().collection("books")
+   *     .define(currentDocument().as("doc"))
+   *     // Access a field from the defined document variable
+   *     .select(variable("doc").getField("title"));
+   * }
+ * + * @return An {@link Expression} representing the current document. + */ + @BetaApi + public static Expression currentDocument() { + return new FunctionExpression("current_document", ImmutableList.of()); + } + + /** + * Creates an expression that retrieves the value of a variable bound via {@link + * Pipeline#define(AliasedExpression, AliasedExpression...)}. + * + *

Example: + * + *

{@code
+   * // Define a variable "discountedPrice" and use it in a filter
+   * firestore.pipeline().collection("products")
+   *     .define(field("price").multiply(0.9).as("discountedPrice"))
+   *     .where(variable("discountedPrice").lessThan(100));
+   * }
+ * + * @param name The name of the variable to retrieve. + * @return An {@link Expression} representing the variable's value. + */ + @BetaApi + public static Expression variable(String name) { + return new Variable(name); + } + + /** + * Accesses a field/property of the expression that evaluates to a Map or Document. + * + * @param key The key of the field to access. + * @return An {@link Expression} representing the value of the field. + */ + @BetaApi + public Expression getField(String key) { + return new FunctionExpression("field", ImmutableList.of(this, constant(key))); + } + + /** + * Retrieves the value of a specific field from the document evaluated by this expression. + * + * @param keyExpression The expression evaluating to the key to access. + * @return A new {@link Expression} representing the field value. + */ + @BetaApi + public Expression getField(Expression keyExpression) { + return new FunctionExpression("field", ImmutableList.of(this, keyExpression)); + } + + /** + * Accesses a field/property of a document field using the provided {@code key}. + * + * @param fieldName The field name of the map or document field. + * @param key The key of the field to access. + * @return An {@link Expression} representing the value of the field. + */ + @BetaApi + public static Expression getField(String fieldName, String key) { + return field(fieldName).getField(key); + } + + /** + * Accesses a field/property of the expression using the provided {@code keyExpression}. + * + * @param expression The expression evaluating to a Map or Document. + * @param keyExpression The expression evaluating to the key. + * @return A new {@link Expression} representing the value of the field. + */ + @BetaApi + public static Expression getField(Expression expression, Expression keyExpression) { + return expression.getField(keyExpression); + } + + /** + * Accesses a field/property of a document field using the provided {@code keyExpression}. + * + * @param fieldName The field name of the map or document field. + * @param keyExpression The expression evaluating to the key. + * @return A new {@link Expression} representing the value of the field. + */ + @BetaApi + public static Expression getField(String fieldName, Expression keyExpression) { + return field(fieldName).getField(keyExpression); + } + + /** + * Accesses a field/property of the expression that evaluates to a Map or Document. + * + * @param expression The expression evaluating to a map/document. + * @param key The key of the field to access. + * @return An {@link Expression} representing the value of the field. + */ + @BetaApi + public static Expression getField(Expression expression, String key) { + return new FunctionExpression("field", ImmutableList.of(expression, constant(key))); + } } 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 eacbf953e..e7b8a5d3d 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 @@ -29,7 +29,8 @@ public class FunctionExpression extends Expression { private final String name; private final List params; - FunctionExpression(String name, List params) { + @InternalApi + public FunctionExpression(String name, List params) { this.name = name; this.params = Collections.unmodifiableList(params); } diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/PipelineValueExpression.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/PipelineValueExpression.java new file mode 100644 index 000000000..9094a1e94 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/PipelineValueExpression.java @@ -0,0 +1,36 @@ +/* + * Copyright 2024 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.expressions; + +import com.google.api.core.InternalApi; +import com.google.cloud.firestore.Pipeline; +import com.google.firestore.v1.Value; + +/** Internal expression representing a pipeline value. */ +@InternalApi +public final class PipelineValueExpression extends Expression { + private final Pipeline pipeline; + + public PipelineValueExpression(Pipeline pipeline) { + this.pipeline = pipeline; + } + + @Override + protected Value toProto() { + return pipeline.toProtoValue(); + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Variable.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Variable.java new file mode 100644 index 000000000..7f16dfcb5 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/expressions/Variable.java @@ -0,0 +1,39 @@ +/* + * 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.expressions; + +import com.google.api.core.InternalApi; +import com.google.firestore.v1.Value; + +/** + * Internal expression representing a variable reference. + * + *

This evaluates to the value of a variable defined in a pipeline context. + */ +@InternalApi +final class Variable extends Expression { + private final String name; + + Variable(String name) { + this.name = name; + } + + @Override + public Value toProto() { + return Value.newBuilder().setVariableReferenceValue(name).build(); + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Define.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Define.java new file mode 100644 index 000000000..699e909f1 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Define.java @@ -0,0 +1,42 @@ +/* + * 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 com.google.api.core.InternalApi; +import com.google.cloud.firestore.pipeline.expressions.Expression; +import com.google.firestore.v1.Value; +import java.util.Collections; +import java.util.Map; + +@InternalApi +public final class Define extends Stage { + + private final Map expressions; + + @InternalApi + public Define(Map expressions) { + super("let", InternalOptions.EMPTY); + this.expressions = expressions; + } + + @Override + Iterable toStageArgs() { + return Collections.singletonList(encodeValue(expressions)); + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Subcollection.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Subcollection.java new file mode 100644 index 000000000..f451e1c71 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Subcollection.java @@ -0,0 +1,40 @@ +/* + * 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 com.google.api.core.InternalApi; +import com.google.firestore.v1.Value; +import java.util.Collections; + +@InternalApi +public final class Subcollection extends Stage { + + private final String path; + + @InternalApi + public Subcollection(String path) { + super("subcollection", InternalOptions.EMPTY); + this.path = path; + } + + @Override + Iterable toStageArgs() { + return Collections.singletonList(encodeValue(path)); + } +} diff --git a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSubqueryTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSubqueryTest.java new file mode 100644 index 000000000..ad4d44225 --- /dev/null +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineSubqueryTest.java @@ -0,0 +1,1139 @@ +/* + * Copyright 2024 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.FieldValue.vector; +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.constant; +import static com.google.cloud.firestore.pipeline.expressions.Expression.currentDocument; +import static com.google.cloud.firestore.pipeline.expressions.Expression.equal; +import static com.google.cloud.firestore.pipeline.expressions.Expression.field; +import static com.google.cloud.firestore.pipeline.expressions.Expression.or; +import static com.google.cloud.firestore.pipeline.expressions.Expression.variable; +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.junit.Assume.assumeFalse; + +import com.google.cloud.firestore.CollectionReference; +import com.google.cloud.firestore.LocalFirestoreHelper; +import com.google.cloud.firestore.Pipeline; +import com.google.cloud.firestore.PipelineResult; +import com.google.cloud.firestore.pipeline.expressions.AggregateFunction; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; +import org.junit.Before; +import org.junit.Ignore; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class ITPipelineSubqueryTest extends ITBaseTest { + private CollectionReference collection; + private Map> bookDocs; + + public CollectionReference testCollectionWithDocs(Map> docs) + throws ExecutionException, InterruptedException, TimeoutException { + CollectionReference collection = firestore.collection(LocalFirestoreHelper.autoId()); + for (Map.Entry> doc : docs.entrySet()) { + collection.document(doc.getKey()).set(doc.getValue()).get(5, TimeUnit.SECONDS); + } + return collection; + } + + List> data(List results) { + return results.stream().map(PipelineResult::getData).collect(Collectors.toList()); + } + + @Before + public void setup() throws Exception { + assumeFalse( + "This test suite only runs against the Enterprise edition.", + !getFirestoreEdition().equals(FirestoreEdition.ENTERPRISE)); + if (collection != null) { + return; + } + + bookDocs = + ImmutableMap.>builder() + .put( + "book1", + ImmutableMap.builder() + .put("title", "The Hitchhiker's Guide to the Galaxy") + .put("author", "Douglas Adams") + .put("genre", "Science Fiction") + .put("published", 1979) + .put("rating", 4.2) + .put("tags", ImmutableList.of("comedy", "space", "adventure")) + .put("awards", ImmutableMap.of("hugo", true, "nebula", false)) + .put( + "embedding", + vector(new double[] {10.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0})) + .build()) + .put( + "book2", + ImmutableMap.builder() + .put("title", "Pride and Prejudice") + .put("author", "Jane Austen") + .put("genre", "Romance") + .put("published", 1813) + .put("rating", 4.5) + .put("tags", ImmutableList.of("classic", "social commentary", "love")) + .put("awards", ImmutableMap.of("none", true)) + .put( + "embedding", + vector(new double[] {1.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0})) + .build()) + .put( + "book3", + ImmutableMap.builder() + .put("title", "One Hundred Years of Solitude") + .put("author", "Gabriel García Márquez") + .put("genre", "Magical Realism") + .put("published", 1967) + .put("rating", 4.3) + .put("tags", ImmutableList.of("family", "history", "fantasy")) + .put("awards", ImmutableMap.of("nobel", true, "nebula", false)) + .put( + "embedding", + vector(new double[] {1.0, 1.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0})) + .build()) + .put( + "book4", + ImmutableMap.builder() + .put("title", "The Lord of the Rings") + .put("author", "J.R.R. Tolkien") + .put("genre", "Fantasy") + .put("published", 1954) + .put("rating", 4.7) + .put("tags", ImmutableList.of("adventure", "magic", "epic")) + .put("awards", ImmutableMap.of("hugo", false, "nebula", false)) + .put("cost", Double.NaN) + .put( + "embedding", + vector(new double[] {1.0, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0})) + .build()) + .put( + "book5", + ImmutableMap.builder() + .put("title", "The Handmaid's Tale") + .put("author", "Margaret Atwood") + .put("genre", "Dystopian") + .put("published", 1985) + .put("rating", 4.1) + .put("tags", ImmutableList.of("feminism", "totalitarianism", "resistance")) + .put("awards", ImmutableMap.of("arthur c. clarke", true, "booker prize", false)) + .put( + "embedding", + vector(new double[] {1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0, 1.0, 1.0})) + .build()) + .put( + "book6", + ImmutableMap.builder() + .put("title", "Crime and Punishment") + .put("author", "Fyodor Dostoevsky") + .put("genre", "Psychological Thriller") + .put("published", 1866) + .put("rating", 4.3) + .put("tags", ImmutableList.of("philosophy", "crime", "redemption")) + .put("awards", ImmutableMap.of("none", true)) + .put( + "embedding", + vector(new double[] {1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0, 1.0})) + .build()) + .put( + "book7", + ImmutableMap.builder() + .put("title", "To Kill a Mockingbird") + .put("author", "Harper Lee") + .put("genre", "Southern Gothic") + .put("published", 1960) + .put("rating", 4.2) + .put("tags", ImmutableList.of("racism", "injustice", "coming-of-age")) + .put("awards", ImmutableMap.of("pulitzer", true)) + .put( + "embedding", + vector(new double[] {1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0, 1.0})) + .build()) + .put( + "book8", + ImmutableMap.builder() + .put("title", "1984") + .put("author", "George Orwell") + .put("genre", "Dystopian") + .put("published", 1949) + .put("rating", 4.2) + .put("tags", ImmutableList.of("surveillance", "totalitarianism", "propaganda")) + .put("awards", ImmutableMap.of("prometheus", true)) + .put( + "embedding", + vector(new double[] {1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0, 1.0})) + .build()) + .put( + "book9", + ImmutableMap.builder() + .put("title", "The Great Gatsby") + .put("author", "F. Scott Fitzgerald") + .put("genre", "Modernist") + .put("published", 1925) + .put("rating", 4.0) + .put("tags", ImmutableList.of("wealth", "american dream", "love")) + .put("awards", ImmutableMap.of("none", true)) + .put( + "embedding", + vector(new double[] {1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0, 1.0})) + .build()) + .put( + "book10", + ImmutableMap.builder() + .put("title", "Dune") + .put("author", "Frank Herbert") + .put("genre", "Science Fiction") + .put("published", 1965) + .put("rating", 4.6) + .put("tags", ImmutableList.of("politics", "desert", "ecology")) + .put("awards", ImmutableMap.of("hugo", true, "nebula", true)) + .put( + "embedding", + vector(new double[] {1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 10.0})) + .build()) + .put( + "book11", + ImmutableMap.builder() + .put("title", "Timestamp Book") + .put("author", "Timestamp Author") + .put("timestamp", new Date()) + .build()) + .build(); + collection = testCollectionWithDocs(bookDocs); + } + + @Test + public void testZeroResultScalarReturnsNull() throws Exception { + Map> testDocs = map("book1", map("title", "A Book Title")); + + for (Map.Entry> doc : testDocs.entrySet()) { + collection.document(doc.getKey()).set(doc.getValue()).get(5, TimeUnit.SECONDS); + } + + Pipeline emptyScalar = + firestore + .pipeline() + .collection(collection.document("book1").collection("reviews").getPath()) + .where(equal("reviewer", "Alice")) + .select(currentDocument().as("data")); + + List results = + firestore + .pipeline() + .collection(collection.getPath()) + .select(emptyScalar.toScalarExpression().as("first_review_data")) + .limit(1) + .execute() + .get() + .getResults(); + + // Expecting result_data field to gracefully produce null + assertThat(data(results)).containsExactly(Collections.singletonMap("first_review_data", null)); + } + + @Test + public void testArraySubqueryJoinAndEmptyResult() throws Exception { + String reviewsCollName = "book_reviews_" + UUID.randomUUID().toString(); + Map> reviewsDocs = + map( + "r1", map("bookTitle", "The Hitchhiker's Guide to the Galaxy", "reviewer", "Alice"), + "r2", map("bookTitle", "The Hitchhiker's Guide to the Galaxy", "reviewer", "Bob")); + + for (Map.Entry> doc : reviewsDocs.entrySet()) { + firestore + .collection(reviewsCollName) + .document(doc.getKey()) + .set(doc.getValue()) + .get(5, TimeUnit.SECONDS); + } + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))) + .select(field("reviewer").as("reviewer")) + .sort(field("reviewer").ascending()); + + List results = + firestore + .pipeline() + .collection(collection.getPath()) + .where( + or( + equal("title", "The Hitchhiker's Guide to the Galaxy"), + equal("title", "Pride and Prejudice"))) + .define(field("title").as("book_title")) + .addFields(reviewsSub.toArrayExpression().as("reviews_data")) + .select("title", "reviews_data") + .sort(field("title").descending()) + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly( + map( + "title", + "The Hitchhiker's Guide to the Galaxy", + "reviews_data", + ImmutableList.of("Alice", "Bob")), + map("title", "Pride and Prejudice", "reviews_data", Collections.emptyList())) + .inOrder(); + } + + @Test + public void testMultipleArraySubqueriesOnBooks() throws Exception { + String reviewsCollName = "reviews_multi_" + UUID.randomUUID().toString(); + String authorsCollName = "authors_multi_" + UUID.randomUUID().toString(); + + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookTitle", "1984", "rating", 5)) + .get(5, TimeUnit.SECONDS); + + firestore + .collection(authorsCollName) + .document("a1") + .set(map("authorName", "George Orwell", "nationality", "British")) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))) + .select(field("rating").as("rating")); + + Pipeline authorsSub = + firestore + .pipeline() + .collection(authorsCollName) + .where(equal("authorName", variable("author_name"))) + .select(field("nationality").as("nationality")); + + List results = + firestore + .pipeline() + .collection(collection.getPath()) + .where(equal("title", "1984")) + .define(field("title").as("book_title"), field("author").as("author_name")) + .addFields( + reviewsSub.toArrayExpression().as("reviews_data"), + authorsSub.toArrayExpression().as("authors_data")) + .select("title", "reviews_data", "authors_data") + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly( + map( + "title", "1984", + "reviews_data", Collections.singletonList(5L), + "authors_data", Collections.singletonList("British"))); + } + + @Test + public void testArraySubqueryJoinMultipleFieldsPreservesMap() throws Exception { + String reviewsCollName = "reviews_map_" + UUID.randomUUID().toString(); + + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookTitle", "1984", "reviewer", "Alice", "rating", 5)) + .get(5, TimeUnit.SECONDS); + + firestore + .collection(reviewsCollName) + .document("r2") + .set(map("bookTitle", "1984", "reviewer", "Bob", "rating", 4)) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))) + .select(field("reviewer").as("reviewer"), field("rating").as("rating")) + .sort(field("reviewer").ascending()); + + List results = + firestore + .pipeline() + .collection(collection.getPath()) + .where(equal("title", "1984")) + .define(field("title").as("book_title")) + .addFields(reviewsSub.toArrayExpression().as("reviews_data")) + .select("title", "reviews_data") + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly( + map( + "title", + "1984", + "reviews_data", + ImmutableList.of( + map("reviewer", "Alice", "rating", 5L), map("reviewer", "Bob", "rating", 4L)))); + } + + @Test + public void testArraySubqueryInWhereStageOnBooks() throws Exception { + String reviewsCollName = "reviews_where_" + UUID.randomUUID().toString(); + + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookTitle", "Dune", "reviewer", "Paul")) + .get(5, TimeUnit.SECONDS); + + firestore + .collection(reviewsCollName) + .document("r2") + .set(map("bookTitle", "Foundation", "reviewer", "Hari")) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))) + .select(field("reviewer").as("reviewer")); + + List results = + firestore + .pipeline() + .collection(collection.getPath()) + .where(or(equal("title", "Dune"), equal("title", "The Great Gatsby"))) + .define(field("title").as("book_title")) + .where(reviewsSub.toArrayExpression().arrayContains("Paul")) + .select("title") + .execute() + .get() + .getResults(); + + assertThat(data(results)).containsExactly(map("title", "Dune")); + } + + @Test + public void testScalarSubquerySingleAggregationUnwrapping() throws Exception { + String reviewsCollName = "reviews_agg_single_" + UUID.randomUUID().toString(); + + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookTitle", "1984", "rating", 4)) + .get(5, TimeUnit.SECONDS); + + firestore + .collection(reviewsCollName) + .document("r2") + .set(map("bookTitle", "1984", "rating", 5)) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))) + .aggregate(AggregateFunction.average("rating").as("val")); + + List results = + firestore + .pipeline() + .collection(collection.getPath()) + .where(equal("title", "1984")) + .define(field("title").as("book_title")) + .addFields(reviewsSub.toScalarExpression().as("average_rating")) + .select("title", "average_rating") + .execute() + .get() + .getResults(); + + assertThat(data(results)).containsExactly(map("title", "1984", "average_rating", 4.5)); + } + + @Test + public void testScalarSubqueryMultipleAggregationsMapWrapping() throws Exception { + String reviewsCollName = "reviews_agg_multi_" + UUID.randomUUID().toString(); + + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookTitle", "1984", "rating", 4)) + .get(5, TimeUnit.SECONDS); + + firestore + .collection(reviewsCollName) + .document("r2") + .set(map("bookTitle", "1984", "rating", 5)) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))) + .aggregate( + AggregateFunction.average("rating").as("avg"), + AggregateFunction.countAll().as("count")); + + List results = + firestore + .pipeline() + .collection(collection.getPath()) + .where(equal("title", "1984")) + .define(field("title").as("book_title")) + .addFields(reviewsSub.toScalarExpression().as("stats")) + .select("title", "stats") + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly(map("title", "1984", "stats", map("avg", 4.5, "count", 2L))); + } + + @Test + public void testScalarSubqueryZeroResults() throws Exception { + String reviewsCollName = "reviews_zero_" + UUID.randomUUID().toString(); + + // No reviews for "1984" + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))) + .aggregate(AggregateFunction.average("rating").as("avg")); + + List results = + firestore + .pipeline() + .collection(collection.getPath()) + .where(equal("title", "1984")) // "1984" exists in the main collection from setup + .define(field("title").as("book_title")) + .addFields(reviewsSub.toScalarExpression().as("average_rating")) + .select("title", "average_rating") + .execute() + .get() + .getResults(); + + assertThat(data(results)).containsExactly(map("title", "1984", "average_rating", null)); + } + + @Test + public void testScalarSubqueryMultipleResultsRuntimeError() throws Exception { + String reviewsCollName = "reviews_multiple_" + UUID.randomUUID().toString(); + + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookTitle", "1984", "rating", 4)) + .get(5, TimeUnit.SECONDS); + + firestore + .collection(reviewsCollName) + .document("r2") + .set(map("bookTitle", "1984", "rating", 5)) + .get(5, TimeUnit.SECONDS); + + // This subquery will return 2 documents, which is invalid for + // toScalarExpression() + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))); + + ExecutionException e = + assertThrows( + ExecutionException.class, + () -> { + firestore + .pipeline() + .collection(collection.getPath()) + .where(equal("title", "1984")) + .define(field("title").as("book_title")) + .addFields(reviewsSub.toScalarExpression().as("review_data")) + .execute() + .get(); + }); + + // Assert that it's an API error from the backend complaining about multiple + // results + assertThat(e.getCause().getMessage()).contains("Subpipeline returned multiple results."); + } + + @Test + public void testMixedScalarAndArraySubqueries() throws Exception { + String reviewsCollName = "reviews_mixed_" + UUID.randomUUID().toString(); + + // Set up some reviews + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookTitle", "1984", "reviewer", "Alice", "rating", 4)) + .get(5, TimeUnit.SECONDS); + firestore + .collection(reviewsCollName) + .document("r2") + .set(map("bookTitle", "1984", "reviewer", "Bob", "rating", 5)) + .get(5, TimeUnit.SECONDS); + + // Array subquery for all reviewers + Pipeline arraySub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))) + .select(field("reviewer").as("reviewer")) + .sort(field("reviewer").ascending()); + + // Scalar subquery for the average rating + Pipeline scalarSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookTitle", variable("book_title"))) + .aggregate(AggregateFunction.average("rating").as("val")); + + List results = + firestore + .pipeline() + .collection(collection.getPath()) + .where(equal("title", "1984")) + .define(field("title").as("book_title")) + .addFields( + arraySub.toArrayExpression().as("all_reviewers"), + scalarSub.toScalarExpression().as("average_rating")) + .select("title", "all_reviewers", "average_rating") + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly( + map( + "title", + "1984", + "all_reviewers", + ImmutableList.of("Alice", "Bob"), + "average_rating", + 4.5)); + } + + @Test + public void testSingleScopeVariableUsage() throws Exception { + String collName = "single_scope_" + UUID.randomUUID().toString(); + firestore.collection(collName).document("doc1").set(map("price", 100)).get(5, TimeUnit.SECONDS); + + List results = + firestore + .pipeline() + .collection(collName) + .define(field("price").multiply(0.8).as("discount")) + .where(variable("discount").lessThan(50.0)) + .select("price") + .execute() + .get() + .getResults(); + + assertThat(data(results)).isEmpty(); + + firestore.collection(collName).document("doc2").set(map("price", 50)).get(5, TimeUnit.SECONDS); + + results = + firestore + .pipeline() + .collection(collName) + .define(field("price").multiply(0.8).as("discount")) + .where(variable("discount").lessThan(50.0)) + .select("price") + .execute() + .get() + .getResults(); + + assertThat(data(results)).containsExactly(map("price", 50L)); + } + + @Test + public void testExplicitFieldBindingScopeBridging() throws Exception { + String outerCollName = "outer_scope_" + UUID.randomUUID().toString(); + firestore + .collection(outerCollName) + .document("doc1") + .set(map("title", "1984", "id", "1")) + .get(5, TimeUnit.SECONDS); + + String reviewsCollName = "reviews_scope_" + UUID.randomUUID().toString(); + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookId", "1", "reviewer", "Alice")) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookId", variable("rid"))) + .select(field("reviewer").as("reviewer")); + + List results = + firestore + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .define(field("id").as("rid")) + .addFields(reviewsSub.toArrayExpression().as("reviews")) + .select("title", "reviews") + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly(map("title", "1984", "reviews", ImmutableList.of("Alice"))); + } + + @Test + public void testMultipleVariableBindings() throws Exception { + String outerCollName = "outer_multi_" + UUID.randomUUID().toString(); + firestore + .collection(outerCollName) + .document("doc1") + .set(map("title", "1984", "id", "1", "category", "sci-fi")) + .get(5, TimeUnit.SECONDS); + + String reviewsCollName = "reviews_multi_" + UUID.randomUUID().toString(); + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookId", "1", "category", "sci-fi", "reviewer", "Alice")) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(and(equal("bookId", variable("rid")), equal("category", variable("rcat")))) + .select(field("reviewer").as("reviewer")); + + List results = + firestore + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .define(field("id").as("rid"), field("category").as("rcat")) + .addFields(reviewsSub.toArrayExpression().as("reviews")) + .select("title", "reviews") + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly(map("title", "1984", "reviews", ImmutableList.of("Alice"))); + } + + @Test + public void testCurrentDocumentBinding() throws Exception { + String outerCollName = "outer_currentdoc_" + UUID.randomUUID().toString(); + firestore + .collection(outerCollName) + .document("doc1") + .set(map("title", "1984", "author", "George Orwell")) + .get(5, TimeUnit.SECONDS); + + String reviewsCollName = "reviews_currentdoc_" + UUID.randomUUID().toString(); + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("authorName", "George Orwell", "reviewer", "Alice")) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("authorName", variable("doc").mapGet("author"))) + .select(field("reviewer").as("reviewer")); + + List results = + firestore + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .define(currentDocument().as("doc")) + .addFields(reviewsSub.toArrayExpression().as("reviews")) + .select("title", "reviews") + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly(map("title", "1984", "reviews", ImmutableList.of("Alice"))); + } + + @Test + public void testUnboundVariableCornerCase() throws Exception { + String outerCollName = "outer_unbound_" + UUID.randomUUID().toString(); + ExecutionException e = + assertThrows( + ExecutionException.class, + () -> { + firestore + .pipeline() + .collection(outerCollName) + .where(equal("title", variable("unknown_var"))) + .execute() + .get(); + }); + + // Assert that it's an API error from the backend complaining about unknown + // variable + assertThat(e.getCause().getMessage()).contains("unknown variable"); + } + + @Test + public void testVariableShadowingCollision() throws Exception { + String outerCollName = "outer_shadow_" + UUID.randomUUID().toString(); + firestore + .collection(outerCollName) + .document("doc1") + .set(map("title", "1984")) + .get(5, TimeUnit.SECONDS); + + String innerCollName = "inner_shadow_" + UUID.randomUUID().toString(); + firestore + .collection(innerCollName) + .document("i1") + .set(map("id", "test")) + .get(5, TimeUnit.SECONDS); + + // Inner subquery re-defines variable "x" to be "inner_val" + Pipeline sub = + firestore + .pipeline() + .collection(innerCollName) + .define(constant("inner_val").as("x")) + .select(variable("x").as("val")); + + // Outer pipeline defines variable "x" to be "outer_val" + List results = + firestore + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .limit(1) + .define(constant("outer_val").as("x")) + .addFields(sub.toArrayExpression().as("shadowed")) + .select("shadowed") + .execute() + .get() + .getResults(); + + // Due to innermost scope winning, the result should use "inner_val" + // Scalar unwrapping applies because it's a single field + assertThat(data(results)).containsExactly(map("shadowed", ImmutableList.of("inner_val"))); + } + + @Test + public void testMissingFieldOnCurrentDocument() throws Exception { + String outerCollName = "outer_missing_" + UUID.randomUUID().toString(); + firestore + .collection(outerCollName) + .document("doc1") + .set(map("title", "1984")) + .get(5, TimeUnit.SECONDS); + + String reviewsCollName = "reviews_missing_" + UUID.randomUUID().toString(); + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookId", "1", "reviewer", "Alice")) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where(equal("bookId", variable("doc").mapGet("does_not_exist"))) + .select(field("reviewer").as("reviewer")); + + List results = + firestore + .pipeline() + .collection(outerCollName) + .where(equal("title", "1984")) + .define(currentDocument().as("doc")) + .addFields(reviewsSub.toArrayExpression().as("reviews")) + .select("title", "reviews") + .execute() + .get() + .getResults(); + + // Evaluating undefined properties acts safely + assertThat(data(results)) + .containsExactly(map("title", "1984", "reviews", Collections.emptyList())); + } + + @Test + public void test3LevelDeepJoin() throws Exception { + String publishersCollName = "publishers_" + UUID.randomUUID().toString(); + String booksCollName = "books_" + UUID.randomUUID().toString(); + String reviewsCollName = "reviews_" + UUID.randomUUID().toString(); + + firestore + .collection(publishersCollName) + .document("p1") + .set(map("publisherId", "pub1", "name", "Penguin")) + .get(5, TimeUnit.SECONDS); + + firestore + .collection(booksCollName) + .document("b1") + .set(map("bookId", "book1", "publisherId", "pub1", "title", "1984")) + .get(5, TimeUnit.SECONDS); + + firestore + .collection(reviewsCollName) + .document("r1") + .set(map("bookId", "book1", "reviewer", "Alice")) + .get(5, TimeUnit.SECONDS); + + // reviews need to know if the publisher is Penguin + Pipeline reviewsSub = + firestore + .pipeline() + .collection(reviewsCollName) + .where( + and( + equal("bookId", variable("book_id")), + equal(variable("pub_name"), "Penguin") // accessing top-level pub_name + )) + .select(field("reviewer").as("reviewer")); + + Pipeline booksSub = + firestore + .pipeline() + .collection(booksCollName) + .where(equal("publisherId", variable("pub_id"))) + .define(field("bookId").as("book_id")) + .addFields(reviewsSub.toArrayExpression().as("reviews")) + .select("title", "reviews"); + + List results = + firestore + .pipeline() + .collection(publishersCollName) + .where(equal("publisherId", "pub1")) + .define(field("publisherId").as("pub_id"), field("name").as("pub_name")) + .addFields(booksSub.toArrayExpression().as("books")) + .select("name", "books") + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly( + map( + "name", + "Penguin", + "books", + ImmutableList.of(map("title", "1984", "reviews", ImmutableList.of("Alice"))))); + } + + @Test + public void testDeepAggregation() throws Exception { + String outerColl = "outer_agg_" + UUID.randomUUID().toString(); + String innerColl = "inner_agg_" + UUID.randomUUID().toString(); + + firestore.collection(outerColl).document("doc1").set(map("id", "1")).get(5, TimeUnit.SECONDS); + firestore.collection(outerColl).document("doc2").set(map("id", "2")).get(5, TimeUnit.SECONDS); + + firestore + .collection(innerColl) + .document("i1") + .set(map("outer_id", "1", "score", 10)) + .get(5, TimeUnit.SECONDS); + firestore + .collection(innerColl) + .document("i2") + .set(map("outer_id", "2", "score", 20)) + .get(5, TimeUnit.SECONDS); + firestore + .collection(innerColl) + .document("i3") + .set(map("outer_id", "1", "score", 30)) + .get(5, TimeUnit.SECONDS); + + // subquery calculates the score for the outer doc + Pipeline innerSub = + firestore + .pipeline() + .collection(innerColl) + .where(equal("outer_id", variable("oid"))) + .aggregate(AggregateFunction.average("score").as("s")); + + List results = + firestore + .pipeline() + .collection(outerColl) + .define(field("id").as("oid")) + .addFields(innerSub.toScalarExpression().as("doc_score")) + // Now we aggregate over the calculated subquery results + .aggregate(AggregateFunction.sum("doc_score").as("total_score")) + .execute() + .get() + .getResults(); + + assertThat(data(results)).containsExactly(map("total_score", 40.0)); + } + + @Test + public void testPipelineStageSupport10Layers() throws Exception { + String collName = "depth_" + UUID.randomUUID().toString(); + firestore + .collection(collName) + .document("doc1") + .set(map("val", "hello")) + .get(5, TimeUnit.SECONDS); + + // Create a nested pipeline of depth 10 + Pipeline currentSubquery = + firestore.pipeline().collection(collName).limit(1).select(field("val").as("val")); + + for (int i = 0; i < 9; i++) { + currentSubquery = + firestore + .pipeline() + .collection(collName) + .limit(1) + .addFields(currentSubquery.toArrayExpression().as("nested_" + i)) + .select("nested_" + i); + } + + List results = currentSubquery.execute().get().getResults(); + assertThat(data(results)).isNotEmpty(); + } + + @Ignore("Pending backend support") + @Test + public void testStandardSubcollectionQuery() throws Exception { + String collName = "subcoll_test_" + UUID.randomUUID().toString(); + + firestore + .collection(collName) + .document("doc1") + .set(map("title", "1984")) + .get(5, TimeUnit.SECONDS); + + firestore + .collection(collName) + .document("doc1") + .collection("reviews") + .document("r1") + .set(map("reviewer", "Alice")) + .get(5, TimeUnit.SECONDS); + + Pipeline reviewsSub = + Pipeline.subcollection("reviews").select(field("reviewer").as("reviewer")); + + List results = + firestore + .pipeline() + .collection(collName) + .where(equal("title", "1984")) + .addFields(reviewsSub.toArrayExpression().as("reviews")) + .select("title", "reviews") + .execute() + .get() + .getResults(); + + assertThat(data(results)) + .containsExactly( + map("title", "1984", "reviews", ImmutableList.of(map("reviewer", "Alice")))); + } + + @Ignore("Pending backend support") + @Test + public void testMissingSubcollection() throws Exception { + String collName = "subcoll_missing_" + UUID.randomUUID().toString(); + + firestore + .collection(collName) + .document("doc1") + .set(map("id", "no_subcollection_here")) + .get(5, TimeUnit.SECONDS); + + // Notably NO subcollections are added to doc1 + + Pipeline missingSub = + Pipeline.subcollection("does_not_exist").select(variable("p").as("sub_p")); + + List results = + firestore + .pipeline() + .collection(collName) + .define(variable("parentDoc").as("p")) + .select(missingSub.toArrayExpression().as("missing_data")) + .limit(1) + .execute() + .get() + .getResults(); + + // Ensure it's not null and evaluates properly to an empty array [] + assertThat(data(results)).containsExactly(map("missing_data", Collections.emptyList())); + } + + @Test + public void testDirectExecutionOfSubcollectionPipeline() throws Exception { + Pipeline sub = Pipeline.subcollection("reviews"); + + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> { + // Attempting to execute a relative subcollection pipeline directly should fail + sub.execute(); + }); + + assertThat(exception) + .hasMessageThat() + .contains("Cannot execute a relative subcollection pipeline directly"); + } +} diff --git a/proto-google-cloud-firestore-v1/src/main/java/com/google/firestore/v1/DocumentProto.java b/proto-google-cloud-firestore-v1/src/main/java/com/google/firestore/v1/DocumentProto.java index b73d1ba85..f72b75031 100644 --- a/proto-google-cloud-firestore-v1/src/main/java/com/google/firestore/v1/DocumentProto.java +++ b/proto-google-cloud-firestore-v1/src/main/java/com/google/firestore/v1/DocumentProto.java @@ -93,67 +93,56 @@ public static com.google.protobuf.Descriptors.FileDescriptor getDescriptor() { static { java.lang.String[] descriptorData = { - "\n" - + "\"google/firestore/v1/document.proto\022\023go" + "\n\"google/firestore/v1/document.proto\022\023go" + "ogle.firestore.v1\032\037google/api/field_beha" + "vior.proto\032\034google/protobuf/struct.proto" - + "\032\037google/protobuf/timestamp.proto\032\030google/type/latlng.proto\"\200\002\n" - + "\010Document\022\014\n" - + "\004name\030\001 \001(\t\0229\n" - + "\006fields\030\002 \003(\0132).google.firestore.v1.Document.FieldsEntry\022/\n" - + "\013create_time\030\003 \001(\0132\032.google.protobuf.Timestamp\022/\n" - + "\013update_time\030\004 \001(\0132\032.google.protobuf.Timestamp\032I\n" - + "\013FieldsEntry\022\013\n" - + "\003key\030\001 \001(\t\022)\n" - + "\005value\030\002 \001(\0132\032.google.firestore.v1.Value:\0028\001\"\301\004\n" - + "\005Value\0220\n\n" - + "null_value\030\013 \001(\0162\032.google.protobuf.NullValueH\000\022\027\n\r" - + "boolean_value\030\001 \001(\010H\000\022\027\n\r" - + "integer_value\030\002 \001(\003H\000\022\026\n" - + "\014double_value\030\003 \001(\001H\000\0225\n" - + "\017timestamp_value\030\n" - + " \001(\0132\032.google.protobuf.TimestampH\000\022\026\n" - + "\014string_value\030\021 \001(\tH\000\022\025\n" - + "\013bytes_value\030\022 \001(\014H\000\022\031\n" - + "\017reference_value\030\005 \001(\tH\000\022.\n" - + "\017geo_point_value\030\010 \001(\0132\023.google.type.LatLngH\000\0226\n" - + "\013array_value\030\t \001(\0132\037.google.firestore.v1.ArrayValueH\000\0222\n" - + "\tmap_value\030\006 \001(\0132\035.google.firestore.v1.MapValueH\000\022\037\n" - + "\025field_reference_value\030\023 \001(\tH\000\0227\n" - + "\016function_value\030\024 \001(\0132\035.google.firestore.v1.FunctionH\000\0227\n" - + "\016pipeline_value\030\025 \001(\0132\035.google.firestore.v1.PipelineH\000B\014\n\n" - + "value_type\"8\n\n" - + "ArrayValue\022*\n" - + "\006values\030\001 \003(\0132\032.google.firestore.v1.Value\"\220\001\n" - + "\010MapValue\0229\n" - + "\006fields\030\001 \003(\0132).google.firestore.v1.MapValue.FieldsEntry\032I\n" - + "\013FieldsEntry\022\013\n" - + "\003key\030\001 \001(\t\022)\n" - + "\005value\030\002 \001(\0132\032.google.firestore.v1.Value:\0028\001\"\332\001\n" - + "\010Function\022\021\n" - + "\004name\030\001 \001(\tB\003\340A\002\022-\n" - + "\004args\030\002 \003(\0132\032.google.firestore.v1.ValueB\003\340A\001\022@\n" - + "\007options\030\003 \003(\0132" - + "*.google.firestore.v1.Function.OptionsEntryB\003\340A\001\032J\n" - + "\014OptionsEntry\022\013\n" - + "\003key\030\001 \001(\t\022)\n" - + "\005value\030\002 \001(\0132\032.google.firestore.v1.Value:\0028\001\"\244\002\n" - + "\010Pipeline\0228\n" - + "\006stages\030\001" - + " \003(\0132#.google.firestore.v1.Pipeline.StageB\003\340A\002\032\335\001\n" - + "\005Stage\022\021\n" - + "\004name\030\001 \001(\tB\003\340A\002\022-\n" - + "\004args\030\002 \003(\0132\032.google.firestore.v1.ValueB\003\340A\001\022F\n" - + "\007options\030\003" - + " \003(\01320.google.firestore.v1.Pipeline.Stage.OptionsEntryB\003\340A\001\032J\n" - + "\014OptionsEntry\022\013\n" - + "\003key\030\001 \001(\t\022)\n" - + "\005value\030\002 \001(\0132\032.google.firestore.v1.Value:\0028\001B\305\001\n" - + "\027com.google.firestore.v1B\r" - + "DocumentProtoP\001Z;cloud.google.com/go/firestore/apiv1/firestorepb;firest" - + "orepb\242\002\004GCFS\252\002\031Google.Cloud.Firestore.V1" - + "\312\002\031Google\\Cloud\\Firestore\\V1\352\002\034Google::C" - + "loud::Firestore::V1b\006proto3" + + "\032\037google/protobuf/timestamp.proto\032\030googl" + + "e/type/latlng.proto\"\200\002\n\010Document\022\014\n\004name" + + "\030\001 \001(\t\0229\n\006fields\030\002 \003(\0132).google.firestor" + + "e.v1.Document.FieldsEntry\022/\n\013create_time" + + "\030\003 \001(\0132\032.google.protobuf.Timestamp\022/\n\013up" + + "date_time\030\004 \001(\0132\032.google.protobuf.Timest" + + "amp\032I\n\013FieldsEntry\022\013\n\003key\030\001 \001(\t\022)\n\005value" + + "\030\002 \001(\0132\032.google.firestore.v1.Value:\0028\001\"\345" + + "\004\n\005Value\0220\n\nnull_value\030\013 \001(\0162\032.google.pr" + + "otobuf.NullValueH\000\022\027\n\rboolean_value\030\001 \001(" + + "\010H\000\022\027\n\rinteger_value\030\002 \001(\003H\000\022\026\n\014double_v" + + "alue\030\003 \001(\001H\000\0225\n\017timestamp_value\030\n \001(\0132\032." + + "google.protobuf.TimestampH\000\022\026\n\014string_va" + + "lue\030\021 \001(\tH\000\022\025\n\013bytes_value\030\022 \001(\014H\000\022\031\n\017re" + + "ference_value\030\005 \001(\tH\000\022.\n\017geo_point_value" + + "\030\010 \001(\0132\023.google.type.LatLngH\000\0226\n\013array_v" + + "alue\030\t \001(\0132\037.google.firestore.v1.ArrayVa" + + "lueH\000\0222\n\tmap_value\030\006 \001(\0132\035.google.firest" + + "ore.v1.MapValueH\000\022\037\n\025field_reference_val" + + "ue\030\023 \001(\tH\000\022\"\n\030variable_reference_value\030\026" + + " \001(\tH\000\0227\n\016function_value\030\024 \001(\0132\035.google." + + "firestore.v1.FunctionH\000\0227\n\016pipeline_valu" + + "e\030\025 \001(\0132\035.google.firestore.v1.PipelineH\000" + + "B\014\n\nvalue_type\"8\n\nArrayValue\022*\n\006values\030\001" + + " \003(\0132\032.google.firestore.v1.Value\"\220\001\n\010Map" + + "Value\0229\n\006fields\030\001 \003(\0132).google.firestore" + + ".v1.MapValue.FieldsEntry\032I\n\013FieldsEntry\022" + + "\013\n\003key\030\001 \001(\t\022)\n\005value\030\002 \001(\0132\032.google.fir" + + "estore.v1.Value:\0028\001\"\332\001\n\010Function\022\021\n\004name" + + "\030\001 \001(\tB\003\340A\002\022-\n\004args\030\002 \003(\0132\032.google.fires" + + "tore.v1.ValueB\003\340A\001\022@\n\007options\030\003 \003(\0132*.go" + + "ogle.firestore.v1.Function.OptionsEntryB" + + "\003\340A\001\032J\n\014OptionsEntry\022\013\n\003key\030\001 \001(\t\022)\n\005val" + + "ue\030\002 \001(\0132\032.google.firestore.v1.Value:\0028\001" + + "\"\244\002\n\010Pipeline\0228\n\006stages\030\001 \003(\0132#.google.f" + + "irestore.v1.Pipeline.StageB\003\340A\002\032\335\001\n\005Stag" + + "e\022\021\n\004name\030\001 \001(\tB\003\340A\002\022-\n\004args\030\002 \003(\0132\032.goo" + + "gle.firestore.v1.ValueB\003\340A\001\022F\n\007options\030\003" + + " \003(\01320.google.firestore.v1.Pipeline.Stag" + + "e.OptionsEntryB\003\340A\001\032J\n\014OptionsEntry\022\013\n\003k" + + "ey\030\001 \001(\t\022)\n\005value\030\002 \001(\0132\032.google.firesto" + + "re.v1.Value:\0028\001B\305\001\n\027com.google.firestore" + + ".v1B\rDocumentProtoP\001Z;cloud.google.com/g" + + "o/firestore/apiv1/firestorepb;firestorep" + + "b\242\002\004GCFS\252\002\031Google.Cloud.Firestore.V1\312\002\031G" + + "oogle\\Cloud\\Firestore\\V1\352\002\034Google::Cloud" + + "::Firestore::V1b\006proto3" }; descriptor = com.google.protobuf.Descriptors.FileDescriptor.internalBuildGeneratedFileFrom( @@ -196,6 +185,7 @@ public static com.google.protobuf.Descriptors.FileDescriptor getDescriptor() { "ArrayValue", "MapValue", "FieldReferenceValue", + "VariableReferenceValue", "FunctionValue", "PipelineValue", "ValueType", diff --git a/proto-google-cloud-firestore-v1/src/main/java/com/google/firestore/v1/Value.java b/proto-google-cloud-firestore-v1/src/main/java/com/google/firestore/v1/Value.java index 0f852d9c2..a9bb7ce64 100644 --- a/proto-google-cloud-firestore-v1/src/main/java/com/google/firestore/v1/Value.java +++ b/proto-google-cloud-firestore-v1/src/main/java/com/google/firestore/v1/Value.java @@ -90,6 +90,7 @@ public enum ValueTypeCase FIELD_REFERENCE_VALUE(19), FUNCTION_VALUE(20), PIPELINE_VALUE(21), + VARIABLE_REFERENCE_VALUE(22), VALUETYPE_NOT_SET(0); private final int value; @@ -137,6 +138,8 @@ public static ValueTypeCase forNumber(int value) { return FUNCTION_VALUE; case 21: return PIPELINE_VALUE; + case 22: + return VARIABLE_REFERENCE_VALUE; case 0: return VALUETYPE_NOT_SET; default: @@ -989,6 +992,91 @@ public com.google.firestore.v1.PipelineOrBuilder getPipelineValueOrBuilder() { return com.google.firestore.v1.Pipeline.getDefaultInstance(); } + public static final int VARIABLE_REFERENCE_VALUE_FIELD_NUMBER = 22; + + /** + * + * + *

+   * Pointer to a variable defined elsewhere in a pipeline.
+   *
+   * **Requires:**
+   *
+   * * Not allowed to be used when writing documents.
+   * 
+ * + * string variable_reference_value = 22; + * + * @return Whether the variableReferenceValue field is set. + */ + public boolean hasVariableReferenceValue() { + return valueTypeCase_ == 22; + } + + /** + * + * + *
+   * Pointer to a variable defined elsewhere in a pipeline.
+   *
+   * **Requires:**
+   *
+   * * Not allowed to be used when writing documents.
+   * 
+ * + * string variable_reference_value = 22; + * + * @return The variableReferenceValue. + */ + public java.lang.String getVariableReferenceValue() { + java.lang.Object ref = ""; + if (valueTypeCase_ == 22) { + ref = valueType_; + } + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (valueTypeCase_ == 22) { + valueType_ = s; + } + return s; + } + } + + /** + * + * + *
+   * Pointer to a variable defined elsewhere in a pipeline.
+   *
+   * **Requires:**
+   *
+   * * Not allowed to be used when writing documents.
+   * 
+ * + * string variable_reference_value = 22; + * + * @return The bytes for variableReferenceValue. + */ + public com.google.protobuf.ByteString getVariableReferenceValueBytes() { + java.lang.Object ref = ""; + if (valueTypeCase_ == 22) { + ref = valueType_; + } + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8((java.lang.String) ref); + if (valueTypeCase_ == 22) { + valueType_ = b; + } + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + private byte memoizedIsInitialized = -1; @java.lang.Override @@ -1045,6 +1133,9 @@ public void writeTo(com.google.protobuf.CodedOutputStream output) throws java.io if (valueTypeCase_ == 21) { output.writeMessage(21, (com.google.firestore.v1.Pipeline) valueType_); } + if (valueTypeCase_ == 22) { + com.google.protobuf.GeneratedMessage.writeString(output, 22, valueType_); + } getUnknownFields().writeTo(output); } @@ -1118,6 +1209,9 @@ public int getSerializedSize() { com.google.protobuf.CodedOutputStream.computeMessageSize( 21, (com.google.firestore.v1.Pipeline) valueType_); } + if (valueTypeCase_ == 22) { + size += com.google.protobuf.GeneratedMessage.computeStringSize(22, valueType_); + } size += getUnknownFields().getSerializedSize(); memoizedSize = size; return size; @@ -1178,6 +1272,9 @@ public boolean equals(final java.lang.Object obj) { case 21: if (!getPipelineValue().equals(other.getPipelineValue())) return false; break; + case 22: + if (!getVariableReferenceValue().equals(other.getVariableReferenceValue())) return false; + break; case 0: default: } @@ -1472,6 +1569,9 @@ private void buildPartialOneofs(com.google.firestore.v1.Value result) { if (valueTypeCase_ == 21 && pipelineValueBuilder_ != null) { result.valueType_ = pipelineValueBuilder_.build(); } + if (valueTypeCase_ == 22) { + result.valueType_ = this.valueType_; + } } @java.lang.Override @@ -1563,6 +1663,13 @@ public Builder mergeFrom(com.google.firestore.v1.Value other) { mergePipelineValue(other.getPipelineValue()); break; } + case VARIABLE_REFERENCE_VALUE: + { + valueTypeCase_ = 22; + valueType_ = other.valueType_; + onChanged(); + break; + } case VALUETYPE_NOT_SET: { break; @@ -4054,6 +4161,166 @@ public com.google.firestore.v1.PipelineOrBuilder getPipelineValueOrBuilder() { return pipelineValueBuilder_; } + /** + * + * + *
+     * Pointer to a variable defined elsewhere in a pipeline.
+     *
+     * **Requires:**
+     *
+     * * Not allowed to be used when writing documents.
+     * 
+ * + * string variable_reference_value = 22; + * + * @return Whether the variableReferenceValue field is set. + */ + public boolean hasVariableReferenceValue() { + return valueTypeCase_ == 22; + } + + /** + * + * + *
+     * Pointer to a variable defined elsewhere in a pipeline.
+     *
+     * **Requires:**
+     *
+     * * Not allowed to be used when writing documents.
+     * 
+ * + * string variable_reference_value = 22; + * + * @return The variableReferenceValue. + */ + public java.lang.String getVariableReferenceValue() { + java.lang.Object ref = ""; + if (valueTypeCase_ == 22) { + ref = valueType_; + } + if (ref instanceof java.lang.String) { + return (java.lang.String) ref; + } else { + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; + java.lang.String s = bs.toStringUtf8(); + if (valueTypeCase_ == 22) { + valueType_ = s; + } + return s; + } + } + + /** + * + * + *
+     * Pointer to a variable defined elsewhere in a pipeline.
+     *
+     * **Requires:**
+     *
+     * * Not allowed to be used when writing documents.
+     * 
+ * + * string variable_reference_value = 22; + * + * @return The bytes for variableReferenceValue. + */ + public com.google.protobuf.ByteString getVariableReferenceValueBytes() { + java.lang.Object ref = ""; + if (valueTypeCase_ == 22) { + ref = valueType_; + } + if (ref instanceof java.lang.String) { + com.google.protobuf.ByteString b = + com.google.protobuf.ByteString.copyFromUtf8((java.lang.String) ref); + if (valueTypeCase_ == 22) { + valueType_ = b; + } + return b; + } else { + return (com.google.protobuf.ByteString) ref; + } + } + + /** + * + * + *
+     * Pointer to a variable defined elsewhere in a pipeline.
+     *
+     * **Requires:**
+     *
+     * * Not allowed to be used when writing documents.
+     * 
+ * + * string variable_reference_value = 22; + * + * @param value The variableReferenceValue to set. + * @return This builder for chaining. + */ + public Builder setVariableReferenceValue(java.lang.String value) { + if (value == null) { + throw new NullPointerException(); + } + valueTypeCase_ = 22; + valueType_ = value; + onChanged(); + return this; + } + + /** + * + * + *
+     * Pointer to a variable defined elsewhere in a pipeline.
+     *
+     * **Requires:**
+     *
+     * * Not allowed to be used when writing documents.
+     * 
+ * + * string variable_reference_value = 22; + * + * @return This builder for chaining. + */ + public Builder clearVariableReferenceValue() { + if (valueTypeCase_ == 22) { + valueTypeCase_ = 0; + valueType_ = null; + onChanged(); + } + return this; + } + + /** + * + * + *
+     * Pointer to a variable defined elsewhere in a pipeline.
+     *
+     * **Requires:**
+     *
+     * * Not allowed to be used when writing documents.
+     * 
+ * + * string variable_reference_value = 22; + * + * @param value The bytes for variableReferenceValue to set. + * @return This builder for chaining. + */ + public Builder setVariableReferenceValueBytes(com.google.protobuf.ByteString value) { + if (value == null) { + throw new NullPointerException(); + } + checkByteStringIsUtf8(value); + valueTypeCase_ = 22; + valueType_ = value; + onChanged(); + return this; + } + // @@protoc_insertion_point(builder_scope:google.firestore.v1.Value) }