Skip to content
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

Missing empty constructor issue: deserialization breaking change from 2.17 to 2.18 #846

Open
2 of 4 tasks
baylrock opened this issue Nov 9, 2024 · 16 comments
Open
2 of 4 tasks
Labels

Comments

@baylrock
Copy link

baylrock commented Nov 9, 2024

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

It appears that 2.18 introduced a change to the constructor detection, causing existing use cases to fail. I observed it in a case where a class extending a Map without an empty constructor can no longer be instantiated. See the test case example.

To Reproduce

With jackson 2.18.0 onboard:

    @Test
    fun test() {
        assertThrows<MissingKotlinParameterException> {
            jacksonObjectMapper().readValue<Old>("""{ "key":"value" }""")
        }

        assertDoesNotThrow {
            jacksonObjectMapper().readValue<New>("""{ "key":"value" }""")
        }
    }

    // what was working prior to 2.18
    class Old : TreeMap<String, String> {
        constructor(map: Map<String, String>) : super(map)
    }

    // what has to be changed to work with 2.18
    class New : TreeMap<String, String> {
        constructor() : super()
        constructor(map: Map<String, String>) : super(map)
    }

Expected behavior

Changes should be backward-compatible per the versioning standard (minor version changed).

Versions

Kotlin:
Jackson-module-kotlin: 2.18.0
Jackson-databind: 2.18.0

Additional context

I'm not 100% sure if this is a Kotlin module issue or not, as I operate in Kotlin codebase only and not invested enough to test it in plane java.
Generally, the fix to the issue is simple; the questionable part is that, if this is intentional, this is technically a breaking change in a minor version change.

@baylrock baylrock added the bug label Nov 9, 2024
@cowtowncoder
Copy link
Member

cowtowncoder commented Nov 9, 2024

Needs to be checked against 2.18.1 at least (ideally also 2.18.2-SNAPSHOT, build from 2.18 branch, but that's optional) as there are multiple relevant jackson-databind fixes.

@fischl-viesure
Copy link

I can confirm the bug with these versions of jackson-module-kotlin:

I also tested with version 2.17.2 and the behavior to assure that the MissingKotlinParameterException is not thrown with the older version, as described.

@k163377
Copy link
Contributor

k163377 commented Nov 16, 2024

The root cause of this also seemed to be the same as #841 (FasterXML/jackson-databind#4777).

I first checked how the Old constructor is handled in KotrinamesAnnotationIntrospector and both were determined to be creator with mode=DEFAULT.
To be precise, 2.17 uses findCreatorAnnotation and 2.18 uses findDefaultCreator, but since the mode is the same, there is probably no difference.

On the other hand, as a result of StdValueInstantiator.createFromObjectSettings now registering withArgsCreator, valueInstantiator.canCreateFromObjectWith now returns true.
This changes the processing path of MapDeserializer.deserialize, causing KotlinValueInstantiator.createFromObjectWith to be called unexpectedly, resulting in an error.

@cowtowncoder
Copy link
Member

@baylrock Please include actual exception message (with at least some of the stack trace).

@cowtowncoder
Copy link
Member

cowtowncoder commented Nov 17, 2024

It looks like intent was for Constructor to use "Delegating" mode; and if so perhaps it is necessary to use annotation

@JsonCreator(mode = JsonCreator.Mode.DELEGATING)

since I don't see any good heuristics for databind to determine this intent (constructor parameter name and underlying property. Not sure why in 2.17 that would be mode selected.

EDIT: Actually, I do have a guess. Perhaps it is due to type of single-argument being Map, tilting heuristics toward delegating.

@baylrock
Copy link
Author

@cowtowncoder issue was found when migrating from 2.12 to 2.18. So it shouldn't be about 2.17 being weird.

@cowtowncoder
Copy link
Member

cowtowncoder commented Nov 17, 2024

@baylrock That is assuming behavior as of 2.17 was correct, which while not unreasonable assumption is not always true. In case of refactoring of property introspection for 2.18, logic was rewritten to address actual flaws for some well-known cases, but making sure all unit tests pass. Problem here is that there is an unlimited number of combinations, edge cases, and so coverage is always incomplete. While case presented here is not very complex, it seems likely it wasn't covered, and change in logic was not detected.

The question, then, is which of behaviors is more correct: that of 2.17, or that of 2.18.
Put another way: just because 2.17 behaves in certain way is not a guarantee it is the intended ("correct") way.

Having said that, I do now have one idea wrt why Creater mode heuristics may have changed with 2.18 -- I vaguely remember there being some logic to consider some types (specifically Map) as indicating preference towards choosing Mode.DELEGATING.

But with all of this said: I would suggest adding explicit

@JsonCreator(mode = JsonCreator.Mode.DELEGATING)

(with whatever mechanism Kotlin requires)
for the intended Constructor. That will eliminate any need to use heuristics on work with any 2.x version o Jackson.

@fischl-viesure
Copy link

@baylrock Please include actual exception message (with at least some of the stack trace).

I take the liberty and step in for baylrock, 'cause I just happened to see the updates to this ticket now.

This is the exception when instantiating class Old with version 2.18.2:

com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException: Instantiation of [map type; class JacksonTest$Old, [simple type, class java.lang.String] -> [simple type, class java.lang.String]] value failed for JSON property map due to missing (therefore NULL) value for creator parameter map which is a non-nullable type
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 17] (through reference chain: JacksonTest$Old["N/A"]->JacksonTest$Old["map"])

	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.std.MapDeserializer._deserializeUsingCreator(MapDeserializer.java:711)
	at com.fasterxml.jackson.databind.deser.std.MapDeserializer.deserialize(MapDeserializer.java:432)
	at com.fasterxml.jackson.databind.deser.std.MapDeserializer.deserialize(MapDeserializer.java:32)
	at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)
	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4917)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3860)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3843)
	at JacksonTest.test(JacksonTest.kt:34)
	at java.base/java.lang.reflect.Method.invoke(Method.java:568)

Also, annotating the constructor, as you described, with…

@JsonCreator(mode = JsonCreator.Mode.DELEGATING)

…made the exception go away 🥳 (anyway, my problem turned out to not being the one described here 😊).

Thank you cowtowncoder for helping us along!

@rafalh
Copy link

rafalh commented Jan 9, 2025

I've stepped onto this issue too, but in a slightly different case. I assume the root cause is the same.
This test shows the change of behavior:

data class MapWrapper1 @JsonCreator constructor(private val m: Map<String, String>) {
    val foo by m
}
data class MapWrapper2(private val m: Map<String, String>) {
    val foo by m
}
class JacksonKotlinTest {
    private val objectMapper = jacksonObjectMapper()
    private val json = """{"foo": "a", "bar": "b"}"""

    @Test
    fun test1() {
        val v = objectMapper.readValue(json, MapWrapper1::class.java)
        assertEquals("a", v.foo)
    }

    @Test
    fun test2() {
        val v = objectMapper.readValue(json, MapWrapper2::class.java)
        assertEquals("a", v.foo)
    }
}

test1 always passes. test2 passes in Jackson 2.17 and fails in Jackson 2.18.
Please note that I don't have to set the mode in @JsonCreator to DELEGATING and instead keep it as the default (DEFAULT).

The question, then, is which of behaviors is more correct: that of 2.17, or that of 2.18.

I am wondering the same. It seems the current behavior is more in line with pure Jackson without Kotlin module. I've tested normal Java classes and records and in both versions they failed to deserialize if there was no @JsonCreator annotation.

Anyway, it's a bit counter-intuitive that @JsonCreator(Mode.DEFAULT) is not implicit. I would assume this annotation is only needed if there are more than one constructor or if I want a non-default mode. Default mode is documented: "DEFAULT means that caller is to use standard heuristics for choosing mode to use.". For some reason Jackson doesn't use that standard heuristics in the case above (single constructor with Map parameter).

If the current behavior of 2.18 is considered valid, it should probably be documented as a breaking change in 2.18 to help people migrate.

@cowtowncoder
Copy link
Member

Quick note: missing @JsonCreator is very different from @JsonCreator(mode = DEFAULT) -- latter indicates annotated Constructor/Factory method is explicitly to be used. Missing @JsonCreator means it won't be, unless auto-detection kicks in (when no Creator is annotated and there's just one viable candidate).

Not sure that helps untangle the issue but thought I'll mention it.

@aishanand
Copy link

aishanand commented Jan 15, 2025

We upgraded to spring boot 3.4 that uses jackson-core 2.18.0 and the existing code broke with the error. When will this issue be fixed?? It's forcing all the applications that went through upgrade to have the empty constructors only work fine :(

https://github.com/aishanand/personal- Github code space to recreate the issue

Exception in thread "main" java.lang.IllegalArgumentException: Cannot construct instance of com.jackson.issue.deserialize.dto.FruitDTO(although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('{"name":null,"flavorDTO":{"color":"yellow","taste":"sweet","smell":{"smellsGood":true}}}') at [Source: UNKNOWN; byte offset: #UNKNOWN] at com.fasterxml.jackson.databind.ObjectMapper._convert(ObjectMapper.java:4636) at com.fasterxml.jackson.databind.ObjectMapper.convertValue(ObjectMapper.java:4567) at com.jackson.issue.deserialize.DeserializeApplication.main(DeserializeApplication.java:18) Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance ofcom.jackson.issue.deserialize.dto.FruitDTO (although at least one Creator exists): no String-argument constructor/factory method to deserialize from String value ('{"name":null,"flavorDTO":{"color":"yellow","taste":"sweet","smell":{"smellsGood":true}}}') at [Source: UNKNOWN; byte offset: #UNKNOWN] at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:63) at com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1754) at com.fasterxml.jackson.databind.DeserializationContext.handleMissingInstantiator(DeserializationContext.java:1379) at com.fasterxml.jackson.databind.deser.std.StdDeserializer._deserializeFromString(StdDeserializer.java:311) at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromString(BeanDeserializerBase.java:1592) at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeOther(BeanDeserializer.java:197) at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:187) at com.fasterxml.jackson.databind.ObjectMapper._convert(ObjectMapper.java:4631)

Image

[Note:https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.4-Release-Notes that uses 2.18.0]

@cowtowncoder
Copy link
Member

cowtowncoder commented Jan 15, 2025 via email

@aishanand
Copy link

@cowtowncoder
2.18.2 is indeed used by spring 3.4 too !
here is the pom https://github.com/aishanand/personal/blob/main/pom.xml
dependency tree
Image

@cowtowncoder
Copy link
Member

Ah. I only saw the email notification and replied without reading the context.

As to fix, it is not clear behavior can or will be changed.

But instead of adding an empty constructor (which really shouldn't be needed) please considering adding annotation -- something I have mentioned multiple times in this thread:

@JsonCreator(mode = JsonCreator.Mode.DELEGATING)

on constructor. That will indicate that the incoming value should match the full argument value of 1-arg constructor, and not 1 property of JSON Object with name matching that argument.

I will also file an issue on jackson-databind to see if there is a way to create Java-only reproduction, and if so if the behavior could be changed.

@cowtowncoder
Copy link
Member

Ok the issue I created on databind side does not seem to capture actual issue. Reading through comments here there may even be multiple different problem cases.

But fundamentally what would be needed would be Java translation of failing test case here, so I could see what is going on and perhaps how to resolve it.

One case I did notice tho was the case with no annotations: Jackson 2.18 can only auto-detect Mode.PROPERTIES constructors implicitly.

But Kotlin module could detect things differently, by KotlinAnnotationIntrospector.kt or KotlinNamesAnnotationIntrospector.kt implementing/overriding findDefaultCreator() -- that would then be used by databind as THE constructor to use.

It looks like this already exists, but I guess it won't detect case(s) here. WDYT @k163377 ?

@k163377
Copy link
Contributor

k163377 commented Feb 8, 2025

@cowtowncoder
I have submitted an Issue regarding a question I discovered during the translation process to Java.
Please check it.
FasterXML/jackson-databind#4960

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

6 participants