Soft Deletion

In the Soft Deletion mode, the remove operation on JPA entities just marks database records as deleted instead of actually deleting them. Later a system administrator can either erase the records completely or restore them.

Soft deletion can help you to reduce the risk of data loss caused by incorrect user actions. It also allows users to make certain records inaccessible instantly even if there are references to them from other tables.

Soft deletion mechanism in Jmix is transparent for application developers. If you define the Soft Delete trait for an entity, the framework marks database records for deleted entity instances, and loads deleted instances according to the following rules:

  • Soft-deleted instances are not returned when loading by Id and are filtered out from results of JPQL queries.

  • In loaded entity graphs, soft-deleted instances are filtered out from collection attributes (To-Many references), but present in single-value attributes (To-One references).

    For example, imagine a Customer - Order - OrderLine data model. Initially, an Order referenced a Customer and five instances of OrderLine. You have soft deleted the Customer instance and one of the OrderLine instances. Then if you load the Order together with Customer and collection of OrderLine, it will contain the reference to the deleted Customer and four OrderLine instances in the collection.

Handling of References

When a normal (hard deleted) entity is deleted, foreign keys in the database define handling of references to this entity. By default, you cannot delete an entity if it has references from other entities. To delete the referencing entity together with your entity, or to set the reference to null, you define ON DELETE CASCADE or ON DELETE SET NULL rules for the foreign key.

For soft deleted entities foreign keys also exist, but they cannot affect the deletion because there is no deletion from the database standpoint. So by default, when an entity instance is soft deleted, it doesn’t affect any linked entities.

Jmix offers @OnDelete and @OnDeleteInverse annotations to handle references between soft-deleted entities.

Studio entity designer contains visual hints to help you choose correct annotations and their values.
  • @OnDelete annotation specifies what to do with referenced entity when deleting the current entity. In the following example, all OrderLine instances are deleted when the owning Order instance is deleted:

    public class Order {
        // ...
        @OnDelete(DeletePolicy.CASCADE)
        @Composition
        @OneToMany(mappedBy = "order")
        private List<OrderLine> lines;
  • @OnDeleteInverse annotation specifies what to do with the current entity when deleting the referenced entity. In the following example, the Customer instance cannot be deleted if there is a reference to it from an Order instance:

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

The annotations can have one of the following values:

  • DeletePolicy.DENY – to throw an exception on attempt to delete an entity if the reference is not null.

  • DeletePolicy.CASCADE – to delete the linked entities together.

  • DeletePolicy.UNLINK – to disconnect the linked entities by setting the reference attribute to null. Use this value only on the owning side of the association (the one with the @JoinColumn annotation).

Unique Constraints

Soft deletion makes creation of database unique constraints more complicated. A constraint must take into account that there may be multiple records with the same value of the unique field: one non-deleted and any number of soft deleted.

The problem is solved differently for different databases. Follow the recommendations below and use the Indexes tab of the Entity Designer to define unique constraints.

PostgreSQL

For PostgreSQL, we recommend using partial indexes.

Define a unique constraint for the desired column. The index definition in entity should look as follows:

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

Studio will generate the following Liquibase changelog:

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

Based on the changelog, Liquibase creates the partial index in the database:

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

Oracle and Microsoft SQL Server

Oracle and Microsoft SQL Server permit only one null value in a composite index. In this case, we recommend using a composite index including the DELETED_DATE column.

Define a unique constraint for the desired column. The index definition in entity should look as follows:

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

Studio will generate the following Liquibase changelog:

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

Based on the changelog, Liquibase creates the composite index in the database:

create unique index IDX_CUSTOMER_UNQ_EMAIL on CUSTOMER (EMAIL, DELETED_DATE)

MySQL and HSQL

For MySQL and HSQL, we recommend creating an additional non-null column and using a composite index which includes this column.

Create an additional attribute and make sure it is updated from the deletedDate setter:

@SystemLevel
@Column(name = "DELETED_DATE_NN")
@Temporal(TemporalType.TIMESTAMP)
private Date deletedDateNN = new Date(0); // add initializer manually

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); // add this manually
}

Define a unique constraint including DELETED_DATE_NN column. The index definition in entity should look as follows:

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

Studio will generate the following Liquibase changelog:

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

Based on the changelog, Liquibase creates the composite index in the database:

create unique index IDX_CUSTOMER_UNQ_EMAIL on CUSTOMER (EMAIL, DELETED_DATE_NN)

Turning Soft Deletion Off

By default, soft deletion is on for all entities having the Soft Delete trait. But you can turn it off for a particular operation using the PersistenceHints.SOFT_DELETION hint with the false value.

  • When loading entities using DataManager:

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

    Results will include soft deleted instances.

  • When removing entities using DataManager:

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

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