diff --git a/dsl/src/commonMain/kotlin/expr/FilterOperators.kt b/dsl/src/commonMain/kotlin/expr/FilterOperators.kt index 1e023870..2cd7d736 100644 --- a/dsl/src/commonMain/kotlin/expr/FilterOperators.kt +++ b/dsl/src/commonMain/kotlin/expr/FilterOperators.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, OpenSavvy and contributors. + * Copyright (c) 2024-2025, 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. @@ -17,6 +17,7 @@ package opensavvy.ktmongo.dsl.expr import opensavvy.ktmongo.bson.DEPRECATED_IN_BSON_SPEC +import opensavvy.ktmongo.bson.buildBsonDocument import opensavvy.ktmongo.bson.types.BsonType import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl @@ -674,6 +675,65 @@ interface FilterOperators : CompoundExpression, FieldDsl { this.field.isEmpty() } + /** + * Matches documents in which a map is empty or absent. + * + * ### Example + * + * Return all users that have no grades (either an empty map, or the `grades` field is absent): + * + * ```kotlin + * class User( + * val name: String?, + * val grades: Map + * ) + * + * collection.find { + * User::grades.isMapEmpty() + * } + * ``` + * + * @see exists + * @see isNull + * @see isNotEmpty + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun Field>.isMapEmpty() { + or { + doesNotExist() + FieldImpl(path) eq buildBsonDocument { } + } + } + + /** + * Matches documents in which a map is empty or absent. + * + * ### Example + * + * Return all users that have no grades (either an empty map, or the `grades` field is absent): + * + * ```kotlin + * class User( + * val name: String?, + * val grades: Map + * ) + * + * collection.find { + * User::grades.isMapEmpty() + * } + * ``` + * + * @see exists + * @see isNull + * @see isNotEmpty + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun KProperty1>.isMapEmpty() { + this.field.isMapEmpty() + } + /** * Matches documents in which an array is not empty. * @@ -729,6 +789,64 @@ interface FilterOperators : CompoundExpression, FieldDsl { this.field.isNotEmpty() } + /** + * Matches documents in which a map is not empty. + * + * ### Example + * + * Return all users that have one or more grades. + * + * ```kotlin + * class User( + * val name: String?, + * val grades: Map + * ) + * + * collection.find { + * User::grades.isMapNotEmpty() + * } + * ``` + * + * @see exists + * @see isNotNull + * @see isEmpty + */ + // Spec: https://www.mongodb.com/docs/manual/reference/bson-type-comparison-order/#objects + // "An object without [fields] is less than an object with [fields]." + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun Field>.isMapNotEmpty() { + FieldImpl(path) gt buildBsonDocument { } + } + + /** + * Matches documents in which a map is not empty. + * + * ### Example + * + * Return all users that have one or more grades. + * + * ```kotlin + * class User( + * val name: String?, + * val grades: Map + * ) + * + * collection.find { + * User::grades.isMapNotEmpty() + * } + * ``` + * + * @see exists + * @see isNotNull + * @see isEmpty + */ + @OptIn(LowLevelApi::class) + @KtMongoDsl + fun KProperty1>.isMapNotEmpty() { + this.field.isMapNotEmpty() + } + // endregion // region $type diff --git a/test/src/commonTest/kotlin/MapsTest.kt b/test/src/commonTest/kotlin/MapsTest.kt new file mode 100644 index 00000000..7d8e1954 --- /dev/null +++ b/test/src/commonTest/kotlin/MapsTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025, 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 kotlinx.serialization.Serializable +import opensavvy.ktmongo.test.testCollection +import opensavvy.prepared.runner.kotest.PreparedSpec + +class MapsTest : PreparedSpec({ + + @Serializable + data class User( + val name: String, + val grades: Map = emptyMap(), + val friends: Map = emptyMap(), + ) + + val users by testCollection("maps") + + suite("Not empty map") { + val cases = mapOf( + "Map has two elements" to User("Bob", grades = mapOf("maths" to 1, "physics" to 2)), + "Map has one element" to User("Bob", grades = mapOf("maths" to 1)), + ) + + for ((case, user) in cases) test(case) { + users().insertOne(user) + users().insertOne(User("Should not be returned")) + + check(users().find { User::grades.isMapNotEmpty() }.toList() == listOf(user)) + } + } + + suite("Empty map") { + val cases = mapOf( + "Map is not present" to User("Marcel"), + "Map is empty" to User("Marcel", grades = emptyMap()), + ) + + for ((case, user) in cases) test(case) { + users().insertOne(user) + users().insertOne(User("Should not be returned", grades = mapOf("maths" to 1))) + + check(users().find { User::grades.isMapEmpty() }.toList() == listOf(user)) + } + } + +}) -- 2.51.2 From 70473b4d4495f506623fb53aeb555abccc5934be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 11 Jan 2025 23:27:07 +0100 Subject: [PATCH 2/2] feat(dsl): Indexing operator for maps --- docs/website/docs/features/fields.md | 22 +++++++++ dsl/src/commonMain/kotlin/path/Field.kt | 59 ++++++++++++++++++++++++- test/src/commonTest/kotlin/MapsTest.kt | 8 ++++ 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/docs/website/docs/features/fields.md b/docs/website/docs/features/fields.md index 19d5da11..4cd4f29f 100644 --- a/docs/website/docs/features/fields.md +++ b/docs/website/docs/features/fields.md @@ -188,3 +188,25 @@ users.updateMany { User::pets.all / Pet::age set 1 } ``` + +## In maps + +Kotlin has the `Map` type, which doesn't exist in BSON. Most serialization libraries treat `Map` specially, and serialize it as an object (the keys become fields and the value their value). + +KtMongo assumes your serialization library works like this and provides a few helpers. If these types are serialized differently, these operators may not work. + +### Using an index + +The easiest way to access a map element is using its index. For example, if we know that we want to update Bob's physics score, we can use: +```kotlin hl_lines="14" +class User( + val name: String, + val scores: Map, +) + +users.updateOne( + filter = { User::name eq "Bob" } +) { + User::scores["physics"] set 20 +} +``` diff --git a/dsl/src/commonMain/kotlin/path/Field.kt b/dsl/src/commonMain/kotlin/path/Field.kt index b1681d65..32b9ff1f 100644 --- a/dsl/src/commonMain/kotlin/path/Field.kt +++ b/dsl/src/commonMain/kotlin/path/Field.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024, OpenSavvy and contributors. + * Copyright (c) 2024-2025, 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. @@ -168,6 +168,35 @@ interface Field<@Suppress("unused") Root, @Suppress("unused") out Type> { operator fun Field>.get(index: Int): Field = FieldImpl(this.path / PathSegment.Indexed(index)) +/** + * Refers to a specific item in a map, by its name. + * + * ### Examples + * + * ```kotlin + * class User( + * val name: String, + * val friends: Map, + * ) + * + * class Friend( + * val name: String, + * ) + * + * // Refer to the friend Bob + * println(User::friends["bob"]) + * // → 'friends.bob' + * + * // Refer to Bob's name + * println(User::friends["bob"] / Friend::name) + * // → 'friends.bob.name' + * ``` + */ +@KtMongoDsl +@OptIn(LowLevelApi::class) +operator fun Field>.get(key: String): Field = + FieldImpl(this.path / PathSegment.Field(key)) + /** * DSL to refer to [fields][Field], usually automatically added into scope by operators. */ @@ -261,6 +290,34 @@ interface FieldDsl { operator fun KProperty1>.get(index: Int): Field = this.field[index] + /** + * Refers to a specific item in a map, by its name. + * + * ### Examples + * + * ```kotlin + * class User( + * val name: String, + * val friends: Map, + * ) + * + * class Friend( + * val name: String, + * ) + * + * // Refer to the friend Bob + * println(User::friends["bob"]) + * // → 'friends.bob' + * + * // Refer to Bob's name + * println(User::friends["bob"] / Friend::name) + * // → 'friends.bob.name' + * ``` + */ + @KtMongoDsl + operator fun KProperty1>.get(index: String): Field = + this.field[index] + } @LowLevelApi diff --git a/test/src/commonTest/kotlin/MapsTest.kt b/test/src/commonTest/kotlin/MapsTest.kt index 7d8e1954..d5b1f487 100644 --- a/test/src/commonTest/kotlin/MapsTest.kt +++ b/test/src/commonTest/kotlin/MapsTest.kt @@ -59,4 +59,12 @@ class MapsTest : PreparedSpec({ } } + test("Position operator: $") { + val user = User("Alex", friends = mapOf("alice" to User("Alice"), "bob" to User("Bob"))) + users().insertOne(user) + users().insertOne(User("Should not be returned", friends = mapOf("arthur" to User("Arthur")))) + + check(users().find { User::friends["bob"].exists() }.toList() == listOf(user)) + } + })