From 102d3e65ad6e9289e16b25d3ddf6f9d4c496ff6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 11 Sep 2026 11:18:59 +0200 Subject: [PATCH 01/14] feat(dsl): Rename Pipeline.reinterpret to Pipeline.unsafeCast Similar functions already use the unsafeCast naming elsewhere (Value.unsafeCast, Field.unsafeCast). --- .../src/commonMain/kotlin/MongoAggregationPipeline.kt | 2 +- .../kotlin/CoroutineMongoAggregationPipeline.kt | 2 +- .../kotlin/CoroutineMongoAggregationPipelineImpl.kt | 4 ++-- .../kotlin/MultiplatformMongoAggregationPipeline.kt | 2 +- .../MultiplatformMongoAggregationPipelineImpl.kt | 4 ++-- .../kotlin/MultiplatformMongoCollectionImpl.kt | 4 ++-- .../kotlin/BlockingMongoAggregationPipeline.kt | 4 ++-- .../src/commonMain/kotlin/MongoAggregationPipeline.kt | 2 +- .../src/jvmMain/kotlin/SyncMongoAggregationPipeline.kt | 2 +- .../jvmMain/kotlin/SyncMongoAggregationPipelineImpl.kt | 4 ++-- .../src/commonMain/kotlin/aggregation/Pipeline.kt | 10 +++++----- .../src/commonMain/kotlin/aggregation/stages/Count.kt | 2 +- .../src/commonMain/kotlin/aggregation/stages/Group.kt | 2 +- .../commonMain/kotlin/aggregation/stages/Project.kt | 2 +- .../src/commonMain/kotlin/aggregation/stages/Set.kt | 2 +- .../src/commonMain/kotlin/aggregation/stages/Unwind.kt | 2 +- dsl/src/commonMain/kotlin/aggregation/Pipeline.kt | 10 +++++----- dsl/src/commonMain/kotlin/aggregation/stages/Count.kt | 2 +- dsl/src/commonMain/kotlin/aggregation/stages/Group.kt | 2 +- .../commonMain/kotlin/aggregation/stages/Project.kt | 2 +- dsl/src/commonMain/kotlin/aggregation/stages/Set.kt | 2 +- dsl/src/commonMain/kotlin/aggregation/stages/Unwind.kt | 2 +- .../kotlin/aggregation/AggregationTestUtils.kt | 2 +- 23 files changed, 36 insertions(+), 36 deletions(-) diff --git a/driver-api/src/commonMain/kotlin/MongoAggregationPipeline.kt b/driver-api/src/commonMain/kotlin/MongoAggregationPipeline.kt index 4bbbd163..2c210446 100644 --- a/driver-api/src/commonMain/kotlin/MongoAggregationPipeline.kt +++ b/driver-api/src/commonMain/kotlin/MongoAggregationPipeline.kt @@ -81,7 +81,7 @@ interface MongoAggregationPipeline : AggregationPipeline reinterpret(): MongoAggregationPipeline + override fun unsafeCast(): MongoAggregationPipeline override fun limit(amount: Long): MongoAggregationPipeline diff --git a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipeline.kt b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipeline.kt index bf978658..61a74884 100644 --- a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipeline.kt +++ b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipeline.kt @@ -55,7 +55,7 @@ interface CoroutineMongoAggregationPipeline : MongoAggregationPi @LowLevelApi @DangerousMongoApi - override fun reinterpret(): CoroutineMongoAggregationPipeline + override fun unsafeCast(): CoroutineMongoAggregationPipeline override fun limit(amount: Long): CoroutineMongoAggregationPipeline diff --git a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipelineImpl.kt b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipelineImpl.kt index 30c5eb38..101531bf 100644 --- a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipelineImpl.kt +++ b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoAggregationPipelineImpl.kt @@ -70,7 +70,7 @@ private class CoroutineMongoAggregationPipelineImpl @OptIn(LowLe @Suppress("UNCHECKED_CAST") @LowLevelApi @DangerousMongoApi - override fun reinterpret(): CoroutineMongoAggregationPipelineImpl = + override fun unsafeCast(): CoroutineMongoAggregationPipelineImpl = this as CoroutineMongoAggregationPipelineImpl // endregion @@ -167,7 +167,7 @@ private class CoroutineMongoAggregationPipelineImpl @OptIn(LowLe try { val results = pipeline .limit(limit) - .reinterpret() + .unsafeCast() .toList() MongoAggregationPipeline.StageDebugReport.Success( diff --git a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoAggregationPipeline.kt b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoAggregationPipeline.kt index 2e19487f..3f70ad4d 100644 --- a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoAggregationPipeline.kt +++ b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoAggregationPipeline.kt @@ -51,7 +51,7 @@ interface MultiplatformMongoAggregationPipeline : MongoAggregati @LowLevelApi @DangerousMongoApi - override fun reinterpret(): MultiplatformMongoAggregationPipeline + override fun unsafeCast(): MultiplatformMongoAggregationPipeline override fun limit(amount: Long): MultiplatformMongoAggregationPipeline diff --git a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoAggregationPipelineImpl.kt b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoAggregationPipelineImpl.kt index 5fb74705..11b09071 100644 --- a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoAggregationPipelineImpl.kt +++ b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoAggregationPipelineImpl.kt @@ -59,7 +59,7 @@ internal class MultiplatformMongoAggregationPipelineImpl @OptIn( @Suppress("UNCHECKED_CAST") @LowLevelApi @DangerousMongoApi - override fun reinterpret(): MultiplatformMongoAggregationPipelineImpl = + override fun unsafeCast(): MultiplatformMongoAggregationPipelineImpl = this as MultiplatformMongoAggregationPipelineImpl // endregion @@ -156,7 +156,7 @@ internal class MultiplatformMongoAggregationPipelineImpl @OptIn( try { val results = pipeline .limit(limit) - .reinterpret() + .unsafeCast() .toList() MongoAggregationPipeline.StageDebugReport.Success( diff --git a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollectionImpl.kt b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollectionImpl.kt index 310c5c1f..16788d63 100644 --- a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollectionImpl.kt +++ b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollectionImpl.kt @@ -162,7 +162,7 @@ internal class MultiplatformMongoCollectionImpl( val message = this.aggregate() .countTo(result) - .reinterpret() + .unsafeCast() .firstOrNull() return message @@ -191,7 +191,7 @@ internal class MultiplatformMongoCollectionImpl( if (skip == null) it else it.skip(skip.skip) } .countTo(result) - .reinterpret() + .unsafeCast() return message .firstOrNull() diff --git a/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoAggregationPipeline.kt b/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoAggregationPipeline.kt index 158c946f..8e3affe7 100644 --- a/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoAggregationPipeline.kt +++ b/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoAggregationPipeline.kt @@ -127,8 +127,8 @@ class BlockingMongoAggregationPipeline( @DangerousMongoApi @LowLevelApi - override fun reinterpret(): BlockingMongoAggregationPipeline = - BlockingMongoAggregationPipeline(inner.reinterpret() as SyncMongoAggregationPipeline) + override fun unsafeCast(): BlockingMongoAggregationPipeline = + BlockingMongoAggregationPipeline(inner.unsafeCast() as SyncMongoAggregationPipeline) @LowLevelApi override fun writeTo(writer: BsonValueWriter) = diff --git a/driver-sync-api/src/commonMain/kotlin/MongoAggregationPipeline.kt b/driver-sync-api/src/commonMain/kotlin/MongoAggregationPipeline.kt index 39b9cca2..dc7c3002 100644 --- a/driver-sync-api/src/commonMain/kotlin/MongoAggregationPipeline.kt +++ b/driver-sync-api/src/commonMain/kotlin/MongoAggregationPipeline.kt @@ -80,7 +80,7 @@ interface MongoAggregationPipeline : AggregationPipeline reinterpret(): MongoAggregationPipeline + override fun unsafeCast(): MongoAggregationPipeline override fun limit(amount: Long): MongoAggregationPipeline diff --git a/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipeline.kt b/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipeline.kt index c6924e86..bf2f49d4 100644 --- a/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipeline.kt +++ b/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipeline.kt @@ -55,7 +55,7 @@ interface SyncMongoAggregationPipeline : MongoAggregationPipelin @LowLevelApi @DangerousMongoApi - override fun reinterpret(): SyncMongoAggregationPipeline + override fun unsafeCast(): SyncMongoAggregationPipeline override fun limit(amount: Long): SyncMongoAggregationPipeline diff --git a/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipelineImpl.kt b/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipelineImpl.kt index 07ffee04..50f51543 100644 --- a/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipelineImpl.kt +++ b/driver-sync/src/jvmMain/kotlin/SyncMongoAggregationPipelineImpl.kt @@ -65,7 +65,7 @@ private class SyncMongoAggregationPipelineImpl @OptIn(LowLevelAp @Suppress("UNCHECKED_CAST") @LowLevelApi @DangerousMongoApi - override fun reinterpret(): SyncMongoAggregationPipelineImpl = + override fun unsafeCast(): SyncMongoAggregationPipelineImpl = this as SyncMongoAggregationPipelineImpl // endregion @@ -161,7 +161,7 @@ private class SyncMongoAggregationPipelineImpl @OptIn(LowLevelAp try { val results = pipeline .limit(limit) - .reinterpret() + .unsafeCast() .toList() MongoAggregationPipeline.StageDebugReport.Success( diff --git a/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt index ea8e622f..95c27206 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -65,9 +65,9 @@ import opensavvy.ktmongo.dsl.tree.CompoundBsonNode * ### Implementing a new stage * * Just like operators, stages can be added as extension methods on this type or any of its subtypes. - * To register the stage, call [withStage], optionally followed by [reinterpret], and return the resulting pipeline. + * To register the stage, call [withStage], optionally followed by [unsafeCast], and return the resulting pipeline. * - * Stages should return [Pipeline] instances **that were generated by the [withStage] or [reinterpret] methods**. + * Stages should return [Pipeline] instances **that were generated by the [withStage] or [unsafeCast] methods**. * [Pipeline] implementations are allowed to assume all stages they will be provided were generated by their own * implementation of these methods, and thus may downcast the resulting pipeline to another type safely. * Returning any other [Pipeline] instance has unspecified behavior. @@ -76,7 +76,7 @@ import opensavvy.ktmongo.dsl.tree.CompoundBsonNode * * - [Official documentation](https://www.mongodb.com/docs/manual/aggregation/) * - * @param Output The type of document that this pipeline results in. Changing this type is possible by calling [reinterpret]. + * @param Output The type of document that this pipeline results in. Changing this type is possible by calling [unsafeCast]. */ @KtMongoDsl interface Pipeline { @@ -126,7 +126,7 @@ interface Pipeline { * } * ``` * - * @see reinterpret Change the output type of this pipeline. + * @see unsafeCast Change the output type of this pipeline. */ @DangerousMongoApi @LowLevelApi @@ -145,7 +145,7 @@ interface Pipeline { @Suppress("UNCHECKED_CAST") @DangerousMongoApi @LowLevelApi - fun reinterpret(): Pipeline + fun unsafeCast(): Pipeline /** * Writes the entire pipeline into [writer]. diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Count.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Count.kt index 4f378f83..1f3a48d3 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Count.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Count.kt @@ -61,7 +61,7 @@ interface HasCount : Pipeline { */ @OptIn(LowLevelApi::class, DangerousMongoApi::class) fun countTo(field: Field): Pipeline = - withStage(CountStage(field.path, context)).reinterpret() + withStage(CountStage(field.path, context)).unsafeCast() /** * Counts how many elements exist in the pipeline and outputs a single document containing a single [field] containing diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Group.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Group.kt index 6ada7ef7..32e7a809 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Group.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Group.kt @@ -136,7 +136,7 @@ interface HasGroup : Pipeline { block: GroupStageOperators.() -> Unit, ): Pipeline = withStage(GroupStage(GroupStageOperatorsImpl(context).apply(block), context)) - .reinterpret() + .unsafeCast() } diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt index cad39ed4..795ad976 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt @@ -101,7 +101,7 @@ interface HasProject : Pipeline { block: ProjectStageOperators.() -> Unit, ): Pipeline = withStage(createProjectStage(context, block)) - .reinterpret() + .unsafeCast() } diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Set.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Set.kt index 1d8e1d71..33df0f1d 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Set.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Set.kt @@ -73,7 +73,7 @@ interface HasSet : Pipeline { block: SetStageOperators.() -> Unit, ): Pipeline = withStage(createSetStage(context, block)) - .reinterpret() + .unsafeCast() } diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Unwind.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Unwind.kt index 82a6a5f6..3c410148 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Unwind.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Unwind.kt @@ -137,7 +137,7 @@ interface HasUnwind : Pipeline { checkNotNull(array) { $$"The $unwind stage must specify an array to unwind: $$unwindStage" } var result = this.withStage(unwindStage) - .reinterpret() + .unsafeCast() val setStage = unwindStage.setBlock if (setStage != null) { diff --git a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt index f5f34489..ab0692d0 100644 --- a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -68,9 +68,9 @@ import opensavvy.ktmongo.dsl.tree.CompoundBsonNode * ### Implementing a new stage * * Just like operators, stages can be added as extension methods on this type or any of its subtypes. - * To register the stage, call [withStage], optionally followed by [reinterpret], and return the resulting pipeline. + * To register the stage, call [withStage], optionally followed by [unsafeCast], and return the resulting pipeline. * - * Stages should return [Pipeline] instances **that were generated by the [withStage] or [reinterpret] methods**. + * Stages should return [Pipeline] instances **that were generated by the [withStage] or [unsafeCast] methods**. * [Pipeline] implementations are allowed to assume all stages they will be provided were generated by their own * implementation of these methods, and thus may downcast the resulting pipeline to another type safely. * Returning any other [Pipeline] instance has unspecified behavior. @@ -79,7 +79,7 @@ import opensavvy.ktmongo.dsl.tree.CompoundBsonNode * * - [Official documentation](https://www.mongodb.com/docs/manual/aggregation/) * - * @param Output The type of document that this pipeline results in. Changing this type is possible by calling [reinterpret]. + * @param Output The type of document that this pipeline results in. Changing this type is possible by calling [unsafeCast]. */ @KtMongoDsl interface Pipeline { @@ -129,7 +129,7 @@ interface Pipeline { * } * ``` * - * @see reinterpret Change the output type of this pipeline. + * @see unsafeCast Change the output type of this pipeline. */ @DangerousMongoApi @LowLevelApi @@ -148,7 +148,7 @@ interface Pipeline { @Suppress("UNCHECKED_CAST") @DangerousMongoApi @LowLevelApi - fun reinterpret(): Pipeline + fun unsafeCast(): Pipeline /** * Writes the entire pipeline into [writer]. diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Count.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Count.kt index 8cce850a..4d9140fe 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Count.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Count.kt @@ -64,7 +64,7 @@ interface HasCount : Pipeline { */ @OptIn(LowLevelApi::class, DangerousMongoApi::class) fun countTo(field: Field): Pipeline = - withStage(CountStage(field.path, context)).reinterpret() + withStage(CountStage(field.path, context)).unsafeCast() /** * Counts how many elements exist in the pipeline and outputs a single document containing a single [field] containing diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Group.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Group.kt index 1f99b88e..7aa3a43e 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Group.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Group.kt @@ -139,7 +139,7 @@ interface HasGroup : Pipeline { block: GroupStageOperators.() -> Unit, ): Pipeline = withStage(GroupStage(GroupStageOperatorsImpl(context).apply(block), context)) - .reinterpret() + .unsafeCast() } diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt index b5ca95f8..91605b3d 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt @@ -104,7 +104,7 @@ interface HasProject : Pipeline { block: ProjectStageOperators.() -> Unit, ): Pipeline = withStage(createProjectStage(context, block)) - .reinterpret() + .unsafeCast() } diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt index 09a9c3b8..ece0bb52 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt @@ -76,7 +76,7 @@ interface HasSet : Pipeline { block: SetStageOperators.() -> Unit, ): Pipeline = withStage(createSetStage(context, block)) - .reinterpret() + .unsafeCast() } diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Unwind.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Unwind.kt index 9633b30c..0ae77761 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Unwind.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Unwind.kt @@ -140,7 +140,7 @@ interface HasUnwind : Pipeline { checkNotNull(array) { $$"The $unwind stage must specify an array to unwind: $$unwindStage" } var result = this.withStage(unwindStage) - .reinterpret() + .unsafeCast() val setStage = unwindStage.setBlock if (setStage != null) { diff --git a/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt index 86cc3dad..6f0e9d87 100644 --- a/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt +++ b/dsl/src/commonTest/kotlin/aggregation/AggregationTestUtils.kt @@ -47,7 +47,7 @@ class TestPipeline( @Suppress("UNCHECKED_CAST") @DangerousMongoApi @LowLevelApi - override fun reinterpret(): TestPipeline = + override fun unsafeCast(): TestPipeline = this as TestPipeline override fun lookup(block: LookupStageOperators.() -> Unit): TestPipeline = -- 2.51.2 From f4139cb8f98d2c06c168aae77f324f4b318d6cb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 11 Sep 2026 18:55:49 +0200 Subject: [PATCH 02/14] docs(dsl): Improve the documentation of Value.unsafeCast() --- .../commonMain/kotlin/aggregation/Value.kt | 21 +++++++++++++++++++ .../commonMain/kotlin/aggregation/Value.kt | 21 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/dsl-template/src/commonMain/kotlin/aggregation/Value.kt b/dsl-template/src/commonMain/kotlin/aggregation/Value.kt index e9f0bd79..dda60b20 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/Value.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/Value.kt @@ -92,6 +92,27 @@ interface Value : Node, BsonValueWriteable { * Overwrites the represented type of this value. * * This method can be useful to bypass type checks. + * + * ### Example + * + * If a field has changed type over time, the DTO will use the newer type. + * However, you may still want to write a query using the old type: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * ) + * + * users.filter { User::age hasType BsonType.Double } + * .updateManyWithPipeline { + * set { + * User::age set User::age.unsafeCast() // Reflect the old type + * .toInt() // Convert to the new type + * } + * } + * ``` */ @Suppress("UNCHECKED_CAST") fun unsafeCast(): Value = diff --git a/dsl/src/commonMain/kotlin/aggregation/Value.kt b/dsl/src/commonMain/kotlin/aggregation/Value.kt index e6e52398..f444d7be 100644 --- a/dsl/src/commonMain/kotlin/aggregation/Value.kt +++ b/dsl/src/commonMain/kotlin/aggregation/Value.kt @@ -95,6 +95,27 @@ interface Value : Node, BsonValueWriteable { * Overwrites the represented type of this value. * * This method can be useful to bypass type checks. + * + * ### Example + * + * If a field has changed type over time, the DTO will use the newer type. + * However, you may still want to write a query using the old type: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * ) + * + * users.filter { User::age hasType BsonType.Double } + * .updateManyWithPipeline { + * set { + * User::age set User::age.unsafeCast() // Reflect the old type + * .toInt() // Convert to the new type + * } + * } + * ``` */ @Suppress("UNCHECKED_CAST") fun unsafeCast(): Value = -- 2.51.2 From 8acf18f6ca99bacaec9351d184c7e06dfac9f3fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 11 Sep 2026 18:56:59 +0200 Subject: [PATCH 03/14] feat(dsl): Add an aggregation shorthand for of(kotlinValue).unsafeCast() --- .../aggregation/operators/ValueOperators.kt | 29 +++++++++++++++++++ .../aggregation/operators/ValueOperators.kt | 29 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/dsl-template/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt b/dsl-template/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt index a526d0b5..5b8f20b5 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt @@ -194,6 +194,35 @@ interface ValueOperators : FieldDsl { fun of(value: BsonType): Value = BsonTypeValue(value, context) + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If a field has changed type over time, the DTO will use the newer type. + * However, you may still want to write a query using the old type: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * ) + * + * users.filter { User::age hasType BsonType.Double } + * .updateManyWithPipeline { + * set { + * User::age set User::age.unsafeCast() // Reflect the old type + * .toInt() // Convert to the new type + * } + * } + * ``` + */ + fun Any?.unsafeCast(): Value = + of(this).unsafeCast() + /** * Refers to [field] as a nested field of the current value. * diff --git a/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt b/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt index f6dc4705..3e961c56 100644 --- a/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt +++ b/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt @@ -197,6 +197,35 @@ interface ValueOperators : FieldDsl { fun of(value: BsonType): Value = BsonTypeValue(value, context) + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If a field has changed type over time, the DTO will use the newer type. + * However, you may still want to write a query using the old type: + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val age: Int, + * ) + * + * users.filter { User::age hasType BsonType.Double } + * .updateManyWithPipeline { + * set { + * User::age set User::age.unsafeCast() // Reflect the old type + * .toInt() // Convert to the new type + * } + * } + * ``` + */ + fun Any?.unsafeCast(): Value = + of(this).unsafeCast() + /** * Refers to [field] as a nested field of the current value. * -- 2.51.2 From afba061eb7cfbdcb10ce7f0a037dee3680b695cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 11 Sep 2026 19:06:43 +0200 Subject: [PATCH 04/14] feat(dsl): Improve the documentation of Field.unsafeCast, and add KProperty1.unsafeCast --- dsl-template/src/commonMain/kotlin/path/Field.kt | 10 +++++++++- dsl/src/commonMain/kotlin/path/Field.kt | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/dsl-template/src/commonMain/kotlin/path/Field.kt b/dsl-template/src/commonMain/kotlin/path/Field.kt index 1b9bd6d0..d5ddf0c6 100644 --- a/dsl-template/src/commonMain/kotlin/path/Field.kt +++ b/dsl-template/src/commonMain/kotlin/path/Field.kt @@ -87,7 +87,7 @@ interface Field { /** * Overwrites the represented type of this field. * - * This method can be useful to bypass type checks. + * This method can be useful to bypass type checks, it tells KtMongo to treat the field as if it were of some other type. */ @Suppress("UNCHECKED_CAST") fun unsafeCast(): Field = @@ -361,6 +361,14 @@ interface FieldDsl { infix fun KProperty1.unsafe(child: KProperty1<*, Child>): Field = this.field.unsafe(child) + /** + * Overwrites the represented type of this field. + * + * This method can be useful to bypass type checks, it tells KtMongo to treat the field as if it were of some other type. + */ + fun KProperty1.unsafeCast(): Field = + this.field.unsafeCast() + /** * Refers to [child] as a nested field of the current field. * diff --git a/dsl/src/commonMain/kotlin/path/Field.kt b/dsl/src/commonMain/kotlin/path/Field.kt index ece9a7a2..60e9591e 100644 --- a/dsl/src/commonMain/kotlin/path/Field.kt +++ b/dsl/src/commonMain/kotlin/path/Field.kt @@ -90,7 +90,7 @@ interface Field { /** * Overwrites the represented type of this field. * - * This method can be useful to bypass type checks. + * This method can be useful to bypass type checks, it tells KtMongo to treat the field as if it were of some other type. */ @Suppress("UNCHECKED_CAST") fun unsafeCast(): Field = @@ -364,6 +364,14 @@ interface FieldDsl { infix fun KProperty1.unsafe(child: KProperty1<*, Child>): Field = this.field.unsafe(child) + /** + * Overwrites the represented type of this field. + * + * This method can be useful to bypass type checks, it tells KtMongo to treat the field as if it were of some other type. + */ + fun KProperty1.unsafeCast(): Field = + this.field.unsafeCast() + /** * Refers to [child] as a nested field of the current field. * -- 2.51.2 From 2d0f57ba1d864f04a4152b6b7adc7666b043edd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 11 Sep 2026 19:15:05 +0200 Subject: [PATCH 05/14] docs(dsl): Improve the documentation of Pipeline.unsafeCast() --- .../src/commonMain/kotlin/aggregation/Pipeline.kt | 12 +++++------- dsl/src/commonMain/kotlin/aggregation/Pipeline.kt | 12 +++++------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt index 95c27206..77e4b602 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -22,6 +22,7 @@ import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.stages.HasProject import opensavvy.ktmongo.dsl.tree.AbstractBsonNode import opensavvy.ktmongo.dsl.tree.AbstractCompoundBsonNode import opensavvy.ktmongo.dsl.tree.BsonNode @@ -135,16 +136,13 @@ interface Pipeline { /** * Changes the type of the returned document, with no type-safety. * - * **End-users should not need to call this function.** - * This function is provided to allow stages to change the return document. - * No type verifications are made, it is solely the responsibility of the caller to ensure that the declared return - * type corresponds to the reality. + * Every subsequent step in the pipeline will think the returned document is of type [New]. + * It is your responsibility to ensure that this corresponds to the reality; the KtMongo DSL makes no verifications. + * + * If you want to edit the representation of the documents in the pipeline, prefer using [HasProject.project]. * * @see withStage Add a new stage to this pipeline. */ - @Suppress("UNCHECKED_CAST") - @DangerousMongoApi - @LowLevelApi fun unsafeCast(): Pipeline /** diff --git a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt index ab0692d0..f0bb60da 100644 --- a/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt +++ b/dsl/src/commonMain/kotlin/aggregation/Pipeline.kt @@ -25,6 +25,7 @@ import opensavvy.ktmongo.dsl.BsonContext import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.ktmongo.dsl.aggregation.stages.HasProject import opensavvy.ktmongo.dsl.tree.AbstractBsonNode import opensavvy.ktmongo.dsl.tree.AbstractCompoundBsonNode import opensavvy.ktmongo.dsl.tree.BsonNode @@ -138,16 +139,13 @@ interface Pipeline { /** * Changes the type of the returned document, with no type-safety. * - * **End-users should not need to call this function.** - * This function is provided to allow stages to change the return document. - * No type verifications are made, it is solely the responsibility of the caller to ensure that the declared return - * type corresponds to the reality. + * Every subsequent step in the pipeline will think the returned document is of type [New]. + * It is your responsibility to ensure that this corresponds to the reality; the KtMongo DSL makes no verifications. + * + * If you want to edit the representation of the documents in the pipeline, prefer using [HasProject.project]. * * @see withStage Add a new stage to this pipeline. */ - @Suppress("UNCHECKED_CAST") - @DangerousMongoApi - @LowLevelApi fun unsafeCast(): Pipeline /** -- 2.51.2 From 3dce6ff8da3487064bf6b976eeda5cc4ca65f5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 11 Sep 2026 19:27:47 +0200 Subject: [PATCH 06/14] docs(dsl): Explicitly list the operators in the documentation of $set and $project --- .../kotlin/aggregation/stages/Project.kt | 20 ++++++++----------- .../kotlin/aggregation/stages/Set.kt | 6 ++++++ .../kotlin/aggregation/stages/Project.kt | 20 ++++++++----------- .../kotlin/aggregation/stages/Set.kt | 6 ++++++ 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt index 795ad976..d115efcc 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Project.kt @@ -45,18 +45,6 @@ interface HasProject : Pipeline { * * `_id` is kept even if not specified. To exclude it, see [ProjectStageOperators.excludeId]. * - * ### Difference with MongoDB - * - * In BSON, the `$project` stage can be used either for declaring an allow-list (by including fields, - * and possibly excluding the `_id`) or a block-list (by excluding fields). MongoDB doesn't allow a single stage - * usage to mix both usages. - * - * Because this is confusing, KtMongo splits both of these use-cases into two different methods. - * The former (selecting fields we want to keep) is performed by this method. - * The latter (selecting fields we want to remove) is performed by the stage [`$unset`][HasUnset.unset]. - * - * Note that just like in MongoDB, this stage can use all operators of the [`$set` stage][HasSet.set]. - * * ### Difference with $set * * This stage and the [`$set` stage][HasSet.set] are quite similar. In fact, both stages behave the same for fields @@ -95,6 +83,14 @@ interface HasProject : Pipeline { * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/project/) + * + * @param block The block defining the fields to include. + * - [ProjectStageOperators.include] Include a field in the document. + * - [ProjectStageOperators.excludeId] Exclude the `_id` field (included by default). + * - [ProjectStageOperators.exclude] Explicitly exclude a field (all fields are excluded by default). + * - [ProjectStageOperators.set] Overwrite the value of a field. + * - [ProjectStageOperators.setIf] Overwrite the value of a field if a condition is met. + * - [ProjectStageOperators.setUnless] Overwrite the value of a field if a condition is not met. */ @OptIn(DangerousMongoApi::class, LowLevelApi::class) fun project( diff --git a/dsl-template/src/commonMain/kotlin/aggregation/stages/Set.kt b/dsl-template/src/commonMain/kotlin/aggregation/stages/Set.kt index 33df0f1d..5de7ae0b 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/stages/Set.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/stages/Set.kt @@ -67,6 +67,12 @@ interface HasSet : Pipeline { * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/set/) + * + * @param block The block defining the fields to set. + * - [SetStageOperators.set] Overwrite the value of a field. + * - [SetStageOperators.setIf] Overwrite the value of a field if a condition is met. + * - [SetStageOperators.setUnless] Overwrite the value of a field if a condition is not met. + * - [SetStageOperators.exclude] Remove a field. */ @OptIn(DangerousMongoApi::class, LowLevelApi::class) fun set( diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt index 91605b3d..3d501c57 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Project.kt @@ -48,18 +48,6 @@ interface HasProject : Pipeline { * * `_id` is kept even if not specified. To exclude it, see [ProjectStageOperators.excludeId]. * - * ### Difference with MongoDB - * - * In BSON, the `$project` stage can be used either for declaring an allow-list (by including fields, - * and possibly excluding the `_id`) or a block-list (by excluding fields). MongoDB doesn't allow a single stage - * usage to mix both usages. - * - * Because this is confusing, KtMongo splits both of these use-cases into two different methods. - * The former (selecting fields we want to keep) is performed by this method. - * The latter (selecting fields we want to remove) is performed by the stage [`$unset`][HasUnset.unset]. - * - * Note that just like in MongoDB, this stage can use all operators of the [`$set` stage][HasSet.set]. - * * ### Difference with $set * * This stage and the [`$set` stage][HasSet.set] are quite similar. In fact, both stages behave the same for fields @@ -98,6 +86,14 @@ interface HasProject : Pipeline { * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/project/) + * + * @param block The block defining the fields to include. + * - [ProjectStageOperators.include] Include a field in the document. + * - [ProjectStageOperators.excludeId] Exclude the `_id` field (included by default). + * - [ProjectStageOperators.exclude] Explicitly exclude a field (all fields are excluded by default). + * - [ProjectStageOperators.set] Overwrite the value of a field. + * - [ProjectStageOperators.setIf] Overwrite the value of a field if a condition is met. + * - [ProjectStageOperators.setUnless] Overwrite the value of a field if a condition is not met. */ @OptIn(DangerousMongoApi::class, LowLevelApi::class) fun project( diff --git a/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt b/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt index ece0bb52..71723d63 100644 --- a/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt +++ b/dsl/src/commonMain/kotlin/aggregation/stages/Set.kt @@ -70,6 +70,12 @@ interface HasSet : Pipeline { * ### External resources * * - [Official documentation](https://www.mongodb.com/docs/manual/reference/operator/aggregation/set/) + * + * @param block The block defining the fields to set. + * - [SetStageOperators.set] Overwrite the value of a field. + * - [SetStageOperators.setIf] Overwrite the value of a field if a condition is met. + * - [SetStageOperators.setUnless] Overwrite the value of a field if a condition is not met. + * - [SetStageOperators.exclude] Remove a field. */ @OptIn(DangerousMongoApi::class, LowLevelApi::class) fun set( -- 2.51.2 From 87b8745897b30547ad791f3426cc07863c9162a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 12 Sep 2026 11:29:14 +0200 Subject: [PATCH 07/14] refactor(dsl): Reorder FieldDsl.div overloads --- .../src/commonMain/kotlin/path/Field.kt | 68 +++++++++---------- dsl/src/commonMain/kotlin/path/Field.kt | 68 +++++++++---------- 2 files changed, 68 insertions(+), 68 deletions(-) diff --git a/dsl-template/src/commonMain/kotlin/path/Field.kt b/dsl-template/src/commonMain/kotlin/path/Field.kt index d5ddf0c6..5d3b390e 100644 --- a/dsl-template/src/commonMain/kotlin/path/Field.kt +++ b/dsl-template/src/commonMain/kotlin/path/Field.kt @@ -251,6 +251,40 @@ interface FieldDsl { operator fun Field.div(child: KProperty1): Field = FieldImpl(path / context.pathOf(child)) + /** + * Refers to [child] as a nested field of the current field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profile: Profile, + * ) + * + * class Profile( + * val name: String, + * val age: Int, + * ) + * + * // Refer to the id + * println(User::id) + * // → 'id' + * + * // Refer to the name + * println(User::profile / Profile::name) + * // → 'profile.name' + * + * // Refer to the age + * println(User::profile / Profile::age) + * // → 'profile.age' + * ``` + * + * @see get Access a specific element of an array + */ + operator fun KProperty1.div(child: KProperty1): Field = + this.field / child + /** * Refers to a field [child] of the current field, with no compile-time safety. * @@ -369,40 +403,6 @@ interface FieldDsl { fun KProperty1.unsafeCast(): Field = this.field.unsafeCast() - /** - * Refers to [child] as a nested field of the current field. - * - * ### Examples - * - * ```kotlin - * class User( - * val id: Int, - * val profile: Profile, - * ) - * - * class Profile( - * val name: String, - * val age: Int, - * ) - * - * // Refer to the id - * println(User::id) - * // → 'id' - * - * // Refer to the name - * println(User::profile / Profile::name) - * // → 'profile.name' - * - * // Refer to the age - * println(User::profile / Profile::age) - * // → 'profile.age' - * ``` - * - * @see get Access a specific element of an array - */ - operator fun KProperty1.div(child: KProperty1): Field = - this.field / child - /** * Refers to a specific item in an array, by its index. * diff --git a/dsl/src/commonMain/kotlin/path/Field.kt b/dsl/src/commonMain/kotlin/path/Field.kt index 60e9591e..59cd5705 100644 --- a/dsl/src/commonMain/kotlin/path/Field.kt +++ b/dsl/src/commonMain/kotlin/path/Field.kt @@ -254,6 +254,40 @@ interface FieldDsl { operator fun Field.div(child: KProperty1): Field = FieldImpl(path / context.pathOf(child)) + /** + * Refers to [child] as a nested field of the current field. + * + * ### Examples + * + * ```kotlin + * class User( + * val id: Int, + * val profile: Profile, + * ) + * + * class Profile( + * val name: String, + * val age: Int, + * ) + * + * // Refer to the id + * println(User::id) + * // → 'id' + * + * // Refer to the name + * println(User::profile / Profile::name) + * // → 'profile.name' + * + * // Refer to the age + * println(User::profile / Profile::age) + * // → 'profile.age' + * ``` + * + * @see get Access a specific element of an array + */ + operator fun KProperty1.div(child: KProperty1): Field = + this.field / child + /** * Refers to a field [child] of the current field, with no compile-time safety. * @@ -372,40 +406,6 @@ interface FieldDsl { fun KProperty1.unsafeCast(): Field = this.field.unsafeCast() - /** - * Refers to [child] as a nested field of the current field. - * - * ### Examples - * - * ```kotlin - * class User( - * val id: Int, - * val profile: Profile, - * ) - * - * class Profile( - * val name: String, - * val age: Int, - * ) - * - * // Refer to the id - * println(User::id) - * // → 'id' - * - * // Refer to the name - * println(User::profile / Profile::name) - * // → 'profile.name' - * - * // Refer to the age - * println(User::profile / Profile::age) - * // → 'profile.age' - * ``` - * - * @see get Access a specific element of an array - */ - operator fun KProperty1.div(child: KProperty1): Field = - this.field / child - /** * Refers to a specific item in an array, by its index. * -- 2.51.2 From 372d8cee9f8ae310b21d71a09499cdf6a820e6ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 12 Sep 2026 12:51:41 +0200 Subject: [PATCH 08/14] fix(bson-multiplatform): Ignore additional fields that are not present in the DTO --- .../kotlin/serialization/MultiplatformDecoder.kt | 14 +++++++++++--- .../src/commonMain/kotlin/BsonDocumentTests.kt | 10 ++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt index 42d48b7f..452c7121 100644 --- a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt +++ b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt @@ -196,9 +196,17 @@ internal class BsonCompositeDecoder( val iterator = source.iterator() lateinit var current: BsonDocument.Field override fun decodeElementIndex(descriptor: SerialDescriptor): Int { - if (!iterator.hasNext()) return CompositeDecoder.DECODE_DONE - current = iterator.next() - return descriptor.getElementIndex(current.name) + // If we find some field that the descriptor doesn't know about, + // just skip it entirely, don't try to deserialize it. + + while (iterator.hasNext()) { + current = iterator.next() + val index = descriptor.getElementIndex(current.name) + if (index != CompositeDecoder.UNKNOWN_NAME) { + return index + } + } + return CompositeDecoder.DECODE_DONE } override fun decodeBooleanElement(descriptor: SerialDescriptor, index: Int): Boolean { diff --git a/bson-tests/src/commonMain/kotlin/BsonDocumentTests.kt b/bson-tests/src/commonMain/kotlin/BsonDocumentTests.kt index db2d4f02..bd0caa00 100644 --- a/bson-tests/src/commonMain/kotlin/BsonDocumentTests.kt +++ b/bson-tests/src/commonMain/kotlin/BsonDocumentTests.kt @@ -113,6 +113,16 @@ fun SuiteDsl.verifyBsonDocuments(factory: Prepared) = suite("BSON d check(exception.message matches "Could not decode opensavvy.ktmongo.bson.BsonDocumentUser.*\n\tfrom value \\{\"a\": \"Bob\"\\}.*\n*.*") } + test("Decode a single document with additional fields (must be ignored)") { + val document = factory().buildDocument { + writeString("a", "Bob") + writeInt32("b", 45) + writeDouble("ignored", 2.3) + } + + check(document.decode() == BsonDocumentUser("Bob", 45)) + } + test("Read a corrupt document") { checkThrows { // This document is malformed -- 2.51.2 From 84cdbadd23396819c8cdc27ba9eac2b4e9c5cc55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 12 Sep 2026 20:06:35 +0200 Subject: [PATCH 09/14] feat(dsl): Create helpers to access BsonDocument fields and BsonArray items --- .../aggregation/operators/ValueOperators.kt | 57 +++++++++ .../src/commonMain/kotlin/path/Field.kt | 112 ++++++++++++++++++ .../aggregation/operators/ValueOperators.kt | 57 +++++++++ dsl/src/commonMain/kotlin/path/Field.kt | 112 ++++++++++++++++++ 4 files changed, 338 insertions(+) diff --git a/dsl-template/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt b/dsl-template/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt index 5b8f20b5..b914f646 100644 --- a/dsl-template/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt +++ b/dsl-template/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt @@ -16,6 +16,8 @@ package opensavvy.ktmongo.dsl.aggregation.operators +import opensavvy.ktmongo.bson.BsonArray +import opensavvy.ktmongo.bson.BsonDocument import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.dsl.BsonContext @@ -25,6 +27,7 @@ import opensavvy.ktmongo.dsl.aggregation.AbstractValue import opensavvy.ktmongo.dsl.aggregation.AggregationOperators import opensavvy.ktmongo.dsl.aggregation.Value import opensavvy.ktmongo.dsl.path.* +import kotlin.jvm.JvmName import kotlin.reflect.KProperty1 import kotlin.reflect.KType import kotlin.reflect.typeOf @@ -280,6 +283,60 @@ interface ValueOperators : FieldDsl { operator fun Value.div(field: KProperty1): Value = this / field.field + /** + * Refers to a child field of a field of type [BsonDocument]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonDocument, + * ) + * + * users.find { + * User::externalData.get("profile") ne null + * } + * ``` + */ + @OptIn(LowLevelApi::class) + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonField") + final operator fun Value.get(field: String): Value = + GetFieldValue( + root = this, + child = Path(field), + context = context, + ) + + /** + * Refers to a child field of a field of type [BsonDocument]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonDocument, + * ) + * + * users.find { + * User::externalData.get("profile") ne null + * } + * ``` + */ + @OptIn(LowLevelApi::class) + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonItem") + final operator fun Value.get(index: Int): Value = + ArrayElemAtValue( + array = this, + index = of(index), + context = context, + ) + /** * Refers to a specific item in an array, by its index. * diff --git a/dsl-template/src/commonMain/kotlin/path/Field.kt b/dsl-template/src/commonMain/kotlin/path/Field.kt index 5d3b390e..d29c3866 100644 --- a/dsl-template/src/commonMain/kotlin/path/Field.kt +++ b/dsl-template/src/commonMain/kotlin/path/Field.kt @@ -16,10 +16,13 @@ package opensavvy.ktmongo.dsl.path +import opensavvy.ktmongo.bson.BsonArray +import opensavvy.ktmongo.bson.BsonDocument import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.query.FilterQuery +import kotlin.jvm.JvmName import kotlin.reflect.KProperty1 /** @@ -403,6 +406,115 @@ interface FieldDsl { fun KProperty1.unsafeCast(): Field = this.field.unsafeCast() + /** + * Refers to a root field of the document being analyzed. + * + * ### Example + * + * ```kotlin + * val users = database.collection("users") + * + * users.find { + * BsonDocument.get("age") gte 18 + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonField") + @OptIn(LowLevelApi::class) + final operator fun BsonDocument.Companion.get(field: String): Field = + FieldImpl(Path(field)) + + /** + * Refers to a child field of a field of type [BsonDocument]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonDocument, + * ) + * + * users.find { + * User::externalData.get("profile") ne null + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonField") + @OptIn(LowLevelApi::class) + final operator fun Field.get(field: String): Field = + FieldImpl(path / PathSegment.Field(field)) + + /** + * Refers to a child field of a field of type [BsonDocument]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonDocument, + * ) + * + * users.find { + * User::externalData.get("profile") ne null + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonField") + final operator fun KProperty1.get(field: String): Field = + this.field[field] + + /** + * Refers to a child item of a field of type [BsonArray]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonArray, + * ) + * + * users.find { + * User::externalData.get(0) ne null + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonItem") + @OptIn(LowLevelApi::class) + final operator fun Field.get(index: Int): Field = + FieldImpl(path / PathSegment.Indexed(index)) + + /** + * Refers to a child item of a field of type [BsonArray]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonArray, + * ) + * + * users.find { + * User::externalData.get(0) ne null + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonItem") + final operator fun KProperty1.get(index: Int): Field = + this.field[index] + /** * Refers to a specific item in an array, by its index. * diff --git a/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt b/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt index 3e961c56..841726a4 100644 --- a/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt +++ b/dsl/src/commonMain/kotlin/aggregation/operators/ValueOperators.kt @@ -19,6 +19,8 @@ package opensavvy.ktmongo.dsl.aggregation.operators +import opensavvy.ktmongo.bson.BsonArray +import opensavvy.ktmongo.bson.BsonDocument import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.BsonValueWriter import opensavvy.ktmongo.dsl.BsonContext @@ -28,6 +30,7 @@ import opensavvy.ktmongo.dsl.aggregation.AbstractValue import opensavvy.ktmongo.dsl.aggregation.AggregationOperators import opensavvy.ktmongo.dsl.aggregation.Value import opensavvy.ktmongo.dsl.path.* +import kotlin.jvm.JvmName import kotlin.reflect.KProperty1 import kotlin.reflect.KType import kotlin.reflect.typeOf @@ -283,6 +286,60 @@ interface ValueOperators : FieldDsl { operator fun Value.div(field: KProperty1): Value = this / field.field + /** + * Refers to a child field of a field of type [BsonDocument]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonDocument, + * ) + * + * users.find { + * User::externalData.get("profile") ne null + * } + * ``` + */ + @OptIn(LowLevelApi::class) + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonField") + final operator fun Value.get(field: String): Value = + GetFieldValue( + root = this, + child = Path(field), + context = context, + ) + + /** + * Refers to a child field of a field of type [BsonDocument]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonDocument, + * ) + * + * users.find { + * User::externalData.get("profile") ne null + * } + * ``` + */ + @OptIn(LowLevelApi::class) + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonItem") + final operator fun Value.get(index: Int): Value = + ArrayElemAtValue( + array = this, + index = of(index), + context = context, + ) + /** * Refers to a specific item in an array, by its index. * diff --git a/dsl/src/commonMain/kotlin/path/Field.kt b/dsl/src/commonMain/kotlin/path/Field.kt index 59cd5705..92001875 100644 --- a/dsl/src/commonMain/kotlin/path/Field.kt +++ b/dsl/src/commonMain/kotlin/path/Field.kt @@ -19,10 +19,13 @@ package opensavvy.ktmongo.dsl.path +import opensavvy.ktmongo.bson.BsonArray +import opensavvy.ktmongo.bson.BsonDocument import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.KtMongoDsl import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.query.FilterQuery +import kotlin.jvm.JvmName import kotlin.reflect.KProperty1 /** @@ -406,6 +409,115 @@ interface FieldDsl { fun KProperty1.unsafeCast(): Field = this.field.unsafeCast() + /** + * Refers to a root field of the document being analyzed. + * + * ### Example + * + * ```kotlin + * val users = database.collection("users") + * + * users.find { + * BsonDocument.get("age") gte 18 + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonField") + @OptIn(LowLevelApi::class) + final operator fun BsonDocument.Companion.get(field: String): Field = + FieldImpl(Path(field)) + + /** + * Refers to a child field of a field of type [BsonDocument]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonDocument, + * ) + * + * users.find { + * User::externalData.get("profile") ne null + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonField") + @OptIn(LowLevelApi::class) + final operator fun Field.get(field: String): Field = + FieldImpl(path / PathSegment.Field(field)) + + /** + * Refers to a child field of a field of type [BsonDocument]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonDocument, + * ) + * + * users.find { + * User::externalData.get("profile") ne null + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonField") + final operator fun KProperty1.get(field: String): Field = + this.field[field] + + /** + * Refers to a child item of a field of type [BsonArray]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonArray, + * ) + * + * users.find { + * User::externalData.get(0) ne null + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonItem") + @OptIn(LowLevelApi::class) + final operator fun Field.get(index: Int): Field = + FieldImpl(path / PathSegment.Indexed(index)) + + /** + * Refers to a child item of a field of type [BsonArray]. + * + * ### Example + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * val externalData: BsonArray, + * ) + * + * users.find { + * User::externalData.get(0) ne null + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + @JvmName("bsonItem") + final operator fun KProperty1.get(index: Int): Field = + this.field[index] + /** * Refers to a specific item in an array, by its index. * -- 2.51.2 From a27f652b17ecaf7c7e6207d6be107a5ece1e5b0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 12 Sep 2026 20:08:02 +0200 Subject: [PATCH 10/14] feat(driver-api): Create MongoCollection.unsafeCast() --- .../src/commonMain/kotlin/MongoCollection.kt | 55 +++++++++++++++++++ .../src/commonMain/kotlin/MongoCollection.kt | 55 +++++++++++++++++++ .../operations/InsertOperations.test.kt | 33 +++++++++++ 3 files changed, 143 insertions(+) diff --git a/driver-api/src/commonMain/kotlin/MongoCollection.kt b/driver-api/src/commonMain/kotlin/MongoCollection.kt index 1bced91b..84712ba0 100644 --- a/driver-api/src/commonMain/kotlin/MongoCollection.kt +++ b/driver-api/src/commonMain/kotlin/MongoCollection.kt @@ -24,6 +24,7 @@ import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.path.PropertyNameStrategy import opensavvy.ktmongo.dsl.query.FilterQuery import kotlin.reflect.KType +import kotlin.reflect.typeOf /** * A collection stores related documents together. @@ -151,6 +152,34 @@ interface MongoCollection : ObjectIdGenerator, @LowLevelApi val type: KType + // region Type manipulation + + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ + fun unsafeCast(type: KType): MongoCollection + + // endregion // region Specializations override fun filter(filter: FilterQuery.() -> Unit): MongoCollection @@ -158,3 +187,29 @@ interface MongoCollection : ObjectIdGenerator, // endregion } + +/** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ +inline fun MongoCollection<*>.unsafeCast(): MongoCollection = + unsafeCast(typeOf()) diff --git a/driver-sync-api/src/commonMain/kotlin/MongoCollection.kt b/driver-sync-api/src/commonMain/kotlin/MongoCollection.kt index 2278bccb..3bdf1d8e 100644 --- a/driver-sync-api/src/commonMain/kotlin/MongoCollection.kt +++ b/driver-sync-api/src/commonMain/kotlin/MongoCollection.kt @@ -24,6 +24,7 @@ import opensavvy.ktmongo.dsl.path.PropertyNameStrategy import opensavvy.ktmongo.dsl.query.FilterQuery import opensavvy.ktmongo.sync.api.operations.* import kotlin.reflect.KType +import kotlin.reflect.typeOf /** * A collection stores related documents together. @@ -151,6 +152,34 @@ interface MongoCollection : ObjectIdGenerator, @LowLevelApi val type: KType + // region Type manipulation + + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ + fun unsafeCast(type: KType): MongoCollection + + // endregion // region Specializations override fun filter(filter: FilterQuery.() -> Unit): MongoCollection @@ -158,3 +187,29 @@ interface MongoCollection : ObjectIdGenerator, // endregion } + +/** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ +inline fun MongoCollection<*>.unsafeCast(): MongoCollection = + unsafeCast(typeOf()) diff --git a/test/src/commonMain/kotlin/operations/InsertOperations.test.kt b/test/src/commonMain/kotlin/operations/InsertOperations.test.kt index 4d0451fa..931494f2 100644 --- a/test/src/commonMain/kotlin/operations/InsertOperations.test.kt +++ b/test/src/commonMain/kotlin/operations/InsertOperations.test.kt @@ -14,14 +14,21 @@ * limitations under the License. */ +@file:OptIn(LowLevelApi::class) + package opensavvy.ktmongo.tests.api.operations import kotlinx.serialization.Serializable import opensavvy.ktmongo.api.MongoClient +import opensavvy.ktmongo.api.unsafeCast +import opensavvy.ktmongo.bson.BsonDocument +import opensavvy.ktmongo.bson.decode import opensavvy.ktmongo.bson.types.ObjectId +import opensavvy.ktmongo.dsl.LowLevelApi import opensavvy.ktmongo.dsl.command.InsertOne import opensavvy.ktmongo.dsl.command.errors.MongoWriteException import opensavvy.ktmongo.dsl.options.WriteConcern +import opensavvy.ktmongo.dsl.path.Field import opensavvy.ktmongo.tests.api.collection import opensavvy.prepared.suite.Prepared import opensavvy.prepared.suite.SuiteDsl @@ -96,6 +103,32 @@ fun SuiteDsl.verifyInsertOperations( } } + test("Decreased type safety") { + val docs = collection().unsafeCast() + val id = docs.newId() + + docs.insertOne( + docs.factory.buildDocument { + writeString("name", "Bob") + writeString("other", "value") + writeObjectId("_id", id) + } + ) + + check(docs.count() == 1L) + check(collection().find().toList() == listOf(InsertOperationsUser(id, "Bob"))) // The additional field is not visible after deserialization + + val withUnsafeField = collection().find { + Field.unsafe("other") eq "value" + } + + val withUnsafeDocument = docs.find { + BsonDocument.get("other") eq "value" + } + + check(withUnsafeField.toList() == withUnsafeDocument.toList().map { it.decode() }) + } + suite("Options") { test("insertOne • Write concern") { collection().insertOne( -- 2.51.2 From 2ab93f0bee14450eaaa40295f4a7a68d38db590c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 12 Sep 2026 20:08:30 +0200 Subject: [PATCH 11/14] feat(driver-coroutines): Implement MongoCollection.unsafeCast() --- .../CoroutineFilteredMongoCollectionImpl.kt | 4 ++ .../kotlin/CoroutineMongoCollection.kt | 55 +++++++++++++++++++ .../kotlin/CoroutineMongoCollectionImpl.kt | 5 ++ .../MultiplatformMongoCollectionImpl.kt | 3 + 4 files changed, 67 insertions(+) diff --git a/driver-coroutines/src/jvmMain/kotlin/CoroutineFilteredMongoCollectionImpl.kt b/driver-coroutines/src/jvmMain/kotlin/CoroutineFilteredMongoCollectionImpl.kt index 1c2e4843..3043a9a6 100644 --- a/driver-coroutines/src/jvmMain/kotlin/CoroutineFilteredMongoCollectionImpl.kt +++ b/driver-coroutines/src/jvmMain/kotlin/CoroutineFilteredMongoCollectionImpl.kt @@ -91,6 +91,10 @@ private class CoroutineFilteredMongoCollectionImpl( override val context: BsonContext get() = upstream.context + @Suppress("UNCHECKED_CAST") + override fun unsafeCast(type: KType): CoroutineMongoCollection = + CoroutineFilteredMongoCollectionImpl(upstream.unsafeCast(type), globalFilter as FilterQuery.() -> Unit) + override suspend fun create(options: CreateCollectionOptions.() -> Unit) { error("It is not possible to call 'create' on the filtered collection $this:\nA filtered collection is a driver-side view, it cannot exist in the database itself.") } diff --git a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollection.kt b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollection.kt index 8057bd2c..809ab674 100644 --- a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollection.kt +++ b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollection.kt @@ -28,6 +28,8 @@ import opensavvy.ktmongo.dsl.command.UpdateOptions import opensavvy.ktmongo.dsl.query.FilterQuery import opensavvy.ktmongo.dsl.query.UpdateWithPipelineQuery import opensavvy.ktmongo.dsl.query.UpsertQuery +import kotlin.reflect.KType +import kotlin.reflect.typeOf /** * A collection stores related documents together. @@ -95,4 +97,57 @@ interface CoroutineMongoCollection : MongoCollection { override fun aggregate(): CoroutineMongoAggregationPipeline override fun filter(filter: FilterQuery.() -> Unit): CoroutineMongoCollection + + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ + override fun unsafeCast(type: KType): CoroutineMongoCollection + + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + final inline fun unsafeCast(): CoroutineMongoCollection = + unsafeCast(typeOf()) + } diff --git a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollectionImpl.kt b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollectionImpl.kt index 384991ff..88472526 100644 --- a/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollectionImpl.kt +++ b/driver-coroutines/src/jvmMain/kotlin/CoroutineMongoCollectionImpl.kt @@ -47,6 +47,7 @@ import opensavvy.ktmongo.official.options.toJava import opensavvy.ktmongo.official.toJava import opensavvy.ktmongo.official.toKtMongo import java.util.concurrent.TimeUnit +import kotlin.reflect.KClass import kotlin.reflect.KType import kotlin.reflect.typeOf import com.mongodb.client.model.ReplaceOptions as MongoReplaceOptions @@ -82,6 +83,10 @@ private class CoroutineMongoCollectionImpl( @LowLevelApi override val context: BsonContext = CoroutineBsonContext() + @Suppress("UNCHECKED_CAST") + override fun unsafeCast(type: KType): CoroutineMongoCollection = + CoroutineMongoCollectionImpl(inner.withDocumentClass((type.classifier as KClass).java), factory, propertyNameStrategy, objectIdGenerator, type, innerDatabase) + // region Count override suspend fun count(): Long = diff --git a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollectionImpl.kt b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollectionImpl.kt index 16788d63..0625aa29 100644 --- a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollectionImpl.kt +++ b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollectionImpl.kt @@ -59,6 +59,9 @@ internal class MultiplatformMongoCollectionImpl( nameStrategy = propertyNameStrategy, ) + override fun unsafeCast(type: KType): MultiplatformMongoCollection = + MultiplatformMongoCollectionImpl(database, name, type, factory, propertyNameStrategy, objectIdGenerator) + override suspend fun insertOne( document: Document, options: InsertOneOptions.() -> Unit, -- 2.51.2 From 229250a5baf350284cabee4c0010b859b2b2bb2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 12 Sep 2026 20:08:48 +0200 Subject: [PATCH 12/14] feat(driver-sync): Implement MongoCollection.unsafeCast() --- .../kotlin/BlockingMongoCollection.kt | 3 + .../kotlin/SyncFilteredMongoCollectionImpl.kt | 4 ++ .../src/jvmMain/kotlin/SyncMongoCollection.kt | 55 +++++++++++++++++++ .../jvmMain/kotlin/SyncMongoCollectionImpl.kt | 5 ++ 4 files changed, 67 insertions(+) diff --git a/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoCollection.kt b/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoCollection.kt index ac1c18ed..082bef1c 100644 --- a/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoCollection.kt +++ b/driver-sync-api-adapter/src/commonMain/kotlin/BlockingMongoCollection.kt @@ -170,6 +170,9 @@ class BlockingMongoCollection( BlockingUpsertResult(inner.upsertOneWithPipeline(options, filter, update)) } + override fun unsafeCast(type: KType): BlockingMongoCollection = + BlockingMongoCollection(inner.unsafeCast(type)) + override fun toString(): String = inner.toString() } diff --git a/driver-sync/src/jvmMain/kotlin/SyncFilteredMongoCollectionImpl.kt b/driver-sync/src/jvmMain/kotlin/SyncFilteredMongoCollectionImpl.kt index 53210b48..a4d27ade 100644 --- a/driver-sync/src/jvmMain/kotlin/SyncFilteredMongoCollectionImpl.kt +++ b/driver-sync/src/jvmMain/kotlin/SyncFilteredMongoCollectionImpl.kt @@ -90,6 +90,10 @@ private class SyncFilteredMongoCollectionImpl( override val context: BsonContext get() = upstream.context + @Suppress("UNCHECKED_CAST") + override fun unsafeCast(type: KType): SyncMongoCollection = + SyncFilteredMongoCollectionImpl(upstream.unsafeCast(type), globalFilter as FilterQuery.() -> Unit) + override fun create(options: CreateCollectionOptions.() -> Unit) = error("It is not possible to call 'create' on the filtered collection $this:\nA filtered collection is a driver-side view, it cannot exist in the database itself.") diff --git a/driver-sync/src/jvmMain/kotlin/SyncMongoCollection.kt b/driver-sync/src/jvmMain/kotlin/SyncMongoCollection.kt index c643d769..4f8ac969 100644 --- a/driver-sync/src/jvmMain/kotlin/SyncMongoCollection.kt +++ b/driver-sync/src/jvmMain/kotlin/SyncMongoCollection.kt @@ -28,6 +28,8 @@ import opensavvy.ktmongo.dsl.query.UpdateWithPipelineQuery import opensavvy.ktmongo.dsl.query.UpsertQuery import opensavvy.ktmongo.sync.api.MongoCollection import opensavvy.ktmongo.sync.api.operations.UpdateOperations +import kotlin.reflect.KType +import kotlin.reflect.typeOf /** * A collection stores related documents together. @@ -99,4 +101,57 @@ interface SyncMongoCollection : MongoCollection { override fun find(): SyncMongoFindIterable override fun find(options: FindOptions.() -> Unit, filter: FilterQuery.() -> Unit): SyncMongoFindIterable + + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ + override fun unsafeCast(type: KType): SyncMongoCollection + + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + final inline fun unsafeCast(): SyncMongoCollection = + unsafeCast(typeOf()) + } diff --git a/driver-sync/src/jvmMain/kotlin/SyncMongoCollectionImpl.kt b/driver-sync/src/jvmMain/kotlin/SyncMongoCollectionImpl.kt index f1d68fb6..595ec8a0 100644 --- a/driver-sync/src/jvmMain/kotlin/SyncMongoCollectionImpl.kt +++ b/driver-sync/src/jvmMain/kotlin/SyncMongoCollectionImpl.kt @@ -47,6 +47,7 @@ import opensavvy.ktmongo.official.toJava import opensavvy.ktmongo.official.toKtMongo import opensavvy.ktmongo.sync.api.operations.UpdateOperations import java.util.concurrent.TimeUnit +import kotlin.reflect.KClass import kotlin.reflect.KType import kotlin.reflect.typeOf import com.mongodb.client.model.ReplaceOptions as MongoReplaceOptions @@ -82,6 +83,10 @@ private class SyncMongoCollectionImpl( @LowLevelApi override val context: BsonContext = CoroutineBsonContext() + @Suppress("UNCHECKED_CAST") + override fun unsafeCast(type: KType): SyncMongoCollection = + SyncMongoCollectionImpl(inner.withDocumentClass((type.classifier as KClass).java), factory, propertyNameStrategy, objectIdGenerator, type, innerDatabase) + // region Count override fun count(): Long = -- 2.51.2 From 61da0af6574214f3ad66b73730f4f5aaf25e963f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 12 Sep 2026 20:08:57 +0200 Subject: [PATCH 13/14] feat(driver-multiplatform): Implement MongoCollection.unsafeCast() --- ...ultiplatformFilteredMongoCollectionImpl.kt | 4 ++ .../kotlin/MultiplatformMongoCollection.kt | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/driver-multiplatform/src/commonMain/kotlin/MultiplatformFilteredMongoCollectionImpl.kt b/driver-multiplatform/src/commonMain/kotlin/MultiplatformFilteredMongoCollectionImpl.kt index be6859c3..385f13cb 100644 --- a/driver-multiplatform/src/commonMain/kotlin/MultiplatformFilteredMongoCollectionImpl.kt +++ b/driver-multiplatform/src/commonMain/kotlin/MultiplatformFilteredMongoCollectionImpl.kt @@ -62,6 +62,10 @@ private class MultiplatformFilteredMongoCollectionImpl( override val factory: BsonFactory get() = upstream.factory + @Suppress("UNCHECKED_CAST") + override fun unsafeCast(type: KType): MultiplatformMongoCollection = + MultiplatformFilteredMongoCollectionImpl(upstream.unsafeCast(type), globalFilter as FilterQuery.() -> Unit) + override suspend fun upsertOne(options: UpdateOptions.() -> Unit, filter: FilterQuery.() -> Unit, update: UpsertQuery.() -> Unit): MultiplatformMongoCollection.UpsertResult = upstream.upsertOne( options = options, diff --git a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollection.kt b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollection.kt index fc546bdd..1777c165 100644 --- a/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollection.kt +++ b/driver-multiplatform/src/commonMain/kotlin/MultiplatformMongoCollection.kt @@ -26,6 +26,8 @@ import opensavvy.ktmongo.dsl.command.UpdateOptions import opensavvy.ktmongo.dsl.query.FilterQuery import opensavvy.ktmongo.dsl.query.UpdateWithPipelineQuery import opensavvy.ktmongo.dsl.query.UpsertQuery +import kotlin.reflect.KType +import kotlin.reflect.typeOf /** * A collection stores related documents together. @@ -100,4 +102,56 @@ interface MultiplatformMongoCollection : MongoCollection.() -> Unit, update: UpdateWithPipelineQuery.() -> Unit, ): UpsertResult + + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ + override fun unsafeCast(type: KType): MultiplatformMongoCollection + + /** + * Overwrites the represented type of this value. + * + * This method can be useful to bypass type checks. + * + * ### Example + * + * If we know there are some documents that have the wrong format, we can use `unsafeCast` to find them + * without needing to add their fields to the production DTO. + * + * ```kotlin + * class User( + * val _id: ObjectId, + * val name: String, + * ) + * + * users.unsafeCast() // Treat all data as arbitrary BSON documents + * .find { BsonDocument.get("oldField") ne null } + * .forEach { + * println(it["oldField"]?.decodeString()) + * } + * ``` + */ + @Suppress("WRONG_MODIFIER_CONTAINING_DECLARATION") + final inline fun unsafeCast(): MultiplatformMongoCollection = + unsafeCast(typeOf()) } -- 2.51.2 From 0d3664e7113902dea0d9a6a59f2a5cd0c6c666c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 12 Sep 2026 21:25:49 +0200 Subject: [PATCH 14/14] docs(website): Create a page on progressive type safety --- docs/website/docs/features/type-safety.md | 171 ++++++++++++++++++++++ docs/website/mkdocs.yml | 1 + 2 files changed, 172 insertions(+) create mode 100644 docs/website/docs/features/type-safety.md diff --git a/docs/website/docs/features/type-safety.md b/docs/website/docs/features/type-safety.md new file mode 100644 index 00000000..3cc6ba4d --- /dev/null +++ b/docs/website/docs/features/type-safety.md @@ -0,0 +1,171 @@ +# Progressive type-safety with the KtMongo driver for MongoDB + +The KtMongo DSL is crafted to allow the Kotlin compiler to verify as much as possible of the validity of the queries. +Since MongoDB itself isn't typed, this trades off liberty to improve safety. + +Most of the time, this is the best choice: developers can quickly write queries without having to worry about syntax details. + +Still, any subsequently complex project will at some point encounter a situation that is safe in MongoDB but cannot be verified +by the Kotlin compiler. KtMongo is built to allow progressive type safety: KtMongo is type-safe by default but provides operators +that bypass some safety in some situations. + +This page describes some of these operators. + +## Type-safe queries + +By default, KtMongo is strongly type-safe. This means that the Kotlin compiler is able to check almost the entirety of a query. + +```kotlin +class User( + val _id: ObjectId, + val profile: Profile, +) + +class Profile( + val name: String, + val age: Int, +) + +val users = database.collection("users") + +users.find { + User::profile / Profile::age gte 18 +} +``` + +In this query, the compiler verifies that: + +- The property `Profile.age` must be of type `Int`, since we're using the operator `gte` with `18`, which is an `Int`. +- The property `User.profile` must be of type `Profile`, since we're referring to its child field `Profile.age`. +- The collection must be declared of type `User`, since the query refers to its field `User.profile`. + +As a comparison, the [KMongo library](../tutorials/from-kmongo/index.md), one of the main inspirations of KtMongo, is only able to verify the first two. +The Java and Kotlin official drivers make none of these verifications. + +Additionally, this type-safety allows the compiler to decide on the correct operator depending on the situation. +For example, KtMongo will automatically use dot-notation or `$getField` depending on the situation, will automatically use the correct syntax of `$eq`, etc. + +In a codebase, almost all queries should remain within the type-safe API, as this provides the best IDE and compiler support. + +## Progressive type safety + +Still, sometimes, type-safety can slow us down if we know how MongoDB will behave in these situations. + +### Nullability + +If a value is declared nullable, but we know `null` cannot happen in practice: + +```kotlin +users.aggregate() + .project { + User::isAdult set (User::age.unsafeNonNull() gte 18) + } +``` + +> Available in: [aggregations](../api/dsl/opensavvy.ktmongo.dsl.aggregation/-value/index.md#unsafenonnull). + +### Incorrect subtype + +If we use polymorphic serialization and want to access a field of one of the possible options: + +```kotlin +class Invoice( + val _id: ObjectId, + val data: Invoice, +) + +sealed interface InvoiceData + +class Accepted( + val signatureDate: Instant, + val author: ObjectId, +) : InvoiceData + +class Draft( + val expirationDate: Instant, +) : InvoiceData + +invoices.find { + Invoice::data unsafe Accepted::signatureDate lte (clock.now() - 7.days) +} +``` + +Here, `Invoice::data / Accepted::signatureDate` is not allowed because `data` is not of type `Accepted`. +The `unsafe` keyword allows bypassing that type verification. + +> Available in: [queries](../api/dsl/opensavvy.ktmongo.dsl.path/-field-dsl/index.md#unsafe). + +### Incorrect type + +Sometimes, especially when dealing with old data that may not have been migrated properly, we may have to deal with +fields that do not have the type declared in the DTO. + +```kotlin +class User( + val _id: ObjectId, + val name: String, + val age: Int, +) + +users.filter { User::age hasType BsonType.Double } + .updateManyWithPipeline { + set { + User::age set User::age.unsafeCast() + .toInt() + } + } +``` + +> Available in: [queries](../api/dsl/opensavvy.ktmongo.dsl.path/-field/index.md#unsafecast), [aggregations](../api/dsl/opensavvy.ktmongo.dsl.aggregation.operators/-value-operators/index.md#unsafecast), on [pipelines](../api/dsl/opensavvy.ktmongo.dsl.aggregation/-aggregation-pipeline/index.md#unsafecast) and on [collections](../api/driver-api/opensavvy.ktmongo.api/-mongo-collection/index.md#unsafecast). + +### Temporary field + +In aggregations, it is common to create a temporary field that only exists in intermediate stages of the pipeline and is never serialized neither in the input nor the output. + +```kotlin +val lookupOutput = Field.unsafe>("departments") + +users.aggregate() + .lookup { + into(lookupOutput) + from(departments.aggregate()) + on(User::departmentId, Department::_id) + } + .project { + include(User::name) + User::department set lookupOutput[0] + } +``` + +Since the field `departments` is never serialized (it is created by the `$lookup` and deleted by the `$project`), it would be inconvenient to add it to one of the DTOs. + +> Available: [everywhere](../api/dsl/opensavvy.ktmongo.dsl.path/-field/-companion/index.md#unsafe). + +### Arbitrary data + +MongoDB documents are not typed, they can contain arbitrary data. When we want to take advantage of this power, we should use the types `BsonDocument`, `BsonArray` or `BsonValue`: + +```kotlin +class User( + val _id: ObjectId, + val name: String, + val externalData: BsonDocument, +) + +users.find { + User::externalData.get("profile") ne null +} +``` + +> Available in: [queries](../api/dsl/opensavvy.ktmongo.dsl.path/-field-dsl/index.md#get), [aggregations](../api/dsl/opensavvy.ktmongo.dsl.aggregation.operators/-value-operators/index.md#get). + +### Arbitrary operators + +If you __really__ need to go outside the features provided by KtMongo, you can implement custom operators. In this case, there are no limits at all. + +To learn how to write custom operators, visit [AbstractBsonNode](../api/dsl/opensavvy.ktmongo.dsl.tree/-abstract-bson-node#index.md). +Every KtMongo DSL block provides the `accept` method which you can use to inject arbitrary BSON in any request. + +If you happen to need to write custom operators, this is probably a sign that something is missing in the KtMongo library. Please create a feature request or contribute your custom operator. + +> Available: [everywhere](../api/dsl/opensavvy.ktmongo.dsl.tree/-compound-bson-node/index.md#accept). diff --git a/docs/website/mkdocs.yml b/docs/website/mkdocs.yml index 5bb5beeb..e08d5b1c 100644 --- a/docs/website/mkdocs.yml +++ b/docs/website/mkdocs.yml @@ -137,6 +137,7 @@ nav: - Bulk writes: features/bulk-writes.md - Optional filters: features/optional-filters.md - Filtered collections: features/filtered-collections.md + - Type safety: features/type-safety.md - Data types: - Nested documents: features/fields.md -- 2.51.2