How to map multiple JPA entities to one database table with Hibernate

Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?

Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.

So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!

Introduction

In this article, we are going to see how we can map multiple JPA entities to one table with Hibernate. Using multiple entities can speed up both read and write operations.

This question has been a recurring theme on StackOverflow, so I decided to explain the advantages of mapping multiple JPA entities to the same database table.

Domain Model

Let’s assume we have a book database table that looks as follows:

The book database table

Notice that the properties column is of the json type. Therefore, the properties column value is as large as the containing JSON object. For this reason, we don’t want to map the book table to a single Book entity because we would have to fetch the properties column every time we load a Book entity from the database.

So, we will map two entities to the book table. One entity is BookSummary, which maps only a subset of book table columns. On the other hand, the Book entity maps all columns from the book table.

Both the BookSummary and the Book entities extend the BaseBook abstract class as illustrated in the following diagram.

Multiple Book entities

The BaseBook is an abstract class that contains the base attributes that will be shared by all entities mapped to the book table, and it looks as follows:

@MappedSuperclass
public abstract class BaseBook<T extends BaseBook> {

    @Id
    @GeneratedValue
    private Long id;

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

    @Column(length = 50)
    private String title;

    @Column(length = 50)
    private String author;

    public Long getId() {
        return id;
    }

    public T setId(Long id) {
        this.id = id;
        return (T) this;
    }

    public String getIsbn() {
        return isbn;
    }

    public T setIsbn(String isbn) {
        this.isbn = isbn;
        return (T) this;
    }

    public String getTitle() {
        return title;
    }

    public T setTitle(String title) {
        this.title = title;
        return (T) this;
    }

    public String getAuthor() {
        return author;
    }

    public T setAuthor(String author) {
        this.author = author;
        return (T) this;
    }
}

The abstract BaseBook is using the @MappedSuperclass annotation, as otherwise, base class attributes are not inherited by entities extending the BaseBook class.

For more details about the @MappedSuperclass JPA annotation, check out this article.

Notice that the setter methods use the fluent interface pattern, and the returning type is given by the <T> type parameter which can be defined by each extending class so that the fluent API always returns the object type reference where the calling method is defined, and not the superclass object reference.

For more details about using the Fluent API pattern with JPA entities, check out this article.

The BookSummary entity simply extends the BaseBook superclass and adds no additional entity attribute.

@Entity(name = "BookSummary")
@Table(name = "book")
public class BookSummary extends BaseBook<BookSummary> {

}

On the other hand, the Book entity extends the BaseBook superclass and maps the properties attribute.

@Entity(name = "Book")
@Table(name = "book")
@TypeDef(
    name = "json", 
    typeClass = JsonType.class
)
@DynamicUpdate
public class Book extends BaseBook<Book> {

    @Type(type = "json")
    @Column(columnDefinition = "jsonb")
    private String properties;

    public String getProperties() {
        return properties;
    }

    public Book setProperties(String properties) {
        this.properties = properties;
        return this;
    }

    public ObjectNode getJsonProperties() {
        return (ObjectNode) JacksonUtil
            .toJsonNode(properties);
    }
}

By default, Hibernate does not support JSON column types. However, you can use the hibernate-types open-source project which provides a great variety of extra Hibernate Types, like JSON, Array, Range, HStore, PostgreSQL Enum types.

Testing time

When persisting a Book entity:

entityManager.persist(
    new Book()
        .setIsbn("978-9730228236")
        .setTitle("High-Performance Java Persistence")
        .setAuthor("Vlad Mihalcea")
        .setProperties(
            "{" +
                "   \"publisher\": \"Amazon\"," +
                "   \"price\": 44.99," +
                "   \"publication_date\": \"2016-20-12\"," +
                "   \"dimensions\": \"8.5 x 1.1 x 11 inches\"," +
                "   \"weight\": \"2.5 pounds\"," +
                "   \"average_review\": \"4.7 out of 5 stars\"," +
                "   \"url\": \"https://amzn.com/973022823X\"" +
            "}"
        )
);

Hibernate properly sets all book table row columns:

INSERT INTO book (
    author, 
    isbn, title, 
    properties, 
    id
) 
VALUES (
    'Vlad Mihalcea', 
    '978-9730228236', 
    'High-Performance Java Persistence', 
    {
        "publisher": "Amazon",
        "price": 44.99,
        "publication_date": "2016-20-12",
        "dimensions": "8.5 x 1.1 x 11 inches",
        "weight": "2.5 pounds",
        "average_review": "4.7 out of 5 stars",
        "url": "https:\/\/amzn.com\/973022823X"
        }, 
    1
)

We can also persist a BookSummary for the SQL Antipatterns book for Bill Karwin:

entityManager.persist(
    new BookSummary()
        .setIsbn("978-1934356555")
        .setTitle("SQL Antipatterns")
        .setAuthor("Bill Karwin")
);

And Hibernate will set only the columns defined by the BookSummary entity:

INSERT INTO book (
    author, 
    isbn, 
    title, 
    id
) 
VALUES (
    'Bill Karwin', 
    '978-1934356555', 
    'SQL Antipatterns', 
    2
)

We can fetch the BookSummary for the High-Performance Java Persistence book as follows:

BookSummary bookSummary = entityManager
    .unwrap(Session.class)
    .bySimpleNaturalId(BookSummary.class)
    .load("978-9730228236");

assertEquals(
    "High-Performance Java Persistence", 
    bookSummary.getTitle()
);

Because the BookSummary is a managed entity, we can modify it:

bookSummary.setTitle("High-Performance Java Persistence, 2nd edition");

And the Hibernate dirty checking mechanism will detect the change and trigger an UPDATE statement when flushing the Persistence Context:

UPDATE 
    book 
SET 
    author = 'Vlad Mihalcea', 
    title = 'High-Performance Java Persistence, 2nd edition' 
WHERE 
    id = 1

Notice that the UPDATE is done only for the BookSummary entity attributes, which are inherited from the BaseBook base class.

Now, in a subsequent Persistence Context, we can also fetch the Book entity associated with the High-Performance Java Persistence book table row.

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

assertEquals(
    "High-Performance Java Persistence, 2nd edition", 
    book.getTitle()
);

Because the Book entity maps the properties attribute as well, we can both read and write the properties attribute.

ObjectNode jsonProperties = book.getJsonProperties();

assertEquals(
    "4.7 out of 5 stars", 
    jsonProperties.get("average_review").asText()
);

jsonProperties.put(
    "average_review", 
    "4.8 out of 5 stars"
);

book.setProperties(
    JacksonUtil.toString(jsonProperties)
);

When flushing the current Persistence Context, Hibernate will issue an UPDATE statement that will set the properties column accordingly:

UPDATE 
    book 
SET 
    properties = {
        "url": "https:\/\/amzn.com\/973022823X",
        "price": 44.99,
        "weight": "2.5 pounds",
        "publisher": "Amazon",
        "dimensions": "8.5 x 1.1 x 11 inches",
        "average_review": "4.8 out of 5 stars",
        "publication_date": "2016-20-12"
    } 
WHERE 
    id = 1

This time, Hibernate only sets the properties column in the UPDATE statement because the Book entity uses the @DynamicUpdate annotation.

For more details about how the @DynamicUpdate annotation works when using JPA and Hibernate, check out this article.

I'm running an online workshop on the 11th of October about High-Performance SQL.

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

Conclusion

So mapping multiple entities to the same database table, not only that it allows us to fetch data more efficiently, but it also speeds up the dirty checking process as Hibernate has to inspect fewer entity properties.

The only drawback of using this approach is that you have to make sure you don’t fetch more than one entity type for the same database table record, as otherwise, this can cause inconsistencies when flushing the Persistence Context.

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.