Spring Data JPA 级联删除单向关联

简介

本文将带你了解,当无法依赖 CascadeType 机制来将状态转换从父实体传播到子实体时,如何使用 Spring Data JPA 级联删除单向关联,

Domain Model

假设我们的系统中有以下实体:

Post 和其单向的子实体

Post 实体是该实体层次结构的根(root),子实体只使用单向 @ManyToOne@OneToOne 关联,用于映射一个一对多或一对一表关系的底层外键。

Post root 实体如下:

@Entity
@Table(name = "post")
public class Post {
 
    @Id
    private Long id;
 
    private String title;
 
    // getter/setter 方法省略
}

请注意,没有双向的 @OneToMany@OneToOne 关联,可以允许我们从父级 Post 级联 DELETE 操作到 PostCommentPostDetailsPostTag 子实体。

PostComment 实体使用单向 @ManyToOne 关联映射 post_id 外键列:

@Entity
@Table(name = "post_comment")
public class PostComment {
 
    @Id
    @GeneratedValue
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;
 
    private String review;
 
    //getter/setter 方法省略
}

PostDetails 实体使用单向 @OneToOne 关联映射 id 外键列:

@Entity
@Table(name = "post_details")
public class PostDetails {
 
    @Id
    private Long id;
 
    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    private Post post;
 
    @Column(name = "created_on")
    private LocalDateTime createdOn;
 
    @Column(name = "created_by")
    private String createdBy;
 
    //getter/setter 方法省略 
}

PostTag 实体使用单向 @ManyToOne 关联映射 post_id 外键列:

@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();
 
    //getter/setter 方法省略 
}

由于 UserVote 实体是 PostComment 的子实体,因此 Post 实体不仅有直接的子关联。因此,它是 Post root 实体的孙子关联:

@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;
     
    //getter/setter 方法省略 
}

创建 Post 实体

创建一个包含以下内容的 Post 实体:

  • 一个 PostDetails 子实体
  • 两个 PostComment 子实体,每个子实体都有一个 UserVote 实体
  • 三个 PostTag 子实体
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));

如何使用 Spring Data JPA 级联 DELETE 单向关联?

现在,我们希望有一种方法来移除给定的 Post 实体,如果我们在之前创建的 Post 实体上使用 PostRepository 的默认 deleteById 方法:

postRepository.deleteById(1L);

将会抛出 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".

抛出 ConstraintViolationException 的原因是 post 表记录仍被 post_detailspost_commentpost_tag 表中的子实体引用。

因此,我们需要确保在删除某个 Post 实体之前,先删除所有子实体。

为此,修改 PostRepository,继承 CustomPostRepository 接口:

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

CustomPostRepository 定义了 deleteById 方法,我们打算在自定义 JPA Repository 中覆写该方法,这样就可以将 DELETE 操作从 Post 实体级联到所有单向关联:

public interface CustomPostRepository<ID> {
 
    void deleteById(ID postId);
}

有关自定义 Spring Data JPA Repository 的介绍,可以参考 官方文档

CustomPostRepository 接口如下:

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) {
        Post post = null;
        PostDetails postDetails = postDetailsRepository.findWithPostById(postId);
 
        if(postDetails != null) {
            postDetailsRepository.delete(postDetails);
            post = postDetails.getPost();
        } else {
            post = entityManager.getReference(Post.class, postId);
        }
 
        userVoteRepository.deleteAllByPostId(postId);
        postCommentRepository.deleteAllByPostId(postId);
        postTagRepository.deleteAllByPostId(postId);
 
        entityManager.remove(post);
    }
}

deleteById 方法的实现方式是,我们可以清理所有指向 Post 实体的关联子表记录,无论是直接通过外键还是间接通过一系列外键引用,就像 user_vote 表记录的情况一样。

因此,首先加载相关的 PostDetails,如果找到这个子实体,删除它并获取对 Post 实体的引用。如果找不到子实体,就加载一个 Post 实体引用,在最后一个操作中使用它来删除这个实体。

第二步,通过批量 DELETE 语句调用 UserVoteRepository 中的 deleteAllByPostId 方法来删除 UserVotes

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

接下来,使用单个批量 DELETE 语句调用 PostCommentRepository 中的 deleteAllByPostId 方法,删除 PostComment 子实体:

@Repository
public interface PostCommentRepository
        extends BaseJpaRepository<PostComment, Long> {
 
    @Query("""
        delete from PostComment
        where post.id = :postId
        """)
    @Modifying
    void deleteAllByPostId(@Param("postId") Long postId);
}

之后,使用批量 DELETE 语句调用 PostTagRepository 中的 deleteAllByPostId 方法,删除 PostTag 子实体:

@Repository
public interface PostTagRepository
        extends BaseJpaRepository<PostTag, PostTagId> {
 
    @Query("""
        delete from PostTag
        where post.id = :postId
        """)
    @Modifying
    void deleteAllByPostId(@Param("postId") Long postId);
}

最后,在删除所有子单向关联后,就可以继续删除 root Post 实体了。

当调用 PostRepository 上的 deleteById 方法时:

postRepository.deleteById(1L);

Spring Data JPA 和 Hibernate 将执行以下 SQL 语句:

SELECT
    pd.post_id,
    pd.created_by,
    pd.created_on,
    p.id,
    p.title
FROM post_details pd
JOIN post p ON p.id = pd.post_id
WHERE pd.post_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_details
WHERE post_id = 1
 
DELETE FROM post
WHERE id = 1

参考:https://vladmihalcea.com/cascade-delete-unidirectional-associations-spring/