From ad6f7aedc4341da0f0aa3ee86b358879d5546e34 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Mon, 20 Jul 2026 13:26:47 +0100 Subject: [PATCH 1/2] 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/2] 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 + } +}