189 lines
8.9 KiB
Java
Executable File
189 lines
8.9 KiB
Java
Executable File
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();
|
|
}
|
|
}
|