Add Hibernate 7 batches 2-6, batch 7, and batch 8: mapping styles, JPA annotations, natural IDs, @Immutable, stored procedures, in-memory test databases, JNDI mocking, proxies, associations, temporal mapping, named queries, HQL, Criteria API, EntityManager bootstrapping, Ehcache 3 L2 cache configuration, HikariCP connection pooling, Hibernate Validator CDI integration, aggregate functions, sorting, pagination, interceptors, and Hibernate Search 8 (Hibernate 7.4.5.Final + Spring Boot 4.1.1 + JDK 25)
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
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.
|
||||
*
|
||||
* <p>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<Article> 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<Article> page = query.getResultList();
|
||||
em.close();
|
||||
|
||||
List<String> 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<String> firstThree = new ArrayList<>();
|
||||
try (ScrollableResults<Article> 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<Article> 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<Article> 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<Article> 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<Article> 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<Article> 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<Article> 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 > <last id seen> -- no OFFSET at all, so this stays O(page size) no
|
||||
// matter how deep into the result set the caller has paged.
|
||||
List<Article> 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<String> firstTitles = firstPage.stream().map(Article::getTitle).toList();
|
||||
List<String> 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<Article> 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<String> 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user