The best way to map the SINGLE_TABLE inheritance with JPA and 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
Java, like any other object-oriented programming language, makes heavy use of inheritance and polymorphism. Inheritance allows defining class hierarchies that offer different implementations of a common interface.
Conceptually, the Domain Model defines both data (e.g. persisted entities) and behavior (business logic). Nevertheless, inheritance is more useful for varying behavior rather than reusing data (composition is much more suitable for sharing structures).
Even if the data (persisted entities) and the business logic (transactional services) are decoupled, inheritance can still help varying business logic (e.g. Visitor pattern).
In this article, we are going to see what is the best way to map the SINGLE_TABLE inheritance, which, not only is the default inheritance strategy, but it’s usually the most efficient way to model entity inheritance.
Domain Model
To illustrate how entity inheritance works, consider the following model diagram:
The root entity of this Domain Model is the Board
entity because, either directly or indirectly, all the other entities are associated with a Board
@Entity @Table(name = "board") public class Board { @Id @GeneratedValue private Long id; private String name; //Getters and setters omitted for brevity }
The end-user can submit either a Post
or an Announcement
on a particular Board
. Because the Post
and the Announcement
share the same functionality (differing only in data), they both inherit from a Topic
base class.
The Topic
class defines a relationship to a Board
entity, hence the Post
and the Announcement
entities can also be associated with a Board
instance.
@Entity @Table(name = "topic") public class Topic { @Id @GeneratedValue private Long id; private String title; private String owner; @Temporal(TemporalType.TIMESTAMP) private Date createdOn = new Date(); @ManyToOne(fetch = FetchType.LAZY) private Board board; //Getters and setters omitted for brevity }
Both the Post
and the Announcement
entities extend the Topic
class and define their own specific attributes.
@Entity public class Post extends Topic { private String content; //Getters and setters omitted for brevity } @Entity public class Announcement extends Topic { @Temporal(TemporalType.TIMESTAMP) private Date validUntil; //Getters and setters omitted for brevity }
The TopicStatistics
is at the bottom of this Domain Model as it is only needed for monitoring purposes, without being directly associated with the main business logic. Because statistics are needed for both Post
and Announcement
entities, the TopicStatistics
defines a Topic
entity association.
@Entity @Table(name = "topic_statistics") public class TopicStatistics { @Id @GeneratedValue private Long id; @OneToOne @MapsId private Topic topic; private long views; //Getters and setters omitted for brevity }
SINGLE_TABLE inheritance mapping
The single table inheritance is the default JPA strategy, funneling a whole inheritance Domain Model hierarchy into a single database table.
To employ this strategy, the Topic
entity class must be mapped with one of the following annotations:
@Inheritance
(being the default inheritance model, it is not mandatory to supply the strategy when using single table inheritance).@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
.
The Post
and the Announcement
entities do not need any extra mapping (the Java inheritance semantics being sufficient). Preserving the same layout as depicted in the Domain Model class diagram, the table relationships associated with this inheritance strategy look like this:
The topic
table contains columns associated with the Topic
base class as well as columns related to attributes from Post
and Announcement
entities.
In the following example, one Post
and one Announcement
entities are going to be persisted along with their associated @OneToOne
TopicStatistics
relations.
Post post = new Post(); post.setOwner("John Doe"); post.setTitle("Inheritance"); post.setContent("Best practices"); post.setBoard(board); entityManager.persist(post); Announcement announcement = new Announcement(); announcement.setOwner("John Doe"); announcement.setTitle("Release x.y.z.Final"); announcement.setValidUntil( Timestamp.valueOf(LocalDateTime.now().plusMonths(1)) ); announcement.setBoard(board); entityManager.persist(announcement); TopicStatistics postStatistics = new TopicStatistics(post); postStatistics.incrementViews(); entityManager.persist(postStatistics); TopicStatistics announcementStatistics = new TopicStatistics(announcement); announcementStatistics.incrementViews(); entityManager.persist(announcementStatistics);
Both the Post
and the Announcement
entities are saved in the topic
table whose primary key is shared with the topic_statistics
table.
INSERT INTO topic ( board_id, createdOn, owner, title, content, DTYPE, id ) VALUES ( 1, '2016-01-17 09:22:22.11', 'John Doe', 'Inheritance', 'Best practices', 'Post', 1 ) INSERT INTO topic ( board_id, createdOn, owner, title, validUntil, DTYPE, id ) VALUES ( 1, '2016-01-17 09:22:22.11', 'John Doe', 'Release x.y.z.Final', '2016-02-17 09:22:22.114', 'Announcement', 2 ) INSERT INTO topic_statistics (views, id) VALUES (1, 2) INSERT INTO topic_statistics (views, id) VALUES (1, 3)
One advantage of using inheritance in the Domain Model is the support for polymorphic queries. When the application developer issues a select query against the Topic
entity:
List<Topic> topics = entityManager.createQuery( "select t from Topic t where t.board.id = :boardId", Topic.class) .setParameter("boardId", 1L) .getResultList();
Hibernate goes to the topic
table, and, after fetching the result set, it maps every row to its associated subclass instance (e.g. Post
or Announcement
) by analyzing the discriminator column (e.g. DTYPE
) value.
SELECT t.id AS id2_1_, t.board_id AS board_id8_1_, t.createdOn AS createdO3_1_, t.owner AS owner4_1_, t.title AS title5_1_, t.content AS content6_1_, t.validUntil AS validUnt7_1_, t.DTYPE AS DTYPE1_1_ FROM topic t WHERE t.board_id = 1
Domain Model inheritance allows base class entity associations to be automatically resolved upon being retrieved. When loading a TopicStatistics
along with its Topic
relation:
TopicStatistics statistics = entityManager.createQuery( "select s from TopicStatistics s join fetch s.topic t where t.id = :topicId", TopicStatistics.class) .setParameter("topicId", topicId) .getSingleResult();
Hibernate joins the topic_statistics
and the topic
tables so that it can create a TopicStatistics
entity with an actual Post
or Announcement
attribute object reference.
SELECT ts.id AS id1_2_0_, t.id AS id2_1_1_, ts.views AS views2_2_0_, t.board_id AS board_id8_1_1_, t.createdOn AS createdO3_1_1_, t.owner AS owner4_1_1_, t.title AS title5_1_1_, t.content AS content6_1_1_, t.validUntil AS validUnt7_1_1_, t.DTYPE AS DTYPE1_1_1_ FROM topic_statistics ts INNER JOIN topic t ON ts.id = t.id WHERE t.id = 2
Even if not practical in this particular example, @OneToMany
associations are also possible.
The Board
entity can map a bidirectional @OneToMany
relationship as follows:
@OneToMany(mappedBy = "board") private List<Topic> topics = new ArrayList<>();
Fetching the collection lazily generates a separate select statement, identical to the aforementioned Topic
entity query. When fetching the collection eagerly, Hibernate requires a single table join.
Board board = entityManager.createQuery( "select b from Board b join fetch b.topics where b.id = :id", Board.class) .setParameter("id", id) .getSingleResult();
SELECT b.id AS id1_0_0_, t.id AS id2_1_1_, b.name AS name2_0_0_, t.board_id AS board_id8_1_1_, t.createdOn AS createdO3_1_1_, t.owner AS owner4_1_1_, t.title AS title5_1_1_, t.content AS content6_1_1_, t.validUntil AS validUnt7_1_1_, t.DTYPE AS DTYPE1_1_1_, t.board_id AS board_id8_1_0__, t.id AS id2_1_0__ FROM board b INNER JOIN topic t ON b.id = t.board_id WHERE b.id = 1
Data integrity constraints
Because all subclass attributes are collocated in a single table, NOT NULL
constraints are not allowed for columns belonging to subclasses. Being automatically inherited by all subclasses, the base class attributes may be non-nullable.
From a data integrity perspective, this limitation defeats the purpose of Consistency (guaranteed by the ACID properties). Nevertheless, the data integrity rules can be enforced through database trigger procedures or CHECK
constraints (a column non-nullability is accounted for based on the class discriminator value).
Another approach is to move the check into the data access layer. Bean Validation can validate @NotNull
attributes at runtime. JPA also defines callback methods (e.g. @PrePersist
, @PreUpdate
) as well as entity listeners (e.g. @EntityListeners
) which can throw an exception when a non-null constraint is violated.
The SQL standard defines the CHECK
constraint which can be used to apply a row-level verification for each table record that is inserted. Depending on the underlying database, the CHECK
constraint can be enforced (e.g. Oracle, SQL Server, PostgreSQL) or ignored (e.g. MySQL).
For the aforementioned database tables, the content
column must never be null if the underlying record is a Post
, and the validUntil
column should not be null if the database row represents an Announcement
entity. Luckily, the default DTYPE
column specifies the entity type associated with each particular table row.
To ensure the aforementioned data integrity rules, the following CHECK
constraints need to be added:
ALTER TABLE Topic ADD CONSTRAINT post_content_check CHECK ( CASE WHEN DTYPE = 'Post' THEN CASE WHEN content IS NOT NULL THEN 1 ELSE 0 END ELSE 1 END = 1 ) ALTER TABLE Topic ADD CONSTRAINT announcement_validUntil_check CHECK ( CASE WHEN DTYPE = 'Announcement' THEN CASE WHEN validUntil IS NOT NULL THEN 1 ELSE 0 END ELSE 1 END = 1 )
With these CHECK
constraints in place, when trying to insert a Post
entity without a content
:
entityManager.persist(new Post());
PostgreSQL generates the following error message:
INSERT INTO topic ( board_id, createdOn, owner, title, content, DTYPE, id ) VALUES ( (NULL(BIGINT), '2016-07-15 13:45:16.705', NULL(VARCHAR), NULL(VARCHAR), NULL(VARCHAR), 'Post', 4 ) -- SQL Error: 0, SQLState: 23514 -- new row for relation "topic" violates check constraint "post_content_check"
Starting with the 8.0.16 version, MySQL supports custom SQL CHECK constraints. For more details, check out this article.
For MySQL prior to version 8.0.16, the same result can be achieved with a TRIGGER
instead.
CREATE TRIGGER post_content_insert_check BEFORE INSERT ON topic FOR EACH ROW BEGIN IF NEW.DTYPE = 'Post' THEN IF NEW.content IS NULL THEN signal sqlstate '45000' set message_text = 'Post content cannot be NULL'; END IF; END IF; END; CREATE TRIGGER post_content_update_check BEFORE UPDATE ON topic FOR EACH ROW BEGIN IF NEW.DTYPE = 'Post' THEN IF NEW.content IS NULL THEN signal sqlstate '45000' set message_text = 'Post content cannot be NULL'; END IF; END IF; END; CREATE TRIGGER announcement_validUntil_insert_check BEFORE INSERT ON topic FOR EACH ROW BEGIN IF NEW.DTYPE = 'Announcement' THEN IF NEW.validUntil IS NULL THEN signal sqlstate '45000' set message_text = 'Announcement validUntil cannot be NULL'; END IF; END IF; END; CREATE TRIGGER announcement_validUntil_update_check BEFORE UPDATE ON topic FOR EACH ROW BEGIN IF NEW.DTYPE = 'Announcement' THEN IF NEW.validUntil IS NULL THEN signal sqlstate '45000' set message_text = 'Announcement validUntil cannot be NULL'; END IF; END IF; END;
When running the previous Post
insert, MySQL generates the following output:
INSERT INTO topic ( board_id, createdOn, owner, title, content, DTYPE, id ) VALUES ( (NULL(BIGINT), '2016-07-15 13:50:51.989', NULL(VARCHAR), NULL(VARCHAR), NULL(VARCHAR), 'Post', 4 ) -- SQL Error: 1644, SQLState: 45000 -- Post content cannot be NULL
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
Since only one table is used for storing entities, both reads and writes are fast. Even when using a @ManyToOne
or a @OneToOne
base class association, Hibernate needs a single join between parent and child tables.
The @OneToMany
base class entity relationship is also efficient since it either generates a secondary select or a single table join.
Although a little bit verbose, the CHECK
and TRIGGER
constraints are very useful to ensure data integrity when using single table inheritance.
