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

Mutator inference during deserialization doesn't work for Kotlin (data) objects

Open hudson155 opened this issue 1 year ago • 2 comments

Search before asking

  • [X] I searched in the issues and found nothing similar.
  • [X] I searched in the issues of databind and other modules used and found nothing similar.
  • [X] I have confirmed that the problem only occurs when using Kotlin.

Describe the bug

If I have a data class with a property defined in the body, deserializing works fine.

If I have a data object with a property defined in the body, deserializing throws an exception when deserializing due to an unrecognized field.

Exception in thread "main" com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException: Unrecognized field "int" (class Second), not marked as ignorable (0 known properties: ])

I think this works fine for classes due to MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS and MapperFeature.INFER_PROPERTY_MUTATORS (both default to true), but with Kotlin objects it doesn't work.

To Reproduce

data class First(
  val foo: String,
) {
  val int: Int = 1
}

data object Second {
  val int: Int = 2
}

fun main() {
  val mapper = jacksonObjectMapper()
  println(mapper.writeValueAsString(First("bar"))) // {"foo":"bar","int":1}
  println(mapper.writeValueAsString(Second)) // {"int":2}
  println(mapper.readValue<First>("{\"foo\":\"bar\",\"int\":1}")) // First(foo=bar)
  println(mapper.readValue<Second>("{\"int\":2}")) // THROWS!
}

Expected behavior

I expect no exception to be thrown in the above example.

Versions

Kotlin: 2.0.0 Jackson-module-kotlin: 2.17.2 Jackson-databind: 2.17.2

Additional context

No response

hudson155 avatar Aug 04 '24 02:08 hudson155

I just verified that this issue also exists when using Kotlin 1.9.25.

hudson155 avatar Aug 15 '24 20:08 hudson155

@hudson155 Sorry for the delay in replying.

I have checked and this appears to be normal behavior for Jackson. When deserializing a class, if an unrequested property exists on JSON, it fails by default. Enabling FAIL_ON_UNKNOWN_PROPERTIES will suppress this problem. https://javadoc.io/doc/com.fasterxml.jackson.core/jackson-databind/latest/com/fasterxml/jackson/databind/DeserializationFeature.html#FAIL_ON_UNKNOWN_PROPERTIES

package com.fasterxml.jackson.module.kotlin

import com.fasterxml.jackson.databind.DeserializationFeature

data object Second {
    val int: Int = 2
}

fun main() {
    val mapper = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    println(mapper.readValue<Second>("{\"int\":2}")) // -> Second
}

k163377 avatar Sep 14 '24 12:09 k163377

Closed as it appears to have been resolved.

k163377 avatar Nov 16 '24 09:11 k163377

It's not resolved @k163377. The bug still exists

See the tests here: https://github.com/hudson155/kairo/blob/main/kairo-serialization/src/test/kotlin/kairo/serialization/module/PolymorphismObjectMapperTest.kt

When I have these classes

internal sealed class Vehicle {
  abstract val model: String?

  abstract val wheels: Int

  internal data class Car(
    override val model: String,
    val plate: String,
    val capacity: Int,
  ) : Vehicle() {
    override val wheels: Int = 4
  }

  internal data class Motorcycle(
    val plate: String,
  ) : Vehicle() {
    override val model: Nothing? = null

    override val wheels: Int = 2
  }

  internal data object Bicycle : Vehicle() {
    override val model: Nothing? = null

    override val wheels: Int = 2
  }
}

These tests pass for the data classes

@Test
fun `deserialize, car`(): Unit = runTest {
  val string = "{\"type\": \"Car\", \"capacity\": 5, \"plate\": \"ABC 1234\", \"model\": \"Ford\", \"wheels\": 2}"
  val vehicle = Vehicle.Car(model = "Ford", plate = "ABC 1234", capacity = 5)
  mapper.readValue<Vehicle.Car>(string).shouldBe(vehicle)
  mapper.readValue<Vehicle>(string).shouldBe(vehicle)
}

@Test
fun `deserialize, motorcycle`(): Unit = runTest {
  val string = "{\"type\": \"Motorcycle\", \"plate\": \"MVM 12\", \"model\": null, \"wheels\": 2}"
  val vehicle = Vehicle.Motorcycle(plate = "MVM 12")
  mapper.readValue<Vehicle.Motorcycle>(string).shouldBe(vehicle)
  mapper.readValue<Vehicle>(string).shouldBe(vehicle)
}

but this test does NOT pass for the data object, which represents this bug.

@Test
fun `deserialize, bicycle`(): Unit = runTest {
  val vehicle = Vehicle.Bicycle
  val string = "{\"type\": \"Bicycle\", \"model\": null, \"wheels\": 2}"
  mapper.readValue<Vehicle.Bicycle>(string).shouldBe(vehicle)
  mapper.readValue<Vehicle>(string).shouldBe(vehicle)
}

hudson155 avatar Jun 28 '25 20:06 hudson155

@hudson155 Please submit a minimum reproduction, including mapper settings, etc. Also, tests should be written in JUnit5 if possible.

k163377 avatar Jul 12 '25 04:07 k163377

Here's a minimum reproduction

data class DataClass(val foo: String) {
  val extra: Int = 42
}

data object DataObject {
  val extra: Int = 42
}

fun main() {
  val mapper = jacksonObjectMapper()
  mapper.readValue<DataClass>("{ \"foo\": \"bar\", \"extra\": 42 } ") // works!
  mapper.readValue<DataObject>("{ \"extra\": 42 } ") // should work, but throws
}

hudson155 avatar Jul 17 '25 13:07 hudson155

@hudson155 As I replied to you first, try FAIL_ON_UNKNOWN_PROPERTIES.

val mapper = jacksonObjectMapper().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)

k163377 avatar Jul 26 '25 16:07 k163377

Right, with that workaround it won't fail deserialization ✅

It's correct that this bug only exists when FAIL_ON_UNKNOWN_PROPERTIES is turned on (default state), and that the bug is covered up by disabling the flag.

However, for use cases like mine where failing on unknown properties is in fact the desired behaviour, this workaround isn't a permanent alternative to a bug fix.

With POJOs and Kotlin data classes, disabling FAIL_ON_UNKNOWN_PROPERTIES is not required in order to successfully deserialize the same string that was serialized, which includes properties defined on the class but not in the constructor.

The data class in my example serializes to { \"foo\": \"bar\", \"extra\": 42 }, which can be deserialized just fine regardless of the user's choice on whether to ignore unknown properties, since although "extra" is not part of the constructor, Jackson recognizes that it exists as a property on the class and therefore isn't considered "unknown".

data class DataClass(val foo: String) {
  val extra: Int = 42
}

Trying to deserialize that same string to the data class if it were defined without "extra" would fail, which is expected since the property is now unknown.

data class DataClass(val foo: String)

While this behaviour works for data classes and POJOs, it's broken for Kotlin data objects. Trying to deserialize the string { "asdf": 42 } to the data object from my example should only work with the FAIL_ON_UNKNOWN_PROPERTIES flag disabled, because "asdf" is unknown. But trying to deserialize the string { "extra": 42 } should not fail either way. It's not an unknown property, since it's defined on the class.

hudson155 avatar Jul 26 '25 18:07 hudson155