How to cascade DELETE with Spring and Hibernate events

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 cascade the DELETE operation for unidirectional associations with Spring Data JPA and Hibernate events.

Using Hibernate events to achieve this goal is an alternative to the bulk DELETE statement strategy, as it allows us to cascade the delete operation from a parent entity to its children when we cannot use the CascadeType mechanism.

Domain Model

We are going to use a table relationship model that is similar to the one we used in the article about cascading DELETE using bulk DELETE statements:

The post table root and its unidirectional table relationships

The difference between the two models is that this time, our tables use a version column for optimistic locking, and for this reason, we created the VersionedEntity base class that all our versioned JPA entities are going to extend:

@MappedSuperclass
public class VersionedEntity {

    @Version
    private Short version;

    public Short getVersion() {
        return version;
    }

    public void setVersion(Short version) {
        this.version = version;
    }
}

If you wonder why the version is mapped to a Short, then check out this article for a detailed explanation.

The Post entity is the root entity of our hierarchy and looks like this:

@Entity
@Table(name = "posts")
public class Post extends VersionedEntity {

    @Id
    private Long id;

    private String title;

    //Getters and setters omitted for brevity
}

Because all child entities reference the parent Post entity using unidirectional @ManyToOne or @OneToOne associations, there is no bidirectional @OneToMany or @OneToOne association that we could use 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 via a unidirectional @ManyToOne association:

@Entity
@Table(name = "post_comments")
public class PostComment extends VersionedEntity {

    @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 along with the @MapsId annotation:

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

    @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_tag many-to-many table, and the post_id Foreign Key column is mapped using a unidirectional @ManyToOne association:

@Entity
@Table(name = "post_tags")
public class PostTag extends VersionedEntity {

    @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    
}

To make the Post hierarchy more complex, we will not just define direct child associations to the root entity. For this reason, we also have a UserVote entity that is a child of the PostComment entity and a grandchild association of the Post root entity:

@Entity
@Table(name = "user_votes")
public class UserVote extends VersionedEntity {

    @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 the Post entity hierarchy

We will create a Post entity hierarchy that will contain the following:

  • one Post root entity
  • 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 with Spring Data JPA and Hibernate events

Now, if we try to remove the Post entity we have just created using the default deleteById method of the PostRepository:

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 was thrown because the post table record is referenced by the child records in the post_details, post_comment, and post_tag tables.

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

To accomplish this goal, we will add a CascadeDeleteEventListener, which will intercept the delete operation of the Post entity like this:

public class CascadeDeleteEventListener 
        implements DeleteEventListener {

    public static final CascadeDeleteEventListener INSTANCE = 
        new CascadeDeleteEventListener();

    @Override
    public void onDelete(
            DeleteEvent event) 
            throws HibernateException {
        final Object entity = event.getObject();
        Session session = event.getSession();

        if (entity instanceof Post post) {
            session.remove(
                session.find(PostDetails.class, post.getId())
            );

            session.createQuery("""
                select uv
                from UserVote uv
                where uv.comment.id in (
                    select id
                    from PostComment
                    where post.id = :postId
                )
                """, UserVote.class)
            .setParameter("postId", post.getId())
            .getResultList()
            .forEach(session::remove);

            session.createQuery("""
                select pc
                from PostComment pc
                where pc.post.id = :postId
                """, PostComment.class)
            .setParameter("postId", post.getId())
            .getResultList()
            .forEach(session::remove);

            session.createQuery("""
                select pt
                from PostTag pt
                where pt.post.id = :postId
                """, PostTag.class)
            .setParameter("postId", post.getId())
            .getResultList()
            .forEach(session::remove);
        }
    }

    @Override
    public void onDelete(
            DeleteEvent event, 
            DeleteContext transientEntities) 
            throws HibernateException {
        onDelete(event);
    }
}

The reason why we are not using a bulk DELETE statement to remove the associated child table records is that we want to take advantage of the optimistic locking mechanism.

To register the CascadeDeleteEventListener, we can use the following CascadeDeleteEventListenerIntegrator:

public class CascadeDeleteEventListenerIntegrator implements Integrator {

    public static final CascadeDeleteEventListenerIntegrator INSTANCE =
        new CascadeDeleteEventListenerIntegrator();

    @Override
    public void integrate(
            Metadata metadata, 
            BootstrapContext bootstrapContext, 
            SessionFactoryImplementor sessionFactory) {
        final EventListenerRegistry eventListenerRegistry = sessionFactory
            .getServiceRegistry()
            .getService(EventListenerRegistry.class);

        eventListenerRegistry.prependListeners(
            EventType.DELETE,
            CascadeDeleteEventListener.INSTANCE
        );
    }

    @Override
    public void disintegrate(
        SessionFactoryImplementor sessionFactory,
        SessionFactoryServiceRegistry serviceRegistry) {}
}

To provide the CascadeDeleteEventListenerIntegrator to Hibernate, we can use the following setting:

properties.put(
    EntityManagerFactoryBuilderImpl.INTEGRATOR_PROVIDER,
    (IntegratorProvider) () -> List.of(
        CascadeDeleteEventListenerIntegrator.INSTANCE
    )
);

With all these changes in place, when deleting the Post entity:

postRepository.deleteById(1L);

Spring Data JPA and Hibernate will execute the following SQL statements:

SELECT 
    p.id,
    p.title,
    p.version 
FROM 
    posts p 
WHERE 
    p.id = 1
    
SELECT 
    pd.id,
    pd.created_by,
    pd.created_on,
    pd.version 
FROM 
    post_details pd 
WHERE 
    pd.id = 1
    
SELECT 
    uv.id,
    uv.comment_id,
    uv.score,
    uv.user_id,
    uv.version 
FROM 
    user_votes uv 
WHERE 
    uv.comment_id IN(
        SELECT 
            pc.id 
        FROM 
            post_comments pc 
        WHERE 
            pc.post_id = 1
    )
    
SELECT 
    p1_0.id,
    p1_0.post_id,
    p1_0.review,
    p1_0.version 
FROM 
    post_comments p1_0 
WHERE 
    p1_0.post_id = 1
    
SELECT 
    p1_0.post_id,
    p1_0.tag_id,
    p1_0.created_on,
    p1_0.version 
FROM 
    post_tags p1_0 
WHERE 
    p1_0.post_id = 1
    
Query:["
    DELETE FROM post_details 
    WHERE id = ? AND version = ?
"], 
Params:[(1, 0)]
Query:["
    DELETE FROM user_votes 
    WHERE id = ? AND version = ?
"], 
Params:[(1, 0), (2, 0)]
Query:["
    DELETE FROM post_comments 
    WHERE id = ? AND version = ?
"], 
Params:[(1, 0), (2, 0)]
Query:["
    DELETE FROM post_tags 
    WHERE post_id = ? AND tag_id = ? AND version = ?
"], 
Params:[(1, 1, 0), (1, 2, 0), (1, 3, 0)]
Query:["
    DELETE FROM posts 
    WHERE id = ? AND version = ?
"], 
Params:[(1, 0)]

Awesome, right?

If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.

Conclusion

While bidirectional associations provide a very simple way to cascade the DELETE operation from the parent entity to child associations, there are other ways you can achieve this goal.

One way is to use bulk DELETE statements, which provides a very efficient way to remove the associated table records.

Another way we can cascade the delete operation is to intercept the Hibernate DeleteEvent and execute the cleanup logic automatically when deleting a root entity.

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.