diff --git a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Pipeline.java b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Pipeline.java index c49ee0ad2f9a..50cb84b94f47 100644 --- a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Pipeline.java +++ b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/Pipeline.java @@ -47,6 +47,7 @@ import com.google.cloud.firestore.pipeline.stages.Distinct; import com.google.cloud.firestore.pipeline.stages.FindNearest; import com.google.cloud.firestore.pipeline.stages.FindNearestOptions; +import com.google.cloud.firestore.pipeline.stages.Insert; import com.google.cloud.firestore.pipeline.stages.Limit; import com.google.cloud.firestore.pipeline.stages.Offset; import com.google.cloud.firestore.pipeline.stages.PipelineExecuteOptions; @@ -63,6 +64,7 @@ import com.google.cloud.firestore.pipeline.stages.Unnest; import com.google.cloud.firestore.pipeline.stages.UnnestOptions; import com.google.cloud.firestore.pipeline.stages.Update; +import com.google.cloud.firestore.pipeline.stages.Upsert; import com.google.cloud.firestore.pipeline.stages.Where; import com.google.cloud.firestore.telemetry.MetricsUtil.MetricsContext; import com.google.cloud.firestore.telemetry.TelemetryConstants; @@ -76,6 +78,7 @@ import com.google.firestore.v1.ExecutePipelineRequest; import com.google.firestore.v1.ExecutePipelineResponse; import com.google.firestore.v1.StructuredPipeline; +import com.google.firestore.v1.TransactionOptions; import com.google.firestore.v1.Value; import com.google.protobuf.ByteString; import java.util.ArrayList; @@ -1198,6 +1201,9 @@ public Pipeline unnest(Selectable field, UnnestOptions options) { /** * Performs a delete operation on documents from previous stages. * + *

Each document produced by the previous stages is deleted from the database. The result + * contains a single document with a {@code documents_modified} field. + * *

Example: * *

{@code
@@ -1212,7 +1218,6 @@ public Pipeline unnest(Selectable field, UnnestOptions options) {
    *
    * @return A new {@code Pipeline} object with this stage appended to the stage list.
    */
-  @BetaApi
   public Pipeline delete() {
     return append(new Delete());
   }
@@ -1223,6 +1228,11 @@ public Pipeline delete() {
    * 

This method updates the documents in place based on the data flowing through the pipeline. * To specify transformations, use {@link #update(Selectable...)}. * + *

Each document is written back to its existing path. The stored document is replaced by the + * pipeline document, so fields removed by earlier stages (for example, with {@link + * #removeFields(String, String...)}) are also removed from the stored document. This stage does + * not create documents: documents that don't exist are skipped. + * *

Example 1: Update a collection's schema by adding a new field and removing an old one. * *

{@code
@@ -1251,7 +1261,6 @@ public Pipeline delete() {
    *
    * @return A new {@code Pipeline} object with this stage appended to the stage list.
    */
-  @BetaApi
   public Pipeline update() {
     return append(new Update());
   }
@@ -1275,7 +1284,6 @@ public Pipeline update() {
    * @param transformedFields The transformations to apply.
    * @return A new {@code Pipeline} object with this stage appended to the stage list.
    */
-  @BetaApi
   public Pipeline update(Selectable... transformedFields) {
     return append(new Update().withTransformedFields(transformedFields));
   }
@@ -1301,14 +1309,104 @@ public Pipeline update(Selectable... transformedFields) {
    * @param update The {@code Update} stage to append.
    * @return A new {@code Pipeline} object with this stage appended to the stage list.
    */
-  @BetaApi
   public Pipeline update(Update update) {
     return append(update);
   }
 
   /**
-   * Performs an insert operation using documents from previous stages. Adds a generic stage to the
-   * pipeline.
+   * Inserts each document produced by the previous stages at its own path, given by its {@code
+   * __name__} field.
+   *
+   * 

This is equivalent to {@code insert(new Insert())}. A document without a {@code __name__} + * field fails with {@code INVALID_ARGUMENT}. + * + *

The insert fails with {@code ALREADY_EXISTS} if a target document already exists. + * + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + public Pipeline insert() { + return append(new Insert()); + } + + /** + * Inserts each document produced by the previous stages as a new document, using a configured + * {@link Insert} stage. + * + *

The insert fails with {@code ALREADY_EXISTS} if a target document already exists. See {@link + * Insert} for how the target path of each document is determined. + * + *

Example: + * + *

{@code
+   * Map book = new HashMap<>();
+   * book.put("title", "Dune");
+   *
+   * firestore.pipeline()
+   *     .literals(book)
+   *     .insert(new Insert().withCollection("books"))
+   *     .execute(new PipelineExecuteOptions().withAtomic(true))
+   *     .get();
+   * }
+ * + * @param insert The {@code Insert} stage to append. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + public Pipeline insert(Insert insert) { + return append(insert); + } + + /** + * Writes each document produced by the previous stages, using a configured {@link Upsert} stage. + * The target document is created if it doesn't exist and replaced if it does; stored fields are + * not merged. + * + *

See {@link Upsert} for how the target path of each document is determined. + * + *

Example: + * + *

{@code
+   * firestore.pipeline()
+   *     .collection("books")
+   *     .where(field("title").equal("Dune"))
+   *     .upsert(new Upsert().withCollection("archive"))
+   *     .execute()
+   *     .get();
+   * }
+ * + * @param upsert The {@code Upsert} stage to append. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + public Pipeline upsert(Upsert upsert) { + return append(upsert); + } + + /** + * Writes each document produced by the previous stages back to its own path, after adding the + * given fields. The document is created if it doesn't exist and replaced if it does; stored + * fields are not merged. A document without a {@code __name__} field fails with {@code + * INVALID_ARGUMENT}. + * + *

Example: + * + *

{@code
+   * firestore.pipeline()
+   *     .collection("books")
+   *     .where(field("rating").greaterThan(4))
+   *     .upsert(constant("Featured").as("status"))
+   *     .execute()
+   *     .get();
+   * }
+ * + * @param additionalFields Fields to add to each input document before it is written. A field with + * the same name as an existing input field overwrites that field. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + public Pipeline upsert(Selectable... additionalFields) { + return append(new Upsert(additionalFields)); + } + + /** + * Adds a generic stage to the pipeline. * *

This method provides a flexible way to extend the pipeline's functionality by adding custom * stages. Each generic stage is defined by a unique `name` and a set of `params` that control its @@ -1509,31 +1607,50 @@ public void onError(Throwable t) { } } - void executeInternal( + ExecutePipelineRequest toExecutePipelineRequest( @Nonnull PipelineExecuteOptions options, @Nullable final ByteString transactionId, - @Nullable com.google.protobuf.Timestamp readTime, - PipelineResultObserver observer, - MetricsContext metricsContext) { + @Nullable com.google.protobuf.Timestamp readTime) { + java.util.Map optionsMap = + new java.util.HashMap<>(StageUtils.toMap(options)); + optionsMap.remove("atomic"); + ExecutePipelineRequest.Builder request = ExecutePipelineRequest.newBuilder() - .setDatabase(rpcContext.getDatabaseName()) + .setDatabase(rpcContext != null ? rpcContext.getDatabaseName() : "") .setStructuredPipeline( StructuredPipeline.newBuilder() .setPipeline(toProto()) - .putAllOptions(StageUtils.toMap(options)) + .putAllOptions(optionsMap) .build()); if (transactionId != null) { request.setTransaction(transactionId); + } else if (options.isAtomic()) { + request.setNewTransaction( + TransactionOptions.newBuilder() + .setReadWrite(TransactionOptions.ReadWrite.getDefaultInstance()) + .build()); + request.setAutoCommitTransaction(true); } if (readTime != null) { request.setReadTime(readTime); } + return request.build(); + } + + void executeInternal( + @Nonnull PipelineExecuteOptions options, + @Nullable final ByteString transactionId, + @Nullable com.google.protobuf.Timestamp readTime, + PipelineResultObserver observer, + MetricsContext metricsContext) { + ExecutePipelineRequest request = toExecutePipelineRequest(options, transactionId, readTime); + pipelineInternalStream( - request.build(), + request, new PipelineResultObserver() { @Override public void onCompleted() { diff --git a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/PipelineSource.java b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/PipelineSource.java index f5c11e76d748..080e10d19a5c 100644 --- a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/PipelineSource.java +++ b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/PipelineSource.java @@ -152,6 +152,13 @@ public Pipeline documents(String... docs) { /** * Creates a new {@link Pipeline} that operates on a static set of documents represented as Maps. * + *

Each map becomes one input document. Field values can be constants or {@link + * com.google.cloud.firestore.pipeline.expressions.Expression}s, including expressions nested + * inside map or list values. {@link FieldValue} sentinels are not supported; use an expression + * such as {@code currentTimestamp()} instead. To give a document a path, set its {@code __name__} + * field to a {@link DocumentReference}. Executing a pipeline with no documents fails with {@code + * INVALID_ARGUMENT}. + * *

Example: * *

{@code
diff --git a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Delete.java b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Delete.java
index c288f0d42a3b..b31d2ee35493 100644
--- a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Delete.java
+++ b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Delete.java
@@ -16,7 +16,6 @@
 
 package com.google.cloud.firestore.pipeline.stages;
 
-import com.google.api.core.BetaApi;
 import com.google.api.core.InternalApi;
 import com.google.firestore.v1.Value;
 import java.util.ArrayList;
@@ -27,7 +26,6 @@ private Delete(InternalOptions options) {
     super("delete", options);
   }
 
-  @BetaApi
   public Delete() {
     this(InternalOptions.EMPTY);
   }
diff --git a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Insert.java b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Insert.java
new file mode 100644
index 000000000000..c87fab63e16f
--- /dev/null
+++ b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Insert.java
@@ -0,0 +1,126 @@
+/*
+ * 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.
+ * 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 com.google.cloud.firestore.PipelineUtils;
+import com.google.cloud.firestore.pipeline.expressions.Expression;
+import com.google.common.collect.ImmutableMap;
+import com.google.firestore.v1.Value;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+/**
+ * A pipeline stage that inserts each document produced by the previous stages as a new document.
+ *
+ * 

The insert fails with {@code ALREADY_EXISTS} if a target document already exists. + * + *

The target path of each document is determined as follows: + * + *

    + *
  • With neither a collection nor a document ID expression, the input document's own path (its + * {@code __name__} field) is used, so inserting documents read from the database fails with + * {@code ALREADY_EXISTS}, and a document without a {@code __name__} field fails with {@code + * INVALID_ARGUMENT}. + *
  • With only a collection, the document is written to that collection under the input + * document's ID, or under a generated ID if the input document has no {@code __name__}. + *
  • With only a document ID expression, the document is written to the input document's parent + * collection under the ID that the expression evaluates to. + *
  • With both, the document is written to the collection under the ID that the expression + * evaluates to. + *
+ * + *

Example: + * + *

{@code
+ * // Copy a document into another collection under a new ID.
+ * firestore.pipeline()
+ *     .collection("books")
+ *     .where(field("title").equal("Dune"))
+ *     .insert(new Insert().withCollection("archive").withDocumentIdExpression(constant("dune")))
+ *     .execute()
+ *     .get();
+ * }
+ * + * @see com.google.cloud.firestore.Pipeline#insert(Insert) + */ +public final class Insert extends Stage { + + @Nullable private final String collectionPath; + @Nullable private final Expression documentIdExpression; + + private Insert( + @Nullable String collectionPath, + @Nullable Expression documentIdExpression, + InternalOptions options) { + super("insert", buildOptions(collectionPath, documentIdExpression, options)); + this.collectionPath = collectionPath; + this.documentIdExpression = documentIdExpression; + } + + /** + * Creates an insert stage with no target collection and no document ID expression, so each + * document is inserted at the input document's own path. + */ + public Insert() { + this(null, null, InternalOptions.EMPTY); + } + + /** + * Sets the collection that documents are inserted into. + * + * @param collectionPath The path of the target collection, for example {@code "books"} or {@code + * "users/alice/books"}. + * @return A new {@code Insert} stage with the target collection set. + */ + public Insert withCollection(String collectionPath) { + return new Insert(collectionPath, this.documentIdExpression, this.options); + } + + /** + * Sets the expression that produces the ID of each inserted document. + * + * @param documentIdExpression An expression that is evaluated against each input document and + * must evaluate to a document ID (not a document path). Use {@code constant("id")} for a + * fixed ID. + * @return A new {@code Insert} stage with the document ID expression set. + */ + public Insert withDocumentIdExpression(Expression documentIdExpression) { + return new Insert(this.collectionPath, documentIdExpression, this.options); + } + + private static InternalOptions buildOptions( + @Nullable String collectionPath, + @Nullable Expression documentIdExpression, + InternalOptions baseOptions) { + Map optsMap = new HashMap<>(baseOptions.options); + if (collectionPath != null) { + String path = collectionPath.startsWith("/") ? collectionPath : "/" + collectionPath; + optsMap.put("collection", Value.newBuilder().setReferenceValue(path).build()); + } + if (documentIdExpression != null) { + optsMap.put("document_id", PipelineUtils.encodeValue(documentIdExpression)); + } + return new InternalOptions(ImmutableMap.copyOf(optsMap)); + } + + @Override + Iterable toStageArgs() { + return new ArrayList<>(); + } +} diff --git a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Literals.java b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Literals.java index f27e172701c7..a3e18cd51805 100644 --- a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Literals.java +++ b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Literals.java @@ -18,9 +18,11 @@ import com.google.api.core.InternalApi; import com.google.cloud.firestore.PipelineUtils; +import com.google.cloud.firestore.pipeline.expressions.Expression; import com.google.firestore.v1.Value; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -49,11 +51,13 @@ private Value encodeLiteralMap(Map map) { for (Map.Entry entry : map.entrySet()) { String key = String.valueOf(entry.getKey()); Object v = entry.getValue(); - if (v instanceof com.google.cloud.firestore.pipeline.expressions.Expression) { - mapValue.putFields( - key, - PipelineUtils.encodeValue( - (com.google.cloud.firestore.pipeline.expressions.Expression) v)); + if (v instanceof Expression) { + mapValue.putFields(key, PipelineUtils.encodeValue((Expression) v)); + } else if (containsExpression(v)) { + // The backend evaluates expressions only in the top-level fields of a literal document, + // so a map or list that contains a nested expression is sent as a map(...) or array(...) + // expression instead of a raw map or array value. + mapValue.putFields(key, PipelineUtils.encodeValue(toExpression(v))); } else if (v instanceof Map) { mapValue.putFields(key, encodeLiteralMap((Map) v)); } else { @@ -62,4 +66,48 @@ private Value encodeLiteralMap(Map map) { } return Value.newBuilder().setMapValue(mapValue.build()).build(); } + + /** Returns whether {@code value} is a map or list that contains an expression at any depth. */ + private static boolean containsExpression(Object value) { + if (value instanceof Map) { + for (Object child : ((Map) value).values()) { + if (child instanceof Expression || containsExpression(child)) { + return true; + } + } + } else if (value instanceof List) { + for (Object child : (List) value) { + if (child instanceof Expression || containsExpression(child)) { + return true; + } + } + } + return false; + } + + /** + * Converts a map or list that contains nested expressions into an equivalent {@code map(...)} or + * {@code array(...)} expression. Children without expressions are kept as constants. + */ + private static Expression toExpression(Object value) { + if (value instanceof Expression) { + return (Expression) value; + } else if (value instanceof Map) { + Map elements = new LinkedHashMap<>(); + for (Map.Entry entry : ((Map) value).entrySet()) { + elements.put(String.valueOf(entry.getKey()), toExpressionIfNeeded(entry.getValue())); + } + return Expression.map(elements); + } else { + List elements = new ArrayList<>(); + for (Object child : (List) value) { + elements.add(toExpressionIfNeeded(child)); + } + return Expression.array(elements); + } + } + + private static Object toExpressionIfNeeded(Object value) { + return value instanceof Expression || containsExpression(value) ? toExpression(value) : value; + } } diff --git a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/PipelineExecuteOptions.java b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/PipelineExecuteOptions.java index 754215e675f2..b9d52aac6d66 100644 --- a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/PipelineExecuteOptions.java +++ b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/PipelineExecuteOptions.java @@ -17,18 +17,24 @@ package com.google.cloud.firestore.pipeline.stages; public final class PipelineExecuteOptions extends AbstractOptions { + private final boolean atomic; public PipelineExecuteOptions() { - super(InternalOptions.EMPTY); + this(InternalOptions.EMPTY, false); } PipelineExecuteOptions(InternalOptions options) { + this(options, false); + } + + PipelineExecuteOptions(InternalOptions options, boolean atomic) { super(options); + this.atomic = atomic; } @Override PipelineExecuteOptions self(InternalOptions options) { - return new PipelineExecuteOptions(options); + return new PipelineExecuteOptions(options, this.atomic); } public PipelineExecuteOptions withExplainOptions(ExplainOptions options) { @@ -38,4 +44,28 @@ public PipelineExecuteOptions withExplainOptions(ExplainOptions options) { public PipelineExecuteOptions withIndexMode(String indexMode) { return with("index_mode", indexMode); } + + /** + * Sets whether the pipeline is executed atomically. + * + *

When {@code true}, the pipeline runs in a single read-write transaction that is committed + * automatically after the pipeline has executed, so either all of its writes (from {@code + * insert}, {@code upsert}, {@code update} and {@code delete} stages) are applied or none are. The + * default is {@code false}. + * + * @param atomic Whether to execute the pipeline atomically. + * @return A new {@code PipelineExecuteOptions} with the atomic setting applied. + */ + public PipelineExecuteOptions withAtomic(boolean atomic) { + return new PipelineExecuteOptions(this.options, atomic); + } + + /** + * Returns whether the pipeline is executed atomically. The default is {@code false}. + * + * @see #withAtomic(boolean) + */ + public boolean isAtomic() { + return this.atomic; + } } diff --git a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Update.java b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Update.java index 22d029045b77..3ce538018777 100644 --- a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Update.java +++ b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Update.java @@ -16,8 +16,6 @@ package com.google.cloud.firestore.pipeline.stages; -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.Expression; import com.google.cloud.firestore.pipeline.expressions.Selectable; @@ -28,7 +26,13 @@ import java.util.Map; import javax.annotation.Nullable; -@InternalApi +/** + * A pipeline stage that updates, in place, the documents produced by the previous stages. + * + *

Use with {@link com.google.cloud.firestore.Pipeline#update(Update)}, or call {@link + * com.google.cloud.firestore.Pipeline#update()} / {@link + * com.google.cloud.firestore.Pipeline#update(Selectable...)} directly. + */ public final class Update extends Stage { @Nullable private final Selectable[] transformedFields; @@ -38,12 +42,10 @@ private Update(@Nullable Selectable[] transformedFields, InternalOptions options this.transformedFields = transformedFields; } - @BetaApi public Update() { this(null, InternalOptions.EMPTY); } - @BetaApi public Update withTransformedFields(Selectable... transformedFields) { return new Update(transformedFields, this.options); } diff --git a/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Upsert.java b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Upsert.java new file mode 100644 index 000000000000..7a24bad867d4 --- /dev/null +++ b/java-firestore/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Upsert.java @@ -0,0 +1,189 @@ +/* + * 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. + * 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 com.google.cloud.firestore.PipelineUtils; +import com.google.cloud.firestore.pipeline.expressions.Expression; +import com.google.cloud.firestore.pipeline.expressions.Selectable; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firestore.v1.Value; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * A pipeline stage that writes each document produced by the previous stages, creating the target + * document if it doesn't exist and replacing it if it does. Stored fields are not merged. + * + *

The target path of each document is determined as follows: + * + *

    + *
  • With neither a collection nor a document ID expression, the input document's own path (its + * {@code __name__} field) is used. A document without a {@code __name__} field fails with + * {@code INVALID_ARGUMENT}. + *
  • With only a collection, the document is written to that collection under the input + * document's ID, or under a generated ID if the input document has no {@code __name__}. + *
  • With only a document ID expression, the document is written to the input document's parent + * collection under the ID that the expression evaluates to. + *
  • With both, the document is written to the collection under the ID that the expression + * evaluates to. + *
+ * + *

Example: + * + *

{@code
+ * // Set "status" on each matching document and write it back to its own path.
+ * firestore.pipeline()
+ *     .collection("books")
+ *     .where(field("rating").greaterThan(4))
+ *     .upsert(new Upsert().withAdditionalFields(constant("Featured").as("status")))
+ *     .execute()
+ *     .get();
+ * }
+ * + * @see com.google.cloud.firestore.Pipeline#upsert(Upsert) + */ +public final class Upsert extends Stage { + + @Nullable private final ImmutableList additionalFields; + @Nullable private final String collectionPath; + @Nullable private final Expression documentIdExpression; + + private Upsert( + @Nullable ImmutableList additionalFields, + @Nullable String collectionPath, + @Nullable Expression documentIdExpression, + InternalOptions options) { + super("upsert", buildOptions(collectionPath, documentIdExpression, options)); + this.additionalFields = additionalFields; + this.collectionPath = collectionPath; + this.documentIdExpression = documentIdExpression; + } + + /** + * Creates an upsert stage with no additional fields, no target collection and no document ID + * expression, so each document is written back to its own path. + */ + public Upsert() { + this((ImmutableList) null, null, null, InternalOptions.EMPTY); + } + + /** + * Creates an upsert stage that adds the given fields to each input document before it is written + * back to its own path. + * + * @param additionalFields Fields to add to each input document. A field with the same name as an + * existing input field overwrites that field. + */ + public Upsert(Selectable... additionalFields) { + this( + additionalFields != null ? ImmutableList.copyOf(additionalFields) : null, + null, + null, + InternalOptions.EMPTY); + } + + /** + * Sets the fields to add to each input document before it is written. + * + * @param additionalFields Fields to add to each input document. A field with the same name as an + * existing input field overwrites that field. + * @return A new {@code Upsert} stage with the additional fields set. + */ + public Upsert withAdditionalFields(Selectable... additionalFields) { + return new Upsert( + ImmutableList.copyOf(additionalFields), + this.collectionPath, + this.documentIdExpression, + this.options); + } + + /** + * Sets the fields to add to each input document before it is written. + * + * @param additionalFields Fields to add to each input document. A field with the same name as an + * existing input field overwrites that field. + * @return A new {@code Upsert} stage with the additional fields set. + */ + public Upsert withAdditionalFields(List additionalFields) { + return new Upsert( + ImmutableList.copyOf(additionalFields), + this.collectionPath, + this.documentIdExpression, + this.options); + } + + /** + * Sets the collection that documents are written to. + * + * @param collectionPath The path of the target collection, for example {@code "books"} or {@code + * "users/alice/books"}. + * @return A new {@code Upsert} stage with the target collection set. + */ + public Upsert withCollection(String collectionPath) { + return new Upsert( + this.additionalFields, collectionPath, this.documentIdExpression, this.options); + } + + /** + * Sets the expression that produces the ID of each target document. + * + * @param documentIdExpression An expression that is evaluated against each input document and + * must evaluate to a document ID (not a document path). Use {@code constant("id")} for a + * fixed ID. + * @return A new {@code Upsert} stage with the document ID expression set. + */ + public Upsert withDocumentIdExpression(Expression documentIdExpression) { + return new Upsert( + this.additionalFields, this.collectionPath, documentIdExpression, this.options); + } + + private static InternalOptions buildOptions( + @Nullable String collectionPath, + @Nullable Expression documentIdExpression, + InternalOptions baseOptions) { + Map optsMap = new HashMap<>(baseOptions.options); + if (collectionPath != null) { + String path = collectionPath.startsWith("/") ? collectionPath : "/" + collectionPath; + optsMap.put("collection", Value.newBuilder().setReferenceValue(path).build()); + } + if (documentIdExpression != null) { + optsMap.put("document_id", PipelineUtils.encodeValue(documentIdExpression)); + } + return new InternalOptions(ImmutableMap.copyOf(optsMap)); + } + + @Override + Iterable toStageArgs() { + List args = new ArrayList<>(); + if (additionalFields != null && !additionalFields.isEmpty()) { + Map map = + PipelineUtils.selectablesToMap(additionalFields.toArray(new Selectable[0])); + Map encodedMap = new HashMap<>(); + for (Map.Entry entry : map.entrySet()) { + encodedMap.put(entry.getKey(), PipelineUtils.encodeValue(entry.getValue())); + } + args.add(PipelineUtils.encodeValue(encodedMap)); + } else { + args.add(PipelineUtils.encodeValue(new HashMap())); + } + return args; + } +} diff --git a/java-firestore/google-cloud-firestore/src/test/java/com/google/cloud/firestore/PipelineProtoTest.java b/java-firestore/google-cloud-firestore/src/test/java/com/google/cloud/firestore/PipelineProtoTest.java index f62e4ef83f63..eb4bb7d4287b 100644 --- a/java-firestore/google-cloud-firestore/src/test/java/com/google/cloud/firestore/PipelineProtoTest.java +++ b/java-firestore/google-cloud-firestore/src/test/java/com/google/cloud/firestore/PipelineProtoTest.java @@ -16,11 +16,16 @@ package com.google.cloud.firestore; +import static com.google.cloud.firestore.pipeline.expressions.Expression.add; 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.Insert; +import com.google.cloud.firestore.pipeline.stages.PipelineExecuteOptions; import com.google.cloud.firestore.pipeline.stages.Search; +import com.google.cloud.firestore.pipeline.stages.Upsert; +import com.google.firestore.v1.ExecutePipelineRequest; import com.google.firestore.v1.Pipeline.Stage; import com.google.firestore.v1.Value; import org.junit.Test; @@ -103,4 +108,377 @@ public void testSearchStageProtoEncoding() { Value addFields = optionsMap.get("add_fields"); assertThat(addFields.getMapValue().getFieldsMap().get("bar").getBooleanValue()).isTrue(); } + + @Test + public void testInsertStageProtoEncoding() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "Test Book"); + + Pipeline pipeline = + firestore + .pipeline() + .literals(data) + .insert( + new com.google.cloud.firestore.pipeline.stages.Insert() + .withCollection("books") + .withDocumentIdExpression(constant("book1"))); + + com.google.firestore.v1.Pipeline protoPipeline = pipeline.toProto(); + assertThat(protoPipeline.getStagesCount()).isEqualTo(2); + + Stage insertStage = protoPipeline.getStages(1); + assertThat(insertStage.getName()).isEqualTo("insert"); + assertThat(insertStage.getArgsCount()).isEqualTo(0); + + java.util.Map optionsMap = insertStage.getOptionsMap(); + assertThat(optionsMap.get("collection").getReferenceValue()).isEqualTo("/books"); + assertThat(optionsMap.get("document_id").getStringValue()).isEqualTo("book1"); + } + + @Test + public void testUpsertStageProtoEncoding() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "Upsert Book"); + data.put("count", 1); + + Pipeline pipeline = + firestore + .pipeline() + .literals(data) + .upsert( + new com.google.cloud.firestore.pipeline.stages.Upsert() + .withAdditionalFields( + com.google.cloud.firestore.pipeline.expressions.Expression.add( + field("count"), constant(1)) + .as("count")) + .withCollection("books") + .withDocumentIdExpression(constant("book1"))); + + com.google.firestore.v1.Pipeline protoPipeline = pipeline.toProto(); + assertThat(protoPipeline.getStagesCount()).isEqualTo(2); + + Stage upsertStage = protoPipeline.getStages(1); + assertThat(upsertStage.getName()).isEqualTo("upsert"); + assertThat(upsertStage.getArgsCount()).isEqualTo(1); + assertThat(upsertStage.getArgs(0).getMapValue().getFieldsMap()).containsKey("count"); + + java.util.Map optionsMap = upsertStage.getOptionsMap(); + assertThat(optionsMap.get("collection").getReferenceValue()).isEqualTo("/books"); + assertThat(optionsMap.get("document_id").getStringValue()).isEqualTo("book1"); + + // Test withAdditionalFields with List + Pipeline pipelineWithList = + firestore + .pipeline() + .literals(data) + .upsert( + new com.google.cloud.firestore.pipeline.stages.Upsert() + .withAdditionalFields( + java.util.Collections.singletonList( + com.google.cloud.firestore.pipeline.expressions.Expression.add( + field("count"), constant(1)) + .as("count"))) + .withCollection("books") + .withDocumentIdExpression(constant("book1"))); + Stage upsertStageWithList = pipelineWithList.toProto().getStages(1); + assertThat(upsertStageWithList.getArgs(0).getMapValue().getFieldsMap()).containsKey("count"); + } + + @Test + public void testAtomicExecutionOptionsConfigureNewTransactionAndAutoCommitTransaction() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "Atomic Book"); + + Pipeline pipeline = + firestore + .pipeline() + .literals(data) + .insert( + new com.google.cloud.firestore.pipeline.stages.Insert() + .withCollection("books") + .withDocumentIdExpression(constant("book1"))); + + PipelineExecuteOptions executeOptions = new PipelineExecuteOptions().withAtomic(true); + ExecutePipelineRequest request = pipeline.toExecutePipelineRequest(executeOptions, null, null); + + assertThat(request.hasNewTransaction()).isTrue(); + assertThat(request.getNewTransaction().hasReadWrite()).isTrue(); + assertThat(request.getAutoCommitTransaction()).isTrue(); + assertThat(request.getStructuredPipeline().getOptionsMap()).doesNotContainKey("atomic"); + } + + @Test + public void testNonAtomicExecutionOptionsDoNotConfigureNewTransactionOrAutoCommitTransaction() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "Non-Atomic Book"); + + Pipeline pipeline = + firestore + .pipeline() + .literals(data) + .insert( + new com.google.cloud.firestore.pipeline.stages.Insert() + .withCollection("books") + .withDocumentIdExpression(constant("book1"))); + + PipelineExecuteOptions executeOptionsDisabled = new PipelineExecuteOptions().withAtomic(false); + ExecutePipelineRequest requestDisabled = + pipeline.toExecutePipelineRequest(executeOptionsDisabled, null, null); + assertThat(requestDisabled.hasNewTransaction()).isFalse(); + assertThat(requestDisabled.getAutoCommitTransaction()).isFalse(); + assertThat(requestDisabled.getStructuredPipeline().getOptionsMap()).doesNotContainKey("atomic"); + + PipelineExecuteOptions executeOptionsDefault = new PipelineExecuteOptions(); + ExecutePipelineRequest requestDefault = + pipeline.toExecutePipelineRequest(executeOptionsDefault, null, null); + assertThat(requestDefault.hasNewTransaction()).isFalse(); + assertThat(requestDefault.getAutoCommitTransaction()).isFalse(); + assertThat(requestDefault.getStructuredPipeline().getOptionsMap()).doesNotContainKey("atomic"); + } + + @Test + public void testDeleteStageProtoEncoding() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + Pipeline pipeline = firestore.pipeline().collection("books").delete(); + + com.google.firestore.v1.Pipeline protoPipeline = pipeline.toProto(); + assertThat(protoPipeline.getStagesCount()).isEqualTo(2); + + Stage deleteStage = protoPipeline.getStages(1); + assertThat(deleteStage.getName()).isEqualTo("delete"); + assertThat(deleteStage.getArgsCount()).isEqualTo(0); + assertThat(deleteStage.getOptionsCount()).isEqualTo(0); + } + + @Test + public void testUpdateStageProtoEncoding() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + // 0-arg update() produces 1 empty MapValue arg and 0 options + Pipeline pipeline0 = firestore.pipeline().collection("books").update(); + com.google.firestore.v1.Pipeline protoPipeline0 = pipeline0.toProto(); + assertThat(protoPipeline0.getStagesCount()).isEqualTo(2); + + Stage updateStage0 = protoPipeline0.getStages(1); + assertThat(updateStage0.getName()).isEqualTo("update"); + assertThat(updateStage0.getArgsCount()).isEqualTo(1); + assertThat(updateStage0.getArgs(0).hasMapValue()).isTrue(); + assertThat(updateStage0.getArgs(0).getMapValue().getFieldsCount()).isEqualTo(0); + assertThat(updateStage0.getOptionsCount()).isEqualTo(0); + + // update with vararg Selectables produces 1 MapValue arg with field mappings and 0 options + Pipeline pipeline = + firestore + .pipeline() + .collection("books") + .update(constant("Updated").as("status"), add(field("count"), constant(1)).as("count")); + com.google.firestore.v1.Pipeline protoPipeline = pipeline.toProto(); + assertThat(protoPipeline.getStagesCount()).isEqualTo(2); + + Stage updateStage = protoPipeline.getStages(1); + assertThat(updateStage.getName()).isEqualTo("update"); + assertThat(updateStage.getArgsCount()).isEqualTo(1); + assertThat(updateStage.getArgs(0).hasMapValue()).isTrue(); + java.util.Map fieldsMap = updateStage.getArgs(0).getMapValue().getFieldsMap(); + assertThat(fieldsMap).containsKey("status"); + assertThat(fieldsMap.get("status").getStringValue()).isEqualTo("Updated"); + assertThat(fieldsMap).containsKey("count"); + assertThat(updateStage.getOptionsCount()).isEqualTo(0); + } + + @Test + public void testInsertStageWithoutDocumentIdProtoEncoding() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "Auto ID Book"); + + Pipeline pipeline = + firestore.pipeline().literals(data).insert(new Insert().withCollection("books")); + + com.google.firestore.v1.Pipeline protoPipeline = pipeline.toProto(); + assertThat(protoPipeline.getStagesCount()).isEqualTo(2); + + Stage insertStage = protoPipeline.getStages(1); + assertThat(insertStage.getName()).isEqualTo("insert"); + assertThat(insertStage.getArgsCount()).isEqualTo(0); + + java.util.Map optionsMap = insertStage.getOptionsMap(); + assertThat(optionsMap.get("collection").getReferenceValue()).isEqualTo("/books"); + assertThat(optionsMap).doesNotContainKey("document_id"); + } + + @Test + public void testUpsertStageWithoutAdditionalFieldsProtoEncoding() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "Upsert Book"); + + Pipeline pipeline = + firestore + .pipeline() + .literals(data) + .upsert( + new Upsert().withCollection("books").withDocumentIdExpression(constant("book1"))); + + com.google.firestore.v1.Pipeline protoPipeline = pipeline.toProto(); + assertThat(protoPipeline.getStagesCount()).isEqualTo(2); + + Stage upsertStage = protoPipeline.getStages(1); + assertThat(upsertStage.getName()).isEqualTo("upsert"); + assertThat(upsertStage.getArgsCount()).isEqualTo(1); + assertThat(upsertStage.getArgs(0).hasMapValue()).isTrue(); + assertThat(upsertStage.getArgs(0).getMapValue().getFieldsCount()).isEqualTo(0); + + java.util.Map optionsMap = upsertStage.getOptionsMap(); + assertThat(optionsMap.get("collection").getReferenceValue()).isEqualTo("/books"); + assertThat(optionsMap.get("document_id").getStringValue()).isEqualTo("book1"); + } + + @Test + public void testLiteralsStageProtoEncoding() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + java.util.Map doc1 = new java.util.HashMap<>(); + doc1.put("title", "Book 1"); + doc1.put("author", "Author 1"); + + java.util.Map doc2 = new java.util.HashMap<>(); + doc2.put("title", "Book 2"); + doc2.put("calc", constant(42)); + + Pipeline pipeline = firestore.pipeline().literals(doc1, doc2); + com.google.firestore.v1.Pipeline protoPipeline = pipeline.toProto(); + assertThat(protoPipeline.getStagesCount()).isEqualTo(1); + + Stage literalsStage = protoPipeline.getStages(0); + assertThat(literalsStage.getName()).isEqualTo("literals"); + assertThat(literalsStage.getArgsCount()).isEqualTo(2); + assertThat(literalsStage.getOptionsCount()).isEqualTo(0); + + java.util.Map doc1Fields = literalsStage.getArgs(0).getMapValue().getFieldsMap(); + assertThat(doc1Fields.get("title").getStringValue()).isEqualTo("Book 1"); + assertThat(doc1Fields.get("author").getStringValue()).isEqualTo("Author 1"); + + java.util.Map doc2Fields = literalsStage.getArgs(1).getMapValue().getFieldsMap(); + assertThat(doc2Fields.get("title").getStringValue()).isEqualTo("Book 2"); + assertThat(doc2Fields.get("calc").getIntegerValue()).isEqualTo(42L); + } + + @Test + public void testLiteralsWithNestedExpressionsProtoEncoding() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + java.util.Map inner = new java.util.HashMap<>(); + inner.put("sum", add(constant(1), constant(2))); + inner.put("plain", 1); + java.util.Map constantMap = new java.util.HashMap<>(); + constantMap.put("stringValue", "abc"); + + java.util.Map doc = new java.util.HashMap<>(); + doc.put("top", add(constant(1), constant(1))); + doc.put("nestedMap", inner); + doc.put("nestedList", java.util.Arrays.asList(1, add(constant(2), constant(3)))); + doc.put("constantMap", constantMap); + doc.put("constantList", java.util.Arrays.asList(1, 2)); + + Stage literalsStage = firestore.pipeline().literals(doc).toProto().getStages(0); + java.util.Map fields = literalsStage.getArgs(0).getMapValue().getFieldsMap(); + + assertThat(fields.get("top").getFunctionValue().getName()).isEqualTo("add"); + + assertThat(fields.get("nestedMap").getFunctionValue().getName()).isEqualTo("map"); + assertThat(fields.get("nestedList").getFunctionValue().getName()).isEqualTo("array"); + java.util.List listArgs = fields.get("nestedList").getFunctionValue().getArgsList(); + assertThat(listArgs.get(0).getIntegerValue()).isEqualTo(1L); + assertThat(listArgs.get(1).getFunctionValue().getName()).isEqualTo("add"); + + // Maps and lists without expressions are encoded as plain map and array values. + assertThat(fields.get("constantMap").hasMapValue()).isTrue(); + assertThat( + fields + .get("constantMap") + .getMapValue() + .getFieldsMap() + .get("stringValue") + .getStringValue()) + .isEqualTo("abc"); + assertThat(fields.get("constantList").getArrayValue().getValuesCount()).isEqualTo(2); + } + + @Test + public void testInsertConvenienceOverloadProtoEncoding() { + FirestoreOptions options = + FirestoreOptions.newBuilder() + .setProjectId("new-project") + .setDatabaseId("(default)") + .build(); + Firestore firestore = options.getService(); + + com.google.firestore.v1.Pipeline insertProto = + firestore.pipeline().collection("books").insert().toProto(); + assertThat(insertProto) + .isEqualTo(firestore.pipeline().collection("books").insert(new Insert()).toProto()); + assertThat(insertProto.getStages(1).getName()).isEqualTo("insert"); + } } diff --git a/java-firestore/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java b/java-firestore/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java index 3af75da42fe7..12f746f83654 100644 --- a/java-firestore/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java +++ b/java-firestore/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java @@ -126,6 +126,7 @@ import com.google.cloud.firestore.LocalFirestoreHelper; import com.google.cloud.firestore.Pipeline; import com.google.cloud.firestore.PipelineResult; +import com.google.cloud.firestore.QuerySnapshot; import com.google.cloud.firestore.pipeline.expressions.AggregateFunction; import com.google.cloud.firestore.pipeline.expressions.Expression; import com.google.cloud.firestore.pipeline.expressions.Field; @@ -137,11 +138,13 @@ import com.google.cloud.firestore.pipeline.stages.ExplainOptions; import com.google.cloud.firestore.pipeline.stages.FindNearest; import com.google.cloud.firestore.pipeline.stages.FindNearestOptions; +import com.google.cloud.firestore.pipeline.stages.Insert; import com.google.cloud.firestore.pipeline.stages.PipelineExecuteOptions; import com.google.cloud.firestore.pipeline.stages.RawOptions; import com.google.cloud.firestore.pipeline.stages.RawStage; import com.google.cloud.firestore.pipeline.stages.Sample; import com.google.cloud.firestore.pipeline.stages.UnnestOptions; +import com.google.cloud.firestore.pipeline.stages.Upsert; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; @@ -4765,4 +4768,470 @@ public void testLiteralsWithExpressions() throws Exception { assertThat(results.get(0).getData().get("base")).isEqualTo(10L); assertThat(results.get(0).getData().get("doubled")).isEqualTo(20L); } + + @Test + public void testInsertStage() throws Exception { + CollectionReference dmlCol = firestore.collection(LocalFirestoreHelper.autoId()); + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "New Book"); + data.put("author", "Author 1"); + + List results = + firestore + .pipeline() + .literals(data) + .insert( + new Insert() + .withCollection(dmlCol.getPath()) + .withDocumentIdExpression(constant("newBook_insert_1"))) + .execute() + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + + DocumentSnapshot snap = dmlCol.document("newBook_insert_1").get().get(); + assertThat(snap.exists()).isTrue(); + assertThat(snap.get("title")).isEqualTo("New Book"); + } + + @Test + public void testUpsertStageWithTransforms() throws Exception { + CollectionReference dmlCol = firestore.collection(LocalFirestoreHelper.autoId()); + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "Upserted Book"); + data.put("count", 1); + + List results = + firestore + .pipeline() + .literals(data) + .upsert( + new Upsert(add(field("count"), constant(1)).as("count")) + .withCollection(dmlCol.getPath()) + .withDocumentIdExpression(constant("upsertBook_1"))) + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + + DocumentSnapshot snap = dmlCol.document("upsertBook_1").get().get(); + assertThat(snap.exists()).isTrue(); + assertThat(snap.get("title")).isEqualTo("Upserted Book"); + } + + @Test + public void testDMLWithAtomicOption() throws Exception { + CollectionReference dmlCol = firestore.collection(LocalFirestoreHelper.autoId()); + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "Atomic Book"); + + List results = + firestore + .pipeline() + .literals(data) + .insert( + new Insert() + .withCollection(dmlCol.getPath()) + .withDocumentIdExpression(constant("atomicBook_1"))) + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + + DocumentSnapshot snap = dmlCol.document("atomicBook_1").get().get(); + assertThat(snap.exists()).isTrue(); + } + + @Test + public void testInsertUpsertStagesInsideTransaction() throws Exception { + CollectionReference dmlCol = firestore.collection(LocalFirestoreHelper.autoId()); + firestore + .runTransaction( + transaction -> { + java.util.Map data = new java.util.HashMap<>(); + data.put("title", "Tx Book"); + Pipeline insertPpl = + firestore + .pipeline() + .literals(data) + .insert( + new Insert() + .withCollection(dmlCol.getPath()) + .withDocumentIdExpression(constant("txBook_1"))); + List res = transaction.execute(insertPpl).get().getResults(); + assertThat(res).hasSize(1); + assertThat(res.get(0).getData().get("documents_modified")).isEqualTo(1L); + return null; + }) + .get(); + + DocumentSnapshot snap = dmlCol.document("txBook_1").get().get(); + assertThat(snap.exists()).isTrue(); + assertThat(snap.get("title")).isEqualTo("Tx Book"); + } + + @Test + public void testDeleteNonExistingDocument() throws Exception { + CollectionReference dmlCol = firestore.collection(LocalFirestoreHelper.autoId()); + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("nonExistingId_999"))) + .delete() + .execute() + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(0L); + } + + @Test + public void testDeleteWithAtomicTrue() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .delete() + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + assertThat(dmlCol.document("book1").get().get().exists()).isFalse(); + } + + @Test + public void testDeleteWithAtomicFalse() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book2"))) + .delete() + .execute(new PipelineExecuteOptions().withAtomic(false)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + assertThat(dmlCol.document("book2").get().get().exists()).isFalse(); + } + + @Test + public void testDeleteStageInsideTransaction() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + firestore + .runTransaction( + transaction -> { + Pipeline deletePpl = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .delete(); + List res = transaction.execute(deletePpl).get().getResults(); + assertThat(res).hasSize(1); + assertThat(res.get(0).getData().get("documents_modified")).isEqualTo(1L); + return null; + }) + .get(); + + assertThat(dmlCol.document("book1").get().get().exists()).isFalse(); + } + + @Test + public void testUpdateSingleDocWithAddFields() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book3"))) + .addFields(field("__name__").documentId().as("id")) + .update(constant("baz").as("foo")) + .execute() + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + DocumentSnapshot snap = dmlCol.document("book3").get().get(); + assertThat(snap.get("foo")).isEqualTo("baz"); + assertThat(snap.get("id")).isEqualTo("book3"); + } + + @Test + public void testUpdateWithMultipleVarargExpressions() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .update(constant("UpdatedMulti").as("status"), constant(99L).as("newField")) + .execute() + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + DocumentSnapshot snap = dmlCol.document("book1").get().get(); + assertThat(snap.get("status")).isEqualTo("UpdatedMulti"); + assertThat(snap.get("newField")).isEqualTo(99L); + } + + @Test + public void testUpdateAtomically() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .update(constant("AtomicUpdate").as("status")) + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + DocumentSnapshot snap = dmlCol.document("book1").get().get(); + assertThat(snap.get("status")).isEqualTo("AtomicUpdate"); + } + + @Test + public void testUpdateStageInsideTransaction() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + firestore + .runTransaction( + transaction -> { + Pipeline updatePpl = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .update(constant("TxUpdate").as("status")); + List res = transaction.execute(updatePpl).get().getResults(); + assertThat(res).hasSize(1); + assertThat(res.get(0).getData().get("documents_modified")).isEqualTo(1L); + return null; + }) + .get(); + + assertThat(dmlCol.document("book1").get().get().get("status")).isEqualTo("TxUpdate"); + } + + @Test + public void testInsertAutoGeneratedId() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .removeFields("__name__") + .insert(new Insert().withCollection(dmlCol.getPath())) + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + QuerySnapshot allDocs = dmlCol.get().get(); + assertThat(allDocs.size()).isEqualTo(bookDocs.size() + 1); + } + + @Test + public void testInsertFailsWhenDocumentAlreadyExists() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + assertThrows( + Exception.class, + () -> + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .insert( + new Insert() + .withCollection(dmlCol.getPath()) + .withDocumentIdExpression(constant("book2"))) + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get()); + } + + @Test + public void testInsertIntoDifferentCollection() throws Exception { + CollectionReference sourceCol = testCollectionWithDocs(bookDocs); + CollectionReference targetCol = firestore.collection(LocalFirestoreHelper.autoId()); + List results = + firestore + .pipeline() + .collection(sourceCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .insert(new Insert().withCollection(targetCol.getPath())) + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + QuerySnapshot targetDocs = targetCol.get().get(); + assertThat(targetDocs.size()).isEqualTo(1); + assertThat(targetDocs.getDocuments().get(0).getString("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy"); + } + + @Test + public void testInsertInPlaceFromLiterals() throws Exception { + CollectionReference dmlCol = firestore.collection(LocalFirestoreHelper.autoId()); + DocumentReference target = dmlCol.document("literal_doc"); + Map doc = new HashMap<>(); + doc.put("__name__", target); + doc.put("title", "Inserted In Place"); + + List results = + firestore + .pipeline() + .literals(doc) + .insert() + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + assertThat(target.get().get().getString("title")).isEqualTo("Inserted In Place"); + } + + @Test + public void testUpsertInPlaceWithVarargs() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .upsert( + constant("Comedy Sci-Fi").as("genre"), + add(field("rating"), constant(0.5)).as("rating")) + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + DocumentSnapshot snap = dmlCol.document("book1").get().get(); + assertThat(snap.get("genre")).isEqualTo("Comedy Sci-Fi"); + assertThat(snap.get("rating")).isEqualTo(4.7); + } + + @Test + public void testUpsertInPlaceWithListViaStage() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .upsert( + new Upsert() + .withAdditionalFields( + java.util.Arrays.asList( + constant("Updated Genre List").as("genre"), + constant(5.0).as("rating")))) + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + DocumentSnapshot snap = dmlCol.document("book1").get().get(); + assertThat(snap.get("genre")).isEqualTo("Updated Genre List"); + assertThat(snap.get("rating")).isEqualTo(5.0); + } + + @Test + public void testUpsertIntoDifferentCollectionWithoutAdditionalFields() throws Exception { + CollectionReference sourceCol = testCollectionWithDocs(bookDocs); + CollectionReference targetCol = firestore.collection(LocalFirestoreHelper.autoId()); + List results = + firestore + .pipeline() + .collection(sourceCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .upsert( + new Upsert() + .withCollection(targetCol.getPath()) + .withDocumentIdExpression(constant("target_doc_2"))) + .execute(new PipelineExecuteOptions().withAtomic(true)) + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(1L); + DocumentSnapshot snap = targetCol.document("target_doc_2").get().get(); + assertThat(snap.exists()).isTrue(); + assertThat(snap.getString("title")).isEqualTo("The Hitchhiker's Guide to the Galaxy"); + } + + @Test + public void testUpsertStageInsideTransaction() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + firestore + .runTransaction( + transaction -> { + Pipeline upsertPpl = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), constant("book1"))) + .upsert(constant("TxUpsert").as("status")); + List res = transaction.execute(upsertPpl).get().getResults(); + assertThat(res).hasSize(1); + assertThat(res.get(0).getData().get("documents_modified")).isEqualTo(1L); + return null; + }) + .get(); + + assertThat(dmlCol.document("book1").get().get().get("status")).isEqualTo("TxUpsert"); + } + + @Test + public void testLiteralsWithNestedExpressions() throws Exception { + java.util.Map deepest = new java.util.HashMap<>(); + deepest.put("product", multiply(constant(2), constant(3))); + java.util.Map nested = new java.util.HashMap<>(); + nested.put("sum", add(constant(1), constant(2))); + nested.put("plain", "value"); + nested.put("deeper", deepest); + + java.util.Map data = new java.util.HashMap<>(); + data.put("nestedMap", nested); + data.put("nestedList", java.util.Arrays.asList(1L, add(constant(1), constant(2)))); + + List results = firestore.pipeline().literals(data).execute().get().getResults(); + + assertThat(results).hasSize(1); + java.util.Map expectedDeepest = new java.util.HashMap<>(); + expectedDeepest.put("product", 6L); + java.util.Map expectedNested = new java.util.HashMap<>(); + expectedNested.put("sum", 3L); + expectedNested.put("plain", "value"); + expectedNested.put("deeper", expectedDeepest); + assertThat(results.get(0).getData().get("nestedMap")).isEqualTo(expectedNested); + assertThat(results.get(0).getData().get("nestedList")) + .isEqualTo(java.util.Arrays.asList(1L, 3L)); + } }