From 364f3a58795728f3d0488745b0e33f9d51d436cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Tue, 14 Oct 2025 21:00:45 +0200 Subject: [PATCH 01/12] feat(bson): Introduce BsonPath --- bson/src/commonMain/kotlin/BsonPath.kt | 90 ++++++++++++++++++++++ bson/src/commonTest/kotlin/BsonPathTest.kt | 49 ++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 bson/src/commonMain/kotlin/BsonPath.kt create mode 100644 bson/src/commonTest/kotlin/BsonPathTest.kt diff --git a/bson/src/commonMain/kotlin/BsonPath.kt b/bson/src/commonMain/kotlin/BsonPath.kt new file mode 100644 index 0000000..d4371d8 --- /dev/null +++ b/bson/src/commonMain/kotlin/BsonPath.kt @@ -0,0 +1,90 @@ +/* + * 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.bson + +@RequiresOptIn("This symbol is part of the experimental BsonPath API. It may change or be removed without warnings. Please provide feedback in https://gitlab.com/opensavvy/ktmongo/-/issues/93.") +annotation class ExperimentalBsonPathApi + +/** + * Access specific fields in arbitrary BSON documents using a [JSONPath](https://www.rfc-editor.org/rfc/rfc9535.html)-like API. + * + * ### Example + * + * ```kotlin + * BsonPath["foo"] // Refer to the field 'foo': $.foo + * BsonPath[0] // Refer to the item at index 0: $[0] + * BsonPath["foo"][0] // Refer to the item at index 0 in the array named 'foo': $.foo[0] + * ``` + */ +@ExperimentalBsonPathApi +sealed interface BsonPath { + + /** + * Points to a [field] in a [Bson] document. + * + * ### Example + * + * ```kotlin + * BsonPath["foo"] // $.foo + * BsonPath["foo"]["bar"] // $.foo.bar + * ``` + */ + operator fun get(field: String): BsonPath = + Field(field, this) + + /** + * Points to the element at [index] in a [BsonArray]. + * + * BSON indices start at 0. + * + * ### Example + * + * ```kotlin + * BsonPath[0] // $[0] + * BsonPath["foo"][1] // $.foo[1] + * ``` + */ + operator fun get(index: Int): BsonPath = + Item(index, this) + + private data class Field(val name: String, val parent: BsonPath) : BsonPath { + + init { + require("'" !in name) { "The character ' (apostrophe) is currently forbidden in BsonPath expressions, found: \"$name\"" } + } + + override fun toString() = + if (name matches legalCharacters) "$parent.$name" + else "$parent['$name']" + + companion object { + private val legalCharacters = Regex("[a-zA-Z0-9]*") + } + } + + private data class Item(val index: Int, val parent: BsonPath) : BsonPath { + init { + require(index >= 0) { "BSON array indices start at 0, found: $index" } + } + + override fun toString() = "$parent[$index]" + } + + companion object Root : BsonPath { + override fun toString() = "$" + } +} diff --git a/bson/src/commonTest/kotlin/BsonPathTest.kt b/bson/src/commonTest/kotlin/BsonPathTest.kt new file mode 100644 index 0000000..4a1f081 --- /dev/null +++ b/bson/src/commonTest/kotlin/BsonPathTest.kt @@ -0,0 +1,49 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalBsonPathApi::class) + +package opensavvy.ktmongo.bson + +import opensavvy.prepared.runner.testballoon.preparedSuite + +val BsonPathTest by preparedSuite { + + suite("Display") { + + test("The root") { + check(BsonPath.toString() == "$") + } + + test("A regular field") { + check(BsonPath["foo"].toString() == "$.foo") + } + + test("A regular array item") { + check(BsonPath[0].toString() == "$[0]") + } + + test("Fields that require escaping") { + check(BsonPath["foo bar"].toString() == "$['foo bar']") + check(BsonPath["baz.foo"].toString() == "$['baz.foo']") + } + + test("Chaining") { + check(BsonPath["store"]["book"][0]["title"].toString() == "$.store.book[0].title") + } + } + +} -- GitLab From 3c471fecbc1542d85660efaa38f1448cf5e765b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 22 Oct 2025 17:56:16 +0200 Subject: [PATCH 02/12] feat(bson): Allow deserializing arrays directly --- .../impl/read/MultiplatformArrayReader.kt | 13 +++++ .../serialization/MultiplatformDecoder.kt | 21 ++++++-- .../src/jvmMain/kotlin/BsonReader.jvm.kt | 50 +++++++++++++++++++ bson/src/commonMain/kotlin/BsonReader.kt | 9 ++++ 4 files changed, 88 insertions(+), 5 deletions(-) diff --git a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformArrayReader.kt b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformArrayReader.kt index 61f68ea..00c1024 100644 --- a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformArrayReader.kt +++ b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformArrayReader.kt @@ -16,12 +16,19 @@ package opensavvy.ktmongo.bson.multiplatform.impl.read +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.serializer import opensavvy.ktmongo.bson.BsonArrayReader import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.BsonValueReader import opensavvy.ktmongo.bson.multiplatform.BsonArray import opensavvy.ktmongo.bson.multiplatform.Bytes +import opensavvy.ktmongo.bson.multiplatform.serialization.BsonDecoderTopLevel import opensavvy.ktmongo.dsl.LowLevelApi +import kotlin.reflect.KClass +import kotlin.reflect.KType @LowLevelApi internal class MultiplatformArrayReader( @@ -70,6 +77,12 @@ internal class MultiplatformArrayReader( override fun asValue(): BsonValueReader = MultiplatformBsonValueReader(BsonType.Array, bytesWithHeader) + @OptIn(ExperimentalSerializationApi::class) + override fun read(type: KType, klass: KClass): T? { + val decoder = BsonDecoderTopLevel(EmptySerializersModule(), bytesWithHeader) + return decoder.decodeSerializableValue(serializer(type) as KSerializer) + } + override fun toString(): String = buildString { append('[') var isFirst = true diff --git a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt index 8396783..e175427 100644 --- a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt +++ b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt @@ -33,6 +33,9 @@ import opensavvy.ktmongo.bson.BsonDocumentReader import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.BsonValueReader import opensavvy.ktmongo.bson.multiplatform.BsonFactory +import opensavvy.ktmongo.bson.multiplatform.Bytes +import opensavvy.ktmongo.bson.multiplatform.impl.read.MultiplatformArrayReader +import opensavvy.ktmongo.bson.multiplatform.impl.read.MultiplatformDocumentReader import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.LowLevelApi @@ -45,7 +48,7 @@ import kotlin.uuid.Uuid internal class BsonDecoderTopLevel( override val serializersModule: SerializersModule, val context: BsonFactory, - val bytes: ByteArray, + val bytesWithHeader: Bytes, ) : AbstractDecoder() { var out: Any? = null @@ -60,8 +63,8 @@ internal class BsonDecoderTopLevel( @LowLevelApi override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { return when (descriptor.kind) { - StructureKind.CLASS, StructureKind.OBJECT -> BsonCompositeDecoder(serializersModule, context.readDocument(bytes).reader()) - StructureKind.LIST -> BsonCompositeListDecoder(serializersModule, context.readArray(bytes).reader()) + StructureKind.CLASS, StructureKind.OBJECT -> BsonCompositeDecoder(serializersModule, MultiplatformDocumentReader(bytesWithHeader)) + StructureKind.LIST -> BsonCompositeListDecoder(serializersModule, MultiplatformArrayReader(bytesWithHeader)) else -> TODO() } } @@ -301,10 +304,18 @@ internal class BsonCompositeListDecoder( @ExperimentalSerializationApi fun decodeFromBson(context: BsonFactory, bytes: ByteArray, deserializer: DeserializationStrategy): T { - val decoder = BsonDecoderTopLevel(EmptySerializersModule(), context, bytes) + val decoder = BsonDecoderTopLevel(EmptySerializersModule(), context, Bytes(bytes.copyOf())) return decoder.decodeSerializableValue(deserializer) } +@ExperimentalSerializationApi +inline fun decodeFromBson(bytes: ByteArray): T = + decodeFromBson(bytes, serializer()) + +@ExperimentalSerializationApi +fun decodeFromBson(context: BsonContext, bytes: ByteArray, deserializer: DeserializationStrategy): T = + decodeFromBson(bytes, deserializer) + @ExperimentalSerializationApi inline fun decodeFromBson(context: BsonFactory, bytes: ByteArray): T = - decodeFromBson(context, bytes, serializer()) + decodeFromBson(bytes, serializer()) diff --git a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt index 7433751..0ef8fe6 100644 --- a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt @@ -25,8 +25,12 @@ import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.BsonDocument +import org.bson.BsonReader import org.bson.BsonValue +import org.bson.BsonWriter +import org.bson.codecs.Codec import org.bson.codecs.DecoderContext +import org.bson.codecs.EncoderContext import java.nio.ByteBuffer import kotlin.reflect.KClass import kotlin.reflect.KType @@ -82,6 +86,24 @@ internal class BsonArrayReader( override fun asValue(): BsonValueReader = BsonValueReader(raw, context) + override fun read(type: KType, klass: KClass): T? { + // The Java BSON codecs expect to decode from a BsonReader positioned on the value to decode. + // However, there is no BsonReader implementation that reads directly from a BsonArray value. + // Work around this by wrapping the array in a temporary BSON document under a known field name + // and delegating decoding of that field to the codec for T. + val arrayHolder = BsonDocument("a", raw) + val documentReader: org.bson.BsonReader = org.bson.BsonDocumentReader(arrayHolder) + + // Acquire the codec for the requested type. + val valueCodec: Codec = context.codecRegistry.get(klass.java) + + // Decode the fake document and extract its only field using a delegating codec. + val docCodec = FakeDocumentCodec(valueCodec) + val decoded = docCodec.decode(documentReader, DecoderContext.builder().build()) + @Suppress("UNCHECKED_CAST") + return decoded.array as T? + } + override fun toString(): String { // Yes, this is very ugly, and probably inefficient. // The Java library doesn't provide a way to serialize arrays to JSON. @@ -94,6 +116,34 @@ internal class BsonArrayReader( document.lastIndexOf(']') + 1 ).trim() } + + private class FakeDocument( + val array: T, + ) + + private class FakeDocumentCodec( + private val valueCodec: Codec, + ) : Codec> { + override fun decode(reader: BsonReader, decoderContext: DecoderContext): FakeDocument { + reader.readStartDocument() + reader.readName("a") + val value = valueCodec.decode(reader, decoderContext) + reader.readEndDocument() + return FakeDocument(value) + } + + override fun encode(writer: BsonWriter, value: FakeDocument, encoderContext: EncoderContext) { + writer.writeStartDocument() + writer.writeName("a") + valueCodec.encode(writer, value.array, encoderContext) + writer.writeEndDocument() + } + + override fun getEncoderClass(): Class> { + @Suppress("UNCHECKED_CAST") + return FakeDocument::class.java as Class> + } + } } @LowLevelApi diff --git a/bson/src/commonMain/kotlin/BsonReader.kt b/bson/src/commonMain/kotlin/BsonReader.kt index 7ed40f3..c3a991f 100644 --- a/bson/src/commonMain/kotlin/BsonReader.kt +++ b/bson/src/commonMain/kotlin/BsonReader.kt @@ -135,6 +135,15 @@ interface BsonArrayReader { */ fun asValue(): BsonValueReader + /** + * Reads this document into an instance of [type] [T]. + * + * [T] should be a type that can contain elements, such as `List` or `Set`. + * + * If it isn't possible to deserialize this BSON to the given type, an exception is thrown. + */ + fun read(type: KType, klass: KClass): T? + /** * JSON representation of the array this [BsonArrayReader] is reading, as a [String]. */ -- GitLab From c61e9386ff1995265cf83ddc175a9cb75ffc30b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 22 Oct 2025 20:38:24 +0200 Subject: [PATCH 03/12] feat(bson): Allow deserializing arbitrary values directly --- .../impl/read/MultiplatformBsonValueReader.kt | 12 +++ .../src/jvmMain/kotlin/BsonReader.jvm.kt | 101 ++++++++++-------- bson/src/commonMain/kotlin/BsonReader.kt | 7 ++ 3 files changed, 75 insertions(+), 45 deletions(-) diff --git a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonValueReader.kt b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonValueReader.kt index 51669e8..d6f14db 100644 --- a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonValueReader.kt +++ b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonValueReader.kt @@ -16,9 +16,13 @@ package opensavvy.ktmongo.bson.multiplatform.impl.read +import kotlinx.serialization.KSerializer +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.serializer import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.bson.multiplatform.Bytes import opensavvy.ktmongo.bson.multiplatform.RawBsonWriter +import opensavvy.ktmongo.bson.multiplatform.serialization.BsonDecoder import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.Timestamp import opensavvy.ktmongo.dsl.DangerousMongoApi @@ -29,6 +33,8 @@ import kotlin.math.abs import kotlin.math.floor import kotlin.math.log10 import kotlin.math.pow +import kotlin.reflect.KClass +import kotlin.reflect.KType import kotlin.time.ExperimentalTime import kotlin.time.Instant @@ -74,6 +80,12 @@ internal class MultiplatformBsonValueReader( TODO("Not yet implemented") } + override fun read(type: KType, klass: KClass): T? { + val decoder = BsonDecoder(EmptySerializersModule(), this) + @Suppress("UNCHECKED_CAST") + return decoder.decodeSerializableValue(serializer(type) as KSerializer) + } + @LowLevelApi override fun readDateTime(): Long { checkType(BsonType.Datetime) diff --git a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt index 0ef8fe6..6c52b0a 100644 --- a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt @@ -31,6 +31,7 @@ import org.bson.BsonWriter import org.bson.codecs.Codec import org.bson.codecs.DecoderContext import org.bson.codecs.EncoderContext +import org.bson.codecs.configuration.CodecRegistry import java.nio.ByteBuffer import kotlin.reflect.KClass import kotlin.reflect.KType @@ -86,23 +87,8 @@ internal class BsonArrayReader( override fun asValue(): BsonValueReader = BsonValueReader(raw, context) - override fun read(type: KType, klass: KClass): T? { - // The Java BSON codecs expect to decode from a BsonReader positioned on the value to decode. - // However, there is no BsonReader implementation that reads directly from a BsonArray value. - // Work around this by wrapping the array in a temporary BSON document under a known field name - // and delegating decoding of that field to the codec for T. - val arrayHolder = BsonDocument("a", raw) - val documentReader: org.bson.BsonReader = org.bson.BsonDocumentReader(arrayHolder) - - // Acquire the codec for the requested type. - val valueCodec: Codec = context.codecRegistry.get(klass.java) - - // Decode the fake document and extract its only field using a delegating codec. - val docCodec = FakeDocumentCodec(valueCodec) - val decoded = docCodec.decode(documentReader, DecoderContext.builder().build()) - @Suppress("UNCHECKED_CAST") - return decoded.array as T? - } + override fun read(type: KType, klass: KClass): T? = + decodeValue(raw, klass, context.codecRegistry) override fun toString(): String { // Yes, this is very ugly, and probably inefficient. @@ -116,34 +102,6 @@ internal class BsonArrayReader( document.lastIndexOf(']') + 1 ).trim() } - - private class FakeDocument( - val array: T, - ) - - private class FakeDocumentCodec( - private val valueCodec: Codec, - ) : Codec> { - override fun decode(reader: BsonReader, decoderContext: DecoderContext): FakeDocument { - reader.readStartDocument() - reader.readName("a") - val value = valueCodec.decode(reader, decoderContext) - reader.readEndDocument() - return FakeDocument(value) - } - - override fun encode(writer: BsonWriter, value: FakeDocument, encoderContext: EncoderContext) { - writer.writeStartDocument() - writer.writeName("a") - valueCodec.encode(writer, value.array, encoderContext) - writer.writeEndDocument() - } - - override fun getEncoderClass(): Class> { - @Suppress("UNCHECKED_CAST") - return FakeDocument::class.java as Class> - } - } } @LowLevelApi @@ -321,6 +279,59 @@ private class BsonValueReader( return BsonArrayReader(value.asArray(), context) } + override fun read(type: KType, klass: KClass): T? = + decodeValue(value, klass, context.codecRegistry) + override fun toString(): String = value.toString() } + +private fun decodeValue( + value: BsonValue, + kClass: KClass, + codecRegistry: CodecRegistry, +): T? { + // At the top-level, only BSON documents exist. Therefore, the Java driver only provides ways + // to decode BSON documents, and not arrays or other values. + // In KtMongo, we need to be able to decode arbitrary values, even if they are not top-level. + // To work around this, we use a temporary BSON document with a single field 'a'. + val valueHolder = BsonDocument("a", value) + val documentReader: BsonReader = org.bson.BsonDocumentReader(valueHolder) + + // Acquire the codec for the requested type. + val valueCodec: Codec = codecRegistry.get(kClass.java) + + // Decode the fake document and extract its only field using a delegating codec. + val docCodec = FakeDocumentCodec(valueCodec) + val decoded = docCodec.decode(documentReader, DecoderContext.builder().build()) + @Suppress("UNCHECKED_CAST") + return decoded.a as T? +} + +private class FakeDocument( + val a: T, +) + +private class FakeDocumentCodec( + private val valueCodec: Codec, +) : Codec> { + override fun decode(reader: BsonReader, decoderContext: DecoderContext): FakeDocument { + reader.readStartDocument() + reader.readName("a") + val value = valueCodec.decode(reader, decoderContext) + reader.readEndDocument() + return FakeDocument(value) + } + + override fun encode(writer: BsonWriter, value: FakeDocument, encoderContext: EncoderContext) { + writer.writeStartDocument() + writer.writeName("a") + valueCodec.encode(writer, value.a, encoderContext) + writer.writeEndDocument() + } + + override fun getEncoderClass(): Class> { + @Suppress("UNCHECKED_CAST") + return FakeDocument::class.java as Class> + } +} diff --git a/bson/src/commonMain/kotlin/BsonReader.kt b/bson/src/commonMain/kotlin/BsonReader.kt index c3a991f..cb40604 100644 --- a/bson/src/commonMain/kotlin/BsonReader.kt +++ b/bson/src/commonMain/kotlin/BsonReader.kt @@ -283,6 +283,13 @@ interface BsonValueReader { @Throws(BsonReaderException::class) fun readArray(): BsonArrayReader + /** + * Reads this reader into an instance of [type] [T]. + * + * If it isn't possible to deserialize this BSON to the given type, an exception is thrown. + */ + fun read(type: KType, klass: KClass): T? + /** * JSON representation of this value. */ -- GitLab From bbf1cf97d78a442e692b49e26ae712654ecd73af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 22 Oct 2025 22:31:33 +0200 Subject: [PATCH 04/12] feat(bson): Use BsonPath to extract information from a complex BSON document --- .../kotlin/MultiplatformBsonTests.kt | 3 + .../src/jvmTest/kotlin/BsonContextTest.jvm.kt | 2 + .../kotlin/path/BsonPathSerializationTest.kt | 107 ++++++++++++++++++ bson/src/commonMain/kotlin/BsonPath.kt | 66 +++++++++++ 4 files changed, 178 insertions(+) create mode 100644 bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt diff --git a/bson-multiplatform/src/commonTest/kotlin/MultiplatformBsonTests.kt b/bson-multiplatform/src/commonTest/kotlin/MultiplatformBsonTests.kt index 09dac93..c53cb45 100644 --- a/bson-multiplatform/src/commonTest/kotlin/MultiplatformBsonTests.kt +++ b/bson-multiplatform/src/commonTest/kotlin/MultiplatformBsonTests.kt @@ -16,6 +16,7 @@ package opensavvy.ktmongo.bson.multiplatform +import opensavvy.ktmongo.bson.path.bsonPathTests import opensavvy.ktmongo.bson.raw.* import opensavvy.ktmongo.dsl.DangerousMongoApi import opensavvy.ktmongo.dsl.LowLevelApi @@ -61,4 +62,6 @@ val MultiplatformBsonWriterTest by preparedSuite { } } shouldBeJson """{"root": {"four": 4, "foo": "bar", "grades": [4, 7]}}""" } + + bsonPathTests(context) } diff --git a/bson-official/src/jvmTest/kotlin/BsonContextTest.jvm.kt b/bson-official/src/jvmTest/kotlin/BsonContextTest.jvm.kt index 68b805e..254cd62 100644 --- a/bson-official/src/jvmTest/kotlin/BsonContextTest.jvm.kt +++ b/bson-official/src/jvmTest/kotlin/BsonContextTest.jvm.kt @@ -17,6 +17,7 @@ package opensavvy.ktmongo.bson.official import com.mongodb.MongoClientSettings +import opensavvy.ktmongo.bson.path.bsonPathTests import opensavvy.ktmongo.bson.writerTests import opensavvy.prepared.runner.testballoon.preparedSuite import opensavvy.prepared.suite.prepared @@ -28,4 +29,5 @@ val testContext by prepared { @OptIn(ExperimentalStdlibApi::class) val JvmBsonContextTest by preparedSuite { writerTests(testContext) + bsonPathTests(testContext) } diff --git a/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt b/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt new file mode 100644 index 0000000..828bb04 --- /dev/null +++ b/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt @@ -0,0 +1,107 @@ +/* + * 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. + */ + +@file:OptIn(ExperimentalTime::class, ExperimentalBsonPathApi::class) + +package opensavvy.ktmongo.bson.path + +import kotlinx.serialization.Serializable +import opensavvy.ktmongo.bson.* +import opensavvy.ktmongo.bson.types.ObjectId +import opensavvy.prepared.suite.Prepared +import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.prepared +import kotlin.time.ExperimentalTime + +@Serializable +data class Profile( + val name: String, + val age: Int, +) + +@Serializable +enum class Species { + Goat, + Cat, + Bird, +} + +@Serializable +data class Pet( + val name: String, + val age: Int?, + val species: Species, +) + +@Serializable +data class User( + val _id: ObjectId, + val profile: Profile, + val pets: List, +) + +fun SuiteDsl.bsonPathTests( + context: Prepared, +) = suite("BsonPath") { + + val bob = User( + _id = ObjectId("68f93c04a7b4c7c3fc6bff38"), + profile = Profile("Bob", 46), + pets = listOf( + Pet("Barbie", 3, Species.Goat), + Pet("Poupette", 1, Species.Bird), + Pet("Michael", null, Species.Cat), + ) + ) + + val bobDoc by prepared { + context().write(bob) + } + + test("Select a simple field") { + check(bobDoc().select(BsonPath["_id"]).firstOrNull() == ObjectId("68f93c04a7b4c7c3fc6bff38")) + } + + test("Select a nested field") { + check(bobDoc().select(BsonPath["profile"]["name"]).firstOrNull() == "Bob") + } + + test("Select a document") { + check(bobDoc().select(BsonPath["profile"]).firstOrNull() == Profile("Bob", 46)) + } + + test("Select a list") { + check(bobDoc().select>(BsonPath["pets"]).first()[0].name == "Barbie") + } + + test("Select a list item") { + check(bobDoc().select(BsonPath["pets"][1]).firstOrNull() == Pet("Poupette", 1, Species.Bird)) + } + + test("Select a list item's field") { + check(bobDoc().select(BsonPath["pets"][1]["name"]).firstOrNull() == "Poupette") + } + + test("Select an enum") { + check(bobDoc().select(BsonPath["pets"][1]["species"]).firstOrNull() == Species.Bird) + } + + test("Select a nullable field") { + check(bobDoc().select(BsonPath["pets"][1]["age"]).firstOrNull() == 1) + check(bobDoc().select(BsonPath["pets"][2]["age"]).firstOrNull() == null) + } + +} diff --git a/bson/src/commonMain/kotlin/BsonPath.kt b/bson/src/commonMain/kotlin/BsonPath.kt index d4371d8..f4e1618 100644 --- a/bson/src/commonMain/kotlin/BsonPath.kt +++ b/bson/src/commonMain/kotlin/BsonPath.kt @@ -16,6 +16,10 @@ package opensavvy.ktmongo.bson +import opensavvy.ktmongo.dsl.LowLevelApi +import kotlin.reflect.KClass +import kotlin.reflect.typeOf + @RequiresOptIn("This symbol is part of the experimental BsonPath API. It may change or be removed without warnings. Please provide feedback in https://gitlab.com/opensavvy/ktmongo/-/issues/93.") annotation class ExperimentalBsonPathApi @@ -61,12 +65,26 @@ sealed interface BsonPath { operator fun get(index: Int): BsonPath = Item(index, this) + @LowLevelApi + fun findIn(reader: BsonValueReader): Sequence + private data class Field(val name: String, val parent: BsonPath) : BsonPath { init { require("'" !in name) { "The character ' (apostrophe) is currently forbidden in BsonPath expressions, found: \"$name\"" } } + @LowLevelApi + override fun findIn(reader: BsonValueReader): Sequence = + parent.findIn(reader) + .mapNotNull { + if (it.type == BsonType.Document) { + it.readDocument().read(name) + } else { + null + } + } + override fun toString() = if (name matches legalCharacters) "$parent.$name" else "$parent['$name']" @@ -81,10 +99,58 @@ sealed interface BsonPath { require(index >= 0) { "BSON array indices start at 0, found: $index" } } + @LowLevelApi + override fun findIn(reader: BsonValueReader): Sequence = + parent.findIn(reader) + .mapNotNull { + if (it.type == BsonType.Array) { + it.readArray().read(index) + } else { + null + } + } + override fun toString() = "$parent[$index]" } companion object Root : BsonPath { + @LowLevelApi + override fun findIn(reader: BsonValueReader): Sequence = + sequenceOf(reader) + override fun toString() = "$" } } + +/** + * Finds all values that match [path] in a given [BSON document][Bson]. + * + * ### Example + * + * ```kotlin + * val document: Bson = … + * + * document.select(BsonPath["foo"]["bar"]).firstOrNull() + * ``` + * will return the value of the field `foo.bar`. + * + * To learn more about BSON paths, see [BsonPath]. + */ +@OptIn(LowLevelApi::class) +@ExperimentalBsonPathApi +inline fun Bson.select(path: BsonPath): Sequence { + val type = typeOf() + + return path.findIn(this.reader().asValue()) + .map { + @Suppress("UNCHECKED_CAST") + val result = it.read(type, type.classifier as KClass) + + if (null is T) { + result as T + } else { + result + ?: throw BsonReaderException("Found an unexpected 'null' when reading the path $path in document $this") + } + } +} -- GitLab From 1038c221e29cce7b6a81acc3b29efb4de1236402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Thu, 23 Oct 2025 14:17:50 +0200 Subject: [PATCH 05/12] refactor(bson-official): Rename internal classes with the prefix Java to find them more easily --- bson-official/src/jvmMain/kotlin/Bson.jvm.kt | 4 +-- .../src/jvmMain/kotlin/BsonReader.jvm.kt | 25 ++++++++----------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/bson-official/src/jvmMain/kotlin/Bson.jvm.kt b/bson-official/src/jvmMain/kotlin/Bson.jvm.kt index 0b817c0..8f516c2 100644 --- a/bson-official/src/jvmMain/kotlin/Bson.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/Bson.jvm.kt @@ -41,7 +41,7 @@ actual class Bson internal constructor( @LowLevelApi override fun reader(): BsonDocumentReader = - BsonDocumentReader(raw, context) + JavaBsonDocumentReader(raw, context) @OptIn(LowLevelApi::class) override fun toString(): String = @@ -61,7 +61,7 @@ actual class BsonArray internal constructor( @LowLevelApi override fun reader(): BsonArrayReader = - BsonArrayReader(raw, context) + JavaBsonArrayReader(raw, context) @OptIn(LowLevelApi::class) override fun toString(): String = diff --git a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt index 6c52b0a..a691f35 100644 --- a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt @@ -17,9 +17,6 @@ package opensavvy.ktmongo.bson.official import opensavvy.ktmongo.bson.* -import opensavvy.ktmongo.bson.BsonArrayReader -import opensavvy.ktmongo.bson.BsonDocumentReader -import opensavvy.ktmongo.bson.BsonValueReader import opensavvy.ktmongo.bson.official.types.toKtMongo import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.Timestamp @@ -40,22 +37,22 @@ import org.bson.BsonArray as OfficialBsonArray import org.bson.BsonDocument as OfficialBsonDocument @LowLevelApi -internal class BsonDocumentReader( +internal class JavaBsonDocumentReader( private val raw: OfficialBsonDocument, private val context: JvmBsonFactory, ) : BsonDocumentReader { override fun read(name: String): BsonValueReader? { - return BsonValueReader(raw[name] ?: return null, context) + return JavaBsonValueReader(raw[name] ?: return null, context) } override val entries: Map - get() = raw.mapValues { (_, value) -> BsonValueReader(value, context) } + get() = raw.mapValues { (_, value) -> JavaBsonValueReader(value, context) } override fun toBson(): Bson = Bson(raw, context) override fun asValue(): BsonValueReader = - BsonValueReader(raw, context) + JavaBsonValueReader(raw, context) override fun read(type: KType, klass: KClass): T? { val codec = context.codecRegistry.get(klass.java) @@ -70,22 +67,22 @@ internal class BsonDocumentReader( } @LowLevelApi -internal class BsonArrayReader( +internal class JavaBsonArrayReader( private val raw: OfficialBsonArray, private val context: JvmBsonFactory, ) : BsonArrayReader { override fun read(index: Int): BsonValueReader? { - return BsonValueReader(raw.getOrNull(index) ?: return null, context) + return JavaBsonValueReader(raw.getOrNull(index) ?: return null, context) } override val elements: List - get() = raw.map { BsonValueReader(it, context) } + get() = raw.map { JavaBsonValueReader(it, context) } override fun toBson(): opensavvy.ktmongo.bson.official.BsonArray = BsonArray(raw, context) override fun asValue(): BsonValueReader = - BsonValueReader(raw, context) + JavaBsonValueReader(raw, context) override fun read(type: KType, klass: KClass): T? = decodeValue(raw, klass, context.codecRegistry) @@ -105,7 +102,7 @@ internal class BsonArrayReader( } @LowLevelApi -private class BsonValueReader( +private class JavaBsonValueReader( private val value: BsonValue, private val context: JvmBsonFactory, ) : BsonValueReader { @@ -270,13 +267,13 @@ private class BsonValueReader( @LowLevelApi override fun readDocument(): BsonDocumentReader { ensureType(BsonType.Document) { value.isDocument } - return BsonDocumentReader(value.asDocument(), context) + return JavaBsonDocumentReader(value.asDocument(), context) } @LowLevelApi override fun readArray(): BsonArrayReader { ensureType(BsonType.Array) { value.isArray } - return BsonArrayReader(value.asArray(), context) + return JavaBsonArrayReader(value.asArray(), context) } override fun read(type: KType, klass: KClass): T? = -- GitLab From b9ccf073f49dac2eaf96e038cf4d334c3920c433 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 1 Nov 2025 18:37:24 +0100 Subject: [PATCH 06/12] feat(bson): Remove opt-in requirement for ExperimentalStdlibApi because the hexadecimal APIs have been stabilized --- bson/src/commonMain/kotlin/types/ObjectId.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bson/src/commonMain/kotlin/types/ObjectId.kt b/bson/src/commonMain/kotlin/types/ObjectId.kt index f97919b..153e8bd 100644 --- a/bson/src/commonMain/kotlin/types/ObjectId.kt +++ b/bson/src/commonMain/kotlin/types/ObjectId.kt @@ -120,7 +120,6 @@ class ObjectId : Comparable { * * To access the hexadecimal representation of an existing ObjectId, see [ObjectId.hex]. */ - @OptIn(ExperimentalStdlibApi::class) constructor(hex: String) : this(hexToBytes(hex)) /** @@ -161,10 +160,8 @@ class ObjectId : Comparable { * * The output string can be passed to [ObjectId] constructor to obtain a new identical [ObjectId] instance. */ - @OptIn(ExperimentalStdlibApi::class) val hex: String by lazy(LazyThreadSafetyMode.PUBLICATION) { bytes.toHexString(HexFormat.Default) } - @OptIn(ExperimentalStdlibApi::class) override fun toString(): String = "ObjectId($hex)" @@ -231,7 +228,6 @@ class ObjectId : Comparable { */ val MAX = ObjectId("FFFFFFFFFFFFFFFFFFFFFFFF") - @OptIn(ExperimentalStdlibApi::class) private fun hexToBytes(hex: String): ByteArray { require(hex.length == 24) { "An ObjectId must be 24-characters long, found ${hex.length} characters: '$hex'" } return hex.hexToByteArray() -- GitLab From d592f895ffbf100d2e4f89324200f2e4a41272f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 12 Nov 2025 20:20:23 +0100 Subject: [PATCH 07/12] build(bson): Enable the KotlinX.Serialization plugin --- bson/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/bson/build.gradle.kts b/bson/build.gradle.kts index f925db8..b480fe6 100644 --- a/bson/build.gradle.kts +++ b/bson/build.gradle.kts @@ -21,6 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(opensavvyConventions.plugins.base) alias(opensavvyConventions.plugins.kotlin.library) + alias(libsCommon.plugins.kotlinx.serialization) alias(libsCommon.plugins.testBalloon) } -- GitLab From 2491bf3e23ecb32008e1f4539885f64cefd7856c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Wed, 12 Nov 2025 20:49:23 +0100 Subject: [PATCH 08/12] feat(bson-official): Hardcode strategies to deal with collections --- .../src/jvmMain/kotlin/BsonReader.jvm.kt | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt index a691f35..dd57e3d 100644 --- a/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonReader.jvm.kt @@ -84,8 +84,51 @@ internal class JavaBsonArrayReader( override fun asValue(): BsonValueReader = JavaBsonValueReader(raw, context) - override fun read(type: KType, klass: KClass): T? = - decodeValue(raw, klass, context.codecRegistry) + override fun read(type: KType, klass: KClass): T? { + // Special-case Kotlin collections/arrays so we can preserve generic element type information + // (the Java codec erases generics and would otherwise decode subdocuments as org.bson.Document). + val typeArg = type.arguments.firstOrNull()?.type + val elementKClass = typeArg?.classifier as? KClass<*> + val elementIsNullable = typeArg?.isMarkedNullable == true + + fun decodeElement(element: BsonValue): Any? { + if (element.isNull) { + if (elementIsNullable) return null + else throw BsonReaderException("Cannot decode null element for non-nullable element type in $type") + } + + return decodeValue(element, elementKClass ?: (klass as KClass<*>), context.codecRegistry) + } + + @Suppress("UNCHECKED_CAST") + return when { + klass == List::class || klass == MutableList::class || klass == Collection::class || klass == MutableCollection::class -> { + val out = ArrayList() + for (v in raw) out.add(decodeElement(v)) + out as T + } + + klass == Set::class || klass == MutableSet::class -> { + val out = LinkedHashSet() + for (v in raw) out.add(decodeElement(v)) + out as T + } + + klass.java.isArray && typeArg != null && elementKClass != null && !klass.java.componentType.isPrimitive -> { + val size = raw.size + val componentClass = elementKClass.java + val arrayObj = java.lang.reflect.Array.newInstance(componentClass, size) + for (i in 0 until size) { + val v = raw[i] + val decoded = decodeElement(v) + java.lang.reflect.Array.set(arrayObj, i, decoded) + } + arrayObj as T + } + + else -> decodeValue(raw, klass, context.codecRegistry) + } + } override fun toString(): String { // Yes, this is very ugly, and probably inefficient. @@ -276,8 +319,20 @@ private class JavaBsonValueReader( return JavaBsonArrayReader(value.asArray(), context) } - override fun read(type: KType, klass: KClass): T? = - decodeValue(value, klass, context.codecRegistry) + override fun read(type: KType, klass: KClass): T? { + // If the underlying BSON value is null, allow returning null for nullable targets + if (value.isNull) { + return if (type.isMarkedNullable) null else decodeValue(value, klass, context.codecRegistry) + } + + // If the value is an array but a parameterized Kotlin collection/array is requested, + // delegate to the array-aware reader to preserve generic element type information. + if (value.isArray) { + return JavaBsonArrayReader(value.asArray(), context).read(type, klass) + } + + return decodeValue(value, klass, context.codecRegistry) + } override fun toString(): String = value.toString() -- GitLab From 88f6947e7d2aba23bac7b515b251e94325a63874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 28 Nov 2025 19:47:28 +0100 Subject: [PATCH 09/12] refactor(bson): Make ObjectId and Timestamp serializers objects instead of classes KotlinX.Serialization cannot find serializers on JS and WASM if they are classes, see https://github.com/Kotlin/kotlinx.serialization/issues/2382. --- .../commonMain/kotlin/serialization/MultiplatformDecoder.kt | 4 ++-- .../commonMain/kotlin/serialization/MultiplatformEncoder.kt | 4 ++-- bson/src/commonMain/kotlin/types/ObjectId.kt | 2 +- bson/src/commonMain/kotlin/types/Timestamp.kt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt index e175427..a499b41 100644 --- a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt +++ b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt @@ -144,8 +144,8 @@ internal class BsonDecoder( } private val bytes = ByteArraySerializer().descriptor - private val objectId = ObjectId.Serializer().descriptor - private val timestamp = Timestamp.Serializer().descriptor + private val objectId = ObjectId.Serializer.descriptor + private val timestamp = Timestamp.Serializer.descriptor private val uuid = Uuid.serializer().descriptor private val instant = Instant.serializer().descriptor override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { diff --git a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformEncoder.kt b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformEncoder.kt index c07c922..d033184 100644 --- a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformEncoder.kt +++ b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformEncoder.kt @@ -134,8 +134,8 @@ private class BsonEncoder(override val serializersModule: SerializersModule, val } private val bytes = ByteArraySerializer().descriptor - private val objectId = ObjectId.Serializer().descriptor - private val timestamp = Timestamp.Serializer().descriptor + private val objectId = ObjectId.Serializer.descriptor + private val timestamp = Timestamp.Serializer.descriptor private val uuid = Uuid.serializer().descriptor private val instant = Instant.serializer().descriptor override fun encodeSerializableValue(serializer: SerializationStrategy, value: T) { diff --git a/bson/src/commonMain/kotlin/types/ObjectId.kt b/bson/src/commonMain/kotlin/types/ObjectId.kt index 153e8bd..4e43de7 100644 --- a/bson/src/commonMain/kotlin/types/ObjectId.kt +++ b/bson/src/commonMain/kotlin/types/ObjectId.kt @@ -279,7 +279,7 @@ class ObjectId : Comparable { */ @ExperimentalTime @LowLevelApi - class Serializer : KSerializer { + object Serializer : KSerializer { override val descriptor: SerialDescriptor get() = PrimitiveSerialDescriptor("opensavvy.ktmongo.bson.types.ObjectId", PrimitiveKind.STRING) diff --git a/bson/src/commonMain/kotlin/types/Timestamp.kt b/bson/src/commonMain/kotlin/types/Timestamp.kt index c3c2052..f044409 100644 --- a/bson/src/commonMain/kotlin/types/Timestamp.kt +++ b/bson/src/commonMain/kotlin/types/Timestamp.kt @@ -130,7 +130,7 @@ class Timestamp( */ @OptIn(ExperimentalTime::class) @LowLevelApi - class Serializer : KSerializer { + object Serializer : KSerializer { override val descriptor: SerialDescriptor get() = PrimitiveSerialDescriptor("opensavvy.ktmongo.bson.types.Timestamp", PrimitiveKind.STRING) -- GitLab From 39f931950eb33f138e3ae560a13e88ac17f3d36b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 28 Nov 2025 20:34:14 +0100 Subject: [PATCH 10/12] feat(bson): Add 'Bson.at()' and improve the documentation of BsonPath --- .../kotlin/path/BsonPathSerializationTest.kt | 13 ++ bson/src/commonMain/kotlin/BsonPath.kt | 112 +++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt b/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt index 828bb04..7422530 100644 --- a/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt +++ b/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt @@ -23,6 +23,7 @@ import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.prepared.suite.Prepared import opensavvy.prepared.suite.SuiteDsl +import opensavvy.prepared.suite.assertions.checkThrows import opensavvy.prepared.suite.prepared import kotlin.time.ExperimentalTime @@ -75,6 +76,18 @@ fun SuiteDsl.bsonPathTests( check(bobDoc().select(BsonPath["_id"]).firstOrNull() == ObjectId("68f93c04a7b4c7c3fc6bff38")) } + test("Select a simple field, with the 'at' syntax") { + val id: ObjectId = bobDoc() at BsonPath["_id"] + check(id == ObjectId("68f93c04a7b4c7c3fc6bff38")) + } + + @Suppress("UNUSED", "UnusedVariable") + test("Select a simple field that doesn't exist") { + checkThrows { + val age: String = bobDoc() at BsonPath["age"] + } + } + test("Select a nested field") { check(bobDoc().select(BsonPath["profile"]["name"]).firstOrNull() == "Bob") } diff --git a/bson/src/commonMain/kotlin/BsonPath.kt b/bson/src/commonMain/kotlin/BsonPath.kt index f4e1618..e97a49f 100644 --- a/bson/src/commonMain/kotlin/BsonPath.kt +++ b/bson/src/commonMain/kotlin/BsonPath.kt @@ -26,13 +26,49 @@ annotation class ExperimentalBsonPathApi /** * Access specific fields in arbitrary BSON documents using a [JSONPath](https://www.rfc-editor.org/rfc/rfc9535.html)-like API. * - * ### Example + * To access fields of a [BSON document][Bson], use [select] or [at]. + * + * ### Why BSON paths? + * + * Most of the time, users want to deserialize documents, which they can do with [opensavvy.ktmongo.bson.read]. + * + * However, sometimes, we receive large BSON payloads but only care about a few fields (for example, an explain plan). + * Writing an entire DTO for such payloads is time-consuming and complex. + * + * Deserializing only a few specific fields can be much faster than deserializing the entire payload, as BSON is designed + * to allow skipping unwanted fields. + * + * We may also face a payload that is too dynamic to easily deserialize, or with so much nesting that accessing fields becomes boilerplate. + * + * In these situations, it may be easier (and often, more performant) to only deserialize a few specific fields, + * which is what [BsonPath] is useful for. + * + * ### Syntax * * ```kotlin * BsonPath["foo"] // Refer to the field 'foo': $.foo * BsonPath[0] // Refer to the item at index 0: $[0] * BsonPath["foo"][0] // Refer to the item at index 0 in the array named 'foo': $.foo[0] * ``` + * + * ### Accessing data + * + * Find the first value for a given BSON path using [at]: + * + * ```kotlin + * val document: Bson = … + * + * val name = document at BsonPath["profile"]["name"] + * ``` + * + * Find all values for a given BSON path using [select]: + * + * ```kotlin + * val document: Bson = … + * + * document.select(BsonPath["profile"]) + * .forEach { println("Found $it") } + * ``` */ @ExperimentalBsonPathApi sealed interface BsonPath { @@ -113,6 +149,14 @@ sealed interface BsonPath { override fun toString() = "$parent[$index]" } + /** + * The root of a [BsonPath] expression. + * + * All BSON paths start at the root. + * For example, `BsonPath["foo"]` refers to the field `"foo"`. + * + * For more information, see [BsonPath]. + */ companion object Root : BsonPath { @LowLevelApi override fun findIn(reader: BsonValueReader): Sequence = @@ -134,7 +178,8 @@ sealed interface BsonPath { * ``` * will return the value of the field `foo.bar`. * - * To learn more about BSON paths, see [BsonPath]. + * @see BsonPath Learn more about BSON paths. + * @see selectFirst If you're only interested about a single element. See also [at]. */ @OptIn(LowLevelApi::class) @ExperimentalBsonPathApi @@ -154,3 +199,66 @@ inline fun Bson.select(path: BsonPath): Sequence { } } } + +/** + * Finds the first value that matches [path] in a given [BSON document][Bson]. + * + * ### Example + * + * ```kotlin + * val document: Bson = … + * + * document.selectFirst(BsonPath["foo"]["bar"]) + * ``` + * will return the value of the field `foo.bar`. + * + * ### Alternatives + * + * Depending on your situation, you can also use the equivalent function [at]: + * ```kotlin + * val document: Bson = … + * + * val bar: String = document at BsonPath["foo"]["bar"] + * ``` + * + * @see BsonPath Learn more about BSON paths. + * @see select Select multiple values with a BSON path. + * @throws NoSuchElementException If no element is found matching the path. + */ +@ExperimentalBsonPathApi +inline fun Bson.selectFirst(path: BsonPath): T { + val iter = select(path).iterator() + + if (iter.hasNext()) { + return iter.next() + } else { + throw NoSuchElementException("Could not find any value at path $path for document $this") + } +} + +/** + * Finds the first value that matches [path] in a given [BSON document][Bson]. + * + * ### Example + * + * ```kotlin + * val document: Bson = … + * + * val bar: String = document at BsonPath["foo"]["bar"] + * ``` + * will return the value of the field `foo.bar`. + * + * Depending on your situation, you can also use the equivalent function [selectFirst]: + * ```kotlin + * val document: Bson = … + * + * document.selectFirst(BsonPath["foo"]["bar"]) + * ``` + * + * @see BsonPath Learn more about BSON paths. + * @see select Select multiple values with a BSON path. + * @throws NoSuchElementException If no element is found matching the path. + */ +@ExperimentalBsonPathApi +inline infix fun Bson.at(path: BsonPath): T = + selectFirst(path) -- GitLab From 3b27fdbf7c0edae049db7e76c6350b480212bd7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Fri, 28 Nov 2025 23:59:40 +0100 Subject: [PATCH 11/12] feat(bson): Add BsonPath.parse --- bson/build.gradle.kts | 1 + bson/src/commonMain/kotlin/BsonPath.kt | 156 +++++++++++++++++++++ bson/src/commonTest/kotlin/BsonPathTest.kt | 31 ++++ 3 files changed, 188 insertions(+) diff --git a/bson/build.gradle.kts b/bson/build.gradle.kts index b480fe6..810a16e 100644 --- a/bson/build.gradle.kts +++ b/bson/build.gradle.kts @@ -57,6 +57,7 @@ kotlin { sourceSets.commonMain.dependencies { api(projects.annotations) implementation(libs.kotlinx.serialization) + implementation(libsCommon.jetbrains.annotations) } sourceSets.commonTest.dependencies { diff --git a/bson/src/commonMain/kotlin/BsonPath.kt b/bson/src/commonMain/kotlin/BsonPath.kt index e97a49f..0e673e9 100644 --- a/bson/src/commonMain/kotlin/BsonPath.kt +++ b/bson/src/commonMain/kotlin/BsonPath.kt @@ -17,6 +17,7 @@ package opensavvy.ktmongo.bson import opensavvy.ktmongo.dsl.LowLevelApi +import org.intellij.lang.annotations.Language import kotlin.reflect.KClass import kotlin.reflect.typeOf @@ -163,6 +164,161 @@ sealed interface BsonPath { sequenceOf(reader) override fun toString() = "$" + + /** + * Parses an [RFC-9535 compliant](https://www.rfc-editor.org/rfc/rfc9535.html) string expression into a [BsonPath] instance. + * + * This function is the mirror of the [BsonPath.toString] methods. + * + * **Warning.** Not everything from the RFC is implemented at the moment. + * As a rule of thumb, if [text] can be returned by the [BsonPath.toString] function of a segment, + * then it can be parsed by this function. + * + * | Syntax | Description | + * | --------------------- | ------------------------------------------------------------ | + * | `$` | The root identifier. See [BsonPath.Root]. | + * | `['foo']` or `.foo` | Accessor for a field named `foo`. See [BsonPath.get]. | + * | `[0]` | Accessor for the first item of an array. See [BsonPath.get]. | + * + * ### Examples + * + * ```kotlin + * val document: Bson = … + * + * val id: Uuid = document at BsonPath.parse("$.profile.id") + * + * for (user in document.select("$.friends")) { + * println("User: $user") + * } + * ``` + * + * @see BsonPath Learn more about BSON paths. + * @see at Access one field by its BSON path. + * @see select Access multiple fields by their BSON path. + */ + fun parse(@Language("JSONPath") text: String): BsonPath { + require(text.startsWith("$")) { "A BsonPath expression must start with a dollar sign: $text\nDid you mean to create a BsonPath to access a specific field? If so, see BsonPath[\"foo\"]" } + + var expr: BsonPath = BsonPath + + for (segment in splitSegments(text)) { + expr = when { + // .foo + segment.startsWith(".") -> + expr[segment.removePrefix(".")] + + // ['foo'] + segment.startsWith("['") && segment.endsWith("']") -> + expr[segment.removePrefix("['").removeSuffix("']")] + + // ["foo"] + segment.startsWith("[\"") && segment.endsWith("\"]") -> + expr[segment.removePrefix("[\"").removeSuffix("\"]")] + + // [0] + segment.startsWith("[") && segment.endsWith("]") -> + expr[segment.removePrefix("[").removeSuffix("]").toInt()] + + else -> throw IllegalArgumentException("Could not parse the segment “$segment” in BsonPath expression “$text”.") + } + } + + return expr + } + + private fun splitSegments(text: String): Sequence = sequence { + val accumulator = StringBuilder() + var i = 1 // skip the $ sign + + // Helper for nicer error messages + fun fail(msg: String, cause: Throwable? = null): Nothing { + val excerpt = + if (i + 5 > text.length) text.substring(i, text.length) + else text.substring(i, i + 5) + "…" + + throw IllegalArgumentException("Could not parse the BSON path expression “$text” at index $i (“${excerpt}”): $msg", cause) + } + + fun accumulate() { + accumulator.append(text[i]) + i++ + } + + fun accumulateWhile(predicate: (Char) -> Boolean) { + while (i < text.length && predicate(text[i])) { + accumulate() + } + } + + try { + while (i < text.length) { + val c = text[i] + + when (c) { + '.' if accumulator.isEmpty() -> { + // .foo + accumulate() + + if (text[i].isNameFirst()) { + accumulateWhile { it.isNameChar() } + } else { + fail("A name segment should start with a non-digit character, found: ${text[i]}") + } + + yield(accumulator.toString()) + accumulator.clear() + } + + '[' if accumulator.isEmpty() -> { + val c2 = text[i + 1] + + when (c2) { + '\'', '"' -> { + accumulate() // opening bracket + accumulate() // opening quote + accumulateWhile { it != '\'' && it != '"' } + accumulate() // closing quote + accumulate() // closing bracket + yield(accumulator.toString()) + accumulator.clear() + } + + in Char(0x30)..Char(0x39) -> { + accumulate() // opening bracket + accumulateWhile { it.isDigit() } + accumulate() // closing bracket + yield(accumulator.toString()) + accumulator.clear() + } + + else -> fail("Unrecognized selector") + } + } + + else -> { + fail("Unrecognized syntax") + } + } + } + } catch (e: Exception) { + if (e is IllegalArgumentException) + throw e + else + fail("An exception was thrown: ${e.message}", e) + } + } + + private inline fun Char.isAlpha() = + this.code in 0x41..0x51 || this.code in 0x61..0x7A + + private inline fun Char.isDigit() = + this.code in 0x30..0x39 + + private inline fun Char.isNameFirst() = + isAlpha() || this == '_' || this.code in 0x80..0xD7FF || this.code in 0xE000..0x10FFFF + + private inline fun Char.isNameChar() = + isNameFirst() || isDigit() } } diff --git a/bson/src/commonTest/kotlin/BsonPathTest.kt b/bson/src/commonTest/kotlin/BsonPathTest.kt index 4a1f081..8e4a1dc 100644 --- a/bson/src/commonTest/kotlin/BsonPathTest.kt +++ b/bson/src/commonTest/kotlin/BsonPathTest.kt @@ -19,6 +19,7 @@ package opensavvy.ktmongo.bson import opensavvy.prepared.runner.testballoon.preparedSuite +import opensavvy.prepared.suite.assertions.checkThrows val BsonPathTest by preparedSuite { @@ -46,4 +47,34 @@ val BsonPathTest by preparedSuite { } } + suite("Parsing") { + + test("A BsonPath expression must start with a $") { + checkThrows { + BsonPath.parse("foo") + } + } + + test("Parse a simple field with dot notation") { + check(BsonPath.parse("$.book").toString() == "$.book") + } + + test("Parse a simple field with bracket notation") { + check(BsonPath.parse("$['book']").toString() == "$.book") + } + + test("Parse a simple field with bracket notation and double quotes") { + check(BsonPath.parse("$[\"book\"]").toString() == "$.book") + } + + test("Parse a combined field names") { + check(BsonPath.parse("$.foo['bar'][\"baz\"]").toString() == "$.foo.bar.baz") + } + + test("Parse a simple array index") { + check(BsonPath.parse("$[0]").toString() == "$[0]") + } + + } + } -- GitLab From 2b9583f2fd670f5c75e98569e787489d2f954599 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20=E2=80=9CCLOVIS=E2=80=9D=20Canet?= Date: Sat, 13 Dec 2025 19:03:18 +0100 Subject: [PATCH 12/12] refactor(bson-multiplatform): Pass the BsonFactory through all objects --- .../src/commonMain/kotlin/Bson.kt | 6 +++-- .../src/commonMain/kotlin/BsonFactory.kt | 12 +++++----- .../impl/read/MultiplatformArrayReader.kt | 10 +++++---- .../impl/read/MultiplatformBsonValueReader.kt | 6 +++-- .../impl/read/MultiplatformDocumentReader.kt | 11 ++++++---- .../serialization/MultiplatformDecoder.kt | 22 ++++++------------- .../kotlin/path/BsonPathSerializationTest.kt | 2 +- 7 files changed, 35 insertions(+), 34 deletions(-) diff --git a/bson-multiplatform/src/commonMain/kotlin/Bson.kt b/bson-multiplatform/src/commonMain/kotlin/Bson.kt index 798bc55..8f71245 100644 --- a/bson-multiplatform/src/commonMain/kotlin/Bson.kt +++ b/bson-multiplatform/src/commonMain/kotlin/Bson.kt @@ -25,6 +25,7 @@ import opensavvy.ktmongo.bson.multiplatform.impl.read.MultiplatformDocumentReade import opensavvy.ktmongo.dsl.LowLevelApi class Bson internal constructor( + private val factory: BsonFactory, private val data: Bytes, ) : Bson { @@ -33,7 +34,7 @@ class Bson internal constructor( @LowLevelApi override fun reader(): BsonDocumentReader = - MultiplatformDocumentReader(data) + MultiplatformDocumentReader(factory, data) @OptIn(LowLevelApi::class) override fun toString(): String = @@ -41,6 +42,7 @@ class Bson internal constructor( } class BsonArray internal constructor( + private val factory: BsonFactory, private val data: Bytes, ) : BsonArray { @@ -49,7 +51,7 @@ class BsonArray internal constructor( @LowLevelApi override fun reader(): BsonArrayReader = - MultiplatformArrayReader(data) + MultiplatformArrayReader(factory, data) @OptIn(LowLevelApi::class) override fun toString(): String = diff --git a/bson-multiplatform/src/commonMain/kotlin/BsonFactory.kt b/bson-multiplatform/src/commonMain/kotlin/BsonFactory.kt index 6a832fb..08403e3 100644 --- a/bson-multiplatform/src/commonMain/kotlin/BsonFactory.kt +++ b/bson-multiplatform/src/commonMain/kotlin/BsonFactory.kt @@ -80,7 +80,7 @@ class BsonFactory : BsonFactory { override fun buildDocument(block: BsonFieldWriter.() -> Unit): Bson = buildArbitraryTopLevel { block(this) - }.let(::Bson) + }.let { Bson(this, it) } @OptIn(ExperimentalSerializationApi::class) @LowLevelApi @@ -95,19 +95,19 @@ class BsonFactory : BsonFactory { return object : TopCompletableBsonFieldWriter, CompletableBsonFieldWriter by MultiplatformDocumentFieldWriter(bsonWriter) { override fun build(): Bson = - Bson(closeArbitraryTopLevel(buffer, bsonWriter)) + Bson(this@BsonFactory, closeArbitraryTopLevel(buffer, bsonWriter)) } } @LowLevelApi override fun readDocument(bytes: ByteArray): Bson = - Bson(Bytes(bytes.copyOf())) + Bson(this, Bytes(bytes.copyOf())) @LowLevelApi override fun buildArray(block: BsonValueWriter.() -> Unit): BsonArray = buildArbitraryTopLevel { block(MultiplatformArrayFieldWriter(this)) - }.let(::BsonArray) + }.let { BsonArray(this, it) } @LowLevelApi @DangerousMongoApi @@ -117,13 +117,13 @@ class BsonFactory : BsonFactory { return object : TopCompletableBsonValueWriter, CompletableBsonValueWriter by MultiplatformArrayFieldWriter(MultiplatformDocumentFieldWriter(bsonWriter)) { override fun build(): BsonArray = - BsonArray(closeArbitraryTopLevel(buffer, bsonWriter)) + BsonArray(this@BsonFactory, closeArbitraryTopLevel(buffer, bsonWriter)) } } @LowLevelApi override fun readArray(bytes: ByteArray): BsonArray = - BsonArray(Bytes(bytes.copyOf())) + BsonArray(this, Bytes(bytes.copyOf())) @LowLevelApi @DangerousMongoApi diff --git a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformArrayReader.kt b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformArrayReader.kt index 00c1024..d9524c8 100644 --- a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformArrayReader.kt +++ b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformArrayReader.kt @@ -24,6 +24,7 @@ import opensavvy.ktmongo.bson.BsonArrayReader import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.BsonValueReader import opensavvy.ktmongo.bson.multiplatform.BsonArray +import opensavvy.ktmongo.bson.multiplatform.BsonFactory import opensavvy.ktmongo.bson.multiplatform.Bytes import opensavvy.ktmongo.bson.multiplatform.serialization.BsonDecoderTopLevel import opensavvy.ktmongo.dsl.LowLevelApi @@ -32,6 +33,7 @@ import kotlin.reflect.KType @LowLevelApi internal class MultiplatformArrayReader( + private val factory: BsonFactory, private val bytesWithHeader: Bytes, ) : BsonArrayReader { @@ -46,7 +48,7 @@ internal class MultiplatformArrayReader( println("Left to read: $reader") // TODO remove val type = BsonType.fromCode(reader.readSignedByte()) reader.skipCString() // We ignore the field name - val field = readField(bytes, reader, "${fields.lastIndex + 1}", type) + val field = readField(bytes, reader, "${fields.lastIndex + 1}", type, factory) fields += field @@ -72,14 +74,14 @@ internal class MultiplatformArrayReader( } override fun toBson(): BsonArray = - BsonArray(bytesWithHeader) + BsonArray(factory, bytesWithHeader) override fun asValue(): BsonValueReader = - MultiplatformBsonValueReader(BsonType.Array, bytesWithHeader) + MultiplatformBsonValueReader(factory, BsonType.Array, bytesWithHeader) @OptIn(ExperimentalSerializationApi::class) override fun read(type: KType, klass: KClass): T? { - val decoder = BsonDecoderTopLevel(EmptySerializersModule(), bytesWithHeader) + val decoder = BsonDecoderTopLevel(EmptySerializersModule(), factory, bytesWithHeader) return decoder.decodeSerializableValue(serializer(type) as KSerializer) } diff --git a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonValueReader.kt b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonValueReader.kt index d6f14db..74b5d25 100644 --- a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonValueReader.kt +++ b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformBsonValueReader.kt @@ -20,6 +20,7 @@ import kotlinx.serialization.KSerializer import kotlinx.serialization.modules.EmptySerializersModule import kotlinx.serialization.serializer import opensavvy.ktmongo.bson.* +import opensavvy.ktmongo.bson.multiplatform.BsonFactory import opensavvy.ktmongo.bson.multiplatform.Bytes import opensavvy.ktmongo.bson.multiplatform.RawBsonWriter import opensavvy.ktmongo.bson.multiplatform.serialization.BsonDecoder @@ -40,6 +41,7 @@ import kotlin.time.Instant @LowLevelApi internal class MultiplatformBsonValueReader( + private val factory: BsonFactory, override val type: BsonType, private val bytes: Bytes, ) : BsonValueReader { @@ -205,13 +207,13 @@ internal class MultiplatformBsonValueReader( @LowLevelApi override fun readDocument(): BsonDocumentReader { checkType(BsonType.Document) - return MultiplatformDocumentReader(bytes) + return MultiplatformDocumentReader(factory, bytes) } @LowLevelApi override fun readArray(): BsonArrayReader { checkType(BsonType.Array) - return MultiplatformArrayReader(bytes) + return MultiplatformArrayReader(factory, bytes) } @OptIn(DangerousMongoApi::class) diff --git a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformDocumentReader.kt b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformDocumentReader.kt index 9ad7adc..6b3900b 100644 --- a/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformDocumentReader.kt +++ b/bson-multiplatform/src/commonMain/kotlin/impl/read/MultiplatformDocumentReader.kt @@ -24,6 +24,7 @@ import opensavvy.ktmongo.bson.BsonDocumentReader import opensavvy.ktmongo.bson.BsonType import opensavvy.ktmongo.bson.BsonValueReader import opensavvy.ktmongo.bson.multiplatform.Bson +import opensavvy.ktmongo.bson.multiplatform.BsonFactory import opensavvy.ktmongo.bson.multiplatform.Bytes import opensavvy.ktmongo.bson.multiplatform.RawBsonReader import opensavvy.ktmongo.bson.multiplatform.serialization.BsonDecoder @@ -44,6 +45,7 @@ internal fun readField( reader: RawBsonReader, name: String, type: BsonType, + factory: BsonFactory, ): MultiplatformBsonValueReader { val fieldStart = reader.readCount @@ -93,11 +95,12 @@ internal fun readField( println("Found field '$name' in range $fieldRange: $fieldBytes") - return MultiplatformBsonValueReader(type, fieldBytes) + return MultiplatformBsonValueReader(factory, type, fieldBytes) } @LowLevelApi internal class MultiplatformDocumentReader( + private val factory: BsonFactory, private val bytesWithHeader: Bytes, ) : BsonDocumentReader { @@ -116,7 +119,7 @@ internal class MultiplatformDocumentReader( println("Left to read: $reader") val type = BsonType.fromCode(reader.readSignedByte()) val name = reader.readCString() - val field = readField(bytes, reader, name, type) + val field = readField(bytes, reader, name, type, factory) fields[name] = field @@ -141,10 +144,10 @@ internal class MultiplatformDocumentReader( } override fun toBson(): Bson = - Bson(bytesWithHeader) + Bson(factory, bytesWithHeader) override fun asValue(): BsonValueReader = - MultiplatformBsonValueReader(BsonType.Document, bytesWithHeader) + MultiplatformBsonValueReader(factory, BsonType.Document, bytesWithHeader) @Suppress("UNCHECKED_CAST") override fun read(type: KType, klass: KClass): T? { diff --git a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt index a499b41..d779315 100644 --- a/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt +++ b/bson-multiplatform/src/commonMain/kotlin/serialization/MultiplatformDecoder.kt @@ -47,7 +47,7 @@ import kotlin.uuid.Uuid @ExperimentalSerializationApi internal class BsonDecoderTopLevel( override val serializersModule: SerializersModule, - val context: BsonFactory, + private val factory: BsonFactory, val bytesWithHeader: Bytes, ) : AbstractDecoder() { var out: Any? = null @@ -63,8 +63,8 @@ internal class BsonDecoderTopLevel( @LowLevelApi override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { return when (descriptor.kind) { - StructureKind.CLASS, StructureKind.OBJECT -> BsonCompositeDecoder(serializersModule, MultiplatformDocumentReader(bytesWithHeader)) - StructureKind.LIST -> BsonCompositeListDecoder(serializersModule, MultiplatformArrayReader(bytesWithHeader)) + StructureKind.CLASS, StructureKind.OBJECT -> BsonCompositeDecoder(serializersModule, MultiplatformDocumentReader(factory, bytesWithHeader)) + StructureKind.LIST -> BsonCompositeListDecoder(serializersModule, MultiplatformArrayReader(factory, bytesWithHeader)) else -> TODO() } } @@ -303,19 +303,11 @@ internal class BsonCompositeListDecoder( } @ExperimentalSerializationApi -fun decodeFromBson(context: BsonFactory, bytes: ByteArray, deserializer: DeserializationStrategy): T { - val decoder = BsonDecoderTopLevel(EmptySerializersModule(), context, Bytes(bytes.copyOf())) +fun decodeFromBson(factory: BsonFactory, bytes: ByteArray, deserializer: DeserializationStrategy): T { + val decoder = BsonDecoderTopLevel(EmptySerializersModule(), factory, Bytes(bytes.copyOf())) return decoder.decodeSerializableValue(deserializer) } @ExperimentalSerializationApi -inline fun decodeFromBson(bytes: ByteArray): T = - decodeFromBson(bytes, serializer()) - -@ExperimentalSerializationApi -fun decodeFromBson(context: BsonContext, bytes: ByteArray, deserializer: DeserializationStrategy): T = - decodeFromBson(bytes, deserializer) - -@ExperimentalSerializationApi -inline fun decodeFromBson(context: BsonFactory, bytes: ByteArray): T = - decodeFromBson(bytes, serializer()) +inline fun decodeFromBson(factory: BsonFactory, bytes: ByteArray): T = + decodeFromBson(factory, bytes, serializer()) diff --git a/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt b/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt index 7422530..9eedac5 100644 --- a/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt +++ b/bson-tests/src/commonMain/kotlin/path/BsonPathSerializationTest.kt @@ -55,7 +55,7 @@ data class User( ) fun SuiteDsl.bsonPathTests( - context: Prepared, + context: Prepared, ) = suite("BsonPath") { val bob = User( -- GitLab