package com.ankurm.hibernatedemo.association; import static org.assertj.core.api.Assertions.assertThat; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import org.hibernate.SessionFactory; import org.hibernate.stat.Statistics; 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 chapter "The @OneToOne lazy trap". * *
{@link LazyUser#getProfile()} is the non-owning ("mappedBy") side of an optional * {@code @OneToOne}, declared {@code FetchType.LAZY}. Without bytecode enhancement, Hibernate * cannot build a lazy proxy for it (it has no FK column to defer against), so it fires an * eager extra SELECT regardless of the annotation. {@link MiUser}/{@link MiProfile} show the * {@code @MapsId} fix: don't map the inverse side at all, look the child up on demand by the * shared primary key. */ @SpringBootTest class OneToOneLazyTest { 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 LazyProfile").executeUpdate(); em.createQuery("DELETE FROM LazyUser").executeUpdate(); em.createQuery("DELETE FROM MiProfile").executeUpdate(); em.createQuery("DELETE FROM MiUser").executeUpdate(); em.getTransaction().commit(); em.close(); } private Statistics stats() { return emf.unwrap(SessionFactory.class).getStatistics(); } @Test void mappedBySideOneToOne_firesExtraSelectEvenThoughItIsDeclaredLazy() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); LazyUser user = new LazyUser("alice"); em.persist(user); LazyProfile profile = new LazyProfile("bio text", user); user.setProfile(profile); em.persist(profile); em.getTransaction().commit(); Long id = user.getId(); em.close(); EntityManager em2 = emf.createEntityManager(); stats().clear(); LazyUser loaded = em2.find(LazyUser.class, id); long queriesAfterFind = stats().getPrepareStatementCount(); DEMO.info("LazyUser.find(): {} queries fired BEFORE touching getProfile() at all (expected 2: user + eager profile join/select)", queriesAfterFind); // Note: the extra SELECT for profile already happened during find(), not here. String bio = loaded.getProfile() == null ? null : "loaded"; long queriesAfterAccess = stats().getPrepareStatementCount(); DEMO.info("after touching getProfile(): {} queries total (profile={})", queriesAfterAccess, bio); assertThat(queriesAfterFind) .as("mappedBy @OneToOne(LAZY) still fires the profile SELECT immediately during find(), not on first access") .isEqualTo(2); assertThat(queriesAfterAccess).as("no further query needed -- it already ran eagerly").isEqualTo(2); em2.close(); } @Test void mapsIdFix_loadingUserAloneFiresOnlyOneQuery_profileFetchedOnDemand() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); MiUser user = new MiUser("bob"); em.persist(user); em.flush(); // need the generated id before building the shared-PK child MiProfile profile = new MiProfile("bob's bio", user); em.persist(profile); em.getTransaction().commit(); Long id = user.getId(); em.close(); EntityManager em2 = emf.createEntityManager(); stats().clear(); MiUser loaded = em2.find(MiUser.class, id); long queriesForUserOnly = stats().getPrepareStatementCount(); DEMO.info("MiUser.find() (no mappedBy field at all): {} query", queriesForUserOnly); // Fetch profile ONLY when actually needed, using the shared PK -- no proxy required. MiProfile profileOnDemand = em2.find(MiProfile.class, id); long queriesAfterExplicitProfileFetch = stats().getPrepareStatementCount(); DEMO.info("explicit MiProfile.find() by shared PK when actually needed: {} total queries", queriesAfterExplicitProfileFetch); assertThat(loaded).isNotNull(); assertThat(queriesForUserOnly) .as("with @MapsId and no inverse mappedBy field, loading the user alone costs exactly 1 query") .isEqualTo(1); assertThat(profileOnDemand.getId()).isEqualTo(id); assertThat(queriesAfterExplicitProfileFetch) .as("fetching the profile only happens when the code actually asks for it") .isEqualTo(2); em2.close(); } }