106 lines
5.0 KiB
Markdown
106 lines
5.0 KiB
Markdown
# 23 — Pagination
|
|
|
|
[← Previous: 22 — Sorting](22-sorting.md) | [Next: 24 — Interceptors →](24-interceptors.md) | [Back to README →](../README.md)
|
|
|
|
Backs the rewrite of ankurm.com post 4890 (pagination).
|
|
|
|
## `setFirstResult`/`setMaxResults` translate to the dialect's real syntax
|
|
|
|
```java
|
|
query.setFirstResult(2);
|
|
query.setMaxResults(2);
|
|
```
|
|
generates, on H2:
|
|
```sql
|
|
... order by a1_0.sequence offset ? rows fetch first ? rows only
|
|
```
|
|
Different dialects render this differently (`LIMIT ... OFFSET ...` on MySQL/PostgreSQL,
|
|
`OFFSET ... FETCH ...` on SQL Server/H2, `ROWNUM` tricks on older Oracle) — the JPA-level API is
|
|
the same everywhere; only the generated SQL shape changes.
|
|
[`PaginationTest.java`](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
|
[output](output/23-limit-offset.txt)
|
|
|
|
## `ScrollableResults` with `ScrollMode.FORWARD_ONLY`
|
|
|
|
```java
|
|
try (ScrollableResults<Article> results = session.createQuery(hql, Article.class)
|
|
.setReadOnly(true)
|
|
.scroll(ScrollMode.FORWARD_ONLY)) {
|
|
while (results.next()) {
|
|
Article a = results.get();
|
|
// process one row at a time
|
|
}
|
|
}
|
|
```
|
|
No `List<Article>` holding every row is built by application code — rows are pulled from the JDBC
|
|
`ResultSet` one at a time as `next()`/`get()` are called. Whether this actually avoids loading the
|
|
whole result set into memory server-side too depends on the JDBC driver's own fetch-size
|
|
behavior, not on this API alone.
|
|
[Test](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
|
[output](output/23-scrollable-forward-only.txt)
|
|
|
|
## `JOIN FETCH` + pagination: it depends on what you order by
|
|
|
|
The often-repeated claim is that `join fetch` over a `to-many` association combined with
|
|
`setFirstResult`/`setMaxResults` always falls back to loading everything into memory and
|
|
paginating in application code, logging a warning. Tested directly against
|
|
`hibernate-core-7.4.5.Final.jar`, this is only half true:
|
|
|
|
- **Ordering by a column on the root entity** (`order by a.sequence`): Hibernate 7.4.5's query
|
|
translator paginates a *derived subquery* of root ids first (its own `OFFSET`/`FETCH`), then
|
|
joins the fetched collection onto that already-paginated set of ids. No in-memory fallback, no
|
|
warning.
|
|
- **Ordering by a column on the fetched collection itself** (`order by c.body`, where `c` is the
|
|
joined collection alias): the "paginate the root ids first" trick can't work, because the sort
|
|
key isn't a root-entity column. This is the query shape that reproduces the real in-memory
|
|
fallback.
|
|
|
|
```sql
|
|
-- root-ordered: paginates a derived subquery of ids, then joins
|
|
select a1_0.id, c1_0.article_id, ... from (
|
|
select distinct a1_0.id, a1_0.sequence, a1_0.title from article a1_0
|
|
where ... order by a1_0.sequence offset ? rows fetch first ? rows only
|
|
) a1_0 join comment c1_0 on a1_0.id = c1_0.article_id order by a1_0.sequence
|
|
```
|
|
|
|
The warning's real message and code, read directly out of `QueryLogging.i18n.properties` in the
|
|
jar:
|
|
```
|
|
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
|
|
```
|
|
Not `HHH000104`, a code sometimes quoted for this that belongs to a different, older message
|
|
entirely — checked with `javap` against `QueryLogging_$logger.class`, not assumed from a search
|
|
result.
|
|
[Tests](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
|
[root-ordered output](output/23-joinfetch-root-order-no-warning.txt),
|
|
[collection-ordered output](output/23-joinfetch-collection-order-warning.txt)
|
|
|
|
## Keyset (seek) pagination
|
|
|
|
```sql
|
|
select a from Article a where a.id > :lastId order by a.id asc
|
|
```
|
|
with `setMaxResults(pageSize)` and no `setFirstResult` at all. 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 — the cost of fetching page 500 is the same as fetching page 1.
|
|
[Test](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
|
[output](output/23-keyset-seek.txt)
|
|
|
|
## The total-count-query pattern
|
|
|
|
A "Page 2 of 7" UI needs two separate queries — a `COUNT` and a `LIMIT`/`OFFSET` `SELECT` — not
|
|
one query doing both; SQL has no way to return a page of rows and the total matching count in a
|
|
single result set without a window function trick most codebases don't bother with for this.
|
|
[Test](../src/test/java/com/ankurm/hibernatedemo/pagination/PaginationTest.java) —
|
|
[output](output/23-total-count-pattern.txt)
|
|
|
|
## Going deeper
|
|
|
|
- Deep `OFFSET` pagination degrades because the database still has to *generate and discard*
|
|
every skipped row before reaching the page — keyset pagination sidesteps this entirely, at the
|
|
cost of not supporting arbitrary "jump to page N" navigation.
|
|
- `Slice`/`Page` abstractions (Spring Data) wrap the count-query pattern automatically; knowing
|
|
the two-query shape underneath explains why a `Pageable` with `unpaged()` sort still issues a
|
|
`COUNT`.
|
|
- [Hibernate ORM 7.4 User Guide — pagination](https://docs.jboss.org/hibernate/orm/7.4/userguide/html_single/Hibernate_User_Guide.html#pagination)
|