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"