diff --git a/driver-coroutines/src/commonMain/kotlin/FilteredCollection.kt b/driver-coroutines/src/commonMain/kotlin/FilteredCollection.kt index 699d13ba..d68f8b5b 100644 --- a/driver-coroutines/src/commonMain/kotlin/FilteredCollection.kt +++ b/driver-coroutines/src/commonMain/kotlin/FilteredCollection.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025, OpenSavvy and contributors. + * Copyright (c) 2024-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package opensavvy.ktmongo.coroutines +import opensavvy.ktmongo.coroutines.operations.UpdateOperations.UpdateResult +import opensavvy.ktmongo.coroutines.operations.UpdateOperations.UpsertResult import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.command.* @@ -67,8 +69,8 @@ private class FilteredCollection( options: UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateQuery.() -> Unit, - ) { - upstream.updateMany( + ): UpdateResult { + return upstream.updateMany( options = options, filter = { globalFilter() @@ -82,8 +84,8 @@ private class FilteredCollection( options: UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateWithPipelineQuery.() -> Unit, - ) { - upstream.updateManyWithPipeline( + ): UpdateResult { + return upstream.updateManyWithPipeline( options = options, filter = { globalFilter() @@ -97,8 +99,8 @@ private class FilteredCollection( options: UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateQuery.() -> Unit, - ) { - upstream.updateOne( + ): UpdateResult { + return upstream.updateOne( options = options, filter = { globalFilter() @@ -142,8 +144,8 @@ private class FilteredCollection( options: UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateWithPipelineQuery.() -> Unit, - ) { - upstream.updateOneWithPipeline( + ): UpdateResult { + return upstream.updateOneWithPipeline( options = options, filter = { globalFilter() @@ -157,8 +159,8 @@ private class FilteredCollection( options: UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpsertQuery.() -> Unit, - ) { - upstream.upsertOne( + ): UpsertResult { + return upstream.upsertOne( options = options, filter = { globalFilter() @@ -172,8 +174,8 @@ private class FilteredCollection( options: UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateWithPipelineQuery.() -> Unit, - ) { - upstream.upsertOneWithPipeline( + ): UpsertResult { + return upstream.upsertOneWithPipeline( options = options, filter = { globalFilter() diff --git a/driver-coroutines/src/commonMain/kotlin/operations/UpdateOperations.kt b/driver-coroutines/src/commonMain/kotlin/operations/UpdateOperations.kt index 1e007e58..0dadcfca 100644 --- a/driver-coroutines/src/commonMain/kotlin/operations/UpdateOperations.kt +++ b/driver-coroutines/src/commonMain/kotlin/operations/UpdateOperations.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024-2025, OpenSavvy and contributors. + * Copyright (c) 2024-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,8 +16,10 @@ package opensavvy.ktmongo.coroutines.operations +import opensavvy.ktmongo.bson.BsonValueReader import opensavvy.ktmongo.coroutines.MongoCollection import opensavvy.ktmongo.coroutines.filter +import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.command.BulkWrite import opensavvy.ktmongo.dsl.command.BulkWriteOptions import opensavvy.ktmongo.dsl.command.ReplaceOptions @@ -73,11 +75,12 @@ interface UpdateOperations : BaseOperations { * If no filter is specified, all documents are updated. * @see updateOne */ + @IgnorableReturnValue suspend fun updateMany( options: UpdateOptions.() -> Unit = {}, filter: FilterQuery.() -> Unit = {}, update: UpdateQuery.() -> Unit, - ) + ): UpdateResult /** * Updates a single document that matches [filter] according to [update]. @@ -124,11 +127,12 @@ interface UpdateOperations : BaseOperations { * @see updateMany Update more than one document. * @see findOneAndUpdate Also returns the result of the update. */ + @IgnorableReturnValue suspend fun updateOne( options: UpdateOptions.() -> Unit = {}, filter: FilterQuery.() -> Unit = {}, update: UpdateQuery.() -> Unit, - ) + ): UpdateResult /** * Updates a single document that matches [filter] according to [update]. @@ -178,11 +182,12 @@ interface UpdateOperations : BaseOperations { * * @see updateOne */ + @IgnorableReturnValue suspend fun upsertOne( options: UpdateOptions.() -> Unit = {}, filter: FilterQuery.() -> Unit = {}, update: UpsertQuery.() -> Unit, - ) + ): UpsertResult /** * Replaces a document that matches [filter] by [document]. @@ -422,5 +427,58 @@ interface UpdateOperations : BaseOperations { operations: BulkWrite.() -> Unit, ) + /** + * The return value of [updateMany] and [updateOne]. + */ + interface UpdateResult { + + /** + * `true` if the update was acknowledged. + * + * To control whether the update is acknowledged, see [UpdateOptions.writeConcern]. + * + * If the update was not acknowledged, this property returns `false` and all properties throw [UnsupportedOperationException]. + */ + val acknowledged: Boolean + + /** + * The number of matched documents. + * + * @throws UnsupportedOperationException If the update was not [acknowledged]. + */ + val matchedCount: Long + + /** + * The number of modified documents. + * + * If this update created new documents (e.g., with [upsertOne]), they are not counted + * by this field: they did not already exist, so they were not modified. + * + * @throws UnsupportedOperationException If the update was not [acknowledged]. + */ + val modifiedCount: Long + } + + /** + * The return value of [upsertOne]. + */ + interface UpsertResult : UpdateResult { + + /** + * The `_id` of the upserted document, if any. + * + * If this request modified an existing document, contains `null`. + * + * @throws UnsupportedOperationException If the update was not [acknowledged]. + */ + @OptIn(LowLevelApi::class) + val upsertedId: BsonValueReader? + /** + * The number of upserted documents. + * + * @throws UnsupportedOperationException If the update was not [acknowledged]. + */ + val upsertedCount: Int + } } diff --git a/driver-coroutines/src/commonMain/kotlin/operations/UpdatePipelineOperations.kt b/driver-coroutines/src/commonMain/kotlin/operations/UpdatePipelineOperations.kt index 1f5b03ef..cc1d3867 100644 --- a/driver-coroutines/src/commonMain/kotlin/operations/UpdatePipelineOperations.kt +++ b/driver-coroutines/src/commonMain/kotlin/operations/UpdatePipelineOperations.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, OpenSavvy and contributors. + * Copyright (c) 2025-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,8 @@ package opensavvy.ktmongo.coroutines.operations +import opensavvy.ktmongo.coroutines.operations.UpdateOperations.UpdateResult +import opensavvy.ktmongo.coroutines.operations.UpdateOperations.UpsertResult import opensavvy.ktmongo.dsl.command.UpdateOptions import opensavvy.ktmongo.dsl.query.FilterQuery import opensavvy.ktmongo.dsl.query.UpdateWithPipelineQuery @@ -55,11 +57,12 @@ interface UpdatePipelineOperations : BaseOperations { * If no filter is specified, all documents are updated. * @see updateOneWithPipeline Update a single document. */ + @IgnorableReturnValue suspend fun updateManyWithPipeline( options: UpdateOptions.() -> Unit = {}, filter: FilterQuery.() -> Unit = {}, update: UpdateWithPipelineQuery.() -> Unit, - ) + ): UpdateResult /** * Updates a single document that matches [filter] according to the [update] pipeline. @@ -94,11 +97,12 @@ interface UpdatePipelineOperations : BaseOperations { * @see updateManyWithPipeline Update multiple documents. * @see upsertOneWithPipeline Update a document, creating it if it doesn't exist. */ + @IgnorableReturnValue suspend fun updateOneWithPipeline( options: UpdateOptions.() -> Unit = {}, filter: FilterQuery.() -> Unit = {}, update: UpdateWithPipelineQuery.() -> Unit, - ) + ): UpdateResult /** * Updates a single document that matches [filter] according to the [update] pipeline. @@ -132,10 +136,11 @@ interface UpdatePipelineOperations : BaseOperations { * * @see updateOneWithPipeline Do nothing if the document doesn't already exist. */ + @IgnorableReturnValue suspend fun upsertOneWithPipeline( options: UpdateOptions.() -> Unit = {}, filter: FilterQuery.() -> Unit = {}, update: UpdateWithPipelineQuery.() -> Unit, - ) + ): UpsertResult } diff --git a/driver-coroutines/src/jvmMain/kotlin/JvmMongoCollection.kt b/driver-coroutines/src/jvmMain/kotlin/JvmMongoCollection.kt index 5f86dbd5..f845d881 100644 --- a/driver-coroutines/src/jvmMain/kotlin/JvmMongoCollection.kt +++ b/driver-coroutines/src/jvmMain/kotlin/JvmMongoCollection.kt @@ -21,9 +21,12 @@ import com.mongodb.client.model.ReplaceOptions import com.mongodb.client.model.UpdateOptions import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.firstOrNull +import opensavvy.ktmongo.bson.BsonValueReader import opensavvy.ktmongo.bson.official.JvmBsonFactory import opensavvy.ktmongo.bson.official.types.Jvm import opensavvy.ktmongo.bson.types.ObjectIdGenerator +import opensavvy.ktmongo.coroutines.operations.UpdateOperations.UpdateResult +import opensavvy.ktmongo.coroutines.operations.UpdateOperations.UpsertResult import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.aggregation.PipelineChainLink import opensavvy.ktmongo.dsl.command.* @@ -131,14 +134,15 @@ class JvmMongoCollection internal constructor( options: opensavvy.ktmongo.dsl.command.UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateQuery.() -> Unit, - ) { + ): UpdateResult { val model = UpdateMany(context) model.options.options() model.filter.filter() model.update.update() - inner.withWriteConcern(model.options).updateMany(context.buildDocument(model.filter).raw, context.buildDocument(model.update).raw, UpdateOptions()) + val result = inner.withWriteConcern(model.options).updateMany(context.buildDocument(model.filter).raw, context.buildDocument(model.update).raw, UpdateOptions()) + return JvmUpdateResult(result, context) } @OptIn(LowLevelApi::class) @@ -146,14 +150,15 @@ class JvmMongoCollection internal constructor( options: opensavvy.ktmongo.dsl.command.UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateQuery.() -> Unit, - ) { + ): UpdateResult { val model = UpdateOne(context) model.options.options() model.filter.filter() model.update.update() - inner.withWriteConcern(model.options).updateOne(context.buildDocument(model.filter).raw, context.buildDocument(model.update).raw, UpdateOptions()) + val result = inner.withWriteConcern(model.options).updateOne(context.buildDocument(model.filter).raw, context.buildDocument(model.update).raw, UpdateOptions()) + return JvmUpdateResult(result, context) } @OptIn(LowLevelApi::class) @@ -189,14 +194,15 @@ class JvmMongoCollection internal constructor( options: opensavvy.ktmongo.dsl.command.UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpsertQuery.() -> Unit, - ) { + ): UpsertResult { val model = UpsertOne(context) model.options.options() model.filter.filter() model.update.update() - inner.withWriteConcern(model.options).updateOne(context.buildDocument(model.filter).raw, context.buildDocument(model.update).raw, UpdateOptions().upsert(true)) + val result = inner.withWriteConcern(model.options).updateOne(context.buildDocument(model.filter).raw, context.buildDocument(model.update).raw, UpdateOptions().upsert(true)) + return JvmUpdateResult(result, context) } @OptIn(LowLevelApi::class) @@ -239,14 +245,15 @@ class JvmMongoCollection internal constructor( options: opensavvy.ktmongo.dsl.command.UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateWithPipelineQuery.() -> Unit, - ) { + ): UpdateResult { val model = UpdateManyWithPipeline(context) model.options.options() model.filter.filter() model.update.update() - inner.withWriteConcern(model.options).updateMany(context.buildDocument(model.filter).raw, model.updates.map { it.toJava() }, UpdateOptions()) + val result = inner.withWriteConcern(model.options).updateMany(context.buildDocument(model.filter).raw, model.updates.map { it.toJava() }, UpdateOptions()) + return JvmUpdateResult(result, context) } @OptIn(LowLevelApi::class) @@ -254,14 +261,15 @@ class JvmMongoCollection internal constructor( options: opensavvy.ktmongo.dsl.command.UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateWithPipelineQuery.() -> Unit, - ) { + ): UpdateResult { val model = UpdateOneWithPipeline(context) model.options.options() model.filter.filter() model.update.update() - inner.withWriteConcern(model.options).updateOne(context.buildDocument(model.filter).raw, model.updates.map { it.toJava() }, UpdateOptions()) + val result = inner.withWriteConcern(model.options).updateOne(context.buildDocument(model.filter).raw, model.updates.map { it.toJava() }, UpdateOptions()) + return JvmUpdateResult(result, context) } @OptIn(LowLevelApi::class) @@ -269,14 +277,15 @@ class JvmMongoCollection internal constructor( options: opensavvy.ktmongo.dsl.command.UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpdateWithPipelineQuery.() -> Unit, - ) { + ): UpsertResult { val model = UpsertOneWithPipeline(context) model.options.options() model.filter.filter() model.update.update() - inner.withWriteConcern(model.options).updateOne(context.buildDocument(model.filter).raw, model.updates.map { it.toJava() }, UpdateOptions().upsert(true)) + val result = inner.withWriteConcern(model.options).updateOne(context.buildDocument(model.filter).raw, model.updates.map { it.toJava() }, UpdateOptions().upsert(true)) + return JvmUpdateResult(result, context) } // endregion @@ -403,3 +412,42 @@ private fun com.mongodb.kotlin.client.coroutine.MongoCollection return this.withWriteConcern(concern.toJava()) } + +private class JvmUpdateResult( + private val inner: com.mongodb.client.result.UpdateResult, + private val context: JvmBsonContext, +) : UpsertResult { // The official driver doesn't differentiate between UpdateResult & UpsertResult + override val acknowledged: Boolean + get() = inner.wasAcknowledged() + override val matchedCount: Long + get() = inner.matchedCount + override val modifiedCount: Long + get() = inner.modifiedCount + + @OptIn(LowLevelApi::class) + override val upsertedId: BsonValueReader? + get() = inner.upsertedId?.let { context.readValue(it) } + override val upsertedCount: Int + get() = if (inner.upsertedId == null) 0 else 1 + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is JvmUpdateResult) return false + + if (inner != other.inner) return false + if (context != other.context) return false + + return true + } + + override fun hashCode(): Int { + var result = inner.hashCode() + result = 31 * result + context.hashCode() + return result + } + + @OptIn(LowLevelApi::class) + override fun toString(): String = + if (acknowledged) "UpdateResult(acknowledged=true, matchedCount=$matchedCount, modifiedCount=$modifiedCount, upsertedCount=$upsertedCount, upsertedId=$upsertedId)" + else "UpdateResult(acknowledged=false)" +} diff --git a/test/src/commonTest/kotlin/AggregationTests.kt b/test/src/commonTest/kotlin/AggregationTests.kt index 53c18422..3d808cf6 100644 --- a/test/src/commonTest/kotlin/AggregationTests.kt +++ b/test/src/commonTest/kotlin/AggregationTests.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, OpenSavvy and contributors. + * Copyright (c) 2025-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -77,12 +77,16 @@ val AggregationTests by preparedSuite(preparedConfig = CoroutineTimeout(30.secon } } - anomalies.updateOneWithPipeline { + val result = anomalies.updateOneWithPipeline { set { Song::creationDate set 12 } } + check(result.acknowledged) + check(result.matchedCount == 1L) + check(result.modifiedCount == 1L) + check(Song(creationDate = 12, editionDate = 1) in anomalies.find().toList()) } diff --git a/test/src/commonTest/kotlin/BasicReadWriteTest.kt b/test/src/commonTest/kotlin/BasicReadWriteTest.kt index 5996f1c0..cc2abc46 100644 --- a/test/src/commonTest/kotlin/BasicReadWriteTest.kt +++ b/test/src/commonTest/kotlin/BasicReadWriteTest.kt @@ -46,7 +46,7 @@ val BasicReadWriteTest by preparedSuite(preparedConfig = CoroutineTimeout(30.sec } test("Simple upsert and read") { - users().upsertOne( + val result = users().upsertOne( filter = { User::name eq "Foo" }, @@ -57,6 +57,10 @@ val BasicReadWriteTest by preparedSuite(preparedConfig = CoroutineTimeout(30.sec } ) + check(result.upsertedCount == 1) + check(result.matchedCount == 0L) + check(result.modifiedCount == 0L) + check(User(_id = id1, "Bad", 0) in users().find().toList()) }