jackson-module-kogera icon indicating copy to clipboard operation
jackson-module-kogera copied to clipboard

Serialization of value classes implementing an interface uses underlying type of value class for array polymorphism

Open NilsWild opened this issue 1 year ago • 5 comments

When a value class object is serialized the unterlying type is used. If the value class implments an interface, array polymorphism is used to store the type. However the type is set to the underlying type instead of the value class type. I am not sure if it's possible to fix this or how as the value classes are inlined.

Example:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
sealed interface Printable {
    fun prettyPrint(): String
}

@JvmInline
value class Name(val s: String) : Printable {
    override fun prettyPrint(): String = "Let's $s!"
}

A Name object gets serialized to ["String","name"] when deserializing it can not be mapped for obvious reasons. Providing ["Name","name"] on the other hand can be deserialized. So the issue is only with serialization.

NilsWild avatar May 07 '24 12:05 NilsWild

Can you attach some more detailed code? To understand the situation, I would like to read the process of instantiating values and mappers and printing serialisation results.

k163377 avatar May 07 '24 13:05 k163377

created a demonstration project:

https://github.com/NilsWild/kotlin-serialization-issue

NilsWild avatar May 07 '24 14:05 NilsWild

It works for the data class, so it is a lack of functionality in the value class support.

import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.annotation.JsonValue
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.util.*

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
sealed interface Id {
    @get:JsonValue
    val value: UUID
}

data class PersonId(override val value: UUID) : Id

data class ThingId(override val value: UUID) : Id

data class PersonOrThing(val id: Id)

class GH230 {
    @Test
    fun `test serialization of value class`() {
        val id = PersonId(UUID.randomUUID())
        val json = jacksonObjectMapper().writeValueAsString(id)
        assertEquals("[\"PersonId\",\"${id.value}\"]", json)
    }

    @Test
    fun `test serialization of data class with value class attribute`() {
        val obj = PersonOrThing(PersonId(UUID.randomUUID()))
        val json = jacksonObjectMapper().writeValueAsString(obj)
        assertEquals("{\"id\":[\"PersonId\",\"${obj.id.value}\"]}", json)
    }

    @Test
    fun `test deserialization of value class by string`() {
        val id = PersonId(UUID.randomUUID())
        val deserialized = jacksonObjectMapper().readValue("[\"PersonId\",\"${id.value}\"]", Id::class.java)
        assertEquals(id, deserialized)
    }

    @Test
    fun `test deserialization of data class with value class attribute by string`() {
        val obj = PersonOrThing(PersonId(UUID.randomUUID()))
        val deserialized = jacksonObjectMapper().readValue("{\"id\":[\"PersonId\",\"${obj.id.value}\"]}", PersonOrThing::class.java)
        assertEquals(obj, deserialized)
    }
}

However, I have done some research and could not determine if this issue can be resolved. I would love to support this feature, but unfortunately I am very busy at the moment and need time to work on this issue.

k163377 avatar May 11 '24 20:05 k163377

How about something like this:

class ValueClassArrayPolymorphismSerializer(private val rawClass: Class<*>, private val cache: ReflectionCache) : JsonSerializer<Any>() {
    override fun serialize(value: Any, gen: JsonGenerator, serializers: SerializerProvider) {

        val unboxConverter = cache.getValueClassUnboxConverter(rawClass)
        val serializer = ValueClassStaticJsonValueSerializer.createOrNull(unboxConverter) ?: unboxConverter.delegatingSerializer

        gen.writeStartArray()
        gen.writeString(rawClass.simpleName)
        serializer.serialize(value, gen, serializers)
        gen.writeEndArray()
    }
}

And changing the KotlinSerializers class like that:

internal class KotlinSerializers(private val cache: ReflectionCache) : SimpleSerializers() {

    val valueClassSerializers = mutableMapOf<Class<*>, JsonSerializer<*>>()

    override fun findSerializer(
        config: SerializationConfig?,
        type: JavaType,
        beanDesc: BeanDescription?
    ): JsonSerializer<*>? {
        val rawClass = type.rawClass

        return when {
            UByte::class.java == rawClass -> UByteSerializer
            UShort::class.java == rawClass -> UShortSerializer
            UInt::class.java == rawClass -> UIntSerializer
            ULong::class.java == rawClass -> ULongSerializer
            // The priority of Unboxing needs to be lowered so as not to break the serialization of Unsigned Integers.
            rawClass.isUnboxableValueClass() -> {
                valueClassSerializers.getOrPut(rawClass){ValueClassArrayPolymorphismSerializer(rawClass, cache)}
            }
            else -> null
        }
    }
}

NilsWild avatar May 14 '24 10:05 NilsWild

https://github.com/NilsWild/jackson-module-kogera

with some improvements to not break custom serialization and deserialization

NilsWild avatar May 15 '24 11:05 NilsWild