HTTP Filters & Pagination
Problem: a GET /users endpoint takes optional query parameters — ?status=active&role=admin&role=editor&page=2&per_page=20 — and must turn them into one SQL query. Each filter applies only when present, pagination must be safe against hostile values, and a malformed query must become an error response, never a panic.
This recipe is the HTTP-shaped version of Dynamic Building: when toggles each filter, paginate handles the page window, and the sqlx helpers return the unified Error so nothing in the handler can panic.
axum is used for illustration only — it is not a dependency of chain-builder. The builder calls are identical under any framework.
The parameters
use serde::Deserialize;
#[derive(Deserialize)]
struct ListParams {
/// Exact-match filter, applied only when present.
status: Option<String>,
/// Repeated parameter (`?role=admin&role=editor`) → `WHERE role IN (…)`.
#[serde(default)]
role: Vec<String>,
/// 1-based page number.
#[serde(default = "default_page")]
page: i64,
#[serde(default = "default_per_page")]
per_page: i64,
}
fn default_page() -> i64 { 1 }
fn default_per_page() -> i64 { 20 }The builder chain
Build the filtered query once, then fork it (QueryBuilder is Clone) into the count query and the page query — the filters can never drift apart:
use chain_builder::{Postgres, QueryBuilder};
fn list_query(p: &ListParams) -> QueryBuilder<Postgres> {
QueryBuilder::<Postgres>::table("users")
.when(p.status.is_some(), |q| q.where_eq("status", p.status.clone()))
.when(!p.role.is_empty(), |q| q.where_in("role", p.role.clone()))
}With status = Some("active") and role = ["admin", "editor"], the page query compiles to:
let per_page = p.per_page.clamp(1, 100); // never trust per_page from the wire
let (sql, binds) = list_query(&p)
.select(["id", "name", "role"])
.order_by_desc("created_at")
.paginate(p.page, per_page)
.try_to_sql()?;
// SELECT "id", "name", "role" FROM "users" WHERE "status" = $1 AND "role" IN ($2, $3) ORDER BY "created_at" DESC LIMIT $4 OFFSET $5Safety properties you get for free:
- Every value — including
LIMIT/OFFSET— travels as a bind, never inside the SQL string. paginateclampspage < 1to page 1, so a hostile?page=-5cannot produce a negative offset. Clampingper_pageis your job (the oneclamp(1, 100)above) — otherwise?per_page=1000000is a free table-dump request.- An empty
rolelist is guarded bywhenhere, but even unguarded,where_in("role", Vec::<String>::new())compiles to the always-false1 = 0rather than invalid SQL (see WHERE). try_to_sql, notto_sql: the query shape is driven by request input, so an invalid builder must surface as aResult, not a panic — the documented policy.
The full axum handler
use axum::{extract::{Query, State}, Json};
use chain_builder::{Postgres, QueryBuilder};
use serde::Serialize;
#[derive(Serialize, sqlx::FromRow)]
struct UserRow {
id: i64,
name: String,
role: String,
}
#[derive(Serialize)]
struct Page {
items: Vec<UserRow>,
total: i64,
page: i64,
per_page: i64,
}
// AppError wraps chain_builder::Error and implements IntoResponse —
// the full mapping is the next recipe (http-error-mapping.md).
async fn list_users(
State(pool): State<sqlx::PgPool>,
Query(p): Query<ListParams>,
) -> Result<Json<Page>, AppError> {
let per_page = p.per_page.clamp(1, 100);
let base = list_query(&p);
// Total count: same filters, wrapped as SELECT COUNT(*) FROM (…) by the
// count() helper — no second copy of the WHERE clause to maintain.
let total = base.clone().count(&pool).await?;
let items: Vec<UserRow> = base
.select(["id", "name", "role"])
.order_by_desc("created_at")
.paginate(p.page, per_page)
.fetch_all(&pool)
.await?;
Ok(Json(Page { items, total, page: p.page.max(1), per_page }))
}Both ? operators convert into chain_builder::Error automatically: a build failure surfaces as Error::Build before any database round-trip, a driver failure as Error::Sqlx. The fetch_all/count helpers never panic on an invalid builder.
Notes & caveats
- Two queries, one source of truth.
base.clone().count(…)plus the paginated fetch is the standard pair; because both fork fromlist_query, adding a filter later updates the total and the page together. - Free-text search (
?q=ali) belongs in the samewhenchain viawhere_ilike— but read Case-insensitive Search first:%and_in user input are not escaped by the builder. - Sortable columns from input? Never pass a request value to
order_by(…)directly — column names are identifiers, not binds. Map the input through an allowlist first (same policy as tenant names in Multi-tenant with.db()). - MySQL/SQLite: identical chain; only placeholders (
?) and quoting change. See Dialect Differences.
Related pages
- Dynamic Building —
when,when_else,paginate, builder reuse - Executing with sqlx —
fetch_all,count, and the unifiedError - Mapping Errors to HTTP Status — the
AppErrorused above - Error Handling — why
try_*for request-driven queries