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