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:
@@ -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();
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user