jackson-databind: Deserialization wrong when using a generic class with a converter as a property

Describe the bug Here are three classes and two converters

    @NoArg
    class OuterClass(
        val inner: TestClassOri<Int, String>,
    )

    @NoArg
    @JsonSerialize(converter = TestClassConverter::class)
    @JsonDeserialize(converter = TestClassOriConverter::class)
    class TestClassOri<T, K>(
        val name: T,
        val nameMap: HashMap<T, K>,
    )

    @NoArg
    class TestClass<T, K>(val hashMap: HashMap<T, HashMap<T, K>>)

    class TestClassConverter<T, K> : StdConverter<TestClassOri<T, K>, TestClass<T, K>>() {
        override fun convert(value: TestClassOri<T, K>): TestClass<T, K> {
            return TestClass(hashMapOf(value.name to value.nameMap))
        }
    }

    class TestClassOriConverter<T, K> : StdConverter<TestClass<T, K>, TestClassOri<T, K>>() {
        override fun convert(value: TestClass<T, K>): TestClassOri<T, K> {
            val (name, map) = value.hashMap.entries.first()
            return TestClassOri(name, map)
        }
    }

when deserialize the OuterClass, the generic type of OuterClass.inner.nameMap will be HashMap<String, String> but not HashMap<Int, String> which is expected.

Version information Which Jackson version(s) was this for? 2.14.2

    @Test
    fun testDeserialize() {
        val jsonMapper = jsonMapper()
        val jsonHashMap = jsonMapper.writeValueAsString(
            OuterClass(TestClassOri(1, hashMapOf(1 to "1", 12 to "12")))
        )
        val de = jsonMapper.readerFor(OuterClass::class.java)
            .readValue<OuterClass>(jsonHashMap)
        assertEquals(de.inner.nameMap.keys.first()::class.java, Int::class.java)
    }
org.opentest4j.AssertionFailedError: expected: <java.lang.String> but was: <int>

About this issue

  • Original URL
  • State: closed
  • Created a year ago
  • Comments: 21 (10 by maintainers)

Most upvoted comments

@yihtserns ,thanks a lot, ContextualDeserializer can really indeed solve the problem.

Serialization Deserialization
TestClassOri<Integer, String>(1, [12: "12"])
➜【TestClassConverter】
➜ TestClass<Object, Object>([1: [12: "12"]])⚠️
➜【???Serializer】
➜ {"hashMap":{"1":{"12":"12"}}}
{"hashMap":{"1":{"12":"12"}}}
➜【???Deserializer】
➜ TestClass<Object, Object>(["1": ["12": "12"]])⚠️
➜【TestClassOriConverter】
➜ TestClassOri<Object, Object>("1", ["12": "12"])💥

The illustration above is what I think happened:

  1. Converters don’t have “context” about what they’re processing, they just take INPUT and convert it into OUTPUT.
  2. Because of that, declaring any generic type <K> or <V> or <Whatever> on a Converter is just equivalent to declaring <Object>.

Only parameterized types are useful on a Converter, e.g. if you do this:

static class TestClassOriConverter extends StdConverter<TestClass<Integer, String>, TestClassOri<Integer, String>> {
    @Override
    public TestClassOri<Integer, String> convert(TestClass<Integer, String> value) {
        Map.Entry<Integer, HashMap<Integer, String>> entry =
                value.hashMap.entrySet().stream().findFirst().get();
        return new TestClassOri<>(entry.getKey(), entry.getValue());
    }
}

…your problem will be solved, but I think you don’t want that. You’re probably type-parameterizing TestClassOri differently in different places, e.g.

public TestClassOri<Integer, String> int2String;
public TestClassOri<Boolean, Integer> bool2Int;
public TestClassOri<String, String> string2String;

Deserializing with “context”

Warning Disclosure: Whatever I’m sharing below is what I’ve learned while playing around in my personal “just for fun” project.
I don’t have experience using them in production/real life, so I don’t know if I’m using them correctly - Jackson pros please feel free to correct any mistake, thanks.

As mentioned above, (I think) Converter API does not have any “context” of what they’re converting - they don’t have info about the “target” e.g. the target field during deserialization.

To access that information, you need to use StdDeserializer + ContextualDeserializer API:

...
@JsonDeserialize(using = TestClassOriDeserializer.class)
static class TestClassOri<T, K> {
    ...
}

static class TestClassOriDeserializer extends StdDeserializer<TestClassOri<Object, Object>> implements ContextualDeserializer {

    /**
     * For Jackson to instantiate and call {@link #createContextual(DeserializationContext, BeanProperty)}.
     */
    public TestClassOriDeserializer() {
        super(TestClassOri.class);
    }

    /**
     * Actual constructor to do work.
     */
    private TestClassOriDeserializer(JavaType targetTestClassOriType) {
        super(targetTestClassOriType);
    }

    /**
     * Get the target's (e.g. field) type info and use that to create the actual deserializer.
     */
    @Override
    public JsonDeserializer<?> createContextual(DeserializationContext context, BeanProperty property) {
        JavaType targetTestClassOriType = context.getContextualType();

        return new TestClassOriDeserializer(targetTestClassOriType);
    }

    @Override
    public TestClassOri<Object, Object> deserialize(JsonParser parser, DeserializationContext context) throws IOException {
        JavaType targetTestClassOriType = getValueType();
        TypeBindings targetTestClassOriParameterizedTypes = targetTestClassOriType.getBindings();

        JavaType parameterizedTestClass = context.getTypeFactory().constructParametricType(TestClass.class, targetTestClassOriParameterizedTypes);
        TestClass<Object, Object> value = context.readValue(parser, parameterizedTestClass);

        Map.Entry<Object, HashMap<Object, Object>> entry =
                value.hashMap.entrySet().stream().findFirst().get();
        return new TestClassOri<>(entry.getKey(), entry.getValue());
    }
}