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:
@@ -344,4 +344,5 @@ Modules with their own `pom.xml` that run plain Hibernate without the Spring Boo
|
|||||||
| [`h8-preview`](h8-preview/) | Hibernate ORM 8 beta against Hibernate 7: what breaks |
|
| [`h8-preview`](h8-preview/) | Hibernate ORM 8 beta against Hibernate 7: what breaks |
|
||||||
| [`inheritance`](inheritance/) | `SINGLE_TABLE` vs `JOINED` vs `TABLE_PER_CLASS` vs `@MappedSuperclass`: generated DDL, SQL, failures and timings (Hibernate `7.4.13.Final`) |
|
| [`inheritance`](inheritance/) | `SINGLE_TABLE` vs `JOINED` vs `TABLE_PER_CLASS` vs `@MappedSuperclass`: generated DDL, SQL, failures and timings (Hibernate `7.4.13.Final`) |
|
||||||
| [`locking`](locking/) | Optimistic (`@Version`, retries) vs pessimistic (`PESSIMISTIC_WRITE`, `NOWAIT`, `SKIP LOCKED`) locking, lost updates and deadlocks on H2 (Hibernate `7.4.14.Final`) |
|
| [`locking`](locking/) | Optimistic (`@Version`, retries) vs pessimistic (`PESSIMISTIC_WRITE`, `NOWAIT`, `SKIP LOCKED`) locking, lost updates and deadlocks on H2 (Hibernate `7.4.14.Final`) |
|
||||||
|
| [`soft-delete`](soft-delete/) | `@SoftDelete` (`DELETED` / `ACTIVE` / `TIMESTAMP`) vs `@SQLDelete` + `@SQLRestriction`: generated SQL, restore, unique constraints, associations, native-query bypass and Spring Data JPA, on H2 and PostgreSQL 17 (Hibernate `7.4.14.Final`; unlike the others this module uses the Spring Boot 4.1.1 parent, for the Spring Data tests) |
|
||||||
| [`migration-5-to-7`](migration-5-to-7/) | Hibernate `5.6.15.Final` vs `7.4.14.Final`: jar diff, Hibernate 5 code compiled against both, schema defaults, `hbm2ddl` validation of a Hibernate 5 schema, and cheat-sheet snippets |
|
| [`migration-5-to-7`](migration-5-to-7/) | Hibernate `5.6.15.Final` vs `7.4.14.Final`: jar diff, Hibernate 5 code compiled against both, schema defaults, `hbm2ddl` validation of a Hibernate 5 schema, and cheat-sheet snippets |
|
||||||
|
|||||||
@@ -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