In our previous tutorial, we added an extra column to an intermediary join table. In this tutorial, we are gonna implement soft delete functionality for the entities having many-to-many
association with JPA and Hibernate.
Introduction
Soft delete performs an update operation to mark some data as deleted instead of physically deleting it from a table in the database. Basically, soft delete can be implemented by adding a field that will indicate whether the data has been deleted or not.
Implementing Soft Deletion
In order to implement soft delete functionality, we are gonna add a column called as ‘deleted
‘ in both the User
and User_Role
tables as explained in our previous tutorial. Then, we need to add support for
- Executing an update statement to set the
deleted
flag totrue
whenever thedelete command
of an entity is executed. - Filtering the records marked as deleted from all the read operations.
This is where the following hibernate annotations come into the picture:
@SQLDelete
annotation provides support for defining a custom SQL statement for the delete of an entity/collection. This SQL statement will get executed whenever the delete command is executed. Basically, we will define an SQL update statement to set thedeleted
flag totrue
instead of deleting the record physically.@Where
annotation provides support for defining a SQLwhere
clause to add to the element Entity or target entity of a collection. Thiswhere
clause will be appended to the SQL select queries of all the read operations. Here, the where clause isdeleted = false
which filters the records that are marked as deleted.
Let’s add both @SQLDelete
and @Where
annotations for both User
and UserRole
entities.
User.java
@Entity
@NoArgsConstructor
@Getter
@Setter
@SQLDelete(sql = "UPDATE user SET deleted = true WHERE id = ?")
@Where(clause = "deleted = false")
public class User implements Serializable {
UserRole.java
@Entity
@Getter
@Setter
@NoArgsConstructor
@SQLDelete(sql = "UPDATE user_role SET deleted = true WHERE role_id = ? and user_id = ?")
@Where(clause = "deleted = false")
public class UserRole implements Serializable {
Pitfalls of Soft Deletion
- When you are using the
@Where
annotation to filter the deleted records, then you may end up withorg.hibernate.exception.ConstraintViolationException
if you try to persist a record with a primary key that already exists in the table.- For example, in this tutorial, the
email
User
table. So, when you want to insert a new user, you will first check if a user with the sameemail id
already exists in the table. If it exists and is marked as deleted, then your select query will filter out this record from the result. So, you will assume that it does not exist and try to insert this new user which will result inConstraintViolationException
UserRole
table as well. Hence, in this scenario, it is better to avoid using the@Where
annotation for filtering the deleted records.
- For example, in this tutorial, the
- When your domain model has many tables and they are associated with each other, then you should not try to implement soft delete for some specific tables alone. Either you should implement it for all or not at all. Otherwise, you may end up with
FOREIGN KEY constraint
exceptions.
Testing Time
Now, we are gonna define h2 in-memory database connection properties in src/test/resources/application.properties
file for our Junit tests.
application.properties
#Test Properties
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=MySQL;NON_KEYWORDS=USER
spring.datasource.username=sa
spring.datasource.password=sa
spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
Note: We have specified
NON_KEYWORDS=USER
in the connection string. In h2 in-memory database,user
is a keyword. But, we have mapped theuser
entity to theuser
table. Hence we need to specify this in the connection string in order to tell the h2 database not to interpretuser
as a keyword. if you don’t do this, then you need to change the table name to something like ‘users
‘. otherwise you will end up with"Syntax error in SQL statement ... expected identifier"
src/test/resources/insert-scripts.sql
Here, we have added the insert statements for inserting roles into the tables required for the tests.
INSERT INTO `role` (`id`, `name`) VALUES (1, 'ROLE_USER');
INSERT INTO `role` (`id`, `name`) VALUES (2, 'ROLE_ADMIN');
UserRepositoryTest.java
This test class is responsible for testing the soft deletion of User
& UserRole
entities.
@DataJpaTest
annotation is used for testing only the JPA components. Using this annotation will disable full auto-configuration and instead apply only configuration relevant to JPA tests. By default, tests annotated with @DataJpaTest
are transactional and roll back at the end of each test. They also use an embedded in-memory database (replacing any explicit or usually auto-configured DataSource). The @AutoConfigureTestDatabase
annotation can be used to override these settings.
@AutoConfigureTestDatabase
annotation can be applied to a test class to configure a test database to use instead of the application-defined or auto-configured DataSource
. In the case of multiple DataSource
beans, only the @Primary
DataSource
is considered. Here, we have set the replace
attribute to Replace.NONE
since we had to customize the h2 database connection string to include NON_KEYWORDS=US
ER
settings. Otherwise, we don’t need to use this annotation at all since @DataJpaTest
will use the default connection string defined for the embedded h2 database.
@SQL
annotation is used to annotate a test class or test method to configure SQL scripts
and statements
to be executed against a given database during integration tests.
package com.javachinna.repo;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.jdbc.Sql;
import com.javachinna.model.Role;
import com.javachinna.model.User;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@DataJpaTest
@Sql(scripts = "classpath:insert-scripts.sql")
@AutoConfigureTestDatabase(replace = Replace.NONE)
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Autowired
private RoleRepository roleRepository;
@Test
void testSoftDelete() {
// Add a new user
User user = new User();
user.setDisplayName("Chinna");
user.setEmail("[email protected]");
// Add a role to the user
Role adminRole = roleRepository.findByName(Role.ROLE_ADMIN);
user.addRole(roleRepository.findByName(Role.ROLE_USER));
user.addRole(adminRole);
user = userRepository.saveAndFlush(user);
assertEquals(2, user.getRoles().size());
// Remove a role from the user
user.removeRole(adminRole);
userRepository.saveAndFlush(user);
user = userRepository.findByEmail("[email protected]");
assertEquals(1, user.getRoles().size());
log.info("User Roles after removal: {}", user.getRoles().stream().map(r -> r.getRole().getName()).collect(Collectors.toList()));
userRepository.delete(user);
assertEquals(0, userRepository.findAll().size());
}
}
Note: Here, I have called the
JpaRepository.saveAndFlush()
method instead of theCrudRepository.save()
method. This is because,save
method will persist only the user entity. But the associated user roles will be persisted when the changes are flushed. Hence, I wanted to save and flush the pending changes immediately in order to see the insert statements consecutively in the output.
Output
2022-08-06 03:08:01.370 INFO 29812 --- [ main] o.s.t.c.transaction.TransactionContext : Began transaction (1) for test context [DefaultTestContext@46268f08 testClass = UserRepositoryTest, testInstance = com.javachinna.repo.UserRepositoryTest@1d3ac898, testMethod = testSoftDelete@UserRepositoryTest, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@2a76840c testClass = UserRepositoryTest, locations = '{}', classes = '{class com.javachinna.Application}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestContextBootstrapper=true}', contextCustomizers = set[[ImportsContextCustomizer@71454b9d key = [org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration, org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration, org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration, org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration, org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration, org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration, org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManagerAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@1bae316d, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@7cbd9d24, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@21129f1f, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@68d279ec, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@351584c0, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@50ae1eef, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@1115ec15, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@0], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]; transaction manager [org.springframework.orm.jpa.JpaTransactionManager@53ddabc6]; rollback [true]
Hibernate: select role0_.id as id1_0_, role0_.name as name2_0_ from role role0_ where role0_.name=?
Hibernate: select role0_.id as id1_0_, role0_.name as name2_0_ from role role0_ where role0_.name=?
Hibernate: insert into user (id, created_date, deleted, display_name, email, enabled, modified_date, password, provider, provider_user_id, secret, using_2fa) values (default, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
Hibernate: insert into user_role (deleted, role_id, user_id) values (?, ?, ?)
Hibernate: insert into user_role (deleted, role_id, user_id) values (?, ?, ?)
Hibernate: UPDATE user_role SET deleted = true WHERE role_id = ? and user_id = ?
Hibernate: select user0_.id as id1_1_, user0_.created_date as created_2_1_, user0_.deleted as deleted3_1_, user0_.display_name as display_4_1_, user0_.email as email5_1_, user0_.enabled as enabled6_1_, user0_.modified_date as modified7_1_, user0_.password as password8_1_, user0_.provider as provider9_1_, user0_.provider_user_id as provide10_1_, user0_.secret as secret11_1_, user0_.using_2fa as using_12_1_ from user user0_ where ( user0_.deleted = false) and user0_.email=?
2022-08-06 03:08:01.608 INFO 29812 --- [ main] com.javachinna.repo.UserRepositoryTest : User Roles after removal: [ROLE_USER]
Hibernate: UPDATE user_role SET deleted = true WHERE role_id = ? and user_id = ?
Hibernate: UPDATE user SET deleted = true WHERE id = ?
Hibernate: select user0_.id as id1_1_, user0_.created_date as created_2_1_, user0_.deleted as deleted3_1_, user0_.display_name as display_4_1_, user0_.email as email5_1_, user0_.enabled as enabled6_1_, user0_.modified_date as modified7_1_, user0_.password as password8_1_, user0_.provider as provider9_1_, user0_.provider_user_id as provide10_1_, user0_.secret as secret11_1_, user0_.using_2fa as using_12_1_ from user user0_ where ( user0_.deleted = false)
2022-08-06 03:08:01.623 INFO 29812 --- [ main] o.s.t.c.transaction.TransactionContext : Rolled back transaction for test: [DefaultTestContext@46268f08 testClass = UserRepositoryTest, testInstance = com.javachinna.repo.UserRepositoryTest@1d3ac898, testMethod = testSoftDelete@UserRepositoryTest, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@2a76840c testClass = UserRepositoryTest, locations = '{}', classes = '{class com.javachinna.Application}', contextInitializerClasses = '[]', activeProfiles = '{}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestContextBootstrapper=true}', contextCustomizers = set[[ImportsContextCustomizer@71454b9d key = [org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration, org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration, org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration, org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration, org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration, org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration, org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManagerAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@1bae316d, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@7cbd9d24, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@21129f1f, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@68d279ec, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@351584c0, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@50ae1eef, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@1115ec15, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@0], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]
Source Code
https://github.com/JavaChinna/jpa-hibernate-soft-delete
Conclusion
That’s all folks. In this article, we have implemented the soft delete functionality for the user entity and written a Junit test to test if the records are soft deleted as expected.
Thank you for reading.