From 5ee7048905a28472c55d02a30ee93c2357270a1e Mon Sep 17 00:00:00 2001 From: Leonid Startsev Date: Wed, 26 Mar 2025 16:12:04 +0100 Subject: [PATCH] Fix incorrect enum coercion during deserialization from JsonElement in cases when a property is nullable and not optional, and explicitNulls is set to false. Fixes #2909 --- .../json/JsonCoerceInputValuesTest.kt | 14 ++++---- .../json/JsonCoerceInputValuesDynamicTest.kt | 18 +++++++++-- .../json/internal/TreeJsonDecoder.kt | 32 +++++++++++-------- .../json/internal/DynamicDecoders.kt | 19 +++++++++-- 4 files changed, 59 insertions(+), 24 deletions(-) diff --git a/formats/json-tests/commonTest/src/kotlinx/serialization/json/JsonCoerceInputValuesTest.kt b/formats/json-tests/commonTest/src/kotlinx/serialization/json/JsonCoerceInputValuesTest.kt index 5653d35725..1652df1762 100644 --- a/formats/json-tests/commonTest/src/kotlinx/serialization/json/JsonCoerceInputValuesTest.kt +++ b/formats/json-tests/commonTest/src/kotlinx/serialization/json/JsonCoerceInputValuesTest.kt @@ -155,8 +155,8 @@ class JsonCoerceInputValuesTest : JsonTestBase() { fun testNullableEnumWithoutDefault() { val j = Json(json) { explicitNulls = false } parametrizedTest { mode -> - assertEquals(NullableEnumHolder(null), j.decodeFromString("{}")) - assertEquals(NullableEnumHolder(null), j.decodeFromString("""{"enum":"incorrect"}""")) + assertEquals(NullableEnumHolder(null), j.decodeFromString("{}", mode)) + assertEquals(NullableEnumHolder(null), j.decodeFromString("""{"enum":"incorrect"}""", mode)) } } @@ -164,8 +164,8 @@ class JsonCoerceInputValuesTest : JsonTestBase() { fun testNullableEnumWithoutDefaultDoesNotCoerceExplicitly() { val j = Json(json) { explicitNulls = true } parametrizedTest { mode -> - assertFailsWith { j.decodeFromString("{}") } - assertFailsWith { j.decodeFromString("""{"enum":"incorrect"}""") } + assertFailsWith { j.decodeFromString("{}", mode) } + assertFailsWith { j.decodeFromString("""{"enum":"incorrect"}""", mode) } } } @@ -173,9 +173,9 @@ class JsonCoerceInputValuesTest : JsonTestBase() { fun testNullableEnumWithDefault() { val j = Json(json) { explicitNulls = false } parametrizedTest { mode -> - assertEquals(NullableEnumWithDefault(), j.decodeFromString("{}")) - assertEquals(NullableEnumWithDefault(), j.decodeFromString("""{"e":"incorrect"}""")) - assertEquals(NullableEnumWithDefault(null), j.decodeFromString("""{"e":null}""")) + assertEquals(NullableEnumWithDefault(), j.decodeFromString("{}", mode)) + assertEquals(NullableEnumWithDefault(), j.decodeFromString("""{"e":"incorrect"}""", mode)) + assertEquals(NullableEnumWithDefault(null), j.decodeFromString("""{"e":null}""", mode)) } } } diff --git a/formats/json-tests/jsTest/src/kotlinx/serialization/json/JsonCoerceInputValuesDynamicTest.kt b/formats/json-tests/jsTest/src/kotlinx/serialization/json/JsonCoerceInputValuesDynamicTest.kt index 0005329702..e1c50d0c8f 100644 --- a/formats/json-tests/jsTest/src/kotlinx/serialization/json/JsonCoerceInputValuesDynamicTest.kt +++ b/formats/json-tests/jsTest/src/kotlinx/serialization/json/JsonCoerceInputValuesDynamicTest.kt @@ -13,9 +13,9 @@ class JsonCoerceInputValuesDynamicTest { isLenient = true } - private fun doTest(inputs: List, expected: T, serializer: KSerializer) { + private fun doTest(inputs: List, expected: T, serializer: KSerializer, jsonImpl: Json = json) { for (input in inputs) { - assertEquals(expected, json.decodeFromDynamic(serializer, input), "Failed on input: $input") + assertEquals(expected, jsonImpl.decodeFromDynamic(serializer, input), "Failed on input: $input") } } @@ -49,6 +49,20 @@ class JsonCoerceInputValuesDynamicTest { } } + @Test + fun testUseNullWithImplicitNulls() { + val withImplicitNulls = Json(json) { explicitNulls = false } + doTest( + listOf( + js("""{}"""), + js("""{"enum":"incorrect"}"""), + ), + JsonCoerceInputValuesTest.NullableEnumHolder(null), + JsonCoerceInputValuesTest.NullableEnumHolder.serializer(), + withImplicitNulls + ) + } + @Test fun testUseDefaultInMultipleCases() { val testData = mapOf( diff --git a/formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonDecoder.kt b/formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonDecoder.kt index 2121b27aed..ee3eb03b72 100644 --- a/formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonDecoder.kt +++ b/formats/json/commonMain/src/kotlinx/serialization/json/internal/TreeJsonDecoder.kt @@ -197,31 +197,35 @@ private open class JsonTreeDecoder( ) : AbstractJsonTreeDecoder(json, value, polymorphicDiscriminator) { private var position = 0 private var forceNull: Boolean = false - /* - * Checks whether JSON has `null` value for non-null property or unknown enum value for enum property - */ - private fun coerceInputValue(descriptor: SerialDescriptor, index: Int, tag: String): Boolean = - json.tryCoerceValue( - descriptor, index, - { currentElement(tag) is JsonNull }, - { (currentElement(tag) as? JsonPrimitive)?.contentOrNull } - ) override fun decodeElementIndex(descriptor: SerialDescriptor): Int { while (position < descriptor.elementsCount) { val name = descriptor.getTag(position++) val index = position - 1 forceNull = false - if ((name in value || absenceIsNull(descriptor, index)) - && (!configuration.coerceInputValues || !coerceInputValue(descriptor, index, name)) - ) { + + if (name in value || setForceNull(descriptor, index)) { + // if forceNull is true, then decodeNotNullMark returns false and `null` is automatically inserted + // by Decoder.decodeIfNullable + if (!configuration.coerceInputValues) return index + + if (json.tryCoerceValue( + descriptor, index, + { currentElementOrNull(name) is JsonNull }, + { (currentElementOrNull(name) as? JsonPrimitive)?.contentOrNull }, + { // an unknown enum value should be coerced to null via decodeNotNullMark if explicitNulls=false : + if (setForceNull(descriptor, index)) return index + } + ) + ) continue // do not read coerced value + return index } } return CompositeDecoder.DECODE_DONE } - private fun absenceIsNull(descriptor: SerialDescriptor, index: Int): Boolean { + private fun setForceNull(descriptor: SerialDescriptor, index: Int): Boolean { forceNull = !json.configuration.explicitNulls && !descriptor.isElementOptional(index) && descriptor.getElementDescriptor(index).isNullable return forceNull @@ -257,6 +261,8 @@ private open class JsonTreeDecoder( override fun currentElement(tag: String): JsonElement = value.getValue(tag) + fun currentElementOrNull(tag: String): JsonElement? = value[tag] + override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder { // polyDiscriminator needs to be preserved so the check for unknown keys // in endStructure can filter polyDiscriminator out. diff --git a/formats/json/jsMain/src/kotlinx/serialization/json/internal/DynamicDecoders.kt b/formats/json/jsMain/src/kotlinx/serialization/json/internal/DynamicDecoders.kt index fc9a5236c0..f5d6bd61d9 100644 --- a/formats/json/jsMain/src/kotlinx/serialization/json/internal/DynamicDecoders.kt +++ b/formats/json/jsMain/src/kotlinx/serialization/json/internal/DynamicDecoders.kt @@ -85,7 +85,22 @@ private open class DynamicInput( val name = descriptor.getTag(currentPosition++) val index = currentPosition - 1 forceNull = false - if ((hasName(name) || absenceIsNull(descriptor, index)) && (!json.configuration.coerceInputValues || !coerceInputValue(descriptor, index, name))) { + + if (hasName(name) || setForceNull(descriptor, index)) { + // if forceNull is true, then decodeNotNullMark returns false and `null` is automatically inserted + // by Decoder.decodeIfNullable + if (!json.configuration.coerceInputValues) return index + + if (json.tryCoerceValue( + descriptor, index, + { getByTag(name) == null }, + { getByTag(name) as? String }, + { // an unknown enum value should be coerced to null via decodeNotNullMark if explicitNulls=false : + if (setForceNull(descriptor, index)) return index + } + ) + ) continue // do not read coerced value + return index } } @@ -94,7 +109,7 @@ private open class DynamicInput( private fun hasName(name: String) = value[name] !== undefined - private fun absenceIsNull(descriptor: SerialDescriptor, index: Int): Boolean { + private fun setForceNull(descriptor: SerialDescriptor, index: Int): Boolean { forceNull = !json.configuration.explicitNulls && !descriptor.isElementOptional(index) && descriptor.getElementDescriptor(index).isNullable return forceNull