From a1fe32719cf15917c37e4d59cdbeeb083fbf379b Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Mon, 13 Apr 2026 16:19:44 +0100 Subject: [PATCH 1/3] Fix Kotlin bson decoding of optionals Previously, DataClassCodec pre-populated all constructor parameters with null before reading the document, which prevented callBy from using Kotlin default parameter values. Now optional parameters missing from the document are left absent from the args map so callBy invokes their defaults, and a clear CodecConfigurationException is thrown when a required non-nullable field is missing. Ported the bson-kotlin test cases to bson-kotlinx to ensure coverage and prevent future regressions. JAVA-6162 --- .../org/bson/codecs/kotlin/DataClassCodec.kt | 15 +++- .../bson/codecs/kotlin/DataClassCodecTest.kt | 63 +++++++++++++++-- .../bson/codecs/kotlin/samples/DataClasses.kt | 6 ++ .../kotlinx/KotlinSerializerCodecTest.kt | 70 +++++++++++++++---- .../codecs/kotlinx/samples/DataClasses.kt | 7 ++ 5 files changed, 143 insertions(+), 18 deletions(-) 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 85e705cb8c0..a2638a548b1 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 @@ -76,7 +76,6 @@ internal data class DataClassCodec( @Suppress("TooGenericExceptionCaught") override fun decode(reader: BsonReader, decoderContext: DecoderContext): T { val args: MutableMap = mutableMapOf() - fieldNamePropertyModelMap.values.forEach { args[it.param] = null } reader.readStartDocument() while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) { @@ -89,6 +88,7 @@ internal data class DataClassCodec( } } else if (propertyModel.param.type.isMarkedNullable && reader.currentBsonType == BsonType.NULL) { reader.readNull() + args[propertyModel.param] = null } else { try { args[propertyModel.param] = decoderContext.decodeWithChildContext(propertyModel.codec, reader) @@ -100,6 +100,19 @@ internal data class DataClassCodec( } reader.readEndDocument() + // For non-optional parameters missing from the document, fail with a clear message + // if non-nullable, or pass null explicitly if nullable. + // Optional parameters (with defaults) are left absent so callBy uses the default value. + fieldNamePropertyModelMap.values.forEach { + if (it.param !in args && !it.param.isOptional) { + if (!it.param.type.isMarkedNullable && it.param.type.classifier is KClass<*>) { + throw CodecConfigurationException( + "Required field '${it.fieldName}' is missing from the document for ${kClass.simpleName} data class") + } + args[it.param] = null + } + } + try { return primaryConstructor.callBy(args) } catch (e: Exception) { 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 c203a5d2358..fd0861848b0 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 @@ -48,6 +48,7 @@ import org.bson.codecs.kotlin.samples.DataClassWithBsonProperty import org.bson.codecs.kotlin.samples.DataClassWithCollections import org.bson.codecs.kotlin.samples.DataClassWithDataClassMapKey import org.bson.codecs.kotlin.samples.DataClassWithDefaults +import org.bson.codecs.kotlin.samples.DataClassWithDefaultsAndNulls import org.bson.codecs.kotlin.samples.DataClassWithEmbedded import org.bson.codecs.kotlin.samples.DataClassWithEnum import org.bson.codecs.kotlin.samples.DataClassWithEnumMapKey @@ -177,8 +178,24 @@ class DataClassCodecTest { |}""" .trimMargin() - val defaultDataClass = DataClassWithDefaults() - assertRoundTrips(expectedDefault, defaultDataClass) + assertRoundTrips(expectedDefault, DataClassWithDefaults()) + + // Assert no data decodes as expected + assertDecodesTo(BsonDocument.parse(emptyDocument), DataClassWithDefaults()) + + // Assert some data + assertDecodesTo(BsonDocument.parse("""{"string": "Custom"}"""), DataClassWithDefaults(string = "Custom")) + + // Assert all data + val expected = + """{ + | "boolean": true, + | "string": "Custom", + | "listSimple": ["x"] + |}""" + .trimMargin() + + assertRoundTrips(expected, DataClassWithDefaults(boolean = true, string = "Custom", listSimple = listOf("x"))) } @Test @@ -186,8 +203,46 @@ class DataClassCodecTest { val dataClass = DataClassWithNulls(null, null, null) assertRoundTrips(emptyDocument, dataClass) - val withStoredNulls = BsonDocument.parse("""{"boolean": null, "string": null, "listSimple": null}""") - assertDecodesTo(withStoredNulls, dataClass) + // Assert all null data decodes as expected + assertDecodesTo(BsonDocument.parse("""{"boolean": null, "string": null, "listSimple": null}"""), dataClass) + + // Assert some data + assertDecodesTo(BsonDocument.parse("""{"string": "Custom"}"""), DataClassWithNulls(null, "Custom", null)) + + // Assert all data + val expected = + """{ + | "boolean": true, + | "string": "Custom", + | "listSimple": ["x"] + |}""" + .trimMargin() + assertRoundTrips(expected, DataClassWithNulls(true, "Custom", listOf("x"))) + } + + @Test + fun testDataClassWithDefaultsAndNulls() { + // All fields provided + val expected = """{"required": "req", "optional": "opt", "nullable": "nul"}""" + assertRoundTrips(expected, DataClassWithDefaultsAndNulls("req", "opt", "nul")) + + // Only required field — optional gets default, nullable gets default (null) + assertDecodesTo(BsonDocument.parse("""{"required": "req"}"""), DataClassWithDefaultsAndNulls("req")) + + // Required + nullable explicit null in document + assertDecodesTo( + BsonDocument.parse("""{"required": "req", "nullable": null}"""), DataClassWithDefaultsAndNulls("req")) + + // Required + optional overridden, nullable absent + assertDecodesTo( + BsonDocument.parse("""{"required": "req", "optional": "custom"}"""), + DataClassWithDefaultsAndNulls("req", "custom")) + + // Missing required field throws + assertThrows { + val codec = DataClassCodec.create(DataClassWithDefaultsAndNulls::class, registry()) + codec?.decode(BsonDocumentReader(BsonDocument()), DecoderContext.builder().build()) + } } @Test 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 77483cc9ee7..6348883b2c0 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 @@ -142,6 +142,12 @@ data class DataClassWithDefaults( data class DataClassWithNulls(val boolean: Boolean?, val string: String?, val listSimple: List?) +data class DataClassWithDefaultsAndNulls( + val required: String, + val optional: String = "default", + val nullable: String? = null +) + data class DataClassWithListThatLastItemDefaultsToNull(val elements: List) data class DataClassLastItemDefaultsToNull(val required: String, val optional: String? = null) 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 f9b3eb753c5..59bbb44a598 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 @@ -87,6 +87,7 @@ import org.bson.codecs.kotlinx.samples.DataClassWithContextualDateValues import org.bson.codecs.kotlinx.samples.DataClassWithDataClassMapKey import org.bson.codecs.kotlinx.samples.DataClassWithDateValues import org.bson.codecs.kotlinx.samples.DataClassWithDefaults +import org.bson.codecs.kotlinx.samples.DataClassWithDefaultsAndNulls import org.bson.codecs.kotlinx.samples.DataClassWithEmbedded import org.bson.codecs.kotlinx.samples.DataClassWithEncodeDefault import org.bson.codecs.kotlinx.samples.DataClassWithEnum @@ -303,28 +304,71 @@ class KotlinSerializerCodecTest { |}""" .trimMargin() - val defaultDataClass = DataClassWithDefaults() - assertRoundTrips(expectedDefault, defaultDataClass) - assertRoundTrips(emptyDocument, defaultDataClass, altConfiguration) + assertRoundTrips(expectedDefault, DataClassWithDefaults()) - val expectedSomeOverrides = """{"boolean": true, "listSimple": ["a"]}""" - val someOverridesDataClass = DataClassWithDefaults(boolean = true, listSimple = listOf("a")) - assertRoundTrips(expectedSomeOverrides, someOverridesDataClass, altConfiguration) + // Assert no data decodes as expected + assertDecodesTo(BsonDocument.parse(emptyDocument), DataClassWithDefaults()) + + // Assert some data + assertDecodesTo(BsonDocument.parse("""{"string": "Custom"}"""), DataClassWithDefaults(string = "Custom")) + + // Assert all data + val expected = + """{ + | "boolean": true, + | "string": "Custom", + | "listSimple": ["x"] + |}""" + .trimMargin() + + assertRoundTrips(expected, DataClassWithDefaults(boolean = true, string = "Custom", listSimple = listOf("x"))) } @Test fun testDataClassWithNulls() { - val expectedNulls = + val dataClass = DataClassWithNulls(null, null, null) + assertRoundTrips(emptyDocument, dataClass) + + // Assert all null data decodes as expected + assertDecodesTo(BsonDocument.parse("""{"boolean": null, "string": null, "listSimple": null}"""), dataClass) + + // Assert some data + assertDecodesTo(BsonDocument.parse("""{"string": "Custom"}"""), DataClassWithNulls(null, "Custom", null)) + + // Assert all data + val expected = """{ - | "boolean": null, - | "string": null, - | "listSimple": null + | "boolean": true, + | "string": "Custom", + | "listSimple": ["x"] |}""" .trimMargin() + assertRoundTrips(expected, DataClassWithNulls(true, "Custom", listOf("x"))) + } - val dataClass = DataClassWithNulls(null, null, null) - assertRoundTrips(emptyDocument, dataClass) - assertRoundTrips(expectedNulls, dataClass, altConfiguration) + @Test + fun testDataClassWithDefaultsAndNulls() { + // All fields provided + val expected = """{"required": "req", "optional": "opt", "nullable": "nul"}""" + assertRoundTrips(expected, DataClassWithDefaultsAndNulls("req", "opt", "nul")) + + // Only required field — optional gets default, nullable gets default (null) + assertDecodesTo(BsonDocument.parse("""{"required": "req"}"""), DataClassWithDefaultsAndNulls("req")) + + // Required + nullable explicit null in document + assertDecodesTo( + BsonDocument.parse("""{"required": "req", "nullable": null}"""), DataClassWithDefaultsAndNulls("req")) + + // Required + optional overridden, nullable absent + assertDecodesTo( + BsonDocument.parse("""{"required": "req", "optional": "custom"}"""), + DataClassWithDefaultsAndNulls("req", "custom")) + + // Missing required field throws + assertThrows { + val codec = KotlinSerializerCodec.create(DataClassWithDefaultsAndNulls::class) + codec?.decode(BsonDocumentReader(BsonDocument()), DecoderContext.builder().build()) + } } @Test 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 773af52cd96..aaf83d1bc9c 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 @@ -129,6 +129,13 @@ data class DataClassWithKotlinAllowedName( @Serializable data class DataClassWithNulls(val boolean: Boolean?, val string: String?, val listSimple: List?) +@Serializable +data class DataClassWithDefaultsAndNulls( + val required: String, + val optional: String = "default", + val nullable: String? = null +) + @Serializable data class DataClassWithListThatLastItemDefaultsToNull(val elements: List) From c79968cfb3d5548921d45ecbf218b16c851c8603 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Mon, 13 Apr 2026 16:52:37 +0100 Subject: [PATCH 2/3] Added code clarification --- .../src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt | 3 +++ 1 file changed, 3 insertions(+) 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 a2638a548b1..11e55377d4d 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 @@ -105,6 +105,9 @@ internal data class DataClassCodec( // Optional parameters (with defaults) are left absent so callBy uses the default value. fieldNamePropertyModelMap.values.forEach { if (it.param !in args && !it.param.isOptional) { + // Only error for concrete types (KClass). Generic type parameters (KTypeParameter) + // may be nullable at runtime even though isMarkedNullable is false at the + // declaration site (e.g. Box(val boxed: T) instantiated as Box). if (!it.param.type.isMarkedNullable && it.param.type.classifier is KClass<*>) { throw CodecConfigurationException( "Required field '${it.fieldName}' is missing from the document for ${kClass.simpleName} data class") From da321db2d45a261ab3b0df7f987aba25a29d3439 Mon Sep 17 00:00:00 2001 From: Ross Lawley Date: Mon, 13 Apr 2026 17:08:27 +0100 Subject: [PATCH 3/3] Fix detekt line length error --- .../src/main/kotlin/org/bson/codecs/kotlin/DataClassCodec.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 11e55377d4d..ad99b0a1560 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 @@ -110,7 +110,8 @@ internal data class DataClassCodec( // declaration site (e.g. Box(val boxed: T) instantiated as Box). if (!it.param.type.isMarkedNullable && it.param.type.classifier is KClass<*>) { throw CodecConfigurationException( - "Required field '${it.fieldName}' is missing from the document for ${kClass.simpleName} data class") + "Required field '${it.fieldName}' is missing from the document for " + + "${kClass.simpleName} data class") } args[it.param] = null }