package com.ankurm.hibernatedemo.association;
import static org.assertj.core.api.Assertions.assertThat;
import jakarta.persistence.EntityGraph;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.TypedQuery;
import java.util.List;
import java.util.Map;
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;
import org.springframework.transaction.annotation.Transactional;
/**
* Backs ankurm.com post 4872, docs/12-association-mappings.md chapter "Counting the N+1".
*
*
Seeds 100 authors with 3 books each (301 rows total incl. authors) and counts real
* {@code Statistics.getPrepareStatementCount()} for four strategies: naive lazy iteration,
* JPQL fetch join, {@code @EntityGraph}, and {@code @BatchSize(10)}. Run with
* {@code mvn -Dtest=NPlusOneTest test}.
*/
@SpringBootTest
class NPlusOneTest {
private static final Logger DEMO = LoggerFactory.getLogger("DEMO");
private static final int AUTHOR_COUNT = 100;
private static final int BOOKS_PER_AUTHOR = 3;
@Autowired
private EntityManagerFactory emf;
@BeforeEach
void cleanTables() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
em.createQuery("DELETE FROM AssocBook").executeUpdate();
em.createQuery("DELETE FROM AssocAuthor").executeUpdate();
em.createQuery("DELETE FROM BatchBook").executeUpdate();
em.createQuery("DELETE FROM BatchAuthor").executeUpdate();
em.getTransaction().commit();
em.close();
}
private Statistics stats() {
return emf.unwrap(SessionFactory.class).getStatistics();
}
private void seedPlainAuthors() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int a = 0; a < AUTHOR_COUNT; a++) {
AssocAuthor author = new AssocAuthor("Author-" + a);
em.persist(author);
for (int b = 0; b < BOOKS_PER_AUTHOR; b++) {
em.persist(new AssocBook("Book-" + a + "-" + b, author));
}
}
em.getTransaction().commit();
em.close();
}
private void seedBatchAuthors() {
EntityManager em = emf.createEntityManager();
em.getTransaction().begin();
for (int a = 0; a < AUTHOR_COUNT; a++) {
BatchAuthor author = new BatchAuthor("BatchAuthor-" + a);
em.persist(author);
for (int b = 0; b < BOOKS_PER_AUTHOR; b++) {
em.persist(new BatchBook("BatchBook-" + a + "-" + b, author));
}
}
em.getTransaction().commit();
em.close();
}
@Test
void naiveLazyIteration_firesOneQueryPerAuthor_theClassicNPlusOne() {
seedPlainAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List authors = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class).getResultList();
long booksTouched = 0;
for (AssocAuthor author : authors) {
booksTouched += author.getBooks().size(); // triggers one SELECT per author
}
long queries = stats().getPrepareStatementCount();
DEMO.info("NAIVE lazy iteration: {} authors, {} queries (1 for authors + {} for their book collections), books touched = {}",
authors.size(), queries, authors.size(), booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries)
.as("1 query to load authors + 1 per author to lazily load its books == N+1")
.isEqualTo(1 + AUTHOR_COUNT);
em.close();
}
@Test
void jpqlFetchJoin_firesExactlyOneQuery() {
seedPlainAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List authors = em.createQuery(
"SELECT DISTINCT a FROM AssocAuthor a JOIN FETCH a.books", AssocAuthor.class)
.getResultList();
long booksTouched = 0;
for (AssocAuthor author : authors) {
booksTouched += author.getBooks().size(); // already initialised, no extra SELECT
}
long queries = stats().getPrepareStatementCount();
DEMO.info("JPQL JOIN FETCH: {} authors, {} queries, books touched = {}", authors.size(), queries, booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries).as("fetch join collapses N+1 into a single query").isEqualTo(1);
em.close();
}
@Test
void entityGraph_firesExactlyOneQuery() {
seedPlainAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
EntityGraph> graph = em.getEntityGraph("AssocAuthor.books");
TypedQuery query = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class);
query.setHint("jakarta.persistence.fetchgraph", graph);
List authors = query.getResultList();
long booksTouched = 0;
for (AssocAuthor author : authors) {
booksTouched += author.getBooks().size();
}
long queries = stats().getPrepareStatementCount();
DEMO.info("@EntityGraph (fetchgraph hint): {} authors, {} queries, books touched = {}", authors.size(), queries, booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries).as("entity graph fetch also collapses to a single query").isEqualTo(1);
em.close();
}
@Test
void batchSize10_collapsesNPlusOneIntoCeilNOverBatchSizePlusOne() {
seedBatchAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List authors = em.createQuery("SELECT a FROM BatchAuthor a", BatchAuthor.class).getResultList();
long booksTouched = 0;
for (BatchAuthor author : authors) {
booksTouched += author.getBooks().size(); // triggers 1 SELECT per 10 authors (batch fetch)
}
long queries = stats().getPrepareStatementCount();
// ceil(100 / 10) = 10 batch selects + 1 for the authors themselves = 11
long expected = 1 + (long) Math.ceil(AUTHOR_COUNT / 10.0);
DEMO.info("@BatchSize(10): {} authors, {} queries (expected {} = 1 + ceil({}/10)), books touched = {}",
authors.size(), queries, expected, AUTHOR_COUNT, booksTouched);
assertThat(authors).hasSize(AUTHOR_COUNT);
assertThat(queries).as("BatchSize(10) turns 101 queries into ceil(100/10)+1 = 11").isEqualTo(expected);
em.close();
}
@Test
void printSideBySideComparisonTable() {
// Re-derive each number in one place so the article table and the assertions above can
// never silently drift apart.
seedPlainAuthors();
seedBatchAuthors();
EntityManager em = emf.createEntityManager();
stats().clear();
List naive = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class).getResultList();
naive.forEach(a -> a.getBooks().size());
long naiveQ = stats().getPrepareStatementCount();
stats().clear();
List fetchJoin = em.createQuery(
"SELECT DISTINCT a FROM AssocAuthor a JOIN FETCH a.books", AssocAuthor.class)
.getResultList();
fetchJoin.forEach(a -> a.getBooks().size());
long fetchJoinQ = stats().getPrepareStatementCount();
stats().clear();
EntityGraph> graph = em.getEntityGraph("AssocAuthor.books");
TypedQuery egQuery = em.createQuery("SELECT a FROM AssocAuthor a", AssocAuthor.class);
egQuery.setHint("jakarta.persistence.fetchgraph", graph);
List eg = egQuery.getResultList();
eg.forEach(a -> a.getBooks().size());
long egQ = stats().getPrepareStatementCount();
stats().clear();
List batched = em.createQuery("SELECT a FROM BatchAuthor a", BatchAuthor.class).getResultList();
batched.forEach(a -> a.getBooks().size());
long batchQ = stats().getPrepareStatementCount();
DEMO.info("=== Side-by-side query counts for {} authors x {} books each ===", AUTHOR_COUNT, BOOKS_PER_AUTHOR);
DEMO.info("naive lazy iteration : {} queries", naiveQ);
DEMO.info("JPQL JOIN FETCH : {} queries", fetchJoinQ);
DEMO.info("@EntityGraph : {} queries", egQ);
DEMO.info("@BatchSize(10) : {} queries", batchQ);
assertThat(Map.of("naive", naiveQ, "fetchJoin", fetchJoinQ, "entityGraph", egQ, "batch10", batchQ))
.isEqualTo(Map.of("naive", 101L, "fetchJoin", 1L, "entityGraph", 1L, "batch10", 11L));
em.close();
}
}