package com.ankurm.hibernatedemo.association; import static org.assertj.core.api.Assertions.assertThat; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; /** * Backs ankurm.com post 4872, docs/12-association-mappings.md chapters "Cascade and orphanRemoval" * and "The owning side". */ @SpringBootTest class CascadeOrphanTest { private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); @Autowired private EntityManagerFactory emf; @BeforeEach void cleanTables() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); em.createQuery("DELETE FROM CascadeBook").executeUpdate(); em.createQuery("DELETE FROM CascadeAuthor").executeUpdate(); em.createQuery("DELETE FROM NoOrphanBook").executeUpdate(); em.createQuery("DELETE FROM NoOrphanAuthor").executeUpdate(); em.getTransaction().commit(); em.close(); } @Test void cascadeAllPlusOrphanRemoval_reassigningTheCollectionThrowsInsteadOfSilentlyDeleting() { // CORRECTION vs the common blog claim ("silently deletes what you did not expect"): // assigning a brand new List instance to a CascadeType.ALL + orphanRemoval=true // collection does NOT silently delete anything -- Hibernate detects the managed // collection was dereferenced and throws at flush/commit time instead. EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); CascadeAuthor author = new CascadeAuthor("Prolific Author"); em.persist(author); em.persist(new CascadeBook("Book A", author)); em.persist(new CascadeBook("Book B", author)); em.getTransaction().commit(); Long id = author.getId(); em.close(); EntityManager em2 = emf.createEntityManager(); em2.getTransaction().begin(); CascadeAuthor managed = em2.find(CascadeAuthor.class, id); CascadeBook keep = managed.getBooks().get(0); managed.replaceBooksWithNewList(new ArrayList<>(List.of(keep))); // assigns a NEW ArrayList jakarta.persistence.RollbackException ex = org.junit.jupiter.api.Assertions.assertThrows( jakarta.persistence.RollbackException.class, () -> em2.getTransaction().commit()); Throwable root = ex.getCause(); DEMO.info("cascade=ALL + orphanRemoval=true, reassigning the collection reference -- wrapper: {}", ex.getClass().getName()); DEMO.info("cascade=ALL + orphanRemoval=true, reassigning the collection reference -- root cause: {}: {}", root.getClass().getName(), root.getMessage()); em2.close(); assertThat(root.getClass().getName()).isEqualTo("org.hibernate.HibernateException"); assertThat(root.getMessage()).contains("no longer referenced by the owning entity instance"); } @Test void cascadeAllPlusOrphanRemoval_mutatingTheExistingCollectionDoesDeleteUnexpectedly() { // The scenario that DOES silently delete: mutating the SAME managed collection instance // (clear() then re-add the ones to keep) rather than reassigning a new List. This is // the realistic version of the "developer did not expect this" cascade trap. EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); CascadeAuthor author = new CascadeAuthor("Prolific Author 2"); em.persist(author); em.persist(new CascadeBook("Book A", author)); em.persist(new CascadeBook("Book B", author)); em.persist(new CascadeBook("Book C", author)); em.getTransaction().commit(); Long id = author.getId(); em.close(); EntityManager check1 = emf.createEntityManager(); long before = ((Number) check1.createQuery("SELECT COUNT(b) FROM CascadeBook b WHERE b.author.id = :id") .setParameter("id", id).getSingleResult()).longValue(); check1.close(); assertThat(before).isEqualTo(3); EntityManager em2 = emf.createEntityManager(); em2.getTransaction().begin(); CascadeAuthor managed = em2.find(CascadeAuthor.class, id); CascadeBook keep = managed.getBooks().stream().filter(b -> b.getTitle().equals("Book A")).findFirst().orElseThrow(); // Mutate the EXISTING Hibernate-managed collection in place -- this is the pattern that // actually reaches production: "keep only Book A" implemented via removeIf. managed.getBooks().removeIf(b -> !b.getTitle().equals("Book A")); em2.getTransaction().commit(); em2.close(); EntityManager check2 = emf.createEntityManager(); long after = ((Number) check2.createQuery("SELECT COUNT(b) FROM CascadeBook b WHERE b.author.id = :id") .setParameter("id", id).getSingleResult()).longValue(); DEMO.info("cascade=ALL + orphanRemoval=true, in-place removeIf(): books before={}, books after={}", before, after); check2.close(); assertThat(after) .as("orphanRemoval=true DELETEd Book B and Book C as soon as they left the managed collection") .isEqualTo(1); } @Test void noOrphanRemoval_removingFromCollectionAndFlushingDoesNothingToTheRow() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); NoOrphanAuthor author = new NoOrphanAuthor("Careful Author"); em.persist(author); NoOrphanBook book1 = new NoOrphanBook("Keep Me", author); NoOrphanBook book2 = new NoOrphanBook("Remove Me From Collection", author); em.persist(book1); em.persist(book2); em.getTransaction().commit(); Long authorId = author.getId(); Long book2Id = book2.getId(); em.close(); EntityManager em2 = emf.createEntityManager(); em2.getTransaction().begin(); NoOrphanAuthor managed = em2.find(NoOrphanAuthor.class, authorId); managed.getBooks().removeIf(b -> b.getId().equals(book2Id)); em2.getTransaction().commit(); // no orphanRemoval, no cascade REMOVE -- nothing should happen to book2's row em2.close(); EntityManager check = emf.createEntityManager(); NoOrphanBook stillThere = check.find(NoOrphanBook.class, book2Id); DEMO.info("orphanRemoval=false: after removing book2 from author.books and flushing, book2 row still exists = {}, author_id still = {}", stillThere != null, stillThere == null ? null : stillThere.getAuthor().getId()); check.close(); assertThat(stillThere) .as("without orphanRemoval or cascade REMOVE, removing a child from the collection does not delete or unlink its row") .isNotNull(); assertThat(stillThere.getAuthor().getId()) .as("its FK is untouched -- still points at the same author") .isEqualTo(authorId); } @Test void owningSide_mutatingOnlyTheInverseCollectionNeverWritesTheForeignKey() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); NoOrphanAuthor author1 = new NoOrphanAuthor("Author One"); NoOrphanAuthor author2 = new NoOrphanAuthor("Author Two"); em.persist(author1); em.persist(author2); NoOrphanBook orphanBook = new NoOrphanBook("Unassigned Book", null); em.persist(orphanBook); em.getTransaction().commit(); Long bookId = orphanBook.getId(); Long author2Id = author2.getId(); em.close(); // Mutate ONLY the inverse (mappedBy) side: add the book to author2's collection, but // never call book.setAuthor(author2) -- the collection is not the owning side. EntityManager em2 = emf.createEntityManager(); em2.getTransaction().begin(); NoOrphanAuthor managedAuthor2 = em2.find(NoOrphanAuthor.class, author2Id); NoOrphanBook managedBook = em2.find(NoOrphanBook.class, bookId); managedAuthor2.getBooks().add(managedBook); // inverse side only em2.getTransaction().commit(); em2.close(); EntityManager check = emf.createEntityManager(); NoOrphanBook reloaded = check.find(NoOrphanBook.class, bookId); DEMO.info("owning side test: mutated only author2.getBooks().add(book) (inverse side), book.author after flush = {}", reloaded.getAuthor() == null ? "null (FK not written)" : reloaded.getAuthor().getId()); check.close(); assertThat(reloaded.getAuthor()) .as("mutating only the inverse (mappedBy) collection never persists the association -- the owning side (Book.author) decides the FK") .isNull(); } }