navree

Pagination

Offset pages in Laravel's {data, meta} shape with PageQuery, paginate and Page, your own page parameters, and cursor pagination over UUID v7 keys.

On this page

Lists should come back a page at a time. navree-db gives you two kinds of pagination:

  • Offset pagination, ?page=2&per_page=15, with the total and the number of pages, in the response shape Laravel uses. Use it for tables a user pages through.
  • Cursor pagination, ?cursor=...&limit=15, which only knows "the next page". Use it for feeds and infinite scrolling, where rows arrive while people read.

Paginating a query

Take a PageQuery in the handler, build an ordered SeaORM query, and pass both to paginate with a connection:

src/modules/deals/service.rs
src/modules/deals/controller.rs

Page<T> is a response, so the handler returns it as it is. PageQuery and Page are in the prelude; paginate is navree::db::paginate.

paginate runs two queries: a COUNT(*) for the total and the page itself with LIMIT and OFFSET. Run it on the unit of work's transaction, as above. On SQLite that keeps the total and the page consistent, because no other writer can commit while the unit of work holds the write lock. PostgreSQL transactions run at its default READ COMMITTED level, where each query sees the latest commits, so a write committed between the two queries can still make them disagree.

Always order the query, and end the order with a unique column such as the id. Without a stable order, the database may return rows in a different order for each page, and a row can appear twice or not at all while someone pages through.

The response

GET /api/v1/deals?page=2&per_page=2 answers:

JSON
FieldWhat it holds
dataThe records of this page.
meta.current_pageThe page number, from 1.
meta.per_pageThe page size actually used.
meta.totalHow many records match, across all pages.
meta.last_pageThe number of the last page. At least 1, so an empty list has one empty page.

A page past the end isn't an error: it has an empty data and the true total and last_page, and paginate doesn't query for its rows. This holds for any page number a client can send, however large.

Page parameters

As an extractor, PageQuery reads page and per_page from the query string:

ParameterDefaultRules
page1A positive integer.
per_page15 (DEFAULT_PER_PAGE)A positive integer. Values above 100 (MAX_PER_PAGE) are lowered to 100.

A value that isn't a positive integer is a 400, with a message per field:

JSON

PageQuery ignores every other query parameter, so a handler can take your filters as well:

Rust

In code, PageQuery::new(page, per_page) builds one with the same limits (a page below 1 becomes 1, a size outside 1..=100 is clamped), PageQuery::default() is page 1 of 15, and offset() is the number of rows before the page.

Your own page parameters

PageQuery lowers a per_page above 100 rather than rejecting it, and defaults to 15. When your API needs different rules, validate the parameters yourself and build the PageQuery from them. The benchmark app does this because its specification asks for a 400 above 100 and a default of 20:

src/modules/deals/dto.rs
src/modules/deals/controller.rs

A failed rule is a 400 in the same format, from validation. PageQuery::new still caps per_page at 100, so raising the limit above 100 isn't possible with paginate.

Shaping the items

page.map(f) converts every item and keeps meta, which is how you turn models into the resources your API returns (above, DealResource::from). When the conversion needs the database, for example to embed each deal's company, take the page apart and put it back together:

Rust

load_many here is the CRM's: it loads every embed for the page in one query per kind of embed, as described in eager loading.

When the rows don't come from a SeaORM Select (raw SQL, another service), build the page yourself with Page::new(data, query, total), which computes meta from the query and the total.

Cursor pagination

Offset pages shift when rows are added: a new deal at the top pushes the last deal of page 1 onto page 2, and a user paging through sees it twice. A cursor page continues after the last row the client saw instead. Navree's cursor pagination pages by a UUID v7 column, usually the primary key, whose order is creation order:

src/modules/deals/controller.rs
JSON

The client asks for the next page with ?cursor=AaF3WVLNcrSxBfEWWAWzVA. On the last page, next_cursor is null.

  • CursorQuery reads cursor (absent for the first page) and limit (default 15, at most 100). A malformed cursor or a limit that isn't a positive integer is a 400.
  • cursor_paginate returns the oldest rows first, cursor_paginate_desc the newest first.
  • The query may filter but must not be ordered: the function orders it by the column.
  • The column must hold UUIDs; any other type fails with a DbErr.
  • There is no total and no previous page. The cursor is the last row's id, encoded; treat it as opaque.

Documenting pages in OpenAPI

Page<T> doesn't implement utoipa's ToSchema, so the OpenAPI document can't describe it by itself. The CRM declares a response type per list, with a From<Page<..>> conversion:

src/modules/companies/resource.rs

PageInfo is the CRM's copy of PageMeta with a ToSchema derive (see apps/crm-api/crates/crm-companies/src/common.rs).

Testing

Assert on meta and on the items in one chain:

Rust

See HTTP tests for the assertions.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks