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
+1
View File
@@ -344,4 +344,5 @@ Modules with their own `pom.xml` that run plain Hibernate without the Spring Boo
| [`h8-preview`](h8-preview/) | Hibernate ORM 8 beta against Hibernate 7: what breaks | | [`h8-preview`](h8-preview/) | Hibernate ORM 8 beta against Hibernate 7: what breaks |
| [`inheritance`](inheritance/) | `SINGLE_TABLE` vs `JOINED` vs `TABLE_PER_CLASS` vs `@MappedSuperclass`: generated DDL, SQL, failures and timings (Hibernate `7.4.13.Final`) | | [`inheritance`](inheritance/) | `SINGLE_TABLE` vs `JOINED` vs `TABLE_PER_CLASS` vs `@MappedSuperclass`: generated DDL, SQL, failures and timings (Hibernate `7.4.13.Final`) |
| [`locking`](locking/) | Optimistic (`@Version`, retries) vs pessimistic (`PESSIMISTIC_WRITE`, `NOWAIT`, `SKIP LOCKED`) locking, lost updates and deadlocks on H2 (Hibernate `7.4.14.Final`) | | [`locking`](locking/) | Optimistic (`@Version`, retries) vs pessimistic (`PESSIMISTIC_WRITE`, `NOWAIT`, `SKIP LOCKED`) locking, lost updates and deadlocks on H2 (Hibernate `7.4.14.Final`) |
| [`soft-delete`](soft-delete/) | `@SoftDelete` (`DELETED` / `ACTIVE` / `TIMESTAMP`) vs `@SQLDelete` + `@SQLRestriction`: generated SQL, restore, unique constraints, associations, native-query bypass and Spring Data JPA, on H2 and PostgreSQL 17 (Hibernate `7.4.14.Final`; unlike the others this module uses the Spring Boot 4.1.1 parent, for the Spring Data tests) |
| [`migration-5-to-7`](migration-5-to-7/) | Hibernate `5.6.15.Final` vs `7.4.14.Final`: jar diff, Hibernate 5 code compiled against both, schema defaults, `hbm2ddl` validation of a Hibernate 5 schema, and cheat-sheet snippets | | [`migration-5-to-7`](migration-5-to-7/) | Hibernate `5.6.15.Final` vs `7.4.14.Final`: jar diff, Hibernate 5 code compiled against both, schema defaults, `hbm2ddl` validation of a Hibernate 5 schema, and cheat-sheet snippets |
+56
View File
@@ -0,0 +1,56 @@
# soft-delete
Companion module for the ankurm.com article **Soft Delete in Hibernate 7: `@SoftDelete` vs `@SQLDelete` + `@SQLRestriction`**.
Every claim in the article comes from a test here; each test writes what it observed to `output/`. Tests that touch a
database run twice, against in-memory H2 and against a real PostgreSQL 17 container started by Testcontainers.
## Versions
| Component | Version | Notes |
|---|---|---|
| Hibernate ORM | `7.4.14.Final` | newest 7.x GA on Maven Central on 2026-10-28 (`8.0.0.Beta4` is also listed; it is not GA). Overrides the `7.4.5.Final` that Spring Boot 4.1.1 manages |
| Spring Boot / Spring Data JPA | `4.1.1` / `4.1.1` | parent POM; only the Spring Data test needs it |
| H2 | `2.4.240` | in memory |
| PostgreSQL | `17.11` | `postgres:17-alpine` through Testcontainers `2.0.5` (needs a running Docker daemon) |
| Java | `25` (Temurin 25.0.5+8) | |
`SoftDeleteType` in the shipped jar: `ACTIVE`, `DELETED`, `TIMESTAMP` (see `output/00-javap.txt`). `TIMESTAMP` is absent from
`6.6.0.Final` and present in `7.0.0.Final` (both jars inspected with `javap`).
## Run
```bash
./scripts/run-all.sh # javap dump + mvn clean test; regenerates everything in output/
```
## Mapping styles
| Class | Mapping |
|---|---|
| [`ArticleDeleted`](src/main/java/com/ankurm/softdelete/ArticleDeleted.java) | `@SoftDelete` (default, boolean `deleted`) |
| [`ArticleActive`](src/main/java/com/ankurm/softdelete/ArticleActive.java) | `@SoftDelete(strategy = ACTIVE)` |
| [`ArticleStamped`](src/main/java/com/ankurm/softdelete/ArticleStamped.java) | `@SoftDelete(strategy = TIMESTAMP, columnName = "deleted_at")` |
| [`ArticleManual`](src/main/java/com/ankurm/softdelete/ArticleManual.java) | `@SQLDelete` + `@SQLRestriction` |
| [`ArticleYesNo`](src/main/java/com/ankurm/softdelete/ArticleYesNo.java) | `converter = YesNoConverter.class` |
| [`SoftBase`](src/main/java/com/ankurm/softdelete/SoftBase.java) / [`Note`](src/main/java/com/ankurm/softdelete/Note.java) | `@SoftDelete` on a `@MappedSuperclass` |
| `Author`, `Book`, `Tag`, `Reader`, `Shelf`, `Item` | associations, collection-level `@SoftDelete`, cascades |
| [`ArticleRepository`](src/main/java/com/ankurm/softdelete/ArticleRepository.java) | Spring Data JPA |
## Output
| File | What it shows |
|---|---|
| `00-javap.txt` | `javap` of `SoftDelete`, `SoftDeleteType`, `SQLDelete`, `SQLRestriction` from the shipped jar |
| `01-api-shape.txt` | enum values, default column names and annotation defaults read by reflection |
| `02-ddl-and-sql.txt` | DDL and the SQL for persist / find / remove / count under all four styles; deleting an already-deleted row |
| `03-restore-and-native.txt` | native queries bypass the predicate, HQL cannot restore, stale persistence context, restore per strategy |
| `04-unique-constraints.txt` | six unique-index variants on H2 and PostgreSQL: plain, `(email, deleted)`, partial, `(email, deleted_at)`, `NULLS NOT DISTINCT` |
| `05-associations.txt` | deleted parent, new child of a deleted parent, collections, many-to-many (join rows are soft-deleted too), collection-level `@SoftDelete`, cascades, purging |
| `06-lazy-to-one.txt` | `@ManyToOne(fetch = LAZY)` towards a `@SoftDelete` entity is rejected at startup |
| `07-bulk-and-sqldelete.txt` | HQL bulk delete (soft vs real DELETE), stale double delete, `converter`, `@MappedSuperclass` |
| `08-spring-data.txt` | `deleteById`, `findById`, `count`, derived deletes, `save` of a soft-deleted instance, bulk and native `@Query`, `deleteAll` |
## Not covered (not run)
`@SoftDelete` with inheritance hierarchies, second-level cache, Envers, SQL Server / Oracle / MySQL (filtered or function-based
indexes there are described in the article but were not executed), and Spring Data on PostgreSQL (the Spring test runs on H2).
+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
+68
View File
@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>soft-delete</artifactId>
<version>1.0.0</version>
<name>soft-delete</name>
<description>Hibernate 7 soft delete: @SoftDelete (DELETED / ACTIVE / TIMESTAMP) vs @SQLDelete + @SQLRestriction, on H2 and PostgreSQL, with Spring Data JPA</description>
<properties>
<java.version>25</java.version>
<!-- Boot 4.1.1 manages 7.4.5.Final; this module overrides to the newest 7.x GA on Maven Central (checked 2026-10-28). -->
<hibernate.version>7.4.14.Final</hibernate.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>-Xmx512m</argLine>
<forkCount>1</forkCount>
</configuration>
</plugin>
</plugins>
</build>
</project>
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Regenerates every file under output/. Needs JDK 25, Maven 3.9 with access to Maven Central, and a running Docker daemon
# (Testcontainers starts postgres:17-alpine for the PostgreSQL half of each transcript).
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p output
CP_FILE="$(mktemp)"
mvn -B -q dependency:build-classpath -Dmdep.outputFile="$CP_FILE" >/dev/null
JAR="$(tr ':' '\n' < "$CP_FILE" | grep 'hibernate-core-')"
{
echo "# javap of the shipped $(basename "$JAR")"
for c in org.hibernate.annotations.SoftDelete org.hibernate.annotations.SoftDeleteType \
org.hibernate.annotations.SQLDelete org.hibernate.annotations.SQLRestriction; do
echo; echo "\$ javap -cp $(basename "$JAR") $c"
javap -cp "$JAR" "$c" 2>&1 | grep -v '^Picked up'
done
} > output/00-javap.txt
rm -f "$CP_FILE"
mvn -B clean test
ls -1 output
@@ -0,0 +1,20 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
/** Boolean strategy plus a business key (email). The unique-constraint tests add their indexes by hand. */
@Entity
@Table(name = "account")
@SoftDelete
public class Account {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
protected Account() {}
public Account(String email) { this.email = email; }
public Long getId() { return id; }
public String getEmail() { return email; }
}
@@ -0,0 +1,21 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.annotations.SoftDeleteType;
/** Timestamp strategy plus a business key (email). */
@Entity
@Table(name = "account_stamped")
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
public class AccountStamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
protected AccountStamped() {}
public AccountStamped(String email) { this.email = email; }
public Long getId() { return id; }
public String getEmail() { return email; }
}
@@ -0,0 +1,21 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.annotations.SoftDeleteType;
/** {@code SoftDeleteType.ACTIVE}: the column is {@code active} and the meaning is inverted (true = still there). */
@Entity
@Table(name = "article_active")
@SoftDelete(strategy = SoftDeleteType.ACTIVE)
public class ArticleActive {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
protected ArticleActive() {}
public ArticleActive(String title) { this.title = title; }
public Long getId() { return id; }
public String getTitle() { return title; }
}
@@ -0,0 +1,24 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
/**
* The default strategy: {@code SoftDeleteType.DELETED}. Hibernate adds a {@code deleted boolean not null}
* column (true = removed). The column is NOT a mapped attribute: there is no field for it below.
*/
@Entity
@Table(name = "article_deleted")
@SoftDelete
public class ArticleDeleted {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
protected ArticleDeleted() {}
public ArticleDeleted(String title) { this.title = title; }
public Long getId() { return id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
}
@@ -0,0 +1,25 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
/** The hand-rolled recipe that predates {@code @SoftDelete}: a mapped column, an UPDATE instead of DELETE, a WHERE on every read. */
@Entity
@Table(name = "article_manual")
@SQLDelete(sql = "update article_manual set deleted = true where id = ?")
@SQLRestriction("deleted = false")
public class ArticleManual {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@Column(nullable = false)
private boolean deleted;
protected ArticleManual() {}
public ArticleManual(String title) { this.title = title; }
public Long getId() { return id; }
public String getTitle() { return title; }
public boolean isDeleted() { return deleted; }
}
@@ -0,0 +1,41 @@
package com.ankurm.softdelete;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.transaction.annotation.Transactional;
/** Spring Data over the boolean-strategy entity. Every method is exercised by SpringDataTest. */
public interface ArticleRepository extends JpaRepository<ArticleDeleted, Long> {
List<ArticleDeleted> findByTitle(String title);
long countByTitle(String title);
boolean existsByTitle(String title);
/** Derived delete: Spring Data loads the matching entities, then calls em.remove on each. Needs a transaction (plain Spring Data behaviour, not soft-delete specific). */
@Transactional
long deleteByTitle(String title);
/** HQL bulk delete: @SoftDelete turns it into an UPDATE. */
@Transactional
@Modifying
@Query("delete from ArticleDeleted a where a.title = :title")
int bulkDeleteByTitle(String title);
/** Native SQL: the soft-delete predicate is NOT added. */
@Query(value = "select count(*) from article_deleted", nativeQuery = true)
long countIncludingDeleted();
@Query(value = "select * from article_deleted where id = :id", nativeQuery = true)
Optional<ArticleDeleted> findEvenIfDeleted(Long id);
/** The only way to un-delete: the flag is not an attribute, so HQL cannot name it. */
@Transactional
@Modifying
@Query(value = "update article_deleted set deleted = false where id = :id", nativeQuery = true)
int restore(Long id);
}
@@ -0,0 +1,21 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.annotations.SoftDeleteType;
/** {@code SoftDeleteType.TIMESTAMP}: a nullable timestamp column; NULL = alive, a value = when it was removed. */
@Entity
@Table(name = "article_stamped")
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at", comment = "NULL while the row is alive")
public class ArticleStamped {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
protected ArticleStamped() {}
public ArticleStamped(String title) { this.title = title; }
public Long getId() { return id; }
public String getTitle() { return title; }
}
@@ -0,0 +1,20 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.type.YesNoConverter;
/** Legacy-schema flavour: column {@code is_gone char(1)} holding 'Y' / 'N', via the {@code converter} attribute. */
@Entity
@Table(name = "article_yes_no")
@SoftDelete(columnName = "is_gone", converter = YesNoConverter.class, comment = "Y = deleted")
public class ArticleYesNo {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
protected ArticleYesNo() {}
public ArticleYesNo(String title) { this.title = title; }
public Long getId() { return id; }
}
@@ -0,0 +1,25 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
import java.util.ArrayList;
import java.util.List;
/** Parent side of a soft-deleted one-to-many. No cascade: deleting an author must not touch the books. */
@Entity
@Table(name = "author")
@SoftDelete
public class Author {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "author")
private List<Book> books = new ArrayList<>();
protected Author() {}
public Author(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
public List<Book> getBooks() { return books; }
}
@@ -0,0 +1,29 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
import java.util.HashSet;
import java.util.Set;
/** Child of {@link Author} (the to-one is EAGER: Hibernate refuses LAZY towards a @SoftDelete entity, see A05b). Also, and owner of a plain many-to-many to {@link Tag}. */
@Entity
@Table(name = "book")
@SoftDelete
public class Book {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne
private Author author;
@ManyToMany
@JoinTable(name = "book_tag")
private Set<Tag> tags = new HashSet<>();
protected Book() {}
public Book(String title, Author author) { this.title = title; this.author = author; }
public Long getId() { return id; }
public String getTitle() { return title; }
public Author getAuthor() { return author; }
public Set<Tag> getTags() { return tags; }
}
@@ -0,0 +1,22 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
@Entity
@Table(name = "item")
@SoftDelete
public class Item {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne
private Shelf shelf;
protected Item() {}
public Item(String name) { this.name = name; }
public Long getId() { return id; }
public String getName() { return name; }
void setShelf(Shelf s) { this.shelf = s; }
}
@@ -0,0 +1,12 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
@Entity
@Table(name = "note")
public class Note extends SoftBase {
private String text;
protected Note() {}
public Note(String text) { this.text = text; }
}
@@ -0,0 +1,28 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.annotations.SoftDelete;
/**
* {@code @SoftDelete} on the COLLECTION: removing a tag from {@code subscriptions} soft-deletes the join-table row
* ({@code reader_subscription.deleted}) instead of deleting it. The Reader and Tag entities stay as they are.
*/
@Entity
@Table(name = "reader")
public class Reader {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToMany
@JoinTable(name = "reader_subscription")
@SoftDelete
private Set<Tag> subscriptions = new HashSet<>();
protected Reader() {}
public Reader(String name) { this.name = name; }
public Long getId() { return id; }
public Set<Tag> getSubscriptions() { return subscriptions; }
}
@@ -0,0 +1,25 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.SoftDelete;
/** Parent with CascadeType.REMOVE and orphanRemoval: shows that cascades fire soft deletes on the children. */
@Entity
@Table(name = "shelf")
@SoftDelete
public class Shelf {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String label;
@OneToMany(mappedBy = "shelf", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Item> items = new ArrayList<>();
protected Shelf() {}
public Shelf(String label) { this.label = label; }
public Long getId() { return id; }
public List<Item> getItems() { return items; }
public void add(Item i) { items.add(i); i.setShelf(this); }
}
@@ -0,0 +1,15 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.annotations.SoftDeleteType;
/** One place to say "soft delete" for a family of entities. */
@MappedSuperclass
@SoftDelete(strategy = SoftDeleteType.TIMESTAMP, columnName = "deleted_at")
public abstract class SoftBase {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
public Long getId() { return id; }
}
@@ -0,0 +1,8 @@
package com.ankurm.softdelete;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/** Only used by the Spring Data chapter ({@code SpringDataTest}); the other tests bootstrap Hibernate directly. */
@SpringBootApplication
public class SoftDeleteApplication {
}
@@ -0,0 +1,19 @@
package com.ankurm.softdelete;
import jakarta.persistence.*;
import org.hibernate.annotations.SoftDelete;
@Entity
@Table(name = "tag")
@SoftDelete
public class Tag {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String label;
protected Tag() {}
public Tag(String label) { this.label = label; }
public Long getId() { return id; }
public String getLabel() { return label; }
}
@@ -0,0 +1,25 @@
package com.ankurm.lazytest;
import com.ankurm.softdelete.Author;
import jakarta.persistence.*;
import org.hibernate.annotations.NotFound;
import org.hibernate.annotations.NotFoundAction;
/** Deliberately outside com.ankurm.softdelete so the Spring Boot tests' entity scan never picks these up. */
public final class LazyEntities {
private LazyEntities() {}
@Entity(name = "LazyChild")
@Table(name = "lazy_child")
public static class LazyChild {
@Id @GeneratedValue Long id;
@ManyToOne(fetch = FetchType.LAZY) Author author;
}
@Entity(name = "LazyIgnoreChild")
@Table(name = "lazy_ignore_child")
public static class LazyIgnoreChild {
@Id @GeneratedValue Long id;
@ManyToOne(fetch = FetchType.LAZY) @NotFound(action = NotFoundAction.IGNORE) Author author;
}
}
@@ -0,0 +1,41 @@
package com.ankurm.softdelete;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.Method;
import java.util.Arrays;
import org.hibernate.Version;
import org.hibernate.annotations.SoftDelete;
import org.hibernate.annotations.SoftDeleteType;
import org.junit.jupiter.api.Test;
/** What the shipped hibernate-core jar actually contains, read by reflection (the javap view is output/00-javap.txt). */
class A01_ApiShapeTest {
@Test
void softDeleteApiInTheShippedJar() throws Exception {
Transcript t = new Transcript("01-api-shape.txt");
t.line("Hibernate ORM " + Version.getVersionString());
t.heading("org.hibernate.annotations.SoftDeleteType");
for (SoftDeleteType type : SoftDeleteType.values()) {
t.line(String.format("%-10s default column name: %s", type, type.getDefaultColumnName()));
}
t.heading("org.hibernate.annotations.SoftDelete attributes and defaults");
for (Method m : SoftDelete.class.getDeclaredMethods()) {
t.line(String.format("%-12s default = %s", m.getName(), render(m.getDefaultValue())));
}
t.heading("@Target of @SoftDelete");
t.line(Arrays.toString(SoftDelete.class.getAnnotation(java.lang.annotation.Target.class).value()));
t.save();
assertThat(SoftDeleteType.values()).containsExactly(SoftDeleteType.ACTIVE, SoftDeleteType.DELETED, SoftDeleteType.TIMESTAMP);
assertThat(SoftDeleteType.DELETED.getDefaultColumnName()).isEqualTo("deleted");
assertThat(SoftDeleteType.ACTIVE.getDefaultColumnName()).isEqualTo("active");
assertThat(SoftDeleteType.TIMESTAMP.getDefaultColumnName()).isEqualTo("deleted");
assertThat(SoftDelete.class.getDeclaredMethod("strategy").getDefaultValue()).isEqualTo(SoftDeleteType.DELETED);
}
private static String render(Object v) {
return v instanceof Class<?> c ? c.getName() : String.valueOf(v);
}
}
@@ -0,0 +1,79 @@
package com.ankurm.softdelete;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.List;
import org.junit.jupiter.api.Test;
/** DDL and the SQL for persist / find / remove / query / bulk delete, for all four mapping styles. */
class A02_LifecycleSqlTest {
@Test
void ddlAndLifecycle() throws Exception {
Both.run("02-ddl-and-sql.txt", "DDL and generated SQL: DELETED vs ACTIVE vs TIMESTAMP vs @SQLDelete", (db, t) -> {
db.open(ArticleDeleted.class, ArticleActive.class, ArticleStamped.class, ArticleManual.class);
Both.withDbName(db, t);
t.heading("DDL Hibernate generates");
for (String l : db.ddl(ArticleDeleted.class, ArticleActive.class, ArticleStamped.class, ArticleManual.class)) {
if (l.startsWith("create table")) t.line(l);
}
// ---- boolean DELETED
t.heading("SoftDeleteType.DELETED (default)");
Long id = db.tx(s -> { var a = new ArticleDeleted("first"); s.persist(a); return a.getId(); });
t.step("insert", db.sql(() -> db.txv(s -> s.persist(new ArticleDeleted("second")))));
t.step("remove(first)", db.sql(() -> db.txv(s -> s.remove(s.find(ArticleDeleted.class, id)))));
t.line("raw rows : " + db.rows("select id, title, deleted from article_deleted order by id"));
List<String> findSql = db.sql(() -> assertThat((Object) db.tx(s -> s.find(ArticleDeleted.class, id))).isNull());
t.step("find(first) after delete -> null", findSql);
assertThat(findSql.get(0)).contains("deleted=false");
t.step("HQL: select count(a) from ArticleDeleted a",
db.sql(() -> assertThat((Object) db.tx(s -> s.createQuery("select count(a) from ArticleDeleted a", Long.class).getSingleResult())).isEqualTo(1L)));
// ---- ACTIVE
t.heading("SoftDeleteType.ACTIVE");
Long aid = db.tx(s -> { var a = new ArticleActive("x"); s.persist(a); return a.getId(); });
t.step("remove(x)", db.sql(() -> db.txv(s -> s.remove(s.find(ArticleActive.class, aid)))));
t.line("raw rows : " + db.rows("select id, title, active from article_active order by id"));
assertThat(db.scalar("select active from article_active")).isEqualTo("false");
// ---- TIMESTAMP
t.heading("SoftDeleteType.TIMESTAMP");
Long tid = db.tx(s -> { var a = new ArticleStamped("y"); s.persist(a); return a.getId(); });
t.line("before delete: " + db.rows("select id, title, deleted_at is not null from article_stamped"));
t.step("insert", db.sql(() -> db.txv(s -> s.persist(new ArticleStamped("z")))));
t.step("remove(y)", db.sql(() -> db.txv(s -> s.remove(s.find(ArticleStamped.class, tid)))));
t.line("after delete : " + db.rows("select id, title, deleted_at is not null from article_stamped order by id"));
t.step("find(y) after delete -> null", db.sql(() -> assertThat((Object) db.tx(s -> s.find(ArticleStamped.class, tid))).isNull()));
assertThat(db.scalar("select count(*) from article_stamped where deleted_at is not null")).isEqualTo("1");
// ---- manual
t.heading("@SQLDelete + @SQLRestriction");
Long mid = db.tx(s -> { var a = new ArticleManual("m"); s.persist(a); return a.getId(); });
t.step("insert", db.sql(() -> db.txv(s -> s.persist(new ArticleManual("n")))));
t.step("remove(m)", db.sql(() -> db.txv(s -> s.remove(s.find(ArticleManual.class, mid)))));
t.line("raw rows : " + db.rows("select id, title, deleted from article_manual order by id"));
t.step("find(m) after delete -> null", db.sql(() -> assertThat((Object) db.tx(s -> s.find(ArticleManual.class, mid))).isNull()));
// ---- double delete from a stale session
t.heading("Deleting a row somebody else already soft-deleted");
Long did = db.tx(s -> { var a = new ArticleDeleted("race"); s.persist(a); return a.getId(); });
try (var s1 = db.sf().openSession()) {
var tx1 = s1.beginTransaction();
var loaded = s1.find(ArticleDeleted.class, did);
db.txv(s2 -> s2.remove(s2.find(ArticleDeleted.class, did))); // somebody else wins
s1.remove(loaded);
List<String> sql = db.sql(() -> {
Throwable ex = null;
try { s1.flush(); } catch (Exception e) { ex = e; }
assertThat(ex).isNotNull();
t.line("flush failed: " + Db.describe(ex));
});
t.step("s1.flush()", sql);
tx1.rollback();
}
});
}
}
@@ -0,0 +1,83 @@
package com.ankurm.softdelete;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/** Seeing deleted rows, bringing them back, and every route that walks around the soft-delete predicate. */
class A03_RestoreAndNativeTest {
@Test
void restoreAndBypass() throws Exception {
Both.run("03-restore-and-native.txt", "Restoring rows and querying around the filter", (db, t) -> {
db.open(ArticleDeleted.class, ArticleStamped.class, ArticleManual.class);
Both.withDbName(db, t);
Long id = db.tx(s -> { var a = new ArticleDeleted("kept"); s.persist(a); return a.getId(); });
db.tx(s -> { s.persist(new ArticleDeleted("alive")); return 0; });
db.txv(s -> s.remove(s.find(ArticleDeleted.class, id)));
t.heading("Boolean strategy: the deleted row is still in the table");
t.line("raw rows : " + db.rows("select id, title, deleted from article_deleted order by id"));
t.step("HQL count", db.sql(() -> assertThat((Object)
db.tx(s -> s.createQuery("select count(a) from ArticleDeleted a", Long.class).getSingleResult())).isEqualTo(1L)));
t.step("native count", db.sql(() -> assertThat((Object)
db.tx(s -> s.createNativeQuery("select count(*) from article_deleted", Long.class).getSingleResult())).isEqualTo(2L)));
t.heading("A native query mapped to the entity returns the deleted row");
var loaded = db.tx(s -> s.createNativeQuery("select * from article_deleted where id = :id", ArticleDeleted.class)
.setParameter("id", id).getResultList());
t.line("native select * ... id=" + id + " -> " + loaded.size() + " entity, title=" + loaded.get(0).getTitle());
assertThat(loaded).hasSize(1);
t.heading("HQL cannot restore: the flag is not an attribute");
Throwable ex = null;
try { db.txv(s -> s.createMutationQuery("update ArticleDeleted a set a.deleted = false where a.id = :id").setParameter("id", id).executeUpdate()); }
catch (Exception e) { ex = e; }
assertThat(ex).isNotNull();
t.line(Db.describe(ex));
t.heading("Native UPDATE restores it");
int n = db.tx(s -> s.createNativeMutationQuery("update article_deleted set deleted = false where id = :id").setParameter("id", id).executeUpdate());
t.line("rows updated: " + n);
t.step("find after restore", db.sql(() -> assertThat((Object) db.tx(s -> s.find(ArticleDeleted.class, id))).isNotNull()));
t.heading("Stale persistence context: a soft delete done behind the session's back");
try (var s = db.sf().openSession()) {
s.beginTransaction();
assertThat(s.find(ArticleDeleted.class, id)).isNotNull();
db.exec("update article_deleted set deleted = true where id = " + id);
t.line("same session, row deleted by someone else: find -> "
+ (s.find(ArticleDeleted.class, id) == null ? "null" : "still the entity (first-level cache)"));
assertThat(s.find(ArticleDeleted.class, id)).isNotNull();
s.clear();
t.line("after session.clear(): find -> "
+ (s.find(ArticleDeleted.class, id) == null ? "null" : "entity"));
assertThat(s.find(ArticleDeleted.class, id)).isNull();
s.getTransaction().rollback();
}
// ---------- timestamp
t.heading("Timestamp strategy: restore sets the column back to NULL");
Long sid = db.tx(s -> { var a = new ArticleStamped("ts"); s.persist(a); return a.getId(); });
db.txv(s -> s.remove(s.find(ArticleStamped.class, sid)));
t.line("after delete : " + db.rows("select id, deleted_at is not null from article_stamped"));
db.exec("update article_stamped set deleted_at = null where id = " + sid);
t.line("after restore : " + db.rows("select id, deleted_at is not null from article_stamped"));
assertThat((Object) db.tx(s -> s.find(ArticleStamped.class, sid))).isNotNull();
// ---------- manual
t.heading("@SQLRestriction: HQL CAN name the column, but the restriction rewrites the UPDATE too");
Long mid = db.tx(s -> { var a = new ArticleManual("man"); s.persist(a); return a.getId(); });
db.txv(s -> s.remove(s.find(ArticleManual.class, mid)));
var sql = db.sql(() -> {
int rows = db.tx(s -> s.createMutationQuery("update ArticleManual a set a.deleted = false where a.id = :id")
.setParameter("id", mid).executeUpdate());
t.line("HQL restore updated " + rows + " rows");
assertThat(rows).isZero();
});
t.step("HQL: update ArticleManual a set a.deleted = false", sql);
t.line("raw rows : " + db.rows("select id, title, deleted from article_manual"));
});
}
}
@@ -0,0 +1,139 @@
package com.ankurm.softdelete;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* The classic soft-delete trap: the row is still in the table, so a plain unique constraint still sees it.
* Each scenario runs on a fresh schema; the indexes are plain SQL because Hibernate cannot express partial indexes.
*/
class A04_UniqueConstraintTest {
@Test
void uniqueConstraints() throws Exception {
Both.run("04-unique-constraints.txt", "Unique constraints and soft delete", (db, t) -> {
// 1. plain unique(email)
db.open(Account.class);
Both.withDbName(db, t);
db.exec("create unique index ux_account_email on account (email)");
t.heading("1. boolean strategy, unique (email)");
String r = registerDeleteRegister(db, t, "[email protected]");
assertThat(r).contains("duplicate").doesNotContain("OK");
// 2. unique(email, deleted)
db.open(Account.class);
db.exec("create unique index ux_account_email_deleted on account (email, deleted)");
t.heading("2. boolean strategy, unique (email, deleted)");
t.line("register, delete, register again:");
Long a = register(db, "[email protected]");
delete(db, a);
Long b = register(db, "[email protected]");
t.line(" second registration OK, rows: " + db.rows("select id, email, deleted from account order by id"));
String err = null;
try { delete(db, b); } catch (Exception e) { err = Db.describe(e); }
// deleting the live row would make a second (bob, true) row
t.line(" deleting the second registration: " + (err == null ? "OK (no collision)" : "FAILED " + err));
t.line(" rows now: " + db.rows("select id, email, deleted from account order by id"));
assertThat(err).contains("SQLState 23505");
// 3. partial unique index
db.open(Account.class);
t.heading("3. partial unique index: create unique index ... (email) where deleted = false");
String ddlErr = null;
try { db.exec("create unique index ux_account_live_email on account (email) where deleted = false"); }
catch (Exception e) { ddlErr = Db.describe(e); }
if (db.isPostgres()) {
assertThat(ddlErr).isNull();
t.line("index created");
Long c1 = register(db, "[email protected]");
delete(db, c1);
Long c2 = register(db, "[email protected]");
delete(db, c2);
Long c3 = register(db, "[email protected]");
t.line("register/delete x2, then register a third time: OK, rows: " + db.rows("select id, email, deleted from account order by id"));
String dup = null;
try { register(db, "[email protected]"); } catch (Exception e) { dup = Db.describe(e); }
t.line("a fourth LIVE registration: " + dup);
assertThat(dup).contains("SQLState 23505");
} else {
t.line("H2 rejects it: " + ddlErr);
assertThat(ddlErr).isNotNull();
}
// 4. timestamp + unique(email, deleted_at)
db.open(AccountStamped.class);
db.exec("create unique index ux_stamped on account_stamped (email, deleted_at)");
t.heading("4. timestamp strategy, unique (email, deleted_at) -- NULLs are distinct");
register(db, "[email protected]", true);
register(db, "[email protected]", true);
t.line("two LIVE rows with the same email were accepted (columns: id | email | deleted_at is null): " + db.rows("select id, email, deleted_at is null from account_stamped order by id"));
assertThat(db.rows("select id from account_stamped")).hasSize(2);
// 5. NULLS NOT DISTINCT
db.open(AccountStamped.class);
t.heading("5. timestamp strategy, unique nulls not distinct (email, deleted_at)");
String nnd = null;
try { db.exec("create unique index ux_stamped_nnd on account_stamped (email, deleted_at) nulls not distinct"); }
catch (Exception e) { nnd = Db.describe(e); }
if (db.isPostgres()) {
assertThat(nnd).isNull();
Long e1 = register(db, "[email protected]", true);
String dup = null;
try { register(db, "[email protected]", true); } catch (Exception e) { dup = Db.describe(e); }
t.line("second LIVE registration: " + dup);
assertThat(dup).contains("23505");
db.tx(s -> { s.remove(s.find(AccountStamped.class, e1)); return 0; });
Long e2 = register(db, "[email protected]", true);
db.tx(s -> { s.remove(s.find(AccountStamped.class, e2)); return 0; });
register(db, "[email protected]", true);
t.line("delete / re-register / delete / re-register: OK (columns: id | email | deleted_at is null), rows: "
+ db.rows("select id, email, deleted_at is null from account_stamped order by id"));
} else {
t.line("H2: " + (nnd == null ? "accepted" : nnd));
if (nnd == null) {
register(db, "[email protected]", true);
String dup = null;
try { register(db, "[email protected]", true); } catch (Exception e) { dup = Db.describe(e); }
t.line("second LIVE registration: " + dup);
}
}
// 6. partial on timestamp
db.open(AccountStamped.class);
t.heading("6. timestamp strategy, partial unique index (email) where deleted_at is null");
String p = null;
try { db.exec("create unique index ux_stamped_live on account_stamped (email) where deleted_at is null"); }
catch (Exception e) { p = Db.describe(e); }
t.line(db.isPostgres() ? "index created" : "H2 rejects it: " + p);
assertThat(p == null).isEqualTo(db.isPostgres());
});
}
private static Long register(Db db, String email) {
return db.tx(s -> { var a = new Account(email); s.persist(a); s.flush(); return a.getId(); });
}
private static Long register(Db db, String email, boolean stamped) {
return db.tx(s -> { var a = new AccountStamped(email); s.persist(a); s.flush(); return a.getId(); });
}
private static void delete(Db db, Long id) {
db.txv(s -> { s.remove(s.find(Account.class, id)); s.flush(); });
}
private static String registerDeleteRegister(Db db, Transcript t, String email) {
Long first = register(db, email);
delete(db, first);
t.line("registered and soft-deleted " + email + "; row is still there: " + db.rows("select id, email, deleted from account"));
try {
register(db, email);
t.line("registering it again: OK");
return "OK";
} catch (Exception e) {
String d = Db.describe(e);
t.line("registering it again: " + d);
return d.toLowerCase().contains("unique") || d.toLowerCase().contains("duplicate") ? "duplicate " + d : d;
}
}
}
@@ -0,0 +1,154 @@
package com.ankurm.softdelete;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/** What soft delete does to @OneToMany, @ManyToOne, @ManyToMany, collection-level @SoftDelete and cascades. */
class A05_AssociationsTest {
@Test
void associations() throws Exception {
Both.run("05-associations.txt", "Associations and soft delete", (db, t) -> {
db.open(Author.class, Book.class, Tag.class, Reader.class, Shelf.class, Item.class);
Both.withDbName(db, t);
// ---- A. parent deleted, children not
t.heading("A. soft-delete the parent of a @OneToMany (no cascade)");
Long[] ids = db.tx(s -> {
var au = new Author("Le Guin"); s.persist(au);
var b1 = new Book("The Dispossessed", au); var b2 = new Book("Earthsea", au);
s.persist(b1); s.persist(b2);
return new Long[] { au.getId(), b1.getId(), b2.getId() };
});
Long authorId = ids[0], bookId = ids[1];
t.step("remove(author)", db.sql(() -> db.txv(s -> s.remove(s.find(Author.class, authorId)))));
t.line("books: " + db.rows("select id, title, author_id from book order by id")
+ " authors: " + db.rows("select id, name, deleted from author"));
var sql = db.sql(() -> {
String r = db.tx(s -> {
Book b = s.find(Book.class, bookId);
return "book.getAuthor() == null ? " + (b.getAuthor() == null);
});
t.line(r);
assertThat(r).endsWith("true");
});
t.step("load a book, touch its (soft-deleted) author", sql);
t.step("HQL: select b.title from Book b join b.author a (inner join)", db.sql(() -> {
var res = db.tx(s -> s.createQuery("select b.title from Book b join b.author a", String.class).getResultList());
t.line("rows: " + res);
assertThat(res).isEmpty();
}));
t.step("HQL: select b.title from Book b left join b.author a", db.sql(() -> {
var res = db.tx(s -> s.createQuery("select b.title from Book b left join b.author a", String.class).getResultList());
t.line("rows: " + res.size());
assertThat(res).hasSize(2);
}));
// ---- B. new child pointing at a deleted parent
t.heading("B. a NEW book can still point at the soft-deleted author");
var insertSql = db.sql(() -> db.txv(s -> {
var ghost = s.getReference(Author.class, authorId); // no SQL: just an id
s.persist(new Book("Orphan", ghost));
}));
t.step("persist(new Book(.., getReference(Author, deletedId)))", insertSql);
t.line("books: " + db.rows("select id, title, author_id from book order by id"));
assertThat(db.rows("select title from book where author_id = " + authorId)).hasSize(3);
// ---- C. live parent, deleted child
t.heading("C. soft-delete a child, then load the live parent's collection");
Long au2 = db.tx(s -> {
var a = new Author("Butler"); s.persist(a);
s.persist(new Book("Kindred", a)); s.persist(new Book("Dawn", a));
return a.getId();
});
Long dawn = Long.valueOf(db.scalar("select id from book where title = 'Dawn'"));
db.txv(s -> s.remove(s.find(Book.class, dawn)));
t.step("author.getBooks().size()", db.sql(() -> {
int n = db.tx(s -> s.find(Author.class, au2).getBooks().size());
t.line("books seen through the collection: " + n);
assertThat(n).isEqualTo(1);
}));
// ---- D. many-to-many to a deleted entity
t.heading("D. plain @ManyToMany to a soft-deleted Tag");
Long bk = db.tx(s -> {
var tg1 = new Tag("sf"); var tg2 = new Tag("classic");
s.persist(tg1); s.persist(tg2);
var b = new Book("Tagged", null); b.getTags().add(tg1); b.getTags().add(tg2);
s.persist(b);
return b.getId();
});
Long tagId = Long.valueOf(db.scalar("select id from tag where label = 'sf'"));
db.txv(s -> s.remove(s.find(Tag.class, tagId)));
t.line("DDL: " + db.ddl(Book.class, Tag.class, Author.class).stream().filter(l -> l.startsWith("create table book_tag")).toList());
t.line("join table rows (book_id | tags_id | deleted): " + db.rows("select book_id, tags_id, deleted from book_tag order by tags_id"));
t.step("book.getTags().size()", db.sql(() -> {
int n = db.tx(s -> s.find(Book.class, bk).getTags().size());
t.line("tags seen through the collection: " + n);
assertThat(n).isEqualTo(1);
}));
t.step("remove(book): what happens to its join-table rows?", db.sql(() -> db.txv(s -> s.remove(s.find(Book.class, bk)))));
t.line("join table rows (book_id | tags_id | deleted): " + db.rows("select book_id, tags_id, deleted from book_tag order by tags_id"));
db.exec("update book set deleted = false where id = " + bk);
t.step("restore ONLY the book row (update book set deleted = false), then book.getTags().size()", db.sql(() -> {
int n = db.tx(s -> s.find(Book.class, bk).getTags().size());
t.line("tags seen: " + n + " (the book_tag rows are still deleted = true)");
assertThat(n).isZero();
}));
db.exec("update book_tag set deleted = false where book_id = " + bk);
t.line("after also restoring book_tag: tags seen = " + db.tx(s -> s.find(Book.class, bk).getTags().size()));
// ---- E. @SoftDelete on the collection
t.heading("E. @SoftDelete on the COLLECTION (join-table rows)");
Long[] rt = db.tx(s -> {
var tg = new Tag("news"); s.persist(tg);
var r = new Reader("rita"); r.getSubscriptions().add(tg); s.persist(r);
return new Long[] { r.getId(), tg.getId() };
});
t.line("DDL: " + db.ddl(Reader.class, Tag.class).stream().filter(l -> l.contains("reader_subscription")).toList());
t.line("join rows before: " + db.rows("select reader_id, subscriptions_id, deleted from reader_subscription"));
t.step("reader.getSubscriptions().clear()", db.sql(() -> db.txv(s -> s.find(Reader.class, rt[0]).getSubscriptions().clear())));
t.line("join rows after : " + db.rows("select reader_id, subscriptions_id, deleted from reader_subscription"));
assertThat(db.rows("select deleted from reader_subscription")).containsExactly("true");
t.step("subscribe to the same tag again", db.sql(() -> {
Throwable ex = null;
try {
db.txv(s -> {
var r = s.find(Reader.class, rt[0]);
r.getSubscriptions().add(s.find(Tag.class, rt[1]));
});
} catch (Exception e) { ex = e; }
t.line("result: " + (ex == null ? "OK" : Db.describe(ex)));
assertThat(ex).isNotNull();
}));
t.line("join rows still: " + db.rows("select reader_id, subscriptions_id, deleted from reader_subscription order by deleted"));
// ---- F. cascade
t.heading("F. CascadeType.ALL + orphanRemoval on a soft-deleted parent");
Long shelf = db.tx(s -> {
var sh = new Shelf("top"); sh.add(new Item("i1")); sh.add(new Item("i2")); s.persist(sh);
return sh.getId();
});
t.step("remove an item from shelf.items (orphanRemoval)", db.sql(() -> db.txv(s -> {
var sh = s.find(Shelf.class, shelf);
sh.getItems().remove(0);
})));
t.step("remove(shelf)", db.sql(() -> db.txv(s -> s.remove(s.find(Shelf.class, shelf)))));
t.line("items: " + db.rows("select id, name, deleted from item order by id")
+ " shelves: " + db.rows("select id, deleted from shelf"));
assertThat(db.rows("select deleted from item")).containsOnly("true");
// ---- G. purge
t.heading("G. purging a soft-deleted parent that still has children");
String purge = null;
try { db.exec("delete from author where id = " + authorId); } catch (Exception e) { purge = Db.describe(e); }
t.line("physical DELETE of the soft-deleted author: " + (purge == null ? "OK" : purge));
assertThat(purge).isNotNull();
});
}
}
@@ -0,0 +1,34 @@
package com.ankurm.softdelete;
import static org.assertj.core.api.Assertions.assertThat;
import org.hibernate.cfg.Configuration;
import com.ankurm.lazytest.LazyEntities.LazyChild;
import com.ankurm.lazytest.LazyEntities.LazyIgnoreChild;
import org.junit.jupiter.api.Test;
/** Hibernate refuses a LAZY to-one towards a @SoftDelete entity. Booted on purpose, on H2 only (the check is not database specific). */
public class A06_LazyToOneTest {
@Test
void lazyToOneIsRejected() throws Exception {
Transcript t = new Transcript("06-lazy-to-one.txt");
t.line("A @ManyToOne(fetch = LAZY) towards a @SoftDelete entity (H2)");
for (Class<?> k : new Class<?>[] { LazyChild.class, LazyIgnoreChild.class }) {
String result;
try {
Configuration c = new Configuration();
c.setProperty("jakarta.persistence.jdbc.url", "jdbc:h2:mem:lazy" + k.getSimpleName() + ";DB_CLOSE_DELAY=-1");
c.setProperty("hibernate.hbm2ddl.auto", "create");
c.addAnnotatedClass(Author.class).addAnnotatedClass(Book.class).addAnnotatedClass(Tag.class).addAnnotatedClass(k);
c.buildSessionFactory().close();
result = "SessionFactory built (accepted)";
} catch (Exception e) {
result = e.getClass().getSimpleName() + ": " + e.getMessage();
}
t.blank().line(k.getSimpleName() + " -> " + result);
if (k == LazyChild.class) assertThat(result).contains("cannot be mapped as LAZY");
}
t.save();
}
}
@@ -0,0 +1,59 @@
package com.ankurm.softdelete;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/** Where @SoftDelete and the @SQLDelete recipe part ways, plus the converter attribute and @MappedSuperclass. */
class A07_BulkAndSqlDeleteTest {
@Test
void differences() throws Exception {
Both.run("07-bulk-and-sqldelete.txt", "@SoftDelete vs @SQLDelete+@SQLRestriction: bulk deletes, double deletes, converters", (db, t) -> {
db.open(ArticleDeleted.class, ArticleManual.class, ArticleYesNo.class, Note.class);
Both.withDbName(db, t);
t.heading("1. HQL bulk delete: delete from ArticleDeleted / delete from ArticleManual");
db.txv(s -> { s.persist(new ArticleDeleted("a")); s.persist(new ArticleDeleted("b")); });
db.txv(s -> { s.persist(new ArticleManual("a")); s.persist(new ArticleManual("b")); });
t.step("delete from ArticleDeleted", db.sql(() ->
assertThat((Object) db.tx(s -> s.createMutationQuery("delete from ArticleDeleted").executeUpdate())).isEqualTo(2)));
t.line("article_deleted rows: " + db.rows("select id, title, deleted from article_deleted order by id"));
t.step("delete from ArticleManual", db.sql(() ->
assertThat((Object) db.tx(s -> s.createMutationQuery("delete from ArticleManual").executeUpdate())).isEqualTo(2)));
t.line("article_manual rows : " + db.rows("select id, title, deleted from article_manual order by id"));
assertThat(db.rows("select * from article_deleted")).hasSize(2);
assertThat(db.rows("select * from article_manual")).isEmpty();
t.heading("2. Two sessions delete the same row (second one is stale)");
Long id = db.tx(s -> { var a = new ArticleManual("race"); s.persist(a); return a.getId(); });
try (var s1 = db.sf().openSession()) {
var tx = s1.beginTransaction();
var loaded = s1.find(ArticleManual.class, id);
db.txv(s2 -> s2.remove(s2.find(ArticleManual.class, id)));
s1.remove(loaded);
t.step("@SQLDelete entity, s1.flush()", db.sql(() -> {
s1.flush();
t.line("flush succeeded silently (the hand-written UPDATE has no 'and deleted = false' guard)");
}));
tx.rollback();
}
t.line("(the @SoftDelete entity fails the same scenario with StaleStateException: see 02-ddl-and-sql.txt)");
t.heading("3. converter attribute: char(1) 'Y'/'N' column named is_gone");
for (String l : db.ddl(ArticleYesNo.class)) if (l.startsWith("create table") || l.startsWith("comment")) t.line(l);
Long yid = db.tx(s -> { var a = new ArticleYesNo("legacy"); s.persist(a); return a.getId(); });
t.line("alive : " + db.rows("select id, is_gone from article_yes_no"));
t.step("remove", db.sql(() -> db.txv(s -> s.remove(s.find(ArticleYesNo.class, yid)))));
t.line("deleted : " + db.rows("select id, is_gone from article_yes_no"));
assertThat(db.scalar("select is_gone from article_yes_no")).isEqualTo("Y");
t.heading("4. @SoftDelete on a @MappedSuperclass");
for (String l : db.ddl(Note.class)) if (l.startsWith("create table")) t.line(l);
Long nid = db.tx(s -> { var n = new Note("hello"); s.persist(n); return n.getId(); });
t.step("remove(note)", db.sql(() -> db.txv(s -> s.remove(s.find(Note.class, nid)))));
t.line("raw: " + db.rows("select id, deleted_at is not null from note"));
assertThat(db.scalar("select deleted_at is not null from note")).isEqualTo("true");
});
}
}
@@ -0,0 +1,103 @@
package com.ankurm.softdelete;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/** Spring Data JPA on top of a @SoftDelete entity (H2; the Spring layer does not care which database is underneath). */
@SpringBootTest(properties = {
"spring.datasource.url=jdbc:h2:mem:springdata;DB_CLOSE_DELAY=-1",
"spring.jpa.hibernate.ddl-auto=create-drop",
"spring.jpa.open-in-view=false",
"spring.jpa.properties.hibernate.session_factory.statement_inspector=com.ankurm.softdelete.SqlLog"})
class A08_SpringDataTest {
@Autowired ArticleRepository repo;
@Test
void springDataBehaviour() throws Exception {
Transcript t = new Transcript("08-spring-data.txt");
t.line("Spring Data JPA over @SoftDelete (boolean strategy), H2, Hibernate " + org.hibernate.Version.getVersionString());
t.heading("save, count, deleteById, findById");
List<ArticleDeleted> saved = repo.saveAll(List.of(new ArticleDeleted("x"), new ArticleDeleted("x"), new ArticleDeleted("y")));
Long id1 = saved.get(0).getId(), id2 = saved.get(1).getId(), id3 = saved.get(2).getId();
t.step("count()", Db.capture(() -> assertThat(repo.count()).isEqualTo(3)));
t.step("deleteById(" + id1 + ")", Db.capture(() -> repo.deleteById(id1)));
t.step("findById(" + id1 + ")", Db.capture(() -> {
var r = repo.findById(id1);
t.line("isPresent = " + r.isPresent());
assertThat(r).isEmpty();
}));
t.step("existsById(" + id1 + ")", Db.capture(() -> {
boolean r = repo.existsById(id1);
t.line("existsById = " + r);
assertThat(r).isFalse();
}));
t.step("count() and findAll().size()", Db.capture(() -> {
long c = repo.count();
int all = repo.findAll().size();
t.line("count = " + c + ", findAll = " + all);
assertThat(c).isEqualTo(2);
}));
t.heading("derived queries and derived deletes");
t.step("countByTitle(\"x\")", Db.capture(() -> {
long c = repo.countByTitle("x");
t.line("countByTitle(x) = " + c);
assertThat(c).isEqualTo(1);
}));
t.step("deleteByTitle(\"x\")", Db.capture(() -> t.line("returned " + repo.deleteByTitle("x"))));
t.heading("deleting something that is already (soft) deleted");
t.step("deleteById(" + id1 + ") again", Db.capture(() -> {
repo.deleteById(id1);
t.line("no exception");
}));
t.step("delete(staleEntity) with the instance saved earlier", Db.capture(() -> {
repo.delete(saved.get(0));
t.line("no exception");
}));
t.heading("save() of a detached instance whose row is soft-deleted");
t.step("save(saved.get(0))", Db.capture(() -> {
String r;
try { repo.save(saved.get(0)); r = "saved"; }
catch (Exception e) { r = Db.describe(e); }
t.line("result: " + r);
assertThat(r).isNotEqualTo("saved");
}));
t.heading("bulk delete, native queries, restore");
repo.save(new ArticleDeleted("z"));
t.step("@Query(\"delete from ArticleDeleted a where a.title = :title\") bulkDeleteByTitle(\"z\")", Db.capture(() -> t.line("returned " + repo.bulkDeleteByTitle("z"))));
t.step("count() vs native count(*)", Db.capture(() -> {
long c = repo.count(), all = repo.countIncludingDeleted();
t.line("count() = " + c + ", countIncludingDeleted() = " + all);
assertThat(all).isGreaterThan(c);
}));
t.step("native findEvenIfDeleted(" + id1 + ")", Db.capture(() -> {
var r = repo.findEvenIfDeleted(id1);
t.line("present = " + r.isPresent());
assertThat(r).isPresent();
}));
t.step("native restore(" + id1 + ")", Db.capture(() -> t.line("rows updated: " + repo.restore(id1))));
t.step("findById(" + id1 + ") after restore", Db.capture(() -> {
var r = repo.findById(id1);
t.line("isPresent = " + r.isPresent());
assertThat(r).isPresent();
}));
t.heading("deleteAll variants");
t.step("deleteAllInBatch()", Db.capture(() -> repo.deleteAllInBatch()));
t.line("count() = " + repo.count() + ", countIncludingDeleted() = " + repo.countIncludingDeleted());
repo.save(new ArticleDeleted("again"));
t.step("deleteAll()", Db.capture(() -> repo.deleteAll()));
t.line("count() = " + repo.count());
assertThat(repo.count()).isZero();
t.save();
}
}
@@ -0,0 +1,27 @@
package com.ankurm.softdelete;
/** Runs one scenario body against H2 and then PostgreSQL and writes a single transcript with both runs. */
public final class Both {
@FunctionalInterface
public interface Body { void run(Db db, Transcript t) throws Exception; }
private Both() {}
public static void run(String file, String title, Body body) throws Exception {
Transcript t = new Transcript(file);
t.line(title);
for (Db.Kind k : Db.Kind.values()) {
try (Db db = Db.of(k)) {
t.blank().line("######## " + k + " ########");
body.run(db, t);
}
}
t.save();
}
public static void withDbName(Db db, Transcript t) {
// product + dialect line is written by the caller after open(); kept here to avoid repetition
t.line("database: " + db.productName());
}
}
@@ -0,0 +1,169 @@
package com.ankurm.softdelete;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.Function;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
import org.testcontainers.postgresql.PostgreSQLContainer;
/** One database (H2 in memory, or a throw-away PostgreSQL 17 container) plus a plain Hibernate SessionFactory over it. */
public final class Db implements AutoCloseable {
public enum Kind { H2, POSTGRES }
public static final String PG_IMAGE = "postgres:17-alpine";
private static PostgreSQLContainer pg;
private final Kind kind;
private SessionFactory sf;
private Db(Kind kind) { this.kind = kind; }
private static synchronized PostgreSQLContainer pg() {
if (pg == null) {
pg = new PostgreSQLContainer(PG_IMAGE);
pg.start();
Runtime.getRuntime().addShutdownHook(new Thread(() -> pg.stop()));
}
return pg;
}
public static Db of(Kind kind) { return new Db(kind); }
public boolean isPostgres() { return kind == Kind.POSTGRES; }
public Kind kind() { return kind; }
private Configuration config(Class<?>... classes) {
Configuration c = new Configuration();
if (kind == Kind.H2) {
c.setProperty("jakarta.persistence.jdbc.url", "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1");
c.setProperty("jakarta.persistence.jdbc.user", "sa");
c.setProperty("jakarta.persistence.jdbc.password", "");
} else {
PostgreSQLContainer p = pg();
c.setProperty("jakarta.persistence.jdbc.url", p.getJdbcUrl());
c.setProperty("jakarta.persistence.jdbc.user", p.getUsername());
c.setProperty("jakarta.persistence.jdbc.password", p.getPassword());
}
c.setProperty("hibernate.session_factory.statement_inspector", SqlLog.class.getName());
for (Class<?> k : classes) c.addAnnotatedClass(k);
return c;
}
/** Opens a fresh schema containing the tables of {@code classes}. Any previous schema content is dropped. */
public Db open(Class<?>... classes) {
close();
if (isPostgres()) {
Configuration bare = config();
try (var cn = java.sql.DriverManager.getConnection(bare.getProperty("jakarta.persistence.jdbc.url"),
bare.getProperty("jakarta.persistence.jdbc.user"), bare.getProperty("jakarta.persistence.jdbc.password"));
var st = cn.createStatement()) {
st.execute("drop schema public cascade");
st.execute("create schema public");
} catch (SQLException e) { throw new IllegalStateException(e); }
}
Configuration c = config(classes);
c.setProperty("hibernate.hbm2ddl.auto", "create");
sf = c.buildSessionFactory();
return this;
}
public SessionFactory sf() { return sf; }
/** Runs {@code work} in its own session and transaction and returns its result. */
public <T> T tx(Function<Session, T> work) {
try (Session s = sf.openSession()) {
var t = s.beginTransaction();
try {
T r = work.apply(s);
t.commit();
return r;
} catch (RuntimeException e) {
if (t.isActive()) t.rollback();
throw e;
}
}
}
public void txv(java.util.function.Consumer<Session> work) { tx(s -> { work.accept(s); return null; }); }
/** Runs the action and returns the SQL Hibernate issued while it ran. */
public List<String> sql(Runnable action) { return capture(action); }
public static List<String> capture(Runnable action) {
SqlLog.clear();
Transcript.parking = true;
try { action.run(); } finally { Transcript.parking = false; }
return SqlLog.snapshot();
}
/** Raw JDBC (invisible to the SqlLog): run DDL or any statement. */
public int exec(String sql) {
return tx(s -> s.doReturningWork(cn -> {
try (var st = cn.createStatement()) {
st.execute(sql);
return st.getUpdateCount();
}
}));
}
/** Raw JDBC query; each row rendered as {@code a | b | c}. */
public List<String> rows(String sql) {
return tx(s -> s.doReturningWork(cn -> {
List<String> out = new ArrayList<>();
try (var st = cn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
ResultSetMetaData md = rs.getMetaData();
while (rs.next()) {
StringBuilder b = new StringBuilder();
for (int i = 1; i <= md.getColumnCount(); i++) {
if (i > 1) b.append(" | ");
b.append(rs.getObject(i));
}
out.add(b.toString());
}
}
return out;
}));
}
public String scalar(String sql) { return rows(sql).get(0); }
public String productName() {
return sf.unwrap(org.hibernate.engine.spi.SessionFactoryImplementor.class)
.getJdbcServices().getDialect().getClass().getSimpleName() + " / "
+ tx(s -> s.doReturningWork(cn -> cn.getMetaData().getDatabaseProductName() + " " + cn.getMetaData().getDatabaseProductVersion()));
}
/** The CREATE TABLE / ALTER TABLE script Hibernate would emit for these entities on this database (script only, nothing executed). */
public List<String> ddl(Class<?>... classes) throws Exception {
java.nio.file.Path tmp = java.nio.file.Files.createTempFile("ddl", ".sql");
Configuration c = config(classes);
c.setProperty("jakarta.persistence.schema-generation.scripts.action", "create");
c.setProperty("jakarta.persistence.schema-generation.scripts.create-target", tmp.toUri().toString());
c.buildSessionFactory().close();
List<String> lines = java.nio.file.Files.readAllLines(tmp);
java.nio.file.Files.delete(tmp);
return lines;
}
/** SQLState + first line of the innermost message, so transcripts stay readable and stable. */
public static String describe(Throwable t) {
Throwable root = t;
while (root.getCause() != null && root.getCause() != root) root = root.getCause();
String state = root instanceof SQLException se ? " SQLState " + se.getSQLState() + "," : "";
String msg = String.valueOf(root.getMessage()).lines().findFirst().orElse("");
return t.getClass().getSimpleName() + " -> " + root.getClass().getSimpleName() + ":" + state + " " + msg;
}
@Override
public void close() {
if (sf != null && sf.isOpen()) sf.close();
sf = null;
}
}
@@ -0,0 +1,24 @@
package com.ankurm.softdelete;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.resource.jdbc.spi.StatementInspector;
/**
* Registered through {@code hibernate.session_factory.statement_inspector}. Records every SQL string Hibernate sends
* to JDBC (with {@code ?} placeholders, before binding). Raw JDBC issued by the tests themselves is not recorded,
* so the transcripts show only what Hibernate generated.
*/
public class SqlLog implements StatementInspector {
private static final List<String> LOG = new ArrayList<>();
@Override
public String inspect(String sql) {
synchronized (LOG) { LOG.add(sql.replaceAll("\\s+", " ").trim()); }
return sql;
}
public static void clear() { synchronized (LOG) { LOG.clear(); } }
public static List<String> snapshot() { synchronized (LOG) { return new ArrayList<>(LOG); } }
}
@@ -0,0 +1,40 @@
package com.ankurm.softdelete;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
/** Collects what a test observed and writes it to {@code output/NN-name.txt}; the tests assert the same facts. */
public final class Transcript {
private final StringBuilder sb = new StringBuilder();
private final String file;
/** While a Db.sql(..) action runs, lines are parked here and written after the step's SQL, so a step reads label / SQL / result. */
static final StringBuilder PARKED = new StringBuilder();
static boolean parking;
public Transcript(String file) { this.file = file; }
public Transcript line(String s) { (parking ? PARKED : sb).append(s).append('\n'); return this; }
public Transcript blank() { return line(""); }
public Transcript heading(String s) { return line("").line("== " + s); }
/** A step: the label, then every SQL statement Hibernate issued during it. */
public Transcript step(String label, List<String> sql) {
line("-- " + label);
if (sql.isEmpty()) line(" (no SQL)");
for (String s : sql) line(" sql> " + s);
sb.append(PARKED);
PARKED.setLength(0);
return this;
}
public void save() throws IOException {
Path out = Path.of("output", file);
Files.createDirectories(out.getParent());
Files.writeString(out, sb.toString(), StandardCharsets.UTF_8);
}
}