diff --git a/firebase-firestore/api.txt b/firebase-firestore/api.txt index 63e79f790f9..b0fa8b4c5bb 100644 --- a/firebase-firestore/api.txt +++ b/firebase-firestore/api.txt @@ -433,6 +433,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(); @@ -440,6 +441,7 @@ 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(String collectionPath, com.google.firebase.firestore.pipeline.Expression? documentIdExpr); 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); @@ -460,11 +462,14 @@ 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.pipeline.Selectable[] transforms, String? collectionPath, com.google.firebase.firestore.pipeline.Expression? documentIdExpr); 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); } @@ -508,6 +513,8 @@ 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.List> data); + 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; diff --git a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java index 9cf8622cc33..9ac997bf432 100644 --- a/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java +++ b/firebase-firestore/src/androidTest/java/com/google/firebase/firestore/PipelineTest.java @@ -4290,4 +4290,62 @@ static Map mapOfEntries(Map.Entry... entries) { } return Collections.unmodifiableMap(res); } + + @Test + public void testDeleteStage() { + CollectionReference collection = testCollection(); + Pipeline.Snapshot snapshot = + waitFor( + collection + .toPipeline() + .where(equal(field("__name__"), constant("book1"))) + .delete() + .execute()); + assertThat(snapshot).isNotNull(); + } + + @Test + public void testUpdateStage() { + CollectionReference collection = testCollection(); + Pipeline.Snapshot snapshot = + waitFor( + collection + .toPipeline() + .where(equal(field("__name__"), constant("book1"))) + .update(constant("Updated").as("status")) + .execute()); + assertThat(snapshot).isNotNull(); + } + + @Test + public void testInsertStage() { + CollectionReference collection = testCollection(); + Map data = new HashMap<>(); + data.put("title", "New Book"); + Pipeline.Snapshot snapshot = + waitFor( + db.pipeline() + .literals(data) + .insert(collection.getPath(), constant("newBook_insert_1")) + .execute()); + assertThat(snapshot).isNotNull(); + } + + @Test + public void testUpsertStage() { + CollectionReference collection = testCollection(); + Map data = new HashMap<>(); + data.put("title", "Upsert Book"); + data.put("count", 1); + Pipeline.Snapshot snapshot = + waitFor( + db.pipeline() + .literals(data) + .upsert( + add(field("count"), constant(1)).as("count"), + collection.getPath(), + constant("upsertBook_1")) + .execute(new Pipeline.ExecuteOptions().withAtomic(true))); + assertThat(snapshot).isNotNull(); + } } 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..4e5612a2cf8 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,8 @@ internal constructor( } fun withIndexMode(indexMode: IndexMode): ExecuteOptions = with("index_mode", indexMode.value) + + fun withAtomic(atomic: Boolean): ExecuteOptions = with("atomic", atomic) } /** @@ -176,6 +184,13 @@ 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.getDefaultInstance()) + .build() + builder.autoCommitTransaction = true + } return builder.build() } @@ -1107,11 +1122,33 @@ internal constructor( * @return A new `Pipeline` object with this stage appended to the stage list. */ @Beta fun search(searchStage: SearchStage): Pipeline = append(searchStage) + + fun delete(): Pipeline = append(DeleteStage()) + + fun update(vararg fields: Selectable): Pipeline = append(UpdateStage(fields)) + + @JvmOverloads + fun insert(collectionPath: String, documentIdExpr: Expression? = null): Pipeline = + append(InsertStage(collectionPath, documentIdExpr)) + + fun upsert(vararg transforms: Selectable): Pipeline = append(UpsertStage(transforms)) + + fun upsert( + vararg transforms: Selectable, + collectionPath: String? = null, + documentIdExpr: Expression? = null + ): Pipeline = append(UpsertStage(transforms, collectionPath, documentIdExpr)) } /** Start of a Firestore Pipeline */ class PipelineSource internal constructor(private val firestore: FirebaseFirestore) { + /** Set the pipeline's source to literal document maps. */ + fun literals(vararg data: Map): Pipeline = literals(data.toList()) + + fun literals(data: List>): Pipeline = + Pipeline(firestore, firestore.userDataReader, listOf(LiteralsSource(data))) + /** * Convert the given Query into an equivalent Pipeline. * @@ -1239,6 +1276,9 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto * @throws [IllegalArgumentException] Thrown if the [documents] provided targets a different * project or database than the pipeline. */ + @JvmName("documents") + fun documents(documents: List): Pipeline = documents(*documents.toTypedArray()) + fun documents(vararg documents: DocumentReference): Pipeline { val databaseId = firestore.databaseId for (document in documents) { @@ -1255,6 +1295,9 @@ class PipelineSource internal constructor(private val firestore: FirebaseFiresto ) } + @JvmName("documentsByPath") + fun documents(documents: List): Pipeline = documents(*documents.toTypedArray()) + companion object { /** * Initializes a pipeline scoped to a subcollection. diff --git a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt index 826c332a152..427847b1738 100644 --- a/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt +++ b/firebase-firestore/src/main/java/com/google/firebase/firestore/pipeline/expressions.kt @@ -8215,6 +8215,8 @@ abstract class Expression internal constructor() { */ open fun alias(alias: String): AliasedExpression = AliasedExpression(alias, this) + open fun `as`(alias: String): AliasedExpression = alias(alias) + /** * Creates an expression that returns the document ID from this path expression. * 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..65bfcfc8747 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 @@ -1736,3 +1736,199 @@ 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 documentIdExpr: Expression?, + options: InternalOptions = InternalOptions.EMPTY +) : Stage("insert", buildOptions(collectionPath, options)) { + + override fun self(options: InternalOptions) = InsertStage(collectionPath, documentIdExpr, 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 (documentIdExpr != null) { + completeOptions = completeOptions.with("document_id", documentIdExpr.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 (documentIdExpr != other.documentIdExpr) return false + return options == other.options + } + + override fun hashCode(): Int { + var result = collectionPath?.hashCode() ?: 0 + result = 31 * result + (documentIdExpr?.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, + internal val collectionPath: String? = null, + internal val documentIdExpr: Expression? = null, + options: InternalOptions = InternalOptions.EMPTY +) : Stage("upsert", buildOptions(collectionPath, options)) { + + override fun self(options: InternalOptions) = + UpsertStage(fields, collectionPath, documentIdExpr, options) + override fun canonicalId(): String = "upsert($collectionPath)" + + override fun args(userDataReader: UserDataReader): Sequence { + return if (fields.isNotEmpty()) { + sequenceOf(encodeValue(associateWithoutDuplications(fields, userDataReader))) + } else { + emptySequence() + } + } + + override fun toProtoStage(userDataReader: UserDataReader): Pipeline.Stage { + var completeOptions = options + if (documentIdExpr != null) { + completeOptions = completeOptions.with("document_id", documentIdExpr.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 (documentIdExpr != other.documentIdExpr) return false + return options == other.options + } + + override fun hashCode(): Int { + var result = fields.contentHashCode() + result = 31 * result + (collectionPath?.hashCode() ?: 0) + result = 31 * result + (documentIdExpr?.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 { encodeLiteralMap(it, userDataReader) } + } + + private fun encodeLiteralMap(map: Map, userDataReader: UserDataReader): Value { + val mapValue = com.google.firestore.v1.MapValue.newBuilder() + for ((key, value) in map) { + when (value) { + null -> mapValue.putFields(key, Values.NULL_VALUE) + is Expression -> mapValue.putFields(key, value.toProto(userDataReader)) + is Map<*, *> -> + @Suppress("UNCHECKED_CAST") + mapValue.putFields(key, encodeLiteralMap(value as Map, userDataReader)) + else -> mapValue.putFields(key, userDataReader.parseQueryValue(value)) + } + } + return Value.newBuilder().setMapValue(mapValue).build() + } + + 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/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..0abef1ed7dc --- /dev/null +++ b/firebase-firestore/src/test/java/com/google/firebase/firestore/pipeline/DmlTests.kt @@ -0,0 +1,132 @@ +// 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.Pipeline +import com.google.firebase.firestore.Pipeline.ExecuteOptions +import com.google.firebase.firestore.TestUtil +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 org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +internal class DmlTests { + + private val db = TestUtil.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").`as`("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 `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 documentIdExpr 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 `upsert stage generates upsert proto with transforms and options`() { + val pipeline = + db + .pipeline() + .literals(mapOf("title" to "Upserted Book", "count" to 1)) + .upsert( + add(field("count"), constant(1)).`as`("count"), + collectionPath = "books", + documentIdExpr = 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.optionsMap["collection"]?.referenceValue).isEqualTo("/books") + assertThat(stage.optionsMap["document_id"]?.stringValue).isEqualTo("book1") + } + + @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.autoCommitTransaction).isTrue() + } + + @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() + + 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() + } +}