jackson-module-kotlin
jackson-module-kotlin copied to clipboard
[Bug] A data class that has an inline class-typed property can't be deserialized
Describe the bug A data class that has an inline class-typed property can't be deserialized.
To Reproduce Run:
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
inline class MyInlineClass(val i: Int)
data class MyDataClass(val i: MyInlineClass)
fun main() {
val mapper = jacksonObjectMapper()
val json = mapper.writeValueAsString(MyDataClass(MyInlineClass(1)))
println(json)
val deserialized = mapper.readValue<MyDataClass>(json)
println(deserialized)
}
Expected behavior The JSON is properly deserialized.
Actual behavior
{"i":1}
Exception in thread "main" com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `MyDataClass` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (String)"{"i":1}"; line: 1, column: 2]
Or, if there are multiple fields in the data class:
{"i":1,"j":1}
Exception in thread "main" com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `MyDataClass` (no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator)
at [Source: (String)"{"i":1,"j":1}"; line: 1, column: 2]
Versions Kotlin: 1.4.21 Jackson-module-kotlin: 2.12.1 Jackson-databind: 2.12.1
Additional context I've tried to dig through this and discovered that adding an inline class-typed property causes some constructor changes - the compiled data class has two constructors:
// access flags 0x2
private <init>(I)V
// access flags 0x1001
public synthetic <init>(ILkotlin/jvm/internal/DefaultConstructorMarker;)V
The second one is ignored by Jackson because it's synthetic. The first one is correctly discovered, however, that constructor is invisible through Kotlin reflection, so Jackson-module-kotlin fails to discover the parameter names and is not able to use that constructor.
The problem can not be worked around by using @JsonProperty
because Kotlin compiler places it onto the parameters of the synthetic constructor, and no annotations appear on the private constructor.
This is a well-known issue, but I didn't realize that it had to do with the constructors being synthetic! Is there no way for Jackson to find synthetic stuff at runtime?
FWIW I'm running into what looks like the same issue using a Kotlin value class
running the following versions:
Kotlin: 1.5.0-RC Jackson-module-kotlin: 2.11.3 Jackson-databind: 2.11.3
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
@JvmInline
value class MyInlineClass(val i: Int)
data class MyDataClass(val i: MyInlineClass)
data class MyDataClassTwoProps(
// This annotation seems to be required due to the property name getting mangled
@get:JsonProperty(value = "i")
val i: MyInlineClass,
@get:JsonProperty(value = "j")
val j: MyInlineClass,
)
fun main() {
val json = objectMapper.writeValueAsString(MyDataClassTwoProps(MyInlineClass(1), 2))
println(json)
val deserialized = objectMapper.readValue<MyDataClassTwoProps>(json)
println(deserialized)
val json2 = objectMapper.writeValueAsString(MyDataClass(MyInlineClass(1)))
println(json2)
val deserialized2 = objectMapper.readValue<MyDataClass>(json2)
println(deserialized2)
}
Interestingly enough, the data class with two properties does deserialize correctly, regardless of the type of the second property (either a value/inline class or a normal class). Only the data class with a single value class
property throws an exception using the above versions.
@dvail That one should be easy to fix. Single-field class constructors are often problematic, anyway. Try writing:
data class MyDataClass @JsonCreator(mode = JsonCreator.DELEGATING) constructor(val i: MyInlineClass)
and see what happens. The above is from memory, so I'm not sure if the delegating mode constant is uppercase or what.
You may or may not also need that JsonProperty annotation.
What @ragnese said, with just one twist: DELEGATING
means that the incoming JSON value must match the argument value, and nothing else -- typically used with simple scalar types like int
, boolean
or String
. Conversely, PROPERTIES
is one that would expect a JSON Object with one property, with name matching.
So I guess that if you want behavior similar to 2- or 3-argument case, you would add:
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
Sorry- yes, @cowtowncoder is right. I was thinking with the mindset of wanting the class to act like a wrapper, which is what I was just doing recently. To have it act like a nested object, you'd use PROPERTIES
Thanks for the tips! Unfortunately I was unable to get this working with either DELEGATING
or PROPERTIES
:
@JvmInline
value class MyInlineClass(private val i: Int)
data class MyDataClass @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) constructor(
@get:JsonProperty(value = "i")
val i: MyInlineClass,
)
fun main() {
val json = objectMapper.writeValueAsString(MyDataClass(MyInlineClass(42)))
println(json)
val deserialized = objectMapper.readValue<MyDataClass>(json)
println(deserialized)
}
What did serve as a workaround in my case though was defining a static create method on the data class as described in this comment: https://github.com/FasterXML/jackson-module-kotlin/issues/199#issuecomment-782184942
@JvmInline
value class MyInlineClass(private val i: Int)
data class MyDataClass(
@get:JsonProperty(value = "i")
val test: MyInlineClass,
) {
companion object {
@JsonCreator
@JvmStatic
fun create(test: Int) = MyDataClass(MyInlineClass(test))
}
}
The key thing here was that the name of the parameter passed to create
must be the same name as the parameter passed to the data class.
One comment on test: please avoid "write then read immediately" construct on reproductions; or, if using both, verify both intermediate JSON and resulting object. This way it is possible to reason about case more easily. This because serialization and deserialization sides are related but not closely coupled, so it is usually necessary to consider them separately. In this case, in particular, it is important to know actual JSON being read and how that matches to K Object.
Similarly, instead of printing out things, assertions are good as they point out expectations: showing just what happens does not always tell what your expectation was (and verbal descriptions are more ambiguous than assert statements).
Here is a complete test code based on the previous message of @dvail
Tested module:
com.fasterxml.jackson.module:jackson-module-kotlin:2.13.4
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
@JvmInline
value class MyInlineClass(val i: Int)
data class MyDataClass(val i: MyInlineClass)
data class MyDataClassTwoProps(
@get:JsonProperty(value = "i")
val i: MyInlineClass,
@get:JsonProperty(value = "j")
val j: MyInlineClass,
)
data class MyNormalDataClass(val i: Int)
class ObjectMapperInlineClassTest {
private val objectMapper = jacksonObjectMapper()
@Test
fun `data class without inline class`() {
val obj = MyNormalDataClass(1)
val json = objectMapper.writeValueAsString(obj)
Assertions.assertEquals("{\"i\":1}", json)
val deserialized = objectMapper.readValue<MyNormalDataClass>(json) // works
Assertions.assertEquals(obj, deserialized)
}
@Test
fun `data class with one inline class`() {
val obj = MyDataClass(MyInlineClass(1))
val json = objectMapper.writeValueAsString(obj)
Assertions.assertEquals("{\"i\":1}", json)
val deserialized = objectMapper.readValue<MyDataClass>(json) // throws MismatchedInputException exception
Assertions.assertEquals(obj, deserialized)
}
@Test
fun `data class with two inline class`() {
val obj = MyDataClassTwoProps(MyInlineClass(1), MyInlineClass(2))
val json = objectMapper.writeValueAsString(obj)
Assertions.assertEquals("{\"i\":1,\"j\":2}", json)
val deserialized = objectMapper.readValue<MyDataClassTwoProps>(json) // throws MismatchedInputException exception
Assertions.assertEquals(obj, deserialized)
}
}
I had some tests. For data class with 2+ inline value fields is OK with the following module register. For data class with 1 field can use JsonIgnore field to workaround. Back to basic, a data class with only 1 value field means the value class self can represent as domain model and no need data class wrapper.
https://github.com/hmchangm/getting-start-QK/blob/master/src/test/kotlin/tw/brandy/ironman/InlineJacksonTest.kt
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule
import io.quarkus.test.junit.QuarkusTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Test
import javax.inject.Inject
@JvmInline
value class MyInlineClass(val i: Int)
data class MyDataClass(val i: MyInlineClass, @JsonIgnore val _i:Int=0)
data class MyDataClassTwoProps(
val i: MyInlineClass,
val j: MyInlineClass
)
data class MyNormalDataClass(val i: Int)
class ObjectMapperInlineClassTest {
val objectMapper : ObjectMapper = jacksonObjectMapper()
.registerModule(Jdk8Module()).registerModule(ParameterNamesModule())
@Test
fun `data class without inline class`() {
val obj = MyNormalDataClass(1)
val json = objectMapper.writeValueAsString(obj)
Assertions.assertEquals("""{"i":1}""", json)
val deserialized = objectMapper.readValue(json,MyNormalDataClass::class.java) // works
Assertions.assertEquals(obj, deserialized)
}
@Test
fun `data class with one inline class`() {
val obj = MyDataClass(MyInlineClass(1))
val json = objectMapper.writeValueAsString(obj)
Assertions.assertEquals("""{"i":1}""", json)
val deserialized = objectMapper.readValue(json,MyDataClass::class.java)
Assertions.assertEquals(obj, deserialized)
}
@Test
fun `data class with two inline class`() {
val obj = MyDataClassTwoProps(MyInlineClass(1), MyInlineClass(2))
val json = objectMapper.writeValueAsString(obj)
Assertions.assertEquals("""{"i":1,"j":2}""", json)
val deserialized = objectMapper.readValue(json,MyDataClassTwoProps::class.java) // throws MismatchedInputException exception
Assertions.assertEquals(obj, deserialized)
}
}
I am working on deserialization support for value class
in an experimental project I created (jackson-module-kogera), so please give it a try.
I would appreciate a star to keep me motivated.
I will basically report on the progress of this project in #199.
This issue is closed as the issue regarding deserialization support for value class
related content will be summarized in #650.