diff --git a/firebase-firestore/CHANGELOG.md b/firebase-firestore/CHANGELOG.md index 4bbb3292f20..06f7482d557 100644 --- a/firebase-firestore/CHANGELOG.md +++ b/firebase-firestore/CHANGELOG.md @@ -7,6 +7,10 @@ Protocol Buffers to `4.36.1`. ([#8580](https://github.com/firebase/firebase-android-sdk/pull/8580)) - [feature] Add support for the following new types: MinKey, MaxKey, RegexValue, Int32Value, BsonObjectId, BsonTimestamp, and subtype for Blob. [#8147](//github.com/firebase/firebase-android-sdk/pull/8147) +- [feature] Added pipeline DML stages (`delete()`, `update()`, `insert()`, `upsert()`), the + `PipelineSource.literals()` source, atomic execution via `Pipeline.ExecuteOptions.withAtomic()`, + and support for removing reserved fields such as `__name__` with `removeFields()`. + ([#8524](https://github.com/firebase/firebase-android-sdk/pull/8524)) # 26.6.0 diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 4fd3832f8a7..8753dcb838f 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -472,6 +472,7 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline aggregate(com.google.firebase.firestore.pipeline.AggregateStage aggregateStage, com.google.firebase.firestore.pipeline.AggregateOptions options); method public com.google.firebase.firestore.Pipeline aggregate(com.google.firebase.firestore.pipeline.AliasedAggregate accumulator, com.google.firebase.firestore.pipeline.AliasedAggregate... additionalAccumulators); method public com.google.firebase.firestore.Pipeline define(com.google.firebase.firestore.pipeline.AliasedExpression aliasedExpression, com.google.firebase.firestore.pipeline.AliasedExpression... additionalExpressions); + method public com.google.firebase.firestore.Pipeline delete(); method public com.google.firebase.firestore.Pipeline distinct(com.google.firebase.firestore.pipeline.Selectable group, java.lang.Object... additionalGroups); method public com.google.firebase.firestore.Pipeline distinct(String groupField, java.lang.Object... additionalGroups); method public com.google.android.gms.tasks.Task execute(); @@ -479,6 +480,12 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline findNearest(com.google.firebase.firestore.pipeline.Field vectorField, double[] vectorValue, com.google.firebase.firestore.pipeline.FindNearestStage.DistanceMeasure distanceMeasure); method public com.google.firebase.firestore.Pipeline findNearest(String vectorField, com.google.firebase.firestore.pipeline.Expression vectorValue, com.google.firebase.firestore.pipeline.FindNearestStage.DistanceMeasure distanceMeasure, com.google.firebase.firestore.pipeline.FindNearestOptions options); method public com.google.firebase.firestore.Pipeline findNearest(String vectorField, double[] vectorValue, com.google.firebase.firestore.pipeline.FindNearestStage.DistanceMeasure distanceMeasure); + method public com.google.firebase.firestore.Pipeline insert(); + method public com.google.firebase.firestore.Pipeline insert(com.google.firebase.firestore.CollectionReference collection); + method public com.google.firebase.firestore.Pipeline insert(com.google.firebase.firestore.CollectionReference collection, com.google.firebase.firestore.pipeline.Expression documentIdExpression); + method public com.google.firebase.firestore.Pipeline insert(com.google.firebase.firestore.pipeline.Expression documentIdExpression); + method public com.google.firebase.firestore.Pipeline insert(String collectionPath); + method public com.google.firebase.firestore.Pipeline insert(String collectionPath, com.google.firebase.firestore.pipeline.Expression documentIdExpression); method public com.google.firebase.firestore.Pipeline limit(int limit); method public com.google.firebase.firestore.Pipeline offset(int offset); method public com.google.firebase.firestore.Pipeline rawStage(com.google.firebase.firestore.pipeline.RawStage rawStage); @@ -499,11 +506,18 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline unnest(com.google.firebase.firestore.pipeline.Selectable arrayWithAlias, com.google.firebase.firestore.pipeline.UnnestOptions options); method public com.google.firebase.firestore.Pipeline unnest(com.google.firebase.firestore.pipeline.UnnestStage unnestStage); method public com.google.firebase.firestore.Pipeline unnest(String arrayField, String alias); + method public com.google.firebase.firestore.Pipeline update(com.google.firebase.firestore.pipeline.Selectable... fields); + method public com.google.firebase.firestore.Pipeline upsert(com.google.firebase.firestore.CollectionReference collection); + method public com.google.firebase.firestore.Pipeline upsert(com.google.firebase.firestore.CollectionReference collection, com.google.firebase.firestore.pipeline.Expression documentIdExpression); + method public com.google.firebase.firestore.Pipeline upsert(com.google.firebase.firestore.pipeline.Selectable... additionalFields); + method public com.google.firebase.firestore.Pipeline upsert(String collectionPath); + method public com.google.firebase.firestore.Pipeline upsert(String collectionPath, com.google.firebase.firestore.pipeline.Expression documentIdExpression); method public com.google.firebase.firestore.Pipeline where(com.google.firebase.firestore.pipeline.BooleanExpression condition); } public static final class Pipeline.ExecuteOptions extends com.google.firebase.firestore.pipeline.AbstractOptions { ctor public Pipeline.ExecuteOptions(); + method public com.google.firebase.firestore.Pipeline.ExecuteOptions withAtomic(boolean atomic); method public com.google.firebase.firestore.Pipeline.ExecuteOptions withIndexMode(com.google.firebase.firestore.Pipeline.ExecuteOptions.IndexMode indexMode); } @@ -547,6 +561,7 @@ package com.google.firebase.firestore { method public com.google.firebase.firestore.Pipeline database(); method public com.google.firebase.firestore.Pipeline documents(com.google.firebase.firestore.DocumentReference... documents); method public com.google.firebase.firestore.Pipeline documents(java.lang.String... documents); + method public com.google.firebase.firestore.Pipeline literals(java.util.Map... data); method public static com.google.firebase.firestore.Pipeline subcollection(com.google.firebase.firestore.pipeline.SubcollectionSource source); method public static com.google.firebase.firestore.Pipeline subcollection(String path); field public static final com.google.firebase.firestore.PipelineSource.Companion Companion; @@ -1994,6 +2009,9 @@ package com.google.firebase.firestore.pipeline { public class FunctionExpression extends com.google.firebase.firestore.pipeline.Expression { } + public final class LiteralsSource extends com.google.firebase.firestore.pipeline.Stage { + } + public final class Ordering { method public static com.google.firebase.firestore.pipeline.Ordering ascending(com.google.firebase.firestore.pipeline.Expression expr); method public static com.google.firebase.firestore.pipeline.Ordering ascending(String fieldName); diff --git a/firebase-firestore/gradle.properties b/firebase-firestore/gradle.properties index 0c371d4cc5b..5e3a691bc0b 100644 --- a/firebase-firestore/gradle.properties +++ b/firebase-firestore/gradle.properties @@ -1,2 +1,2 @@ -version=27.0.1 +version=27.1.0 latestReleasedVersion=27.0.0 diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineDmlIntegrationTest.kt b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineDmlIntegrationTest.kt new file mode 100644 index 00000000000..4c97d948f3b --- /dev/null +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineDmlIntegrationTest.kt @@ -0,0 +1,704 @@ +// 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.firebase.firestore + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.Pipeline.ExecuteOptions +import com.google.firebase.firestore.pipeline.Expression.Companion.add +import com.google.firebase.firestore.pipeline.Expression.Companion.constant +import com.google.firebase.firestore.pipeline.Expression.Companion.equal +import com.google.firebase.firestore.pipeline.Expression.Companion.field +import com.google.firebase.firestore.pipeline.Expression.Companion.multiply +import com.google.firebase.firestore.testutil.IntegrationTestUtil +import com.google.firebase.firestore.testutil.IntegrationTestUtil.waitFor +import com.google.firebase.firestore.testutil.IntegrationTestUtil.writeAllDocs +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Assume +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class PipelineDmlIntegrationTest { + private lateinit var db: FirebaseFirestore + private lateinit var collRef: CollectionReference + + private val testBookDocs: Map> = + mapOf( + "book1" to + mapOf( + "title" to "The Hitchhiker's Guide to the Galaxy", + "author" to "Douglas Adams", + "genre" to "Science Fiction", + "rating" to 4.2, + "awards" to mapOf("hugo" to true, "nebula" to false) + ), + "book2" to + mapOf( + "title" to "Pride and Prejudice", + "author" to "Jane Austen", + "genre" to "Romance", + "rating" to 4.5 + ), + "book3" to + mapOf( + "title" to "One Hundred Years of Solitude", + "author" to "Gabriel García Márquez", + "genre" to "Magical Realism", + "rating" to 4.3 + ), + "book10" to + mapOf( + "title" to "Dune", + "author" to "Frank Herbert", + "genre" to "Science Fiction", + "rating" to 4.8, + "awards" to mapOf("hugo" to true) + ) + ) + + @Before + fun setUp() { + Assume.assumeTrue( + "DML tests require enterprise backend edition", + IntegrationTestUtil.getBackendEdition() == IntegrationTestUtil.BackendEdition.ENTERPRISE + ) + + collRef = IntegrationTestUtil.testCollection() + db = collRef.firestore + writeAllDocs(collRef, testBookDocs) + } + + @After + fun tearDown() { + IntegrationTestUtil.tearDown() + } + + // ========================================================================= + // Delete Stage + // ========================================================================= + + @Test + fun testDeleteSingleDocument() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book2"))) + .delete() + .execute() + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book2").get()) + assertThat(docSnap.exists()).isFalse() + } + + @Test + fun testDeleteMultipleDocumentsWithWhereFilter() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(equal(field("genre"), constant("Science Fiction"))) + .delete() + .execute() + ) + assertThat(snapshot).isNotNull() + val docSnap1 = waitFor(collRef.document("book1").get()) + assertThat(docSnap1.exists()).isFalse() + val docSnap10 = waitFor(collRef.document("book10").get()) + assertThat(docSnap10.exists()).isFalse() + } + + @Test + fun testDeleteNonExistingDocument() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("nonExistingId_999"))) + .delete() + .execute() + ) + assertThat(snapshot).isNotNull() + } + + @Test + fun testDeleteWithAtomicTrue() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .delete() + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book1").get()) + assertThat(docSnap.exists()).isFalse() + } + + @Test + fun testDeleteWithAtomicFalse() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book3"))) + .delete() + .execute(ExecuteOptions().withAtomic(false)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book3").get()) + assertThat(docSnap.exists()).isFalse() + } + + // ========================================================================= + // Update Stage + // ========================================================================= + + @Test + fun testUpdateSingleDocWithAddFields() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book3"))) + .addFields(field(FieldPath.documentId()).documentId().alias("id")) + .update(constant("baz").alias("foo")) + .execute() + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book3").get()) + assertThat(docSnap.getString("foo")).isEqualTo("baz") + assertThat(docSnap.getString("id")).isEqualTo("book3") + } + + @Test + fun testUpdateMultipleDocsWithRemoveFields() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(equal(field("genre"), constant("Science Fiction"))) + .removeFields("awards") + .update(constant("Updated").alias("status")) + .execute() + ) + assertThat(snapshot).isNotNull() + val docSnap1 = waitFor(collRef.document("book1").get()) + assertThat(docSnap1.getString("status")).isEqualTo("Updated") + assertThat(docSnap1.contains("awards")).isFalse() + + val docSnap10 = waitFor(collRef.document("book10").get()) + assertThat(docSnap10.getString("status")).isEqualTo("Updated") + assertThat(docSnap10.contains("awards")).isFalse() + } + + @Test + fun testUpdateWithExpressions() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .update(add(field("rating"), constant(1.0)).alias("rating")) + .execute() + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book1").get()) + assertThat(docSnap.getDouble("rating")).isEqualTo(5.2) + } + + @Test + fun testUpdateWithSingleVarargExpression() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .update(constant("UpdatedVariadic").alias("status")) + .execute() + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book1").get()) + assertThat(docSnap.getString("status")).isEqualTo("UpdatedVariadic") + } + + @Test + fun testUpdateWithMultipleVarargExpressions() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .update(constant("UpdatedMulti").alias("status"), constant(99L).alias("newField")) + .execute() + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book1").get()) + assertThat(docSnap.getString("status")).isEqualTo("UpdatedMulti") + assertThat(docSnap.getLong("newField")).isEqualTo(99L) + } + + @Test + fun testUpdateNonExistingDocModifiesZeroDocs() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("nonExistingId_123"))) + .update(constant("Updated").alias("status")) + .execute() + ) + assertThat(snapshot).isNotNull() + } + + @Test + fun testUpdateAtomically() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .update(constant("AtomicUpdate").alias("status")) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book1").get()) + assertThat(docSnap.getString("status")).isEqualTo("AtomicUpdate") + } + + // ========================================================================= + // Insert Stage (all with withAtomic(true)) + // ========================================================================= + + @Test + fun testInsertAutoGeneratedId() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .removeFields("__name__") + .insert(collRef.path) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val allDocs = waitFor(collRef.get()) + assertThat(allDocs.size()).isEqualTo(5) + } + + @Test + fun testInsertSpecifiedDocumentIdExpression() { + val targetCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .insert(targetCol.path, constant("my_custom_id_123")) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(targetCol.document("my_custom_id_123").get()) + assertThat(docSnap.exists()).isTrue() + assertThat(docSnap.getString("title")).isEqualTo("The Hitchhiker's Guide to the Galaxy") + } + + @Test + fun testInsertFailsWhenDocumentAlreadyExists() { + assertThrows(Exception::class.java) { + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .insert(collRef.path, constant("book2")) + .execute(ExecuteOptions().withAtomic(true)) + ) + } + } + + @Test + fun testInsertIntoDifferentCollection() { + val targetCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .insert(targetCol.path) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val targetDocs = waitFor(targetCol.get()) + assertThat(targetDocs.size()).isEqualTo(1) + assertThat(targetDocs.documents[0].getString("title")) + .isEqualTo("The Hitchhiker's Guide to the Galaxy") + } + + @Test + fun testInsertIntoCollectionReference() { + val targetCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .insert(targetCol, constant("book1_ref_copy")) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(targetCol.document("book1_ref_copy").get()) + assertThat(docSnap.exists()).isTrue() + assertThat(docSnap.getString("title")).isEqualTo("The Hitchhiker's Guide to the Galaxy") + } + + @Test + fun testInsertWithOnlyDocumentIdExpressionUsesInputParentCollection() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .insert(documentIdExpression = constant("book1_copy")) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book1_copy").get()) + assertThat(docSnap.exists()).isTrue() + assertThat(docSnap.getString("title")).isEqualTo("The Hitchhiker's Guide to the Galaxy") + } + + @Test + fun testInsertWithoutTargetFailsForExistingDocuments() { + val error = + assertThrows(Exception::class.java) { + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .insert() + .execute(ExecuteOptions().withAtomic(true)) + ) + } + var cause: Throwable? = error + while (cause != null && cause !is FirebaseFirestoreException) cause = cause.cause + assertThat(cause).isInstanceOf(FirebaseFirestoreException::class.java) + assertThat((cause as FirebaseFirestoreException).code) + .isEqualTo(FirebaseFirestoreException.Code.ALREADY_EXISTS) + } + + @Test + fun testInsertWithoutTargetUsesLiteralName() { + val targetCol = IntegrationTestUtil.testCollection() + val emptyCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .literals(mapOf("__name__" to targetCol.document("named"), "name" to "Named Literal")) + .union(db.pipeline().collection(emptyCol.path)) + .insert() + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(targetCol.document("named").get()) + assertThat(docSnap.exists()).isTrue() + assertThat(docSnap.getString("name")).isEqualTo("Named Literal") + } + + @Test + fun testInsertWithoutTargetFailsForLiteralWithoutName() { + val emptyCol = IntegrationTestUtil.testCollection() + val error = + assertThrows(Exception::class.java) { + waitFor( + db + .pipeline() + .literals(mapOf("name" to "Unnamed Literal")) + .union(db.pipeline().collection(emptyCol.path)) + .insert() + .execute(ExecuteOptions().withAtomic(true)) + ) + } + var cause: Throwable? = error + while (cause != null && cause !is FirebaseFirestoreException) cause = cause.cause + assertThat(cause).isInstanceOf(FirebaseFirestoreException::class.java) + assertThat((cause as FirebaseFirestoreException).code) + .isEqualTo(FirebaseFirestoreException.Code.INVALID_ARGUMENT) + } + + // ========================================================================= + // Upsert Stage + // ========================================================================= + + @Test + fun testUpsertExistingDocWithVarargs() { + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .upsert( + constant("Comedy Sci-Fi").alias("genre"), + add(field("rating"), constant(0.5)).alias("rating") + ) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document("book1").get()) + assertThat(docSnap.getString("genre")).isEqualTo("Comedy Sci-Fi") + assertThat(docSnap.getDouble("rating")).isEqualTo(4.7) + } + + @Test + fun testUpsertNewDocWhenDoesNotExist() { + val newDocId = "new_upsert_doc_id" + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .addFields(constant("New Book Title").alias("title"), constant("Sci-Fi").alias("genre")) + .upsert(collRef.path, constant(newDocId)) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(collRef.document(newDocId).get()) + assertThat(docSnap.exists()).isTrue() + assertThat(docSnap.getString("title")).isEqualTo("New Book Title") + assertThat(docSnap.getString("genre")).isEqualTo("Sci-Fi") + } + + @Test + fun testUpsertIntoCollectionReferenceAfterAddFields() { + val targetCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .addFields( + constant("Target Upsert Title").alias("title"), + constant("Target Genre").alias("genre") + ) + .upsert(targetCol, constant("target_doc_1")) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(targetCol.document("target_doc_1").get()) + assertThat(docSnap.exists()).isTrue() + assertThat(docSnap.getString("title")).isEqualTo("Target Upsert Title") + assertThat(docSnap.getString("genre")).isEqualTo("Target Genre") + } + + @Test + fun testUpsertIntoDifferentCollectionWithoutAdditionalFields() { + val targetCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .collection(collRef.path) + .where(field(FieldPath.documentId()).equal(collRef.document("book1"))) + .upsert(collectionPath = targetCol.path, documentIdExpression = constant("target_doc_2")) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(targetCol.document("target_doc_2").get()) + assertThat(docSnap.exists()).isTrue() + assertThat(docSnap.getString("title")).isEqualTo("The Hitchhiker's Guide to the Galaxy") + } + + @Test + fun testUpsertWithoutTargetUsesLiteralName() { + val targetCol = IntegrationTestUtil.testCollection() + val emptyCol = IntegrationTestUtil.testCollection() + waitFor(targetCol.document("named").set(mapOf("old" to true))) + val snapshot = + waitFor( + db + .pipeline() + .literals(mapOf("__name__" to targetCol.document("named"), "name" to "Named Literal")) + .union(db.pipeline().collection(emptyCol.path)) + .upsert() + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val docSnap = waitFor(targetCol.document("named").get()) + assertThat(docSnap.getData()).containsExactly("name", "Named Literal") + } + + @Test + fun testUpsertWithoutTargetFailsForLiteralWithoutName() { + val emptyCol = IntegrationTestUtil.testCollection() + val error = + assertThrows(Exception::class.java) { + waitFor( + db + .pipeline() + .literals(mapOf("name" to "Unnamed Literal")) + .union(db.pipeline().collection(emptyCol.path)) + .upsert() + .execute(ExecuteOptions().withAtomic(true)) + ) + } + var cause: Throwable? = error + while (cause != null && cause !is FirebaseFirestoreException) cause = cause.cause + assertThat(cause).isInstanceOf(FirebaseFirestoreException::class.java) + assertThat((cause as FirebaseFirestoreException).code) + .isEqualTo(FirebaseFirestoreException.Code.INVALID_ARGUMENT) + assertThat(waitFor(emptyCol.get()).isEmpty).isTrue() + } + + @Test + fun testUpsertIntoCollectionGeneratesIdForLiteral() { + val targetCol = IntegrationTestUtil.testCollection() + val emptyCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .literals(mapOf("name" to "Unnamed Literal")) + .union(db.pipeline().collection(emptyCol.path)) + .upsert(targetCol.path) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val targetDocs = waitFor(targetCol.get()) + assertThat(targetDocs.size()).isEqualTo(1) + assertThat(targetDocs.documents[0].id).isNotEmpty() + assertThat(targetDocs.documents[0].getString("name")).isEqualTo("Unnamed Literal") + } + + // ========================================================================= + // Literals Stage (using .union to satisfy Firebase Security Rules) + // ========================================================================= + + @Test + fun testLiteralsBasicDocuments() { + val emptyCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .literals(mapOf("name" to "Alice", "age" to 30L), mapOf("name" to "Bob", "age" to 25L)) + .union(db.pipeline().collection(emptyCol.path)) + .execute() + ) + assertThat(snapshot.results).hasSize(2) + val first = snapshot.results[0].getData() + val second = snapshot.results[1].getData() + assertThat(first).containsExactly("name", "Alice", "age", 30L) + assertThat(second).containsExactly("name", "Bob", "age", 25L) + } + + @Test + fun testLiteralsWithExpressions() { + val emptyCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .literals(mapOf("base" to 10L, "doubled" to multiply(constant(10L), constant(2L)))) + .union(db.pipeline().collection(emptyCol.path)) + .execute() + ) + assertThat(snapshot.results).hasSize(1) + val first = snapshot.results[0].getData() + assertThat(first).containsExactly("base", 10L, "doubled", 20L) + } + + @Test + fun testLiteralsWithExpressionNestedInMap() { + val emptyCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .literals( + mapOf( + "nested" to mapOf("doubled" to multiply(constant(10L), constant(2L)), "label" to "x") + ) + ) + .union(db.pipeline().collection(emptyCol.path)) + .execute() + ) + assertThat(snapshot.results).hasSize(1) + val first = snapshot.results[0].getData() + assertThat(first).containsExactly("nested", mapOf("doubled" to 20L, "label" to "x")) + } + + @Test + fun testLiteralsWithExpressionNestedInList() { + val emptyCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .literals(mapOf("list" to listOf(multiply(constant(10L), constant(2L)), 1L))) + .union(db.pipeline().collection(emptyCol.path)) + .execute() + ) + assertThat(snapshot.results).hasSize(1) + val first = snapshot.results[0].getData() + assertThat(first).containsExactly("list", listOf(20L, 1L)) + } + + @Test + fun testLiteralsCombinedWithInsert() { + val targetCol = IntegrationTestUtil.testCollection() + val emptyCol = IntegrationTestUtil.testCollection() + val snapshot = + waitFor( + db + .pipeline() + .literals(mapOf("name" to "Literal Inserted", "age" to 42L)) + .union(db.pipeline().collection(emptyCol.path)) + .insert(targetCol.path) + .execute(ExecuteOptions().withAtomic(true)) + ) + assertThat(snapshot).isNotNull() + val targetDocs = waitFor(targetCol.get()) + assertThat(targetDocs.size()).isEqualTo(1) + assertThat(targetDocs.documents[0].getString("name")).isEqualTo("Literal Inserted") + assertThat(targetDocs.documents[0].getLong("age")).isEqualTo(42L) + } +} diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt index 1dcbbd74e7c..5c4de894078 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/Pipeline.kt @@ -36,6 +36,7 @@ import com.google.firebase.firestore.pipeline.CollectionSource import com.google.firebase.firestore.pipeline.CollectionSourceOptions import com.google.firebase.firestore.pipeline.DatabaseSource import com.google.firebase.firestore.pipeline.DefineStage +import com.google.firebase.firestore.pipeline.DeleteStage import com.google.firebase.firestore.pipeline.DistinctStage import com.google.firebase.firestore.pipeline.DocumentsSource import com.google.firebase.firestore.pipeline.Expression @@ -44,8 +45,10 @@ import com.google.firebase.firestore.pipeline.Field import com.google.firebase.firestore.pipeline.FindNearestOptions import com.google.firebase.firestore.pipeline.FindNearestStage import com.google.firebase.firestore.pipeline.FunctionExpression +import com.google.firebase.firestore.pipeline.InsertStage import com.google.firebase.firestore.pipeline.InternalOptions import com.google.firebase.firestore.pipeline.LimitStage +import com.google.firebase.firestore.pipeline.LiteralsSource import com.google.firebase.firestore.pipeline.OffsetStage import com.google.firebase.firestore.pipeline.Ordering import com.google.firebase.firestore.pipeline.RawStage @@ -61,6 +64,8 @@ import com.google.firebase.firestore.pipeline.SubcollectionSource import com.google.firebase.firestore.pipeline.UnionStage import com.google.firebase.firestore.pipeline.UnnestOptions import com.google.firebase.firestore.pipeline.UnnestStage +import com.google.firebase.firestore.pipeline.UpdateStage +import com.google.firebase.firestore.pipeline.UpsertStage import com.google.firebase.firestore.pipeline.WhereStage import com.google.firebase.firestore.pipeline.evaluation.notImplemented import com.google.firebase.firestore.remote.RemoteSerializer @@ -68,6 +73,7 @@ import com.google.firebase.firestore.util.Logger import com.google.firestore.v1.ExecutePipelineRequest import com.google.firestore.v1.Pipeline as ProtoPipeline import com.google.firestore.v1.StructuredPipeline +import com.google.firestore.v1.TransactionOptions import com.google.firestore.v1.Value /** @@ -112,6 +118,18 @@ internal constructor( } fun withIndexMode(indexMode: IndexMode): ExecuteOptions = with("index_mode", indexMode.value) + + /** + * Sets whether the pipeline is executed atomically. + * + * When `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 `insert`, + * `upsert`, `update` and `delete` stages) are applied or none are. The default is `false`. + * + * @param atomic Whether to execute the pipeline atomically. + * @return A new [ExecuteOptions] with the atomic setting applied. + */ + fun withAtomic(atomic: Boolean): ExecuteOptions = with("atomic", atomic) } /** @@ -158,7 +176,11 @@ internal constructor( ): StructuredPipeline { val builder = StructuredPipeline.newBuilder() builder.pipeline = toPipelineProto(userDataReader) - options?.forEach(builder::putOptions) + options?.forEach { key, value -> + if (key != "atomic") { + builder.putOptions(key, value) + } + } return builder.build() } @@ -176,6 +198,17 @@ internal constructor( val builder = ExecutePipelineRequest.newBuilder() builder.database = "projects/${database.projectId}/databases/${database.databaseId}" builder.structuredPipeline = toStructuredPipelineProto(options, firestore.userDataReader) + if (options != null && options.hasAtomic()) { + builder.newTransaction = + TransactionOptions.newBuilder() + .setReadWrite( + TransactionOptions.ReadWrite.newBuilder() + .setConcurrencyMode(TransactionOptions.ConcurrencyMode.OPTIMISTIC) + .build() + ) + .build() + builder.autoCommitTransaction = true + } return builder.build() } @@ -224,8 +257,8 @@ internal constructor( ) } - override fun onComplete(executionTime: Timestamp) { - taskCompletionSource.setResult(Snapshot(executionTime, results)) + override fun onComplete(executionTime: Timestamp?) { + taskCompletionSource.setResult(Snapshot(executionTime ?: Timestamp.now(), results)) } override fun onError(exception: FirebaseFirestoreException) { @@ -1107,11 +1140,253 @@ internal constructor( * @return A new `Pipeline` object with this stage appended to the stage list. */ @Beta fun search(searchStage: SearchStage): Pipeline = append(searchStage) + + /** + * Deletes each document produced by the previous stages from the database. + * + * Example: + * ```kotlin + * db.pipeline() + * .collection("books") + * .where(field("rating").lessThan(2)) + * .delete() + * .execute() + * ``` + * + * @return A new [Pipeline] object with this stage appended to the stage list. + */ + fun delete(): Pipeline = append(DeleteStage()) + + /** + * Writes each document produced by the previous stages back to its existing path, after setting + * the given [fields]. + * + * The stored document is replaced by the pipeline document, so fields removed by earlier stages + * (for example, with [removeFields]) are also removed from the stored document. This stage does + * not create documents. + * + * Example: + * ```kotlin + * db.pipeline() + * .collection("books") + * .where(field("genre").equal("Science Fiction")) + * .update(constant("Updated").alias("status")) + * .execute() + * ``` + * + * @param fields The fields to set on each document. Each field is written under its alias. If + * none are given, the documents are written back unchanged. + * @return A new [Pipeline] object with this stage appended to the stage list. + */ + fun update(vararg fields: Selectable): Pipeline = append(UpdateStage(fields)) + + /** + * Inserts each document produced by the previous stages as a new document at the path in its + * `__name__` field. The insert fails if a target document already exists, so it fails for + * documents read from the database. A document from [PipelineSource.literals] must set `__name__` + * to a [DocumentReference], or the pipeline fails. + * + * @return A new [Pipeline] object with this stage appended to the stage list. + */ + fun insert(): Pipeline = append(InsertStage(null, null)) + + /** + * Inserts each document produced by the previous stages as a new document in the collection at + * [collectionPath], reusing the input document's ID. If the input document has no ID (for + * example, a [PipelineSource.literals] document without `__name__`), an ID is generated + * automatically. The insert fails if a target document already exists. + * + * Example: + * ```kotlin + * db.pipeline() + * .literals(mapOf("title" to "Dune", "author" to "Frank Herbert")) + * .insert("books") + * .execute() + * ``` + * + * @param collectionPath The path of the collection to insert documents into. + * @return A new [Pipeline] object with this stage appended to the stage list. + */ + fun insert(collectionPath: String): Pipeline = append(InsertStage(collectionPath, null)) + + /** + * Inserts each document produced by the previous stages as a new document in [collection], + * reusing the input document's ID. If the input document has no ID (for example, a + * [PipelineSource.literals] document without `__name__`), an ID is generated automatically. The + * insert fails if a target document already exists. + * + * @param collection The collection to insert documents into. + * @return A new [Pipeline] object with this stage appended to the stage list. + * @throws IllegalArgumentException Thrown if [collection] belongs to a different Firestore + * instance than the pipeline. + */ + fun insert(collection: CollectionReference): Pipeline = + append(InsertStage(collectionPathOf(collection), null)) + + /** + * Inserts each document produced by the previous stages as a new document in its input document's + * parent collection, with the ID produced by [documentIdExpression]. The insert fails if a target + * document already exists. + * + * @param documentIdExpression An expression that is evaluated against each input document to + * produce the ID of the document to insert. It must evaluate to a document ID, not a document + * path. + * @return A new [Pipeline] object with this stage appended to the stage list. + */ + fun insert(documentIdExpression: Expression): Pipeline = + append(InsertStage(null, documentIdExpression)) + + /** + * Inserts each document produced by the previous stages as a new document in the collection at + * [collectionPath], with the ID produced by [documentIdExpression]. The insert fails if a target + * document already exists. + * + * @param collectionPath The path of the collection to insert documents into. + * @param documentIdExpression An expression that is evaluated against each input document to + * produce the ID of the document to insert. It must evaluate to a document ID, not a document + * path. + * @return A new [Pipeline] object with this stage appended to the stage list. + */ + fun insert(collectionPath: String, documentIdExpression: Expression): Pipeline = + append(InsertStage(collectionPath, documentIdExpression)) + + /** + * Inserts each document produced by the previous stages as a new document in [collection], with + * the ID produced by [documentIdExpression]. The insert fails if a target document already + * exists. + * + * @param collection The collection to insert documents into. + * @param documentIdExpression An expression that is evaluated against each input document to + * produce the ID of the document to insert. It must evaluate to a document ID, not a document + * path. + * @return A new [Pipeline] object with this stage appended to the stage list. + * @throws IllegalArgumentException Thrown if [collection] belongs to a different Firestore + * instance than the pipeline. + */ + fun insert(collection: CollectionReference, documentIdExpression: Expression): Pipeline = + append(InsertStage(collectionPathOf(collection), documentIdExpression)) + + /** + * Writes each document produced by the previous stages to the path in its `__name__` field, + * creating the document if it doesn't exist and replacing it entirely if it does (stored fields + * are not merged). + * + * No document ID is generated: a document from [PipelineSource.literals] must set `__name__` to a + * [DocumentReference], or the pipeline fails. To generate IDs, use an `upsert` overload that + * takes a collection. + * + * @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 [Pipeline] object with this stage appended to the stage list. + */ + fun upsert(vararg additionalFields: Selectable): Pipeline = + append(UpsertStage(fields = additionalFields)) + + /** + * Writes each document produced by the previous stages to the collection at [collectionPath], + * reusing the input document's ID. If the input document has no ID (for example, a + * [PipelineSource.literals] document without `__name__`), an ID is generated automatically. The + * target document is created if it doesn't exist and replaced entirely if it does (stored fields + * are not merged). + * + * To add fields to each document before it is written, call [addFields] before this stage. + * + * @param collectionPath The path of the collection to write documents to. + * @return A new [Pipeline] object with this stage appended to the stage list. + */ + fun upsert(collectionPath: String): Pipeline = append(UpsertStage(collectionPath, null)) + + /** + * Writes each document produced by the previous stages to [collection], reusing the input + * document's ID. If the input document has no ID (for example, a [PipelineSource.literals] + * document without `__name__`), an ID is generated automatically. The target document is created + * if it doesn't exist and replaced entirely if it does (stored fields are not merged). + * + * To add fields to each document before it is written, call [addFields] before this stage. + * + * @param collection The collection to write documents to. + * @return A new [Pipeline] object with this stage appended to the stage list. + * @throws IllegalArgumentException Thrown if [collection] belongs to a different Firestore + * instance than the pipeline. + */ + fun upsert(collection: CollectionReference): Pipeline = + append(UpsertStage(collectionPathOf(collection), null)) + + /** + * Writes each document produced by the previous stages to the collection at [collectionPath], + * with the ID produced by [documentIdExpression]. The target document is created if it doesn't + * exist and replaced entirely if it does (stored fields are not merged). + * + * To add fields to each document before it is written, call [addFields] before this stage. + * + * @param collectionPath The path of the collection to write documents to. + * @param documentIdExpression An expression that is evaluated against each input document to + * produce the ID of the target document. It must evaluate to a document ID, not a document path. + * @return A new [Pipeline] object with this stage appended to the stage list. + */ + fun upsert(collectionPath: String, documentIdExpression: Expression): Pipeline = + append(UpsertStage(collectionPath, documentIdExpression)) + + /** + * Writes each document produced by the previous stages to [collection], with the ID produced by + * [documentIdExpression]. The target document is created if it doesn't exist and replaced + * entirely if it does (stored fields are not merged). + * + * To add fields to each document before it is written, call [addFields] before this stage. + * + * @param collection The collection to write documents to. + * @param documentIdExpression An expression that is evaluated against each input document to + * produce the ID of the target document. It must evaluate to a document ID, not a document path. + * @return A new [Pipeline] object with this stage appended to the stage list. + * @throws IllegalArgumentException Thrown if [collection] belongs to a different Firestore + * instance than the pipeline. + */ + fun upsert(collection: CollectionReference, documentIdExpression: Expression): Pipeline = + append(UpsertStage(collectionPathOf(collection), documentIdExpression)) + + private fun collectionPathOf(collection: CollectionReference): String { + if ( + firestore != null && + (collection.firestore.databaseId != firestore.databaseId || + collection.firestore.app?.options?.projectId != firestore.app?.options?.projectId) + ) { + throw IllegalArgumentException( + "Invalid CollectionReference. The Firestore instance of the CollectionReference must match the Firestore instance of the Pipeline." + ) + } + return collection.path + } } /** Start of a Firestore Pipeline */ class PipelineSource internal constructor(private val firestore: FirebaseFirestore) { + /** + * Sets the pipeline's source to the given literal documents. Each map becomes one input document. + * + * Field values can be constants or [Expression]s; expressions are evaluated, including + * expressions nested inside map or list values. FieldValue sentinels (for example, + * `FieldValue.serverTimestamp()`) are not supported; use an expression such as + * `currentTimestamp()` instead. + * + * To give a document a path, for example to write it with [Pipeline.upsert], set its `__name__` + * field to a [DocumentReference]. + * + * Example: + * ```kotlin + * db.pipeline() + * .literals(mapOf("title" to "Dune"), mapOf("title" to "Emma")) + * .insert("books") + * .execute() + * ``` + * + * @param data The documents to use as the source. At least one document is required; a pipeline + * without literal documents fails when it is executed. + * @return A new [Pipeline] object with the literal documents as its source. + */ + fun literals(vararg data: Map): Pipeline = + Pipeline(firestore, firestore.userDataReader, listOf(LiteralsSource(data.toList()))) + /** * Convert the given Query into an equivalent Pipeline. * @@ -1424,6 +1699,6 @@ internal interface PipelineResultObserver { createTime: Timestamp?, updateTime: Timestamp? ) - fun onComplete(executionTime: Timestamp) + fun onComplete(executionTime: Timestamp?) fun onError(exception: FirebaseFirestoreException) } diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/options.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/options.kt index dfdf36c767c..6fef67ddfad 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/options.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/options.kt @@ -64,6 +64,10 @@ internal constructor(private val options: ImmutableMap) { } } + internal fun hasAtomic(): Boolean { + return options.containsKey("atomic") && options["atomic"]?.booleanValue == true + } + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is InternalOptions) return false diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt index e77077c8187..eb2808fd993 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/stage.kt @@ -17,6 +17,7 @@ package com.google.firebase.firestore.pipeline import com.google.common.annotations.Beta import com.google.firebase.firestore.UserDataReader import com.google.firebase.firestore.VectorValue +import com.google.firebase.firestore.core.UserData import com.google.firebase.firestore.model.Document import com.google.firebase.firestore.model.DocumentKey.KEY_FIELD_NAME import com.google.firebase.firestore.model.MutableDocument @@ -1423,14 +1424,6 @@ internal constructor( private val fields: Array, options: InternalOptions = InternalOptions.EMPTY ) : Stage("remove_fields", options) { - init { - for (field in fields) { - val alias = field.alias - require(alias != Field.DOCUMENT_ID.alias, { "Alias ${Field.DOCUMENT_ID.alias} is required" }) - require(alias != Field.CREATE_TIME.alias, { "Alias ${Field.CREATE_TIME.alias} is required" }) - require(alias != Field.UPDATE_TIME.alias, { "Alias ${Field.UPDATE_TIME.alias} is required" }) - } - } override fun self(options: InternalOptions) = RemoveFieldsStage(fields, options) override fun canonicalId(): String { TODO("Not yet implemented") @@ -1736,3 +1729,264 @@ internal constructor( return result } } + +internal class DeleteStage internal constructor(options: InternalOptions = InternalOptions.EMPTY) : + Stage("delete", options) { + override fun self(options: InternalOptions) = DeleteStage(options) + override fun canonicalId(): String = "delete()" + override fun args(userDataReader: UserDataReader): Sequence = emptySequence() + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DeleteStage) return false + return options == other.options + } + + override fun hashCode(): Int = options.hashCode() +} + +internal class UpdateStage +internal constructor( + private val fields: Array, + options: InternalOptions = InternalOptions.EMPTY +) : Stage("update", options) { + override fun self(options: InternalOptions) = UpdateStage(fields, options) + override fun canonicalId(): String = "update()" + + override fun args(userDataReader: UserDataReader): Sequence { + return if (fields.isNotEmpty()) { + sequenceOf(encodeValue(associateWithoutDuplications(fields, userDataReader))) + } else { + sequenceOf(encodeValue(emptyMap())) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is UpdateStage) return false + if (!fields.contentEquals(other.fields)) return false + return options == other.options + } + + override fun hashCode(): Int { + var result = fields.contentHashCode() + result = 31 * result + options.hashCode() + return result + } +} + +internal class InsertStage +internal constructor( + internal val collectionPath: String?, + internal val documentIdExpression: Expression?, + options: InternalOptions = InternalOptions.EMPTY +) : Stage("insert", buildOptions(collectionPath, options)) { + + override fun self(options: InternalOptions) = + InsertStage(collectionPath, documentIdExpression, options) + override fun canonicalId(): String = "insert($collectionPath)" + override fun args(userDataReader: UserDataReader): Sequence = emptySequence() + + override fun toProtoStage(userDataReader: UserDataReader): Pipeline.Stage { + var completeOptions = options + if (documentIdExpression != null) { + completeOptions = + completeOptions.with("document_id", documentIdExpression.toProto(userDataReader)) + } + return toProtoStage(name, args(userDataReader), completeOptions, userDataReader) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is InsertStage) return false + if (collectionPath != other.collectionPath) return false + if (documentIdExpression != other.documentIdExpression) return false + return options == other.options + } + + override fun hashCode(): Int { + var result = collectionPath?.hashCode() ?: 0 + result = 31 * result + (documentIdExpression?.hashCode() ?: 0) + result = 31 * result + options.hashCode() + return result + } + + companion object { + private fun buildOptions( + collectionPath: String?, + baseOptions: InternalOptions + ): InternalOptions { + var opts = baseOptions + if (collectionPath != null) { + val path = if (collectionPath.startsWith("/")) collectionPath else "/$collectionPath" + opts = opts.with("collection", Value.newBuilder().setReferenceValue(path).build()) + } + return opts + } + } +} + +internal class UpsertStage +internal constructor( + private val fields: Array = emptyArray(), + internal val collectionPath: String? = null, + internal val documentIdExpression: Expression? = null, + options: InternalOptions = InternalOptions.EMPTY +) : Stage("upsert", buildOptions(collectionPath, options)) { + + internal val additionalFields: Array + get() = fields + + internal constructor( + collectionPath: String?, + documentIdExpression: Expression? = null, + additionalFields: Array = emptyArray(), + options: InternalOptions = InternalOptions.EMPTY + ) : this(additionalFields, collectionPath, documentIdExpression, options) + + override fun self(options: InternalOptions) = + UpsertStage(fields, collectionPath, documentIdExpression, options) + override fun canonicalId(): String = "upsert($collectionPath)" + + override fun args(userDataReader: UserDataReader): Sequence = + sequenceOf(encodeValue(associateWithoutDuplications(fields, userDataReader))) + + override fun toProtoStage(userDataReader: UserDataReader): Pipeline.Stage { + var completeOptions = options + if (documentIdExpression != null) { + completeOptions = + completeOptions.with("document_id", documentIdExpression.toProto(userDataReader)) + } + return toProtoStage(name, args(userDataReader), completeOptions, userDataReader) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is UpsertStage) return false + if (!fields.contentEquals(other.fields)) return false + if (collectionPath != other.collectionPath) return false + if (documentIdExpression != other.documentIdExpression) return false + return options == other.options + } + + override fun hashCode(): Int { + var result = fields.contentHashCode() + result = 31 * result + (collectionPath?.hashCode() ?: 0) + result = 31 * result + (documentIdExpression?.hashCode() ?: 0) + result = 31 * result + options.hashCode() + return result + } + + companion object { + private fun buildOptions( + collectionPath: String?, + baseOptions: InternalOptions + ): InternalOptions { + var opts = baseOptions + if (collectionPath != null) { + val path = if (collectionPath.startsWith("/")) collectionPath else "/$collectionPath" + opts = opts.with("collection", Value.newBuilder().setReferenceValue(path).build()) + } + return opts + } + } +} + +class LiteralsSource +internal constructor( + internal val data: List>, + options: InternalOptions = InternalOptions.EMPTY +) : Stage("literals", options) { + + override fun self(options: InternalOptions) = LiteralsSource(data, options) + override fun canonicalId(): String = "literals()" + + override fun args(userDataReader: UserDataReader): Sequence { + return data.asSequence().map { encodeLiteralDocument(it, userDataReader) } + } + + private fun encodeLiteralDocument( + document: Map, + userDataReader: UserDataReader + ): Value { + val rootContext = UserData.ParseAccumulator(UserData.Source.Argument).rootContext() + val mapValue = com.google.firestore.v1.MapValue.newBuilder() + for ((key, value) in document) { + val context = rootContext.childContext(key) + mapValue.putFields( + key, + if (containsExpression(value)) { + toLiteralExpression(value, context, userDataReader).toProto(userDataReader) + } else { + parseValue(value, context, userDataReader) + } + ) + } + return Value.newBuilder().setMapValue(mapValue).build() + } + + /** + * The backend only evaluates expressions in the top-level fields of a literal document, so maps + * and lists that contain an [Expression] are sent as `map(...)` and `array(...)` expressions for + * the nested expressions to be evaluated. + */ + private fun toLiteralExpression( + value: Any?, + context: UserData.ParseContext, + userDataReader: UserDataReader + ): Expression = + when { + value is Expression -> value + value is Map<*, *> && containsExpression(value) -> + Expression.map( + value.entries + .flatMap { (key, element) -> + if (key !is String) { + throw context.createError("Maps with non-string keys are not supported") + } + listOf( + constant(key), + toLiteralExpression(element, context.childContext(key), userDataReader) + ) + } + .toTypedArray() + ) + value is List<*> && containsExpression(value) -> + Expression.array( + value.mapIndexed { index, element -> + toLiteralExpression(element, context.childContext(index), userDataReader) + } + ) + else -> Expression.Constant(parseValue(value, context, userDataReader)) + } + + private fun parseValue( + value: Any?, + context: UserData.ParseContext, + userDataReader: UserDataReader + ): Value = + checkNotNull(userDataReader.convertAndParseFieldData(value, context)) { + "Parsed literal value should not be null." + } + + private fun containsExpression(value: Any?): Boolean = + when (value) { + is Expression -> true + is Map<*, *> -> value.values.any(::containsExpression) + is List<*> -> value.any(::containsExpression) + else -> false + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LiteralsSource) return false + if (data != other.data) return false + return options == other.options + } + + override fun hashCode(): Int { + var result = data.hashCode() + result = 31 * result + options.hashCode() + return result + } +} diff --git a/firebase-firestore/src/proto/google/firestore/v1/common.proto b/firebase-firestore/src/proto/google/firestore/v1/common.proto index fcb0f49fb00..eb044a3aca1 100644 --- a/firebase-firestore/src/proto/google/firestore/v1/common.proto +++ b/firebase-firestore/src/proto/google/firestore/v1/common.proto @@ -56,13 +56,26 @@ message Precondition { // Options for creating a new transaction. message TransactionOptions { + // Modes available for transactions. + enum ConcurrencyMode { + // Defaults to the database's default concurrency mode. + CONCURRENCY_MODE_UNSPECIFIED = 0; + + // Optimistic concurrency mode. + OPTIMISTIC = 1; + + // Pessimistic concurrency mode. + PESSIMISTIC = 2; + } + // Options for a transaction that can be used to read and write documents. - // - // Firestore does not allow 3rd party auth requests to create read-write. - // transactions. message ReadWrite { // An optional transaction to retry. bytes retry_transaction = 1; + + // The concurrency mode for this transaction. If not set, defaults to + // the database's default concurrency mode. + ConcurrencyMode concurrency_mode = 2; } // Options for a transaction that can only be used to read documents. diff --git a/firebase-firestore/src/proto/google/firestore/v1/firestore.proto b/firebase-firestore/src/proto/google/firestore/v1/firestore.proto index be7ce9065c3..7d40730e9e6 100644 --- a/firebase-firestore/src/proto/google/firestore/v1/firestore.proto +++ b/firebase-firestore/src/proto/google/firestore/v1/firestore.proto @@ -617,6 +617,10 @@ message ExecutePipelineRequest { // Explain / analyze options for the pipeline. // ExplainOptions explain_options = 8 [(google.api.field_behavior) = OPTIONAL]; + + // Automatically commits the transaction after the pipeline has been executed. + // Only permitted in combination with `transaction` or `new_transaction`. + bool auto_commit_transaction = 9 [(google.api.field_behavior) = OPTIONAL]; } // The response for [Firestore.Execute][]. diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/PipelineProtoTest.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/PipelineProtoTest.kt index fbd7e3e904f..ce87c7c0c98 100644 --- a/firebase-firestore/src/test/java/com/google/firebase/firestore/PipelineProtoTest.kt +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/PipelineProtoTest.kt @@ -53,6 +53,8 @@ class PipelineProtoTest { val request = pipeline.toExecutePipelineRequest(null) assertThat(request.database).isEqualTo("projects/new-project/databases/(default)") + assertThat(request.hasNewTransaction()).isFalse() + assertThat(request.autoCommitTransaction).isFalse() val structuredPipeline = request.structuredPipeline val protoPipeline = structuredPipeline.pipeline diff --git a/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DmlTests.kt b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DmlTests.kt new file mode 100644 index 00000000000..7fce3784719 --- /dev/null +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DmlTests.kt @@ -0,0 +1,536 @@ +// 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.firebase.firestore.pipeline + +import com.google.common.truth.Truth.assertThat +import com.google.firebase.firestore.FieldValue +import com.google.firebase.firestore.FirebaseFirestoreIntegrationTestFactory +import com.google.firebase.firestore.Pipeline +import com.google.firebase.firestore.Pipeline.ExecuteOptions +import com.google.firebase.firestore.TestUtil +import com.google.firebase.firestore.model.DatabaseId +import com.google.firebase.firestore.pipeline.Expression.Companion.add +import com.google.firebase.firestore.pipeline.Expression.Companion.constant +import com.google.firebase.firestore.pipeline.Expression.Companion.field +import com.google.firestore.v1.TransactionOptions +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class DmlTests { + + private val db = TestUtil.firestore() + private val otherDb = + FirebaseFirestoreIntegrationTestFactory(DatabaseId.forDatabase("otherProject", "otherDb")) + .firestore + + @Test + fun `delete stage generates delete proto`() { + val pipeline = db.pipeline().collection("books").delete() + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("delete") + assertThat(stage.argsCount).isEqualTo(0) + } + + @Test + fun `update stage generates update proto with fields`() { + val pipeline = db.pipeline().collection("books").update(constant("Updated").alias("status")) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("update") + assertThat(stage.argsCount).isEqualTo(1) + assertThat(stage.getArgs(0).mapValue.fieldsMap["status"]?.stringValue).isEqualTo("Updated") + } + + @Test + fun `update stage with 0 args generates update proto with empty map`() { + val pipeline = db.pipeline().collection("books").update() + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("update") + assertThat(stage.argsCount).isEqualTo(1) + assertThat(stage.getArgs(0).mapValue.fieldsCount).isEqualTo(0) + } + + @Test + fun `update stage with multiple varargs generates update proto with all fields`() { + val pipeline = + db + .pipeline() + .collection("books") + .update( + constant("Updated").alias("status"), + add(field("count"), constant(1)).alias("count") + ) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("update") + assertThat(stage.argsCount).isEqualTo(1) + val fields = stage.getArgs(0).mapValue.fieldsMap + assertThat(fields["status"]?.stringValue).isEqualTo("Updated") + assertThat(fields.containsKey("count")).isTrue() + } + + @Test + fun `insert stage generates insert proto with options`() { + val pipeline = + db.pipeline().literals(mapOf("title" to "New Book")).insert("books", constant("book1")) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("insert") + assertThat(stage.optionsMap["collection"]?.referenceValue).isEqualTo("/books") + assertThat(stage.optionsMap["document_id"]?.stringValue).isEqualTo("book1") + } + + @Test + fun `insert stage without documentIdExpression generates insert proto with only collection option`() { + val pipeline = db.pipeline().literals(mapOf("title" to "New Book")).insert("books") + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("insert") + assertThat(stage.optionsMap["collection"]?.referenceValue).isEqualTo("/books") + assertThat(stage.optionsMap.containsKey("document_id")).isFalse() + } + + @Test + fun `insert stage without arguments generates insert proto without options`() { + val pipeline = db.pipeline().collection("books").insert() + val stage = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline.getStages(1) + assertThat(stage.name).isEqualTo("insert") + assertThat(stage.argsCount).isEqualTo(0) + assertThat(stage.optionsCount).isEqualTo(0) + } + + @Test + fun `insert stage with only documentIdExpression generates insert proto without collection`() { + val pipeline = + db.pipeline().collection("books").insert(documentIdExpression = constant("book1_copy")) + val stage = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline.getStages(1) + assertThat(stage.name).isEqualTo("insert") + assertThat(stage.optionsMap.containsKey("collection")).isFalse() + assertThat(stage.optionsMap["document_id"]?.stringValue).isEqualTo("book1_copy") + } + + @Test + fun `insert stage with CollectionReference generates insert proto with collection option`() { + val pipeline = + db.pipeline().literals(mapOf("title" to "New Book")).insert(db.collection("books")) + val stage = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline.getStages(1) + assertThat(stage.name).isEqualTo("insert") + assertThat(stage.optionsMap["collection"]?.referenceValue).isEqualTo("/books") + assertThat(stage.optionsMap.containsKey("document_id")).isFalse() + } + + @Test + fun `insert stage with CollectionReference and documentIdExpression generates insert proto`() { + val pipeline = + db + .pipeline() + .literals(mapOf("title" to "New Book")) + .insert(db.collection("books"), constant("book1")) + val stage = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline.getStages(1) + assertThat(stage.name).isEqualTo("insert") + assertThat(stage.optionsMap["collection"]?.referenceValue).isEqualTo("/books") + assertThat(stage.optionsMap["document_id"]?.stringValue).isEqualTo("book1") + } + + @Test + fun `insert stage rejects a CollectionReference from another Firestore instance`() { + val error = + assertThrows(IllegalArgumentException::class.java) { + db.pipeline().collection("books").insert(otherDb.collection("books")) + } + assertThat(error) + .hasMessageThat() + .isEqualTo( + "Invalid CollectionReference. The Firestore instance of the CollectionReference must " + + "match the Firestore instance of the Pipeline." + ) + } + + @Test + fun `target-collection upsert with CollectionReference and documentIdExpression generates upsert proto`() { + val pipeline = + db + .pipeline() + .literals(mapOf("title" to "Upserted Book")) + .upsert(db.collection("books"), constant("book1")) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("upsert") + assertThat(stage.argsCount).isEqualTo(1) + assertThat(stage.getArgs(0).mapValue.fieldsCount).isEqualTo(0) + assertThat(stage.optionsMap["collection"]?.referenceValue).isEqualTo("/books") + assertThat(stage.optionsMap["document_id"]?.stringValue).isEqualTo("book1") + } + + @Test + fun `in-place upsert with varargs generates upsert proto without options`() { + val pipeline = + db.pipeline().collection("books").upsert(add(field("count"), constant(1)).alias("count")) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("upsert") + assertThat(stage.argsCount).isEqualTo(1) + assertThat(stage.optionsCount).isEqualTo(0) + } + + @Test + fun `target-collection upsert with collectionPath only generates upsert proto`() { + val pipeline = db.pipeline().collection("books").upsert("books_backup") + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("upsert") + assertThat(stage.argsCount).isEqualTo(1) + assertThat(stage.getArgs(0).mapValue.fieldsCount).isEqualTo(0) + assertThat(stage.optionsMap["collection"]?.referenceValue).isEqualTo("/books_backup") + assertThat(stage.optionsMap.containsKey("document_id")).isFalse() + } + + @Test + fun `target-collection upsert with collectionPath and documentIdExpression generates upsert proto`() { + val pipeline = + db.pipeline().literals(mapOf("title" to "Upserted Book")).upsert("books", constant("book1")) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("upsert") + assertThat(stage.argsCount).isEqualTo(1) + assertThat(stage.getArgs(0).mapValue.fieldsCount).isEqualTo(0) + assertThat(stage.optionsMap["collection"]?.referenceValue).isEqualTo("/books") + assertThat(stage.optionsMap["document_id"]?.stringValue).isEqualTo("book1") + } + + @Test + fun `target-collection upsert with CollectionReference only generates upsert proto`() { + val pipeline = db.pipeline().collection("books").upsert(db.collection("books_backup")) + val stage = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline.getStages(1) + assertThat(stage.name).isEqualTo("upsert") + assertThat(stage.optionsMap["collection"]?.referenceValue).isEqualTo("/books_backup") + assertThat(stage.optionsMap.containsKey("document_id")).isFalse() + } + + @Test + fun `target-collection upsert rejects a CollectionReference from another Firestore instance`() { + val error = + assertThrows(IllegalArgumentException::class.java) { + db.pipeline().collection("books").upsert(otherDb.collection("books"), constant("book1")) + } + assertThat(error) + .hasMessageThat() + .isEqualTo( + "Invalid CollectionReference. The Firestore instance of the CollectionReference must " + + "match the Firestore instance of the Pipeline." + ) + } + + @Test + fun `in-place upsert with 0 args generates upsert proto without options`() { + val pipeline = db.pipeline().collection("books").upsert() + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("upsert") + assertThat(stage.argsCount).isEqualTo(1) + assertThat(stage.getArgs(0).mapValue.fieldsCount).isEqualTo(0) + assertThat(stage.optionsCount).isEqualTo(0) + } + + @Test + fun `in-place upsert with multiple varargs generates upsert proto without options`() { + val pipeline = + db + .pipeline() + .collection("books") + .upsert( + constant("In-Place").alias("status"), + add(field("count"), constant(1)).alias("count") + ) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(2) + + val stage = proto.getStages(1) + assertThat(stage.name).isEqualTo("upsert") + assertThat(stage.argsCount).isEqualTo(1) + val fields = stage.getArgs(0).mapValue.fieldsMap + assertThat(fields["status"]?.stringValue).isEqualTo("In-Place") + assertThat(fields.containsKey("count")).isTrue() + assertThat(stage.optionsCount).isEqualTo(0) + } + + @Test + fun `literals stage with multiple maps generates literals proto with documents`() { + val pipeline = db.pipeline().literals(mapOf("title" to "Book 1"), mapOf("title" to "Book 2")) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(1) + + val stage = proto.getStages(0) + assertThat(stage.name).isEqualTo("literals") + assertThat(stage.argsCount).isEqualTo(2) + assertThat(stage.getArgs(0).mapValue.fieldsMap["title"]?.stringValue).isEqualTo("Book 1") + assertThat(stage.getArgs(1).mapValue.fieldsMap["title"]?.stringValue).isEqualTo("Book 2") + } + + @Test + fun `literals stage with expressions and nested maps generates literals proto`() { + val pipeline = + db + .pipeline() + .literals( + mapOf( + "title" to "Book 1", + "nested" to mapOf("key" to "value"), + "computed" to add(constant(1), constant(2)) + ) + ) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + assertThat(proto.stagesCount).isEqualTo(1) + + val stage = proto.getStages(0) + assertThat(stage.name).isEqualTo("literals") + assertThat(stage.argsCount).isEqualTo(1) + val fields = stage.getArgs(0).mapValue.fieldsMap + assertThat(fields["title"]?.stringValue).isEqualTo("Book 1") + assertThat(fields["nested"]?.mapValue?.fieldsMap?.get("key")?.stringValue).isEqualTo("value") + assertThat(fields["computed"]?.functionValue?.name).isEqualTo("add") + } + + @Test + fun `literals stage wraps nested map containing expressions in a map expression`() { + val pipeline = + db + .pipeline() + .literals( + mapOf("nested" to mapOf("sum" to add(constant(1L), constant(2L)), "label" to "x")) + ) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + val fields = proto.getStages(0).getArgs(0).mapValue.fieldsMap + + val nested = fields["nested"]!! + assertThat(nested.hasFunctionValue()).isTrue() + assertThat(nested.functionValue.name).isEqualTo("map") + // map(key1, value1, key2, value2): keys are constants, values keep their expressions. + val args = nested.functionValue.argsList + assertThat(args).hasSize(4) + val entries = args.chunked(2).associate { (k, v) -> k.stringValue to v } + assertThat(entries["sum"]?.functionValue?.name).isEqualTo("add") + assertThat(entries["label"]?.stringValue).isEqualTo("x") + } + + @Test + fun `literals stage wraps list containing expressions in an array expression`() { + val pipeline = + db.pipeline().literals(mapOf("list" to listOf(add(constant(1L), constant(2L)), 1L, null))) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + val list = proto.getStages(0).getArgs(0).mapValue.fieldsMap["list"]!! + + assertThat(list.hasFunctionValue()).isTrue() + assertThat(list.functionValue.name).isEqualTo("array") + val args = list.functionValue.argsList + assertThat(args).hasSize(3) + assertThat(args[0].functionValue.name).isEqualTo("add") + assertThat(args[1].integerValue).isEqualTo(1L) + assertThat(args[2].hasNullValue()).isTrue() + } + + @Test + fun `literals stage wraps deeply nested expressions at the top-level field`() { + val pipeline = + db.pipeline().literals(mapOf("outer" to mapOf("inner" to listOf(mapOf("v" to constant(1L)))))) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + val outer = proto.getStages(0).getArgs(0).mapValue.fieldsMap["outer"]!! + + assertThat(outer.functionValue.name).isEqualTo("map") + val inner = outer.functionValue.getArgs(1) + assertThat(inner.functionValue.name).isEqualTo("array") + val element = inner.functionValue.getArgs(0) + assertThat(element.functionValue.name).isEqualTo("map") + assertThat(element.functionValue.getArgs(1).integerValue).isEqualTo(1L) + } + + @Test + fun `literals stage encodes nested values without expressions as plain values`() { + val pipeline = + db + .pipeline() + .literals( + mapOf("nested" to mapOf("key" to "value", "n" to null), "list" to listOf(1L, "a")) + ) + val proto = pipeline.toExecutePipelineRequest(null).structuredPipeline.pipeline + val fields = proto.getStages(0).getArgs(0).mapValue.fieldsMap + + assertThat(fields["nested"]!!.hasMapValue()).isTrue() + assertThat(fields["nested"]!!.mapValue.fieldsMap["key"]?.stringValue).isEqualTo("value") + assertThat(fields["nested"]!!.mapValue.fieldsMap["n"]?.hasNullValue()).isTrue() + assertThat(fields["list"]!!.hasArrayValue()).isTrue() + assertThat(fields["list"]!!.arrayValue.valuesList.map { it.valueTypeCase.name }) + .containsExactly("INTEGER_VALUE", "STRING_VALUE") + .inOrder() + } + + @Test + fun `literals without documents produces a stage with no arguments`() { + val stage = + db + .pipeline() + .literals() + .toExecutePipelineRequest(null) + .structuredPipeline + .pipeline + .getStages(0) + assertThat(stage.name).isEqualTo("literals") + assertThat(stage.argsCount).isEqualTo(0) + } + + @Test + fun `literals rejects empty field names at any depth`() { + for (document in + listOf( + mapOf("" to 1L), + mapOf("m" to mapOf("" to 2L)), + mapOf("l" to listOf(mapOf("" to 3L))), + mapOf("m" to mapOf("sum" to add(constant(1L), constant(2L)), "" to 4L)), + )) { + val pipeline = db.pipeline().literals(document) + val error = + assertThrows(IllegalArgumentException::class.java) { + pipeline.toExecutePipelineRequest(null) + } + assertThat(error) + .hasMessageThat() + .startsWith("Invalid data. Document fields must not be empty") + } + } + + @Test + fun `literals rejects nested arrays without expressions`() { + val pipeline = db.pipeline().literals(mapOf("l" to listOf(listOf(1L)))) + val error = + assertThrows(IllegalArgumentException::class.java) { pipeline.toExecutePipelineRequest(null) } + assertThat(error).hasMessageThat().isEqualTo("Invalid data. Nested arrays are not supported") + } + + @Test + fun `literals rejects FieldValue sentinel in a list`() { + val pipeline = db.pipeline().literals(mapOf("l" to listOf(mapOf("ts" to FieldValue.delete())))) + val error = + assertThrows(IllegalArgumentException::class.java) { pipeline.toExecutePipelineRequest(null) } + assertThat(error) + .hasMessageThat() + .isEqualTo("Invalid data. FieldValue.delete() can only be used with set() and update()") + } + + @Test + fun `literals rejects top-level FieldValue sentinel with field path`() { + val pipeline = db.pipeline().literals(mapOf("a" to 1L, "ts" to FieldValue.serverTimestamp())) + val error = + assertThrows(IllegalArgumentException::class.java) { pipeline.toExecutePipelineRequest(null) } + assertThat(error) + .hasMessageThat() + .isEqualTo( + "Invalid data. " + + "FieldValue.serverTimestamp() can only be used with set() and update() (found in field ts)" + ) + } + + @Test + fun `literals rejects nested FieldValue sentinel with full field path`() { + val pipeline = db.pipeline().literals(mapOf("a" to mapOf("b" to FieldValue.increment(1)))) + val error = + assertThrows(IllegalArgumentException::class.java) { pipeline.toExecutePipelineRequest(null) } + assertThat(error) + .hasMessageThat() + .isEqualTo( + "Invalid data. " + + "FieldValue.increment() can only be used with set() and update() (found in field a.b)" + ) + } + + @Test + fun `literals rejects FieldValue sentinel next to a nested expression with field path`() { + val pipeline = + db + .pipeline() + .literals( + mapOf("a" to mapOf("sum" to add(constant(1L), constant(2L)), "d" to FieldValue.delete())) + ) + val error = + assertThrows(IllegalArgumentException::class.java) { pipeline.toExecutePipelineRequest(null) } + assertThat(error) + .hasMessageThat() + .isEqualTo( + "Invalid data. " + + "FieldValue.delete() can only be used with set() and update() (found in field a.d)" + ) + } + + @Test + fun `atomic execution options configure newTransaction and autoCommitTransaction`() { + val pipeline = + db.pipeline().literals(mapOf("title" to "Atomic")).insert("books", constant("book1")) + val executeOptions = Pipeline.ExecuteOptions().withAtomic(true) + val request = pipeline.toExecutePipelineRequest(executeOptions.options) + assertThat(request.hasNewTransaction()).isTrue() + assertThat(request.newTransaction.hasReadWrite()).isTrue() + assertThat(request.newTransaction.readWrite.concurrencyMode) + .isEqualTo(TransactionOptions.ConcurrencyMode.OPTIMISTIC) + assertThat(request.autoCommitTransaction).isTrue() + assertThat(request.structuredPipeline.optionsMap).doesNotContainKey("atomic") + } + + @Test + fun `non-atomic execution options do not configure newTransaction or autoCommitTransaction`() { + val pipeline = + db.pipeline().literals(mapOf("title" to "Non-Atomic")).insert("books", constant("book1")) + + val executeOptionsDisabled = Pipeline.ExecuteOptions().withAtomic(false) + val requestDisabled = pipeline.toExecutePipelineRequest(executeOptionsDisabled.options) + assertThat(requestDisabled.hasNewTransaction()).isFalse() + assertThat(requestDisabled.autoCommitTransaction).isFalse() + assertThat(requestDisabled.structuredPipeline.optionsMap).doesNotContainKey("atomic") + + val executeOptionsDefault = Pipeline.ExecuteOptions() + val requestDefault = pipeline.toExecutePipelineRequest(executeOptionsDefault.options) + assertThat(requestDefault.hasNewTransaction()).isFalse() + assertThat(requestDefault.autoCommitTransaction).isFalse() + + val requestNull = pipeline.toExecutePipelineRequest(null) + assertThat(requestNull.hasNewTransaction()).isFalse() + assertThat(requestNull.autoCommitTransaction).isFalse() + } +}