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.
How to map multiple JPA entities to one database table with Hibernate @vlad_mihalcea https://t.co/YqBCZSlVuk pic.twitter.com/JbSov7BZrW
— Java (@java) August 22, 2019
Domain Model
Let’s assume we have a book
database table that looks as follows:
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.
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 theBaseBook
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 theBook
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.
