Pagination

Pagination

On this page
let page = posts.paginate(1, 20).await?;
let page = posts.paginate_matching(criteria, 1, 20).await?;

Both return Paginated<T> — the rows, plus the counts a page needs.

pub struct Paginated<T> {
    pub data: Vec<T>,
    pub total: u64,
    pub page: u64,
    pub per_page: u64,
}

Two queries run: a COUNT with the criteria's filters but not its paging, and a SELECT with both.

What it computes

page.last_page();          // total pages, at least 1
page.from();               // Option<u64> — 1-based index of the first row
page.to();                 // Option<u64> — …and the last
page.has_more_pages();
page.next_page();          // Option<u64>
page.previous_page();      // Option<u64>
page.offset();             // the SQL offset this page used
page.len();                // rows on this page
page.is_empty();

from and to are Option because an empty result has neither — rendering "Showing 1 to 0 of 0" is a bug, and this makes it one you cannot write by accident.

Serialising

Paginated<T> is Serialize, so an API endpoint is one line:

pub async fn index(Validated(query): Validated<ListPostsRequest>) -> Result<Response> {
    let posts = resolve::<PostRepository>()?;
    let page = posts.published_page(query.page, query.per_page, query.search.as_deref()).await?;

    Ok(Response::json(&page))
}
{
  "data": [ … ],
  "total": 137,
  "page": 2,
  "per_page": 20
}

The computed values are methods rather than fields, so the payload stays the four facts a client needs to compute the rest — and stays stable if the computations change.

Transforming

map keeps the counts and changes the rows, which is how a model becomes a view struct without rebuilding the page:

let page = posts.paginate(1, 20).await?.map(PublicPost::from);

That is the answer to serialising a model with a secret in it.

Validating the page parameters

Page numbers come from the client, so they belong in a request contract:

#[derive(Deserialize)]
pub struct ListPostsRequest {
    pub page: u64,
    pub per_page: u64,
    pub search: Option<String>,
}

#[async_trait]
impl FormRequest for ListPostsRequest {
    fn rules() -> RuleSet {
        vec![
            field("page", [Rule::Integer, Rule::Min(1.0)]),
            field("per_page", [Rule::Integer, Rule::Between(1.0, 100.0)]),
            field("search", [Rule::String, Rule::Max(100.0)]),
        ]
    }

    fn from_input(input: Value) -> Result<Self> {
        Ok(Self {
            page: input.get("page").and_then(|v| v.as_u64()).unwrap_or(1),
            per_page: input.get("per_page").and_then(|v| v.as_u64()).unwrap_or(20),
            search: input.get("search").and_then(|v| v.as_str()).map(str::to_string),
        })
    }
}

Note the Between(1, 100) on per_page. Without an upper bound, a client asking for per_page=1000000 is a denial-of-service against your own database — and it is the first thing anyone tries.

The custom from_input supplies defaults for absent parameters, which is the case where the derived deserialisation is not what you want.

Building one directly

Paginated::new(rows, total, page, per_page);
Paginated::empty(page, per_page);

Useful when the rows come from somewhere other than a repository — a search index, a cache, an upstream API.

Cursor pagination

Rainier does not ship it. For a large, frequently-written table, offset paging gets slower as the offset grows and can skip or repeat rows when the data changes underneath a user.

Use a keyset query, which Criteria expresses directly:

let next = posts.matching(
    Criteria::new()
        .where_lt("created_at", cursor)
        .order_by_desc("created_at")
        .limit(20),
).await?;

Rainier ORM also has a cursor API, reachable through Database because it implements Executor.

This page is the framework’s docs/pagination.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.