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

Deserialization issue, @JsonCreator ignored

Open fabienfleureau opened this issue 8 months ago • 8 comments

Search before asking

  • [x] I searched in the issues and found nothing similar.
  • [x] I have confirmed that the same problem is not reproduced if I exclude the KotlinModule.
  • [x] I searched in the issues of databind and other modules used and found nothing similar.
  • [ ] I have confirmed that the problem does not reproduce in Java and only occurs when using Kotlin and KotlinModule.

Describe the bug

Hello, While updating to spring boot 3.4.x I faced an issue related to the upgrade of jackson dependencies from 2.17.3 to 2.18.2 Deserialization is not working anymore for a class with 2 constructor, one with @JsonCreator annotation.

It fails with this exception:

Instantiation of [simple type, class User] value failed for JSON property fullName due to missing (therefore NULL) value for creator parameter fullName which is a non-nullable type
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 6, column: 13] (through reference chain: User["fullName"])
com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException: Instantiation of [simple type, class User] value failed for JSON property fullName due to missing (therefore NULL) value for creator parameter fullName which is a non-nullable type
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 6, column: 13] (through reference chain: User["fullName"])
	at com.fasterxml.jackson.module.kotlin.KotlinValueInstantiator.createFromObjectWith(KotlinValueInstantiator.kt:97)
	at com.fasterxml.jackson.databind.deser.impl.PropertyBasedCreator.build(PropertyBasedCreator.java:214)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:541)
	at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1497)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:348)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185)
	at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)
	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4931)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3868)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3851)
	at UserDeserializationTest.test user deserialization(UserDeserializationTest.kt:25)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)
	at java.base/java.util.ArrayList.forEach(ArrayList.java:1596)

It seems it take the wrong constructor, I also try with @JsonIgnore and @JsonCreator(mode=DISABLED) on the first constructor but it didn't help. Any idea on how to solve this issue?

To Reproduce

Project with failing test can be found on this repo https://github.com/fabienfleureau/jackson-deserialization-issue/

I have defined this class:

class User(
    val age: Int,
    fullName: String,
) {
    var firstName: String = fullName.split(" ").first()
    var lastName: String = fullName.split(" ").last()

    fun fullName() = "$firstName $lastName"

    @JsonCreator
    constructor(): this(
        age = 0,
        fullName = "John Doe",
    )
}

and the json to deserialize looks like this:

            {
                "age": 25,
                "firstName": "Jane",
                "lastName": "Doe"
            }

Expected behavior

I expected to have a user deserialized having age set to 25, firstName set to Jane and lastName set to Doe

Versions

Kotlin: 2.1 Jackson-module-kotlin: 2.18.3 Jackson-databind: 2.18.3

Additional context

also created an issue in databind https://github.com/FasterXML/jackson-databind/issues/5040 but someone suggested to ask here

fabienfleureau avatar Mar 21 '25 14:03 fabienfleureau

Which constructor are you expecting to call? If I understand creator/constructor priority correctly, should be calling one with @JsonCreator thus age=0 and fullName=John Doe. Maby I misunderestand Kotlin-module behavior.

JooHyukKim avatar Mar 22 '25 04:03 JooHyukKim

Full reproduction

class TestGithub22222 {

    class User(
        val age: Int,
        fullName: String,
    ) {
        var firstName: String = fullName.split(" ").first()
        var lastName: String = fullName.split(" ").last()

        fun fullName() = "$firstName $lastName"

        @JsonCreator
        constructor() : this(
            age = 0,
            fullName = "default value",
        )
    }


    @Test
    fun testUser() {
        val objectMapper = jsonMapper {
            addModule(kotlinModule())
        }
        val userJson = """
            {
                "age": 25,
                "firstName": "My",
                "lastName": "Name"
            }
        """
        val deserializedUser = objectMapper.readValue<User>(userJson)
        assertEquals(25, deserializedUser.age)
        assertEquals("My Name", deserializedUser.fullName())
    }

}

Analysis

In 2.17 version : No Kotlin module functionality worked. Straight up jackson-databind module --check below screen shot of readValue stacktrace. Executed in follwoing order....

  1. empty constructor is called (annotated with @JsonCreator)
  2. then setter methods are called
Image

In 2.18 version

  • call path goes down to KotlinValueInstantiator.createFromObjectWith()
  • Calls the class User(val age: Int, fullName: String) for deserialization, can't find fullName as parameter, so fails.
  • Test would pass when we JSON input contains "fullName"
Image

JooHyukKim avatar Mar 22 '25 04:03 JooHyukKim

@cowtowncoder This may be another Kotlin module issue post-property-introspection-rewrite in databind.

Solution idea... short term

I wonder if instead we can...

  1. create another extension function for deciding which creator to choose like say we have providePrimaryCreator() extend by Kotlin module... then
  2. New version of createFromObjectWith(DeserializationContext ctxt, Object[] args, Creator primaryCreator) is called.

Opinion on long-term

Regardless, we want to slim down KotlinValueInstantiator for Jackson 3 realease since Kotlin module is quite widely used. I can't remember which issue but there was similar issue around KotlinValueInstantiator.createFromObjectWith() and property-rewrite in the past.🤔

JooHyukKim avatar Mar 22 '25 05:03 JooHyukKim

@JooHyukKim Personally, I felt that there may be an error in the databind regarding prioritization between DefaultCreator and explicit creator.

Since kotlin-module only reports DefaultCreator, the KotlinValueInstantiator._withArgsCreator should be set to the explicitly specified no-argument constructor. On the other hand, debugging and checking actually sets the DefaultCreator.

I will see if it can be reproduced in Java.

k163377 avatar Mar 22 '25 08:03 k163377

I have submitted the issue to databind as I have detected a defect that can be reproduced at least in Java only. https://github.com/FasterXML/jackson-databind/issues/5045

k163377 avatar Mar 22 '25 09:03 k163377

It may be superfluous, but I personally felt that it would be better to change the DTO. It is a bit forced to write it, but the following is a possible approach.

data class User private constructor(
    val age: Int,
    val firstName: String,
    val lastName: String,
) {
    private constructor(age: Int, fullName: List<String>) : this(age, fullName[0], fullName[1])
    constructor(age: Int, fullName: String) : this(age, fullName.split(" "))
}

Also, given the generation rules regarding fullName, it seems to me that it would be better to create a DTO like the following and use that.

data class FullName(
    val firstName: String,
    val lastName: String,
) {
    companion object {
        fun fromRawFullName(fullName: String) = fullName.split(" ").let {
            if (it.length != 2) throw TODO()

            FullName(it[0], it[1])
        }
    }
}

data class User private constructor(
    val age: Int,
    val firstName: String,
    val lastName: String,
) {
    constructor(age: Int, fullName: FullName) : this(age, fullName.firstName, fullName.lastName)
}

k163377 avatar Mar 23 '25 05:03 k163377

Hello, indeed the dto is not well structured but let's say I can't modify it due to "legacy" reasons. 🙃 Thanks for digging

Also, even by adding @JsonIgnore or @Creator(mode=DISABLED) to the default constructor it is not ignored.

fabienfleureau avatar Mar 24 '25 08:03 fabienfleureau

FWTW, @JsonCreator(mode = DISABLED) definitely should make constructor ignored (if not a bug). @JsonIgnore interesting -- conceptually it should cause ignoral, too, but might not be checked (worth an issue for jackson-databind).

Why former is not ignored is probably due to changes in processing -- some Creators are kept in processing list for a bit before removed so maybe accidentally passed to method that selects the preferred Creator.

cowtowncoder avatar Mar 24 '25 15:03 cowtowncoder

Quick note: databind side now has a fix for issue as reported (https://github.com/FasterXML/jackson-databind/issues/5045), for branch 2.x (2.21.0-SNAPSHOT).

That fix does lead to failures of Github722 (#722) tests FWTW.

cowtowncoder avatar Sep 09 '25 04:09 cowtowncoder

Filed #1051 for regression; it's an odd one.

cowtowncoder avatar Sep 10 '25 03:09 cowtowncoder

I am experiencing a similar issue: with 2.18.0 up to and including 2.19.2, the primary constructor (even if it is private) is selected over an explicit @JsonCreator unless I mark the primary constructor as @JsonCreator(mode = JsonCreator.Mode.DISABLED). For example, this crashes:

import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.module.kotlin.jsonMapper
import com.fasterxml.jackson.module.kotlin.kotlinModule

class JsonSafeImmutableMap private constructor(
    private val delegate: Map<String, Any?>
) : Map<String, Any?> {

    override val size: Int
        get() = delegate.size

    override fun isEmpty(): Boolean {
        return delegate.isEmpty()
    }

    override fun containsKey(key: String): Boolean {
        return delegate.containsKey(key)
    }

    override fun containsValue(value: Any?): Boolean {
        return delegate.containsValue(value)
    }

    override fun get(key: String): Any? {
        return delegate.get(key)
    }

    override val keys: Set<String>
        get() = delegate.keys

    override val values: Collection<Any?>
        get() = delegate.values

    override val entries: Set<Map.Entry<String, Any?>>
        get() = delegate.entries

    override fun equals(other: Any?): Boolean {
        return delegate.equals(other)
    }

    override fun hashCode(): Int {
        return delegate.hashCode()
    }

    override fun toString(): String {
        return delegate.toString()
    }

    companion object {
        @JsonCreator
        @JvmStatic
        fun from(jsonNode: JsonNode): JsonSafeImmutableMap? {
            if (jsonNode.isObject) {
                val map = LinkedHashMap<String, Any?>()
                for ((key, value) in jsonNode.properties()) {
                    map[key] = value
                }
                return JsonSafeImmutableMap(map)
            } else if (jsonNode.isNull) {
                return null
            } else {
                throw IllegalArgumentException()
            }
        }
    }
}

fun main(vararg args: String) {
    val objectMapper = jsonMapper {
        addModule(kotlinModule())
    }
    val actual = objectMapper.readValue("""{"a":1}""", JsonSafeImmutableMap::class.java)
    println(actual)
}

zhenlin-pay2 avatar Sep 22 '25 07:09 zhenlin-pay2

@zhenlin-pay2 Does 2.20.0 make any difference?

cowtowncoder avatar Sep 22 '25 22:09 cowtowncoder

2.20.0 has the same issue.

zhenlin-pay2 avatar Sep 23 '25 00:09 zhenlin-pay2