Can Jackson polymorphic deserialization be used to serialize to a subtype if a specific field is present?
Asked Answered
S

6

39

Using a spin on the zoo example:

public class ZooPen {
    public String type;
    public List<Animal> animals;
}

public class Animal {
    public String name;
    public int age;
}

public class Bird extends Animal {
    public double wingspan;
}

I want to use polymorphic deserialization to construct Animal instances if no wingspan is specified, and Bird instances if it is. In Jackson, untyped deserialization typically looks something like this:

@JsonTypeInfo( 
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "wingspan",
    visible = true,
    defaultImpl = Animal.class
)
@JsonSubTypes({
    @Type(value = Bird.class, name = "bird")
})  
public class Animal {
    ...
}

The wingspan value can be anything, and without that matching something specifically, Jackson falls back on the defaultImpl class.

I could probably use @JsonCreator:

@JsonCreator
public static Animal create(Map<String,Object> jsonMap) 
        throws JsonProcessingException {

    ObjectMapper mapper = new ObjectMapper();
    if (jsonMap.get("wingspan") == null) {
        // Construct and return animal
    } else {
        // Construct and return bird
    }
}

However, then I have to manually handle extra values and throw consistent exceptions, and it's not clear if the Animal would be serialized properly later.

It seems I can use my own TypeResolver or TypeIdResolver, but that seems like more work than just deserializing the raw json myself. Additionally, TypeResolver and TypeIdResolver seem to intrinsically assume that type info is serialized, so those aren't good to use.

Would it be feasible to implement my own JsonDeserializer that hooks into the lifecycle to specify type, but still uses basic Jackson annotation processing functionality? I've been having a look at JsonDeserializer.deserializeWithType(...), but that seems to delegate deserialization entirely to a TypeDeserializer. There's also the issue that I'd need to deserialize some of the object before I know which type to use.

Alternatively, there might be a way to target the type of zoo pen, even though it's in the parent object.

Is there a way to do what I want with polymorphic type handling?

Salpingectomy answered 10/5, 2013 at 18:50 Comment(2)
Did you ever find a way of achieving this? I've been having a similar problem, but with multiple Bird-like subclasses, each with a certain field whose presence identifies that subclass.Cavil
Does this answer your question? Deserializing polymorphic types with JacksonDeerhound
B
16

As of Jackson 2.12.2, the following accomplishes the goal using the "deduction-based polymorphism" feature. If properties distinct to the Bird subtype (i.e. wingspan) are present, the deserialized type will be Bird; else it will be Animal:

@JsonTypeInfo(use=Id.DEDUCTION, defaultImpl = Animal.class)
@JsonSubTypes({@Type(Bird.class)})
public class Animal {
    public String name;
    public int age;
}

Deduction-based polymorphism

The deduction-based polymorphism feature deduces subtypes based on the presence of properties distinct to a particular subtype. If there isn't a subtype uniquely identifiable by the subtype-specific properties, the type specified by defaultImpl value will be used.

The deduction-based polymorphism feature was implemented per jackson-databind#43 in Jackson 2.12, and is summarized in the 2.12 release notes:

It basically allows omitting of actual Type Id field or value, as long as the subtype can be deduced (@JsonTypeInfo(use=DEDUCTION)) from existence of fields. That is, every subtype has a distinct set of fields they included, and so during deserialization type can be uniquely and reliably detected.

This ability to specify a default type — rather than throw an exception — when there is no uniquely identifiable subtype was added by jackson-databind#3055 in Jackson 2.12.2:

In the absence of a single candidate, defaultImpl should be the target type regardless of suitability.

A slightly longer explanation of deduction-based polymorphism is given in the Jackson 2.12 Most Wanted (1/5): Deduction-Based Polymorphism article written by the Jackson creator.

Benavides answered 12/2, 2021 at 7:16 Comment(0)
R
3

EDIT: If you can use the latest Jackson release candidate, your problem is solved. I assembled a quick demo here https://github.com/MariusSchmidt/de.denktmit.stackoverflow/tree/main/de.denktmit.jackson

You should take a look at this thread https://github.com/FasterXML/jackson-databind/issues/1627, as it discusses your problem and proposes a solution. There is a Merge, that looks promising to me https://github.com/FasterXML/jackson-databind/pull/2813. So you might try to follow the path of @JsonTypeInfo(use = DEDUCTION).

If however you can not use the latest upcoming Jackson version, here is what I would likely do:

Backport the merge request, OR

  1. Use Jackson to deserialize the input into a general JsonNode
  2. Use https://github.com/json-path/JsonPath check for one or more properties existence. Some container class could wrap all the paths needed to uniquely identify a class type.
  3. Map the JsonNode to the determined class, as outlined here Convert JsonNode into POJO

This way, you can leverage the full power of Jackson without handling low-level mapping logic

Best regards,

Marius

Animal

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Bird;
import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Fish;
import org.junit.jupiter.api.Test;

import java.util.List;

import static com.fasterxml.jackson.annotation.JsonTypeInfo.Id.DEDUCTION;
import static org.assertj.core.api.Assertions.assertThat;

@JsonTypeInfo(use = DEDUCTION)
@JsonSubTypes( {@JsonSubTypes.Type(Bird.class), @JsonSubTypes.Type(Fish.class)})
public class Animal {
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

Bird

public class Bird extends de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal {
    private double wingspan;

    public double getWingspan() {
        return wingspan;
    }

    public void setWingspan(double wingspan) {
        this.wingspan = wingspan;
    }
}

Fish

public class Fish extends de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal {

    private boolean freshwater;

    public boolean isFreshwater() {
        return freshwater;
    }

    public void setFreshwater(boolean freshwater) {
        this.freshwater = freshwater;
    }
}

ZooPen

public class ZooPen {

    private String type;
    private List<de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal> animals;

    public String getType() {
        return type;
    }

    public void setType(String type) {
        this.type = type;
    }

    public List<de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal> getAnimals() {
        return animals;
    }

    public void setAnimals(List<de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal> animals) {
        this.animals = animals;
    }
}

The test

import com.fasterxml.jackson.databind.ObjectMapper;
        import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal;
        import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Bird;
        import de.denktmit.stackoverflow.jackson.polymorphic.deductive.Fish;
        import de.denktmit.stackoverflow.jackson.polymorphic.deductive.ZooPen;
        import org.junit.jupiter.api.Test;

        import static org.assertj.core.api.Assertions.assertThat;

public class DeductivePolymorphicDeserializationTest {

    private static final String birdString = "{\n" +
            "      \"name\": \"Tweety\",\n" +
            "      \"age\": 79,\n" +
            "      \"wingspan\": 2.9\n" +
            "    }";

    private static final String fishString = "{\n" +
            "      \"name\": \"Nemo\",\n" +
            "      \"age\": 16,\n" +
            "      \"freshwater\": false\n" +
            "    }";

    private static final String zooPenString = "{\n" +
            "  \"type\": \"aquaviary\",\n" +
            "  \"animals\": [\n" +
            "    {\n" +
            "      \"name\": \"Tweety\",\n" +
            "      \"age\": 79,\n" +
            "      \"wingspan\": 2.9\n" +
            "    },\n" +
            "    {\n" +
            "      \"name\": \"Nemo\",\n" +
            "      \"age\": 16,\n" +
            "      \"freshwater\": false\n" +
            "    }\n" +
            "  ]\n" +
            "}";
    private final ObjectMapper mapper = new ObjectMapper();

    @Test
    void deserializeBird() throws Exception {
        de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal animal = mapper.readValue(birdString, de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal.class);
        assertThat(animal).isInstanceOf(de.denktmit.stackoverflow.jackson.polymorphic.deductive.Bird.class);
    }

    @Test
    void deserializeFish() throws Exception {
        de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal animal = mapper.readValue(fishString, de.denktmit.stackoverflow.jackson.polymorphic.deductive.Animal.class);
        assertThat(animal).isInstanceOf(de.denktmit.stackoverflow.jackson.polymorphic.deductive.Fish.class);
    }

    @Test
    void deserialize() throws Exception {
        de.denktmit.stackoverflow.jackson.polymorphic.deductive.ZooPen zooPen = mapper.readValue(zooPenString, de.denktmit.stackoverflow.jackson.polymorphic.deductive.ZooPen.class);
        assertThat(zooPen).isInstanceOf(de.denktmit.stackoverflow.jackson.polymorphic.deductive.ZooPen.class);
    }
}
Receipt answered 22/11, 2020 at 12:24 Comment(0)
G
0

While not directly answering your question, I did think it was worth pointing out that it's not overly burdensome to use @JsonCreator:

@JsonCreator
public static Animal create(Map<String,Object> jsonMap) {
    String name = (String) jsonMap.get("name");
    int age = (int) jsonMap.get("age");
    if (jsonMap.keySet().contains("wingspan")) {
        double wingspan = (double) jsonMap.get("wingspan");
        return new Bird(name, age, wingspan);
    } else {
        return new Animal(name, age);
    }
}

No need to throw JsonProcessingException. This custom deserializer would fail for exactly the same reasons that the built-in Jackson deserializer would, namely, casting exceptions. For complex deserialization I prefer this way of doing things, as it makes the code much easier to understand and modify.

Grossularite answered 29/4, 2019 at 15:14 Comment(0)
S
0

You can use pretius-jddl deserialization to achieve your goal. I extended the class hierarchy a bit to show how it works. Here's a sample code:

public class SOAnswer3 {

    @ToString @Getter @Setter
    @AllArgsConstructor @NoArgsConstructor
    public static class Animal {
        String name;
        int age;
    }

    @ToString(callSuper = true) @Getter @Setter
    @AllArgsConstructor @NoArgsConstructor
    public static class Bird extends Animal {
        double wingspan;
    }

    @ToString(callSuper = true) @Getter @Setter
    @AllArgsConstructor @NoArgsConstructor
    public static class Elephant extends Animal {
        double trunkLength;
    }

    public static void main(String[] args) {
        String json = "[{"
                + "    \"name\": \"Marty\","
                + "    \"age\": 3"
                + "},"
                + "{"
                + "    \"name\": \"Danny\","
                + "    \"age\": 7,"
                + "    \"wingspan\": 1.4159"
                + "},{"
                + "    \"name\": \"King\","
                + "    \"age\": 21,"
                + "    \"trunkLength\": 2.11"
                + "}]";
        
        // create a deserializer instance
        DynamicObjectDeserializer deserializer = new DynamicObjectDeserializer();
        
        // runtime-configure deserialization rules
        deserializer.addRule(DeserializationRuleFactory.newRule(1, // priority 
                DeserializationCriterionFactory.targetClassIsAssignableFrom(Animal.class)
                    .and((e) -> e.getJsonNode().has("wingspan")),
                DeserializationActionFactory.objectToType(Bird.class)));
        
        deserializer.addRule(DeserializationRuleFactory.newRule(1,
                DeserializationCriterionFactory.targetClassIsAssignableFrom(Animal.class)
                    .and((e) -> e.getJsonNode().has("trunkLength")),
                DeserializationActionFactory.objectToType(Elephant.class)));
        
        List<Animal> deserializedObjects = deserializer.deserializeArray(json, Animal.class);
        
        for (Animal obj : deserializedObjects) {
            System.out.println("Deserialized Class: " + obj.getClass().getSimpleName()+";\t value: "+obj.toString());
        }
    }
}

Result:

Deserialized Class: Animal;  value: SOAnswer3.Animal(name=Marty, age=3)
Deserialized Class: Bird;    value: SOAnswer3.Bird(super=SOAnswer3.Animal(name=Danny, age=7), wingspan=1.4159)
Deserialized Class: Elephant;    value: SOAnswer3.Elephant(super=SOAnswer3.Animal(name=King, age=21), trunkLength=2.11)

Maven depenendency for pretius-jddl (check newest version at maven.org/jddl:

<dependency>
  <groupId>com.pretius</groupId>
  <artifactId>jddl</artifactId>
  <version>1.0.0</version>
</dependency>
Staffordshire answered 21/12, 2020 at 9:17 Comment(0)
C
-1

If you're not married to Jackson, I believe something similar to this can be accomplished using FlexJSON.

http://flexjson.sourceforge.net/javadoc/flexjson/JSONDeserializer.html

I'm unfamiliar with Jackson's methods for doing similar things, but I can say that FlexJSON is very performant, and in general intuitive to use during serialization/deserialziation steps.

Chancellorsville answered 21/10, 2013 at 1:35 Comment(0)
S
-1

Hi Shaun you can achieve this behaviour with quite easily actually with Jackson using inheritance. I have modeled the Animal and Bird scenario here.

The constructors inside the Impls allow for the correct instance of the Animal to be instantiated (i.e An Animal if name and age are present and Bird if name age and wingspan are present). This will work the same for retrieving values over an API using something like Jersey

@com.fasterxml.jackson.annotation.JsonSubTypes({
    @com.fasterxml.jackson.annotation.JsonSubTypes.Type(AnimalImpl.class)
})
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(as = AnimalImpl.class)
public interface Animal {

    public String getName();

    public int getAge();
}

public class AnimalImpl implements Animal {

    private final String name;
    private final int age;

    public AnimalImpl(
        @JsonProperty("name") final String name,
        @JsonProperty("age") final int age
    ) {
    this.name = Objects.requireNonNull(name);
    this.age = Objects.requireNonNull(age);
    }

    @Override
    public String getName() {
    return name;
    }

    @Override
    public int getAge() {
    return age;
    }
}

@com.fasterxml.jackson.annotation.JsonSubTypes({
    @com.fasterxml.jackson.annotation.JsonSubTypes.Type(BirdImpl.class)
})
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(as = BirdImpl.class)
public interface Bird extends Animal {

    public double getWingspan();
}

public class BirdImpl extends AnimalImpl implements Bird {

    private final double wingspan;

    public BirdImpl(
        @com.fasterxml.jackson.annotation.JsonProperty("name") final String name,
        @com.fasterxml.jackson.annotation.JsonProperty("age") final int age,
        @com.fasterxml.jackson.annotation.JsonProperty("wingspan") final double wingspan
    ) {
    super(name, age);
    this.wingspan = wingspan;
    }

    @Override
    public double getWingspan() {
    return wingspan;
    }
}

public class Test {

    public static void main(final String[] args) throws java.io.IOException {

    final com.fasterxml.jackson.databind.ObjectMapper objectMapper
        = new com.fasterxml.jackson.databind.ObjectMapper();

    final String animalJson = "{\"name\": \"the name\", \"age\": 42}";
    final Animal animal = objectMapper.readValue(animalJson, Animal.class);

    System.out.println(animal);

    final String birdJson = "{\"name\": \"the name\", \"age\": 42, \"wingspan\": 21}";
    final Bird bird = objectMapper.readValue(birdJson, Bird.class);

    System.out.println(bird);
    }
}
Sherrod answered 21/2, 2020 at 7:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.