package com.ankurm.hibernatedemo.association; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import java.util.List; 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 chapters "MultipleBagFetchException" * and "The cartesian-product trap". */ @SpringBootTest class BagFetchTest { 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 BagBookL").executeUpdate(); em.createQuery("DELETE FROM BagAwardL").executeUpdate(); em.createQuery("DELETE FROM BagAuthorList").executeUpdate(); em.createQuery("DELETE FROM BagBookS").executeUpdate(); em.createQuery("DELETE FROM BagAwardS").executeUpdate(); em.createQuery("DELETE FROM BagAuthorSet").executeUpdate(); em.getTransaction().commit(); em.close(); } private Statistics stats() { return emf.unwrap(SessionFactory.class).getStatistics(); } private Long seedListAuthor(int nBooks, int nAwards) { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); BagAuthorList author = new BagAuthorList("List Author"); em.persist(author); for (int i = 0; i < nBooks; i++) { em.persist(new BagBookL("Book" + i, author)); } for (int i = 0; i < nAwards; i++) { em.persist(new BagAwardL("Award" + i, author)); } em.getTransaction().commit(); Long id = author.getId(); em.close(); return id; } private Long seedSetAuthor(int nBooks, int nAwards) { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); BagAuthorSet author = new BagAuthorSet("Set Author"); em.persist(author); for (int i = 0; i < nBooks; i++) { em.persist(new BagBookS("Book" + i, author)); } for (int i = 0; i < nAwards; i++) { em.persist(new BagAwardS("Award" + i, author)); } em.getTransaction().commit(); Long id = author.getId(); em.close(); return id; } @Test void fetchJoiningTwoListsInOneQuery_throwsMultipleBagFetchException() { seedListAuthor(4, 3); EntityManager em = emf.createEntityManager(); IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> em.createQuery( "SELECT a FROM BagAuthorList a JOIN FETCH a.books JOIN FETCH a.awards", BagAuthorList.class).getResultList()); Throwable root = ex; while (root.getCause() != null && root.getCause() != root) { root = root.getCause(); } DEMO.info("MultipleBagFetchException reproduction -- wrapper class: {}", ex.getClass().getName()); DEMO.info("MultipleBagFetchException reproduction -- root cause class: {}", root.getClass().getName()); DEMO.info("MultipleBagFetchException reproduction -- verbatim message: {}", root.getMessage()); assertThat(ex.getClass().getName()).as("EntityManager.createQuery(...).getResultList() wraps it as IllegalArgumentException, NOT PersistenceException") .isEqualTo("java.lang.IllegalArgumentException"); assertThat(root.getClass().getName()).isEqualTo("org.hibernate.loader.MultipleBagFetchException"); em.close(); } @Test void fix1_useSetsInsteadOfLists_noExceptionOneQuery() { seedSetAuthor(4, 3); EntityManager em = emf.createEntityManager(); stats().clear(); List authors = em.createQuery( "SELECT DISTINCT a FROM BagAuthorSet a JOIN FETCH a.books JOIN FETCH a.awards", BagAuthorSet.class) .getResultList(); long queries = stats().getPrepareStatementCount(); DEMO.info("Fix #1 (Set instead of List): {} distinct authors returned, {} queries fired", authors.size(), queries); assertThat(authors).hasSize(1); assertThat(authors.get(0).getBooks()).hasSize(4); assertThat(authors.get(0).getAwards()).hasSize(3); assertThat(queries).isEqualTo(1); em.close(); } @Test void fix2_twoSeparateQueries_avoidsBagFetchExceptionEntirely() { Long id = seedListAuthor(4, 3); EntityManager em = emf.createEntityManager(); stats().clear(); BagAuthorList author = em.createQuery( "SELECT a FROM BagAuthorList a JOIN FETCH a.books WHERE a.id = :id", BagAuthorList.class) .setParameter("id", id) .getSingleResult(); // second, separate query for the other bag -- no exception because only one JOIN FETCH per query author = em.createQuery( "SELECT a FROM BagAuthorList a JOIN FETCH a.awards WHERE a.id = :id", BagAuthorList.class) .setParameter("id", id) .getSingleResult(); long queries = stats().getPrepareStatementCount(); DEMO.info("Fix #2 (two queries): {} queries fired, books={}, awards={}", queries, author.getBooks().size(), author.getAwards().size()); assertThat(author.getBooks()).hasSize(4); assertThat(author.getAwards()).hasSize(3); assertThat(queries).as("one JOIN FETCH per query, run twice").isEqualTo(2); em.close(); } @Test void cartesianProduct_fetchJoiningTwoAllowedSetsExplodesRowCount() { seedSetAuthor(4, 3); EntityManager em = emf.createEntityManager(); // Raw SQL join without DISTINCT to see the true row count the database returns. Object rawCount = em.createNativeQuery( "SELECT COUNT(*) FROM bag_author_set a " + "JOIN bag_book_s b ON b.author_id = a.id " + "JOIN bag_award_s w ON w.author_id = a.id") .getSingleResult(); long rawRowCount = ((Number) rawCount).longValue(); List entities = em.createQuery( "SELECT DISTINCT a FROM BagAuthorSet a JOIN FETCH a.books JOIN FETCH a.awards", BagAuthorSet.class) .getResultList(); DEMO.info("Cartesian product: 4 books x 3 awards for 1 author -> raw SQL join rows = {}, distinct entities returned = {}", rawRowCount, entities.size()); assertThat(rawRowCount).as("SQL returns one row per (book, award) pair: 4 x 3").isEqualTo(12); assertThat(entities).as("Hibernate's DISTINCT root-entity de-duplication collapses this back to 1 entity") .hasSize(1); assertThat(entities.get(0).getBooks()).hasSize(4); assertThat(entities.get(0).getAwards()).hasSize(3); em.close(); } }