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:

The one-to-one table relationship

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:

Bidirectional OneToOne JPA association

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:

The one_to_one table relationships without @MapsId

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.

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.