diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4e4a4efef2b4bbc71f0eece7a52e27dfc0d3d06e..da4bcfeef39a5ba31c4579a5ea571b8d2f577632 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -63,8 +63,17 @@ workflow: # endregion # region Check +.with-mongo: + parallel: + matrix: + - mongodb: [ mongo:6.0.19, mongo:7.0.15, mongo:8.0.3 ] + + services: + - name: $mongodb + alias: mongo + check[jvm]: - extends: [ .kotlin-jvm ] + extends: [ .kotlin-jvm, .with-mongo ] stage: test script: diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000000000000000000000000000000000000..a5136fc14f795ad843b61bea0d64c23286d994e9 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + mongo + true + com.dbschema.MongoJdbcDriver + mongodb://localhost:27017 + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Check.xml b/.idea/runConfigurations/Check.xml index 3b214a8cc4ea6e4b9eb2fe0653173139293c3985..bab2591d65799b4da361aaf4d93be508610996fc 100644 --- a/.idea/runConfigurations/Check.xml +++ b/.idea/runConfigurations/Check.xml @@ -19,6 +19,8 @@ true false true - + + - \ No newline at end of file + diff --git a/.idea/runConfigurations/MongoDB.xml b/.idea/runConfigurations/MongoDB.xml new file mode 100644 index 0000000000000000000000000000000000000000..bb7f2e6f8c662e6ae52544237ea94214270a939c --- /dev/null +++ b/.idea/runConfigurations/MongoDB.xml @@ -0,0 +1,12 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/startup.xml b/.idea/startup.xml new file mode 100644 index 0000000000000000000000000000000000000000..5994d1a8e4cd369c0607e68b99264fe126dbe5f2 --- /dev/null +++ b/.idea/startup.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/bson/src/commonMain/kotlin/Bson.kt b/bson/src/commonMain/kotlin/Bson.kt index 11630067c289d4ebf18737ea0d3b4627e0623960..9c7a546386ba6611d09f9f4e3e99c8104f6757b3 100644 --- a/bson/src/commonMain/kotlin/Bson.kt +++ b/bson/src/commonMain/kotlin/Bson.kt @@ -19,8 +19,6 @@ package opensavvy.ktmongo.bson /** * A BSON document. * - * A BSON value *always* has a root document. - * * To create instances of this class, see [buildBsonDocument]. */ expect class Bson { @@ -30,3 +28,16 @@ expect class Bson { */ override fun toString(): String } + +/** + * A BSON array. + * + * To create instances of this class, see [buildBsonArray]. + */ +expect class BsonArray { + + /** + * JSON representation of this [BsonArray] object, as a [String]. + */ + override fun toString(): String +} diff --git a/bson/src/commonMain/kotlin/BsonWriter.kt b/bson/src/commonMain/kotlin/BsonWriter.kt index 3a0360b59ff5ff543cbdfb4a7e048e4d73cc7ea3..5479a79cad3aabc561d4ff7eb233520dc22b7367 100644 --- a/bson/src/commonMain/kotlin/BsonWriter.kt +++ b/bson/src/commonMain/kotlin/BsonWriter.kt @@ -23,6 +23,13 @@ import opensavvy.ktmongo.dsl.LowLevelApi @DslMarker annotation class BsonWriterDsl +/** + * Parent interface for type parameters that can accept either [BsonValueWriter] or [BsonFieldWriter]. + */ +@LowLevelApi +@BsonWriterDsl +sealed interface AnyBsonWriter + /** * Generator of BSON values. * @@ -31,11 +38,11 @@ annotation class BsonWriterDsl * * To write fields in a BSON document, see [BsonFieldWriter]. * - * Instances of this interface are commonly obtained by calling the [buildBsonDocument] function. + * Instances of this interface are commonly obtained by calling the [buildBsonArray] function. */ @LowLevelApi @BsonWriterDsl -interface BsonValueWriter { +interface BsonValueWriter : AnyBsonWriter { @LowLevelApi fun writeBoolean(value: Boolean) @LowLevelApi fun writeDouble(value: Double) @LowLevelApi fun writeInt32(value: Int) @@ -87,7 +94,7 @@ interface BsonValueWriter { */ @LowLevelApi @BsonWriterDsl -interface BsonFieldWriter { +interface BsonFieldWriter : AnyBsonWriter { @LowLevelApi fun write(name: String, block: BsonValueWriter.() -> Unit) @LowLevelApi fun writeBoolean(name: String, value: Boolean) @@ -168,3 +175,32 @@ interface BsonFieldWriter { */ @LowLevelApi expect fun buildBsonDocument(block: BsonFieldWriter.() -> Unit): Bson + +/** + * Instantiates a new [Bson] array. + * + * ### Example + * + * To create the following BSON array: + * ```bson + * [ + * 12, + * null, + * { + * "name": "Barry" + * } + * ] + * ``` + * use the code: + * ```kotlin + * buildBsonArray { + * writeInt32(12) + * writeNull() + * writeDocument { + * writeString("name", "Barry") + * } + * } + * ``` + */ +@LowLevelApi +expect fun buildBsonArray(block: BsonValueWriter.() -> Unit): BsonArray diff --git a/bson/src/jsMain/kotlin/Bson.js.kt b/bson/src/jsMain/kotlin/Bson.js.kt index 77862ee42bf5ffc22bb229462163d9258e91cf31..268d97fe89ac4f1bec4da19761d1a81a9bbe15ec 100644 --- a/bson/src/jsMain/kotlin/Bson.js.kt +++ b/bson/src/jsMain/kotlin/Bson.js.kt @@ -19,3 +19,7 @@ package opensavvy.ktmongo.bson actual class Bson { // TODO: implement the BSON type on top of the NPM 'bson' library } + +actual class BsonArray { + // TODO: implement the BSON array type on top of the NPM 'bson' library +} diff --git a/bson/src/jsMain/kotlin/BsonWriter.js.kt b/bson/src/jsMain/kotlin/BsonWriter.js.kt index dec54634a9ba76623346a01b466873b761b7a961..68abe5483942f84cc6a26fa35a7ed3a42173209d 100644 --- a/bson/src/jsMain/kotlin/BsonWriter.js.kt +++ b/bson/src/jsMain/kotlin/BsonWriter.js.kt @@ -20,3 +20,8 @@ actual fun buildBsonDocument(block: BsonFieldWriter.() -> Unit): Bson { console.error("buildBsonDocument is not implemented on this platform") return Bson() } + +actual fun buildBsonArray(block: BsonValueWriter.() -> Unit): BsonArray { + console.error("buildBsonArray is not implemented on this platform") + return BsonArray() +} diff --git a/bson/src/jvmMain/kotlin/Bson.jvm.kt b/bson/src/jvmMain/kotlin/Bson.jvm.kt index ea2d8fa2bf9ddc94cd280910566baf895768b132..6227ee4b3d41eb6bb6e1eed368da417930855987 100644 --- a/bson/src/jvmMain/kotlin/Bson.jvm.kt +++ b/bson/src/jvmMain/kotlin/Bson.jvm.kt @@ -16,6 +16,9 @@ package opensavvy.ktmongo.bson +import org.bson.BsonArray import org.bson.BsonDocument actual typealias Bson = BsonDocument + +actual typealias BsonArray = BsonArray diff --git a/bson/src/jvmMain/kotlin/BsonWriter.jvm.kt b/bson/src/jvmMain/kotlin/BsonWriter.jvm.kt index d25313be21dfab87579896ca066cf32944451b1e..5261562fd0e7756414d101ccec8a6e9424b5ff7d 100644 --- a/bson/src/jvmMain/kotlin/BsonWriter.jvm.kt +++ b/bson/src/jvmMain/kotlin/BsonWriter.jvm.kt @@ -235,3 +235,124 @@ actual fun buildBsonDocument(block: BsonFieldWriter.() -> Unit): Bson { return document } + +@LowLevelApi +private class JavaRootArrayWriter( + private val array: BsonArray, +) : BsonValueWriter { + @LowLevelApi + override fun writeBoolean(value: Boolean) { + array.add(BsonBoolean(value)) + } + + @LowLevelApi + override fun writeDouble(value: Double) { + array.add(BsonDouble(value)) + } + + @LowLevelApi + override fun writeInt32(value: Int) { + array.add(BsonInt32(value)) + } + + @LowLevelApi + override fun writeInt64(value: Long) { + array.add(BsonInt64(value)) + } + + @LowLevelApi + override fun writeDecimal128(value: Decimal128) { + array.add(BsonDecimal128(value)) + } + + @LowLevelApi + override fun writeDateTime(value: Long) { + array.add(BsonDateTime(value)) + } + + @LowLevelApi + override fun writeNull() { + array.add(BsonNull()) + } + + @LowLevelApi + override fun writeObjectId(value: ObjectId) { + array.add(BsonObjectId(value)) + } + + @LowLevelApi + override fun writeRegularExpression(pattern: String, options: String) { + array.add(BsonRegularExpression(pattern, options)) + } + + @LowLevelApi + override fun writeString(value: String) { + array.add(BsonString(value)) + } + + @LowLevelApi + override fun writeTimestamp(value: Long) { + array.add(BsonTimestamp(value)) + } + + @LowLevelApi + override fun writeSymbol(value: String) { + array.add(BsonSymbol(value)) + } + + @LowLevelApi + override fun writeUndefined() { + array.add(BsonUndefined()) + } + + @LowLevelApi + override fun writeDBPointer(namespace: String, id: ObjectId) { + array.add(BsonDbPointer(namespace, id)) + } + + @LowLevelApi + override fun writeJavaScriptWithScope(code: String) { + array.add(BsonJavaScript(code)) + } + + @LowLevelApi + override fun writeBinaryData(type: Byte, data: ByteArray) { + array.add(BsonBinary(type, data)) + } + + @LowLevelApi + override fun writeJavaScript(code: String) { + array.add(BsonJavaScript(code)) + } + + @LowLevelApi + override fun writeDocument(block: BsonFieldWriter.() -> Unit) { + array.add(buildBsonDocument(block)) + } + + @LowLevelApi + override fun writeArray(block: BsonValueWriter.() -> Unit) { + array.add(buildBsonArray(block)) + } + + @LowLevelApi + override fun writeObjectSafe(obj: T, context: BsonContext) { + val document = BsonDocument() + + BsonDocumentWriter(document).use { writer -> + JavaBsonWriter(writer).writeObjectSafe(obj, context) + } + + array.add(document) + } + +} + +@LowLevelApi +actual fun buildBsonArray(block: BsonValueWriter.() -> Unit): BsonArray { + val array = BsonArray() + + JavaRootArrayWriter(array).block() + + return array +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..abbc08a8f338d1135ada25b4dfde1f16be3c62dc --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3.6" + +services: + mongo: + image: "mongo:8.0.1" + ports: + - "27017:27017" diff --git a/driver-coroutines/src/commonMain/kotlin/FilteredCollection.kt b/driver-coroutines/src/commonMain/kotlin/FilteredCollection.kt new file mode 100644 index 0000000000000000000000000000000000000000..68b1f0381ac033dd1e6977b949b3392b615a6014 --- /dev/null +++ b/driver-coroutines/src/commonMain/kotlin/FilteredCollection.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.coroutines + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.expr.FilterExpression +import opensavvy.ktmongo.dsl.expr.UpdateExpression + +private class FilteredCollection( + private val upstream: MongoCollection, + private val globalFilter: FilterExpression.() -> Unit, +) : MongoCollection { + + override fun find(): MongoIterable = + upstream.find(globalFilter) + + override fun find(predicate: FilterExpression.() -> Unit): MongoIterable = + upstream.find { + globalFilter() + predicate() + } + + @LowLevelApi + override val context: BsonContext + get() = upstream.context + + override suspend fun count(): Long = + upstream.count(globalFilter) + + override suspend fun count(predicate: FilterExpression.() -> Unit): Long = + upstream.count { + globalFilter() + predicate() + } + + override suspend fun countEstimated(): Long = + count() + + override suspend fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit) = + upstream.updateMany( + filter = { + globalFilter() + filter() + }, + update = update, + ) + + override suspend fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit) = + upstream.updateOne( + filter = { + globalFilter() + filter() + }, + update = update, + ) + + override suspend fun upsertOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit) = + upstream.upsertOne( + filter = { + globalFilter() + filter() + }, + update = update, + ) + + override suspend fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? = + upstream.findOneAndUpdate( + filter = { + globalFilter() + filter() + }, + update = update, + ) +} + +/** + * Returns a filtered collection that only contains the elements that match [filter]. + * + * 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 [filter]. + * + * 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(filter: FilterExpression.() -> Unit): MongoCollection = + FilteredCollection(this, filter) diff --git a/driver-coroutines/src/commonMain/kotlin/operations/UpdateOperations.kt b/driver-coroutines/src/commonMain/kotlin/operations/UpdateOperations.kt index 7065aadb29d8e6ca9757ff9603cb47680b03a039..e88840aa95c40d271f756e86309836272d9262af 100644 --- a/driver-coroutines/src/commonMain/kotlin/operations/UpdateOperations.kt +++ b/driver-coroutines/src/commonMain/kotlin/operations/UpdateOperations.kt @@ -16,6 +16,8 @@ package opensavvy.ktmongo.coroutines.operations +import opensavvy.ktmongo.coroutines.MongoCollection +import opensavvy.ktmongo.coroutines.filter import opensavvy.ktmongo.dsl.expr.FilterExpression import opensavvy.ktmongo.dsl.expr.UpdateExpression @@ -45,6 +47,19 @@ interface UpdateOperations : BaseOperations { * ) * ``` * + * ### 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/) @@ -81,6 +96,19 @@ interface UpdateOperations : BaseOperations { * ) * ``` * + * ### 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/) @@ -123,6 +151,19 @@ interface UpdateOperations : BaseOperations { * 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](https://www.mongodb.com/docs/manual/reference/command/update/) @@ -156,6 +197,19 @@ interface UpdateOperations : BaseOperations { * ) * ``` * + * ### Using filtered collections + * + * The following code is equivalent: + * ```kotlin + * collection.filter { + * User::name eq "Patrick" + * }.findOneAndUpdate { + * User::age set 15 + * } + * ``` + * + * To learn more, see [filter][MongoCollection.filter]. + * * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/findAndModify/) diff --git a/driver-coroutines/src/jvmMain/kotlin/JvmMongoCollection.kt b/driver-coroutines/src/jvmMain/kotlin/JvmMongoCollection.kt index 54fb82e720dcf129f33f13a1860aa66a77334320..f64b37b4aefb4c6302123f607d93af923ab4a1d1 100644 --- a/driver-coroutines/src/jvmMain/kotlin/JvmMongoCollection.kt +++ b/driver-coroutines/src/jvmMain/kotlin/JvmMongoCollection.kt @@ -22,7 +22,7 @@ import opensavvy.ktmongo.bson.buildBsonDocument import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.expr.FilterExpression import opensavvy.ktmongo.dsl.expr.UpdateExpression -import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundDocumentExpression import org.bson.BsonDocument /** @@ -135,7 +135,7 @@ class JvmMongoCollection internal constructor( } @OptIn(LowLevelApi::class) -private fun AbstractCompoundExpression.toBsonDocument(): BsonDocument = +private fun AbstractCompoundDocumentExpression.toBsonDocument(): BsonDocument = buildBsonDocument { writeTo(this) } diff --git a/driver-sync/src/commonMain/kotlin/FilteredCollection.kt b/driver-sync/src/commonMain/kotlin/FilteredCollection.kt new file mode 100644 index 0000000000000000000000000000000000000000..a1226872f7d22cf54fe46c5ca028a6f41dda907b --- /dev/null +++ b/driver-sync/src/commonMain/kotlin/FilteredCollection.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.sync + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.expr.BulkUpdateExpression +import opensavvy.ktmongo.dsl.expr.FilterExpression +import opensavvy.ktmongo.dsl.expr.UpdateExpression + +private class FilteredCollection( + private val upstream: MongoCollection, + private val globalFilter: FilterExpression.() -> Unit, +) : MongoCollection { + + override fun find(): MongoIterable = + upstream.find(globalFilter) + + override fun find(predicate: FilterExpression.() -> Unit): MongoIterable = + upstream.find { + globalFilter() + predicate() + } + + @LowLevelApi + override val context: BsonContext + get() = upstream.context + + override fun count(): Long = + upstream.count(globalFilter) + + override fun count(predicate: FilterExpression.() -> Unit): Long = + upstream.count { + globalFilter() + predicate() + } + + override fun countEstimated(): Long = + count() + + override fun updateMany(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit) = + upstream.updateMany( + filter = { + globalFilter() + filter() + }, + update = update, + ) + + override fun updateOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit) = + upstream.updateOne( + filter = { + globalFilter() + filter() + }, + update = update, + ) + + override fun upsertOne(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit) = + upstream.upsertOne( + filter = { + globalFilter() + filter() + }, + update = update, + ) + + override fun findOneAndUpdate(filter: FilterExpression.() -> Unit, update: UpdateExpression.() -> Unit): Document? = + upstream.findOneAndUpdate( + filter = { + globalFilter() + filter() + }, + update = update, + ) + + override fun bulkWrite(filter: FilterExpression.() -> Unit, update: BulkUpdateExpression.() -> Unit) = + upstream.bulkWrite( + filter = { + globalFilter() + filter() + }, + update = update, + ) + +} + +/** + * Returns a filtered collection that only contains the elements that match [filter]. + * + * 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 [filter]. + * + * 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(filter: FilterExpression.() -> Unit): MongoCollection = + FilteredCollection(this, filter) diff --git a/driver-sync/src/commonMain/kotlin/operations/UpdateOperations.kt b/driver-sync/src/commonMain/kotlin/operations/UpdateOperations.kt index eac38a40c6ba2acc11326525170825590f52d70f..1aa7b2a394c7ae5afdfd24ea7a8ef0bfdc196aa6 100644 --- a/driver-sync/src/commonMain/kotlin/operations/UpdateOperations.kt +++ b/driver-sync/src/commonMain/kotlin/operations/UpdateOperations.kt @@ -16,8 +16,11 @@ package opensavvy.ktmongo.sync.operations +import opensavvy.ktmongo.dsl.expr.BulkUpdateExpression import opensavvy.ktmongo.dsl.expr.FilterExpression import opensavvy.ktmongo.dsl.expr.UpdateExpression +import opensavvy.ktmongo.sync.MongoCollection +import opensavvy.ktmongo.sync.filter /** * Interface grouping MongoDB operations allowing to update existing information. @@ -45,6 +48,19 @@ interface UpdateOperations : BaseOperations { * ) * ``` * + * ### 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/) @@ -81,6 +97,19 @@ interface UpdateOperations : BaseOperations { * ) * ``` * + * ### 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/) @@ -123,6 +152,19 @@ interface UpdateOperations : BaseOperations { * 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](https://www.mongodb.com/docs/manual/reference/command/update/) @@ -156,6 +198,19 @@ interface UpdateOperations : BaseOperations { * ) * ``` * + * ### Using filtered collections + * + * The following code is equivalent: + * ```kotlin + * collection.filter { + * User::name eq "Patrick" + * }.findOneAndUpdate { + * User::age set 15 + * } + * ``` + * + * To learn more, see [filter][MongoCollection.filter]. + * * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/command/findAndModify/) @@ -170,4 +225,36 @@ interface UpdateOperations : BaseOperations { update: UpdateExpression.() -> Unit, ): Document? + /** + * Performs multiple write operations in a single request. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.bulkWrite { + * upsertOne({ User::name eq "Alex" }) { + * User::age set 18 + * } + * + * updateMany { + * User::age setOnInsert 20 + * User::age inc 1 + * } + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.bulkWrite) + */ + fun bulkWrite( + filter: FilterExpression.() -> Unit = {}, + update: BulkUpdateExpression.() -> Unit, + ) + } diff --git a/driver-sync/src/commonTest/kotlin/BasicReadWriteTest.kt b/driver-sync/src/commonTest/kotlin/BasicReadWriteTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..bb5db58f02a3bc2d61cc019f726a2b2e8c9025ae --- /dev/null +++ b/driver-sync/src/commonTest/kotlin/BasicReadWriteTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.sync + +import opensavvy.prepared.runner.kotest.PreparedSpec +import opensavvy.prepared.suite.SuiteDsl + +fun SuiteDsl.basicReadWriteTest() = suite("Basic read/write test") { + class User( + val name: String, + val age: Int, + ) + + val users by testCollection("basic-users") + + test("Foo") { + users().upsertOne( + filter = { + User::name eq "Foo" + }, + update = { + User::name set "Bad" + User::age setOnInsert 0 + } + ) + } +} + +class BasicReadWriteTest : PreparedSpec({ + basicReadWriteTest() +}) diff --git a/driver-sync/src/commonTest/kotlin/TestDatabase.kt b/driver-sync/src/commonTest/kotlin/TestDatabase.kt new file mode 100644 index 0000000000000000000000000000000000000000..d868fcc00a84346ac9960d8ac36d99e1e023fb4f --- /dev/null +++ b/driver-sync/src/commonTest/kotlin/TestDatabase.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.sync + +import opensavvy.prepared.suite.PreparedProvider + +expect inline fun testCollection(name: String): PreparedProvider> diff --git a/driver-sync/src/jsTest/kotlin/TestDatabase.js.kt b/driver-sync/src/jsTest/kotlin/TestDatabase.js.kt new file mode 100644 index 0000000000000000000000000000000000000000..97e2b8ab759bd54b5b41e92e001414c0f93b519e --- /dev/null +++ b/driver-sync/src/jsTest/kotlin/TestDatabase.js.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.sync + +import opensavvy.prepared.suite.PreparedProvider + +actual inline fun testCollection(name: String): PreparedProvider> { + TODO("Not yet implemented") +} diff --git a/driver-sync/src/jvmMain/kotlin/JvmMongoCollection.kt b/driver-sync/src/jvmMain/kotlin/JvmMongoCollection.kt index f6dd4ceb18c4518a993c6362e022512cc4275964..e5d04ec7c4961bef98ec2c5f26e65b02da4e9c3b 100644 --- a/driver-sync/src/jvmMain/kotlin/JvmMongoCollection.kt +++ b/driver-sync/src/jvmMain/kotlin/JvmMongoCollection.kt @@ -18,11 +18,15 @@ package opensavvy.ktmongo.sync import com.mongodb.client.model.UpdateOptions import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.buildBsonArray import opensavvy.ktmongo.bson.buildBsonDocument import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.expr.BulkUpdateExpression import opensavvy.ktmongo.dsl.expr.FilterExpression import opensavvy.ktmongo.dsl.expr.UpdateExpression -import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundArrayExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundDocumentExpression +import org.bson.BsonArray import org.bson.BsonDocument /** @@ -130,16 +134,34 @@ class JvmMongoCollection internal constructor( return inner.findOneAndUpdate(filter, update) } + override fun bulkWrite(filter: FilterExpression.() -> Unit, update: BulkUpdateExpression.() -> Unit) { + val bulk = BulkUpdateExpression(context, filter) + .apply(update) + .toBsonArray() + + inner.bulkWrite( + listOf() // TODO + ) + + TODO("Not yet implemented") + } + // endregion } @OptIn(LowLevelApi::class) -private fun AbstractCompoundExpression.toBsonDocument(): BsonDocument = +private fun AbstractCompoundDocumentExpression.toBsonDocument(): BsonDocument = buildBsonDocument { writeTo(this) } +@OptIn(LowLevelApi::class) +private fun AbstractCompoundArrayExpression.toBsonArray(): BsonArray = + buildBsonArray { + writeTo(this) + } + /** * Converts a [MongoDB collection][com.mongodb.kotlin.client.MongoCollection] into a [KtMongo collection][JvmMongoCollection]. */ diff --git a/driver-sync/src/jvmTest/kotlin/TestDatabase.jvm.kt b/driver-sync/src/jvmTest/kotlin/TestDatabase.jvm.kt new file mode 100644 index 0000000000000000000000000000000000000000..e696cabe197c691e2067ed541072e15b43aa1667 --- /dev/null +++ b/driver-sync/src/jvmTest/kotlin/TestDatabase.jvm.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.sync + +import com.mongodb.MongoTimeoutException +import com.mongodb.kotlin.client.MongoClient +import kotlinx.coroutines.CoroutineName +import opensavvy.prepared.suite.PreparedProvider +import opensavvy.prepared.suite.prepared +import opensavvy.prepared.suite.shared + +@PublishedApi +internal val database by shared(CoroutineName("mongodb-establish-connection")) { + val client = try { + MongoClient.create("mongodb://localhost:27017") + .also { it.getDatabase("ktmongo-sync-tests").getCollection("test").countDocuments() } + } catch (e: MongoTimeoutException) { + System.err.println("Cannot connect to localhost:27017. Did you start the docker-compose services? [This is normal in CI]\n${e.stackTraceToString()}") + MongoClient.create("mongodb://mongo:27017") + } + client.getDatabase("ktmongo-sync-tests") +} + +actual inline fun testCollection(name: String): PreparedProvider> = prepared(CoroutineName("mongodb-create-collection-$name")) { + val collection = database().getCollection(name) + collection.asKtMongo() +} diff --git a/dsl/src/commonMain/kotlin/expr/BulkUpdateExpression.kt b/dsl/src/commonMain/kotlin/expr/BulkUpdateExpression.kt new file mode 100644 index 0000000000000000000000000000000000000000..78df3fb3754a6592b64e5df6ba4200a370a5d855 --- /dev/null +++ b/dsl/src/commonMain/kotlin/expr/BulkUpdateExpression.kt @@ -0,0 +1,251 @@ +/* + * Copyright (c) 2024, 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.dsl.expr + +import opensavvy.ktmongo.bson.BsonContext +import opensavvy.ktmongo.bson.BsonValueWriter +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.KtMongoDsl +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.expr.common.AbstractArrayExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundArrayExpression +import opensavvy.ktmongo.dsl.expr.common.DocumentExpression +import opensavvy.ktmongo.dsl.path.FieldDsl + +/** + * DSL for MongoDB's `bulkWrite` operation. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * ) + * + * collection.bulkWrite { + * updateOne( + * filter = { User::name eq "Bob" }, + * update = { User::age set 19 } + * ) + * + * upsertOne( + * filter = { User::name eq "Alex" }, + * update = { User::age set 20 } + * ) + * } + * ``` + * + * ### Operations + * + * - [updateOne] and [upsertOne] + * - [updateMany] + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.bulkWrite) + */ +@KtMongoDsl +class BulkUpdateExpression( + context: BsonContext, + private val globalFilter: FilterExpression.() -> Unit, +) : AbstractCompoundArrayExpression(context), FieldDsl { + + // region Low-level operations + + @LowLevelApi + private sealed class BulkUpdateExpressionNode(context: BsonContext) : AbstractArrayExpression(context) + + // endregion + // region update + + /** + * Updates a single document in the collection that matches the [filter]. + * + * If multiple documents match, only the first matching document will be updated. + * + * ### Example + * + * ```kotlin + * class User( + * val id: Long, + * val name: String, + * val score: Int, + * ) + * + * users.bulkWrite { + * updateOne({ User::id eq 123456L }) { + * User::name set "Bobby" + * } + * + * updateOne({ User::id eq 123123L }) { + * User::score.inc() + * } + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.bulkWrite/#syntax) + * + * @see updateMany Update multiple documents. + * @see upsertOne Create the document if it doesn't already exist. + */ + @KtMongoDsl + @OptIn(LowLevelApi::class, DangerousMongoApi::class) + fun updateOne( + filter: FilterExpression.() -> Unit = {}, + update: UpdateExpression.() -> Unit, + ) { + accept(UpdateExpressionNode( + operation = "updateOne", + filter = FilterExpression(context).apply { + this@BulkUpdateExpression.globalFilter(this) + filter() + }, + update = UpdateExpression(context).apply(update), + upsert = false, + context = context + )) + } + + /** + * Updates a single document in the collection that matches the [filter], creating one if none are found. + * + * If multiple documents match, only the first matching document will be updated. + * + * If no document match, a new document is created, using the information provided both in the filter + * and in the update. + * + * ### Example + * + * ```kotlin + * class User( + * val id: Long, + * val name: String, + * val score: Int, + * ) + * + * users.bulkWrite { + * upsertOne({ User::name eq "Bobby" }) { + * User::score inc 1 + * } + * } + * ``` + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.bulkWrite/#syntax) + * - [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 Do nothing if the document doesn't exist. + */ + @KtMongoDsl + @OptIn(LowLevelApi::class, DangerousMongoApi::class) + fun upsertOne( + filter: FilterExpression.() -> Unit = {}, + update: UpdateExpression.() -> Unit, + ) { + accept(UpdateExpressionNode( + operation = "updateOne", + filter = FilterExpression(context).apply { + this@BulkUpdateExpression.globalFilter(this) + filter() + }, + update = UpdateExpression(context).apply(update), + upsert = true, + context = context + )) + } + + /** + * Updates all documents in the collection that match the [filter]. + * + * ### Example + * + * ```kotlin + * class User( + * val id: Long, + * val name: String, + * val score: Int, + * ) + * + * users.bulkWrite { + * updateMany { + * User::score inc 1 + * } + * + * updateMany({ User::id eq 123456L }) { + * User::score inc 3 + * } + * } + * ``` + * + * At the end of this operation, the user with identifier `123456`, if it exists, will have a score increased by 4 (1 + 3). + * All other users will have a score increased by 1. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/method/db.collection.bulkWrite/#syntax) + * + * @see updateOne Update a single document. + */ + @KtMongoDsl + @OptIn(LowLevelApi::class, DangerousMongoApi::class) + fun updateMany( + filter: FilterExpression.() -> Unit = {}, + update: UpdateExpression.() -> Unit, + ) { + accept(UpdateExpressionNode( + operation = "updateMany", + filter = FilterExpression(context).apply { + this@BulkUpdateExpression.globalFilter(this) + filter() + }, + update = UpdateExpression(context).apply(update), + upsert = false, + context = context + )) + } + + @LowLevelApi + private class UpdateExpressionNode( + private val operation: String, + private val filter: DocumentExpression, + private val update: DocumentExpression, + private val upsert: Boolean, + context: BsonContext, + ) : BulkUpdateExpressionNode(context) { + @LowLevelApi + override fun write(writer: BsonValueWriter) = with(writer) { + writeDocument { + writeDocument(operation) { + writeDocument("filter") { + filter.writeTo(this) + } + writeDocument("update") { + update.writeTo(this) + } + writeBoolean("upsert", upsert) + } + } + } + } + + // endregion + +} diff --git a/dsl/src/commonMain/kotlin/expr/FilterExpression.kt b/dsl/src/commonMain/kotlin/expr/FilterExpression.kt index 912fa4a7f16e21fe4e35a2cb00b9a6c56f3f6826..7a12a3e717447c52130ac24c8f8c39287eb11072 100644 --- a/dsl/src/commonMain/kotlin/expr/FilterExpression.kt +++ b/dsl/src/commonMain/kotlin/expr/FilterExpression.kt @@ -23,9 +23,10 @@ import opensavvy.ktmongo.bson.types.BsonType import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundDocumentExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractDocumentExpression import opensavvy.ktmongo.dsl.expr.common.AbstractExpression -import opensavvy.ktmongo.dsl.expr.common.Expression +import opensavvy.ktmongo.dsl.expr.common.DocumentExpression import opensavvy.ktmongo.dsl.path.Field import opensavvy.ktmongo.dsl.path.FieldDsl import opensavvy.ktmongo.dsl.path.FieldImpl @@ -127,14 +128,14 @@ import kotlin.reflect.KProperty1 @KtMongoDsl class FilterExpression( context: BsonContext, -) : AbstractCompoundExpression(context), +) : AbstractCompoundDocumentExpression(context), FieldDsl { // region Low-level operations @OptIn(DangerousMongoApi::class) @LowLevelApi - override fun simplify(children: List): AbstractExpression? = + override fun simplify(children: List): AbstractExpression? = when (children.size) { 0 -> null 1 -> this @@ -142,7 +143,7 @@ class FilterExpression( } @LowLevelApi - private sealed class FilterExpressionNode(context: BsonContext) : AbstractExpression(context) + private sealed class FilterExpressionNode(context: BsonContext) : AbstractDocumentExpression(context) // endregion // region $and, $or @@ -182,11 +183,11 @@ class FilterExpression( @DangerousMongoApi @LowLevelApi private class AndFilterExpressionNode( - val declaredChildren: List, + val declaredChildren: List, context: BsonContext, ) : FilterExpressionNode(context) { - override fun simplify(): AbstractExpression? { + override fun simplify(): AbstractExpression? { if (declaredChildren.isEmpty()) return null @@ -194,7 +195,7 @@ class FilterExpression( return FilterExpression(context).apply { accept(declaredChildren.single()) } // If there are nested $and operators, we combine them into the current one - val nestedChildren = ArrayList() + val nestedChildren = ArrayList() for (child in declaredChildren) { if (child is AndFilterExpressionNode<*>) { @@ -256,11 +257,11 @@ class FilterExpression( @DangerousMongoApi @LowLevelApi private class OrFilterExpressionNode( - val declaredChildren: List, + val declaredChildren: List, context: BsonContext, ) : FilterExpressionNode(context) { - override fun simplify(): AbstractExpression? { + override fun simplify(): AbstractExpression? { if (declaredChildren.isEmpty()) return null @@ -354,11 +355,11 @@ class FilterExpression( @LowLevelApi private class PredicateInFilterExpression( val target: Path, - val expression: Expression, + val expression: DocumentExpression, context: BsonContext, ) : FilterExpressionNode(context) { - override fun simplify(): AbstractExpression? = + override fun simplify(): AbstractExpression? = expression.simplify() ?.let { PredicateInFilterExpression(target, it, context) } @@ -2136,11 +2137,11 @@ class FilterExpression( @LowLevelApi private class ElementMatchExpressionNode( val target: Path, - val expression: Expression, + val expression: DocumentExpression, context: BsonContext, ) : FilterExpressionNode(context) { - override fun simplify(): AbstractExpression = + override fun simplify(): AbstractExpression = ElementMatchExpressionNode(target, expression.simplify() ?: OrFilterExpressionNode(emptyList(), context), context) diff --git a/dsl/src/commonMain/kotlin/expr/PredicateExpression.kt b/dsl/src/commonMain/kotlin/expr/PredicateExpression.kt index 3dbb4de481f3761d8724f9ce7962affaee02d1aa..53caca1331e9ede29eaf3f4fddc7a49564dc56a2 100644 --- a/dsl/src/commonMain/kotlin/expr/PredicateExpression.kt +++ b/dsl/src/commonMain/kotlin/expr/PredicateExpression.kt @@ -23,7 +23,8 @@ import opensavvy.ktmongo.bson.types.BsonType import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundDocumentExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractDocumentExpression import opensavvy.ktmongo.dsl.expr.common.AbstractExpression /** @@ -58,12 +59,12 @@ import opensavvy.ktmongo.dsl.expr.common.AbstractExpression @KtMongoDsl class PredicateExpression( context: BsonContext, -) : AbstractCompoundExpression(context) { +) : AbstractCompoundDocumentExpression(context) { // region Low-level operations @LowLevelApi - private sealed class PredicateExpressionNode(context: BsonContext) : AbstractExpression(context) + private sealed class PredicateExpressionNode(context: BsonContext) : AbstractDocumentExpression(context) // endregion // region $eq @@ -302,8 +303,8 @@ class PredicateExpression( * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/type/) * * @see FilterExpression.hasType Shorthand. - * @see isNull Checks if a value has the type [BsonType.NULL]. - * @see isUndefined Checks if a value has the type [BsonType.UNDEFINED]. + * @see isNull Checks if a value has the type [BsonType.Null]. + * @see isUndefined Checks if a value has the type [BsonType.Undefined]. */ @OptIn(LowLevelApi::class, DangerousMongoApi::class) @KtMongoDsl @@ -365,7 +366,7 @@ class PredicateExpression( context: BsonContext, ) : PredicateExpressionNode(context) { - override fun simplify(): AbstractExpression? { + override fun simplify(): AbstractExpression? { if (expression.children.isEmpty()) return null @@ -706,7 +707,7 @@ class PredicateExpression( * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/query/lt/) * * @see FilterExpression.ltNotNull - * @see lqNotNull Learn more about the 'notNull' variants + * @see ltNotNull Learn more about the 'notNull' variants */ @KtMongoDsl fun ltNotNull(value: T?) { diff --git a/dsl/src/commonMain/kotlin/expr/UpdateExpression.kt b/dsl/src/commonMain/kotlin/expr/UpdateExpression.kt index d333da76a1ae2e422c3c957427c31bcfaaac98f1..cffd51b5ea63d3c47da866ddfbbc166358e3a050 100644 --- a/dsl/src/commonMain/kotlin/expr/UpdateExpression.kt +++ b/dsl/src/commonMain/kotlin/expr/UpdateExpression.kt @@ -21,10 +21,7 @@ import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression -import opensavvy.ktmongo.dsl.expr.common.AbstractExpression -import opensavvy.ktmongo.dsl.expr.common.Expression -import opensavvy.ktmongo.dsl.expr.common.acceptAll +import opensavvy.ktmongo.dsl.expr.common.* import opensavvy.ktmongo.dsl.path.Field import opensavvy.ktmongo.dsl.path.FieldDsl import opensavvy.ktmongo.dsl.path.Path @@ -74,22 +71,23 @@ import kotlin.reflect.KProperty1 @KtMongoDsl class UpdateExpression( context: BsonContext, -) : AbstractCompoundExpression(context), +) : AbstractCompoundDocumentExpression(context), FieldDsl { // region Low-level operations - private class OperatorCombinator( + @OptIn(LowLevelApi::class) + private class OperatorCombinator>( val type: KClass, val combinator: (List, BsonContext) -> T ) { @Suppress("UNCHECKED_CAST") // This is a private class, it should not be used incorrectly - operator fun invoke(sources: List, context: BsonContext) = + operator fun invoke(sources: List>, context: BsonContext) = combinator(sources as List, context) } @LowLevelApi - override fun simplify(children: List): AbstractExpression? { + override fun simplify(children: List): AbstractExpression? { if (children.isEmpty()) return null @@ -116,7 +114,7 @@ class UpdateExpression( } @LowLevelApi - private sealed class UpdateExpressionNode(context: BsonContext) : AbstractExpression(context) + private sealed class UpdateExpressionNode(context: BsonContext) : AbstractDocumentExpression(context) // endregion // region $set @@ -281,7 +279,7 @@ class UpdateExpression( val mappings: List>, context: BsonContext, ) : UpdateExpressionNode(context) { - override fun simplify(): AbstractExpression? = + override fun simplify(): AbstractExpression? = this.takeUnless { mappings.isEmpty() } override fun write(writer: BsonFieldWriter) = with(writer) { @@ -371,7 +369,7 @@ class UpdateExpression( val mappings: List>, context: BsonContext, ) : UpdateExpressionNode(context) { - override fun simplify(): AbstractExpression? = + override fun simplify(): AbstractExpression? = this.takeUnless { mappings.isEmpty() } override fun write(writer: BsonFieldWriter) = with(writer) { @@ -453,7 +451,7 @@ class UpdateExpression( val fields: List, context: BsonContext, ) : UpdateExpressionNode(context) { - override fun simplify(): AbstractExpression? = + override fun simplify(): AbstractExpression? = this.takeUnless { fields.isEmpty() } override fun write(writer: BsonFieldWriter) = with(writer) { @@ -585,7 +583,7 @@ class UpdateExpression( val fields: List>, context: BsonContext, ) : UpdateExpressionNode(context) { - override fun simplify(): AbstractExpression? = + override fun simplify(): AbstractExpression? = this.takeUnless { fields.isEmpty() } override fun write(writer: BsonFieldWriter) = with(writer) { diff --git a/dsl/src/commonMain/kotlin/expr/common/CompoundExpression.kt b/dsl/src/commonMain/kotlin/expr/common/CompoundExpression.kt index ad72f105bdca4d55a29dcea0e5b14517e10efee2..4d1624f08840272b4d53bb2c9fafc41ba39c2f47 100644 --- a/dsl/src/commonMain/kotlin/expr/common/CompoundExpression.kt +++ b/dsl/src/commonMain/kotlin/expr/common/CompoundExpression.kt @@ -16,15 +16,21 @@ package opensavvy.ktmongo.dsl.expr.common -import opensavvy.ktmongo.bson.BsonContext -import opensavvy.ktmongo.bson.BsonFieldWriter +import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.utils.asImmutable /** - * A compound expression is an [Expression] that may have children. + * A compound expression is an [Expression] that combines multiple expressions together into a single BSON value. + * + * For example, the compound expression [PredicateExpression][opensavvy.ktmongo.dsl.expr.PredicateExpression] allows + * combining multiple operators (for example `{ $ne: 12 }` and `{ $gt: 10 }`) into a single predicate expression: + * `{ $ne: 12, $gt: 10 }`. + * + * The distinct expressions that are combined by this compound are called "children". + * See [CompoundDocumentExpression] and [CompoundArrayExpression] to learn more about how children are merged. * * A compound expression may have `0..n` children. * Children are added by calling the [accept] function. @@ -34,9 +40,12 @@ import opensavvy.ktmongo.dsl.utils.asImmutable * * ### Implementation notes * - * Prefer implementing [AbstractCompoundExpression] instead of implementing this interface directly. + * See [AbstractCompoundExpression] instead of implementing this interface directly. + * + * @param Writer See [CompoundDocumentExpression] and [CompoundArrayExpression]. */ -interface CompoundExpression : Expression { +@OptIn(LowLevelApi::class) +interface CompoundExpression : Expression { /** * Adds a new [expression] as a child of this one. @@ -53,27 +62,105 @@ interface CompoundExpression : Expression { @LowLevelApi @DangerousMongoApi @KtMongoDsl - fun accept(expression: Expression) + fun accept(expression: Expression) companion object } -abstract class AbstractCompoundExpression( +/** + * A compound expression that is expected to be added in a [BSON document][Bson]. + * + * When an expression is added into this compound, it is merged with the existing children, overriding previous + * expressions with the same identifier. + * + * For example, if we have a compound expression: + * ```bson + * { + * $ne: 12, + * $gt: 10 + * } + * ``` + * and call [accept][CompoundExpression.accept] with the following expression: + * ```bson + * { + * $lte: 17, + * $ne: 13 + * } + * ``` + * we obtain the following result (the order of fields is not guaranteed): + * ```bson + * { + * $ne: 13, + * $gt: 10, + * $lte: 17 + * } + * ``` + * + * @see CompoundArrayExpression Equivalent for arrays. + */ +@OptIn(LowLevelApi::class) +typealias CompoundDocumentExpression = CompoundExpression + +/** + * A compound expression that is expected to be added in a [BSON array][BsonArray]. + * + * When an expression is added into this compound, it is added as a new element of the underlying array. + * + * For example, if we have a compound expression: + * ```bson + * [ + * 5, + * null + * ] + * ``` + * and we call [accept][CompoundExpression.accept] with the following expression: + * ```bson + * { + * "foo": "bar" + * } + * ``` + * we obtain the following result (the order of fields is guaranteed): + * ```bson + * [ + * 5, + * null, + * { + * "foo": "bar" + * } + * ] + * ``` + * + * @see CompoundDocumentExpression Equivalent for documents. + */ +@OptIn(LowLevelApi::class) +typealias CompoundArrayExpression = CompoundExpression + +/** + * Interface for declaring custom compound expressions. + * + * Each KtMongo DSL is powered by an instance of this class. + * + * Start by learning [how to create operators][AbstractExpression]. + * Then, implement either [AbstractCompoundDocumentExpression] or [AbstractCompoundArrayExpression]. + */ +@OptIn(LowLevelApi::class) +sealed class AbstractCompoundExpression( context: BsonContext, -) : AbstractExpression(context), CompoundExpression { + defaultWriterGenerator: (block: Writer.() -> Unit) -> Any, +) : AbstractExpression(context, defaultWriterGenerator), CompoundExpression { // region Sub-expression binding - private val _children = ArrayList() + private val _children = ArrayList>() @LowLevelApi - protected val children: List + protected val children: List> get() = _children.asImmutable() @LowLevelApi @DangerousMongoApi @KtMongoDsl - override fun accept(expression: Expression) { + override fun accept(expression: Expression) { require(!frozen) { "This expression has already been frozen, it cannot accept the child expression $expression" } require(expression != this) { "Trying to add an expression to itself!" } @@ -97,11 +184,11 @@ abstract class AbstractCompoundExpression( * @see Expression.simplify */ @LowLevelApi - protected open fun simplify(children: List): AbstractExpression? = + protected open fun simplify(children: List>): AbstractExpression? = this @LowLevelApi - final override fun simplify(): AbstractExpression? = + final override fun simplify(): AbstractExpression? = simplify(children) // endregion @@ -115,21 +202,51 @@ abstract class AbstractCompoundExpression( * @see AbstractExpression.write */ @LowLevelApi - protected open fun write(writer: BsonFieldWriter, children: List) { - for (child in children) { - check(this !== child) { "Trying to write myself as my own child!" } - child.writeTo(writer) - } - } + protected abstract fun write(writer: Writer, children: List>) @LowLevelApi - final override fun write(writer: BsonFieldWriter) { + final override fun write(writer: Writer) { write(writer, children) } // endregion +} - companion object +/** + * Parent interface for DSLs that are written into BSON documents. + * + * Implements the merging behavior described in [CompoundDocumentExpression]. + */ +@OptIn(LowLevelApi::class) +abstract class AbstractCompoundDocumentExpression( + context: BsonContext, +) : AbstractCompoundExpression(context, ::buildBsonDocument), DocumentExpression { + + override fun write(writer: BsonFieldWriter, children: List) { + for (child in children) { + check(this !== child) { "Trying to write myself as my own child!" } + child.writeTo(writer) + } + } +} + +/** + * Parent interface for DSLs that are written into BSON arrays. + * + * Implements the merging behavior described in [CompoundArrayExpression]. + */ +@OptIn(LowLevelApi::class) +abstract class AbstractCompoundArrayExpression( + context: BsonContext, +) : AbstractCompoundExpression(context, ::buildBsonArray), ArrayExpression { + + final override fun write(writer: BsonValueWriter, children: List) { + writer.writeArray { + for (child in children) { + child.writeTo(this) + } + } + } } /** @@ -140,7 +257,7 @@ abstract class AbstractCompoundExpression( @LowLevelApi @DangerousMongoApi @KtMongoDsl -fun CompoundExpression.acceptAll(expressions: Iterable) { +fun CompoundExpression.acceptAll(expressions: Iterable>) { for (child in expressions) { accept(child) } diff --git a/dsl/src/commonMain/kotlin/expr/common/Expression.kt b/dsl/src/commonMain/kotlin/expr/common/Expression.kt index 7676d72839a8c00b5b37ab5ff61969991e1edfa6..64ef36c6008a805140c487ca40bda7f6cd779fb1 100644 --- a/dsl/src/commonMain/kotlin/expr/common/Expression.kt +++ b/dsl/src/commonMain/kotlin/expr/common/Expression.kt @@ -16,9 +16,7 @@ package opensavvy.ktmongo.dsl.expr.common -import opensavvy.ktmongo.bson.BsonContext -import opensavvy.ktmongo.bson.BsonFieldWriter -import opensavvy.ktmongo.bson.buildBsonDocument +import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.expr.PredicateExpression @@ -39,8 +37,13 @@ import opensavvy.ktmongo.dsl.expr.PredicateExpression * ### Debugging notes * * Use [toString][Any.toString] to view the JSON representation of this expression. + * + * @param Writer The context in which this expression is written. + * - If this expression is an element of an object, [BsonFieldWriter] is used. See [DocumentExpression]. + * - If this expression is an element of an array, [BsonValueWriter] is used. See [ArrayExpression]. */ -interface Expression { +@OptIn(LowLevelApi::class) +interface Expression { /** * The context used to generate this expression. @@ -63,22 +66,32 @@ interface Expression { * Returns `null` when the current expression was simplified into a no-op (= it does nothing). */ @LowLevelApi - fun simplify(): Expression? + fun simplify(): Expression? /** * Writes the result of [simplifying][simplify] this expression into [writer]. */ @LowLevelApi - fun writeTo(writer: BsonFieldWriter) + fun writeTo(writer: Writer) /** * JSON representation of this expression. */ override fun toString(): String - - companion object } +/** + * An expression that is expected to be added in a [BSON document][Bson]. + */ +@OptIn(LowLevelApi::class) +typealias DocumentExpression = Expression + +/** + * An expression that is expected to be added in a [BSON array][BsonArray]. + */ +@OptIn(LowLevelApi::class) +typealias ArrayExpression = Expression + /** * Utility implementation for [Expression], which handles the [context], [toString] representation and [freezing][freeze]. * @@ -93,7 +106,7 @@ interface Expression { * Before writing your own operator, familiarize yourself with the documentation of [Expression], [AbstractExpression], * [CompoundExpression] and [AbstractCompoundExpression], as well as [BsonFieldWriter]. * - * Fundamentally, an operator is anything that is able to [write] itself into a BSON document. + * Fundamentally, an operator is anything that is able to [write] itself into a BSON document or array. * Operators should not be mutable, except through their [accept][CompoundExpression.accept] method (if they have one). * * An operator generally looks like the following: @@ -102,13 +115,17 @@ interface Expression { * private class TypePredicateExpressionNode( * val type: BsonType, * context: BsonContext, - * ) : AbstractExpression(context) { + * ) : AbstractDocumentExpression(context) { * * override fun write(writer: BsonFieldWriter) { * writer.writeInt32("\$type", type.code) * } * } * ``` + * Note that we implemented [AbstractDocumentExpression] instead of this class. The implementation is slightly different + * depending on whether this expression is expected to be written to a document or an array. Implement either + * [AbstractDocumentExpression] or [AbstractArrayExpression]. + * * The [BsonContext] is required at construction because it is needed to implement [toString], which the user could call at any time, * including while the operator is being constructed (e.g. when using a debugger). It is extremely important that the * `toString` representation they see is consistent with the final BSON sent over the wire. @@ -133,9 +150,11 @@ interface Expression { * operator you create so they can benefit from future fixes. Again, **an improperly-written operator may allow data * corruption or leaking**. */ -abstract class AbstractExpression( +@OptIn(LowLevelApi::class) +sealed class AbstractExpression( @property:LowLevelApi override val context: BsonContext, -) : Expression { + private val defaultWriterGenerator: (block: Writer.() -> Unit) -> Any, +) : Expression { /** * `true` if this expression is immutable. @@ -155,13 +174,13 @@ abstract class AbstractExpression( * so it is guaranteed that this expression is fully simplified already. */ @LowLevelApi - protected abstract fun write(writer: BsonFieldWriter) + protected abstract fun write(writer: Writer) @LowLevelApi - override fun simplify(): AbstractExpression? = this + override fun simplify(): AbstractExpression? = this @LowLevelApi - final override fun writeTo(writer: BsonFieldWriter) { + final override fun writeTo(writer: Writer) { this.simplify()?.write(writer) } @@ -173,7 +192,7 @@ abstract class AbstractExpression( */ @OptIn(LowLevelApi::class) fun toString(simplified: Boolean): String { - val document = buildBsonDocument { + val document = defaultWriterGenerator { if (simplified) writeTo(this) else @@ -185,6 +204,24 @@ abstract class AbstractExpression( final override fun toString(): String = toString(simplified = true) - - companion object } + +/** + * Specification of [AbstractExpression] for the case of implementing a [DocumentExpression]. + * + * Learn more about implementing this class in [AbstractExpression]. + */ +@OptIn(LowLevelApi::class) +abstract class AbstractDocumentExpression( + context: BsonContext +) : AbstractExpression(context, ::buildBsonDocument), DocumentExpression + +/** + * Specification of [AbstractExpression] for the case of implementing an [ArrayExpression]. + * + * Learn more about implementing this class in [AbstractExpression]. + */ +@OptIn(LowLevelApi::class) +abstract class AbstractArrayExpression( + context: BsonContext +) : AbstractExpression(context, ::buildBsonArray), ArrayExpression diff --git a/dsl/src/commonMain/kotlin/options/CountOptions.kt b/dsl/src/commonMain/kotlin/options/CountOptions.kt index 13702d806c1d22c5a2bb5fd7d7b2721b870fadc2..6073057c233fb5675abb96bcffaf5e03f0235ae7 100644 --- a/dsl/src/commonMain/kotlin/options/CountOptions.kt +++ b/dsl/src/commonMain/kotlin/options/CountOptions.kt @@ -17,11 +17,11 @@ package opensavvy.ktmongo.dsl.options import opensavvy.ktmongo.bson.BsonContext -import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractCompoundDocumentExpression import opensavvy.ktmongo.dsl.options.common.LimitOption import opensavvy.ktmongo.dsl.options.common.Options /** * The options for a `collection.count` operation. */ -class CountOptions(context: BsonContext) : AbstractCompoundExpression(context), Options, LimitOption +class CountOptions(context: BsonContext) : AbstractCompoundDocumentExpression(context), Options, LimitOption diff --git a/dsl/src/commonMain/kotlin/options/common/LimitOption.kt b/dsl/src/commonMain/kotlin/options/common/LimitOption.kt index 3d36d087f9bfd3125f347dac11a1714691f4a5ee..b91ad2d892a39c74ec43f21de6c944b82b331288 100644 --- a/dsl/src/commonMain/kotlin/options/common/LimitOption.kt +++ b/dsl/src/commonMain/kotlin/options/common/LimitOption.kt @@ -20,15 +20,15 @@ import opensavvy.ktmongo.bson.BsonContext import opensavvy.ktmongo.bson.BsonFieldWriter import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.LowLevelApi -import opensavvy.ktmongo.dsl.expr.common.AbstractExpression -import opensavvy.ktmongo.dsl.expr.common.CompoundExpression +import opensavvy.ktmongo.dsl.expr.common.AbstractDocumentExpression +import opensavvy.ktmongo.dsl.expr.common.CompoundDocumentExpression /** * Limits the number of elements returned by a query. * * See [limit]. */ -interface LimitOption : CompoundExpression { +interface LimitOption : CompoundDocumentExpression { /** * The maximum number of matching documents to return. @@ -48,7 +48,7 @@ interface LimitOption : CompoundExpression { private class LimitOptionExpression( private val limit: Long, context: BsonContext, - ) : AbstractExpression(context) { + ) : AbstractDocumentExpression(context) { @LowLevelApi override fun write(writer: BsonFieldWriter) { diff --git a/dsl/src/commonMain/kotlin/options/common/Options.kt b/dsl/src/commonMain/kotlin/options/common/Options.kt index 80816cf6b98455e7315aec0baae125e794a9b068..6a778787b844b2e60c606c804977f9c9a66fd901 100644 --- a/dsl/src/commonMain/kotlin/options/common/Options.kt +++ b/dsl/src/commonMain/kotlin/options/common/Options.kt @@ -16,7 +16,7 @@ package opensavvy.ktmongo.dsl.options.common -import opensavvy.ktmongo.dsl.expr.common.CompoundExpression +import opensavvy.ktmongo.dsl.expr.common.CompoundDocumentExpression /** * Parent interface for all option types. @@ -24,4 +24,4 @@ import opensavvy.ktmongo.dsl.expr.common.CompoundExpression * Option types are used to pass additional arguments to MongoDB operations. * See the different implementations for more information. */ -interface Options : CompoundExpression +interface Options : CompoundDocumentExpression