软删除

Soft Deletion 模式下,对 JPA 实体的删除操作只是将数据库记录标记为已删除,实际上并没有删除。之后,系统管理员可以彻底删除或恢复这些数据。

软删除可以帮助降低因用户错误操作而导致数据丢失的风险。另外,即使其他表引用了某些记录,软删除也可以让这些记录立即变为不可访问状态。

Jmix 中的软删除机制对应用程序开发人员是透明的。如果为实体定义了 软删除特性,框架会为在数据库标记已删除的实体实例,并根据以下规则加载已删除的实例:

  • 按 Id 加载时不返回软删除的实例,从 JPQL 查询的结果中过滤掉。

  • 在加载的实体关系图中,软删除的实例会从集合属性(To-Many 引用)中过滤掉,但会存在于单值属性(To-One 引用)中。

    例如,想象一个 Customer - Order - OrderLine 数据模型。最初,一个 Order 引用了一个 Customer 和 五个 OrderLine 实例。你软删除了 Customer 实例和一个 OrderLine 实例。然后,如果你将 Order、Customer 和 OrderLine 集合一起加载,将包含已删除的 Customer 和 四个 OrderLine 实例的引用。

对引用的处理

当一个普通的(硬删除的)实体被删除时,由数据库中的外键定义对该实体引用的处理。默认情况下,如果一个实体被其他实体引用,则无法被删除。如需将引用的实体与要删除的实体一起删除,或将引用设置为 null,则需要为外键定义 ON DELETE CASCADEON DELETE SET NULL 规则。

对于软删除的实体,外键也存在,但它们不会影响删除,因为从数据库的角度来看没有删除。所以默认情况下,当实体实例被软删除时,不会影响任何有关联的实体。

Jmix 提供了 @OnDelete@OnDeleteInverse 注解来处理软删除实体之间的引用。

Studio 实体设计器有提示可以帮助你选择正确的注解及其值。
  • @OnDelete 注解指定在删除当前实体时如何处理引用的实体。在下面的例子中,当拥有方 Order 实例被删除时,所有的 OrderLine 实例都会被删除:

    public class Order {
        // ...
        @OnDelete(DeletePolicy.CASCADE)
        @Composition
        @OneToMany(mappedBy = "order")
        private List<OrderLine> lines;
  • @OnDeleteInverse 注解指定删除引用的实体时如何处理当前实体。在以下示例中,如果 Order 实例中有对 Customer 实例的引用,则无法删除 Customer

    public class Order {
        // ...
        @OnDeleteInverse(DeletePolicy.DENY)
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "CUSTOMER_ID")
        private Customer customer;

注解值可以有以下三种:

  • DeletePolicy.DENY - 如果引用不为空,则在尝试删除实体时抛出异常。

  • DeletePolicy.CASCADE - 一并删除关联的实体。

  • DeletePolicy.UNLINK - 通过将引用属性设置为空来断开关联。仅在关联的拥有方(带有 @JoinColumn 注解的一方)使用此值。

唯一性约束

软删除使创建数据库唯一约束更加复杂。约束必须考虑到可能有多个记录具有相同的唯一字段值:一个未删除和任意数量的软删除记录。

对于不同的数据库,该问题的解决方式不同。按照下面的建议并使用 实体设计器Indexes 标签页定义唯一约束。

PostgreSQL

对于 PostgreSQL,我们推荐使用部分索引(partial indexes)。

为需要的列定义唯一约束。实体中的索引定义示例:

@Table(name = "CUSTOMER", uniqueConstraints = {
        @UniqueConstraint(name = "IDX_CUSTOMER_UNQ_EMAIL", columnNames = {"EMAIL"})
})

Studio 会生成下列 Liquibase 更改日志:

<changeSet id="1" author="demo" dbms="postgresql">
    <createIndex indexName="IDX_CUSTOMER_UNQ_EMAIL" tableName="CUSTOMER" unique="true">
        <column name="EMAIL"/>
    </createIndex>

    <modifySql>
        <append value="where DELETED_DATE is null"/>
    </modifySql>
</changeSet>

根据更改日志,Liquibase 会在数据库创建部分索引:

create unique index IDX_CUSTOMER_UNQ_EMAIL on CUSTOMER (EMAIL) where DELETED_DATE is null

Oracle 和 Microsoft SQL Server

Oracle 和 Microsoft SQL Server 仅支持组合索引中有一个 null 值。因此,我们推荐使用包含 DELETED_DATE 列的组合索引。

为需要的列定义唯一约束。实体中的索引定义示例:

@Table(name = "CUSTOMER", uniqueConstraints = {
        @UniqueConstraint(name = "IDX_CUSTOMER_UNQ_EMAIL", columnNames = {"EMAIL"})
})

Studio 会生成下列 Liquibase 更改日志:

<changeSet id="1" author="demo">
    <createIndex indexName="IDX_CUSTOMER_UNQ_EMAIL" tableName="CUSTOMER" unique="true">
        <column name="EMAIL"/>
        <column name="DELETED_DATE"/>
    </createIndex>
</changeSet>

根据更改日志,Liquibase 会在数据库创建组合索引:

create unique index IDX_CUSTOMER_UNQ_EMAIL on CUSTOMER (EMAIL, DELETED_DATE)

MySQL 和 HSQL

对于 MySQL 和 HSQL,我们建议创建一个额外的非 null 列,并使用包含该列的组合索引。

创建一个额外的属性,并确保通过 deletedDate setter 更新:

@SystemLevel
@Column(name = "DELETED_DATE_NN")
@Temporal(TemporalType.TIMESTAMP)
private Date deletedDateNN = new Date(0); // 手动初始化

public Date getDeletedDateNN() {
    return deletedDateNN;
}

public void setDeletedDateNN(Date deletedDateNN) {
    this.deletedDateNN = deletedDateNN;
}

public void setDeletedDate(Date deletedDate) {
    this.deletedDate = deletedDate;
    setDeletedDateNN(deletedDate == null ? new Date(0) : deletedDate); // 手动添加这些代码
}

定义包含 DELETED_DATE_NN 列的唯一约束。实体中的索引定义示例:

@Table(name = "CUSTOMER", uniqueConstraints = {
        @UniqueConstraint(name = "IDX_CUSTOMER_UNQ_EMAIL", columnNames = {"EMAIL", "DELETED_DATE_NN"})
})

Studio 会生成下列 Liquibase 更改日志:

<changeSet id="1" author="demo">
    <createIndex indexName="IDX_CUSTOMER_UNQ_EMAIL" tableName="CUSTOMER" unique="true">
        <column name="EMAIL"/>
        <column name="DELETED_DATE_NN"/>
    </createIndex>
</changeSet>

根据更改日志,Liquibase 会在数据库创建组合索引:

create unique index IDX_CUSTOMER_UNQ_EMAIL on CUSTOMER (EMAIL, DELETED_DATE_NN)

关闭软删除

默认情况下,对所有具有 软删除特性 的实体启用软删除。但是你可以使用带有 false 值的 PersistenceHints.SOFT_DELETION hint 为特定的操作关闭它。

  • 使用 DataManager 加载实体时:

    @Autowired
    private DataManager dataManager;
    
    public Customer loadHardDeletedCustomer(Id<Customer> customerId) {
        return dataManager.load(customerId).hint(PersistenceHints.SOFT_DELETION, false).one();
    }

    结果中将会包含软删除的实例。

  • 使用 DataManager 删除实体时:

    @Autowired
    private DataManager dataManager;
    
    public void hardDeleteCustomer(Customer customer) {
        dataManager.save(
                new SaveContext()
                        .removing(customer)
                        .setHint(PersistenceHints.SOFT_DELETION, false)
        );
    }
  • 使用 EntityManager 时:

    @PersistenceContext
    private EntityManager entityManager;
    
    @Transactional
    public void hardRemoveCustomerByEM(Customer customer) {
        entityManager.setProperty(PersistenceHints.SOFT_DELETION, false);
        entityManager.remove(customer);
    }