Skip to content

Deserialization issue, @JsonCreator ignored #932

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
3 of 4 tasks
fabienfleureau opened this issue Mar 21, 2025 · 8 comments
Open
3 of 4 tasks

Deserialization issue, @JsonCreator ignored #932

fabienfleureau opened this issue Mar 21, 2025 · 8 comments
Labels

Comments

@fabienfleureau
Copy link

fabienfleureau commented Mar 21, 2025

Search before asking

  • I searched in the issues and found nothing similar.
  • I have confirmed that the same problem is not reproduced if I exclude the KotlinModule.
  • 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 FasterXML/jackson-databind#5040 but someone suggested to ask here

@JooHyukKim
Copy link
Member

JooHyukKim commented Mar 22, 2025

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
Copy link
Member

JooHyukKim commented Mar 22, 2025

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
Copy link
Member

JooHyukKim commented Mar 22, 2025

@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.🤔

@k163377
Copy link
Contributor

k163377 commented Mar 22, 2025

@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
Copy link
Contributor

k163377 commented Mar 22, 2025

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

@k163377
Copy link
Contributor

k163377 commented Mar 23, 2025

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)
}

@fabienfleureau
Copy link
Author

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.

@cowtowncoder
Copy link
Member

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

4 participants