How to cascade DELETE unidirectional associations with Spring Data JPA

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 to cascade DELETE the unidirectional associations with Spring Data JPA when we cannot rely on the CascadeType mechanism that propagates state transitions from parent to child entities.

Domain Model

Let’s consider we have the following entities in our system:

Post and unidirectional child entities

The Post entity is the root of this entity hierarchy, and the child entities use only unidirectional @ManyToOne or @OneToOne associations that map the underlying Foreign Key of a one-to-many or one-to-one table relationship.

The root Post entity looks as follows:

@Entity
@Table(name = "post")
public class Post {

    @Id
    private Long id;

    private String title;

    //Getters and setters omitted for brevity
}

Notice that there’s no bidirectional @OneToMany or @OneToOne association that would allow us to cascade the DELETE operation from the parent Post to the PostComment, PostDetails, or PostTag child entities.

The PostComment entity maps the post_id Foreign Key column using a unidirectional @ManyToOne association:

@Entity
@Table(name = "post_comment")
public class PostComment {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;

    private String review;

    //Getters and setters omitted for brevity
}

The PostDetails entity maps the id Foreign Key column using a unidirectional @OneToOne association:

@Entity
@Table(name = "post_details")
public class PostDetails {

    @Id
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "id")
    private Post post;

    @Column(name = "created_on")
    private LocalDateTime createdOn;

    @Column(name = "created_by")
    private String createdBy;

    //Getters and setters omitted for brevity    
}

The PostTag entity maps the post_id Foreign Key column using a unidirectional @ManyToOne association:

@Entity
@Table(name = "post_tag")
public class PostTag {

    @EmbeddedId
    private PostTagId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("postId")
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("tagId")
    private Tag tag;

    @Column(name = "created_on")
    private Date createdOn = new Date();

    //Getters and setters omitted for brevity    
}

And the Post entity doesn’t only have direct child associations since the UserVote entity is a child of the PostComment. Hence, it’s a grandchild association of the Post root entity:

@Entity
@Table(name = "user_vote")
public class UserVote {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    private PostComment comment;

    private int score;
	
    //Getters and setters omitted for brevity    
}

Creating a Post entity hierarchy

Let’s assume we have created a Post entity hierarchy that contains:

  • one PostDetails child entity
  • two PostComment child entities, each one with a UserVote entity
  • three PostTag child entities
Post post = new Post()
    .setId(1L)
    .setTitle("High-Performance Java Persistence");
postRepository.persist(post);

postDetailsRepository.persist(
    new PostDetails()
        .setCreatedBy("Vlad Mihalcea")
        .setPost(post)
);

PostComment comment1 = new PostComment()
    .setReview("Best book on JPA and Hibernate!")
    .setPost(post);

PostComment comment2 = new PostComment()
    .setReview("A must-read for every Java developer!")
    .setPost(post);

postCommentRepository.persist(comment1);
postCommentRepository.persist(comment2);

User alice = new User()
    .setId(1L)
    .setName("Alice");

User bob = new User()
    .setId(2L)
    .setName("Bob");

userRepository.persist(alice);
userRepository.persist(bob);

userVoteRepository.persist(
    new UserVote()
        .setUser(alice)
        .setComment(comment1)
        .setScore(Math.random() > 0.5 ? 1 : -1)
);

userVoteRepository.persist(
    new UserVote()
        .setUser(bob)
        .setComment(comment2)
        .setScore(Math.random() > 0.5 ? 1 : -1)
);

Tag jdbc = new Tag().setName("JDBC");
Tag hibernate = new Tag().setName("Hibernate");
Tag jOOQ = new Tag().setName("jOOQ");

tagRepository.persist(jdbc);
tagRepository.persist(hibernate);
tagRepository.persist(jOOQ);

postTagRepository.persist(new PostTag(post, jdbc));
postTagRepository.persist(new PostTag(post, hibernate));
postTagRepository.persist(new PostTag(post, jOOQ));

How to cascade DELETE unidirectional associations with Spring Data JPA

Now, we would like to have a way to remove a given Post entity, and if we use the default deleteById method of the PostRepository on the Post entity we have previously created:

postRepository.deleteById(1L);

We are going to get the following ConstraintViolationException:

Caused by: org.postgresql.util.PSQLException: 
ERROR: 
    update or delete on table "post" violates 
    foreign key constraint "fk_post_comment_post_id" 
    on table "post_comment"
Detail: 
    Key (id)=(1) is still referenced 
    from table "post_comment".

The ConstraintViolationException is thrown because the post table record is still referenced by its child entities in the post_details, post_comment, and post_tag tables.

Therefore, we need to make sure we remove all child entries prior to removing a given Post entity.

To accomplish this goal, we will change the PostRepository so that it now extends the CustomPostRepository interface:

@Repository
public interface PostRepository extends BaseJpaRepository<Post, Long>,
    CustomPostRepository<Long> {

}

The CustomPostRepository defines the deleteById method that we plan on overriding in the custom JPA repository so that we can cascade DELETE operation from the Post entity to all the unidirectional associations:

public interface CustomPostRepository<ID> {

    void deleteById(ID postId);
}

For an introduction to custom Spring Data JPA Repositories, check out this article.

The CustomPostRepository interface is implemented by the CustomPostRepositoryImpl class, which is going to define the cascading delete logic for all the dependencies of the Post entity:

public class CustomPostRepositoryImpl 
        implements CustomPostRepository<Long> {

    private final PostDetailsRepository postDetailsRepository;
    private final UserVoteRepository userVoteRepository;
    private final PostCommentRepository postCommentRepository;
    private final PostTagRepository postTagRepository;
    private final EntityManager entityManager;

    public CustomPostRepositoryImpl(
            PostDetailsRepository postDetailsRepository,
            UserVoteRepository userVoteRepository, 
            PostCommentRepository postCommentRepository,
            PostTagRepository postTagRepository,
            EntityManager entityManager) {
        this.postDetailsRepository = postDetailsRepository;
        this.userVoteRepository = userVoteRepository;
        this.postCommentRepository = postCommentRepository;
        this.postTagRepository = postTagRepository;
        this.entityManager = entityManager;
    }

    @Override
    public void deleteById(Long postId) {
        postDetailsRepository.deleteByPostId(postId);
        userVoteRepository.deleteAllByPostId(postId);
        postCommentRepository.deleteAllByPostId(postId);
        postTagRepository.deleteAllByPostId(postId);

        entityManager.createQuery("""
            delete from Post
            where id = :postId
            """)
        .setParameter("postId", postId)
        .executeUpdate();
    }
}

The deleteById method is implemented in such a way that we can clean up all associated child table records that point to the Post entity, either directly via a Foreign Key or indirectly via a chain of Foreign Key references, as is the case with the user_vote table records.

Therefore, we will first delete the associated PostDetails child entities by calling the deleteByPostId method from the PostDetailsRepository using a bulk DELETE statement:

@Repository
public interface PostDetailsRepository 
        extends BaseJpaRepository<PostDetails, Long> {

    @Query("""
        delete from PostDetails
        where post.id = :postId
        """)
    @Modifying
    void deleteByPostId(@Param("postId") Long postId);
}

In the second step, we delete the UserVotes by calling the deleteAllByPostId method from the UserVoteRepository via a bulk DELETE statement:

@Repository
public interface UserVoteRepository 
        extends BaseJpaRepository<UserVote, Long> {

    @Query("""
        delete from UserVote
        where comment.id in (
            select id
            from PostComment
            where post.id = :postId
        )
        """)
    @Modifying
    void deleteAllByPostId(@Param("postId") Long postId);
}

Next, we delete the PostComment child entities by calling the deleteAllByPostId method from the PostCommentRepository using a single bulk DELETE statement:

@Repository
public interface PostCommentRepository 
        extends BaseJpaRepository<PostComment, Long> {

    @Query("""
        delete from PostComment
        where post.id = :postId
        """)
    @Modifying
    void deleteAllByPostId(@Param("postId") Long postId);
}

Afterward, we delete the PostTag child entities by calling the deleteAllByPostId method from the PostTagRepository using a bulk DELETE statement:

@Repository
public interface PostTagRepository 
        extends BaseJpaRepository<PostTag, PostTagId> {

    @Query("""
        delete from PostTag
        where post.id = :postId
        """)
    @Modifying
    void deleteAllByPostId(@Param("postId") Long postId);
}

In the end, after deleting all the child unidirectional associations, we can proceed to delete the root Post entity:

entityManager.createQuery("""
    delete from Post
    where id = :postId
    """)
.setParameter("postId", postId)
.executeUpdate();

Spring Data JPA and Hibernate are going to execute the following SQL statements:

DELETE FROM post_details
WHERE id = 1

DELETE FROM user_vote
WHERE comment_id IN (
    SELECT pd.id
    FROM post_comment pd
    WHERE pd.post_id = 1
)

DELETE FROM post_comment
WHERE post_id = 1

DELETE FROM post_tag
WHERE post_id = 1

DELETE FROM post
WHERE id = 1

Awesome, right?

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

While you can use bidirectional associations to cascade the DELETE operation from the parent entity to child entities, this is not the only way you can accomplish this task.

By extending a custom Spring Data JPA Repository method, we can cascade DELETE operational to every direct or indirect unidirectional association.

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.