Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ internal data class DataClassCodec<T : Any>(
@Suppress("TooGenericExceptionCaught")
override fun decode(reader: BsonReader, decoderContext: DecoderContext): T {
val args: MutableMap<KParameter, Any?> = mutableMapOf()
fieldNamePropertyModelMap.values.forEach { args[it.param] = null }

reader.readStartDocument()
while (reader.readBsonType() != BsonType.END_OF_DOCUMENT) {
Expand All @@ -89,6 +88,7 @@ internal data class DataClassCodec<T : Any>(
}
} 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)
Expand All @@ -100,6 +100,23 @@ internal data class DataClassCodec<T : Any>(
}
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<T>(val boxed: T) instantiated as Box<String?>).
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -177,17 +178,71 @@ 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
fun testDataClassWithNulls() {
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<CodecConfigurationException> {
val codec = DataClassCodec.create(DataClassWithDefaultsAndNulls::class, registry())
codec?.decode(BsonDocumentReader(BsonDocument()), DecoderContext.builder().build())
}
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,12 @@ data class DataClassWithDefaults(

data class DataClassWithNulls(val boolean: Boolean?, val string: String?, val listSimple: List<String?>?)

data class DataClassWithDefaultsAndNulls(
val required: String,
val optional: String = "default",
val nullable: String? = null
)

data class DataClassWithListThatLastItemDefaultsToNull(val elements: List<DataClassLastItemDefaultsToNull>)

data class DataClassLastItemDefaultsToNull(val required: String, val optional: String? = null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<MissingFieldException> {
val codec = KotlinSerializerCodec.create(DataClassWithDefaultsAndNulls::class)
codec?.decode(BsonDocumentReader(BsonDocument()), DecoderContext.builder().build())
}
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ data class DataClassWithKotlinAllowedName(

@Serializable data class DataClassWithNulls(val boolean: Boolean?, val string: String?, val listSimple: List<String?>?)

@Serializable
data class DataClassWithDefaultsAndNulls(
val required: String,
val optional: String = "default",
val nullable: String? = null
)

@Serializable
data class DataClassWithListThatLastItemDefaultsToNull(val elements: List<DataClassLastItemDefaultsToNull>)

Expand Down