From 5ca85b3b1254cd04c209ae9cc971d15cae9fce42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sun, 17 May 2026 00:22:04 +0200 Subject: [PATCH] feat(dsl): Create the arrayFilter option --- .../src/commonMain/kotlin/command/Update.kt | 4 +- .../kotlin/options/ArrayFiltersOption.kt | 157 +++++++++++++++++ dsl/src/commonMain/kotlin/command/Update.kt | 4 +- .../kotlin/options/ArrayFiltersOption.kt | 160 ++++++++++++++++++ .../kotlin/options/ArrayFiltersTest.kt | 143 ++++++++++++++++ 5 files changed, 466 insertions(+), 2 deletions(-) create mode 100644 dsl-template/src/commonMain/kotlin/options/ArrayFiltersOption.kt create mode 100644 dsl/src/commonMain/kotlin/options/ArrayFiltersOption.kt create mode 100644 dsl/src/commonTest/kotlin/options/ArrayFiltersTest.kt diff --git a/dsl-template/src/commonMain/kotlin/command/Update.kt b/dsl-template/src/commonMain/kotlin/command/Update.kt index c0eabffb..722f5698 100644 --- a/dsl-template/src/commonMain/kotlin/command/Update.kt +++ b/dsl-template/src/commonMain/kotlin/command/Update.kt @@ -22,6 +22,7 @@ import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.options.Options import opensavvy.ktmongo.dsl.options.OptionsHolder +import opensavvy.ktmongo.dsl.options.WithArrayFilters import opensavvy.ktmongo.dsl.options.WithWriteConcern import opensavvy.ktmongo.dsl.query.FilterQuery import opensavvy.ktmongo.dsl.query.UpdateQuery @@ -177,4 +178,5 @@ class UpdateMany private constructor( */ class UpdateOptions(context: BsonContext) : Options by OptionsHolder(context), - WithWriteConcern + WithWriteConcern, + WithArrayFilters diff --git a/dsl-template/src/commonMain/kotlin/options/ArrayFiltersOption.kt b/dsl-template/src/commonMain/kotlin/options/ArrayFiltersOption.kt new file mode 100644 index 00000000..15cc424f --- /dev/null +++ b/dsl-template/src/commonMain/kotlin/options/ArrayFiltersOption.kt @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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.options + +import opensavvy.ktmongo.bson.BsonDocument +import opensavvy.ktmongo.bson.BsonValueWriter +import opensavvy.ktmongo.dsl.BsonContext +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.KtMongoDsl +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.path.Field +import opensavvy.ktmongo.dsl.path.FieldImpl +import opensavvy.ktmongo.dsl.path.Path +import opensavvy.ktmongo.dsl.query.FilterQuery +import opensavvy.ktmongo.dsl.query.UpdateQuery +import opensavvy.ktmongo.dsl.tree.BsonNode +import opensavvy.ktmongo.dsl.tree.CompoundBsonNode + +/** + * Specifies which array elements should be updated by [UpdateQuery.filter]. + * + * For more information, see [UpdateQuery.filter] and [WithArrayFilters.arrayFilter]. + */ +class ArrayFiltersOption internal constructor( + private val arrayFilters: List>, + context: BsonContext, +) : AbstractOption("arrayFilters", context) { + + /** + * The different registered array filters. + */ + @OptIn(LowLevelApi::class) + val filters: List + get() = read() + .decodeArray() + .asIterable() + .map { it.decodeDocument() } + + @OptIn(DangerousMongoApi::class) + @LowLevelApi + override fun merge(other: Option): Option { + require(other is ArrayFiltersOption) { "Cannot merge arrayFilters options of different types: ${this::class} and ${other::class}" } + + return ArrayFiltersOption( + arrayFilters + other.arrayFilters, + context, + ) + } + + @LowLevelApi + override fun write(writer: BsonValueWriter) = with(writer) { + writeArray { + for ((_, filter) in arrayFilters) { + writeDocument { + filter.writeTo(this) + } + } + } + } +} + +/** + * Specifies which array elements should be updated by [UpdateQuery.filter]. + * + * See [arrayFilter]. + */ +@KtMongoDsl +interface WithArrayFilters : Options { + + /** + * Declares an array filter, which can then be passed to [UpdateQuery.filter]. + * + * To learn more about the usage of array filters, see [UpdateQuery.filter]. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * val score: Int, + * ) + * + * class Group( + * val _id: ObjectId, + * val users: List, + * ) + * + * groups.updateOne( + * filter = { + * Group::_id eq ObjectId("…") + * }, + * options = { + * arrayFilter("adult") { + * it / User::age gte 18 + * } + * }, + * update = { + * Group::users.filter("adult") / User::score inc 1 + * } + * ) + * ``` + * + * Note the `it` parameter in the `arrayFilter` function, which allows declaring a filter + * expression on a given element. The `arrayFilter` block accepts the same syntax as `find()`. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/positional-filtered) + */ + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + fun arrayFilter( + id: String, + filter: ArrayFiltersOptionDsl.(it: Field, Item>) -> Unit, + ) { + accept(ArrayFiltersOption(listOf(id to ArrayFiltersBlockNode(context).apply { filter(FieldImpl(Path(id))) }), context)) + } + + @OptIn(LowLevelApi::class) + private class ArrayFiltersBlockNode( + context: BsonContext, + private val filterQuery: FilterQuery> = FilterQuery(context), + ) : CompoundBsonNode, + ArrayFiltersOptionDsl, + FilterQuery> by filterQuery +} + +/** + * DSL to declare array filters. + * + * See [WithArrayFilters.arrayFilter]. + */ +@KtMongoDsl +interface ArrayFiltersOptionDsl : CompoundBsonNode, + FilterQuery> { + + /** + * Special type used by the [WithArrayFilters.arrayFilter] lambda parameter to designate + * the current array filter. + */ + @KtMongoDsl + interface IteratorType<@Suppress("unused") Document> +} diff --git a/dsl/src/commonMain/kotlin/command/Update.kt b/dsl/src/commonMain/kotlin/command/Update.kt index d8a91a10..97476183 100644 --- a/dsl/src/commonMain/kotlin/command/Update.kt +++ b/dsl/src/commonMain/kotlin/command/Update.kt @@ -25,6 +25,7 @@ import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.options.Options import opensavvy.ktmongo.dsl.options.OptionsHolder +import opensavvy.ktmongo.dsl.options.WithArrayFilters import opensavvy.ktmongo.dsl.options.WithWriteConcern import opensavvy.ktmongo.dsl.query.FilterQuery import opensavvy.ktmongo.dsl.query.UpdateQuery @@ -180,4 +181,5 @@ class UpdateMany private constructor( */ class UpdateOptions(context: BsonContext) : Options by OptionsHolder(context), - WithWriteConcern + WithWriteConcern, + WithArrayFilters diff --git a/dsl/src/commonMain/kotlin/options/ArrayFiltersOption.kt b/dsl/src/commonMain/kotlin/options/ArrayFiltersOption.kt new file mode 100644 index 00000000..c7c5bcde --- /dev/null +++ b/dsl/src/commonMain/kotlin/options/ArrayFiltersOption.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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. + */ + +// This file is generated from dsl-template/src/commonMain/kotlin/options/ArrayFiltersOption.kt +// DO NOT EDIT THIS FILE DIRECTLY. To learn more, read dsl-template/README.md. + +package opensavvy.ktmongo.dsl.options + +import opensavvy.ktmongo.bson.BsonDocument +import opensavvy.ktmongo.bson.BsonValueWriter +import opensavvy.ktmongo.dsl.BsonContext +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.KtMongoDsl +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.path.Field +import opensavvy.ktmongo.dsl.path.FieldImpl +import opensavvy.ktmongo.dsl.path.Path +import opensavvy.ktmongo.dsl.query.FilterQuery +import opensavvy.ktmongo.dsl.query.UpdateQuery +import opensavvy.ktmongo.dsl.tree.BsonNode +import opensavvy.ktmongo.dsl.tree.CompoundBsonNode + +/** + * Specifies which array elements should be updated by [UpdateQuery.filter]. + * + * For more information, see [UpdateQuery.filter] and [WithArrayFilters.arrayFilter]. + */ +class ArrayFiltersOption internal constructor( + private val arrayFilters: List>, + context: BsonContext, +) : AbstractOption("arrayFilters", context) { + + /** + * The different registered array filters. + */ + @OptIn(LowLevelApi::class) + val filters: List + get() = read() + .decodeArray() + .asIterable() + .map { it.decodeDocument() } + + @OptIn(DangerousMongoApi::class) + @LowLevelApi + override fun merge(other: Option): Option { + require(other is ArrayFiltersOption) { "Cannot merge arrayFilters options of different types: ${this::class} and ${other::class}" } + + return ArrayFiltersOption( + arrayFilters + other.arrayFilters, + context, + ) + } + + @LowLevelApi + override fun write(writer: BsonValueWriter) = with(writer) { + writeArray { + for ((_, filter) in arrayFilters) { + writeDocument { + filter.writeTo(this) + } + } + } + } +} + +/** + * Specifies which array elements should be updated by [UpdateQuery.filter]. + * + * See [arrayFilter]. + */ +@KtMongoDsl +interface WithArrayFilters : Options { + + /** + * Declares an array filter, which can then be passed to [UpdateQuery.filter]. + * + * To learn more about the usage of array filters, see [UpdateQuery.filter]. + * + * ### Example + * + * ```kotlin + * class User( + * val name: String, + * val age: Int, + * val score: Int, + * ) + * + * class Group( + * val _id: ObjectId, + * val users: List, + * ) + * + * groups.updateOne( + * filter = { + * Group::_id eq ObjectId("…") + * }, + * options = { + * arrayFilter("adult") { + * it / User::age gte 18 + * } + * }, + * update = { + * Group::users.filter("adult") / User::score inc 1 + * } + * ) + * ``` + * + * Note the `it` parameter in the `arrayFilter` function, which allows declaring a filter + * expression on a given element. The `arrayFilter` block accepts the same syntax as `find()`. + * + * ### External resources + * + * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/update/positional-filtered) + */ + @OptIn(DangerousMongoApi::class, LowLevelApi::class) + fun arrayFilter( + id: String, + filter: ArrayFiltersOptionDsl.(it: Field, Item>) -> Unit, + ) { + accept(ArrayFiltersOption(listOf(id to ArrayFiltersBlockNode(context).apply { filter(FieldImpl(Path(id))) }), context)) + } + + @OptIn(LowLevelApi::class) + private class ArrayFiltersBlockNode( + context: BsonContext, + private val filterQuery: FilterQuery> = FilterQuery(context), + ) : CompoundBsonNode, + ArrayFiltersOptionDsl, + FilterQuery> by filterQuery +} + +/** + * DSL to declare array filters. + * + * See [WithArrayFilters.arrayFilter]. + */ +@KtMongoDsl +interface ArrayFiltersOptionDsl : CompoundBsonNode, + FilterQuery> { + + /** + * Special type used by the [WithArrayFilters.arrayFilter] lambda parameter to designate + * the current array filter. + */ + @KtMongoDsl + interface IteratorType<@Suppress("unused") Document> +} diff --git a/dsl/src/commonTest/kotlin/options/ArrayFiltersTest.kt b/dsl/src/commonTest/kotlin/options/ArrayFiltersTest.kt new file mode 100644 index 00000000..75015ecd --- /dev/null +++ b/dsl/src/commonTest/kotlin/options/ArrayFiltersTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * 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.options + +import opensavvy.ktmongo.dsl.DangerousMongoApi +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.command.UpdateOptions +import opensavvy.ktmongo.dsl.multiContextSuite +import opensavvy.ktmongo.dsl.query.shouldBeBson +import opensavvy.ktmongo.dsl.testContext + +@OptIn(DangerousMongoApi::class, LowLevelApi::class) +val ArrayFiltersTest by multiContextSuite { + + class Target( + val name: String, + val age: Int, + ) + + test("Direct filter") { + val options = UpdateOptions(testContext()) + + options.arrayFilter("test") { + it eq 12.0 + } + + options.toString() shouldBeBson $$""" + { + "arrayFilters": [ + { + "test": { + "$eq": 12.0 + } + } + ] + } + """.trimIndent() + } + + test("Filter on field") { + val options = UpdateOptions(testContext()) + + options.arrayFilter("test") { + it / Target::age gte 18 + } + + options.toString() shouldBeBson $$""" + { + "arrayFilters": [ + { + "test.age": { + "$gte": 18 + } + } + ] + } + """.trimIndent() + } + + test("Filter on multiple fields") { + val options = UpdateOptions(testContext()) + + options.arrayFilter("test") { + it / Target::name eq "John" + it / Target::age gte 18 + } + + options.toString() shouldBeBson $$""" + { + "arrayFilters": [ + { + "$and": [ + { + "test.name": { + "$eq": "John" + } + }, + { + "test.age": { + "$gte": 18 + } + } + ] + } + ] + } + """.trimIndent() + } + + test("Two different filters") { + val options = UpdateOptions(testContext()) + + options.arrayFilter("a") { + it / Target::name eq "Damien" + } + + options.arrayFilter("b") { + it eq 42 + } + + options.toString() shouldBeBson $$""" + { + "arrayFilters": [ + { + "a.name": { + "$eq": "Damien" + } + }, + { + "b": { + "$eq": 42 + } + } + ] + } + """.trimIndent() + } + + test("Logical or") { + val options = UpdateOptions(testContext()) + + options.arrayFilter("a") { + or { + it / Target::name eq "John" + it / Target::age gte 18 + } + } + } +} -- 2.51.2