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 With JPA Version Property

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 a find method. Using a custom SQL query, we can filter out the records that have the deleted column set to the value of true.
  • @Where – to override the default query plan when loading the entity using an entity query so that we filter out the records that have the deleted column set to the value of true.

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 as Integer or Long, 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!

I'm running an online workshop on the 20-21 and 23-24 of November about High-Performance Java Persistence.

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.

Transactions and Concurrency Control eBook

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.