使用 Hibernate 实现软删除的最佳方式

使用 Hibernate 实现软删除的最佳方式

1、引言

每个数据库应用程序都是独特的。虽然大多数时候删除记录是最好的方法,但有时应用程序的要求是数据库记录永远不应该被物理删除。

那么,谁会使用这种技术呢?

例如,StackOverflow 对所有帖子(如问题和答案)都使用了这种技术。StackOverflow 的 Posts 表中有一个 ClosedDate 列,它作为软删除机制,因为它隐藏了所有声望低于 10k 的用户的答案。

如果你使用的是 Oracle,你可以利用其 Flashback 功能,这样你就不需要更改应用程序代码来提供这样的功能。另一个选择是使用 SQL Server 的 Temporal Table 功能。

然而,并不是所有的关系数据库系统都支持 Flashback 查询,或者它们允许你在不从数据库备份中恢复的情况下恢复某条记录。在这种情况下,Hibernate 允许你简化软删除的实现,本篇文章将解释实现逻辑删除机制的最佳方式。

如果你的实体正在使用乐观锁定的 @Version 属性,那么请查看这篇文章,了解如何将版本属性映射到你的实体中。

文章: https://vladmihalcea.com/soft-delete-jpa-version/

2、领域模型

假设我们在数据库中有以下表:

39706d4a6a146345c6c2f9f58ec45ac3_softdeletedomainmodel

软删除领域模型

如上图所示,post、post_details、post_comment 和 tag 表包含一个 deleted 列,它决定了给定行的可见性。这个数据库表模型的有趣之处在于它涵盖了所有三种数据库关系类型:

  • 一对一
  • 一对多
  • 多对多

因此,我们将讨论所有这些实体及其关系的映射,敬请期待!

3、Tag 实体

让我们从 Tag 实体映射开始,因为它缺少任何实体关系:

@Entity(name = "Tag")
@Table(name = "tag")
@SQLDelete(sql = """
    UPDATE tag
    SET deleted = true
    WHERE id = ?
    """)
@Loader(namedQuery = "findTagById")
@NamedQuery(name = "findTagById", query = """
    SELECT t
    FROM Tag t
    WHERE
        t.id = ?1 AND
        t.deleted = false
    """)
@Where(clause = "deleted = false")
public class Tag extends BaseEntity {
 
    @Id
    private String id;
 
    // Getters 和 setters 省略
}

deleted 列定义在 BaseEntity 类中,如下所示:

@MappedSuperclass
public abstract class BaseEntity {
 
    private boolean deleted;
}

@SqlDelete 注解允许你覆盖 Hibernate 执行的默认 DELETE 语句,因此我们用 UPDATE 语句代替。因此,删除一个实体最终会将 deleted 列更新为 true。

@Loader 注解允许我们自定义用于通过标识符加载实体的 SELECT 查询。因此,我们希望过滤掉所有 deleted 列设置为 true 的记录。

@Where 子句用于实体查询,我们希望提供它,以便 Hibernate 可以附加 deleted 列过滤条件来隐藏已删除的行

在 Hibernate 5.2 之前,只提供 @Where 子句注解已经足够,但在 Hibernate 5.2 中,还需要提供一个自定义 @Loader,以便直接获取也能正常工作。

所以,假设我们有四个 Tag 实体:

doInJPA(entityManager -> {
    Tag javaTag = new Tag();
    javaTag.setId("Java");
    entityManager.persist(javaTag);
 
    Tag jpaTag = new Tag();
    jpaTag.setId("JPA");
    entityManager.persist(jpaTag);
 
    Tag hibernateTag = new Tag();
    hibernateTag.setId("Hibernate");
    entityManager.persist(hibernateTag);
 
    Tag miscTag = new Tag();
    miscTag.setId("Misc");
    entityManager.persist(miscTag);
});

当删除 Misc Tag 时:

doInJPA(entityManager -> {
    Tag miscTag = entityManager.getReference(Tag.class, "Misc");
    entityManager.remove(miscTag);
});

Hibernate 将执行以下 SQL 语句:

UPDATE tag
SET deleted = true
WHERE id = 'Misc'

精彩!

所以现在,如果我们想加载这个实体,我们会得到 null:

doInJPA(entityManager -> {
    assertNull(entityManager.find(Tag.class, "Misc"));
});

这是因为 Hibernate 执行了以下 SQL 语句:

SELECT
    t.id as id1_4_,
    t.deleted as deleted2_4_
FROM
    tag t
WHERE
    ( t.deleted = 0 ) AND
    t.id = ? AND
    t.deleted = 0

虽然 deleted 子句被附加了两次,因为我们同时声明了 @Where 子句和 @Loader,但大多数 RDBMS 在查询解析期间会消除重复的过滤器。如果我们只提供 @Where 子句,就不会有重复的删除子句,但在直接获取时已删除的行会变得可见。

此外,当对所有 Tag 实体运行实体查询时,我们可以看到现在只有三个 Tag:

doInJPA(entityManager -> {
    List<Tag> tags = entityManager.createQuery(
        "select t from Tag t", Tag.class)
    .getResultList();
 
    assertEquals(3, tags.size());
});

这是因为 Hibernate 在执行 SQL 查询时成功附加了 deleted 子句过滤器:

SELECT
    t.id as id1_4_,
    t.deleted as deleted2_4_
FROM tag t
WHERE ( t.deleted = 0 )

4、PostDetails 实体

就像 Tag 一样,PostDetails 也遵循相同的映射考虑:

@Entity(name = "PostDetails")
@Table(name = "post_details")
@SQLDelete(sql = """
    UPDATE post_details
    SET deleted = true
    WHERE id = ?
    """)
@Loader(namedQuery = "findPostDetailsById")
@NamedQuery(name = "findPostDetailsById", query = """
    SELECT pd
    FROM PostDetails pd
    WHERE
        pd.id = ?1 AND
        pd.deleted = false
    """)
@Where(clause = "deleted = false")
public class PostDetails extends BaseEntity {
 
    @Id
    private Long id;
 
    @Column(name = "created_on")
    private Date createdOn;
 
    @Column(name = "created_by")
    private String createdBy;
 
    public PostDetails() {
        createdOn = new Date();
    }
 
    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    private Post post;
 
    // Getters 和 setters 省略
}

即使它具有与 Post 的 @OneToOne 关联,也不需要过滤这个关系,因为子实体不能在没有父实体的情况下存在。

5、PostComment 实体

同样的逻辑适用于 PostComment:

@Entity(name = "PostComment")
@Table(name = "post_comment")
@SQLDelete(sql = """
    UPDATE post_comment
    SET deleted = true
    WHERE id = ?
    """)
@Loader(namedQuery = "findPostCommentById")
@NamedQuery(name = "findPostCommentById", query = """
    SELECT pc
    from PostComment pc
    WHERE
        pc.id = ?1 AND
        pc.deleted = false
    """)
@Where(clause = "deleted = false")
public class PostComment extends BaseEntity {
 
    @Id
    private Long id;
 
    @ManyToOne(fetch = FetchType.LAZY)
    private Post post;
 
    private String review;
 
    // Getters 和 setters 省略
}

即使它具有与 Post 的 @ManyToOne 关联,也不需要过滤这个关系,因为子实体不能在没有父实体的情况下存在。

6、Post 实体

作为我们实体聚合的根,Post 实体与 PostDetails、PostComment 和 Tag 有关系:

@Entity(name = "Post")
@Table(name = "post")
@SQLDelete(sql = """
    UPDATE post
    SET deleted = true
    WHERE id = ?
    """)
@Loader(namedQuery = "findPostById")
@NamedQuery(name = "findPostById", query = """
    SELECT p
    FROM Post p
    WHERE
        p.id = ?1 AND
        p.deleted = false
    """)
@Where(clause = "deleted = false")
public class Post extends BaseEntity {
 
    @Id
    private Long id;
 
    private String title;
 
    @OneToMany(
        mappedBy = "post",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private List<PostComment> comments = new ArrayList<>();
 
    @OneToOne(
        mappedBy = "post",
        cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.LAZY
    )
    private PostDetails details;
 
    @ManyToMany
    @JoinTable(
        name = "post_tag",
        joinColumns = @JoinColumn(name = "post_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    private List<Tag> tags = new ArrayList<>();
 
    // Getters 和 setters 省略
 
    public void addComment(PostComment comment) {
        comments.add(comment);
        comment.setPost(this);
    }
 
    public void removeComment(PostComment comment) {
        comments.remove(comment);
        comment.setPost(null);
    }
 
    public void addDetails(PostDetails details) {
        this.details = details;
        details.setPost(this);
    }
 
    public void removeDetails() {
        this.details.setPost(null);
        this.details = null;
    }
 
    public void addTag(Tag tag) {
        tags.add(tag);
    }
}

Post 实体映射与我们已经讨论过的 Tag 实体类似,所以我们将重点放在 @OneToMany 和 @ManyToMany 关联上。

7、双向 @OneToMany 关联

在 Hibernate 5.2 之前,有必要为集合(如 @OneToMany@ManyToMany)提供 @Where 子句注解,但在 Hibernate 5.2 中,我们不需要这些集合级别的注解,因为 PostComment 已经相应地注解了,Hibernate 知道需要过滤任何已删除的 PostComment

因此,假设我们有一个包含两个 PostComment 子实体的 Post 实体:

doInJPA(entityManager -> {
    Post post = new Post();
    post.setId(1L);
    post.setTitle("High-Performance Java Persistence");
    entityManager.persist(post);
 
    PostComment comment1 = new PostComment();
    comment1.setId(1L);
    comment1.setReview("Great!");
    post.addComment(comment1);
 
    PostComment comment2 = new PostComment();
    comment2.setId(2L);
    comment2.setReview("Excellent!");
    post.addComment(comment2);
});

当我们删除一个 PostComment 时:

doInJPA(entityManager -> {
    Post post = entityManager.find(Post.class, 1L);
    post.removeComment(post.getComments().get(0));
});

级联机制将触发子删除,Hibernate 将执行以下 SQL 语句:

UPDATE post_comment
SET deleted = true
WHERE id = 1

现在我们可以看到集合中只有一个条目:

doInJPA(entityManager -> {
    Post post = entityManager.find(Post.class, 1L);
    assertEquals(1, post.getComments().size());
});

在获取 comments 集合时,Hibernate 执行以下查询:

SELECT
    pc.id as id1_0_,
    pc.deleted as deleted2_0_,
    pc.title as title3_0_
FROM
    post pc
WHERE
    ( pc.deleted = 0) AND
    pc.id=1 AND
    pc.deleted = 0

我们需要在 @OneToMany 和 @ManyToMany 关联上使用 @Where 子句注解的原因是,集合就像实体查询一样。子实体可能已被删除,因此在获取集合时我们需要隐藏它。

8、双向 @ManyToMany 关联

同样,因为我们使用的是双向关联,所以不需要在子关系级别应用 @Where 注解。@Where 注解在集合上只有在单向关联时才有意义,但这些关联不如双向关联高效。

所以,如果我们有一个包含三个 Tag 子实体的 Post 实体:

doInJPA(entityManager -> {
    Post post = new Post();
    post.setId(1L);
    post.setTitle("High-Performance Java Persistence");
 
    entityManager.persist(post);
 
    post.addTag(entityManager.getReference(
        Tag.class, "Java"
    ));
    post.addTag(entityManager.getReference(
        Tag.class, "Hibernate"
    ));
    post.addTag(entityManager.getReference(
        Tag.class, "Misc"
    ));
});
 
doInJPA(entityManager -> {
    Post post = entityManager.find(Post.class, 1L);
    assertEquals(3, post.getTags().size());
});

如果我们删除一个 Tag:

doInJPA(entityManager -> {
    Tag miscTag = entityManager.getReference(Tag.class, "Misc");
    entityManager.remove(miscTag);
});

然后,我们将不再在 tags 集合中看到它:

doInJPA(entityManager -> {
    Post post = entityManager.find(Post.class, 1L);
    assertEquals(2, post.getTags().size());
});

这是因为 Hibernate 在加载子实体时将其过滤掉:

SELECT
    pt.post_id as post_id1_3_0_,
    pt.tag_id as tag_id2_3_0_,
    t.id as id1_4_1_,
    t.deleted as deleted2_4_1_
FROM post_tag pt
INNER JOIN
    tag t ON pt.tag_id = t.id
WHERE
    ( t.deleted = 0 ) AND
    pt.post_id = 1

9、结论

当你的应用程序需要保留已删除的条目并仅在 UI 中隐藏它们时,软删除是一个非常方便的功能。虽然使用 Oracle 的 Flashback 技术更为方便,但如果你的数据库没有这样的功能,Hibernate 可以简化这项任务。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值