diff --git a/bson-official/build.gradle.kts b/bson-official/build.gradle.kts index 0ba9d684..27f996fa 100644 --- a/bson-official/build.gradle.kts +++ b/bson-official/build.gradle.kts @@ -28,10 +28,12 @@ kotlin { sourceSets.commonMain.dependencies { api(projects.bson) + implementation(libs.kotlinx.serialization) } sourceSets.jvmMain.dependencies { api(libs.mongodb.bson.jvm) + compileOnly(libs.mongodb.kotlinx.serialization) } sourceSets.commonTest.dependencies { @@ -45,7 +47,6 @@ kotlin { implementation(libs.mongodb.sync.jvm) implementation(libs.mongodb.kotlinx.serialization) implementation(libs.mongodb.kotlin.reflection) - implementation(libs.kotlinx.serialization) } } diff --git a/bson-official/src/commonMain/kotlin/Bson.kt b/bson-official/src/commonMain/kotlin/Bson.kt index 407e66d1..6c729951 100644 --- a/bson-official/src/commonMain/kotlin/Bson.kt +++ b/bson-official/src/commonMain/kotlin/Bson.kt @@ -16,6 +16,8 @@ package opensavvy.ktmongo.bson.official +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable import opensavvy.ktmongo.bson.BsonArray import opensavvy.ktmongo.bson.BsonDocument import opensavvy.ktmongo.bson.BsonFactory @@ -49,6 +51,7 @@ import kotlin.reflect.typeOf * } * ``` */ +@Serializable(with = opensavvy.ktmongo.bson.official.BsonDocument.Serializer::class) expect class BsonDocument : BsonDocument { override val factory: opensavvy.ktmongo.bson.official.BsonFactory @@ -74,6 +77,15 @@ expect class BsonDocument : BsonDocument { override fun component2(): opensavvy.ktmongo.bson.official.BsonValue } + + /** + * Serializer for the official [opensavvy.ktmongo.bson.official.BsonDocument] implementation. + * + * This serializer only supports the official MongoDB library `org.bson:bson-kotlinx`. + * + * If support for other formats is important to you, please [comment on the tracking issue](https://gitlab.com/opensavvy/ktmongo/-/work_items/122). + */ + object Serializer : KSerializer } /** @@ -102,6 +114,7 @@ expect class BsonDocument : BsonDocument { * } * ``` */ +@Serializable(with = opensavvy.ktmongo.bson.official.BsonArray.Serializer::class) expect class BsonArray : BsonArray { override val factory: opensavvy.ktmongo.bson.official.BsonFactory @@ -119,6 +132,15 @@ expect class BsonArray : BsonArray { override fun asSequence(): Sequence override fun withIndex(): Iterable> + + /** + * Serializer for the official [opensavvy.ktmongo.bson.official.BsonArray] implementation. + * + * This serializer only supports the official MongoDB library `org.bson:bson-kotlinx`. + * + * If support for other formats is important to you, please [comment on the tracking issue](https://gitlab.com/opensavvy/ktmongo/-/work_items/122). + */ + object Serializer : KSerializer } /** @@ -151,6 +173,7 @@ expect class BsonArray : BsonArray { * Some BSON types cannot be represented by a single Kotlin type, so multiple methods are provided to decode * their components. For example: [decodeRegularExpressionPattern] and [decodeRegularExpressionOptions]. */ +@Serializable(with = opensavvy.ktmongo.bson.official.BsonValue.Serializer::class) expect class BsonValue : BsonValue { override val factory: opensavvy.ktmongo.bson.official.BsonFactory @@ -158,6 +181,15 @@ expect class BsonValue : BsonValue { override fun decodeDocument(): opensavvy.ktmongo.bson.official.BsonDocument override fun decodeArray(): opensavvy.ktmongo.bson.official.BsonArray + + /** + * Serializer for the official [opensavvy.ktmongo.bson.official.BsonValue] implementation. + * + * This serializer only supports the official MongoDB library `org.bson:bson-kotlinx`. + * + * If support for other formats is important to you, please [comment on the tracking issue](https://gitlab.com/opensavvy/ktmongo/-/work_items/122). + */ + object Serializer : KSerializer } /** diff --git a/bson-official/src/jvmMain/kotlin/BsonArray.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonArray.jvm.kt index 2f76489c..b457eb51 100644 --- a/bson-official/src/jvmMain/kotlin/BsonArray.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonArray.jvm.kt @@ -16,10 +16,21 @@ package opensavvy.ktmongo.bson.official +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import opensavvy.ktmongo.bson.BsonArray import opensavvy.ktmongo.bson.BsonDecodingException +import opensavvy.ktmongo.bson.official.BsonArray.Serializer import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.BsonDocument +import org.bson.codecs.kotlinx.BsonDecoder +import org.bson.codecs.kotlinx.BsonEncoder import kotlin.reflect.KClass import kotlin.reflect.KType import org.bson.BsonArray as OfficialBsonArray @@ -29,6 +40,7 @@ import org.bson.BsonArray as OfficialBsonArray * * To create an instance of this class, see [BsonFactory.readArray]. */ +@Serializable(with = Serializer::class) actual class BsonArray internal constructor( val raw: OfficialBsonArray, actual override val factory: BsonFactory, @@ -239,4 +251,25 @@ actual class BsonArray internal constructor( override fun hashCode(): Int = BsonArray.hashCode(this) + @OptIn(ExperimentalSerializationApi::class) + actual object Serializer : KSerializer { + private const val NAME = "opensavvy.ktmongo.bson.official.BsonArray" + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(NAME, PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: opensavvy.ktmongo.bson.official.BsonArray) { + require(encoder is BsonEncoder) { "${this::class} only supports org.bson:bson-kotlinx. See its documentation for details. Found encoder: $encoder" } + + encoder.encodeBsonValue(value.raw) + } + + @OptIn(LowLevelApi::class) + override fun deserialize(decoder: Decoder): opensavvy.ktmongo.bson.official.BsonArray { + require(decoder is BsonDecoder) { "${this::class} only supports org.bson:bson-kotlinx. See its documentation for details. Found decoder: $decoder" } + + val decoded = decoder.decodeBsonValue() + return BsonArray(decoded as OfficialBsonArray, BsonFactory.current()) + } + } } diff --git a/bson-official/src/jvmMain/kotlin/BsonDocument.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonDocument.jvm.kt index 553892fa..c390b8be 100644 --- a/bson-official/src/jvmMain/kotlin/BsonDocument.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonDocument.jvm.kt @@ -16,12 +16,22 @@ package opensavvy.ktmongo.bson.official +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import opensavvy.ktmongo.bson.BsonDecodingException import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.BsonBinaryWriter import org.bson.codecs.DecoderContext import org.bson.codecs.DocumentCodec import org.bson.codecs.EncoderContext +import org.bson.codecs.kotlinx.BsonDecoder +import org.bson.codecs.kotlinx.BsonEncoder import org.bson.io.BasicOutputBuffer import kotlin.reflect.KType import org.bson.BsonDocument as OfficialBsonDocument @@ -31,6 +41,7 @@ import org.bson.BsonDocument as OfficialBsonDocument * * To create an instance of this class, see [BsonFactory.readDocument]. */ +@Serializable(with = BsonDocument.Serializer::class) actual class BsonDocument internal constructor( val raw: OfficialBsonDocument, actual override val factory: BsonFactory, @@ -45,10 +56,12 @@ actual class BsonDocument internal constructor( val codec = factory.findCodecForType(type) return try { - codec.decode( - raw.asBsonReader(), - DecoderContext.builder().build(), - ) + BsonFactory.setCurrent(factory) { + codec.decode( + raw.asBsonReader(), + DecoderContext.builder().build(), + ) + } } catch (e: Exception) { throw BsonDecodingException("Could not decode $type\n\tfrom value $this\n\tusing $codec", e) } @@ -244,6 +257,28 @@ actual class BsonDocument internal constructor( override fun toString(): String = "($name, $value)" } + + @OptIn(ExperimentalSerializationApi::class) + actual object Serializer : KSerializer { + private const val NAME = "opensavvy.ktmongo.bson.official.BsonDocument" + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(NAME, PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: BsonDocument) { + require(encoder is BsonEncoder) { "${this::class} only supports org.bson:bson-kotlinx. See its documentation for details. Found encoder: $encoder" } + + encoder.encodeBsonValue(value.raw) + } + + @OptIn(LowLevelApi::class) + override fun deserialize(decoder: Decoder): BsonDocument { + require(decoder is BsonDecoder) { "${this::class} only supports org.bson:bson-kotlinx. See its documentation for details. Found decoder: $decoder" } + + val decoded = decoder.decodeBsonValue() + return BsonDocument(decoded as OfficialBsonDocument, BsonFactory.current()) + } + } } // Inspired by https://gist.github.com/Koboo/ebd7c6802101e1a941ef31baca04113d diff --git a/bson-official/src/jvmMain/kotlin/BsonFactory.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonFactory.jvm.kt index 59867d9d..2aa4c5c7 100644 --- a/bson-official/src/jvmMain/kotlin/BsonFactory.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonFactory.jvm.kt @@ -18,6 +18,8 @@ package opensavvy.ktmongo.bson.official import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.bson.BsonFactory +import opensavvy.ktmongo.bson.official.BsonFactory.Companion.current +import opensavvy.ktmongo.bson.official.BsonFactory.Companion.setCurrent import opensavvy.ktmongo.bson.official.types.* import opensavvy.ktmongo.dsl.LowLevelApi import org.bson.BsonBinaryReader @@ -68,6 +70,7 @@ actual class BsonFactory( CodecRegistries.fromCodecs( KotlinBsonDocumentCodec(this), KotlinCommonBsonDocumentCodec(this), + KotlinBsonValueCodec(this), KotlinBsonArrayCodec(this), KotlinObjectIdCodec(), KotlinTimestampCodec(), @@ -103,7 +106,7 @@ actual class BsonFactory( } @LowLevelApi - actual override fun buildDocument(instance: BsonFieldWriteable): BsonDocument = + actual override fun buildDocument(instance: BsonFieldWriteable): BsonDocument = buildDocument { instance.writeTo(this) } @LowLevelApi @@ -234,4 +237,42 @@ actual class BsonFactory( @LowLevelApi inline fun findCodecForType(): Codec = findCodecForType(typeOf()) + + companion object { + private val currentFactory = ThreadLocal() + + /** + * Accesses the currently-set [opensavvy.ktmongo.bson.official.BsonFactory]. + * + * In most situations, no current factory is set, and this method throws [IllegalStateException]. + * + * This method is part of a work-around to deserialize [BsonDocument] & co. + * These types are themselves deserializable, so they need to know which factory they are created with. + * However, KotlinX.Serialization does not pass that information to the deserializer. + * + * Instead, we call [setCurrent] when deserializing, so the deserializer has access to the factory + * at the top of the stacktrace. + */ + @LowLevelApi + internal fun current(): opensavvy.ktmongo.bson.official.BsonFactory = + currentFactory.get() + ?: error("The current BSON factory is not set. This usually means you are deserializing a special type like BsonDocument from a function that is not KtMongo aware. If that's your situation, try deserializing with one of the decode() methods provided by the KtMongo library. If you cannot use these methods, please report your use-case.") + + /** + * Sets [current] to [factory] during the lifetime of [block]. + */ + @LowLevelApi + internal fun setCurrent( + factory: opensavvy.ktmongo.bson.official.BsonFactory, + block: () -> T, + ): T { + currentFactory.set(factory) + try { + return block() + } finally { + currentFactory.remove() + // We don't reset to the value from before the call, because it was 'null' anyway + } + } + } } diff --git a/bson-official/src/jvmMain/kotlin/BsonValue.jvm.kt b/bson-official/src/jvmMain/kotlin/BsonValue.jvm.kt index 35607cb6..b8330e01 100644 --- a/bson-official/src/jvmMain/kotlin/BsonValue.jvm.kt +++ b/bson-official/src/jvmMain/kotlin/BsonValue.jvm.kt @@ -16,9 +16,18 @@ package opensavvy.ktmongo.bson.official +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import opensavvy.ktmongo.bson.* import opensavvy.ktmongo.bson.BsonDocument import opensavvy.ktmongo.bson.BsonValue +import opensavvy.ktmongo.bson.official.BsonValue.Serializer import opensavvy.ktmongo.bson.official.types.toKtMongo import opensavvy.ktmongo.bson.types.ObjectId import opensavvy.ktmongo.bson.types.Timestamp @@ -28,6 +37,8 @@ import org.bson.BsonWriter import org.bson.codecs.Codec import org.bson.codecs.DecoderContext import org.bson.codecs.EncoderContext +import org.bson.codecs.kotlinx.BsonDecoder +import org.bson.codecs.kotlinx.BsonEncoder import java.nio.ByteBuffer import kotlin.reflect.KClass import kotlin.reflect.KType @@ -37,6 +48,7 @@ import kotlin.reflect.KType * * To create an instance of this class, see [BsonFactory.readValue]. */ +@Serializable(with = Serializer::class) actual class BsonValue internal constructor( val raw: org.bson.BsonValue, actual override val factory: BsonFactory, @@ -226,8 +238,31 @@ actual class BsonValue internal constructor( override fun hashCode(): Int { return BsonValue.hashCode(this) } + + @OptIn(ExperimentalSerializationApi::class) + actual object Serializer : KSerializer { + private const val NAME = "opensavvy.ktmongo.bson.official.BsonValue" + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor(NAME, PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: opensavvy.ktmongo.bson.official.BsonValue) { + require(encoder is BsonEncoder) { "${this::class} only supports org.bson:bson-kotlinx. See its documentation for details. Found encoder: $encoder" } + + encoder.encodeBsonValue(value.raw) + } + + @OptIn(LowLevelApi::class) + override fun deserialize(decoder: Decoder): opensavvy.ktmongo.bson.official.BsonValue { + require(decoder is BsonDecoder) { "${this::class} only supports org.bson:bson-kotlinx. See its documentation for details. Found decoder: $decoder" } + + val decoded = decoder.decodeBsonValue() + return BsonValue(decoded, BsonFactory.current()) + } + } } +@OptIn(LowLevelApi::class) internal fun decodeValue( value: org.bson.BsonValue, kClass: KClass, @@ -246,7 +281,9 @@ internal fun decodeValue( try { // 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()) + val decoded = BsonFactory.setCurrent(factory) { + docCodec.decode(documentReader, DecoderContext.builder().build()) + } return decoded.a } catch (e: Exception) { throw BsonDecodingException("Could not decode $kClass\n\tfrom value ${factory.readValue(value)}\n\tusing $valueCodec", e) diff --git a/bson-official/src/jvmMain/kotlin/types/BsonValue.jvm.kt b/bson-official/src/jvmMain/kotlin/types/BsonValue.jvm.kt new file mode 100644 index 00000000..f29eee74 --- /dev/null +++ b/bson-official/src/jvmMain/kotlin/types/BsonValue.jvm.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opensavvy.ktmongo.bson.official.types + +import opensavvy.ktmongo.bson.official.BsonFactory +import opensavvy.ktmongo.bson.official.BsonValue +import org.bson.BsonReader +import org.bson.BsonWriter +import org.bson.codecs.BsonValueCodec +import org.bson.codecs.Codec +import org.bson.codecs.DecoderContext +import org.bson.codecs.EncoderContext + +internal class KotlinBsonValueCodec( + private val factory: BsonFactory, +) : Codec { + private val valueCodec = BsonValueCodec() + + override fun encode(writer: BsonWriter, value: BsonValue, encoderContext: EncoderContext) { + valueCodec.encode(writer, value.raw, encoderContext) + } + + override fun getEncoderClass(): Class = + BsonValue::class.java + + override fun decode(reader: BsonReader, decoderContext: DecoderContext): BsonValue? = + valueCodec.decode(reader, decoderContext)?.let { BsonValue(it, factory) } + +} diff --git a/bson-official/src/jvmTest/kotlin/OfficialBsonFactory.jvm.kt b/bson-official/src/jvmTest/kotlin/OfficialBsonFactory.jvm.kt index 06b98a5b..f407d625 100644 --- a/bson-official/src/jvmTest/kotlin/OfficialBsonFactory.jvm.kt +++ b/bson-official/src/jvmTest/kotlin/OfficialBsonFactory.jvm.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025, OpenSavvy and contributors. + * Copyright (c) 2025-2026, OpenSavvy and contributors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -150,6 +150,7 @@ val OfficialJvmBsonFactory by preparedSuite { check(testDocument().decode().name == "Bob") } + serializeIntoDocumentTypes(reflectionFactory) verifyBsonFactory(reflectionFactory) } @@ -174,6 +175,7 @@ val OfficialJvmBsonFactory by preparedSuite { check(testDocument().decode().name == "Bob") } + serializeIntoDocumentTypes(serializationFactory) verifyBsonFactory(serializationFactory) } } diff --git a/bson-official/src/jvmTest/kotlin/SerializeIntoDocumentTypes.kt b/bson-official/src/jvmTest/kotlin/SerializeIntoDocumentTypes.kt new file mode 100644 index 00000000..4b1fa15a --- /dev/null +++ b/bson-official/src/jvmTest/kotlin/SerializeIntoDocumentTypes.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2026, OpenSavvy and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(LowLevelApi::class, ExperimentalBsonDiffApi::class) + +package opensavvy.ktmongo.bson.official + +import kotlinx.serialization.Serializable +import opensavvy.ktmongo.bson.ExperimentalBsonDiffApi +import opensavvy.ktmongo.bson.decode +import opensavvy.ktmongo.bson.diff +import opensavvy.ktmongo.bson.encode +import opensavvy.ktmongo.dsl.LowLevelApi +import opensavvy.prepared.suite.Prepared +import opensavvy.prepared.suite.SuiteDsl + +@Serializable +data class NestedDocument( + val data: BsonDocument, +) + +@Serializable +data class NestedArray( + val data: BsonArray, +) + +@Serializable +data class NestedValues( + val values: List, +) + +fun SuiteDsl.serializeIntoDocumentTypes(factory: Prepared) = suite("Nested BsonDocument & co") { + + test("BsonDocument") { + val document = factory().buildDocument { + writeDocument("data") { + writeString("a", "1") + writeInt32("b", 2) + } + } + + val decoded = document.decode() + + check(decoded.data["a"]?.decodeString() == "1") + check(decoded.data["b"]?.decodeInt32() == 2) + + val encoded = factory().encode(decoded) + + check(document == encoded) { "Diff: ${document diff encoded}" } + } + + test("BsonArray") { + val document = factory().buildDocument { + writeArray("data") { + writeString("1") + writeInt32(2) + } + } + + val decoded = document.decode() + + check(decoded.data[0]?.decodeString() == "1") + check(decoded.data[1]?.decodeInt32() == 2) + + val encoded = factory().encode(decoded) + + check(document == encoded) { "Diff: ${document diff encoded}" } + } + + test("BsonValue") { + val document = factory().buildDocument { + writeArray("values") { + writeString("1") + writeInt32(2) + writeDocument { + writeString("a", "1") + writeInt32("b", 2) + } + writeArray { + writeBoolean(true) + writeBoolean(false) + } + } + } + + val decoded = document.decode() + + check(decoded.values[0].decodeString() == "1") + check(decoded.values[1].decodeInt32() == 2) + check(decoded.values[2].decodeDocument()["a"]?.decodeString() == "1") + check(decoded.values[2].decodeDocument()["b"]?.decodeInt32() == 2) + check(decoded.values[3].decodeArray()[0]?.decodeBoolean() == true) + check(decoded.values[3].decodeArray()[1]?.decodeBoolean() == false) + + val encoded = factory().encode(decoded) + + check(document == encoded) { "Diff: ${document diff encoded}" } + } +}