How to map polymorphic JSON objects with JPA and Hibernate

If you are trading Stocks and Crypto using Revolut, then you are going to love RevoGain!

Introduction

In this article, I’m going to show you how you can map polymorphic JSON objects when using JPA and Hibernate.

Since Hibernate doesn’t support JSON natively, I’m going to use the Hibernate Types library to achieve this goal.

Polymorphic Types

Let’s assume we have the following DiscountCoupon class hierarchy:

DiscountCoupon class hierarchy

The DiscountCoupon is the base class of the AmountDiscountCoupon and PercentageDiscountCoupon concrete classes, which define two specific ways of discounting the price of a given Book entity.

The Book entity is mapped as follows:

@Entity(name = "Book")
@Table(name = "book")
public class Book {

    @Id
    @GeneratedValue
    private Long id;

    @NaturalId
    @Column(length = 15)
    private String isbn;

    @Column(columnDefinition = "jsonb")
    private List<DiscountCoupon> coupons = new ArrayList<>();
}

Note that we want to map the List of coupons to a JSON column in the database, and for this reason, we need a custom type that can handle the polymorphic types.

The default JsonType works just fine with concrete classes, but when using a generic List, the actual type is lost unless we pass it to the database at write time.

Mapping polymorphic JSON objects with Jackson DefaultTyping and Hibernate

One solution is to define a JsonType that allows us to handle class types that don’t have an explicit concrete type, as it’s the case of abstract classes or interfaces.

In our case, the DiscountCoupon is an abstract class, hence it cannot be instantiated by Jackson, so we need to know the exact class type of the DiscountCoupon object reference that we need to instantiate when loading the JSON column from the database.

And, for this reason, we can use the following custom JsonType:

ObjectMapper objectMapper = new ObjectMapperWrapper().getObjectMapper();

properties.put(
    "hibernate.type_contributors",
    (TypeContributorList) () -> Collections.singletonList(
        (typeContributions, serviceRegistry) ->
            typeContributions.contributeType(
                new JsonType(
                    objectMapper.activateDefaultTypingAsProperty(
                        objectMapper.getPolymorphicTypeValidator(),
                        ObjectMapper.DefaultTyping.OBJECT_AND_NON_CONCRETE,
                        "type"
                    ),
                    ArrayList.class
                ) {
                    @Override
                    public String getName() {
                        return "json-polymorphic-list";
                    }
                }
            )
    )
);

The json-polymorphic-list customizes the generic JsonType and provides a custom Jackson ObjectMapper that uses the DefaultTyping.OBJECT_AND_NON_CONCRETE strategy.

With the json-polymorphic-list registered, we just have to provide it to the coupons property:

@Type(type = "json-polymorphic-list")
@Column(columnDefinition = "jsonb")
private List<DiscountCoupon> coupons = new ArrayList<>();

Now, when persisting a Book entity:

entityManager.persist(
    new Book()
        .setIsbn("978-9730228236")
        .addCoupon(
            new AmountDiscountCoupon("PPP")
                .setAmount(new BigDecimal("4.99"))
        )
        .addCoupon(
            new PercentageDiscountCoupon("Black Friday")
                .setPercentage(BigDecimal.valueOf(0.02))
        )
);

For more details about how you can customize the Jackson ObjectMapper that the Hibernate Types project uses, check out this article as well.

Hibernate generates the following SQL INSERT statements:

INSERT INTO book (
  coupons, 
  isbn, 
  id
) 
VALUES (
  [
    {
      "type":"com.vladmihalcea.hibernate.type.json.polymorphic.AmountDiscountCoupon",
      "name":"PPP",
      "amount":4.99
    },
    {
      "type":"com.vladmihalcea.hibernate.type.json.polymorphic.PercentageDiscountCoupon",
      "name":"Black Friday",
      "percentage":0.02
    }
  ], 
  978-9730228236, 
  1
)

Notice that Jackson inserted the type property into the DiscountCoupon JSON objects. The type attribute will be used by Jackson when fetching the Book entity since the underlying JSON object needs to be populated to the associated DiscountCoupon subclass type.

And, when loading the Book entity, we can see it loads the DiscountCoupon objects properly:

Book book = entityManager.unwrap(Session.class)
    .bySimpleNaturalId(Book.class)
    .load("978-9730228236");

Map<String, DiscountCoupon> topics = book.getCoupons()
    .stream()
    .collect(
        Collectors.toMap(
            DiscountCoupon::getName,
            Function.identity()
        )
    );

assertEquals(2, topics.size());

AmountDiscountCoupon amountDiscountCoupon = 
    (AmountDiscountCoupon) topics.get("PPP");
assertEquals(
    new BigDecimal("4.99"),
    amountDiscountCoupon.getAmount()
);

PercentageDiscountCoupon percentageDiscountCoupon = 
    (PercentageDiscountCoupon) topics.get("Black Friday");
assertEquals(
    BigDecimal.valueOf(0.02),
    percentageDiscountCoupon.getPercentage()
);

Mapping polymorphic JSON objects with Jackson JsonTypeInfo

Another approach is using the Jackson @JsonTypeInfo to define the discriminator property that Kacson can use when reconstructing the Java object from its underlying JSON value.

For that, we need to define a getType property in DiscountCoupon and provide the mapping between the type property values and the associated DiscountCoupon classes via the @JsonSubTypes annotation:

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "type"
)
@JsonSubTypes({
    @JsonSubTypes.Type(
        name = "discount.coupon.amount",
        value = AmountDiscountCoupon.class
    ),
    @JsonSubTypes.Type(
        name = "discount.coupon.percentage",
        value = PercentageDiscountCoupon.class
    ),
})
public abstract class DiscountCoupon implements Serializable {

    private String name;

    public DiscountCoupon() {
    }

    public DiscountCoupon(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

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

    @JsonTypeInfo(use = JsonTypeInfo.Id.NAME)
    public abstract String getType();

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof DiscountCoupon)) return false;
        DiscountCoupon that = (DiscountCoupon) o;
        return Objects.equals(getName(), that.getName());
    }

    @Override
    public int hashCode() {
        return Objects.hash(getName());
    }
}

The equals and hashCode methods are needed by the Hibernate dirty checking mechanism to find out when you’re modifying the coupons and trigger an UPDATE statement.

The AmountDiscountCoupon implements the getType method and defines the same discriminator value that the DiscountCoupon mapped using the @JsonSubTypes.Type annotation.

public class AmountDiscountCoupon extends DiscountCoupon {

    public static final String DISCRIMINATOR = "discount.coupon.amount";

    private BigDecimal amount;

    public AmountDiscountCoupon() {
    }

    public AmountDiscountCoupon(String name) {
        super(name);
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public AmountDiscountCoupon setAmount(BigDecimal amount) {
        this.amount = amount;
        return this;
    }

    @Override
    public String getType() {
        return DISCRIMINATOR;
    }
}

The PercentageDiscountCoupon also implements the getType method and defines the same discriminator value that was used by the associated @JsonSubTypes.Type annotation in the DiscountCoupon base class:

public class PercentageDiscountCoupon extends DiscountCoupon {

    public static final String DISCRIMINATOR = "discount.coupon.percentage";

    private BigDecimal percentage;

    public PercentageDiscountCoupon() {
    }

    public PercentageDiscountCoupon(String name) {
        super(name);
    }

    public BigDecimal getPercentage() {
        return percentage;
    }

    public PercentageDiscountCoupon setPercentage(BigDecimal amount) {
        this.percentage = amount;
        return this;
    }

    @Override
    public String getType() {
        return DISCRIMINATOR;
    }
}

Now, the Book entity can use the generic JsonType since the DiscountCoupun Java objects can be instantiated by Jackson using the available @JsonTypeInfo mapping:

@Entity(name = "Book")
@Table(name = "book")
@TypeDef(name = "json", typeClass = JsonType.class)
public static class Book {

    @Id
    @GeneratedValue
    private Long id;

    @NaturalId
    @Column(length = 15)
    private String isbn;

    @Type(type = "json")
    @Column(columnDefinition = "jsonb")
    private List<DiscountCoupon> coupons = new ArrayList<>();
    
}

And, when persisting the same Book entity, Hibernate is going to generate the following SQL INSERT statement:

INSERT INTO book (
  coupons, 
  isbn, 
  id
) 
VALUES (
  [
    {
      "name":"PPP",
      "amount":4.99,
	  "type":"discount.coupon.amount"
    },
    {
      "name":"Black Friday",
      "percentage":0.02,
	  "type":"discount.coupon.percentage"
    }
  ], 
  978-9730228236, 
  1
)

Cool, right?

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

Conclusion

Mapping polymorphic JSON objects are very easy with the Hibernate Types project. Because you can customize the Jackson ObjectMapper any way you want, you can address a great variety of use cases using this approach.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.