Serialize/Deserialize using JsonTypeInfo

Viewed 1418

My goal is to convert a JSON string field to the right class using Jackson.

I have the following class:

public class AnimalRecord {

    private String id;
    private String source;
    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "source", include = JsonTypeInfo.As.EXTERNAL_PROPERTY)
    @JsonSubTypes(value = {
            @JsonSubTypes.Type(value = CatProbeMetadata.class, name 
 = "catProbeMetadata"),
    @JsonSubTypes.Type(value = DogProbeMetadata.class, name = "dogProbeMetadata"),
            })
   private AnimalMetadata metadata;

In addition to this class, I have a DB table where I store records of AnimalRecord (AnimalRecord = row). The AnimalMetadata is a different JSON string based on the source of this class. Each source has it's own metadata and class definition. In this example, CatProbeMetadata class will be the output when doing de-serialization from the string when the source is "cat".

The issue is that I'm not sure what to do when reading the rows from the DB. I have the following method:

private class ActiveProbeWrapper implements RowMapper<ActiveProbeRecord> {

        @Override
        public ActiveProbeRecord mapRow(ResultSet rs, int rowNum) throws SQLException {
            String id= rs.getString("id");
            String source= rs.getString("source");
            Animalmetadata metadata = // NOT SURE WHAT TO DO HERE;
            ActiveProbeRecord record = new ActiveProbeRecord(deviceId,segment, source, metadata);
            return record;
        }

    }

I need to convert the string in the DB to the right class instance, but my metadata string will NOT include the source (since it's outside the metadata JSON).

The question: Will I have to add the "source" field to the metadata itself or is there any better way of doing this that I missed?

Updated example: Example of DB rows: id | source | metadata 1 | catSource | {"catName": "Mewy"} 2 | dogSource | {"dogName": "Barky"}

When I read the rows from the DB I want to use the source field to de-serialize metadata to the right class - String --> CatMetadata

2 Answers

Jackson 2.12 introduced a new feature for type deduction :

@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes({
        @JsonSubTypes.Type(DogMetadata.class),
        @JsonSubTypes.Type(CatMetadata.class) })
public abstract class AnimalMetadata {
}

and therefore:

AnimalMetadata metadata = om.readValue("{\"catName\": \"Paws\"}", AnimalMetadata.class);
assertThat(metadata).isInstanceOf(CatMetadata.class);

The downside is that it might break if Jackson can't figure out which subtype to use based solely on properties names. With this solution, optional json fields (like an absent catName property), or too similar subtypes may rise problems. @Sergei solutions doesn't have these issues (also, his solution makes use of the source field, which was your requirement).

On a side note, if you're working with SpringBoot, upgrading jackson is a matter of adding this property in pom.xml

        <jackson-bom.version>2.12.3</jackson-bom.version>

The property attribute of the @JsonTypeInfo annotation marks the property that defines the entity subclass, and include = JsonTypeInfo.As.EXTERNAL_PROPERTY means that this property should be included not inside the metadata value, but on an upper level, as a property of the AnimalRecord class. This will only work if you parse the string as the AnimalRecord class.

This property should contain the value catProbeMetadata for cats and dogProbeMetadata of dogs, otherwise Jackson won't know how to parse the contents of your source field. The property may be also included inside the source string itself, but then you have to use include = JsonTypeInfo.As.PROPERTY.

Approach 1 - type is inside the metadata

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
        @JsonSubTypes.Type(value = CatProbeMetadata.class, name = "catProbeMetadata"),
        @JsonSubTypes.Type(value = DogProbeMetadata.class, name = "dogProbeMetadata"),
})
class AnimalMetadata {

    private String type;
}

class CatProbeMetadata extends AnimalMetadata {

    private String catName;
}

class DogProbeMetadata extends AnimalMetadata {

    private String dogName;
}

class AnimalRecord {

    private AnimalMetadata metadata;
}

Then you could parse it as follows:

ObjectMapper mapper = new ObjectMapper();

AnimalRecord catRecord = new AnimalRecord();
catRecord.setMetadata(mapper.readValue("{\"type\":\"catProbeMetadata\",\"catName\": \"Paws\"}", AnimalMetadata.class));

AnimalRecord dogRecord = new AnimalRecord();
dogRecord.setMetadata(mapper.readValue("{\"type\":\"dogProbeMetadata\",\"dogName\": \"Fido\"}", AnimalMetadata.class));

Approach 2 - type is outside of metadata

Just select the class manually, based on the type. You don't need any annotations:

class AnimalMetadata {
}

class CatProbeMetadata extends AnimalMetadata {
    private String catName;
}

class DogProbeMetadata extends AnimalMetadata {
    private String dogName;
}

class AnimalRecord {

    private String type;

    private AnimalMetadata metadata;
}

Then you can parse like this. Putting the selection logic inside a separate method has exactly same consequences as putting it into annotations - you just need to update a different piece of code if you want to add a new subclass:

public Class<? extends AnimalMetadata> getMetadataClass(AnimalRecord record) {
    switch (record.getType()) {
        case "cat":
            return CatProbeMetadata.class;
        case "dog":
            return DogProbeMetadata.class;
        default:
            throw new UnsupportedOperationException();
    }
}

public void parse() {
    ObjectMapper mapper = new ObjectMapper();

    AnimalRecord catRecord = new AnimalRecord();
    catRecord.setType("cat");
    catRecord.setMetadata(mapper.readValue("{\"catName\": \"Paws\"}", getMetadataClass(catRecord)));

    AnimalRecord dogRecord = new AnimalRecord();
    dogRecord.setType("dog");
    dogRecord.setMetadata(mapper.readValue("{\"dogName\": \"Fido\"}", getMetadataClass(dogRecord)));
}

Related