The best way to use the JPA OneToOne optional attribute
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 what is the best way we can use OneToOne optional attribute so that we can avoid N+1 query issues.
One-To-One table relationship
As I explained in this article, in a one-to-one table relationship, the parent and the child tables share the Primary Key column values because the child table Primary Key column has a Foreign Key constraint referencing the Primary Key column in the parent table.
For example, a one-to-one table relationship between a parent post
table and a child post_details
will look like this:
Domain Model
In our application, we want to map the aforementioned one-to-one table relationship between the post
and post_details
tables using the following Post
and PostDetails
JPA entity classes:
As I explained in this article, the best way to map the @OneToOne
association is using the @MapsId
annotation on the child side.
Therefore, the PostDetails
JPA entity will be mapped like this:
@Entity @Table(name = "post_details") public class PostDetails { @Id private Long id; @Column(name = "created_on") private Date createdOn = new Date(); @Column(name = "created_by") private String createdBy; @OneToOne(fetch = FetchType.LAZY) @MapsId @JoinColumn(name = "id") private Post post; }
Notice that we are using both the @MapsId
and the @JoinColumn
annotation to instruct Hibernate that the Foreign Key column referencing the Post
entity is the PostDetails
identifier column.
On the parent side, the Post
entity is mapped as follows:
@Entity @Table(name = "post") public class Post { @Id @GeneratedValue private Long id; private String title; @OneToOne( mappedBy = "post", cascade = CascadeType.ALL, fetch = FetchType.LAZY ) private PostDetails details; public Post setDetails(PostDetails details) { if (details == null) { if (this.details != null) { this.details.setPost(null); } } else { details.setPost(this); } this.details = details; return this; } }
The parent-side @OneToOne
association uses mappedBy
to instruct Hibernate that the underlying Foreign Key column is managed by the post
attribute in the PostDetails
entity.
The
setDetails
method synchronizes both ends of this bidirectional association. Without this synchronization, there is no guarantee that the relationship will be managed correctly by JPA and Hibernate.For more details about this topic, check out this article.
Now, assuming we have created 100 Post
and PostDetails
entities:
for (int i = 1; i <= 100; i++) { entityManager.persist( new Post() .setTitle(String.format("Post nr. %d", i)) .setDetails( new PostDetails() .setCreatedBy("Vlad Mihalcea") ) ); }
When fetching the Post
entities using the following JPQL query:
SQLStatementCountValidator.reset(); List<Post> posts = doInJPA(entityManager -> { return entityManager.createQuery(""" select p from Post p where p.title like 'Post nr.%' """, Post.class) .getResultList(); }); assertEquals(100, posts.size()); SQLStatementCountValidator.assertSelectCount(1);
We get an SQLStatementCountMismatchException
because, instead of a single SQL query, there were 101 queries that got executed.
Query:["select p1_0.id,p1_0.title from post p1_0 where p1_0.title like 'Post nr.%'"] Query:["select pd1_0.id,pd1_0.created_by,pd1_0.created_on,pd1_0.post_id from post_details pd1_0 where pd1_0.post_id=?"], Params:[(1)] Query:["select pd1_0.id,pd1_0.created_by,pd1_0.created_on,pd1_0.post_id from post_details pd1_0 where pd1_0.post_id=?"], Params:[(2)] ... Query:["select pd1_0.id,pd1_0.created_by,pd1_0.created_on,pd1_0.post_id from post_details pd1_0 where pd1_0.post_id=?"], Params:[(100)] SQLStatementCountMismatchException: Expected 1 statement(s) but recorded 101 instead!
If you take a look at the first SQL query that gets executed, you can see that only the id
and the title
columns can be fetched from the post
table. However, the Post
entity has a details
property that Hibernate has to know whether to leave it as null or set it to an uninitialized Proxy. Because it cannot determine which option is the right one, a secondary query will be needed even if the parent-side @OneToOne
association was using the FetchType.LAZY
fetching strategy.
Disabling the OneToOne optional attribute
If Hibernate knows that the identifier is shared and the association is mandatory, then it can initialize the association to a Proxy instead of triggering a secondary SQL query to find whether there’s a child entity associated to the parent in question.
Therefore, if we set the optional
attribute to the value of false
on the @OneToOne
association on the parent side:
@OneToOne( mappedBy = "post", cascade = CascadeType.ALL, fetch = FetchType.LAZY, optional = false ) private PostDetails details;
Then, when fetching the Post
entity using the same JPQL query:
SQLStatementCountValidator.reset(); List<Post> posts = doInJPA(entityManager -> { return entityManager.createQuery(""" select p from Post p where p.title like 'Post nr.%' """, Post.class) .getResultList(); }); assertEquals(100, posts.size()); SQLStatementCountValidator.assertSelectCount(1);
We can see that a single SQL query will be executed:
Query:["select p1_0.id,p1_0.title from post p1_0 where p1_0.title like 'Post nr.%'"]
Disabling the OneToOne optional attribute when not using @MapsId
If we don’t use the @MapsId
annotation and choose the default @OneToOne
mapping on the child side:
@Entity @Table(name = "post_details") public class PostDetails { @Id @GeneratedValue private Long id; @Column(name = "created_on") private Date createdOn = new Date(); @Column(name = "created_by") private String createdBy; @OneToOne(fetch = FetchType.LAZY) private Post post; }
However, when using this default @OneToOne
mapping, Hibernate will expect the following database table schema:
Notice the post_id
column on the post_details
table that is associated with the one-to-many table relationships. When not using the @MapsId
annotation, Hibernate will emulate the one-to-one relationship using a one-to-many table relationship.
However, having a column and index on the Foreign Key column is not the only drawback of this default @OneToOne
JPA mapping.
Even if the parent Post
entity uses the optional
attribute:
@OneToOne( mappedBy = "post", cascade = CascadeType.ALL, fetch = FetchType.LAZY, optional = false ) private PostDetails details;
When fetching the Post
entity using the previous JPQL query:
List<Post> posts = doInJPA(entityManager -> { return entityManager.createQuery(""" select p from Post p where p.title like 'Post nr.%' """, Post.class) .getResultList(); });
We get the N+1 query issue since Hibernate will still fetch the PostDetails
entity in spite of the FetchType.LAZY
fetching strategy:
Query:["select p1_0.id,p1_0.title from post p1_0 where p1_0.title like 'Post nr.%'"] Query:["select pd1_0.id,pd1_0.created_by,pd1_0.created_on,pd1_0.post_id from post_details pd1_0 where pd1_0.post_id=?"], Params:[(1)] Query:["select pd1_0.id,pd1_0.created_by,pd1_0.created_on,pd1_0.post_id from post_details pd1_0 where pd1_0.post_id=?"], Params:[(2)] ... Query:["select pd1_0.id,pd1_0.created_by,pd1_0.created_on,pd1_0.post_id from post_details pd1_0 where pd1_0.post_id=?"], Params:[(100)]
The only way you can get rid of this N+1 query issue when not using the @MapsId
annotation is to use Bytecode Enhancement lazy loading.
I'm running an online workshop on the 20-21 and 23-24 of November about High-Performance Java Persistence.
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
Conclusion
As I explained in this article, the one-to-one table relationship can be very useful, and knowing how to map it correctly with JPA and Hibernate is very important for performance.
The @MapsId
annotation allows us to get the proper one-to-one table relationship and avoid the N+1 query issue if the parent side uses the optional
attribute to the value of false
.