Truly Implement Soft Delete in Spring Boot Hibernate

18-08-2023

DON'T USE @Where Annotation. Use this implementation because @Where annotation adds deleted = false parameter into each query, so you can't fetch deleted data in no way.

Soft delete is a database management technique that involves marking records as "deleted" without physically removing them from the database. This approach is valuable for retaining data for historical or audit purposes. In a Spring Boot application using Hibernate as the JPA provider, implementing soft delete functionality can greatly enhance data management. In this article, we'll explore how to implement true soft delete in Spring Boot Hibernate using a custom Statement Inspector class.

Custom Statement Inspector

In a Spring Boot application, Hibernate is a popular choice for Object-Relational Mapping (ORM). By default, Hibernate does not provide built-in support for soft delete. However, we can leverage the StatementInspector interface to customize SQL statements and seamlessly integrate soft delete functionality.


Step 1: Creating the CustomInspector Class:


To begin, we need to create a custom class that implements the StatementInspector interface. This class will enable us to modify SQL statements before they are executed by Hibernate. Below is the implementation of the CustomInspector class:

@Component
public class CustomInspector implements StatementInspector {

    public String inspect(String sql) {
        if(sql.contains("user_roles")){
            System.out.println("user_roles");
        }
         sql = handleJoinClauses(sql);
        Pattern pattern = Pattern.compile("\\b(\\w+)\\.deleted\\b");
        Matcher matcher = pattern.matcher(sql);

        StringBuilder builder = new StringBuilder();

        while (matcher.find()) {
            String group = matcher.group(1);
            if (!containsDeletedClause(sql, group)) {
                builder.append(group).append(".deleted = false and ");
            }
        }

        if (builder.isEmpty()) return sql;
        int end = builder.length() - " and ".length();
        String conjunction = sql.contains(" where ") ? " and " : " where ";
        if (sql.contains("order by")) {
            int index = sql.indexOf(" order by");
            return sql.substring(0, index) + conjunction + builder.substring(0, end) + sql.substring(index);
        } else if (sql.contains("group by")) {
            int index = sql.indexOf(" group by");
            return sql.substring(0, index) + conjunction + builder.substring(0, end) + sql.substring(index);
        }
        return sql + conjunction + builder.substring(0, end);
    }


    private String handleJoinClauses(String sql) {
        Pattern joinPattern = Pattern.compile("(left\\s+(outer\\s+)?join|right\\s+outer\\s+join|join)\\s+(\\w+)\\s+(\\w+)\\s+on\\s+(\\w+\\.\\w+\\s*=\\s*\\w+\\.\\w+)");
        Matcher joinMatcher = joinPattern.matcher(sql);
        StringBuffer buffer = new StringBuffer();

        while (joinMatcher.find()) {
            String alias = joinMatcher.group(4); // corrected group index
            if (!containsDeletedClause(sql, alias)) {
                String replacement = joinMatcher.group(0) + " and " + alias + ".deleted = 0"; // Add the new condition
                joinMatcher.appendReplacement(buffer, replacement);
            }
        }
        joinMatcher.appendTail(buffer);
        return buffer.toString();
    }
    private boolean containsDeletedClause(String sql, String group) {
        if (sql.contains(group + ".deleted = false")) return true;
        if (sql.contains(group + ".deleted = 0")) return true;
        if (sql.contains(group + ".deleted = 1")) return true;
        if (sql.contains(group + ".deleted = true")) return true;
        if (sql.contains(group + ".deleted = ?")) return true;
        if (sql.contains(group + ".deleted=false")) return true;
        if (sql.contains(group + ".deleted=true")) return true;
        if (sql.contains(group + ".deleted=?")) return true;
        if (sql.contains(group + ".deleted= false")) return true;
        if (sql.contains(group + ".deleted= true")) return true;
        if (sql.contains(group + ".deleted= ?")) return true;
        if (sql.contains(group + ".deleted =false")) return true;
        if (sql.contains(group + ".deleted =true")) return true;
        if (sql.contains(group + ".deleted =?")) return true;
        return false;


    }

}

Step 2: Integrating CustomInspector


Now, lets register this inspector in our project:

@Component
public class MyInterceptorRegistration implements HibernatePropertiesCustomizer {

    private final CustomInspector customInspector;

    public MyInterceptorRegistration(CustomInspector customInspector) {
        this.customInspector = customInspector;
    }

    @Override
    public void customize(Map<String, Object> hibernateProperties) {
        hibernateProperties.put("hibernate.session_factory.statement_inspector", customInspector);
    }
}

Once the CustomInspector class is created, Spring Boot will automatically detect it as a component and manage its lifecycle. The inspect method within this class is where the magic happens. It uses regular expressions to identify instances of alias.deleted in the SELECT clause of SQL statements.

For each match found, the method ensures that the corresponding alias.deleted = false condition is added to the WHERE clause of the SQL statement. This approach effectively filters out logically deleted records from query results.



SoftDeletesRepository Interface and Implementation

To further enhance soft delete functionality, we can create a custom repository interface and its implementation. These components will allow us to manage soft delete operations more efficiently.

SoftDeletesRepository Interface

@Transactional
@NoRepositoryBean
public interface SoftDeletesRepository<T, ID extends Serializable> extends JpaRepository<T, ID> {

    @Modifying
    void delete(ID id);

    @Override
    @Modifying
    void delete(T entity);

    void deleteForce(ID id);
    void deleteForce(T entity);
    Optional<T> findByIdForce(ID id);
    List<T> findAllForced();
    List<T> findAllDeleted();

}

SoftDeleteRepository Implementation

import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.*;
import jakarta.transaction.Transactional;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.support.JpaEntityInformation;
import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport;
import org.springframework.data.jpa.repository.support.SimpleJpaRepository;
import org.springframework.util.Assert;

import java.io.Serializable;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

public class SoftDeletesRepositoryImpl<T, ID extends Serializable> extends SimpleJpaRepository<T, ID>
        implements SoftDeletesRepository<T, ID> {

    private final JpaEntityInformation<T, ?> entityInformation;
    private final EntityManager em;
    private final Class<T> domainClass;
    public static final String DELETED_FIELD = "deleted";

    public SoftDeletesRepositoryImpl(Class<T> domainClass, EntityManager em) {
        super(domainClass, em);
        this.em = em;
        this.domainClass = domainClass;
        this.entityInformation = JpaEntityInformationSupport.getEntityInformation(domainClass, em);
    }

    @Override
    @Transactional
    public void delete(ID id) {
        if(isFieldDeletedAtExists()){
            softDelete(id);
        }else{
            deleteForce(id);
        }
    }
    private boolean isFieldDeletedAtExists() {
        try {
            domainClass.getSuperclass().getDeclaredField(DELETED_FIELD);
            return true;
        } catch (NoSuchFieldException e) {
            return false;
        }
    }
    private void softDelete(ID id) {
        Assert.notNull(id, "The given id must not be null!");

        Optional<T> entity = findById(id);

        if (entity.isEmpty())
            throw new EmptyResultDataAccessException(
                    String.format("No %s entity with id %s exists!", entityInformation.getJavaType(), id), 1);

        softDelete(entity.get());
    }
    private void softDelete(T entity) {
        Assert.notNull(entity, "The entity must not be null!");

        CriteriaBuilder cb = em.getCriteriaBuilder();

        CriteriaUpdate<T> update = cb.createCriteriaUpdate(domainClass);

        Root<T> root = update.from(domainClass);

        update.set(DELETED_FIELD, true);

        update.where(
                cb.equal(
                        root.<ID>get(Objects.requireNonNull(entityInformation.getIdAttribute()).getName()),
                        entityInformation.getId(entity)
                )
        );

        em.createQuery(update).executeUpdate();
    }

    @Override
    @Transactional
    public void delete(T entity) {
        softDelete(entity);
    }

    @Override
    public void deleteForce(ID id) {
        var entity = findByIdForce(id);
        if(entity.isPresent()){
            deleteForce(entity.get());
        }else{
            throw new EmptyResultDataAccessException(
                    String.format("No %s entity with id %s exists!", entityInformation.getJavaType(), id), 1);
        }

    }

    @Override
    public void deleteForce(T entity) {
        super.delete(entity);
    }

    @Override
    public Optional<T> findByIdForce(ID id) {

        if(!isFieldDeletedAtExists()) return findById(id);
        Assert.notNull(id, "ID must not be null!");

        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<T> query = cb.createQuery(domainClass);
        Root<T> root = query.from(domainClass);

        Predicate deletedPredicateYes = cb.equal(root.get(DELETED_FIELD), true);
        Predicate deletedPredicateNo = cb.equal(root.get(DELETED_FIELD), false);
        Predicate idPredicate = cb.equal(
                root.<ID>get(Objects.requireNonNull(entityInformation.getIdAttribute()).getName()),
                id
        );
        query.select(root).where(cb.and(cb.or(deletedPredicateYes,deletedPredicateNo), idPredicate));
        var resp= em.createQuery(query).getSingleResult();
        return Optional.ofNullable(resp);
    }

    @Override
    public List<T> findAllForced() {

        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<T> query = cb.createQuery(domainClass);
        Root<T> root = query.from(domainClass);

        Predicate deletedPredicateYes = cb.equal(root.get(DELETED_FIELD), true);
        Predicate deletedPredicateNo = cb.equal(root.get(DELETED_FIELD), false);

        query.select(root).where(cb.or(deletedPredicateYes,deletedPredicateNo));
        return em.createQuery(query).getResultList();
    }
    public List<T> findAllDeleted() {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<T> query = cb.createQuery(domainClass);
        Root<T> root = query.from(domainClass);

        Predicate deletedPredicateYes = cb.equal(root.get(DELETED_FIELD), true);

        query.select(root).where(deletedPredicateYes);
        return em.createQuery(query).getResultList();
    }
}

CustomJpaRepositoryFactoryBean Class

import com.codesenior.helloworld.angular.repositories.softdeletes.SoftDeletesRepositoryImpl;
import jakarta.persistence.EntityManager;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean;
import org.springframework.data.jpa.repository.support.JpaRepositoryImplementation;
import org.springframework.data.repository.core.RepositoryInformation;
import org.springframework.data.repository.core.RepositoryMetadata;
import org.springframework.data.repository.core.support.RepositoryFactorySupport;

import java.io.Serializable;

@SuppressWarnings("all")
public class CustomJpaRepositoryFactoryBean<T extends JpaRepository<S, ID>, S, ID extends Serializable>
        extends JpaRepositoryFactoryBean<T, S, ID> {

    public CustomJpaRepositoryFactoryBean(Class<? extends T> repositoryInterface) {
        super(repositoryInterface);
    }

    @Override
    protected RepositoryFactorySupport createRepositoryFactory(EntityManager entityManager) {
        return new CustomJpaRepositoryFactory<T, ID>(entityManager);
    }

    private static class CustomJpaRepositoryFactory<T, ID extends Serializable> extends JpaRepositoryFactory {

        private final EntityManager entityManager;

        CustomJpaRepositoryFactory(EntityManager entityManager) {
            super(entityManager);
            this.entityManager = entityManager;
        }

        @Override
        protected JpaRepositoryImplementation<?, ?> getTargetRepository(RepositoryInformation information, EntityManager entityManager) {
            return new SoftDeletesRepositoryImpl<T, ID>((Class<T>) information.getDomainType(), this.entityManager);
        }

        @Override
        protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
            return SoftDeletesRepositoryImpl.class;
        }
    }

}

Enabling CustomJpaRepositoryFactoryBean in Spring Boot

To enable our custom repository factory bean, we need to configure it within our Spring Boot application:


@ImportRuntimeHints(CustomHint.class)
@SpringBootApplication
@EnableJpaRepositories(repositoryFactoryBeanClass = CustomJpaRepositoryFactoryBean.class)
public class PttFastApplication {

    public static void main(String[] args) {
        SpringApplication.run(PttFastApplication.class, args);
    }
}

Example Usage: BookRepository

Finally, we can use our custom repository in our application. Here's an example of a BookRepository using the SoftDeletesRepository interface:

@Repository
public interface BookRepository extends SoftDeletesRepository<Book, Long> {
}

Summary

DON'T USE @Where Annotation. Use this implementation because @Where annotation adds deleted = false parameter into each query, so you can't fetch deleted data in no way.

In this tutorial, we delved into implementing soft delete functionality in a Spring Boot application using Hibernate as the JPA provider. By harnessing the power of the CustomInspector class, we seamlessly integrated soft delete support into the data access layer of our application. This approach empowers developers to effectively manage data, retain historical records, and adhere to audit requirements, all while maintaining a clean and intuitive codebase. As you continue to develop Spring Boot applications, consider leveraging custom inspectors to tailor SQL statements and enhance database interactions according to your specific needs.

© 2019 All rights reserved. Codesenior.COM