Truly Implement Soft Delete in Spring Boot Hibernate
18-08-2023DON'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.