diff --git a/.agents/skills/verify-local-changes/SKILL.md b/.agents/skills/verify-local-changes/SKILL.md new file mode 100644 index 000000000..02db50857 --- /dev/null +++ b/.agents/skills/verify-local-changes/SKILL.md @@ -0,0 +1,95 @@ +--- +name: Verify Local Changes +description: Verifies local Java SDK changes. +--- + +# Verify Local Changes + +This skill documents how to verify local code changes for the Java Firestore SDK. This should be run **every time** you complete a fix or feature and are prepared to push a pull request. + +## Prerequisites + +Ensure you have Maven installed and are in the `java-firestore` directory before running commands. + +--- + +## Step 0: Format the Code + +Run the formatter to ensure formatting checks pass: + +```bash +mvn com.spotify.fmt:fmt-maven-plugin:format +``` + +--- + +## Step 1: Unit Testing (Isolated then Suite) + +1. **Identify modified unit tests** in your changes. +2. **Run specific units only** to test isolated logic regressions: + ```bash + mvn test -Dtest=MyUnitTest#testMethod + ``` +3. **Run the entire unit test suite** that contains those modified tests if the isolated unit tests pass: + ```bash + mvn test -Dtest=MyUnitTest + ``` + +--- + +## Step 2: Integration Testing (Isolated then Suite) + +### 💡 Integration Test Nuances (from `ITBaseTest.java`) + +When running integration tests, configure your execution using properties or environment variables: + +- **`FIRESTORE_EDITION`**: + - `standard` (Default) + - `enterprise` + - *Note*: **Pipelines can only be run against `enterprise` editions**, while standard Queries run on both. +- **`FIRESTORE_NAMED_DATABASE`**: + - Enterprise editions usually require a named database (often `enterprise`). Adjust this flag if pointing to specific instances. +- **`FIRESTORE_TARGET_BACKEND`**: + - `PROD` (Default) + - `QA` (points to standard sandboxes) + - `NIGHTLY` (points to `test-firestore.sandbox.googleapis.com:443`) + - `EMULATOR` (points to `localhost:8080`) + +1. **Identify modified integration tests** (usually Starting in `IT`). +2. **Run specific integration tests only** (isolated checks run quicker): + ```bash + mvn verify -Penable-integration-tests -DFIRESTORE_EDITION=enterprise -DFIRESTORE_NAMED_DATABASE=enterprise -Dtest=ITTest#testMethod -Dclirr.skip=true -Denforcer.skip=true -fae + ``` +3. **Run the entire integration test suite** for the modified class if isolation tests pass: + ```bash + mvn verify -Penable-integration-tests -DFIRESTORE_EDITION=enterprise -DFIRESTORE_NAMED_DATABASE=enterprise -Dtest=ITTest -Dclirr.skip=true -Denforcer.skip=true -fae + ``` + + + +--- + +## Step 3: Full Suite Regressions + +Run the full integration regression suite once you are confident subsets pass: + +```bash +mvn verify -Penable-integration-tests -DFIRESTORE_EDITION=enterprise -DFIRESTORE_NAMED_DATABASE=enterprise -Dclirr.skip=true -Denforcer.skip=true -fae +``` + +--- + +> [!TIP] +> Use `-Dclirr.skip=true -Denforcer.skip=true` to speed up iterations where appropriate without leaking compliance checks. + +--- + +## 🛠️ Troubleshooting & Source of Truth + +If you run into issues executing tests with the commands above, **consult the Kokoro configuration files** as the ultimate source of truth: + +- **Presubmit configurations**: See `.kokoro/presubmit/integration.cfg` (or `integration-named-db.cfg`) +- **Nightly configurations**: See `.kokoro/nightly/integration.cfg` +- **Build shell scripts**: See `.kokoro/build.sh` + +These files define the exact environment variables (e.g., specific endpoints or endpoints overrides) the CI server uses! 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..5970192d6 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 @@ -41,6 +41,7 @@ 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.Delete; import com.google.cloud.firestore.pipeline.stages.Distinct; import com.google.cloud.firestore.pipeline.stages.FindNearest; import com.google.cloud.firestore.pipeline.stages.FindNearestOptions; @@ -58,6 +59,7 @@ import com.google.cloud.firestore.pipeline.stages.Union; 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.Where; import com.google.cloud.firestore.telemetry.MetricsUtil.MetricsContext; import com.google.cloud.firestore.telemetry.TelemetryConstants; @@ -996,7 +998,119 @@ public Pipeline unnest(Selectable field, UnnestOptions options) { } /** - * Adds a generic stage to the pipeline. + * Performs a delete operation on documents from previous stages. + * + *

Example: + * + *

{@code
+   * // Delete all documents in the "logs" collection where "status" is "archived"
+   * firestore.pipeline()
+   *     .collection("logs")
+   *     .where(field("status").equal("archived"))
+   *     .delete()
+   *     .execute()
+   *     .get();
+   * }
+ * + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + @BetaApi + public Pipeline delete() { + return append(new Delete()); + } + + /** + * Performs an update operation using documents from previous stages. + * + *

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

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

{@code
+   * firestore.pipeline()
+   *     .collection("books")
+   *     .addFields(constant("Fiction").as("genre"))
+   *     .removeFields("old_genre")
+   *     .update()
+   *     .execute()
+   *     .get();
+   * }
+ * + *

Example 2: Update documents in place with data from literals. + * + *

{@code
+   * Map updateData = new HashMap<>();
+   * updateData.put("__name__", firestore.collection("books").document("book1"));
+   * updateData.put("status", "Updated");
+   *
+   * firestore.pipeline()
+   *     .literals(updateData)
+   *     .update()
+   *     .execute()
+   *     .get();
+   * }
+ * + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + @BetaApi + public Pipeline update() { + return append(new Update()); + } + + /** + * Performs an update operation using documents from previous stages with specified + * transformations. + * + *

Example: + * + *

{@code
+   * // Update the "status" field to "Discounted" for all books where price > 50
+   * firestore.pipeline()
+   *     .collection("books")
+   *     .where(field("price").greaterThan(50))
+   *     .update(constant("Discounted").as("status"))
+   *     .execute()
+   *     .get();
+   * }
+ * + * @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)); + } + + /** + * Performs an update operation using an {@link Update} stage. + * + *

This method allows you to use a pre-configured {@link Update} stage. + * + *

Example: + * + *

{@code
+   * Update updateStage = new Update().withTransformedFields(constant("Updated").as("status"));
+   *
+   * firestore.pipeline()
+   *     .collection("books")
+   *     .where(field("title").equal("The Hitchhiker's Guide to the Galaxy"))
+   *     .update(updateStage)
+   *     .execute()
+   *     .get();
+   * }
+ * + * @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. * *

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 diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/PipelineSource.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/PipelineSource.java index 207ddd7f8..308782fa3 100644 --- a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/PipelineSource.java +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/PipelineSource.java @@ -24,6 +24,7 @@ import com.google.cloud.firestore.pipeline.stages.CollectionOptions; import com.google.cloud.firestore.pipeline.stages.Database; import com.google.cloud.firestore.pipeline.stages.Documents; +import com.google.cloud.firestore.pipeline.stages.Literals; import com.google.common.base.Preconditions; import java.util.Arrays; import javax.annotation.Nonnull; @@ -157,6 +158,32 @@ public Pipeline documents(String... docs) { .toArray(DocumentReference[]::new))); } + /** + * Creates a new {@link Pipeline} that operates on a static set of documents represented as Maps. + * + *

Example: + * + *

{@code
+   * Map doc1 = new HashMap<>();
+   * doc1.put("title", "Book 1");
+   * Map doc2 = new HashMap<>();
+   * doc2.put("title", "Book 2");
+   *
+   * Snapshot snapshot = firestore.pipeline()
+   *     .literals(doc1, doc2)
+   *     .execute()
+   *     .get();
+   * }
+ * + * @param data The Maps representing documents to include in the pipeline. + * @return A new {@code Pipeline} instance with a literals source. + */ + @Nonnull + @BetaApi + public final Pipeline literals(java.util.Map... data) { + return new Pipeline(this.rpcContext, new Literals(data)); + } + /** * Creates a new {@link Pipeline} from the given {@link Query}. Under the hood, this will * translate the query semantics (order by document ID, etc.) to an equivalent pipeline. diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Delete.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Delete.java new file mode 100644 index 000000000..c288f0d42 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Delete.java @@ -0,0 +1,39 @@ +/* + * 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.api.core.BetaApi; +import com.google.api.core.InternalApi; +import com.google.firestore.v1.Value; +import java.util.ArrayList; + +@InternalApi +public final class Delete extends Stage { + private Delete(InternalOptions options) { + super("delete", options); + } + + @BetaApi + public Delete() { + this(InternalOptions.EMPTY); + } + + @Override + Iterable toStageArgs() { + return new ArrayList<>(); + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Literals.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Literals.java new file mode 100644 index 000000000..d3d5e5272 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Literals.java @@ -0,0 +1,67 @@ +/* + * 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.api.core.BetaApi; +import com.google.api.core.InternalApi; +import com.google.cloud.firestore.PipelineUtils; +import com.google.firestore.v1.Value; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +@InternalApi +public final class Literals extends Stage { + + private final List> data; + + @BetaApi + public Literals(Map... data) { + super("literals", InternalOptions.EMPTY); + this.data = Arrays.asList(data); + } + + @Override + Iterable toStageArgs() { + List args = new ArrayList<>(); + for (Map map : data) { + args.add(encodeLiteralMap(map)); + } + return args; + } + + private Value encodeLiteralMap(Map map) { + com.google.firestore.v1.MapValue.Builder mapValue = + com.google.firestore.v1.MapValue.newBuilder(); + 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)); + } else if (v instanceof Map) { + mapValue.putFields(key, encodeLiteralMap((Map) v)); + } else { + mapValue.putFields(key, PipelineUtils.encodeValue(v)); + } + } + return Value.newBuilder().setMapValue(mapValue.build()).build(); + } +} diff --git a/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Update.java b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Update.java new file mode 100644 index 000000000..22d029045 --- /dev/null +++ b/google-cloud-firestore/src/main/java/com/google/cloud/firestore/pipeline/stages/Update.java @@ -0,0 +1,66 @@ +/* + * 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.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; +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; + +@InternalApi +public final class Update extends Stage { + + @Nullable private final Selectable[] transformedFields; + + private Update(@Nullable Selectable[] transformedFields, InternalOptions options) { + super("update", options); + this.transformedFields = transformedFields; + } + + @BetaApi + public Update() { + this(null, InternalOptions.EMPTY); + } + + @BetaApi + public Update withTransformedFields(Selectable... transformedFields) { + return new Update(transformedFields, this.options); + } + + @Override + Iterable toStageArgs() { + List args = new ArrayList<>(); + if (transformedFields != null && transformedFields.length > 0) { + Map map = PipelineUtils.selectablesToMap(transformedFields); + 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/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java index c9b78f11a..091b7cf92 100644 --- a/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java +++ b/google-cloud-firestore/src/test/java/com/google/cloud/firestore/it/ITPipelineTest.java @@ -67,6 +67,7 @@ import static com.google.cloud.firestore.pipeline.expressions.Expression.logicalMinimum; import static com.google.cloud.firestore.pipeline.expressions.Expression.mapMerge; import static com.google.cloud.firestore.pipeline.expressions.Expression.mapRemove; +import static com.google.cloud.firestore.pipeline.expressions.Expression.multiply; import static com.google.cloud.firestore.pipeline.expressions.Expression.nor; import static com.google.cloud.firestore.pipeline.expressions.Expression.notEqual; import static com.google.cloud.firestore.pipeline.expressions.Expression.nullValue; @@ -102,6 +103,7 @@ import com.google.cloud.Timestamp; import com.google.cloud.firestore.Blob; import com.google.cloud.firestore.CollectionReference; +import com.google.cloud.firestore.DocumentSnapshot; import com.google.cloud.firestore.Firestore; import com.google.cloud.firestore.FirestoreOptions; import com.google.cloud.firestore.GeoPoint; @@ -3773,4 +3775,164 @@ public void disallowDuplicateAliasesAcrossStages() { }); assertThat(exception).hasMessageThat().contains("Duplicate alias or field name"); } + + @Test + public void testDeleteMultipleDocuments() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + if ("NIGHTLY".equals(getTargetBackend())) { + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("genre"), "Science Fiction")) + .delete() + .execute() + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(2L); + assertThat(dmlCol.document("book1").get().get().exists()).isFalse(); + assertThat(dmlCol.document("book10").get().get().exists()).isFalse(); + } else { + assertThrows( + ExecutionException.class, + () -> { + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("genre"), "Science Fiction")) + .delete() + .execute() + .get(); + }); + } + } + + @Test + public void testUpdateMultipleDocuments() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + if ("NIGHTLY".equals(getTargetBackend())) { + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("genre"), "Science Fiction")) + .removeFields("awards") + .update(constant("Updated").as("status")) + .execute() + .get() + .getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(2L); + assertThat(dmlCol.document("book1").get().get().get("status")).isEqualTo("Updated"); + assertThat(dmlCol.document("book1").get().get().get("awards")).isNull(); + + assertThat(dmlCol.document("book10").get().get().get("status")).isEqualTo("Updated"); + assertThat(dmlCol.document("book10").get().get().get("awards")).isNull(); + } else { + assertThrows( + ExecutionException.class, + () -> { + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("genre"), "Science Fiction")) + .removeFields("awards") + .update(constant("Updated").as("status")) + .execute() + .get(); + }); + } + } + + @Test + public void testUpdateWithExpressions() throws Exception { + CollectionReference dmlCol = testCollectionWithDocs(bookDocs); + if ("NIGHTLY".equals(getTargetBackend())) { + List results = + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), "book1")) + .update( + com.google.cloud.firestore.pipeline.expressions.Expression.add( + field("rating"), constant(1.0)) + .as("rating")) + .execute() + .get() + .getResults(); + + assertThat(results).hasSize(1); + DocumentSnapshot doc = dmlCol.document("book1").get().get(); + assertThat(doc.get("rating")).isEqualTo(5.2); + } else { + assertThrows( + ExecutionException.class, + () -> { + firestore + .pipeline() + .collection(dmlCol.getPath()) + .where(equal(field("__name__").documentId(), "book1")) + .update( + com.google.cloud.firestore.pipeline.expressions.Expression.add( + field("rating"), constant(1.0)) + .as("rating")) + .execute() + .get(); + }); + } + } + + @Test + public void testUpdateNonExistingDocumentModifiesZeroDocuments() throws Exception { + CollectionReference dmlCol = firestore.collection(LocalFirestoreHelper.autoId()); + + java.util.Map book = new java.util.HashMap<>(); + book.put("title", "Non Existing"); + book.put("__name__", dmlCol.document("nonExisting")); + + if ("NIGHTLY".equals(getTargetBackend())) { + List results = + firestore.pipeline().literals(book).update().execute().get().getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("documents_modified")).isEqualTo(0L); + } else { + assertThrows( + ExecutionException.class, + () -> { + firestore.pipeline().literals(book).update().execute().get(); + }); + } + } + + @Test + public void testLiteralsStage() throws Exception { + java.util.Map data1 = new java.util.HashMap<>(); + data1.put("foo", "bar"); + java.util.Map data2 = new java.util.HashMap<>(); + data2.put("baz", "qux"); + + List results = + firestore.pipeline().literals(data1, data2).execute().get().getResults(); + + assertThat(results).hasSize(2); + assertThat(results.get(0).getData()).isEqualTo(data1); + assertThat(results.get(1).getData()).isEqualTo(data2); + } + + @Test + public void testLiteralsWithExpressions() throws Exception { + java.util.Map data = new java.util.HashMap<>(); + data.put("base", 10); + data.put("doubled", multiply(constant(10), constant(2))); + + List results = firestore.pipeline().literals(data).execute().get().getResults(); + + assertThat(results).hasSize(1); + assertThat(results.get(0).getData().get("base")).isEqualTo(10L); + assertThat(results.get(0).getData().get("doubled")).isEqualTo(20L); + } }