From c9faf35e61f0ca39fe9fc6c02e387d2e5637806d Mon Sep 17 00:00:00 2001 From: asmhatre Date: Wed, 28 Oct 2026 18:22:03 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy --- README.md | 1 + soft-delete/README.md | 56 ++++++ soft-delete/output/00-javap.txt | 39 ++++ soft-delete/output/01-api-shape.txt | 16 ++ soft-delete/output/02-ddl-and-sql.txt | 107 +++++++++++ soft-delete/output/03-restore-and-native.txt | 71 ++++++++ soft-delete/output/04-unique-constraints.txt | 54 ++++++ soft-delete/output/05-associations.txt | 157 ++++++++++++++++ soft-delete/output/06-lazy-to-one.txt | 5 + soft-delete/output/07-bulk-and-sqldelete.txt | 67 +++++++ soft-delete/output/08-spring-data.txt | 68 +++++++ soft-delete/pom.xml | 68 +++++++ soft-delete/scripts/run-all.sh | 22 +++ .../java/com/ankurm/softdelete/Account.java | 20 +++ .../com/ankurm/softdelete/AccountStamped.java | 21 +++ .../com/ankurm/softdelete/ArticleActive.java | 21 +++ .../com/ankurm/softdelete/ArticleDeleted.java | 24 +++ .../com/ankurm/softdelete/ArticleManual.java | 25 +++ .../ankurm/softdelete/ArticleRepository.java | 41 +++++ .../com/ankurm/softdelete/ArticleStamped.java | 21 +++ .../com/ankurm/softdelete/ArticleYesNo.java | 20 +++ .../java/com/ankurm/softdelete/Author.java | 25 +++ .../main/java/com/ankurm/softdelete/Book.java | 29 +++ .../main/java/com/ankurm/softdelete/Item.java | 22 +++ .../main/java/com/ankurm/softdelete/Note.java | 12 ++ .../java/com/ankurm/softdelete/Reader.java | 28 +++ .../java/com/ankurm/softdelete/Shelf.java | 25 +++ .../java/com/ankurm/softdelete/SoftBase.java | 15 ++ .../softdelete/SoftDeleteApplication.java | 8 + .../main/java/com/ankurm/softdelete/Tag.java | 19 ++ .../com/ankurm/lazytest/LazyEntities.java | 25 +++ .../ankurm/softdelete/A01_ApiShapeTest.java | 41 +++++ .../softdelete/A02_LifecycleSqlTest.java | 79 ++++++++ .../softdelete/A03_RestoreAndNativeTest.java | 83 +++++++++ .../softdelete/A04_UniqueConstraintTest.java | 139 ++++++++++++++ .../softdelete/A05_AssociationsTest.java | 154 ++++++++++++++++ .../ankurm/softdelete/A06_LazyToOneTest.java | 34 ++++ .../softdelete/A07_BulkAndSqlDeleteTest.java | 59 ++++++ .../ankurm/softdelete/A08_SpringDataTest.java | 103 +++++++++++ .../test/java/com/ankurm/softdelete/Both.java | 27 +++ .../test/java/com/ankurm/softdelete/Db.java | 169 ++++++++++++++++++ .../java/com/ankurm/softdelete/SqlLog.java | 24 +++ .../com/ankurm/softdelete/Transcript.java | 40 +++++ 43 files changed, 2084 insertions(+) create mode 100644 soft-delete/README.md create mode 100644 soft-delete/output/00-javap.txt create mode 100644 soft-delete/output/01-api-shape.txt create mode 100644 soft-delete/output/02-ddl-and-sql.txt create mode 100644 soft-delete/output/03-restore-and-native.txt create mode 100644 soft-delete/output/04-unique-constraints.txt create mode 100644 soft-delete/output/05-associations.txt create mode 100644 soft-delete/output/06-lazy-to-one.txt create mode 100644 soft-delete/output/07-bulk-and-sqldelete.txt create mode 100644 soft-delete/output/08-spring-data.txt create mode 100644 soft-delete/pom.xml create mode 100755 soft-delete/scripts/run-all.sh create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/Account.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/AccountStamped.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/ArticleActive.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/ArticleDeleted.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/ArticleManual.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/ArticleRepository.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/ArticleStamped.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/ArticleYesNo.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/Author.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/Book.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/Item.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/Note.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/Reader.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/Shelf.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/SoftBase.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/SoftDeleteApplication.java create mode 100644 soft-delete/src/main/java/com/ankurm/softdelete/Tag.java create mode 100644 soft-delete/src/test/java/com/ankurm/lazytest/LazyEntities.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/A01_ApiShapeTest.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/A02_LifecycleSqlTest.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/A03_RestoreAndNativeTest.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/A04_UniqueConstraintTest.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/A05_AssociationsTest.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/A06_LazyToOneTest.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/A07_BulkAndSqlDeleteTest.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/A08_SpringDataTest.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/Both.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/Db.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/SqlLog.java create mode 100644 soft-delete/src/test/java/com/ankurm/softdelete/Transcript.java diff --git a/README.md b/README.md index 3221665..ea39bd1 100755 --- a/README.md +++ b/README.md @@ -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 | | [`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`) | +| [`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 | diff --git a/soft-delete/README.md b/soft-delete/README.md new file mode 100644 index 0000000..974b0ff --- /dev/null +++ b/soft-delete/README.md @@ -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). diff --git a/soft-delete/output/00-javap.txt b/soft-delete/output/00-javap.txt new file mode 100644 index 0000000..e6e82b7 --- /dev/null +++ b/soft-delete/output/00-javap.txt @@ -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> 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 { + 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 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(); +} diff --git a/soft-delete/output/01-api-shape.txt b/soft-delete/output/01-api-shape.txt new file mode 100644 index 0000000..bb43b88 --- /dev/null +++ b/soft-delete/output/01-api-shape.txt @@ -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] diff --git a/soft-delete/output/02-ddl-and-sql.txt b/soft-delete/output/02-ddl-and-sql.txt new file mode 100644 index 0000000..ae683ab --- /dev/null +++ b/soft-delete/output/02-ddl-and-sql.txt @@ -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] diff --git a/soft-delete/output/03-restore-and-native.txt b/soft-delete/output/03-restore-and-native.txt new file mode 100644 index 0000000..8949b78 --- /dev/null +++ b/soft-delete/output/03-restore-and-native.txt @@ -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] diff --git a/soft-delete/output/04-unique-constraints.txt b/soft-delete/output/04-unique-constraints.txt new file mode 100644 index 0000000..7cbd586 --- /dev/null +++ b/soft-delete/output/04-unique-constraints.txt @@ -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 ann@example.com; row is still there: [1 | ann@example.com | 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 */ 'ann@example.com' )"; SQL statement: + +== 2. boolean strategy, unique (email, deleted) +register, delete, register again: + second registration OK, rows: [1 | bob@example.com | true, 2 | bob@example.com | 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 */ 'bob@example.com', TRUE)"; SQL statement: + rows now: [1 | bob@example.com | true, 2 | bob@example.com | 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 | dee@example.com | true, 2 | dee@example.com | 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 ann@example.com; row is still there: [1 | ann@example.com | 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 | bob@example.com | true, 2 | bob@example.com | false] + deleting the second registration: FAILED ConstraintViolationException -> PSQLException: SQLState 23505, ERROR: duplicate key value violates unique constraint "ux_account_email_deleted" + rows now: [1 | bob@example.com | true, 2 | bob@example.com | 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 | cy@example.com | true, 2 | cy@example.com | true, 3 | cy@example.com | 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 | dee@example.com | true, 2 | dee@example.com | 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 | eve@example.com | false, 3 | eve@example.com | false, 4 | eve@example.com | true] + +== 6. timestamp strategy, partial unique index (email) where deleted_at is null +index created diff --git a/soft-delete/output/05-associations.txt b/soft-delete/output/05-associations.txt new file mode 100644 index 0000000..274698b --- /dev/null +++ b/soft-delete/output/05-associations.txt @@ -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" diff --git a/soft-delete/output/06-lazy-to-one.txt b/soft-delete/output/06-lazy-to-one.txt new file mode 100644 index 0000000..82f385c --- /dev/null +++ b/soft-delete/output/06-lazy-to-one.txt @@ -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) diff --git a/soft-delete/output/07-bulk-and-sqldelete.txt b/soft-delete/output/07-bulk-and-sqldelete.txt new file mode 100644 index 0000000..28eab0c --- /dev/null +++ b/soft-delete/output/07-bulk-and-sqldelete.txt @@ -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] diff --git a/soft-delete/output/08-spring-data.txt b/soft-delete/output/08-spring-data.txt new file mode 100644 index 0000000..a283164 --- /dev/null +++ b/soft-delete/output/08-spring-data.txt @@ -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 diff --git a/soft-delete/pom.xml b/soft-delete/pom.xml new file mode 100644 index 0000000..92d9309 --- /dev/null +++ b/soft-delete/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + soft-delete + 1.0.0 + soft-delete + Hibernate 7 soft delete: @SoftDelete (DELETED / ACTIVE / TIMESTAMP) vs @SQLDelete + @SQLRestriction, on H2 and PostgreSQL, with Spring Data JPA + + + 25 + + 7.4.14.Final + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.h2database + h2 + + + org.postgresql + postgresql + + + org.springframework.boot + spring-boot-starter-test + test + + + org.testcontainers + testcontainers-postgresql + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -Xmx512m + 1 + + + + + diff --git a/soft-delete/scripts/run-all.sh b/soft-delete/scripts/run-all.sh new file mode 100755 index 0000000..02eaf39 --- /dev/null +++ b/soft-delete/scripts/run-all.sh @@ -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 diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/Account.java b/soft-delete/src/main/java/com/ankurm/softdelete/Account.java new file mode 100644 index 0000000..3a01ddf --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/Account.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/AccountStamped.java b/soft-delete/src/main/java/com/ankurm/softdelete/AccountStamped.java new file mode 100644 index 0000000..f2b4272 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/AccountStamped.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/ArticleActive.java b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleActive.java new file mode 100644 index 0000000..ba49a17 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleActive.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/ArticleDeleted.java b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleDeleted.java new file mode 100644 index 0000000..6d8f594 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleDeleted.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/ArticleManual.java b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleManual.java new file mode 100644 index 0000000..e3b87af --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleManual.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/ArticleRepository.java b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleRepository.java new file mode 100644 index 0000000..a487b6d --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleRepository.java @@ -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 { + + List 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 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); +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/ArticleStamped.java b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleStamped.java new file mode 100644 index 0000000..5e21822 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleStamped.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/ArticleYesNo.java b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleYesNo.java new file mode 100644 index 0000000..592d0c5 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/ArticleYesNo.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/Author.java b/soft-delete/src/main/java/com/ankurm/softdelete/Author.java new file mode 100644 index 0000000..0aec806 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/Author.java @@ -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 books = new ArrayList<>(); + + protected Author() {} + public Author(String name) { this.name = name; } + + public Long getId() { return id; } + public String getName() { return name; } + public List getBooks() { return books; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/Book.java b/soft-delete/src/main/java/com/ankurm/softdelete/Book.java new file mode 100644 index 0000000..2705ba2 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/Book.java @@ -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 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 getTags() { return tags; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/Item.java b/soft-delete/src/main/java/com/ankurm/softdelete/Item.java new file mode 100644 index 0000000..1d1ba3f --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/Item.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/Note.java b/soft-delete/src/main/java/com/ankurm/softdelete/Note.java new file mode 100644 index 0000000..4725df3 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/Note.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/Reader.java b/soft-delete/src/main/java/com/ankurm/softdelete/Reader.java new file mode 100644 index 0000000..0abdbf4 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/Reader.java @@ -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 subscriptions = new HashSet<>(); + + protected Reader() {} + public Reader(String name) { this.name = name; } + + public Long getId() { return id; } + public Set getSubscriptions() { return subscriptions; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/Shelf.java b/soft-delete/src/main/java/com/ankurm/softdelete/Shelf.java new file mode 100644 index 0000000..3994ff9 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/Shelf.java @@ -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 items = new ArrayList<>(); + + protected Shelf() {} + public Shelf(String label) { this.label = label; } + + public Long getId() { return id; } + public List getItems() { return items; } + public void add(Item i) { items.add(i); i.setShelf(this); } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/SoftBase.java b/soft-delete/src/main/java/com/ankurm/softdelete/SoftBase.java new file mode 100644 index 0000000..d2810ec --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/SoftBase.java @@ -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; } +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/SoftDeleteApplication.java b/soft-delete/src/main/java/com/ankurm/softdelete/SoftDeleteApplication.java new file mode 100644 index 0000000..75bc1bd --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/SoftDeleteApplication.java @@ -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 { +} diff --git a/soft-delete/src/main/java/com/ankurm/softdelete/Tag.java b/soft-delete/src/main/java/com/ankurm/softdelete/Tag.java new file mode 100644 index 0000000..30d3528 --- /dev/null +++ b/soft-delete/src/main/java/com/ankurm/softdelete/Tag.java @@ -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; } +} diff --git a/soft-delete/src/test/java/com/ankurm/lazytest/LazyEntities.java b/soft-delete/src/test/java/com/ankurm/lazytest/LazyEntities.java new file mode 100644 index 0000000..4450cd3 --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/lazytest/LazyEntities.java @@ -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; + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/A01_ApiShapeTest.java b/soft-delete/src/test/java/com/ankurm/softdelete/A01_ApiShapeTest.java new file mode 100644 index 0000000..b0fb19a --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/A01_ApiShapeTest.java @@ -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); + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/A02_LifecycleSqlTest.java b/soft-delete/src/test/java/com/ankurm/softdelete/A02_LifecycleSqlTest.java new file mode 100644 index 0000000..b6cdad4 --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/A02_LifecycleSqlTest.java @@ -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 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 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(); + } + }); + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/A03_RestoreAndNativeTest.java b/soft-delete/src/test/java/com/ankurm/softdelete/A03_RestoreAndNativeTest.java new file mode 100644 index 0000000..d422e78 --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/A03_RestoreAndNativeTest.java @@ -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")); + }); + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/A04_UniqueConstraintTest.java b/soft-delete/src/test/java/com/ankurm/softdelete/A04_UniqueConstraintTest.java new file mode 100644 index 0000000..920c3af --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/A04_UniqueConstraintTest.java @@ -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, "ann@example.com"); + 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, "bob@example.com"); + delete(db, a); + Long b = register(db, "bob@example.com"); + 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, "cy@example.com"); + delete(db, c1); + Long c2 = register(db, "cy@example.com"); + delete(db, c2); + Long c3 = register(db, "cy@example.com"); + 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, "cy@example.com"); } 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, "dee@example.com", true); + register(db, "dee@example.com", 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, "eve@example.com", true); + String dup = null; + try { register(db, "eve@example.com", 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, "eve@example.com", true); + db.tx(s -> { s.remove(s.find(AccountStamped.class, e2)); return 0; }); + register(db, "eve@example.com", 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, "eve@example.com", true); + String dup = null; + try { register(db, "eve@example.com", 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; + } + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/A05_AssociationsTest.java b/soft-delete/src/test/java/com/ankurm/softdelete/A05_AssociationsTest.java new file mode 100644 index 0000000..631fa2a --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/A05_AssociationsTest.java @@ -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(); + }); + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/A06_LazyToOneTest.java b/soft-delete/src/test/java/com/ankurm/softdelete/A06_LazyToOneTest.java new file mode 100644 index 0000000..85fb909 --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/A06_LazyToOneTest.java @@ -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(); + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/A07_BulkAndSqlDeleteTest.java b/soft-delete/src/test/java/com/ankurm/softdelete/A07_BulkAndSqlDeleteTest.java new file mode 100644 index 0000000..bab6167 --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/A07_BulkAndSqlDeleteTest.java @@ -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"); + }); + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/A08_SpringDataTest.java b/soft-delete/src/test/java/com/ankurm/softdelete/A08_SpringDataTest.java new file mode 100644 index 0000000..c322af8 --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/A08_SpringDataTest.java @@ -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 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(); + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/Both.java b/soft-delete/src/test/java/com/ankurm/softdelete/Both.java new file mode 100644 index 0000000..dae6ac1 --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/Both.java @@ -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()); + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/Db.java b/soft-delete/src/test/java/com/ankurm/softdelete/Db.java new file mode 100644 index 0000000..40cb39f --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/Db.java @@ -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 tx(Function 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 work) { tx(s -> { work.accept(s); return null; }); } + + /** Runs the action and returns the SQL Hibernate issued while it ran. */ + public List sql(Runnable action) { return capture(action); } + + public static List 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 rows(String sql) { + return tx(s -> s.doReturningWork(cn -> { + List 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 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 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; + } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/SqlLog.java b/soft-delete/src/test/java/com/ankurm/softdelete/SqlLog.java new file mode 100644 index 0000000..ffc7781 --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/SqlLog.java @@ -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 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 snapshot() { synchronized (LOG) { return new ArrayList<>(LOG); } } +} diff --git a/soft-delete/src/test/java/com/ankurm/softdelete/Transcript.java b/soft-delete/src/test/java/com/ankurm/softdelete/Transcript.java new file mode 100644 index 0000000..4d37e7d --- /dev/null +++ b/soft-delete/src/test/java/com/ankurm/softdelete/Transcript.java @@ -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 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); + } +}