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..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 @@ -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,23 @@ 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) { + // 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") + } + 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)