package com.ankurm.hibernatedemo.pagination; import static org.assertj.core.api.Assertions.assertThat; import com.ankurm.hibernatedemo.HibernateDemoApplication; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.TypedQuery; import java.util.ArrayList; import java.util.List; import org.hibernate.ScrollMode; import org.hibernate.ScrollableResults; import org.hibernate.Session; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; /** * Proves chapter 23's pagination claims: {@code setFirstResult}/{@code setMaxResults} * translating to the dialect's real LIMIT/OFFSET syntax, {@code ScrollableResults} with * {@code ScrollMode.FORWARD_ONLY}, the in-memory-pagination fallback that a JOIN FETCH triggers * (and its real warning code), keyset/seek pagination as the fix for deep offsets, and the * total-count-query pattern for a "Page N of M" UI. * *

Docs: docs/23-pagination.md. */ @SpringBootTest(classes = HibernateDemoApplication.class) class PaginationTest { @Autowired private EntityManagerFactory emf; @Test void limitOffset_translatesToDialectSyntax() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); for (int i = 1; i <= 5; i++) { em.persist(new Article("LimitOffset-" + i, i)); } em.getTransaction().commit(); em.clear(); TypedQuery

query = em.createQuery( "select a from Article a where a.title like 'LimitOffset-%' order by a.sequence", Article.class); query.setFirstResult(2); query.setMaxResults(2); List
page = query.getResultList(); em.close(); List titles = page.stream().map(Article::getTitle).toList(); System.out.println("RESULT[pagination-limit-offset]: setFirstResult(2).setMaxResults(2) " + "over 5 rows ordered by sequence -- page contents: " + titles + " -- items 3 and 4 of 5, confirming the OFFSET skipped exactly 2 rows and the" + " LIMIT capped the page at exactly 2."); assertThat(titles).containsExactly("LimitOffset-3", "LimitOffset-4"); } @Test void scrollableResults_forwardOnly_readsWithoutLoadingWholeListUpfront() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); for (int i = 1; i <= 10; i++) { em.persist(new Article("Scroll-" + i, i)); } em.getTransaction().commit(); Session session = em.unwrap(Session.class); int seen = 0; List firstThree = new ArrayList<>(); try (ScrollableResults
results = session.createQuery( "select a from Article a where a.title like 'Scroll-%' order by a.sequence", Article.class) .setReadOnly(true) .scroll(ScrollMode.FORWARD_ONLY)) { while (results.next()) { Article a = results.get(); seen++; if (firstThree.size() < 3) { firstThree.add(a.getTitle()); } } } em.close(); System.out.println("RESULT[pagination-scrollable-forward-only]: ScrollMode.FORWARD_ONLY " + "walked all " + seen + " rows one at a time via results.next()/results.get() -- " + "first three encountered: " + firstThree + " -- no List
holding all 10 rows was ever built by this test's own" + " code, unlike getResultList()."); assertThat(seen).isEqualTo(10); assertThat(firstThree).containsExactly("Scroll-1", "Scroll-2", "Scroll-3"); } @Test void joinFetchOrderedByRoot_paginatesViaDerivedTable_noInMemoryFallback() { // Ordering by a column on the ROOT entity: Hibernate 7.4.5.Final's query translator // paginates the root ids first (a derived-table subquery with its own OFFSET/FETCH) and // joins the fetched collection onto that already-paginated set of ids -- confirmed by // reading the generated SQL below, not by assuming the older in-memory-fallback story // still applies here. EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); for (int i = 1; i <= 3; i++) { Article a = new Article("JoinFetch-" + i, i); a.addComment(new Comment("first comment on " + i)); a.addComment(new Comment("second comment on " + i)); em.persist(a); } em.getTransaction().commit(); em.clear(); TypedQuery
query = em.createQuery( "select distinct a from Article a join fetch a.comments " + "where a.title like 'JoinFetch-%' order by a.sequence", Article.class); query.setFirstResult(0); query.setMaxResults(2); List
page = query.getResultList(); em.close(); System.out.println("RESULT[pagination-joinfetch-root-order-no-warning]: join fetch + " + "setFirstResult/setMaxResults, ordered by a ROOT-entity column -- no " + "HHH90003004 warning was logged; the generated SQL (see the committed " + "transcript) paginates a derived subquery of article ids first, then joins the " + "comments onto that already-paginated set. Page size: " + page.size() + " distinct articles."); assertThat(page).hasSize(2); } @Test void joinFetchOrderedByCollectionColumn_fallsBackToInMemoryPagination() { // Ordering by a column that lives on the FETCHED COLLECTION itself: the "paginate the // root ids first" trick above can't work when the sort key isn't a root-entity column, // so this is the query shape that actually reproduces the classic in-memory-pagination // fallback and its real warning code. EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); for (int i = 1; i <= 3; i++) { Article a = new Article("JoinFetchOrder-" + i, i); a.addComment(new Comment("first comment on " + i)); a.addComment(new Comment("second comment on " + i)); em.persist(a); } em.getTransaction().commit(); em.clear(); TypedQuery
query = em.createQuery( "select distinct a from Article a join fetch a.comments c " + "where a.title like 'JoinFetchOrder-%' order by c.body", Article.class); query.setFirstResult(0); query.setMaxResults(2); List
page = query.getResultList(); em.close(); System.out.println("RESULT[pagination-joinfetch-collection-order-warning]: join fetch + " + "setFirstResult/setMaxResults, ordered by a column on the FETCHED COLLECTION " + "(c.body) -- Hibernate logs its own HHH90003004 warning (\"firstResult/" + "maxResults specified with collection fetch; applying in memory\"), not the " + "HHH000104 code sometimes quoted for this; that code belongs to a different, " + "older message entirely. Page size returned: " + page.size() + " distinct articles, computed by loading the full joined result set into " + "memory and paginating it there in application code."); assertThat(page).hasSize(2); } @Test void keysetPagination_avoidsOffsetEntirely() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); for (int i = 1; i <= 20; i++) { em.persist(new Article("Keyset-" + i, i)); } em.getTransaction().commit(); em.clear(); // Page 1: no keyset yet, so a sentinel "everything greater than the smallest possible id". List
firstPage = em.createQuery( "select a from Article a where a.title like 'Keyset-%' and a.id > :lastId " + "order by a.id asc", Article.class) .setParameter("lastId", 0L) .setMaxResults(5) .getResultList(); Long lastIdOfFirstPage = firstPage.get(firstPage.size() - 1).getId(); // Page 2: WHERE id > -- no OFFSET at all, so this stays O(page size) no // matter how deep into the result set the caller has paged. List
secondPage = em.createQuery( "select a from Article a where a.title like 'Keyset-%' and a.id > :lastId " + "order by a.id asc", Article.class) .setParameter("lastId", lastIdOfFirstPage) .setMaxResults(5) .getResultList(); em.close(); List firstTitles = firstPage.stream().map(Article::getTitle).toList(); List secondTitles = secondPage.stream().map(Article::getTitle).toList(); System.out.println("RESULT[pagination-keyset-seek]: keyset page 1 (id > 0) -- " + firstTitles + " | keyset page 2 (id > last id of page 1) -- " + secondTitles + " -- each page's WHERE clause carries the previous page's last id, so the " + "database never has to count-and-skip rows the way OFFSET does."); assertThat(firstTitles).containsExactly( "Keyset-1", "Keyset-2", "Keyset-3", "Keyset-4", "Keyset-5"); assertThat(secondTitles).containsExactly( "Keyset-6", "Keyset-7", "Keyset-8", "Keyset-9", "Keyset-10"); } @Test void totalCountQuery_forPageOfMPattern() { EntityManager em = emf.createEntityManager(); em.getTransaction().begin(); for (int i = 1; i <= 7; i++) { em.persist(new Article("CountPattern-" + i, i)); } em.getTransaction().commit(); em.clear(); long totalMatching = em.createQuery( "select count(a) from Article a where a.title like 'CountPattern-%'", Long.class) .getSingleResult(); int pageSize = 3; long totalPages = (totalMatching + pageSize - 1) / pageSize; List
pageTwo = em.createQuery( "select a from Article a where a.title like 'CountPattern-%' order by a.sequence", Article.class) .setFirstResult(pageSize) .setMaxResults(pageSize) .getResultList(); em.close(); List pageTwoTitles = pageTwo.stream().map(Article::getTitle).toList(); System.out.println("RESULT[pagination-total-count-pattern]: " + totalMatching + " matching rows, page size " + pageSize + " -> " + totalPages + " total pages ('Page 2 of " + totalPages + "') | page 2 contents: " + pageTwoTitles + " -- two separate queries (a COUNT and a LIMIT/OFFSET SELECT), not one query" + " doing both."); assertThat(totalMatching).isEqualTo(7); assertThat(totalPages).isEqualTo(3); assertThat(pageTwoTitles).containsExactly("CountPattern-4", "CountPattern-5", "CountPattern-6"); } }