From ad6f7aedc4341da0f0aa3ee86b358879d5546e34 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Mon, 20 Jul 2026 13:26:47 +0100 Subject: [PATCH 1/3] Encode ByteArray data class fields as BSON Binary Narrow the DataClassCodec array interception to exclude ByteArray so it falls through to ByteArrayCodec (BSON Binary), restoring pre-5.1.3 behavior. ArrayCodecProvider is registered before ByteArrayCodec in both the test registry and the production KotlinCodecProvider, so it must apply the same ByteArray exclusion and return null to let the registry fall through to ByteArrayCodec. Object arrays still route through ArrayCodec (JAVA-5122). JAVA-6224 --- .../bson/codecs/kotlin/ArrayCodecProvider.kt | 4 +- .../org/bson/codecs/kotlin/DataClassCodec.kt | 4 +- .../bson/codecs/kotlin/DataClassCodecTest.kt | 61 ++++++++++++++++++- .../bson/codecs/kotlin/samples/DataClasses.kt | 20 +++++- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt index eccb5b88b27..3c3f608faa5 100644 --- a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt +++ b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/ArrayCodecProvider.kt @@ -25,7 +25,9 @@ public class ArrayCodecProvider : CodecProvider { override fun get(clazz: Class, registry: CodecRegistry): Codec? = get(clazz, emptyList(), registry) override fun get(clazz: Class, typeArguments: List, registry: CodecRegistry): Codec? = - if (clazz.isArray) { + // ByteArrays must be encoded as compact BSON Binary by ByteArrayCodec. + // Returning null lets the registry fall through to ByteArrayCodec. + if (clazz.isArray && clazz != ByteArray::class.java) { ArrayCodec.create(clazz.kotlin, typeArguments, registry) } else null } diff --git a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt index ad99b0a1560..e6aff170b25 100644 --- a/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt +++ b/bson-kotlin/src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt @@ -251,8 +251,10 @@ internal data class DataClassCodec( @Suppress("UNCHECKED_CAST") private fun CodecRegistry.getCodec(kParameter: KParameter, clazz: Class, types: List): Codec { + // ByteArrays use a dedicated ByteArrayCodec in the registry that encodes compact BSON + // Binary. val codec = - if (clazz.isArray) { + if (clazz.isArray && clazz != ByteArray::class.java) { ArrayCodec.create(clazz.kotlin, types, this) } else if (types.isEmpty()) { this.get(clazz) diff --git a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt index fd0861848b0..558a8658e17 100644 --- a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt +++ b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt @@ -45,6 +45,7 @@ import org.bson.codecs.kotlin.samples.DataClassWithBsonExtraElements import org.bson.codecs.kotlin.samples.DataClassWithBsonId import org.bson.codecs.kotlin.samples.DataClassWithBsonIgnore import org.bson.codecs.kotlin.samples.DataClassWithBsonProperty +import org.bson.codecs.kotlin.samples.DataClassWithByteArray import org.bson.codecs.kotlin.samples.DataClassWithCollections import org.bson.codecs.kotlin.samples.DataClassWithDataClassMapKey import org.bson.codecs.kotlin.samples.DataClassWithDefaults @@ -122,6 +123,11 @@ class DataClassCodecTest { | "arraySimple": ["a", "b", "c", "d"], | "nestedArrays": [["e", "f"], [], ["g", "h"]], | "arrayOfMaps": [{"A": ["aa"], "B": ["bb"]}, {}, {"C": ["cc", "ccc"]}], + | "byteArray": {"${'$'}binary": {"base64": "AQIDBA==", "subType": "00"}}, + | "nestedByteArrays": [ + | {"${'$'}binary": {"base64": "AQI=", "subType": "00"}}, + | {"${'$'}binary": {"base64": "AwQF", "subType": "00"}} + | ], |}""" .trimMargin() @@ -130,7 +136,9 @@ class DataClassCodecTest { arrayOf("a", "b", "c", "d"), arrayOf(arrayOf("e", "f"), emptyArray(), arrayOf("g", "h")), arrayOf( - mapOf("A" to arrayOf("aa"), "B" to arrayOf("bb")), emptyMap(), mapOf("C" to arrayOf("cc", "ccc")))) + mapOf("A" to arrayOf("aa"), "B" to arrayOf("bb")), emptyMap(), mapOf("C" to arrayOf("cc", "ccc"))), + byteArrayOf(1, 2, 3, 4), + arrayOf(byteArrayOf(1, 2), byteArrayOf(3, 4, 5))) assertRoundTrips(expected, dataClass) } @@ -140,7 +148,7 @@ class DataClassCodecTest { val expected = """{ | "booleanArray": [true, false], - | "byteArray": [1, 2], + | "byteArray": {"${'$'}binary": {"base64": "AQI=", "subType": "00"}}, | "charArray": ["a", "b"], | "doubleArray": [ 1.1, 2.2, 3.3], | "floatArray": [1.0, 2.0, 3.0], @@ -168,6 +176,55 @@ class DataClassCodecTest { assertRoundTrips(expected, dataClass) } + @Test + fun testDataClassWithByteArrayEncodesAsBinary() { + // A ByteArray field must encode as compact BSON Binary (subType 00), + // via ByteArrayCodec, not as a BSON Array of Int32 (one element per byte). + val expected = """{"byteArray": {"${'$'}binary": {"base64": "AQIDBA==", "subType": "00"}}}""" + assertRoundTrips(expected, DataClassWithByteArray(byteArrayOf(1, 2, 3, 4))) + } + + @Test + fun testDataClassWithByteArrayDoesNotExpandDocumentSize() { + // BSON Array of ints encoding expands size ~8-10x, breaking the 16MB limit. + // Binary encoding keeps a 1MB payload close to 1MB. + val oneMegabyte = ByteArray(1_000_000) + val codec = DataClassCodec.create(DataClassWithByteArray::class, registry())!! + val document = BsonDocument() + codec.encode( + BsonDocumentWriter(document), DataClassWithByteArray(oneMegabyte), EncoderContext.builder().build()) + val encodedSize = document.toBsonDocument().getBinary("byteArray").data.size + assertEquals(1_000_000, encodedSize) + } + + @Test + fun testDataClassWithObjectArrayEncodesAsBsonArray() { + // Ensure normal arrays and ByteArrays are handled correctly. + val expected = + """{ + | "arraySimple": ["a", "b", "c", "d"], + | "nestedArrays": [["e", "f"], [], ["g", "h"]], + | "arrayOfMaps": [{"A": ["aa"], "B": ["bb"]}, {}, {"C": ["cc", "ccc"]}], + | "byteArray": {"${'$'}binary": {"base64": "AQIDBA==", "subType": "00"}}, + | "nestedByteArrays": [ + | {"${'$'}binary": {"base64": "AQI=", "subType": "00"}}, + | {"${'$'}binary": {"base64": "AwQF", "subType": "00"}} + | ] + |}""" + .trimMargin() + + val dataClass = + DataClassWithArrays( + arrayOf("a", "b", "c", "d"), + arrayOf(arrayOf("e", "f"), emptyArray(), arrayOf("g", "h")), + arrayOf( + mapOf("A" to arrayOf("aa"), "B" to arrayOf("bb")), emptyMap(), mapOf("C" to arrayOf("cc", "ccc"))), + byteArrayOf(1, 2, 3, 4), + arrayOf(byteArrayOf(1, 2), byteArrayOf(3, 4, 5))) + + assertRoundTrips(expected, dataClass) + } + @Test fun testDataClassWithDefaults() { val expectedDefault = diff --git a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt index 6348883b2c0..59036757193 100644 --- a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt +++ b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/samples/DataClasses.kt @@ -52,7 +52,9 @@ data class DataClassWithCollections( data class DataClassWithArrays( val arraySimple: Array, val nestedArrays: Array>, - val arrayOfMaps: Array>> + val arrayOfMaps: Array>>, + val byteArray: ByteArray, + val nestedByteArrays: Array ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -70,6 +72,9 @@ data class DataClassWithArrays( map.keys.forEach { key -> if (!map[key].contentEquals(otherMap[key])) return false } } + if (!byteArray.contentEquals(other.byteArray)) return false + if (!nestedByteArrays.contentDeepEquals(other.nestedByteArrays)) return false + return true } @@ -77,6 +82,8 @@ data class DataClassWithArrays( var result = arraySimple.contentHashCode() result = 31 * result + nestedArrays.contentDeepHashCode() result = 31 * result + arrayOfMaps.contentHashCode() + result = 31 * result + byteArray.contentHashCode() + result = 31 * result + nestedByteArrays.contentDeepHashCode() return result } } @@ -134,6 +141,17 @@ data class DataClassWithNativeArrays( } } +data class DataClassWithByteArray(val byteArray: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DataClassWithByteArray + return byteArray.contentEquals(other.byteArray) + } + + override fun hashCode(): Int = byteArray.contentHashCode() +} + data class DataClassWithDefaults( val boolean: Boolean = false, val string: String = "String", From b25de959f862a8883fbe43d4076514994627a5b2 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Mon, 20 Jul 2026 15:54:15 +0100 Subject: [PATCH 2/3] Add opt-in ByteArray as BSON Binary serializer for bson-kotlinx kotlinx.serialization encodes a ByteArray as a BSON array of int32 elements by default, which differs from bson-kotlin's DataClassCodec (compact BSON Binary). Add an opt-in ByteArrayAsBsonBinary KSerializer so users can store ByteArray fields as BsonBinary via @Serializable(with = ByteArrayAsBsonBinary::class) or @Contextual plus its serializersModule. It is not registered in defaultSerializersModule, so existing behavior and on-disk format are unchanged. JAVA-6224 --- .../codecs/kotlinx/ByteArrayAsBsonBinary.kt | 64 +++++++++++++++++++ .../kotlinx/KotlinSerializerCodecTest.kt | 46 +++++++++++++ .../codecs/kotlinx/samples/DataClasses.kt | 54 ++++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/ByteArrayAsBsonBinary.kt diff --git a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/ByteArrayAsBsonBinary.kt b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/ByteArrayAsBsonBinary.kt new file mode 100644 index 00000000000..b6faa4b0af6 --- /dev/null +++ b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/ByteArrayAsBsonBinary.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2008-present MongoDB, Inc. + * + * 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 org.bson.codecs.kotlinx + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +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 kotlinx.serialization.modules.SerializersModule +import org.bson.BsonBinary + +/** + * ByteArray KSerializer. + * + * Encodes and decodes `ByteArray` values to and from a compact `BsonBinary` (subtype + * [org.bson.BsonBinarySubType.BINARY]), matching the behavior of the standard `ByteArrayCodec` and the `bson-kotlin` + * `DataClassCodec`. + * + * This is an opt-in serializer: kotlinx.serialization's built-in `ByteArray` serializer encodes a `ByteArray` as a BSON + * array of int32 elements (one element per byte). To store a `ByteArray` field as `BsonBinary` instead, either annotate + * the field with `@Serializable(with = ByteArrayAsBsonBinary::class)`, or annotate it with `@Contextual` and register + * [ByteArrayAsBsonBinary.serializersModule] on the codec. + * + * @since 5.10 + */ +@ExperimentalSerializationApi +public object ByteArrayAsBsonBinary : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ByteArrayAsBsonBinary", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: ByteArray) { + when (encoder) { + is BsonEncoder -> encoder.encodeBsonValue(BsonBinary(value)) + else -> throw SerializationException("ByteArray is not supported by ${encoder::class}") + } + } + + override fun deserialize(decoder: Decoder): ByteArray { + return when (decoder) { + is BsonDecoder -> decoder.decodeBsonValue().asBinary().data + else -> throw SerializationException("ByteArray is not supported by ${decoder::class}") + } + } + + public val serializersModule: SerializersModule = SerializersModule { + contextual(ByteArray::class, ByteArrayAsBsonBinary) + } +} diff --git a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt index de6d7d107fe..dbb82025bc1 100644 --- a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt +++ b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt @@ -74,6 +74,7 @@ import org.bson.codecs.kotlinx.samples.DataClassSealedB import org.bson.codecs.kotlinx.samples.DataClassSealedC import org.bson.codecs.kotlinx.samples.DataClassSelfReferential import org.bson.codecs.kotlinx.samples.DataClassWithAnnotations +import org.bson.codecs.kotlinx.samples.DataClassWithArrays import org.bson.codecs.kotlinx.samples.DataClassWithBooleanMapKey import org.bson.codecs.kotlinx.samples.DataClassWithBsonConstructor import org.bson.codecs.kotlinx.samples.DataClassWithBsonDiscriminator @@ -82,6 +83,7 @@ import org.bson.codecs.kotlinx.samples.DataClassWithBsonId import org.bson.codecs.kotlinx.samples.DataClassWithBsonIgnore import org.bson.codecs.kotlinx.samples.DataClassWithBsonProperty import org.bson.codecs.kotlinx.samples.DataClassWithBsonRepresentation +import org.bson.codecs.kotlinx.samples.DataClassWithByteArray import org.bson.codecs.kotlinx.samples.DataClassWithCamelCase import org.bson.codecs.kotlinx.samples.DataClassWithCollections import org.bson.codecs.kotlinx.samples.DataClassWithContextualDateValues @@ -299,6 +301,50 @@ class KotlinSerializerCodecTest { assertRoundTrips(expected, expectedDataClass) } + @Test + fun testDataClassWithByteArrayEncodesAsBsonArrayByDefault() { + // By default kotlinx.serialization encodes a ByteArray as a BSON array of int32 elements + // (one per byte). + // This differs from bson-kotlin's DataClassCodec, which encodes ByteArray as compact BSON + // Binary. + val expected = """{"byteArray": [1, 2, 3, 4]}""" + + assertRoundTrips(expected, DataClassWithByteArray(byteArrayOf(1, 2, 3, 4))) + } + + @Test + fun testDataClassWithObjectArrayEncodesAsBsonArray() { + // Object arrays encode as BSON arrays, while ByteArray fields opted in via + // @Serializable(with = ByteArrayAsBsonBinary::class) encode as compact BSON Binary (subType + // 00), + // consistent with the standard ByteArrayCodec and bson-kotlin's DataClassCodec. The + // per-type-argument + // annotation on nestedByteArrays applies the serializer to each inner ByteArray element. + val expected = + """{ + | "arraySimple": ["a", "b", "c", "d"], + | "nestedArrays": [["e", "f"], [], ["g", "h"]], + | "arrayOfMaps": [{"A": ["aa"], "B": ["bb"]}, {}, {"C": ["cc", "ccc"]}], + | "byteArray": {"${'$'}binary": {"base64": "AQIDBA==", "subType": "00"}}, + | "nestedByteArrays": [ + | {"${'$'}binary": {"base64": "AQI=", "subType": "00"}}, + | {"${'$'}binary": {"base64": "AwQF", "subType": "00"}} + | ] + |}""" + .trimMargin() + + val dataClass = + DataClassWithArrays( + arrayOf("a", "b", "c", "d"), + arrayOf(arrayOf("e", "f"), emptyArray(), arrayOf("g", "h")), + arrayOf( + mapOf("A" to arrayOf("aa"), "B" to arrayOf("bb")), emptyMap(), mapOf("C" to arrayOf("cc", "ccc"))), + byteArrayOf(1, 2, 3, 4), + arrayOf(byteArrayOf(1, 2), byteArrayOf(3, 4, 5))) + + assertRoundTrips(expected, dataClass) + } + @Test fun testDataClassWithComplexTypes() { val expected = diff --git a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt index aaf83d1bc9c..1f9be0ca5b2 100644 --- a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt +++ b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt @@ -47,6 +47,7 @@ import org.bson.BsonSymbol import org.bson.BsonTimestamp import org.bson.BsonType import org.bson.BsonUndefined +import org.bson.codecs.kotlinx.ByteArrayAsBsonBinary import org.bson.codecs.pojo.annotations.BsonCreator import org.bson.codecs.pojo.annotations.BsonDiscriminator import org.bson.codecs.pojo.annotations.BsonExtraElements @@ -376,3 +377,56 @@ data class DataClassWithJsonElementsNullable( val jsonElements: List?, val jsonNestedMap: Map? ) + +@Serializable +data class DataClassWithByteArray(val byteArray: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DataClassWithByteArray + return byteArray.contentEquals(other.byteArray) + } + + override fun hashCode(): Int = byteArray.contentHashCode() +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class DataClassWithArrays( + val arraySimple: Array, + val nestedArrays: Array>, + val arrayOfMaps: Array>>, + @Serializable(with = ByteArrayAsBsonBinary::class) val byteArray: ByteArray, + val nestedByteArrays: Array<@Serializable(with = ByteArrayAsBsonBinary::class) ByteArray> +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as DataClassWithArrays + + if (!arraySimple.contentEquals(other.arraySimple)) return false + if (!nestedArrays.contentDeepEquals(other.nestedArrays)) return false + + if (arrayOfMaps.size != other.arrayOfMaps.size) return false + arrayOfMaps.forEachIndexed { i, map -> + val otherMap = other.arrayOfMaps[i] + if (map.keys != otherMap.keys) return false + map.keys.forEach { key -> if (!map[key].contentEquals(otherMap[key])) return false } + } + + if (!byteArray.contentEquals(other.byteArray)) return false + if (!nestedByteArrays.contentDeepEquals(other.nestedByteArrays)) return false + + return true + } + + override fun hashCode(): Int { + var result = arraySimple.contentHashCode() + result = 31 * result + nestedArrays.contentDeepHashCode() + result = 31 * result + arrayOfMaps.contentHashCode() + result = 31 * result + byteArray.contentHashCode() + result = 31 * result + nestedByteArrays.contentDeepHashCode() + return result + } +} From 38c0df7253ecbfee86f9c92e1cb8a0e81846243d Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Mon, 3 Aug 2026 14:18:25 +0100 Subject: [PATCH 3/3] JAVA-6224 Address review feedback - Remove the duplicated array test from bson-kotlin's DataClassCodecTest - Document the ByteArrayAsBsonBinary.serializersModule opt-in - Cover the @Contextual, empty, nullable and non-binary decode cases - Tidy the new test comments --- .../bson/codecs/kotlin/DataClassCodecTest.kt | 30 +--------- .../codecs/kotlinx/ByteArrayAsBsonBinary.kt | 8 +++ .../kotlinx/KotlinSerializerCodecTest.kt | 55 +++++++++++++++---- .../codecs/kotlinx/samples/DataClasses.kt | 42 ++++++++++++++ 4 files changed, 96 insertions(+), 39 deletions(-) diff --git a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt index 558a8658e17..b9779610f2d 100644 --- a/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt +++ b/bson-kotlin/src/test/kotlin/org/bson/codecs/kotlin/DataClassCodecTest.kt @@ -193,38 +193,10 @@ class DataClassCodecTest { val document = BsonDocument() codec.encode( BsonDocumentWriter(document), DataClassWithByteArray(oneMegabyte), EncoderContext.builder().build()) - val encodedSize = document.toBsonDocument().getBinary("byteArray").data.size + val encodedSize = document.getBinary("byteArray").data.size assertEquals(1_000_000, encodedSize) } - @Test - fun testDataClassWithObjectArrayEncodesAsBsonArray() { - // Ensure normal arrays and ByteArrays are handled correctly. - val expected = - """{ - | "arraySimple": ["a", "b", "c", "d"], - | "nestedArrays": [["e", "f"], [], ["g", "h"]], - | "arrayOfMaps": [{"A": ["aa"], "B": ["bb"]}, {}, {"C": ["cc", "ccc"]}], - | "byteArray": {"${'$'}binary": {"base64": "AQIDBA==", "subType": "00"}}, - | "nestedByteArrays": [ - | {"${'$'}binary": {"base64": "AQI=", "subType": "00"}}, - | {"${'$'}binary": {"base64": "AwQF", "subType": "00"}} - | ] - |}""" - .trimMargin() - - val dataClass = - DataClassWithArrays( - arrayOf("a", "b", "c", "d"), - arrayOf(arrayOf("e", "f"), emptyArray(), arrayOf("g", "h")), - arrayOf( - mapOf("A" to arrayOf("aa"), "B" to arrayOf("bb")), emptyMap(), mapOf("C" to arrayOf("cc", "ccc"))), - byteArrayOf(1, 2, 3, 4), - arrayOf(byteArrayOf(1, 2), byteArrayOf(3, 4, 5))) - - assertRoundTrips(expected, dataClass) - } - @Test fun testDataClassWithDefaults() { val expectedDefault = diff --git a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/ByteArrayAsBsonBinary.kt b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/ByteArrayAsBsonBinary.kt index b6faa4b0af6..5e6a0960025 100644 --- a/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/ByteArrayAsBsonBinary.kt +++ b/bson-kotlinx/src/main/kotlin/org/bson/codecs/kotlinx/ByteArrayAsBsonBinary.kt @@ -58,6 +58,14 @@ public object ByteArrayAsBsonBinary : KSerializer { } } + /** + * A [SerializersModule] that registers [ByteArrayAsBsonBinary] as the contextual serializer for `ByteArray`. + * + * Register it on the codec (or combine it with an existing module) so that any `ByteArray` field annotated with + * `@Contextual` is encoded and decoded as a compact `BsonBinary` instead of a BSON array of int32 elements. + * + * @since 5.10 + */ public val serializersModule: SerializersModule = SerializersModule { contextual(ByteArray::class, ByteArrayAsBsonBinary) } diff --git a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt index dbb82025bc1..c15c453935d 100644 --- a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt +++ b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/KotlinSerializerCodecTest.kt @@ -84,8 +84,11 @@ import org.bson.codecs.kotlinx.samples.DataClassWithBsonIgnore import org.bson.codecs.kotlinx.samples.DataClassWithBsonProperty import org.bson.codecs.kotlinx.samples.DataClassWithBsonRepresentation import org.bson.codecs.kotlinx.samples.DataClassWithByteArray +import org.bson.codecs.kotlinx.samples.DataClassWithByteArrayAsBsonBinary +import org.bson.codecs.kotlinx.samples.DataClassWithByteArrayAsBsonBinaryNullable import org.bson.codecs.kotlinx.samples.DataClassWithCamelCase import org.bson.codecs.kotlinx.samples.DataClassWithCollections +import org.bson.codecs.kotlinx.samples.DataClassWithContextualByteArray import org.bson.codecs.kotlinx.samples.DataClassWithContextualDateValues import org.bson.codecs.kotlinx.samples.DataClassWithDataClassMapKey import org.bson.codecs.kotlinx.samples.DataClassWithDateValues @@ -303,23 +306,55 @@ class KotlinSerializerCodecTest { @Test fun testDataClassWithByteArrayEncodesAsBsonArrayByDefault() { - // By default kotlinx.serialization encodes a ByteArray as a BSON array of int32 elements - // (one per byte). - // This differs from bson-kotlin's DataClassCodec, which encodes ByteArray as compact BSON - // Binary. + // kotlinx.serialization's built-in ByteArray serializer encodes one int32 element per byte. val expected = """{"byteArray": [1, 2, 3, 4]}""" assertRoundTrips(expected, DataClassWithByteArray(byteArrayOf(1, 2, 3, 4))) } + @Test + fun testDataClassWithByteArrayAsBsonBinary() { + val expected = """{"byteArray": {"${'$'}binary": {"base64": "AQIDBA==", "subType": "00"}}}""" + + assertRoundTrips(expected, DataClassWithByteArrayAsBsonBinary(byteArrayOf(1, 2, 3, 4))) + } + + @Test + fun testDataClassWithEmptyByteArrayAsBsonBinary() { + val expected = """{"byteArray": {"${'$'}binary": {"base64": "", "subType": "00"}}}""" + + assertRoundTrips(expected, DataClassWithByteArrayAsBsonBinary(byteArrayOf())) + } + + @Test + fun testDataClassWithNullableByteArrayAsBsonBinary() { + // Nulls are omitted by default (explicitNulls = false). + assertRoundTrips("{}", DataClassWithByteArrayAsBsonBinaryNullable(null)) + assertRoundTrips( + """{"byteArray": {"${'$'}binary": {"base64": "AQI=", "subType": "00"}}}""", + DataClassWithByteArrayAsBsonBinaryNullable(byteArrayOf(1, 2))) + } + + @Test + fun testDataClassWithContextualByteArrayAsBsonBinary() { + val expected = """{"byteArray": {"${'$'}binary": {"base64": "AQIDBA==", "subType": "00"}}}""" + + assertRoundTrips( + expected, + DataClassWithContextualByteArray(byteArrayOf(1, 2, 3, 4)), + serializersModule = ByteArrayAsBsonBinary.serializersModule) + } + + @Test + fun testByteArrayAsBsonBinaryFailsToDecodeNonBinaryValue() { + assertThrows { + deserialize(BsonDocument.parse("""{"byteArray": [1, 2, 3, 4]}""")) + } + } + @Test fun testDataClassWithObjectArrayEncodesAsBsonArray() { - // Object arrays encode as BSON arrays, while ByteArray fields opted in via - // @Serializable(with = ByteArrayAsBsonBinary::class) encode as compact BSON Binary (subType - // 00), - // consistent with the standard ByteArrayCodec and bson-kotlin's DataClassCodec. The - // per-type-argument - // annotation on nestedByteArrays applies the serializer to each inner ByteArray element. + // The annotation inside `Array<@Serializable(...) ByteArray>` applies per element. val expected = """{ | "arraySimple": ["a", "b", "c", "d"], diff --git a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt index 1f9be0ca5b2..704f03a9648 100644 --- a/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt +++ b/bson-kotlinx/src/test/kotlin/org/bson/codecs/kotlinx/samples/DataClasses.kt @@ -390,6 +390,48 @@ data class DataClassWithByteArray(val byteArray: ByteArray) { override fun hashCode(): Int = byteArray.contentHashCode() } +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class DataClassWithByteArrayAsBsonBinary( + @Serializable(with = ByteArrayAsBsonBinary::class) val byteArray: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DataClassWithByteArrayAsBsonBinary + return byteArray.contentEquals(other.byteArray) + } + + override fun hashCode(): Int = byteArray.contentHashCode() +} + +@Serializable +data class DataClassWithContextualByteArray(@Contextual val byteArray: ByteArray) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DataClassWithContextualByteArray + return byteArray.contentEquals(other.byteArray) + } + + override fun hashCode(): Int = byteArray.contentHashCode() +} + +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class DataClassWithByteArrayAsBsonBinaryNullable( + @Serializable(with = ByteArrayAsBsonBinary::class) val byteArray: ByteArray? +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as DataClassWithByteArrayAsBsonBinaryNullable + return byteArray.contentEquals(other.byteArray) + } + + override fun hashCode(): Int = byteArray?.contentHashCode() ?: 0 +} + @OptIn(ExperimentalSerializationApi::class) @Serializable data class DataClassWithArrays(