Fix Polymorphic Deserialization Error with Subclass using JsonTransformingSerializer #3139
Fix Polymorphic Deserialization Error with Subclass using JsonTransformingSerializer #3139oungsi2000 wants to merge 5 commits intoKotlin:devfrom
Conversation
Pass the class discriminator to `JsonTreeDecoder` to enable `JsonTransformingSerializer` to correctly deserialize polymorphic types. A regression test is added to verify this fix.
Adds a regression test to ensure that nested polymorphic sealed classes can be correctly deserialized. The new test case covers a `sealed class` with a `List` of its own type and a nullable field of its own type, exercising recursive and nested polymorphic deserialization.
|
@sandwwraith Hello! I'm a first-time contributor. Could you please review this PR? Thanks |
The trailing newline at the end of `JsonTransformingSerializerTest.kt` has been removed.
There was a problem hiding this comment.
Thanks for your PR! As I explained in the comment, the change is too broad. The core reason for the current bug lies in the JsonTransformingSerializer.deserialize: by re-creating decoder in input.json.decodeFromJsonElement we forgo the already existing state of the JsonDecoder (which contains the class discriminator we want to know). Perhaps it can be rewritten in a way that it retrieves the discriminator itself, conceptually:
final override fun deserialize(decoder: Decoder): T {
val input = decoder.asJsonDecoder()
val element = input.decodeJsonElement()
val discriminator = when(input) {
is StreamingJsonDecoder -> input.discriminatorHolder?.discriminatorToSkip
is AbstractJsonTreeDecoder -> input.polymorphicDiscriminator
else -> null
}
return readJson(input.json, transformDeserialize(element), tSerializer, discriminator)
// return input.json.decodeFromJsonElement(tSerializer, transformDeserialize(element))
}Note that this is a rough draft, and I won't accept it in this form: there is still a need to account for other potential subclasses, and likely a nice API should be added to JsonDecoder to "continue" decoding elements with the current decoder instead of creating a new one.
| val correctExample = DocExample("str1") | ||
| assertEquals(correctExample, json.decodeFromString(DocExample.serializer(), """{"data":["str1"]}""", streaming)) | ||
| assertEquals(correctExample, json.decodeFromString(DocExample.serializer(), """{"data":"str1"}""", streaming)) | ||
| assertEquals( |
There was a problem hiding this comment.
Please do not add irrelevant changes to the PR, especially if they're about formatting.
| public fun <T> readJson(json: Json, element: JsonElement, deserializer: DeserializationStrategy<T>): T { | ||
| val input = when (element) { | ||
| is JsonObject -> JsonTreeDecoder(json, element) | ||
| is JsonObject -> JsonTreeDecoder(json, element, deserializer.descriptor.classDiscriminator(json)) |
There was a problem hiding this comment.
Sadly, this change is too broad to fix the problem. By using classDiscriminator(json), you're adding a discriminator to every JsonTreeDecoder, even one that was not originally polymorphic. Consider this class:
@Serializable(SubExample2.PolymorphicSerializer::class)
@KeepGeneratedSerializer
@SerialName("Sub")
data class SubExample2(
val data: String
) {
object PolymorphicSerializer : JsonTransformingSerializer<SubExample2>(generatedSerializer())
}It is almost identical to yours, but it does not have a superclass and therefore is not polymorphic. Therefore, we expect this test to fail:
@Test
fun testNonPolymorphicExampleShouldNotBeParsed() {
val input = """{"type":"Sub","data":"str1"}"""
val j = Json { ignoreUnknownKeys = false }
println(j.decodeFromString<SubExample2>(input)) // should throw "Encountered an unknown key 'type'"
}However, with your change, this test passes.
|
@sandwwraith Regarding the JsonTreeDecoder constructor, Regarding the "Nice API" you mentioned, I’ve identified three main implementations of JsonDecoder: StreamingJsonDecoder, AbstractJsonTreeDecoder, and DynamicInput. Since only StreamingJsonDecoder, AbstractJsonTreeDecoder need to hold discriminator state, internal interface PolymorphicJsonDecoder : JsonDecoder {
val discriminator: String?
}
internal open class StreamingJsonDecoder(...) : PolymorphicJsonDecoder, ... {
override val discriminator get() = discriminatorHolder?.discriminatorToSkip
}
private sealed class AbstractJsonTreeDecoder(...) : PolymorphicJsonDecoder, ... {
override val discriminator get() = polymorphicDiscriminator
}With this, the deserialize method can be simplified as follows: final override fun deserialize(decoder: Decoder): T {
//use if decoder should never be a DynamicInput.
//otherwise use: val discriminator = (input as? PolymorphicJsonDecoder)?.discriminator
val input = decoder.asPolymorphicJsonDecoder()
val element = input.decodeJsonElement()
return readJson(input.json, transformDeserialize(element), tSerializer, input.discriminator)
}Does this abstraction cover the "other potential subclasses" you had in mind, or would you prefer a different approach? |
|
The idea with public fun <T> decodeFromJsonElementAs(element: JsonElement, serializer: DeserializationStrategy<T>): T {
return readJson(json, element, serializer, this)
}we can add to public fun <T> readJson(json: Json, element: JsonElement, deserializer: DeserializationStrategy<T>, previousDecoder: JsonDecoder?): T {
val discriminator = (previousDecoder as? PolymorphicJsonDecoder)?.discriminator
... |
Pass the discriminator from the existing JsonDecoder instead of creating a new one to maintain consistency during JSON transformation.
|
@sandwwraith |
|
@sandwwraith |
Fixes #3108
I have identified an issue where JsonTransformingSerializer fails
during decoding when using
JsonTreeDecoderAfter debugging, I found the cause of the issue in the endStructure method of
JsonTreeDecoder:The issue is that when a JsonTreeDecoder instance is created,
the polymorphicDiscriminator is not explicitly set (remaining null).
As a result, the "type" key—which holds the polymorphic information—is treated as a regular unknown key, triggering a JsonDecodingException.
I have resolved this bug by explicitly setting the discriminator in the readJson method.
However, even though all unit tests and the build have passed (including 2 new test cases),
I am not entirely sure if this is the intended way to fix it.
Thanks