Add soft-delete module: @SoftDelete (DELETED/ACTIVE/TIMESTAMP) vs @SQLDelete + @SQLRestriction on Hibernate 7.4.14.Final, H2 and PostgreSQL 17

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
This commit is contained in:
2026-10-28 18:22:03 +00:00
co-authored by Claude Sonnet 5.5
parent aea104f021
commit c9faf35e61
43 changed files with 2084 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# javap of the shipped hibernate-core-7.4.14.Final.jar
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.SoftDelete
Compiled from "SoftDelete.java"
public interface org.hibernate.annotations.SoftDelete extends java.lang.annotation.Annotation {
public abstract java.lang.String columnName();
public abstract java.lang.String options();
public abstract java.lang.String comment();
public abstract org.hibernate.annotations.SoftDeleteType strategy();
public abstract java.lang.Class<? extends jakarta.persistence.AttributeConverter<java.lang.Boolean, ?>> converter();
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.SoftDeleteType
Compiled from "SoftDeleteType.java"
public final class org.hibernate.annotations.SoftDeleteType extends java.lang.Enum<org.hibernate.annotations.SoftDeleteType> {
public static final org.hibernate.annotations.SoftDeleteType ACTIVE;
public static final org.hibernate.annotations.SoftDeleteType DELETED;
public static final org.hibernate.annotations.SoftDeleteType TIMESTAMP;
public static org.hibernate.annotations.SoftDeleteType[] values();
public static org.hibernate.annotations.SoftDeleteType valueOf(java.lang.String);
public java.lang.String getDefaultColumnName();
static {};
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.SQLDelete
Compiled from "SQLDelete.java"
public interface org.hibernate.annotations.SQLDelete extends java.lang.annotation.Annotation {
public abstract java.lang.String sql();
public abstract boolean callable();
public abstract java.lang.Class<? extends org.hibernate.jdbc.Expectation> verify();
public abstract org.hibernate.annotations.ResultCheckStyle check();
public abstract java.lang.String table();
}
$ javap -cp hibernate-core-7.4.14.Final.jar org.hibernate.annotations.SQLRestriction
Compiled from "SQLRestriction.java"
public interface org.hibernate.annotations.SQLRestriction extends java.lang.annotation.Annotation {
public abstract java.lang.String value();
}
+16
View File
@@ -0,0 +1,16 @@
Hibernate ORM 7.4.14.Final
== org.hibernate.annotations.SoftDeleteType
ACTIVE default column name: active
DELETED default column name: deleted
TIMESTAMP default column name: deleted
== org.hibernate.annotations.SoftDelete attributes and defaults
comment default =
options default =
strategy default = DELETED
converter default = org.hibernate.annotations.SoftDelete$UnspecifiedConversion
columnName default =
== @Target of @SoftDelete
[PACKAGE, TYPE, FIELD, METHOD, ANNOTATION_TYPE]
+107
View File
@@ -0,0 +1,107 @@
DDL and generated SQL: DELETED vs ACTIVE vs TIMESTAMP vs @SQLDelete
######## H2 ########
database: H2Dialect / H2 2.4.240 (2025-09-22)
== DDL Hibernate generates
create table article_active (active boolean not null, id bigint generated by default as identity, title varchar(255), primary key (id));
create table article_deleted (deleted boolean not null, id bigint generated by default as identity, title varchar(255), primary key (id));
create table article_manual (deleted boolean not null, id bigint generated by default as identity, title varchar(255), primary key (id));
create table article_stamped (deleted_at timestamp(6) with time zone, id bigint generated by default as identity, title varchar(255), primary key (id));
== SoftDeleteType.DELETED (default)
-- insert
sql> insert into article_deleted (title,deleted,id) values (?,false,default)
-- remove(first)
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
sql> update article_deleted set deleted=true where id=? and deleted=false
raw rows : [1 | first | true, 2 | second | false]
-- find(first) after delete -> null
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
-- HQL: select count(a) from ArticleDeleted a
sql> select count(ad1_0.id) from article_deleted ad1_0 where ad1_0.deleted=false
== SoftDeleteType.ACTIVE
-- remove(x)
sql> select aa1_0.id,aa1_0.title from article_active aa1_0 where aa1_0.active=true and aa1_0.id=?
sql> update article_active set active=false where id=? and active=true
raw rows : [1 | x | false]
== SoftDeleteType.TIMESTAMP
before delete: [1 | y | false]
-- insert
sql> insert into article_stamped (title,deleted_at,id) values (?,null,default)
-- remove(y)
sql> select as1_0.id,as1_0.title from article_stamped as1_0 where as1_0.deleted_at is null and as1_0.id=?
sql> update article_stamped set deleted_at=localtimestamp where id=? and deleted_at is null
after delete : [1 | y | true, 2 | z | false]
-- find(y) after delete -> null
sql> select as1_0.id,as1_0.title from article_stamped as1_0 where as1_0.deleted_at is null and as1_0.id=?
== @SQLDelete + @SQLRestriction
-- insert
sql> insert into article_manual (deleted,title,id) values (?,?,default)
-- remove(m)
sql> select am1_0.id,am1_0.deleted,am1_0.title from article_manual am1_0 where am1_0.id=? and (am1_0.deleted = false)
sql> update article_manual set deleted = true where id = ?
raw rows : [1 | m | true, 2 | n | false]
-- find(m) after delete -> null
sql> select am1_0.id,am1_0.deleted,am1_0.title from article_manual am1_0 where am1_0.id=? and (am1_0.deleted = false)
== Deleting a row somebody else already soft-deleted
-- s1.flush()
sql> update article_deleted set deleted=true where id=? and deleted=false
flush failed: OptimisticLockException -> StaleStateException: Unexpected row count (expected row count 1 but was 0) [update article_deleted set deleted=true where id=? and deleted=false]
######## POSTGRES ########
database: PostgreSQLDialect / PostgreSQL 17.11
== DDL Hibernate generates
create table article_active (active boolean not null, id bigint generated by default as identity, title varchar(255), primary key (id));
create table article_deleted (deleted boolean not null, id bigint generated by default as identity, title varchar(255), primary key (id));
create table article_manual (deleted boolean not null, id bigint generated by default as identity, title varchar(255), primary key (id));
create table article_stamped (deleted_at timestamp(6) with time zone, id bigint generated by default as identity, title varchar(255), primary key (id));
== SoftDeleteType.DELETED (default)
-- insert
sql> insert into article_deleted (title,deleted) values (?,false)
-- remove(first)
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
sql> update article_deleted set deleted=true where id=? and deleted=false
raw rows : [1 | first | true, 2 | second | false]
-- find(first) after delete -> null
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
-- HQL: select count(a) from ArticleDeleted a
sql> select count(ad1_0.id) from article_deleted ad1_0 where ad1_0.deleted=false
== SoftDeleteType.ACTIVE
-- remove(x)
sql> select aa1_0.id,aa1_0.title from article_active aa1_0 where aa1_0.active=true and aa1_0.id=?
sql> update article_active set active=false where id=? and active=true
raw rows : [1 | x | false]
== SoftDeleteType.TIMESTAMP
before delete: [1 | y | false]
-- insert
sql> insert into article_stamped (title,deleted_at) values (?,null)
-- remove(y)
sql> select as1_0.id,as1_0.title from article_stamped as1_0 where as1_0.deleted_at is null and as1_0.id=?
sql> update article_stamped set deleted_at=localtimestamp where id=? and deleted_at is null
after delete : [1 | y | true, 2 | z | false]
-- find(y) after delete -> null
sql> select as1_0.id,as1_0.title from article_stamped as1_0 where as1_0.deleted_at is null and as1_0.id=?
== @SQLDelete + @SQLRestriction
-- insert
sql> insert into article_manual (deleted,title) values (?,?)
-- remove(m)
sql> select am1_0.id,am1_0.deleted,am1_0.title from article_manual am1_0 where am1_0.id=? and (am1_0.deleted = false)
sql> update article_manual set deleted = true where id = ?
raw rows : [1 | m | true, 2 | n | false]
-- find(m) after delete -> null
sql> select am1_0.id,am1_0.deleted,am1_0.title from article_manual am1_0 where am1_0.id=? and (am1_0.deleted = false)
== Deleting a row somebody else already soft-deleted
-- s1.flush()
sql> update article_deleted set deleted=true where id=? and deleted=false
flush failed: OptimisticLockException -> StaleStateException: Unexpected row count (expected row count 1 but was 0) [update article_deleted set deleted=true where id=? and deleted=false]
@@ -0,0 +1,71 @@
Restoring rows and querying around the filter
######## H2 ########
database: H2Dialect / H2 2.4.240 (2025-09-22)
== Boolean strategy: the deleted row is still in the table
raw rows : [1 | kept | true, 2 | alive | false]
-- HQL count
sql> select count(ad1_0.id) from article_deleted ad1_0 where ad1_0.deleted=false
-- native count
sql> select count(*) from article_deleted
== A native query mapped to the entity returns the deleted row
native select * ... id=1 -> 1 entity, title=kept
== HQL cannot restore: the flag is not an attribute
IllegalArgumentException -> PathElementException: Could not resolve attribute 'deleted' of 'com.ankurm.softdelete.ArticleDeleted'
== Native UPDATE restores it
rows updated: 1
-- find after restore
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
== Stale persistence context: a soft delete done behind the session's back
same session, row deleted by someone else: find -> still the entity (first-level cache)
after session.clear(): find -> null
== Timestamp strategy: restore sets the column back to NULL
after delete : [1 | true]
after restore : [1 | false]
== @SQLRestriction: HQL CAN name the column, but the restriction rewrites the UPDATE too
-- HQL: update ArticleManual a set a.deleted = false
sql> update article_manual am1_0 set deleted=false where am1_0.id=? and (am1_0.deleted = false)
HQL restore updated 0 rows
raw rows : [1 | man | true]
######## POSTGRES ########
database: PostgreSQLDialect / PostgreSQL 17.11
== Boolean strategy: the deleted row is still in the table
raw rows : [1 | kept | true, 2 | alive | false]
-- HQL count
sql> select count(ad1_0.id) from article_deleted ad1_0 where ad1_0.deleted=false
-- native count
sql> select count(*) from article_deleted
== A native query mapped to the entity returns the deleted row
native select * ... id=1 -> 1 entity, title=kept
== HQL cannot restore: the flag is not an attribute
IllegalArgumentException -> PathElementException: Could not resolve attribute 'deleted' of 'com.ankurm.softdelete.ArticleDeleted'
== Native UPDATE restores it
rows updated: 1
-- find after restore
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
== Stale persistence context: a soft delete done behind the session's back
same session, row deleted by someone else: find -> still the entity (first-level cache)
after session.clear(): find -> null
== Timestamp strategy: restore sets the column back to NULL
after delete : [1 | true]
after restore : [1 | false]
== @SQLRestriction: HQL CAN name the column, but the restriction rewrites the UPDATE too
-- HQL: update ArticleManual a set a.deleted = false
sql> update article_manual am1_0 set deleted=false where am1_0.id=? and (am1_0.deleted = false)
HQL restore updated 0 rows
raw rows : [1 | man | true]
@@ -0,0 +1,54 @@
Unique constraints and soft delete
######## H2 ########
database: H2Dialect / H2 2.4.240 (2025-09-22)
== 1. boolean strategy, unique (email)
registered and soft-deleted [email protected]; row is still there: [1 | [email protected] | true]
registering it again: ConstraintViolationException -> JdbcSQLIntegrityConstraintViolationException: SQLState 23505, Unique index or primary key violation: "PUBLIC.UX_ACCOUNT_EMAIL ON PUBLIC.ACCOUNT(EMAIL NULLS FIRST) VALUES ( /* 1 */ '[email protected]' )"; SQL statement:
== 2. boolean strategy, unique (email, deleted)
register, delete, register again:
second registration OK, rows: [1 | [email protected] | true, 2 | [email protected] | false]
deleting the second registration: FAILED ConstraintViolationException -> JdbcSQLIntegrityConstraintViolationException: SQLState 23505, Unique index or primary key violation: "PUBLIC.UX_ACCOUNT_EMAIL_DELETED ON PUBLIC.ACCOUNT(EMAIL NULLS FIRST, DELETED NULLS FIRST) VALUES ( /* key:1 */ '[email protected]', TRUE)"; SQL statement:
rows now: [1 | [email protected] | true, 2 | [email protected] | false]
== 3. partial unique index: create unique index ... (email) where deleted = false
H2 rejects it: SQLGrammarException -> JdbcSQLSyntaxErrorException: SQLState 42000, Syntax error in SQL statement "create unique index ux_account_live_email on account (email) [*]where deleted = false"; SQL statement:
== 4. timestamp strategy, unique (email, deleted_at) -- NULLs are distinct
two LIVE rows with the same email were accepted (columns: id | email | deleted_at is null): [1 | [email protected] | true, 2 | [email protected] | true]
== 5. timestamp strategy, unique nulls not distinct (email, deleted_at)
H2: SQLGrammarException -> JdbcSQLSyntaxErrorException: SQLState 42000, Syntax error in SQL statement "create unique index ux_stamped_nnd on account_stamped (email, deleted_at) [*]nulls not distinct"; SQL statement:
== 6. timestamp strategy, partial unique index (email) where deleted_at is null
H2 rejects it: SQLGrammarException -> JdbcSQLSyntaxErrorException: SQLState 42000, Syntax error in SQL statement "create unique index ux_stamped_live on account_stamped (email) [*]where deleted_at is null"; SQL statement:
######## POSTGRES ########
database: PostgreSQLDialect / PostgreSQL 17.11
== 1. boolean strategy, unique (email)
registered and soft-deleted [email protected]; row is still there: [1 | [email protected] | true]
registering it again: ConstraintViolationException -> PSQLException: SQLState 23505, ERROR: duplicate key value violates unique constraint "ux_account_email"
== 2. boolean strategy, unique (email, deleted)
register, delete, register again:
second registration OK, rows: [1 | [email protected] | true, 2 | [email protected] | false]
deleting the second registration: FAILED ConstraintViolationException -> PSQLException: SQLState 23505, ERROR: duplicate key value violates unique constraint "ux_account_email_deleted"
rows now: [1 | [email protected] | true, 2 | [email protected] | false]
== 3. partial unique index: create unique index ... (email) where deleted = false
index created
register/delete x2, then register a third time: OK, rows: [1 | [email protected] | true, 2 | [email protected] | true, 3 | [email protected] | false]
a fourth LIVE registration: ConstraintViolationException -> PSQLException: SQLState 23505, ERROR: duplicate key value violates unique constraint "ux_account_live_email"
== 4. timestamp strategy, unique (email, deleted_at) -- NULLs are distinct
two LIVE rows with the same email were accepted (columns: id | email | deleted_at is null): [1 | [email protected] | true, 2 | [email protected] | true]
== 5. timestamp strategy, unique nulls not distinct (email, deleted_at)
second LIVE registration: ConstraintViolationException -> PSQLException: SQLState 23505, ERROR: duplicate key value violates unique constraint "ux_stamped_nnd"
delete / re-register / delete / re-register: OK (columns: id | email | deleted_at is null), rows: [1 | [email protected] | false, 3 | [email protected] | false, 4 | [email protected] | true]
== 6. timestamp strategy, partial unique index (email) where deleted_at is null
index created
+157
View File
@@ -0,0 +1,157 @@
Associations and soft delete
######## H2 ########
database: H2Dialect / H2 2.4.240 (2025-09-22)
== A. soft-delete the parent of a @OneToMany (no cascade)
-- remove(author)
sql> select a1_0.id,a1_0.name from author a1_0 where a1_0.deleted=false and a1_0.id=?
sql> update author set deleted=true where id=? and deleted=false
books: [1 | The Dispossessed | 1, 2 | Earthsea | 1] authors: [1 | Le Guin | true]
-- load a book, touch its (soft-deleted) author
sql> select b1_0.id,a1_0.id,a1_0.name,b1_0.title from book b1_0 left join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false and b1_0.id=?
book.getAuthor() == null ? true
-- HQL: select b.title from Book b join b.author a (inner join)
sql> select b1_0.title from book b1_0 join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false
rows: []
-- HQL: select b.title from Book b left join b.author a
sql> select b1_0.title from book b1_0 where b1_0.deleted=false
rows: 2
== B. a NEW book can still point at the soft-deleted author
-- persist(new Book(.., getReference(Author, deletedId)))
sql> insert into book (author_id,title,deleted,id) values (?,?,false,default)
books: [1 | The Dispossessed | 1, 2 | Earthsea | 1, 3 | Orphan | 1]
== C. soft-delete a child, then load the live parent's collection
-- author.getBooks().size()
sql> select a1_0.id,a1_0.name from author a1_0 where a1_0.deleted=false and a1_0.id=?
sql> select b1_0.author_id,b1_0.id,b1_0.title from book b1_0 where b1_0.author_id=? and b1_0.deleted=false
books seen through the collection: 1
== D. plain @ManyToMany to a soft-deleted Tag
DDL: [create table book_tag (deleted boolean not null, Book_id bigint not null, tags_id bigint not null, primary key (Book_id, tags_id));]
join table rows (book_id | tags_id | deleted): [6 | 1 | false, 6 | 2 | false]
-- book.getTags().size()
sql> select b1_0.id,a1_0.id,a1_0.name,b1_0.title from book b1_0 left join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false and b1_0.id=?
sql> select t1_0.Book_id,t1_1.id,t1_1.label from book_tag t1_0 join tag t1_1 on t1_1.id=t1_0.tags_id where t1_0.Book_id=? and t1_1.deleted=false and t1_0.deleted=false
tags seen through the collection: 1
-- remove(book): what happens to its join-table rows?
sql> select b1_0.id,a1_0.id,a1_0.name,b1_0.title from book b1_0 left join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false and b1_0.id=?
sql> update book_tag set deleted=true where Book_id=? and deleted=false
sql> update book set deleted=true where id=? and deleted=false
join table rows (book_id | tags_id | deleted): [6 | 1 | true, 6 | 2 | true]
-- restore ONLY the book row (update book set deleted = false), then book.getTags().size()
sql> select b1_0.id,a1_0.id,a1_0.name,b1_0.title from book b1_0 left join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false and b1_0.id=?
sql> select t1_0.Book_id,t1_1.id,t1_1.label from book_tag t1_0 join tag t1_1 on t1_1.id=t1_0.tags_id where t1_0.Book_id=? and t1_1.deleted=false and t1_0.deleted=false
tags seen: 0 (the book_tag rows are still deleted = true)
after also restoring book_tag: tags seen = 1
== E. @SoftDelete on the COLLECTION (join-table rows)
DDL: [create table reader_subscription (deleted boolean not null, Reader_id bigint not null, subscriptions_id bigint not null, primary key (Reader_id, subscriptions_id));, comment on column reader_subscription.deleted is 'Soft-delete indicator';, alter table if exists reader_subscription add constraint FK8mnw2gfmlf0orqbofu5tgdq7p foreign key (subscriptions_id) references tag;, alter table if exists reader_subscription add constraint FKcmo9696q8cos9fjs9hgewj9f2 foreign key (Reader_id) references reader;]
join rows before: [1 | 3 | false]
-- reader.getSubscriptions().clear()
sql> select r1_0.id,r1_0.name from reader r1_0 where r1_0.id=?
sql> select s1_0.Reader_id,s1_1.id,s1_1.label from reader_subscription s1_0 join tag s1_1 on s1_1.id=s1_0.subscriptions_id where s1_0.Reader_id=? and s1_1.deleted=false and s1_0.deleted=false
sql> update reader_subscription set deleted=true where Reader_id=? and deleted=false
join rows after : [1 | 3 | true]
-- subscribe to the same tag again
sql> select r1_0.id,r1_0.name from reader r1_0 where r1_0.id=?
sql> select t1_0.id,t1_0.label from tag t1_0 where t1_0.deleted=false and t1_0.id=?
sql> select s1_0.Reader_id,s1_1.id,s1_1.label from reader_subscription s1_0 join tag s1_1 on s1_1.id=s1_0.subscriptions_id where s1_0.Reader_id=? and s1_1.deleted=false and s1_0.deleted=false
sql> insert into reader_subscription (Reader_id,subscriptions_id,deleted) values (?,?,false)
result: ConstraintViolationException -> JdbcSQLIntegrityConstraintViolationException: SQLState 23505, Unique index or primary key violation: "PUBLIC.CONSTRAINT_F INDEX PUBLIC.PRIMARY_KEY_F ON PUBLIC.READER_SUBSCRIPTION(READER_ID, SUBSCRIPTIONS_ID) VALUES ( /* key:1 */ CAST(1 AS BIGINT), CAST(3 AS BIGINT))"; SQL statement:
join rows still: [1 | 3 | true]
== F. CascadeType.ALL + orphanRemoval on a soft-deleted parent
-- remove an item from shelf.items (orphanRemoval)
sql> select s1_0.id,s1_0.label from shelf s1_0 where s1_0.deleted=false and s1_0.id=?
sql> select i1_0.shelf_id,i1_0.id,i1_0.name from item i1_0 where i1_0.shelf_id=? and i1_0.deleted=false
sql> update item set deleted=true where id=? and deleted=false
-- remove(shelf)
sql> select s1_0.id,s1_0.label from shelf s1_0 where s1_0.deleted=false and s1_0.id=?
sql> select i1_0.shelf_id,i1_0.id,i1_0.name from item i1_0 where i1_0.shelf_id=? and i1_0.deleted=false
sql> update item set deleted=true where id=? and deleted=false
sql> update shelf set deleted=true where id=? and deleted=false
items: [1 | i1 | true, 2 | i2 | true] shelves: [1 | true]
== G. purging a soft-deleted parent that still has children
physical DELETE of the soft-deleted author: ConstraintViolationException -> JdbcSQLIntegrityConstraintViolationException: SQLState 23503, Referential integrity constraint violation: "FKKLNRV3WELER2FTKWEEWLKY958: PUBLIC.BOOK FOREIGN KEY(AUTHOR_ID) REFERENCES PUBLIC.AUTHOR(ID) (CAST(1 AS BIGINT))"; SQL statement:
######## POSTGRES ########
database: PostgreSQLDialect / PostgreSQL 17.11
== A. soft-delete the parent of a @OneToMany (no cascade)
-- remove(author)
sql> select a1_0.id,a1_0.name from author a1_0 where a1_0.deleted=false and a1_0.id=?
sql> update author set deleted=true where id=? and deleted=false
books: [1 | The Dispossessed | 1, 2 | Earthsea | 1] authors: [1 | Le Guin | true]
-- load a book, touch its (soft-deleted) author
sql> select b1_0.id,a1_0.id,a1_0.name,b1_0.title from book b1_0 left join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false and b1_0.id=?
book.getAuthor() == null ? true
-- HQL: select b.title from Book b join b.author a (inner join)
sql> select b1_0.title from book b1_0 join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false
rows: []
-- HQL: select b.title from Book b left join b.author a
sql> select b1_0.title from book b1_0 where b1_0.deleted=false
rows: 2
== B. a NEW book can still point at the soft-deleted author
-- persist(new Book(.., getReference(Author, deletedId)))
sql> insert into book (author_id,title,deleted) values (?,?,false)
books: [1 | The Dispossessed | 1, 2 | Earthsea | 1, 3 | Orphan | 1]
== C. soft-delete a child, then load the live parent's collection
-- author.getBooks().size()
sql> select a1_0.id,a1_0.name from author a1_0 where a1_0.deleted=false and a1_0.id=?
sql> select b1_0.author_id,b1_0.id,b1_0.title from book b1_0 where b1_0.author_id=? and b1_0.deleted=false
books seen through the collection: 1
== D. plain @ManyToMany to a soft-deleted Tag
DDL: [create table book_tag (deleted boolean not null, Book_id bigint not null, tags_id bigint not null, primary key (Book_id, tags_id));]
join table rows (book_id | tags_id | deleted): [6 | 1 | false, 6 | 2 | false]
-- book.getTags().size()
sql> select b1_0.id,a1_0.id,a1_0.name,b1_0.title from book b1_0 left join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false and b1_0.id=?
sql> select t1_0.Book_id,t1_1.id,t1_1.label from book_tag t1_0 join tag t1_1 on t1_1.id=t1_0.tags_id where t1_0.Book_id=? and t1_1.deleted=false and t1_0.deleted=false
tags seen through the collection: 1
-- remove(book): what happens to its join-table rows?
sql> select b1_0.id,a1_0.id,a1_0.name,b1_0.title from book b1_0 left join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false and b1_0.id=?
sql> update book_tag set deleted=true where Book_id=? and deleted=false
sql> update book set deleted=true where id=? and deleted=false
join table rows (book_id | tags_id | deleted): [6 | 1 | true, 6 | 2 | true]
-- restore ONLY the book row (update book set deleted = false), then book.getTags().size()
sql> select b1_0.id,a1_0.id,a1_0.name,b1_0.title from book b1_0 left join author a1_0 on a1_0.id=b1_0.author_id and a1_0.deleted=false where b1_0.deleted=false and b1_0.id=?
sql> select t1_0.Book_id,t1_1.id,t1_1.label from book_tag t1_0 join tag t1_1 on t1_1.id=t1_0.tags_id where t1_0.Book_id=? and t1_1.deleted=false and t1_0.deleted=false
tags seen: 0 (the book_tag rows are still deleted = true)
after also restoring book_tag: tags seen = 1
== E. @SoftDelete on the COLLECTION (join-table rows)
DDL: [create table reader_subscription (deleted boolean not null, Reader_id bigint not null, subscriptions_id bigint not null, primary key (Reader_id, subscriptions_id));, comment on column reader_subscription.deleted is 'Soft-delete indicator';, alter table if exists reader_subscription add constraint FK8mnw2gfmlf0orqbofu5tgdq7p foreign key (subscriptions_id) references tag;, alter table if exists reader_subscription add constraint FKcmo9696q8cos9fjs9hgewj9f2 foreign key (Reader_id) references reader;]
join rows before: [1 | 3 | false]
-- reader.getSubscriptions().clear()
sql> select r1_0.id,r1_0.name from reader r1_0 where r1_0.id=?
sql> select s1_0.Reader_id,s1_1.id,s1_1.label from reader_subscription s1_0 join tag s1_1 on s1_1.id=s1_0.subscriptions_id where s1_0.Reader_id=? and s1_1.deleted=false and s1_0.deleted=false
sql> update reader_subscription set deleted=true where Reader_id=? and deleted=false
join rows after : [1 | 3 | true]
-- subscribe to the same tag again
sql> select r1_0.id,r1_0.name from reader r1_0 where r1_0.id=?
sql> select t1_0.id,t1_0.label from tag t1_0 where t1_0.deleted=false and t1_0.id=?
sql> select s1_0.Reader_id,s1_1.id,s1_1.label from reader_subscription s1_0 join tag s1_1 on s1_1.id=s1_0.subscriptions_id where s1_0.Reader_id=? and s1_1.deleted=false and s1_0.deleted=false
sql> insert into reader_subscription (Reader_id,subscriptions_id,deleted) values (?,?,false)
result: ConstraintViolationException -> PSQLException: SQLState 23505, ERROR: duplicate key value violates unique constraint "reader_subscription_pkey"
join rows still: [1 | 3 | true]
== F. CascadeType.ALL + orphanRemoval on a soft-deleted parent
-- remove an item from shelf.items (orphanRemoval)
sql> select s1_0.id,s1_0.label from shelf s1_0 where s1_0.deleted=false and s1_0.id=?
sql> select i1_0.shelf_id,i1_0.id,i1_0.name from item i1_0 where i1_0.shelf_id=? and i1_0.deleted=false
sql> update item set deleted=true where id=? and deleted=false
-- remove(shelf)
sql> select s1_0.id,s1_0.label from shelf s1_0 where s1_0.deleted=false and s1_0.id=?
sql> select i1_0.shelf_id,i1_0.id,i1_0.name from item i1_0 where i1_0.shelf_id=? and i1_0.deleted=false
sql> update item set deleted=true where id=? and deleted=false
sql> update shelf set deleted=true where id=? and deleted=false
items: [1 | i1 | true, 2 | i2 | true] shelves: [1 | true]
== G. purging a soft-deleted parent that still has children
physical DELETE of the soft-deleted author: ConstraintViolationException -> PSQLException: SQLState 23503, ERROR: update or delete on table "author" violates foreign key constraint "fkklnrv3weler2ftkweewlky958" on table "book"
+5
View File
@@ -0,0 +1,5 @@
A @ManyToOne(fetch = LAZY) towards a @SoftDelete entity (H2)
LazyChild -> UnsupportedMappingException: To-one attribute (com.ankurm.lazytest.LazyEntities$LazyChild.author) cannot be mapped as LAZY as its associated entity is defined with @SoftDelete
LazyIgnoreChild -> SessionFactory built (accepted)
@@ -0,0 +1,67 @@
@SoftDelete vs @SQLDelete+@SQLRestriction: bulk deletes, double deletes, converters
######## H2 ########
database: H2Dialect / H2 2.4.240 (2025-09-22)
== 1. HQL bulk delete: delete from ArticleDeleted / delete from ArticleManual
-- delete from ArticleDeleted
sql> update article_deleted ad1_0 set deleted=true where ad1_0.deleted=false
article_deleted rows: [1 | a | true, 2 | b | true]
-- delete from ArticleManual
sql> delete from article_manual am1_0 where (am1_0.deleted = false)
article_manual rows : []
== 2. Two sessions delete the same row (second one is stale)
-- @SQLDelete entity, s1.flush()
sql> update article_manual set deleted = true where id = ?
flush succeeded silently (the hand-written UPDATE has no 'and deleted = false' guard)
(the @SoftDelete entity fails the same scenario with StaleStateException: see 02-ddl-and-sql.txt)
== 3. converter attribute: char(1) 'Y'/'N' column named is_gone
create table article_yes_no (is_gone char(1) not null check ((is_gone in ('N','Y'))), id bigint generated by default as identity, title varchar(255), primary key (id));
comment on column article_yes_no.is_gone is 'Y = deleted';
alive : [1 | N]
-- remove
sql> select ayn1_0.id,ayn1_0.title from article_yes_no ayn1_0 where ayn1_0.is_gone='N' and ayn1_0.id=?
sql> update article_yes_no set is_gone='Y' where id=? and is_gone='N'
deleted : [1 | Y]
== 4. @SoftDelete on a @MappedSuperclass
create table note (deleted_at timestamp(6) with time zone, id bigint generated by default as identity, text varchar(255), primary key (id));
-- remove(note)
sql> select n1_0.id,n1_0.text from note n1_0 where n1_0.deleted_at is null and n1_0.id=?
sql> update note set deleted_at=localtimestamp where id=? and deleted_at is null
raw: [1 | true]
######## POSTGRES ########
database: PostgreSQLDialect / PostgreSQL 17.11
== 1. HQL bulk delete: delete from ArticleDeleted / delete from ArticleManual
-- delete from ArticleDeleted
sql> update article_deleted ad1_0 set deleted=true where ad1_0.deleted=false
article_deleted rows: [1 | a | true, 2 | b | true]
-- delete from ArticleManual
sql> delete from article_manual am1_0 where (am1_0.deleted = false)
article_manual rows : []
== 2. Two sessions delete the same row (second one is stale)
-- @SQLDelete entity, s1.flush()
sql> update article_manual set deleted = true where id = ?
flush succeeded silently (the hand-written UPDATE has no 'and deleted = false' guard)
(the @SoftDelete entity fails the same scenario with StaleStateException: see 02-ddl-and-sql.txt)
== 3. converter attribute: char(1) 'Y'/'N' column named is_gone
create table article_yes_no (is_gone char(1) not null check ((is_gone in ('N','Y'))), id bigint generated by default as identity, title varchar(255), primary key (id));
comment on column article_yes_no.is_gone is 'Y = deleted';
alive : [1 | N]
-- remove
sql> select ayn1_0.id,ayn1_0.title from article_yes_no ayn1_0 where ayn1_0.is_gone='N' and ayn1_0.id=?
sql> update article_yes_no set is_gone='Y' where id=? and is_gone='N'
deleted : [1 | Y]
== 4. @SoftDelete on a @MappedSuperclass
create table note (deleted_at timestamp(6) with time zone, id bigint generated by default as identity, text varchar(255), primary key (id));
-- remove(note)
sql> select n1_0.id,n1_0.text from note n1_0 where n1_0.deleted_at is null and n1_0.id=?
sql> update note set deleted_at=localtimestamp where id=? and deleted_at is null
raw: [1 | true]
+68
View File
@@ -0,0 +1,68 @@
Spring Data JPA over @SoftDelete (boolean strategy), H2, Hibernate 7.4.14.Final
== save, count, deleteById, findById
-- count()
sql> select count(*) from article_deleted ad1_0 where ad1_0.deleted=false
-- deleteById(1)
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
sql> update article_deleted set deleted=true where id=? and deleted=false
-- findById(1)
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
isPresent = false
-- existsById(1)
sql> select count(*) from article_deleted ad1_0 where ad1_0.id=? and ad1_0.deleted=false
existsById = false
-- count() and findAll().size()
sql> select count(*) from article_deleted ad1_0 where ad1_0.deleted=false
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false
count = 2, findAll = 2
== derived queries and derived deletes
-- countByTitle("x")
sql> select count(ad1_0.id) from article_deleted ad1_0 where ad1_0.title=? and ad1_0.deleted=false
countByTitle(x) = 1
-- deleteByTitle("x")
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.title=? and ad1_0.deleted=false
sql> update article_deleted set deleted=true where id=? and deleted=false
returned 1
== deleting something that is already (soft) deleted
-- deleteById(1) again
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
no exception
-- delete(staleEntity) with the instance saved earlier
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
no exception
== save() of a detached instance whose row is soft-deleted
-- save(saved.get(0))
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
result: ObjectOptimisticLockingFailureException -> StaleObjectStateException: Row was already updated or deleted by another transaction for entity [com.ankurm.softdelete.ArticleDeleted with id '1']
== bulk delete, native queries, restore
-- @Query("delete from ArticleDeleted a where a.title = :title") bulkDeleteByTitle("z")
sql> update article_deleted ad1_0 set deleted=true where ad1_0.title=? and ad1_0.deleted=false
returned 1
-- count() vs native count(*)
sql> select count(*) from article_deleted ad1_0 where ad1_0.deleted=false
sql> select count(*) from article_deleted
count() = 1, countIncludingDeleted() = 4
-- native findEvenIfDeleted(1)
sql> select * from article_deleted where id = ?
present = true
-- native restore(1)
sql> update article_deleted set deleted = false where id = ?
rows updated: 1
-- findById(1) after restore
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false and ad1_0.id=?
isPresent = true
== deleteAll variants
-- deleteAllInBatch()
sql> update article_deleted ad1_0 set deleted=true where ad1_0.deleted=false
count() = 0, countIncludingDeleted() = 4
-- deleteAll()
sql> select ad1_0.id,ad1_0.title from article_deleted ad1_0 where ad1_0.deleted=false
sql> update article_deleted set deleted=true where id=? and deleted=false
count() = 0