FasterXML / jackson-module-kotlin

Module that adds support for serialization/deserialization of Kotlin (http://kotlinlang.org) classes and data classes.
Apache License 2.0
1.12k stars 175 forks source link

Deserialization fails for class with single private accessor property #753

Open hotire opened 8 months ago

hotire commented 8 months ago

Search before asking

Describe the bug

Deserialization fails for class with single private accessor property.

When deserializing, a MismatchedInputException occurs.

To Reproduce


class A(
    private val name: String,
)
class B(
    private val name: String,
    private val age: Int,
)

private val mapper: ObjectMapper = jacksonObjectMapper()

@Test
fun fail() {
    // given
    val json = """
        {"name" : "hello"}
    """.trimIndent()
    // when, then
    shouldThrow<MismatchedInputException> {
        mapper.readValue(json, A::class.java)
    }
}

@Test
fun success() {
    // given
    val json = """
        {"name" : "hello", "age": 12}
    """.trimIndent()
    shouldNotThrowAny {
        mapper.readValue(json, B::class.java)
    }
}

Expected behavior

A class with single private accessor property is properly deserialized.

Actual behavior

com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of `test.SimpleTest$A` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)
 at [Source: (String)"{"name" : "hello"}"; line: 1, column: 2]

    at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:63)

Versions

Kotlin: 1.8.0 Jackson-module-kotlin: 2.12.7 Jackson-databind: 2.12.7.1

Additional context

I tried to dig into this and discovered the flow below: (Code has been simplified)

  1. In the "BasicDeserializerFactory _addExplicitAnyCreator" method, it is processed according to the number of constructor parameters.
 protected void _addExplicitAnyCreator(DeserializationContext ctxt,
            BeanDescription beanDesc, CreatorCollector creators,
            CreatorCandidate candidate, ConstructorDetector ctorDetector)
        throws JsonMappingException
    {
        // Looks like there's bit of magic regarding 1-parameter creators; others simpler:
        if (1 != candidate.paramCount()) {

        }
    }
  1. Because of the private accessor, the useProps variable is set to false, so _creators[C_PROPS] of CreatorCollector cannot be set.

default:
            { // Note: behavior pre-Jackson-2.12
                final BeanPropertyDefinition paramDef = candidate.propertyDef(0);
                // with heuristic, need to start with just explicit name
                paramName = candidate.explicitParamName(0);

                // If there's injection or explicit name, should be properties-based
                useProps = (paramName != null) || (injectId != null);
                if (!useProps && (paramDef != null)) {
                    // One more thing: if implicit name matches property with a getter
                    // or field, we'll consider it property-based as well

                    // 25-May-2018, tatu: as per [databind#2051], looks like we have to get
                    //    not implicit name, but name with possible strategy-based-rename
        //            paramName = candidate.findImplicitParamName(0);
                    paramName = candidate.paramName(0);
                    useProps = (paramName != null) && paramDef.couldSerialize();
                }
            }

if (useProps) {
   SettableBeanProperty[] properties = new SettableBeanProperty[] {
                    constructCreatorProperty(ctxt, beanDesc, paramName, 0, param, injectId)
      };
   creators.addPropertyCreator(candidate.creator(), true, properties);
   return;
}
  1. The MismatchedInputException occurs because "_propertyBasedCreator" is null in the "BeanDeserializer.deserializeFromObjectUsingNonDefault" method
k163377 commented 8 months ago

Thank you for digging deeper.

By the way, is this a bug in KotlinModule? If databind is the cause, please check there and report.

As a side note, if you are creating a Java only sample code regarding deserialization, you need to set JsonCreator in the constructor.

hotire commented 8 months ago

Hello.

In case of Java it succeeds.

The test code is below.

class JacksonJavaTest {

    final ObjectMapper mapper = new ObjectMapper();

    static class A {
        private final String name;

        @JsonCreator
        public A(@JsonProperty("name") String name){
            this.name = name;
        }
    }

    static class B {
        private final String name;
        private final int age;

        @JsonCreator
        public B(
                @JsonProperty("name")String name,
                @JsonProperty("age")int age){
            this.name = name;
            this.age = age;
        }
    }

    @Test
    void success() throws JsonProcessingException {
        final String json = "{\"name\" : \"hello\", \"age\": 12}";
        mapper.readValue(json, B.class);
    }

    @Test // expected fail but success
    void fail() throws JsonProcessingException {
        final String json = "{\"name\" : \"hello\"}";
        mapper.readValue(json, A.class);
    }
}
marilatte53 commented 7 months ago

I'm having the same problem when deserializing the following class:

class StringFromListGenerator(
    val options: List<String> // Bug in Kotlin module: this cannot be private
) : ArgumentGenerator {
    override fun generate(random: Random): String {
        return options[random.nextInt(options.size)]
    }
}

When I try to deserialize this from JSON:

{
  "options": [
    "ABC",
    "DEF",
    "123"
  ]
}

I get a misleading error message:

Cannot deserialize value of type java.util.ArrayList<java.lang.String> from Object value (token JsonToken.FIELD_NAME)

k163377 commented 7 months ago

I have been very busy for a while and do not have time to check this issue.

Personally, I think the problem is related to AnnotationIntrospector::findImplicitPropertyName. In kotlin-module, it is implemented as KotlinNamesAnnotationIntrospector::findImplicitPropertyName.

The sample code you gave us uses JsonProperty to name it, but in kotlin-module it is named using the above function. In other words, the reproduced code in Java that you gave me does not actually reproduce the behavior of kotlin-module completely.

I have a vague recollection that a similar issue was previously posted on kotlin-module and was closed as a duplication of a databind or core issue.

Your help in a more in-depth investigation may speed up the resolution of the issue.

jool commented 2 months ago

Here is a possibly related example. I am not sure if the root cause is the same, perhaps I should open a new issue for this but this was similar enough to entice me to first post this as a comment here.

The interesting part here is the behaviour of deser into class B, which has a single field that unconventionally starts with an uppercase

data class A(
    val foo: String,
)

data class B(
    val Foo: String
)

data class C(
    val Foo: String,
    val Bar: String
)

data class D(
    @JsonProperty("Foo")
    val Foo: String,
)

fun main() {
    val om = ObjectMapper().apply {
        registerKotlinModule()
        disable(DeserializationFeature.FAIL_ON_IGNORED_PROPERTIES)
        disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
    }
    val json = """{"foo": "bar", "Foo": "Foo", "Bar": "Bar", "a": "b"}  """

    om.readValue<A>(json) // Works
    om.readValue<B>(json) // Fails with Exception in thread "main" ... Cannot construct instance of B` (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)

    om.readValue<C>(json) // Works
    om.readValue<D>(json) // Works
}