Soft delete and JPA version property
Imagine having a tool that can automatically detect JPA and Hibernate performance issues. Wouldn’t that be just awesome?
Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, or Play Framework.
So, enjoy spending your time on the things you love rather than fixing performance issues in your production system on a Saturday night!
Introduction
In this article, we are going to see how we can implement a soft delete mechanism when the JPA entity features a version property.
If you are not using a version property for optimistic locking, then you can implement the soft delete strategy, as explained in this article.
Domain Model
Let’s assume we have the following Post
, PostDetails
, PostComment
, and Tag
entities that extend the SoftDeletable
base class, as illustrated by the following diagram:
Soft delete when the entity has a JPA version property
Now, we want to instruct Hibernate to execute an UPDATE statement that sets the deleted
column to the value of true
when a Post
, PostDetails
, PostComments
, or Tag
entity is deleted.
To accomplish this task, we will need to use the following Hibernate-specific annotations:
@SQLDelete
– to override the SQL statements that’s executed whenever an entity is deleted so that we run an UPDATE statement instead.@Loader
and – to override the default query that loads the entity when using afind
method. Using a custom SQL query, we can filter out the records that have thedeleted
column set to the value oftrue
.@Where
– to override the default query plan when loading the entity using an entity query so that we filter out the records that have thedeleted
column set to the value oftrue
.
Soft deleting the Tag entity
For the Tag
entity, we will use these Hibernate annotations as follows:
@Entity(name = "Tag") @Table(name = "tag") @SQLDelete(sql = """ UPDATE tag SET deleted = true WHERE id = ? AND version = ? """) @Loader(namedQuery = "findTagById") @NamedQuery(name = "findTagById", query = """ select t from Tag t where t.id = ?1 and t.deleted = false """) @Where(clause = "deleted = false") public class Tag extends SoftDeletable { @Id @GeneratedValue private Long id; @NaturalId private String name; @Version private short version; }
Notice that the @SqlDelete
query uses the version
property as well when deleting the entity so that we can prevent lost updates.
If you wonder why the version property is mapped as a
Short
, and not asInteger
orLong
, then check out this article about the benefits of using a compact optimistic locking property.
Now, let’s assume we have the following data in the database:
entityManager.persist(new Tag().setName("Java")); entityManager.persist(new Tag().setName("JPA")); entityManager.persist(new Tag().setName("Hibernate")); entityManager.persist(new Tag().setName("Misc")); Post post = new Post().setTitle("High-Performance Java Persistence"); entityManager.persist(post); Session session = entityManager.unwrap(Session.class); post.addTag(session.bySimpleNaturalId(Tag.class).getReference("Java")); post.addTag(session.bySimpleNaturalId(Tag.class).getReference("Hibernate")); post.addTag(session.bySimpleNaturalId(Tag.class).getReference("Misc"));
Now, when removing one Tag
entity:
Tag miscTag = doInJPA(entityManager -> { return entityManager .unwrap(Session.class) .bySimpleNaturalId(Tag.class) .getReference("Misc"); }); doInJPA(entityManager -> { entityManager.remove(miscTag); });
We can see that Hibernate executes an UPDATE
statement that sets the deleted
column to the value of true
for the tag
record that’s just got deleted:
UPDATE tag SET deleted = true WHERE id = 4 AND version = 0
When loading the Post
entity, we can see that it now has only 2 Tag
entities associated since we deleted one entry:
Post post = entityManager.find(Post.class, 1L); assertEquals(2, post.getTags().size());
While the tag
record with the value of Misc
was not physically deleted, the Post
entity can no longer see it since the following SQL query is executed to load the associated Tag
entities:
SELECT pt.post_id, t1_1.id, t1_1.deleted, t1_1.name, t1_1.version FROM post_tag pt JOIN tag t1_1 ON t.id = pt.tag_id WHERE pt.post_id = 1 AND (t.deleted = FALSE)
If we try to load the Tag
entity that was soft deleted using a find
call, we can see that no entity is returned if the Misc
tag is still present in the database table, as illustrated by the native SQL query that follows the find
call:
assertNull(entityManager.find(Tag.class, miscTag.getId())); assertEquals( miscTag.getName(), entityManager.createNativeQuery(""" SELECT name FROM tag WHERE id = :id """) .setParameter("id", miscTag.getId()) .getSingleResult() );
The soft-deleted entities are filtered even when executing JPQL queries, so when loading all Tag
entities:
List<Tag> tags = entityManager.createQuery(""" select t from Tag t """, Tag.class) .getResultList(); assertEquals(3, tags.size());
Hibernate executes the following SQL query that filters the tag
rows by their deleted
column value:
SELECT t1_0.id, t1_0.deleted, t1_0.name, t1_0.version FROM tag t1_0 WHERE (t1_0.deleted = FALSE)
Soft delete the other entities with a JPA version property
The other entities follow exactly the same soft delete mappings we used for the Tag
entity.
For instance, the PostComment
entity is mapped as follows:
@Entity(name = "PostComment") @Table(name = "post_comment") @SQLDelete(sql = """ UPDATE post_comment SET deleted = true WHERE id = ? AND version = ? """) @Loader(namedQuery = "findPostCommentById") @NamedQuery(name = "findPostCommentById", query = """ select pc from PostComment pc where pc.id = ?1 and pc.deleted = false """) @Where(clause = "deleted = false") public class PostComment extends SoftDeletable { @Id @GeneratedValue private Long id; @ManyToOne(fetch = FetchType.LAZY) private Post post; private String review; @Version private short version; }
The PostDetails
is mapped like this:
@Entity(name = "Post") @Table(name = "post") @SQLDelete(sql = """ UPDATE post SET deleted = true WHERE id = ? AND version = ? """) @Loader(namedQuery = "findPostById") @NamedQuery(name = "findPostById", query = """ select p from Post p where p.id = ?1 and p.deleted = false """) @Where(clause = "deleted = false") public class Post extends SoftDeletable { @Id @GeneratedValue private Long id; private String title; @OneToMany( mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true ) private List<PostComment> comments = new ArrayList<>(); @OneToOne( mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY ) private PostDetails details; @ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}) @JoinTable( name = "post_tag", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "tag_id") ) private List<Tag> tags = new ArrayList<>(); @Version private short version; }
And the Post
entity is mapped as follows:
@Entity(name = "Post") @Table(name = "post") @SQLDelete(sql = """ UPDATE post SET deleted = true WHERE id = ? AND version = ? """) @Loader(namedQuery = "findPostById") @NamedQuery(name = "findPostById", query = """ select p from Post p where p.id = ?1 and p.deleted = false """) @Where(clause = "deleted = false") public class Post extends SoftDeletable { @Id @GeneratedValue private Long id; private String title; @OneToMany( mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true ) private List<PostComment> comments = new ArrayList<>(); @OneToOne( mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY ) private PostDetails details; @ManyToMany @JoinTable( name = "post_tag", joinColumns = @JoinColumn(name = "post_id"), inverseJoinColumns = @JoinColumn(name = "tag_id") ) private List<Tag> tags = new ArrayList<>(); @Version private short version; public Post addComment(PostComment comment) { comments.add(comment); comment.setPost(this); return this; } public Post removeComment(PostComment comment) { comments.remove(comment); comment.setPost(null); return this; } public Post addDetails(PostDetails details) { this.details = details; details.setPost(this); return this; } public Post removeDetails() { this.details.setPost(null); this.details = null; return this; } public Post addTag(Tag tag) { tags.add(tag); return this; } }
Assuming we have persisted the following Post
entity:
entityManager.persist( new Post() .setTitle("High-Performance Java Persistence") .addDetails(new PostDetails().setCreatedBy("Vlad Mihalcea")) .addComment(new PostComment().setReview("Excellent!")) .addComment(new PostComment().setReview("Great!")) );
When fetching a Post
entity along with associated PostDetails
child entity and removing the PostDetails
:
Post post = entityManager.createQuery(""" select p from Post p join fetch p.details where p.title = :title """, Post.class) .setParameter("title", "High-Performance Java Persistence") .getSingleResult(); assertNotNull(post.getDetails()); post.removeDetails();
We can see that Hibernate executed an UPDATE statement to set the deleted
column of the associated post_details
record to the value of true
:
UPDATE post_details SET deleted = true WHERE id = 1 AND version = 0
The same applies to the PostComment
entity, so when loading the Post
entity along with its associated PsotComment
child entities and removing one of the comments:
Post post = entityManager.createQuery(""" select p from Post p left join fetch p.comments where p.title = :title """, Post.class) .setParameter("title", "High-Performance Java Persistence") .getSingleResult(); post.removeComment(post.getComments().get(0));
Hibernate will soft delete the associated post_comment
record by executing an UPDATE statement instead of a DELETE:
UPDATE post_comment SET deleted = true WHERE id = 1 AND version = 0
That’s it!
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
Conclusion
Hibernate is a very flexible JPA provider implementation, allowing you to customize its default behavior based on your application-specific needs.
If your business requirements include the implementation of a soft delete mechanism, then you can achieve this goal with just a few mappings, no matter if the entity uses the JPA version property or not.
