package com.ankurm.hibernatedemo; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import com.ankurm.hibernatedemo.model.Book; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.EntityNotFoundException; import java.util.HashSet; import java.util.Set; import org.hibernate.LazyInitializationException; import org.hibernate.Session; import org.hibernate.SessionFactory; import org.hibernate.stat.Statistics; 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 4859. Docs: docs/01-get-vs-load.md. * *

Run with {@code ./mvnw -Dtest=GetVsGetReferenceTest test}. Every assertion here was first * observed by running the same code and reading the log, then pinned down as an assertion -- * none of the outcomes below were assumed going in. */ @SpringBootTest class GetVsGetReferenceTest { private static final Logger DEMO = LoggerFactory.getLogger("DEMO"); @Autowired private EntityManagerFactory emf; private Long seedBook(String title) { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Book book = new Book(title, "Test Author"); em.persist(book); em.getTransaction().commit(); Long id = book.getId(); em.close(); return id; } private Statistics stats() { return emf.unwrap(SessionFactory.class).getStatistics(); } // ---- Part 1: four calls, four outcomes ---- @Test void getOnExistingId_firesSelect_returnsRealEntity() { Long id = seedBook("Effective Java"); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); stats().clear(); Book book = session.get(Book.class, id); assertThat(stats().getPrepareStatementCount()).as("get() on an existing id must fire a SELECT").isEqualTo(1); assertThat(book).isNotNull(); assertThat(book.getClass()).isEqualTo(Book.class); em.getTransaction().commit(); em.close(); } @Test void getOnMissingId_firesSelect_returnsNull() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); stats().clear(); Book book = session.get(Book.class, 999_111_222L); assertThat(stats().getPrepareStatementCount()).as("get() on a missing id still fires a SELECT").isEqualTo(1); assertThat(book).isNull(); em.getTransaction().commit(); em.close(); } @Test void getReferenceOnExistingId_noSelectUntilPropertyAccessed() { Long id = seedBook("Domain-Driven Design"); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); stats().clear(); Book proxy = session.getReference(Book.class, id); assertThat(stats().getPrepareStatementCount()) .as("getReference() must not fire a SELECT at the call site") .isEqualTo(0); String title = proxy.getTitle(); assertThat(stats().getPrepareStatementCount()) .as("the SELECT is deferred until a non-id accessor is called") .isEqualTo(1); assertThat(title).isEqualTo("Domain-Driven Design"); em.getTransaction().commit(); em.close(); } @Test void getReferenceOnMissingId_noExceptionUntilAccessed() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); Book proxy = session.getReference(Book.class, 999_333_444L); // No exception yet -- constructing the proxy never touched the database. assertThat(proxy).isNotNull(); EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, proxy::getTitle); DEMO.info("getReference() on a missing id, once accessed, threw: {}: {}", ex.getClass().getName(), ex.getMessage()); em.getTransaction().rollback(); em.close(); } // ---- Part 2: same-session matrix ---- // For each combination, both calls target the SAME id in the SAME session. @Test void sessionMatrix_getThenGet_secondCallHitsL1Cache_sameInstance() { Long id = seedBook("Matrix: get/get"); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); Book first = session.get(Book.class, id); stats().clear(); Book second = session.get(Book.class, id); assertThat(stats().getPrepareStatementCount()) .as("second get() in the same session must NOT re-fire a SELECT (L1 cache hit)") .isEqualTo(0); assertThat(second).isSameAs(first); em.getTransaction().commit(); em.close(); } @Test void sessionMatrix_getReferenceThenGetReference_secondCallHitsL1Cache_sameInstance() { Long id = seedBook("Matrix: getReference/getReference"); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); Book first = session.getReference(Book.class, id); stats().clear(); Book second = session.getReference(Book.class, id); assertThat(stats().getPrepareStatementCount()) .as("second getReference() in the same session must not fire anything either -- still just a reference") .isEqualTo(0); assertThat(second).isSameAs(first); em.getTransaction().commit(); em.close(); } @Test void sessionMatrix_getThenGetReference_returnsTheSameAlreadyInitializedInstance() { Long id = seedBook("Matrix: get/getReference"); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); Book real = session.get(Book.class, id); stats().clear(); Book second = session.getReference(Book.class, id); assertThat(stats().getPrepareStatementCount()) .as("getReference() after get() must not fire a SELECT -- the real entity is already in the L1 cache") .isEqualTo(0); assertThat(second) .as("getReference() returns the SAME already-managed real instance, not a new proxy, once one exists in this session") .isSameAs(real); assertThat(second.getClass()).isEqualTo(Book.class); em.getTransaction().commit(); em.close(); } @Test void sessionMatrix_getReferenceThenGet_getReturnsTheExistingProxyAndDoesNotForceInitialization() { Long id = seedBook("Matrix: getReference/get"); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Session session = em.unwrap(Session.class); Book proxy = session.getReference(Book.class, id); stats().clear(); Book second = session.get(Book.class, id); assertThat(second) .as("get() after getReference() returns the SAME proxy already sitting in the L1 cache") .isSameAs(proxy); DEMO.info("get() after getReference(): prepareStatementCount for this call = {}, returned class = {}", stats().getPrepareStatementCount(), second.getClass().getName()); em.getTransaction().commit(); em.close(); } // ---- Part 3: proxy identity experiment ---- @Test void proxyIdentity_instanceofSurvives_equalsDoesNot() { Long id = seedBook("Proxy Identity"); EntityManager em1 = emf.createEntityManager(); em1.getTransaction().begin(); Book real = em1.unwrap(Session.class).get(Book.class, id); EntityManager em2 = emf.createEntityManager(); em2.getTransaction().begin(); Book proxy = em2.unwrap(Session.class).getReference(Book.class, id); assertThat(proxy).isInstanceOf(Book.class); assertThat(org.hibernate.Hibernate.getClass(proxy)).isEqualTo(Book.class); assertThat(proxy.getClass()).isNotEqualTo(Book.class); // Book does not override equals()/hashCode() -- this is the point of the test. assertThat(real.equals(proxy)).isFalse(); assertThat(proxy.equals(real)).isFalse(); Set set = new HashSet<>(); set.add(real); assertThat(set.contains(proxy)) .as("a HashSet built on default equals()/hashCode() cannot recognise the proxy and the real instance as the same row") .isFalse(); em1.getTransaction().commit(); em1.close(); em2.getTransaction().commit(); em2.close(); } @Test void proxyOutlivesItsSession_throwsLazyInitializationExceptionOnAccess() { Long id = seedBook("Outlives Session"); EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); Book proxy = em.unwrap(Session.class).getReference(Book.class, id); em.getTransaction().commit(); em.close(); assertThrows(LazyInitializationException.class, proxy::getTitle); } }