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");
}
}