package com.ankurm.hibernatedemo; import static org.assertj.core.api.Assertions.assertThat; import com.ankurm.hibernatedemo.model.Book; import com.ankurm.hibernatedemo.model.Note; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import org.hibernate.Hibernate; import org.hibernate.Session; 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 4860. Docs: docs/02-merge-vs-refresh.md. * Run with {@code ./mvnw -Dtest=MergeRefreshTest test}. * *
The optimistic-lock-conflict case lives in {@link OptimisticLockTest} instead, since it * needs its own careful narration of exactly when the check fires. */ @SpringBootTest class MergeRefreshTest { private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); @Autowired private EntityManagerFactory emf; private Long seedBook(String title, String status) { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Book book = new Book(title, "Test Author"); book.setStatus(status); em.persist(book); em.getTransaction().commit(); Long id = book.getId(); em.close(); return id; } @Test void mergeOfDetachedInstance_returnsTheSameManagedInstanceAlreadyInSession() { Long id = seedBook("Managed + Detached", "DRAFT"); // A separate, already-detached copy of the same row (simulates "the object a controller // method was handed earlier"). EntityManager scratch = emf.createEntityManager(); scratch.getTransaction().begin(); Book detached = scratch.unwrap(Session.class).get(Book.class, id); scratch.getTransaction().commit(); scratch.close(); detached.setAuthor("Changed On The Detached Copy"); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); // This session already has ITS OWN managed instance for the same row before merge() is // ever called. Book managed = session.get(Book.class, id); Book result = session.merge(detached); assertThat(result) .as("merge() must return the identity-equal MANAGED instance already tracked by this session, not a new object") .isSameAs(managed); assertThat(result).isNotSameAs(detached); assertThat(managed.getAuthor()) .as("the pre-existing managed instance is the one that actually receives the copied state") .isEqualTo("Changed On The Detached Copy"); em.getTransaction().commit(); em.close(); } @Test void mergeWithCascadeInitializesTheLazyCollectionAnyway() { Long id; EntityManager seed = emf.createEntityManager(); seed.getTransaction().begin(); Book book = new Book("Lazy Collection Book", "Test Author"); seed.persist(book); seed.flush(); seed.persist(new Note("first note", book)); seed.getTransaction().commit(); id = book.getId(); seed.close(); // Load and detach WITHOUT ever touching book.getNotes() -- the collection proxy is never // initialized. EntityManager em1 = emf.createEntityManager(); em1.getTransaction().begin(); Book detached = em1.unwrap(Session.class).get(Book.class, id); assertThat(Hibernate.isInitialized(detached.getNotes())) .as("sanity check: the collection must still be uninitialized going into detachment") .isFalse(); em1.getTransaction().commit(); em1.close(); detached.setAuthor("Edited While Detached"); EntityManager em2 = emf.createEntityManager(); em2.getTransaction().begin(); Session session = em2.unwrap(Session.class); // Going in, the expectation was "merge() doesn't need to touch a collection it was // never asked to load." That's true ONLY when the collection has no CascadeType.MERGE. // Book.notes DOES cascade MERGE (see its Javadoc), and the measured result is the // opposite of the naive expectation: merge() initializes the collection anyway, because // cascading the merge to each element requires knowing what those elements are. Removing // cascade = CascadeType.MERGE from Book.notes and re-running this test flips the result // back to "stays uninitialized" -- confirmed with a throwaway probe before writing this // assertion. See docs/02-merge-vs-refresh.md. Book merged = session.merge(detached); assertThat(Hibernate.isInitialized(merged.getNotes())) .as("merge() DOES initialize a LAZY collection when it cascades MERGE to it -- cascading requires traversal") .isTrue(); assertThat(merged.getAuthor()).isEqualTo("Edited While Detached"); em2.getTransaction().commit(); em2.close(); DEMO.info("merge() initialized the LAZY notes collection because CascadeType.MERGE forces traversal of it"); } @Test void refreshDiscardsUnflushedEditSilently_noExceptionEver() { Long id = seedBook("Silent Overwrite", "USER_EDIT"); // Simulate an admin process changing the row out from under the in-memory object. EntityManager admin = emf.createEntityManager(); admin.getTransaction().begin(); Book row = admin.unwrap(Session.class).get(Book.class, id); row.setStatus("ADMIN_EDIT"); admin.getTransaction().commit(); admin.close(); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); Book managed = session.get(Book.class, id); assertThat(managed.getStatus()).isEqualTo("ADMIN_EDIT"); // A local, unflushed edit -- never sent to the database. managed.setStatus("USER_EDIT"); assertThat(managed.getStatus()).isEqualTo("USER_EDIT"); session.refresh(managed); assertThat(managed.getStatus()) .as("refresh() replaces managed state with the database row -- it does not merge the two; the local edit is simply gone, no exception") .isEqualTo("ADMIN_EDIT"); em.getTransaction().commit(); em.close(); } }