116 lines
4.9 KiB
Java
Executable File
116 lines
4.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 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".
|
|
*
|
|
* <p>{@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();
|
|
}
|
|
}
|