jackson-databind icon indicating copy to clipboard operation
jackson-databind copied to clipboard

@JsonDeserialize(contentUsing=...) is ignored if content type is determined by @JsonTypeInfo

Open pdegoeje opened this issue 7 years ago • 14 comments

Tested versions: 2.9.0.pr3, 2.8.8 and 2.0.1

Expected behavior: Jackson respects @JsonDeserialize(contentUsing=...) even if the content's type is determined via @JsonTypeInfo. Actual behavior: @JsonDeserialize(contentUsing=...) is ignored.

The following example demonstrates the issue. The expected output is "ok". The observed output is empty. BarDeserializer is never called (nor constructed for that matter).

Removing the @JsonTypeInfo line "fixes" the example.

public class Test {
  static class BarDeserializer extends JsonDeserializer<Bar> {
    @Override
    public Bar deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
      System.out.println("ok");
      return p.readValueAs(Bar.class);
    }
  }

  @JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
  static class Bar {
    public int dummy;
  }

  static class Foo {
    @JsonDeserialize(contentUsing = BarDeserializer.class)
    public List<Bar> bars;
  }

  public static void main(String[] args) throws IOException {
    Foo foo = new Foo();
    foo.bars = Arrays.asList(new Bar());

    ObjectMapper om = new ObjectMapper();
    om.readValue(om.writeValueAsString(foo), Foo.class);
  }
}

pdegoeje avatar Jun 12 '17 10:06 pdegoeje

Hmmh. I will have to think hard for what would be the expected behavior. I can see why you would expect override to work here, but unfortunately due to handling of content (JsonDeserializer) being distinct from processing of polymorphic type (TypeDeserializer) it may be the case that this is actually working the way things need to, in order for all delegation to work as expected.

One thing that may help resolve this locally would be to add separate @JsonTypeInfo for List property, with use=Id.NONE -- that should disable polymorphic handling.

So: for now I can't yet say whether this is something that may be changed, but I hope to look into it. Thank you for reporting the issue.

cowtowncoder avatar Jun 12 '17 17:06 cowtowncoder

Yeah I worked around it by writing a custom deserializer for the entire list, which is basically a single line of extra code.

Perhaps this behavior could be documented in the mean time, because it isn't immediately obvious (to me at least) why contentUsing isn't compatible with polymorphic types.

pdegoeje avatar Jun 13 '17 10:06 pdegoeje

@pdegoeje Yes, documentation improvement would be a good idea.

cowtowncoder avatar Jun 14 '17 04:06 cowtowncoder

@pdegoeje could you share your workaround? I am facing the same issue without using @JsonTypeInfo, but with @JsonProperty

tkaymak avatar Sep 07 '17 13:09 tkaymak

@james-woods I ended up with something like the following:

  static class BarListDeserializer extends JsonDeserializer<List<Bar>> {
    @Override
    public List<Bar> deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException {
      List<Bar> barList = jsonParser.readValueAs(new TypeReference<List<Bar>>() { });
      // do post-processing
      return barList;
    }
  }

  static class Foo {
    // Use a custom deserializer for the entire list, instead of a per element deserializer.
    @JsonDeserialize(using = BarListDeserializer.class)
    public List<Bar> bars;
  }

For my use case, registering a list of polymorphic typed objects so they could be referenced by constructors from objects later in the same json file, this was sufficient.

pdegoeje avatar Sep 07 '17 14:09 pdegoeje

My use case is a parsing arrays full of null values that I wanted to map to a "NULL" string value. As it turns out, writing a custom deserializer on the String type is not enough for that. I think it needs to be one on List<String>.

tkaymak avatar Sep 07 '17 14:09 tkaymak

@james-woods That is odd -- defining method getNullValue(DeserializationContext ctxt) on custom String deserializer should work and there should be no need to override container deserializer. But this handling is distinct from regular deserialize calls, so maybe that's what gave the impression?

cowtowncoder avatar Sep 07 '17 17:09 cowtowncoder

@pdegoeje Actually, I am not quite sure what is being attempted here. If Bar says it (and its subtypes, which is implied by Jackson) are to be handled using polymorphic type handling, that's what needs to happen. If you are trying to remove that, you should be overriding @JsonTypeInfo with use=Id.NONE. Adding deserializer should not change this aspect, since deserializers do not deal with type id resolution, TypeDeserializers do. This is by design.

However, if the goal is to use custom deserializer beyond type handling I could see that as legit usage. Code just has to go via TypeDeserializer route -- and that would seem potentially problematic.

But all in all looking simply at your example, I think the proper solution here is to add both:

static class Foo {
    @JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
    @JsonDeserialize(contentUsing = BarDeserializer.class)
    public List<Bar> bars;
  }

and that this should work as expected.

cowtowncoder avatar Sep 07 '17 17:09 cowtowncoder

Added a failing test case that shows that override of @JsonTypeInfo with Id.NONE does not work as expected.

Can extend to handling of custom deserializer too, but I wanted to add test for one problem I understand clearly.

cowtowncoder avatar Sep 07 '17 17:09 cowtowncoder

I was doing it wrong @cowtowncoder - I've adjusted the code from @pdegoeje to illustrate my problem and forgot to override the mentioned method. The following works - ok2 is emitted :) PEBCAK.

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

public class Test {
    static class BarDeserializer extends JsonDeserializer<String> {
        @Override
        public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
            System.out.println("ok");
            return p.getValueAsString();
        }

        @Override
        public String getNullValue(DeserializationContext ctxt){
            System.out.println("ok2");
            return "null";
        }
    }

    static class Foo {
        @JsonDeserialize(contentUsing = BarDeserializer.class)
        public List<String> bars;
    }

    public static void main(String[] args) throws IOException {
        Foo foo = new Foo();
        ArrayList arrayList = new ArrayList();
        arrayList.add(null);
        arrayList.add(null);
        foo.bars = arrayList;

        ObjectMapper om = new ObjectMapper();
        String json = om.writeValueAsString(foo);
        System.out.println("json: " + json);
        om.readValue(json, Foo.class);
    }
}

tkaymak avatar Sep 08 '17 07:09 tkaymak

@cowtowncoder Yes, adding @JsonTypeInfo(use=JsonTypeInfo.Id.NONE) works as well.

Indeed, as I alluded to in my previous comment, my goal here was to be able to run some custom code after certain parts of the JSON file were loaded. Using a custom deserializer for this purpose is a hack which turns out to be a lot less code than either alternatives I explored: using full object graph persistence with manual fixup after deserialization or manually parsing the relevant parts with the streaming API.

To give some more context, I'm parsing a mini DSL which starts with a list of symbols with type information, followed by a list of functions (expression trees) which may reference any of the symbols. Because each expression node must be fully constructed after its constructor has completed, it must have access to the list of symbols during deserialization.

Eventually I might need to convert some of the parsing code to the streaming API anyway, but for now this solution lets jackson's objectmapper do all the heavy lifting, without a single line of actual custom parsing code. Which is pretty awesome :-)

pdegoeje avatar Sep 08 '17 13:09 pdegoeje

Very cool, thank you for sharing more context. I agree that ability to use databinding for most things is good and streaming is much more work. So I hope I can figure out how to improve handling here.

cowtowncoder avatar Sep 08 '17 23:09 cowtowncoder

The given workaround doesn't work for me when using @JsonDeserialize(using=...)

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
@JsonSubTypes(
    ...
)
sealed class Criteria

class CriteriaStringDeserializer : JsonDeserializer<Criteria>() {
    override fun deserialize(p: JsonParser, ctxt: DeserializationContext?): Criteria {
        ...
    }

}

data class MyConfigClass(
    @JsonTypeInfo(use=JsonTypeInfo.Id.NONE) @JsonDeserialize(using = CriteriaStringDeserializer::class) val criteria: Criteria? = null
)

Results in the error:

Caused by: com.fasterxml.jackson.databind.exc.InvalidTypeIdException: Missing type id when trying to resolve subtype of [simple type, class Criteria]: missing type id property 'type' (for POJO property 'criteria')

My use case is I have a DSL that can be represented as JSON or as a string - I'm reading from a config file where I want to be able to read write criteria as a String (because it's a lot easier to write the string DSL that the JSON version). I've created a custom deserializer that parses the string and returns the POJO representation but Jackson is still trying to apply the JsonTypeInfo annotation despite it being overridden on the property.

jebbench avatar Sep 28 '20 12:09 jebbench

I have what I think is the same or similar use case, which I found moving from 2.10 to 2.12 or 2.13, although the workaround does work for all versions (2.10, 2.12, 2.13).
However, it is not at all intuitive that the serializer from @JsonSerialize is used, but the deserializer annotated on the same field is not. In addition, I found that if you register the deserializer on the ObjectMapper it also works, which provides a bit more merit that the deserializer defined in @JsonDeserialize should not be ignored.

Here is a simplified use case. There also seems to be some difference between 2.10 and later versions, although I have not been able to reproduce in a simple use case and the workaround works for both. As in, the original code is very similar and is currently working with 2.10.5 without adding @JsonTypeInfo on the field, so also wondering if there has been some change in how this handled since 2.10.

Thanks

    @JsonTypeInfo(defaultImpl = Point.class, use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type")
    @Builder
    @Getter
    @EqualsAndHashCode
    public static class Point {
        private final int x;
        private final int y;
        private final int z;

        public static Point from(String str) {
            final String v = str.substring(1, str.length() - 1);
            final String[] c = v.split(",");
            return Point.builder()
                .x(Integer.parseInt(c[0]))
                .y(Integer.parseInt(c[1]))
                .z(Integer.parseInt(c[2]))
                .build();
        }

        @Override
        public String toString() {
            return "(" + x + "," + y + "," + z + ")";
        }
    }

    public static class PointSerializer extends JsonSerializer<Point> {
        @Override
        public void serialize(Point p, JsonGenerator gen, SerializerProvider sp)
                throws IOException {
            gen.writeString(p.toString());
        }

        @Override
        public void serializeWithType(Point value, JsonGenerator gen, SerializerProvider provider,
                TypeSerializer typeSer)
                throws IOException, JsonProcessingException {
            this.serialize(value, gen, provider);
        }
    }

    public static class PointDeserializer extends JsonDeserializer<Point> {
        @Override
        public Point deserialize(JsonParser jp, DeserializationContext dctx)
                throws IOException, JsonProcessingException {
            return Point.from(jp.getValueAsString());
        }
    }

    @NoArgsConstructor(access = AccessLevel.PRIVATE)
    @Getter(AccessLevel.PRIVATE)
    @EqualsAndHashCode
    public static class PointContainer {

        @JsonProperty("name")
        private String name;

        // It works if this is uncommented
        // @JsonTypeInfo(use = JsonTypeInfo.Id.NONE)
        @JsonSerialize(using = PointSerializer.class)
        @JsonDeserialize(using = PointDeserializer.class)
        @JsonProperty("pt")
        private Point point;

        @Builder
        public PointContainer(String name, Point point) {
            this.name = name;
            this.point = point;
        }
    }

    @Test
    public void testRoundTrip() throws JsonProcessingException {
        final ObjectMapper mapper = new ObjectMapper();
        final SimpleModule module = new SimpleModule();
        module.addDeserializer(Point.class, new PointDeserializer());
        // It also works if this is uncommented
        // mapper.registerModule(module);
        final Point p = new Point(100, 200, 300);
        final PointContainer pc = new PointContainer("test-meta", p);
        final String ser = mapper.writeValueAsString(pc);
        final PointContainer deser = mapper.readValue(ser, PointContainer.class);
        Assert.assertEquals(pc, deser);
 }

thinkrest avatar Feb 10 '22 09:02 thinkrest