From d0932ccc2382b857fddac6804e12423f77d5f2c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 10:05:11 +0200 Subject: [PATCH 01/16] docs(dsl): Clarify what Expression.simplify returning null means --- dsl/src/main/kotlin/expr/common/Expression.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dsl/src/main/kotlin/expr/common/Expression.kt b/dsl/src/main/kotlin/expr/common/Expression.kt index 969b155..a32c519 100644 --- a/dsl/src/main/kotlin/expr/common/Expression.kt +++ b/dsl/src/main/kotlin/expr/common/Expression.kt @@ -57,6 +57,9 @@ abstract class Expression( * it may use this function to replace itself by that child. * * **Implementations must be pure.** + * + * @return The simplified expression. + * Returning `null` means that the entire expression has been simplified to a no-op, and can be removed. */ @LowLevelApi open fun simplify(): Expression? = this From 2d0f5e5ff1b566014765e1ec2fd1b4803b3cacd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 10:29:37 +0200 Subject: [PATCH 02/16] fix(dsl): Don't crash when toString is called on an operator outside its expected context --- dsl/src/main/kotlin/expr/common/Expression.kt | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/dsl/src/main/kotlin/expr/common/Expression.kt b/dsl/src/main/kotlin/expr/common/Expression.kt index a32c519..4ea5021 100644 --- a/dsl/src/main/kotlin/expr/common/Expression.kt +++ b/dsl/src/main/kotlin/expr/common/Expression.kt @@ -1,8 +1,10 @@ package fr.qsh.ktmongo.dsl.expr.common import fr.qsh.ktmongo.dsl.LowLevelApi +import fr.qsh.ktmongo.dsl.writeDocument import org.bson.BsonDocument import org.bson.BsonDocumentWriter +import org.bson.BsonInvalidOperationException import org.bson.BsonWriter import org.bson.codecs.configuration.CodecRegistry @@ -74,6 +76,14 @@ abstract class Expression( this.simplify()?.write(writer) } + private fun writeWithSimplifications(writer: BsonWriter, simplified: Boolean) { + @OptIn(LowLevelApi::class) + if (simplified) + writeTo(writer) + else + write(writer) + } + /** * Returns a JSON representation of this node. * @@ -86,10 +96,19 @@ abstract class Expression( .withLoggedContext() @OptIn(LowLevelApi::class) - if (simplified) - writeTo(writer) - else - write(writer) + try { + writeWithSimplifications(writer, simplified) + } catch (e: BsonInvalidOperationException) { + // Some operators cannot be written to the root document, + // and require a surrounding document. + // This isn't a problem in production code, because the DSLs are type-safe, + // and cannot be called in the wrong context. + // However, it is a problem for toString, which can be called in any context + // to help debug. If writing this fake document fails too, we give up. + writer.writeDocument { + writeWithSimplifications(writer, simplified) + } + } return document.toString() } From 4837ddff15733ca94c0ca8d2ea9652ddc3ca2549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 10:30:58 +0200 Subject: [PATCH 03/16] feat(dsl): $set --- dsl/README.md | 4 + dsl/src/main/kotlin/expr/UpdateExpression.kt | 109 ++++++++++++++++++ .../test/kotlin/expr/UpdateExpressionTest.kt | 88 ++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 dsl/src/main/kotlin/expr/UpdateExpression.kt create mode 100644 dsl/src/test/kotlin/expr/UpdateExpressionTest.kt diff --git a/dsl/README.md b/dsl/README.md index 4813b7f..0358276 100644 --- a/dsl/README.md +++ b/dsl/README.md @@ -16,3 +16,7 @@ - [`$not`][fr.qsh.ktmongo.dsl.expr.FilterExpression.not] - [`$or`][fr.qsh.ktmongo.dsl.expr.FilterExpression.or] - [`$type`][fr.qsh.ktmongo.dsl.expr.FilterExpression.hasType] + +### Update + +- [`$set`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.set] diff --git a/dsl/src/main/kotlin/expr/UpdateExpression.kt b/dsl/src/main/kotlin/expr/UpdateExpression.kt new file mode 100644 index 0000000..20d7eaf --- /dev/null +++ b/dsl/src/main/kotlin/expr/UpdateExpression.kt @@ -0,0 +1,109 @@ +package fr.qsh.ktmongo.dsl.expr + +import fr.qsh.ktmongo.dsl.KtMongoDsl +import fr.qsh.ktmongo.dsl.LowLevelApi +import fr.qsh.ktmongo.dsl.expr.common.CompoundExpression +import fr.qsh.ktmongo.dsl.expr.common.Expression +import fr.qsh.ktmongo.dsl.expr.common.acceptAll +import fr.qsh.ktmongo.dsl.path.Path +import fr.qsh.ktmongo.dsl.path.path +import fr.qsh.ktmongo.dsl.writeDocument +import fr.qsh.ktmongo.dsl.writeObject +import org.bson.BsonWriter +import org.bson.codecs.configuration.CodecRegistry +import kotlin.internal.OnlyInputTypes +import kotlin.reflect.KProperty1 + +/** + * DSL for MongoDB operators that are used to update fields. + * + * For example, these operators are available with `insertOne` or as the update in `updateOne`. + */ +@KtMongoDsl +class UpdateExpression( + codec: CodecRegistry, +) : CompoundExpression(codec) { + + // region Low-level operations + + @LowLevelApi + override fun simplify(children: List): Expression? { + if (children.isEmpty()) + return null + + var simplifiedChildren = children + + run { + // Combine all $set operators together + val sets = simplifiedChildren.filterIsInstance() + val combinedSet = + if (sets.size > 1) + SetExpressionNode(sets.flatMap { it.mappings }, codec) + else null + if (combinedSet != null) { + val childrenWithoutSets = simplifiedChildren - sets.toSet() + simplifiedChildren = childrenWithoutSets + combinedSet + } + } + + if (simplifiedChildren != children) + return UpdateExpression(codec).apply { + acceptAll(simplifiedChildren) + } + return this + } + + @LowLevelApi + private sealed class UpdateExpressionNode(codec: CodecRegistry) : Expression(codec) + + // endregion + // region $set + + /** + * Replaces the value of a field with the specified [value]. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String?, + * val age: Int, + * ) + * + * collection.update { + * User::age set 18 + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/set/) + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun <@OnlyInputTypes V> KProperty1.set(value: V) { + accept(SetExpressionNode(listOf(this.path() to value), codec)) + } + + @LowLevelApi + private class SetExpressionNode( + val mappings: List>, + codec: CodecRegistry, + ) : UpdateExpressionNode(codec) { + + override fun simplify() = + this.takeUnless { mappings.isEmpty() } + + override fun write(writer: BsonWriter) { + writer.writeDocument("\$set") { + for ((field, value) in mappings) { + writer.writeName(field.toString()) + writer.writeObject(value, codec) + } + } + } + } + + // endregion + +} diff --git a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt new file mode 100644 index 0000000..be42de3 --- /dev/null +++ b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt @@ -0,0 +1,88 @@ +package fr.qsh.ktmongo.dsl.expr + +import fr.qsh.ktmongo.dsl.LowLevelApi +import fr.qsh.ktmongo.dsl.expr.common.withLoggedContext +import fr.qsh.ktmongo.dsl.path.div +import fr.qsh.ktmongo.dsl.writeDocument +import io.kotest.core.spec.style.FunSpec +import org.bson.BsonDocument +import org.bson.BsonDocumentWriter + +@OptIn(LowLevelApi::class) +class UpdateExpressionTest : FunSpec({ + + class Friend( + val id: String, + val name: String, + ) + + class User( + val id: String, + val name: String, + val age: Int?, + val bestFriend: Friend, + val friends: List, + ) + + fun update(block: UpdateExpression.() -> Unit): String { + val document = BsonDocument() + + val writer = BsonDocumentWriter(document) + .withLoggedContext() + + writer.writeDocument { + UpdateExpression(testCodec()) + .apply(block) + .writeTo(writer) + } + + return document.toString() + } + + val set = "\$set" + + test("Empty update") { + update { } shouldBeBson """{}""" + } + + context("Operator $set") { + test("Single field") { + update { + User::age set 18 + } shouldBeBson """ + { + "$set": { + "age": 18 + } + } + """.trimIndent() + } + + test("Nested field") { + update { + User::bestFriend / Friend::name set "foo" + } shouldBeBson """ + { + "$set": { + "bestFriend.name": "foo" + } + } + """.trimIndent() + } + + test("Multiple fields") { + update { + User::age set 18 + User::name set "foo" + } shouldBeBson """ + { + "$set": { + "age": 18, + "name": "foo" + } + } + """.trimIndent() + } + } + +}) From 3edaa980368419bee83613efef115ce74a82edc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 11:10:04 +0200 Subject: [PATCH 04/16] feat(dsl): $setOnInsert --- dsl/README.md | 1 + dsl/src/main/kotlin/expr/UpdateExpression.kt | 100 +++++++++++++++--- .../test/kotlin/expr/UpdateExpressionTest.kt | 40 +++++++ 3 files changed, 128 insertions(+), 13 deletions(-) diff --git a/dsl/README.md b/dsl/README.md index 0358276..7ec69e8 100644 --- a/dsl/README.md +++ b/dsl/README.md @@ -20,3 +20,4 @@ ### Update - [`$set`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.set] +- [`$setOnInsert`](fr.qsh.ktmongo.dsl.expr.UpdateExpression.setOnInsert) diff --git a/dsl/src/main/kotlin/expr/UpdateExpression.kt b/dsl/src/main/kotlin/expr/UpdateExpression.kt index 20d7eaf..4086bf6 100644 --- a/dsl/src/main/kotlin/expr/UpdateExpression.kt +++ b/dsl/src/main/kotlin/expr/UpdateExpression.kt @@ -12,6 +12,7 @@ import fr.qsh.ktmongo.dsl.writeObject import org.bson.BsonWriter import org.bson.codecs.configuration.CodecRegistry import kotlin.internal.OnlyInputTypes +import kotlin.reflect.KClass import kotlin.reflect.KProperty1 /** @@ -26,24 +27,29 @@ class UpdateExpression( // region Low-level operations + private class OperatorCombinator( + val type: KClass, + val combinator: (List, CodecRegistry) -> T + ) { + @Suppress("UNCHECKED_CAST") // This is a private class, it should not be used incorrectly + operator fun invoke(sources: List, codec: CodecRegistry) = + combinator(sources as List, codec) + } + @LowLevelApi override fun simplify(children: List): Expression? { if (children.isEmpty()) return null - var simplifiedChildren = children - - run { - // Combine all $set operators together - val sets = simplifiedChildren.filterIsInstance() - val combinedSet = - if (sets.size > 1) - SetExpressionNode(sets.flatMap { it.mappings }, codec) - else null - if (combinedSet != null) { - val childrenWithoutSets = simplifiedChildren - sets.toSet() - simplifiedChildren = childrenWithoutSets + combinedSet - } + val simplifiedChildren = combinators.fold(children) { newChildren, combinator -> + val matching = newChildren.filterIsInstance(combinator.type.java) + + if (matching.size <= 1) + // At least two elements are required to combine them into a single one! + return@fold newChildren + + val childrenWithoutMatching = newChildren - matching.toSet() + childrenWithoutMatching + combinator(matching, codec) } if (simplifiedChildren != children) @@ -78,6 +84,8 @@ class UpdateExpression( * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/set/) + * + * @see setOnInsert Only set if a new document is created. */ @OptIn(LowLevelApi::class) @KtMongoDsl @@ -104,6 +112,72 @@ class UpdateExpression( } } + // endregion + // region $setOnInsert + + /** + * If an upsert operation results in an insert of a document, + * then this operator assigns the specified [value] to the field. + * If the update operation does not result in an insert, this operator does nothing. + * + * If used in an update operation that isn't an upset, no document can be inserted, + * and thus this operator never does anything. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String?, + * val age: Int, + * ) + * + * collection.update { + * User::age setOnInsert 18 + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/setOnInsert/) + * + * @see set Always set the value. + */ + // TODO: make the above example an upsert + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun <@OnlyInputTypes V> KProperty1.setOnInsert(value: V) { + accept(SetOnInsertExpressionNode(listOf(this.path() to value), codec)) + } + + @LowLevelApi + private class SetOnInsertExpressionNode( + val mappings: List>, + codec: CodecRegistry, + ) : UpdateExpressionNode(codec) { + override fun simplify(): Expression? = + this.takeUnless { mappings.isEmpty() } + + override fun write(writer: BsonWriter) { + writer.writeDocument("\$setOnInsert") { + for ((field, value) in mappings) { + writer.writeName(field.toString()) + writer.writeObject(value, codec) + } + } + } + } + // endregion + companion object { + @OptIn(LowLevelApi::class) + private val combinators = listOf( + OperatorCombinator(SetExpressionNode::class) { sources, codec -> + SetExpressionNode(sources.flatMap { it.mappings }, codec) + }, + OperatorCombinator(SetOnInsertExpressionNode::class) { sources, codec -> + SetOnInsertExpressionNode(sources.flatMap { it.mappings }, codec) + } + ) + } } diff --git a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt index be42de3..c6c5c71 100644 --- a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt +++ b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt @@ -40,6 +40,7 @@ class UpdateExpressionTest : FunSpec({ } val set = "\$set" + val setOnInsert = "\$setOnInsert" test("Empty update") { update { } shouldBeBson """{}""" @@ -85,4 +86,43 @@ class UpdateExpressionTest : FunSpec({ } } + context("Operator $setOnInsert") { + test("Single field") { + update { + User::age setOnInsert 18 + } shouldBeBson """ + { + "$setOnInsert": { + "age": 18 + } + } + """.trimIndent() + } + + test("Nested field") { + update { + User::bestFriend / Friend::name setOnInsert "foo" + } shouldBeBson """ + { + "$setOnInsert": { + "bestFriend.name": "foo" + } + } + """.trimIndent() + } + + test("Multiple fields") { + update { + User::age setOnInsert 18 + User::name setOnInsert "foo" + } shouldBeBson """ + { + "$setOnInsert": { + "age": 18, + "name": "foo" + } + } + """.trimIndent() + } + } }) From 5ea22b4ff04c7904745afa56b852cef1ace5f290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 11:42:33 +0200 Subject: [PATCH 05/16] refactor(sync): The MongoCollection is an interface --- demo/src/main/kotlin/Main.kt | 1 - driver-sync/src/main/kotlin/Count.kt | 62 -------- driver-sync/src/main/kotlin/Find.kt | 82 ---------- .../src/main/kotlin/MongoCollection.kt | 142 +++++++++++++++++- .../src/main/kotlin/NativeMongoCollection.kt | 72 +++++++++ 5 files changed, 206 insertions(+), 153 deletions(-) delete mode 100644 driver-sync/src/main/kotlin/Count.kt delete mode 100644 driver-sync/src/main/kotlin/Find.kt create mode 100644 driver-sync/src/main/kotlin/NativeMongoCollection.kt diff --git a/demo/src/main/kotlin/Main.kt b/demo/src/main/kotlin/Main.kt index bb28c59..5705513 100644 --- a/demo/src/main/kotlin/Main.kt +++ b/demo/src/main/kotlin/Main.kt @@ -2,7 +2,6 @@ package fr.qsh.ktmongo.demo import com.mongodb.kotlin.client.MongoClient import fr.qsh.ktmongo.sync.asKtMongo -import fr.qsh.ktmongo.sync.find data class Jedi( val name: String, diff --git a/driver-sync/src/main/kotlin/Count.kt b/driver-sync/src/main/kotlin/Count.kt deleted file mode 100644 index 0e7a835..0000000 --- a/driver-sync/src/main/kotlin/Count.kt +++ /dev/null @@ -1,62 +0,0 @@ -package fr.qsh.ktmongo.sync - -import fr.qsh.ktmongo.dsl.LowLevelApi -import fr.qsh.ktmongo.dsl.expr.FilterExpression -import org.bson.BsonDocument -import org.bson.BsonDocumentWriter - -/** - * Counts all documents in a collection. - * - * ### External resources - * - * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.countDocuments/) - * - * @see countDocumentsEstimated Faster alternative when the result doesn't need to be exact. - */ -@OptIn(LowLevelApi::class) -fun MongoCollection.countDocuments(): Long { - return unsafe.countDocuments() -} - -/** - * Counts documents that match [predicate] in a collection. - * - * ### External resources - * - * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.countDocuments/) - */ -@OptIn(LowLevelApi::class) -fun MongoCollection.countDocuments(predicate: FilterExpression.() -> Unit): Long { - val bson = BsonDocument() - - BsonDocumentWriter(bson).use { writer -> - FilterExpression(unsafe.codecRegistry) - .apply(predicate) - .writeTo(writer) - } - - return unsafe.countDocuments(filter = bson) -} - -/** - * Counts documents in a collection. - * - * This function reads collection metadata instead of actually counting through all documents. - * This makes it much more performant (almost no CPU nor RAM usage), but the count may be slightly out of date. - * - * Note that this count may become inaccurate when: - * - there are orphaned documents in a sharded cluster, - * - an unclean shutdown happened. - * - * Views do not possess the required metadata. Thus, when this function is called on a view, a regular [countDocuments] - * is executed instead. - * - * ### External resources - * - * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.estimatedDocumentCount/) - */ -@OptIn(LowLevelApi::class) -fun MongoCollection.countDocumentsEstimated(): Long { - return unsafe.estimatedDocumentCount() -} diff --git a/driver-sync/src/main/kotlin/Find.kt b/driver-sync/src/main/kotlin/Find.kt deleted file mode 100644 index 7ee05c9..0000000 --- a/driver-sync/src/main/kotlin/Find.kt +++ /dev/null @@ -1,82 +0,0 @@ -package fr.qsh.ktmongo.sync - -import com.mongodb.kotlin.client.FindIterable -import fr.qsh.ktmongo.dsl.LowLevelApi -import fr.qsh.ktmongo.dsl.expr.FilterExpression -import org.bson.BsonDocument -import org.bson.BsonDocumentWriter - -/** - * Finds all documents in the collection. - * - * ### External resources - * - * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.find/) - */ -@OptIn(LowLevelApi::class) -fun MongoCollection.find(): FindIterable { - return unsafe.find() -} - -/** - * Finds all documents in the collection that satisfy [predicate]. - * - * If multiple expressions are specified, an [and][FilterExpression.and] is used by default. - * - * ### Example - * - * ```kotlin - * class User( - * val name: String, - * val age: Int, - * ) - * - * collection.find { - * User::name eq "foo" - * User::age eq 18 - * } - * ``` - * - * ### External resources - * - * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.find/) - */ -@OptIn(LowLevelApi::class) -fun MongoCollection.find(predicate: FilterExpression.() -> Unit): FindIterable { - val bson = BsonDocument() - - BsonDocumentWriter(bson).use { writer -> - FilterExpression(unsafe.codecRegistry) - .apply(predicate) - .writeTo(writer) - } - - return unsafe.find(bson.asDocument()) -} - -/** - * Finds one or zero documents in the collection that satisfy [predicate]. - * - * If multiple expressions are specified, an [and][FilterExpression.and] is used by default. - * - * ### Example - * - * ```kotlin - * class User( - * val name: String, - * val age: Int, - * ) - * - * collection.findOne { - * User::name eq "foo" - * User::age eq 18 - * } - * ``` - * - * ### External resources - * - * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.find/) - */ -@OptIn(LowLevelApi::class) -fun MongoCollection.findOne(predicate: FilterExpression.() -> Unit): T? = - find(predicate).firstOrNull() diff --git a/driver-sync/src/main/kotlin/MongoCollection.kt b/driver-sync/src/main/kotlin/MongoCollection.kt index 5687d32..dba3659 100644 --- a/driver-sync/src/main/kotlin/MongoCollection.kt +++ b/driver-sync/src/main/kotlin/MongoCollection.kt @@ -1,12 +1,138 @@ package fr.qsh.ktmongo.sync -import fr.qsh.ktmongo.dsl.LowLevelApi -import com.mongodb.kotlin.client.MongoCollection as OfficialMongoCollection +import com.mongodb.kotlin.client.FindIterable +import fr.qsh.ktmongo.dsl.expr.FilterExpression -class MongoCollection( - @property:LowLevelApi - val unsafe: OfficialMongoCollection, -) +/** + * Parent interface to all collection types provided by KtMongo. + * + * To obtain an instance of this interface, see [asKtMongo]. + */ +sealed interface MongoCollection { -fun OfficialMongoCollection.asKtMongo() = - MongoCollection(this) + // region Find + + /** + * Finds all documents in this collection. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.find/) + */ + fun find(): FindIterable + + /** + * Finds all documents in this collection that satisfy [predicate]. + * + * If multiple predicates are specified, and [and][FilterExpression.and] operator is implied. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.find { + * User::name eq "foo" + * User::age eq 10 + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.find/) + * + * @see findOne When only one result is expected. + */ + fun find(predicate: FilterExpression.() -> Unit): FindIterable + + /** + * Finds a document in this collection that satisfies [predicate]. + * + * If multiple predicates are specified, and [and][FilterExpression.and] operator is implied. + * + * This function doesn't check that there is exactly one value in the collection. + * It simply returns the first matching document it finds. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.findOne { + * User::name eq "foo" + * User::age eq 10 + * } + * ``` + * + * @see find When multiple results are expected. + */ + fun findOne(predicate: FilterExpression.() -> Unit): Document? = + find(predicate).firstOrNull() + + // endregion + // region Count + + /** + * Counts all documents in the collection. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.countDocuments/) + * + * @see countEstimated Faster alternative when the result doesn't need to be exact. + */ + fun count(): Long + + /** + * Counts how many documents match [predicate] in the collection. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.countDocuments { + * User::name eq "foo" + * User::age eq 10 + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.countDocuments/) + */ + fun count(predicate: FilterExpression.() -> Unit): Long + + /** + * Counts all documents in the collection. + * + * This function reads collection metadata instead of actually counting through all documents. + * This makes it much more performant (almost no CPU nor RAM usage), but the count may be slightly out of date. + * + * In particular, it may become inaccurate when: + * - there are orphaned documents in a shared cluster, + * - an unclean shutdown happened. + * + * Views do not possess the required metadata. When this function is called on a view, + * a regular [count] is executed instead. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.estimatedDocumentCount/) + * + * @see count Perform the count for real. + */ + fun countEstimated(): Long + + // endregion + +} diff --git a/driver-sync/src/main/kotlin/NativeMongoCollection.kt b/driver-sync/src/main/kotlin/NativeMongoCollection.kt new file mode 100644 index 0000000..aba7eb9 --- /dev/null +++ b/driver-sync/src/main/kotlin/NativeMongoCollection.kt @@ -0,0 +1,72 @@ +package fr.qsh.ktmongo.sync + +import com.mongodb.kotlin.client.FindIterable +import fr.qsh.ktmongo.dsl.LowLevelApi +import fr.qsh.ktmongo.dsl.expr.FilterExpression +import org.bson.BsonDocument +import org.bson.BsonDocumentWriter +import com.mongodb.kotlin.client.MongoCollection as OfficialMongoCollection + +class NativeMongoCollection( + private val unsafe: OfficialMongoCollection, +) : MongoCollection { + + @LowLevelApi + fun asOfficialMongoCollection() = unsafe + + // region Find + + override fun find(): FindIterable = + unsafe.find() + + override fun find(predicate: FilterExpression.() -> Unit): FindIterable { + val bson = BsonDocument() + + @OptIn(LowLevelApi::class) + BsonDocumentWriter(bson).use { writer -> + FilterExpression(unsafe.codecRegistry) + .apply(predicate) + .writeTo(writer) + } + + return unsafe.find(bson.asDocument()) + } + + // endregion + // region Count + + override fun count(): Long = + unsafe.countDocuments() + + override fun count(predicate: FilterExpression.() -> Unit): Long { + val bson = BsonDocument() + + @OptIn(LowLevelApi::class) + BsonDocumentWriter(bson).use { writer -> + FilterExpression(unsafe.codecRegistry) + .apply(predicate) + .writeTo(writer) + } + + return unsafe.countDocuments(filter = bson) + } + + override fun countEstimated(): Long = + unsafe.estimatedDocumentCount() + + // endregion +} + +/** + * Converts a MongoDB collection from the Kotlin driver into a KtMongo collection. + * + * ### Example + * + * ```kotlin + * val client = MongoClient.create() + * val database = client.getDatabase("test") + * val collection = database.getCollection("users").asKtMongo() + * ``` + */ +fun OfficialMongoCollection.asKtMongo() = + NativeMongoCollection(this) From 1728b6bfb283866a91429670a586b12214c03e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 14:18:13 +0200 Subject: [PATCH 06/16] feat(sync): Introduce filtered collections --- .../main/kotlin/FilteredMongoCollection.kt | 65 +++++++++++++++++++ .../src/main/kotlin/MongoCollection.kt | 4 +- 2 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 driver-sync/src/main/kotlin/FilteredMongoCollection.kt diff --git a/driver-sync/src/main/kotlin/FilteredMongoCollection.kt b/driver-sync/src/main/kotlin/FilteredMongoCollection.kt new file mode 100644 index 0000000..2ecfc30 --- /dev/null +++ b/driver-sync/src/main/kotlin/FilteredMongoCollection.kt @@ -0,0 +1,65 @@ +package fr.qsh.ktmongo.sync + +import com.mongodb.kotlin.client.FindIterable +import fr.qsh.ktmongo.dsl.expr.FilterExpression + +private class FilteredMongoCollection( + private val upstream: MongoCollection, + private val baseFilter: FilterExpression.() -> Unit, +) : MongoCollection { + override fun find(): FindIterable = upstream.find { + baseFilter() + } + + override fun count(): Long = upstream.count { + baseFilter() + } + + // countEstimated is a real count when a filter is present, it's slower but at least it won't break the app + override fun countEstimated(): Long = upstream.count { + baseFilter() + } + + override fun count(predicate: FilterExpression.() -> Unit): Long = + upstream.count { + baseFilter() + predicate() + } + + override fun find(predicate: FilterExpression.() -> Unit): FindIterable = + upstream.find { + baseFilter() + predicate() + } +} + +/** + * Returns a filtered collection that only contains the elements that match [predicate]. + * + * This function creates a logical view of the collection: by itself, this function does nothing, and MongoDB is never + * aware of the existence of this logical view. However, operations invoked on the returned collection will only affect + * elements from the original that match the [predicate]. + * + * Unlike actual MongoDB views, which are read-only, collections returned by this function can also be used for write operations. + * + * ### Example + * + * A typical usage of this function is to reuse filters for multiple operations. + * For example, if you have a concept of logical deletion, this function can be used to hide deleted values. + * + * ```kotlin + * class Order( + * val id: String, + * val date: Instant, + * val deleted: Boolean, + * ) + * + * val allOrders = database.getCollection("orders").asKtMongo() + * val activeOrders = allOrders.filter { Order::deleted ne true } + * + * allOrders.find() // Returns all orders, deleted or not + * activeOrders.find() // Only returns orders that are not logically deleted + * ``` + */ +fun MongoCollection.filter(predicate: FilterExpression.() -> Unit): MongoCollection = + FilteredMongoCollection(this, predicate) diff --git a/driver-sync/src/main/kotlin/MongoCollection.kt b/driver-sync/src/main/kotlin/MongoCollection.kt index dba3659..03f765a 100644 --- a/driver-sync/src/main/kotlin/MongoCollection.kt +++ b/driver-sync/src/main/kotlin/MongoCollection.kt @@ -122,8 +122,8 @@ sealed interface MongoCollection { * - there are orphaned documents in a shared cluster, * - an unclean shutdown happened. * - * Views do not possess the required metadata. When this function is called on a view, - * a regular [count] is executed instead. + * Views do not possess the required metadata. + * When this function is called on a view (either a MongoDB view or a [filter] logical view), a regular [count] is executed instead. * * ### External resources * From a8c605094365309bd3f8975f4742f5090f9f11fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 14:36:11 +0200 Subject: [PATCH 07/16] feat(sync): Introduce multi-collections --- .../src/main/kotlin/MultiMongoCollection.kt | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 driver-sync/src/main/kotlin/MultiMongoCollection.kt diff --git a/driver-sync/src/main/kotlin/MultiMongoCollection.kt b/driver-sync/src/main/kotlin/MultiMongoCollection.kt new file mode 100644 index 0000000..3cb2785 --- /dev/null +++ b/driver-sync/src/main/kotlin/MultiMongoCollection.kt @@ -0,0 +1,58 @@ +package fr.qsh.ktmongo.sync + +import com.mongodb.kotlin.client.FindIterable +import com.mongodb.kotlin.client.MongoDatabase +import fr.qsh.ktmongo.dsl.expr.FilterExpression + +private class MultiMongoCollection( + private val generator: () -> MongoCollection +) : MongoCollection { + override fun find(): FindIterable = + generator().find() + + override fun count(): Long = + generator().count() + + override fun countEstimated(): Long = + generator().countEstimated() + + override fun count(predicate: FilterExpression.() -> Unit): Long = + generator().count(predicate) + + override fun find(predicate: FilterExpression.() -> Unit): FindIterable = + generator().find(predicate) + +} + +/** + * Wraps multiple collections based on the context, as computed by [generator]. + * + * The [generator] function is executed in the context of each request, and is responsible for deciding which collection + * the request will be routed to. + * + * ### Example + * + * A common use-case is for SaaS companies, to segregate between each client to its own collection to ensure + * data from different clients isn't mixed. + * + * For example, if you store the client ID in a [ScopedValue]: + * ```kotlin + * val clientId = ScopedValue.newInstance(); + * + * val client = MongoClient.create() + * val database = client.getDatabase("test") + * val users = database.getMultiCollection { + * it.getCollection("users-${clientId.get()}").asKtMongo() + * } + * + * ScopedValue.runWhere(clientId, "foo") { + * users.find() // finds in the collection "users-foo" + * } + * + * ScopedValue.runWhere(clientId, "bar") { + * users.find() // finds in the collection "users-bar" + * } + * ``` + */ +fun MongoDatabase.getMultiCollection(generator: (MongoDatabase) -> MongoCollection): MongoCollection = + MultiMongoCollection { generator(this) } From b9e6e0f16d2500163756befb6e4f3563de796cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 15:25:35 +0200 Subject: [PATCH 08/16] feat(sync): update operations --- .../main/kotlin/FilteredMongoCollection.kt | 32 +++- .../src/main/kotlin/MongoCollection.kt | 178 ++++++++++++++++++ .../src/main/kotlin/MultiMongoCollection.kt | 10 + .../src/main/kotlin/NativeMongoCollection.kt | 86 +++++++-- 4 files changed, 280 insertions(+), 26 deletions(-) diff --git a/driver-sync/src/main/kotlin/FilteredMongoCollection.kt b/driver-sync/src/main/kotlin/FilteredMongoCollection.kt index 2ecfc30..36bb16a 100644 --- a/driver-sync/src/main/kotlin/FilteredMongoCollection.kt +++ b/driver-sync/src/main/kotlin/FilteredMongoCollection.kt @@ -1,24 +1,38 @@ package fr.qsh.ktmongo.sync +import com.mongodb.client.result.UpdateResult import com.mongodb.kotlin.client.FindIterable import fr.qsh.ktmongo.dsl.expr.FilterExpression +import fr.qsh.ktmongo.dsl.expr.UpdateExpression private class FilteredMongoCollection( private val upstream: MongoCollection, private val baseFilter: FilterExpression.() -> Unit, ) : MongoCollection { - override fun find(): FindIterable = upstream.find { - baseFilter() - } + override fun find(): FindIterable = upstream.find(baseFilter) - override fun count(): Long = upstream.count { - baseFilter() - } + override fun count(): Long = upstream.count(baseFilter) // countEstimated is a real count when a filter is present, it's slower but at least it won't break the app - override fun countEstimated(): Long = upstream.count { - baseFilter() - } + override fun countEstimated(): Long = upstream.count(baseFilter) + + override fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? = + upstream.findOneAndUpdate( + filter = { baseFilter(); filter() }, + update = update, + ) + + override fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = + upstream.updateOne( + filter = { baseFilter(); filter() }, + update = update, + ) + + override fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = + upstream.updateMany( + filter = { baseFilter(); filter() }, + update = update, + ) override fun count(predicate: FilterExpression.() -> Unit): Long = upstream.count { diff --git a/driver-sync/src/main/kotlin/MongoCollection.kt b/driver-sync/src/main/kotlin/MongoCollection.kt index 03f765a..4a868a1 100644 --- a/driver-sync/src/main/kotlin/MongoCollection.kt +++ b/driver-sync/src/main/kotlin/MongoCollection.kt @@ -1,7 +1,9 @@ package fr.qsh.ktmongo.sync +import com.mongodb.client.result.UpdateResult import com.mongodb.kotlin.client.FindIterable import fr.qsh.ktmongo.dsl.expr.FilterExpression +import fr.qsh.ktmongo.dsl.expr.UpdateExpression /** * Parent interface to all collection types provided by KtMongo. @@ -134,5 +136,181 @@ sealed interface MongoCollection { fun countEstimated(): Long // endregion + // region Update + + /** + * Updates all documents in this collection according to [update]. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.updateMany { + * User::name set "foo" + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/update/) + * + * @see updateOne + */ + fun updateMany(update: UpdateExpression.() -> Unit): UpdateResult = + updateMany({}, update) + + /** + * Updates all documents that match [filter] according to [update]. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.updateMany( + * filter = { + * User::name eq "Patrick" + * }, + * age = { + * User::age set 15 + * }, + * ) + * ``` + * + * ### Using filtered collections + * + * The following code is equivalent: + * ```kotlin + * collection.filter { + * User::name eq "Patrick" + * }.updateMany { + * User::age set 15 + * } + * ``` + * + * To learn more, see [filter][MongoCollection.filter]. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/update/) + * + * @see updateOne + */ + fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult + + /** + * Updates a single document according to [update]. + * + * If there are multiple documents in this collection, only the first one found is updated. + * + * ### Example + * + * This function is more useful when paired with [filter][MongoCollection.filter]: + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.filter { + * User::name eq "Patrick" + * }.updateOne { + * User::age set 15 + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/update/) + * + * @see updateMany Update more than one document. + * @see findOneAndUpdate Also returns the result of the update. + */ + fun updateOne(update: UpdateExpression.() -> Unit): UpdateResult = + updateOne({}, update) + + /** + * Updates a single document that matches [filter] according to [update]. + * + * If multiple documents match [filter], only the first one found is updated. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.updateOne( + * filter = { + * User::name eq "Patrick" + * }, + * age = { + * User::age set 15 + * }, + * ) + * ``` + * + * ### Using filtered collections + * + * The following code is equivalent: + * ```kotlin + * collection.filter { + * User::name eq "Patrick" + * }.updateOne { + * User::age set 15 + * } + * ``` + * + * To learn more, see [filter][MongoCollection.filter]. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/update/) + * + * @see updateMany Update more than one document. + * @see findOneAndUpdate Also returns the result of the update. + */ + fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult + + /** + * Updates one element that matches [filter] according to [update] and returns it, atomically. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.findOneAndUpdate( + * filter = { + * User::name eq "Patrick" + * }, + * age = { + * User::age set 15 + * }, + * ) + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/findAndModify/) + * + * @see updateMany Update more than one document. + * @see updateOne Do not return the value. + */ + fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? + + // endregion } diff --git a/driver-sync/src/main/kotlin/MultiMongoCollection.kt b/driver-sync/src/main/kotlin/MultiMongoCollection.kt index 3cb2785..7014de4 100644 --- a/driver-sync/src/main/kotlin/MultiMongoCollection.kt +++ b/driver-sync/src/main/kotlin/MultiMongoCollection.kt @@ -1,8 +1,10 @@ package fr.qsh.ktmongo.sync +import com.mongodb.client.result.UpdateResult import com.mongodb.kotlin.client.FindIterable import com.mongodb.kotlin.client.MongoDatabase import fr.qsh.ktmongo.dsl.expr.FilterExpression +import fr.qsh.ktmongo.dsl.expr.UpdateExpression private class MultiMongoCollection( private val generator: () -> MongoCollection @@ -22,6 +24,14 @@ private class MultiMongoCollection( override fun find(predicate: FilterExpression.() -> Unit): FindIterable = generator().find(predicate) + override fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = + generator().updateMany(filter, update) + + override fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = + generator().updateOne(filter, update) + + override fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? = + generator().findOneAndUpdate(filter, update) } /** diff --git a/driver-sync/src/main/kotlin/NativeMongoCollection.kt b/driver-sync/src/main/kotlin/NativeMongoCollection.kt index aba7eb9..3e6bac5 100644 --- a/driver-sync/src/main/kotlin/NativeMongoCollection.kt +++ b/driver-sync/src/main/kotlin/NativeMongoCollection.kt @@ -1,8 +1,11 @@ package fr.qsh.ktmongo.sync +import com.mongodb.client.result.UpdateResult import com.mongodb.kotlin.client.FindIterable import fr.qsh.ktmongo.dsl.LowLevelApi import fr.qsh.ktmongo.dsl.expr.FilterExpression +import fr.qsh.ktmongo.dsl.expr.UpdateExpression +import fr.qsh.ktmongo.dsl.expr.common.CompoundExpression import org.bson.BsonDocument import org.bson.BsonDocumentWriter import com.mongodb.kotlin.client.MongoCollection as OfficialMongoCollection @@ -14,22 +17,28 @@ class NativeMongoCollection( @LowLevelApi fun asOfficialMongoCollection() = unsafe + @OptIn(LowLevelApi::class) + private fun CompoundExpression.toBsonDocument(): BsonDocument { + val bson = BsonDocument() + + BsonDocumentWriter(bson).use { writer -> + this.writeTo(writer) + } + + return bson + } + // region Find override fun find(): FindIterable = unsafe.find() override fun find(predicate: FilterExpression.() -> Unit): FindIterable { - val bson = BsonDocument() + val bson = FilterExpression(unsafe.codecRegistry) + .apply(predicate) + .toBsonDocument() - @OptIn(LowLevelApi::class) - BsonDocumentWriter(bson).use { writer -> - FilterExpression(unsafe.codecRegistry) - .apply(predicate) - .writeTo(writer) - } - - return unsafe.find(bson.asDocument()) + return unsafe.find(bson) } // endregion @@ -39,14 +48,9 @@ class NativeMongoCollection( unsafe.countDocuments() override fun count(predicate: FilterExpression.() -> Unit): Long { - val bson = BsonDocument() - - @OptIn(LowLevelApi::class) - BsonDocumentWriter(bson).use { writer -> - FilterExpression(unsafe.codecRegistry) - .apply(predicate) - .writeTo(writer) - } + val bson = FilterExpression(unsafe.codecRegistry) + .apply(predicate) + .toBsonDocument() return unsafe.countDocuments(filter = bson) } @@ -54,6 +58,54 @@ class NativeMongoCollection( override fun countEstimated(): Long = unsafe.estimatedDocumentCount() + // endregion + // region Update + + override fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult { + val filterBson = FilterExpression(unsafe.codecRegistry) + .apply(filter) + .toBsonDocument() + + val updateBson = UpdateExpression(unsafe.codecRegistry) + .apply(update) + .toBsonDocument() + + return unsafe.updateOne( + filter = filterBson, + update = updateBson, + ) + } + + override fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult { + val filterBson = FilterExpression(unsafe.codecRegistry) + .apply(filter) + .toBsonDocument() + + val updateBson = UpdateExpression(unsafe.codecRegistry) + .apply(update) + .toBsonDocument() + + return unsafe.updateMany( + filter = filterBson, + update = updateBson, + ) + } + + override fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? { + val filterBson = FilterExpression(unsafe.codecRegistry) + .apply(filter) + .toBsonDocument() + + val updateBson = UpdateExpression(unsafe.codecRegistry) + .apply(update) + .toBsonDocument() + + return unsafe.findOneAndUpdate( + filter = filterBson, + update = updateBson, + ) + } + // endregion } From 5b842553c81abfc51f9cd28d250462919041142d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 15:44:36 +0200 Subject: [PATCH 09/16] feat(sync): Accept operation options --- demo/src/main/kotlin/Main.kt | 8 +++ .../main/kotlin/FilteredMongoCollection.kt | 40 ++++++++++++--- .../src/main/kotlin/MongoCollection.kt | 49 +++++++++++++++---- .../src/main/kotlin/MultiMongoCollection.kt | 28 ++++++----- .../src/main/kotlin/NativeMongoCollection.kt | 40 +++++++++++---- 5 files changed, 127 insertions(+), 38 deletions(-) diff --git a/demo/src/main/kotlin/Main.kt b/demo/src/main/kotlin/Main.kt index 5705513..63ac19c 100644 --- a/demo/src/main/kotlin/Main.kt +++ b/demo/src/main/kotlin/Main.kt @@ -1,7 +1,9 @@ package fr.qsh.ktmongo.demo +import com.mongodb.client.model.UpdateOptions import com.mongodb.kotlin.client.MongoClient import fr.qsh.ktmongo.sync.asKtMongo +import fr.qsh.ktmongo.sync.filter data class Jedi( val name: String, @@ -21,4 +23,10 @@ fun main() { Jedi::age eq 18 } } + + collection.filter { + Jedi::name eq "foo" + }.updateOne(UpdateOptions().upsert(true)) { + Jedi::age set 19 + } } diff --git a/driver-sync/src/main/kotlin/FilteredMongoCollection.kt b/driver-sync/src/main/kotlin/FilteredMongoCollection.kt index 36bb16a..018e32c 100644 --- a/driver-sync/src/main/kotlin/FilteredMongoCollection.kt +++ b/driver-sync/src/main/kotlin/FilteredMongoCollection.kt @@ -1,9 +1,14 @@ package fr.qsh.ktmongo.sync +import com.mongodb.client.model.CountOptions +import com.mongodb.client.model.EstimatedDocumentCountOptions +import com.mongodb.client.model.FindOneAndUpdateOptions +import com.mongodb.client.model.UpdateOptions import com.mongodb.client.result.UpdateResult import com.mongodb.kotlin.client.FindIterable import fr.qsh.ktmongo.dsl.expr.FilterExpression import fr.qsh.ktmongo.dsl.expr.UpdateExpression +import java.util.concurrent.TimeUnit private class FilteredMongoCollection( private val upstream: MongoCollection, @@ -11,31 +16,52 @@ private class FilteredMongoCollection( ) : MongoCollection { override fun find(): FindIterable = upstream.find(baseFilter) - override fun count(): Long = upstream.count(baseFilter) + override fun count(options: CountOptions): Long = upstream.count(options, baseFilter) // countEstimated is a real count when a filter is present, it's slower but at least it won't break the app - override fun countEstimated(): Long = upstream.count(baseFilter) + override fun countEstimated(options: EstimatedDocumentCountOptions): Long = upstream.count( + CountOptions().maxTime(options.getMaxTime(TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS).comment(options.comment), + baseFilter, + ) - override fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? = + override fun findOneAndUpdate( + options: FindOneAndUpdateOptions, + filter: FilterExpression.() -> Unit, + update: UpdateExpression.() -> Unit + ): Document? = upstream.findOneAndUpdate( + options, filter = { baseFilter(); filter() }, update = update, ) - override fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = + override fun updateOne( + options: UpdateOptions, + filter: FilterExpression.() -> Unit, + update: UpdateExpression.() -> Unit, + ): UpdateResult = upstream.updateOne( + options, filter = { baseFilter(); filter() }, update = update, ) - override fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = + override fun updateMany( + options: UpdateOptions, + filter: FilterExpression.() -> Unit, + update: UpdateExpression.() -> Unit, + ): UpdateResult = upstream.updateMany( + options, filter = { baseFilter(); filter() }, update = update, ) - override fun count(predicate: FilterExpression.() -> Unit): Long = - upstream.count { + override fun count( + options: CountOptions, + predicate: FilterExpression.() -> Unit, + ): Long = + upstream.count(options) { baseFilter() predicate() } diff --git a/driver-sync/src/main/kotlin/MongoCollection.kt b/driver-sync/src/main/kotlin/MongoCollection.kt index 4a868a1..04abd13 100644 --- a/driver-sync/src/main/kotlin/MongoCollection.kt +++ b/driver-sync/src/main/kotlin/MongoCollection.kt @@ -1,5 +1,9 @@ package fr.qsh.ktmongo.sync +import com.mongodb.client.model.CountOptions +import com.mongodb.client.model.EstimatedDocumentCountOptions +import com.mongodb.client.model.FindOneAndUpdateOptions +import com.mongodb.client.model.UpdateOptions import com.mongodb.client.result.UpdateResult import com.mongodb.kotlin.client.FindIterable import fr.qsh.ktmongo.dsl.expr.FilterExpression @@ -89,7 +93,9 @@ sealed interface MongoCollection { * * @see countEstimated Faster alternative when the result doesn't need to be exact. */ - fun count(): Long + fun count( + options: CountOptions = CountOptions(), + ): Long /** * Counts how many documents match [predicate] in the collection. @@ -112,7 +118,10 @@ sealed interface MongoCollection { * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.countDocuments/) */ - fun count(predicate: FilterExpression.() -> Unit): Long + fun count( + options: CountOptions = CountOptions(), + predicate: FilterExpression.() -> Unit, + ): Long /** * Counts all documents in the collection. @@ -133,7 +142,9 @@ sealed interface MongoCollection { * * @see count Perform the count for real. */ - fun countEstimated(): Long + fun countEstimated( + options: EstimatedDocumentCountOptions = EstimatedDocumentCountOptions(), + ): Long // endregion // region Update @@ -160,8 +171,11 @@ sealed interface MongoCollection { * * @see updateOne */ - fun updateMany(update: UpdateExpression.() -> Unit): UpdateResult = - updateMany({}, update) + fun updateMany( + options: UpdateOptions = UpdateOptions(), + update: UpdateExpression.() -> Unit, + ): UpdateResult = + updateMany(options, {}, update) /** * Updates all documents that match [filter] according to [update]. @@ -203,7 +217,11 @@ sealed interface MongoCollection { * * @see updateOne */ - fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult + fun updateMany( + options: UpdateOptions = UpdateOptions(), + filter: FilterExpression.() -> Unit, + update: UpdateExpression.() -> Unit, + ): UpdateResult /** * Updates a single document according to [update]. @@ -233,8 +251,11 @@ sealed interface MongoCollection { * @see updateMany Update more than one document. * @see findOneAndUpdate Also returns the result of the update. */ - fun updateOne(update: UpdateExpression.() -> Unit): UpdateResult = - updateOne({}, update) + fun updateOne( + options: UpdateOptions = UpdateOptions(), + update: UpdateExpression.() -> Unit, + ): UpdateResult = + updateOne(filter = {}, update = update) /** * Updates a single document that matches [filter] according to [update]. @@ -279,7 +300,11 @@ sealed interface MongoCollection { * @see updateMany Update more than one document. * @see findOneAndUpdate Also returns the result of the update. */ - fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult + fun updateOne( + options: UpdateOptions = UpdateOptions(), + filter: FilterExpression.() -> Unit, + update: UpdateExpression.() -> Unit, + ): UpdateResult /** * Updates one element that matches [filter] according to [update] and returns it, atomically. @@ -309,7 +334,11 @@ sealed interface MongoCollection { * @see updateMany Update more than one document. * @see updateOne Do not return the value. */ - fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? + fun findOneAndUpdate( + options: FindOneAndUpdateOptions = FindOneAndUpdateOptions(), + filter: FilterExpression.() -> Unit, + update: UpdateExpression.() -> Unit, + ): Document? // endregion diff --git a/driver-sync/src/main/kotlin/MultiMongoCollection.kt b/driver-sync/src/main/kotlin/MultiMongoCollection.kt index 7014de4..566c7c5 100644 --- a/driver-sync/src/main/kotlin/MultiMongoCollection.kt +++ b/driver-sync/src/main/kotlin/MultiMongoCollection.kt @@ -1,5 +1,9 @@ package fr.qsh.ktmongo.sync +import com.mongodb.client.model.CountOptions +import com.mongodb.client.model.EstimatedDocumentCountOptions +import com.mongodb.client.model.FindOneAndUpdateOptions +import com.mongodb.client.model.UpdateOptions import com.mongodb.client.result.UpdateResult import com.mongodb.kotlin.client.FindIterable import com.mongodb.kotlin.client.MongoDatabase @@ -12,26 +16,26 @@ private class MultiMongoCollection( override fun find(): FindIterable = generator().find() - override fun count(): Long = - generator().count() + override fun count(options: CountOptions): Long = + generator().count(options) - override fun countEstimated(): Long = - generator().countEstimated() + override fun countEstimated(options: EstimatedDocumentCountOptions): Long = + generator().countEstimated(options) - override fun count(predicate: FilterExpression.() -> Unit): Long = - generator().count(predicate) + override fun count(options: CountOptions, predicate: FilterExpression.() -> Unit): Long = + generator().count(options, predicate) override fun find(predicate: FilterExpression.() -> Unit): FindIterable = generator().find(predicate) - override fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = - generator().updateMany(filter, update) + override fun updateMany(options: UpdateOptions, filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = + generator().updateMany(options, filter, update) - override fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = - generator().updateOne(filter, update) + override fun updateOne(options: UpdateOptions, filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult = + generator().updateOne(options, filter, update) - override fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? = - generator().findOneAndUpdate(filter, update) + override fun findOneAndUpdate(options: FindOneAndUpdateOptions, filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? = + generator().findOneAndUpdate(options, filter, update) } /** diff --git a/driver-sync/src/main/kotlin/NativeMongoCollection.kt b/driver-sync/src/main/kotlin/NativeMongoCollection.kt index 3e6bac5..ea99a33 100644 --- a/driver-sync/src/main/kotlin/NativeMongoCollection.kt +++ b/driver-sync/src/main/kotlin/NativeMongoCollection.kt @@ -1,5 +1,9 @@ package fr.qsh.ktmongo.sync +import com.mongodb.client.model.CountOptions +import com.mongodb.client.model.EstimatedDocumentCountOptions +import com.mongodb.client.model.FindOneAndUpdateOptions +import com.mongodb.client.model.UpdateOptions import com.mongodb.client.result.UpdateResult import com.mongodb.kotlin.client.FindIterable import fr.qsh.ktmongo.dsl.LowLevelApi @@ -44,24 +48,31 @@ class NativeMongoCollection( // endregion // region Count - override fun count(): Long = - unsafe.countDocuments() + override fun count(options: CountOptions): Long = + unsafe.countDocuments(options = options) - override fun count(predicate: FilterExpression.() -> Unit): Long { + override fun count( + options: CountOptions, + predicate: FilterExpression.() -> Unit, + ): Long { val bson = FilterExpression(unsafe.codecRegistry) .apply(predicate) .toBsonDocument() - return unsafe.countDocuments(filter = bson) + return unsafe.countDocuments(bson, options) } - override fun countEstimated(): Long = - unsafe.estimatedDocumentCount() + override fun countEstimated(options: EstimatedDocumentCountOptions): Long = + unsafe.estimatedDocumentCount(options) // endregion // region Update - override fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult { + override fun updateOne( + options: UpdateOptions, + filter: FilterExpression.() -> Unit, + update: UpdateExpression.() -> Unit, + ): UpdateResult { val filterBson = FilterExpression(unsafe.codecRegistry) .apply(filter) .toBsonDocument() @@ -73,10 +84,15 @@ class NativeMongoCollection( return unsafe.updateOne( filter = filterBson, update = updateBson, + options = options, ) } - override fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): UpdateResult { + override fun updateMany( + options: UpdateOptions, + filter: FilterExpression.() -> Unit, + update: UpdateExpression.() -> Unit, + ): UpdateResult { val filterBson = FilterExpression(unsafe.codecRegistry) .apply(filter) .toBsonDocument() @@ -88,10 +104,15 @@ class NativeMongoCollection( return unsafe.updateMany( filter = filterBson, update = updateBson, + options = options, ) } - override fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? { + override fun findOneAndUpdate( + options: FindOneAndUpdateOptions, + filter: FilterExpression.() -> Unit, + update: UpdateExpression.() -> Unit, + ): Document? { val filterBson = FilterExpression(unsafe.codecRegistry) .apply(filter) .toBsonDocument() @@ -103,6 +124,7 @@ class NativeMongoCollection( return unsafe.findOneAndUpdate( filter = filterBson, update = updateBson, + options = options, ) } From cdeb82c55a7beddc87e732a04278494b61b4956c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 15:49:46 +0200 Subject: [PATCH 10/16] refactor(sync): Replace update overloads by optional arguments --- .../src/main/kotlin/MongoCollection.kt | 74 +++---------------- 1 file changed, 9 insertions(+), 65 deletions(-) diff --git a/driver-sync/src/main/kotlin/MongoCollection.kt b/driver-sync/src/main/kotlin/MongoCollection.kt index 04abd13..85195e3 100644 --- a/driver-sync/src/main/kotlin/MongoCollection.kt +++ b/driver-sync/src/main/kotlin/MongoCollection.kt @@ -149,34 +149,6 @@ sealed interface MongoCollection { // endregion // region Update - /** - * Updates all documents in this collection according to [update]. - * - * ### Example - * - * ```kotlin - * class User( - * val name: String, - * val age: Int, - * ) - * - * collection.updateMany { - * User::name set "foo" - * } - * ``` - * - * ### External resources - * - * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/update/) - * - * @see updateOne - */ - fun updateMany( - options: UpdateOptions = UpdateOptions(), - update: UpdateExpression.() -> Unit, - ): UpdateResult = - updateMany(options, {}, update) - /** * Updates all documents that match [filter] according to [update]. * @@ -215,48 +187,16 @@ sealed interface MongoCollection { * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/update/) * + * @param filter Optional filter to select which documents are updated. + * If no filter is specified, all documents are updated. * @see updateOne */ fun updateMany( options: UpdateOptions = UpdateOptions(), - filter: FilterExpression.() -> Unit, + filter: FilterExpression.() -> Unit = {}, update: UpdateExpression.() -> Unit, ): UpdateResult - /** - * Updates a single document according to [update]. - * - * If there are multiple documents in this collection, only the first one found is updated. - * - * ### Example - * - * This function is more useful when paired with [filter][MongoCollection.filter]: - * ```kotlin - * class User( - * val name: String, - * val age: Int, - * ) - * - * collection.filter { - * User::name eq "Patrick" - * }.updateOne { - * User::age set 15 - * } - * ``` - * - * ### External resources - * - * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/update/) - * - * @see updateMany Update more than one document. - * @see findOneAndUpdate Also returns the result of the update. - */ - fun updateOne( - options: UpdateOptions = UpdateOptions(), - update: UpdateExpression.() -> Unit, - ): UpdateResult = - updateOne(filter = {}, update = update) - /** * Updates a single document that matches [filter] according to [update]. * @@ -297,12 +237,14 @@ sealed interface MongoCollection { * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/update/) * + * @param filter Optional filter to select which document is updated. + * If no filter is specified, the first document found is updated. * @see updateMany Update more than one document. * @see findOneAndUpdate Also returns the result of the update. */ fun updateOne( options: UpdateOptions = UpdateOptions(), - filter: FilterExpression.() -> Unit, + filter: FilterExpression.() -> Unit = {}, update: UpdateExpression.() -> Unit, ): UpdateResult @@ -331,12 +273,14 @@ sealed interface MongoCollection { * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/findAndModify/) * + * @param filter Optional filter to select which document is updated. + * If no filter is specified, the first document found is updated. * @see updateMany Update more than one document. * @see updateOne Do not return the value. */ fun findOneAndUpdate( options: FindOneAndUpdateOptions = FindOneAndUpdateOptions(), - filter: FilterExpression.() -> Unit, + filter: FilterExpression.() -> Unit = {}, update: UpdateExpression.() -> Unit, ): Document? From a42e1604e49266668959cda47da3446062acd47e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 16:05:13 +0200 Subject: [PATCH 11/16] feat(sync): Introduce upsertOne --- demo/src/main/kotlin/Main.kt | 4 +- .../src/main/kotlin/MongoCollection.kt | 54 +++++++++++++++++++ dsl/src/main/kotlin/expr/UpdateExpression.kt | 5 +- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/demo/src/main/kotlin/Main.kt b/demo/src/main/kotlin/Main.kt index 63ac19c..e1a7b5a 100644 --- a/demo/src/main/kotlin/Main.kt +++ b/demo/src/main/kotlin/Main.kt @@ -1,6 +1,5 @@ package fr.qsh.ktmongo.demo -import com.mongodb.client.model.UpdateOptions import com.mongodb.kotlin.client.MongoClient import fr.qsh.ktmongo.sync.asKtMongo import fr.qsh.ktmongo.sync.filter @@ -8,6 +7,7 @@ import fr.qsh.ktmongo.sync.filter data class Jedi( val name: String, val age: Int, + val level: Int, ) fun main() { @@ -26,7 +26,7 @@ fun main() { collection.filter { Jedi::name eq "foo" - }.updateOne(UpdateOptions().upsert(true)) { + }.upsertOne { Jedi::age set 19 } } diff --git a/driver-sync/src/main/kotlin/MongoCollection.kt b/driver-sync/src/main/kotlin/MongoCollection.kt index 85195e3..dd9287c 100644 --- a/driver-sync/src/main/kotlin/MongoCollection.kt +++ b/driver-sync/src/main/kotlin/MongoCollection.kt @@ -248,6 +248,60 @@ sealed interface MongoCollection { update: UpdateExpression.() -> Unit, ): UpdateResult + /** + * Updates a single document that matches [filter] according to [update]. + * + * If multiple documents match [filter], only the first one is updated. + * + * If no documents match [filter], a new one is created. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.upsertOne( + * filter = { + * User::name eq "Patrick" + * }, + * age = { + * User::age set 15 + * }, + * ) + * ``` + * + * If a document exists that has the `name` of "Patrick", its age is set to 15. + * If none exists, a document with `name` "Patrick" and `age` 15 is created. + * + * ### Using filtered collections + * + * The following code is equivalent: + * ```kotlin + * collection.filter { + * User::name eq "Patrick" + * }.upsertOne { + * User::age set 15 + * } + * ``` + * + * To learn more, see [filter][MongoCollection.filter]. + * + * ### External resources + * + * - [The update operation] + * - [The behavior of upsert functions](https://www.mongodb.com/docs/manual/reference/method/db.collection.update/#insert-a-new-document-if-no-match-exists--upsert-) + * + * @see updateOne + */ + fun upsertOne( + options: UpdateOptions = UpdateOptions(), + filter: FilterExpression.() -> Unit = {}, + update: UpdateExpression.() -> Unit, + ) = updateOne(options.upsert(true), filter, update) + /** * Updates one element that matches [filter] according to [update] and returns it, atomically. * diff --git a/dsl/src/main/kotlin/expr/UpdateExpression.kt b/dsl/src/main/kotlin/expr/UpdateExpression.kt index 4086bf6..f471162 100644 --- a/dsl/src/main/kotlin/expr/UpdateExpression.kt +++ b/dsl/src/main/kotlin/expr/UpdateExpression.kt @@ -76,7 +76,7 @@ class UpdateExpression( * val age: Int, * ) * - * collection.update { + * collection.updateMany { * User::age set 18 * } * ``` @@ -131,7 +131,7 @@ class UpdateExpression( * val age: Int, * ) * - * collection.update { + * collection.upsertOne { * User::age setOnInsert 18 * } * ``` @@ -142,7 +142,6 @@ class UpdateExpression( * * @see set Always set the value. */ - // TODO: make the above example an upsert @OptIn(LowLevelApi::class) @KtMongoDsl infix fun <@OnlyInputTypes V> KProperty1.setOnInsert(value: V) { From 6465f0012ad015c4e9e26b73501975e57d0fcd25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 16:37:04 +0200 Subject: [PATCH 12/16] feat(dsl): $inc --- demo/src/main/kotlin/Main.kt | 1 + dsl/README.md | 1 + dsl/src/main/kotlin/expr/UpdateExpression.kt | 70 +++++++++++++++++-- .../test/kotlin/expr/UpdateExpressionTest.kt | 43 ++++++++++++ 4 files changed, 111 insertions(+), 4 deletions(-) diff --git a/demo/src/main/kotlin/Main.kt b/demo/src/main/kotlin/Main.kt index e1a7b5a..f16c5c1 100644 --- a/demo/src/main/kotlin/Main.kt +++ b/demo/src/main/kotlin/Main.kt @@ -28,5 +28,6 @@ fun main() { Jedi::name eq "foo" }.upsertOne { Jedi::age set 19 + Jedi::level inc 1 } } diff --git a/dsl/README.md b/dsl/README.md index 7ec69e8..ebd6dfb 100644 --- a/dsl/README.md +++ b/dsl/README.md @@ -19,5 +19,6 @@ ### Update +- [`$inc`](fr.qsh.ktmongo.dsl.expr.UpdateExpression.inc) - [`$set`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.set] - [`$setOnInsert`](fr.qsh.ktmongo.dsl.expr.UpdateExpression.setOnInsert) diff --git a/dsl/src/main/kotlin/expr/UpdateExpression.kt b/dsl/src/main/kotlin/expr/UpdateExpression.kt index f471162..28844fb 100644 --- a/dsl/src/main/kotlin/expr/UpdateExpression.kt +++ b/dsl/src/main/kotlin/expr/UpdateExpression.kt @@ -76,7 +76,9 @@ class UpdateExpression( * val age: Int, * ) * - * collection.updateMany { + * collection.filter { + * User::name eq "foo" + * }.updateMany { * User::age set 18 * } * ``` @@ -120,7 +122,7 @@ class UpdateExpression( * then this operator assigns the specified [value] to the field. * If the update operation does not result in an insert, this operator does nothing. * - * If used in an update operation that isn't an upset, no document can be inserted, + * If used in an update operation that isn't an upsert, no document can be inserted, * and thus this operator never does anything. * * ### Example @@ -131,7 +133,9 @@ class UpdateExpression( * val age: Int, * ) * - * collection.upsertOne { + * collection.filter { + * User::name eq "foo" + * }.upsertOne { * User::age setOnInsert 18 * } * ``` @@ -166,6 +170,61 @@ class UpdateExpression( } } + // endregion + // region $inc + + /** + * Increments a field by the specified [amount]. + * + * [amount] may be negative, in which case the field is decremented. + * + * If the field doesn't exist (either the document doesn't have it, or the operation is an upsert and a new document is created), + * the field is created with an initial value of [amount]. + * + * Use of this operator with a field with a `null` value will generate an error. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * // It's the new year! + * collection.updateMany { + * User::age inc 1 + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/inc/) + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun KProperty1.inc(amount: V) { + accept(IncrementExpressionNode(listOf(this.path() to amount), codec)) + } + + @LowLevelApi + private class IncrementExpressionNode( + val mappings: List>, + codec: CodecRegistry, + ) : UpdateExpressionNode(codec) { + override fun simplify(): Expression? = + this.takeUnless { mappings.isEmpty() } + + override fun write(writer: BsonWriter) { + writer.writeDocument("\$inc") { + for ((field, value) in mappings) { + writer.writeName(field.toString()) + writer.writeObject(value, codec) + } + } + } + } + // endregion companion object { @@ -176,7 +235,10 @@ class UpdateExpression( }, OperatorCombinator(SetOnInsertExpressionNode::class) { sources, codec -> SetOnInsertExpressionNode(sources.flatMap { it.mappings }, codec) - } + }, + OperatorCombinator(IncrementExpressionNode::class) { sources, codec -> + IncrementExpressionNode(sources.flatMap { it.mappings }, codec) + }, ) } } diff --git a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt index c6c5c71..6af7956 100644 --- a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt +++ b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt @@ -14,12 +14,14 @@ class UpdateExpressionTest : FunSpec({ class Friend( val id: String, val name: String, + val money: Float, ) class User( val id: String, val name: String, val age: Int?, + val money: Double, val bestFriend: Friend, val friends: List, ) @@ -41,6 +43,7 @@ class UpdateExpressionTest : FunSpec({ val set = "\$set" val setOnInsert = "\$setOnInsert" + val inc = "\$inc" test("Empty update") { update { } shouldBeBson """{}""" @@ -125,4 +128,44 @@ class UpdateExpressionTest : FunSpec({ """.trimIndent() } } + + context("Operator $inc") { + test("Single field") { + update { + User::money inc 18.0 + } shouldBeBson """ + { + "$inc": { + "money": 18.0 + } + } + """.trimIndent() + } + + test("Nested field") { + update { + User::bestFriend / Friend::money inc -12.9f + } shouldBeBson """ + { + "$inc": { + "bestFriend.money": -12.899999618530273 + } + } + """.trimIndent() + } + + test("Multiple fields") { + update { + User::money inc 5.2 + User::bestFriend / Friend::money inc -5.2f + } shouldBeBson """ + { + "$inc": { + "money": 5.2, + "bestFriend.money": -5.199999809265137 + } + } + """.trimIndent() + } + } }) From 07a24e4e5a721a604a63bb1263ec34bff0d319c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 16:39:33 +0200 Subject: [PATCH 13/16] feat(dsl): $inc --- dsl/src/main/kotlin/expr/UpdateExpression.kt | 2 +- dsl/src/test/kotlin/expr/UpdateExpressionTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dsl/src/main/kotlin/expr/UpdateExpression.kt b/dsl/src/main/kotlin/expr/UpdateExpression.kt index 28844fb..0238448 100644 --- a/dsl/src/main/kotlin/expr/UpdateExpression.kt +++ b/dsl/src/main/kotlin/expr/UpdateExpression.kt @@ -203,7 +203,7 @@ class UpdateExpression( */ @OptIn(LowLevelApi::class) @KtMongoDsl - infix fun KProperty1.inc(amount: V) { + infix fun <@OnlyInputTypes V : Number> KProperty1.inc(amount: V) { accept(IncrementExpressionNode(listOf(this.path() to amount), codec)) } diff --git a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt index 6af7956..cce83ec 100644 --- a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt +++ b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt @@ -131,7 +131,7 @@ class UpdateExpressionTest : FunSpec({ context("Operator $inc") { test("Single field") { - update { + update { User::money inc 18.0 } shouldBeBson """ { @@ -143,7 +143,7 @@ class UpdateExpressionTest : FunSpec({ } test("Nested field") { - update { + update { User::bestFriend / Friend::money inc -12.9f } shouldBeBson """ { @@ -155,7 +155,7 @@ class UpdateExpressionTest : FunSpec({ } test("Multiple fields") { - update { + update { User::money inc 5.2 User::bestFriend / Friend::money inc -5.2f } shouldBeBson """ From 60d18fabcbf00cbaaca8c3e2385ff4c8cc932fde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 16:49:19 +0200 Subject: [PATCH 14/16] feat(dsl): $unset --- dsl/README.md | 5 +- dsl/src/main/kotlin/expr/UpdateExpression.kt | 54 +++++++++++++++++++ .../test/kotlin/expr/UpdateExpressionTest.kt | 41 ++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/dsl/README.md b/dsl/README.md index ebd6dfb..e221ebd 100644 --- a/dsl/README.md +++ b/dsl/README.md @@ -19,6 +19,7 @@ ### Update -- [`$inc`](fr.qsh.ktmongo.dsl.expr.UpdateExpression.inc) +- [`$inc`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.inc] - [`$set`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.set] -- [`$setOnInsert`](fr.qsh.ktmongo.dsl.expr.UpdateExpression.setOnInsert) +- [`$setOnInsert`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.setOnInsert] +- [`$unset`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.unset] diff --git a/dsl/src/main/kotlin/expr/UpdateExpression.kt b/dsl/src/main/kotlin/expr/UpdateExpression.kt index 0238448..5b464cd 100644 --- a/dsl/src/main/kotlin/expr/UpdateExpression.kt +++ b/dsl/src/main/kotlin/expr/UpdateExpression.kt @@ -225,6 +225,57 @@ class UpdateExpression( } } + // endregion + // region $unset + + /** + * Deletes a field. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * val alive: Boolean, + * ) + * + * collection.filter { + * User::name eq "Luke Skywalker" + * }.updateOne { + * User::age.unset() + * User::alive set false + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/unset/) + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun <@OnlyInputTypes V> KProperty1.unset() { + accept(UnsetExpressionNode(listOf(this.path()), codec)) + } + + @LowLevelApi + private class UnsetExpressionNode( + val fields: List, + codec: CodecRegistry, + ) : UpdateExpressionNode(codec) { + override fun simplify(): Expression? = + this.takeUnless { fields.isEmpty() } + + override fun write(writer: BsonWriter) { + writer.writeDocument("\$unset") { + for (field in fields) { + writer.writeName(field.toString()) + writer.writeBoolean(true) + } + } + } + } + // endregion companion object { @@ -239,6 +290,9 @@ class UpdateExpression( OperatorCombinator(IncrementExpressionNode::class) { sources, codec -> IncrementExpressionNode(sources.flatMap { it.mappings }, codec) }, + OperatorCombinator(UnsetExpressionNode::class) { sources, codec -> + UnsetExpressionNode(sources.flatMap { it.fields }, codec) + }, ) } } diff --git a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt index cce83ec..85363dd 100644 --- a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt +++ b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt @@ -44,6 +44,7 @@ class UpdateExpressionTest : FunSpec({ val set = "\$set" val setOnInsert = "\$setOnInsert" val inc = "\$inc" + val unset = "\$unset" test("Empty update") { update { } shouldBeBson """{}""" @@ -168,4 +169,44 @@ class UpdateExpressionTest : FunSpec({ """.trimIndent() } } + + context("Operator $unset") { + test("Single field") { + update { + User::money.unset() + } shouldBeBson """ + { + "$unset": { + "money": true + } + } + """.trimIndent() + } + + test("Nested field") { + update { + (User::bestFriend / Friend::money).unset() + } shouldBeBson """ + { + "$unset": { + "bestFriend.money": true + } + } + """.trimIndent() + } + + test("Multiple fields") { + update { + User::money.unset() + User::bestFriend.unset() + } shouldBeBson """ + { + "$unset": { + "money": true, + "bestFriend": true + } + } + """.trimIndent() + } + } }) From 116d10af5e9bdb4af315c72c3c4034e8aedb9249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 17:00:00 +0200 Subject: [PATCH 15/16] feat(dsl): $rename --- dsl/README.md | 1 + dsl/src/main/kotlin/expr/UpdateExpression.kt | 51 +++++++++++++++++++ .../test/kotlin/expr/UpdateExpressionTest.kt | 30 +++++++++++ 3 files changed, 82 insertions(+) diff --git a/dsl/README.md b/dsl/README.md index e221ebd..cba87df 100644 --- a/dsl/README.md +++ b/dsl/README.md @@ -20,6 +20,7 @@ ### Update - [`$inc`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.inc] +- [`$rename`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.renameTo] - [`$set`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.set] - [`$setOnInsert`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.setOnInsert] - [`$unset`][fr.qsh.ktmongo.dsl.expr.UpdateExpression.unset] diff --git a/dsl/src/main/kotlin/expr/UpdateExpression.kt b/dsl/src/main/kotlin/expr/UpdateExpression.kt index 5b464cd..3808268 100644 --- a/dsl/src/main/kotlin/expr/UpdateExpression.kt +++ b/dsl/src/main/kotlin/expr/UpdateExpression.kt @@ -276,6 +276,54 @@ class UpdateExpression( } } + // endregion + // region $rename + + /** + * Renames a field. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * val ageOld: Int, + * ) + * + * collection.updateMany { + * User::ageOld renameTo User::age + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/rename/) + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + infix fun <@OnlyInputTypes V> KProperty1.renameTo(newName: KProperty1) { + accept(RenameExpressionNode(listOf(this.path() to newName.path()), codec)) + } + + @LowLevelApi + private class RenameExpressionNode( + val fields: List>, + codec: CodecRegistry, + ) : UpdateExpressionNode(codec) { + override fun simplify(): Expression? = + this.takeUnless { fields.isEmpty() } + + override fun write(writer: BsonWriter) { + writer.writeDocument("\$rename") { + for ((before, after) in fields) { + writer.writeName(before.toString()) + writer.writeString(after.toString()) + } + } + } + } + // endregion companion object { @@ -293,6 +341,9 @@ class UpdateExpression( OperatorCombinator(UnsetExpressionNode::class) { sources, codec -> UnsetExpressionNode(sources.flatMap { it.fields }, codec) }, + OperatorCombinator(RenameExpressionNode::class) { sources, codec -> + RenameExpressionNode(sources.flatMap { it.fields }, codec) + }, ) } } diff --git a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt index 85363dd..a8218ce 100644 --- a/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt +++ b/dsl/src/test/kotlin/expr/UpdateExpressionTest.kt @@ -3,6 +3,7 @@ package fr.qsh.ktmongo.dsl.expr import fr.qsh.ktmongo.dsl.LowLevelApi import fr.qsh.ktmongo.dsl.expr.common.withLoggedContext import fr.qsh.ktmongo.dsl.path.div +import fr.qsh.ktmongo.dsl.path.get import fr.qsh.ktmongo.dsl.writeDocument import io.kotest.core.spec.style.FunSpec import org.bson.BsonDocument @@ -45,6 +46,7 @@ class UpdateExpressionTest : FunSpec({ val setOnInsert = "\$setOnInsert" val inc = "\$inc" val unset = "\$unset" + val rename = "\$rename" test("Empty update") { update { } shouldBeBson """{}""" @@ -209,4 +211,32 @@ class UpdateExpressionTest : FunSpec({ """.trimIndent() } } + + context("Operator $rename") { + test("Single and nested field") { + update { + User::bestFriend / Friend::name renameTo User::name + } shouldBeBson """ + { + "$rename": { + "bestFriend.name": "name" + } + } + """.trimIndent() + } + + test("Multiple fields") { + update { + User::bestFriend / Friend::name renameTo User::name + User::friends[0] / Friend::name renameTo User::friends[1] / Friend::name + } shouldBeBson """ + { + "$rename": { + "bestFriend.name": "name", + "friends.$0.name": "friends.$1.name" + } + } + """.trimIndent() + } + } }) From 9a7ff2ea7942091d3b0bbe9b25e08d04c04ea919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 18 Jul 2024 17:16:53 +0200 Subject: [PATCH 16/16] refactor(dsl): PropertyPath is an implementation detail --- dsl/src/main/kotlin/path/PropertyPath.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dsl/src/main/kotlin/path/PropertyPath.kt b/dsl/src/main/kotlin/path/PropertyPath.kt index ece1f53..22642b0 100644 --- a/dsl/src/main/kotlin/path/PropertyPath.kt +++ b/dsl/src/main/kotlin/path/PropertyPath.kt @@ -18,7 +18,7 @@ import kotlin.reflect.* */ @LowLevelApi @Suppress("NO_REFLECTION_IN_CLASS_PATH") // None of the functions are called by our code. The caller is responsible for fixing this. -class PropertyPath( +private class PropertyPath( /** * The path of this property. *