A comprehensive, modular Rust pagination library with support for multiple databases and web frameworks. Built for production use with a focus on ergonomics, performance, and maintainability.
- π― Flexible Pagination: Page-based and offset/limit pagination
- π§ Builder Pattern: Fluent API for constructing pagination parameters
- π Rich Metadata: Automatic calculation of total pages, has_next, has_prev
- π¨ Sorting Support: Multi-field sorting with ascending/descending order
β οΈ Error Handling: Comprehensive error types with helpful messages- π JSON Serialization: Built-in serde support
- π Cursor Pagination: Keyset-based pagination for large datasets with consistent results
- β‘ Optional COUNT(): Skip expensive COUNT queries with
.disable_total_count() - π SQL Injection Prevention: Parameterized queries in all database integrations
- ποΈ CTE Support: Common Table Expressions (WITH clauses) work seamlessly
- π Advanced Filtering: 14 filter operators (eq, ne, gt, lt, like, in, between, etc.)
- π Full-text Search: Multi-field fuzzy search with case-sensitive options
- SQLx (
paginator-sqlx): PostgreSQL, MySQL, SQLite support - SeaORM (
paginator-sea-orm): Type-safe ORM pagination with entity support - SurrealDB (
paginator-surrealdb): Multi-model database with SQL-like queries
- Axum (
paginator-axum): Query extractors and JSON responses with headers - Rocket (
paginator-rocket): Request guards and responders - Actix-web (
paginator-actix): Extractors, responders, and middleware
paginator-rs/
βββ paginator-rs/ # Core trait and types
βββ paginator-utils/ # Shared types (params, response, metadata)
βββ paginator-sqlx/ # SQLx database integration
βββ paginator-sea-orm/ # SeaORM integration
βββ paginator-surrealdb/ # SurrealDB integration
βββ paginator-axum/ # Axum web framework integration
βββ paginator-rocket/ # Rocket web framework integration
βββ paginator-actix/ # Actix-web integration
βββ paginator-examples/ # Usage examples
[dependencies]
paginator-rs = "0.2.1"
paginator-utils = "0.2.1"
serde = { version = "1", features = ["derive"] }[dependencies]
paginator-sqlx = { version = "0.2.1", features = ["postgres", "runtime-tokio"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio"] }[dependencies]
paginator-sea-orm = { version = "0.2.1", features = ["sqlx-postgres", "runtime-tokio"] }
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio"] }[dependencies]
paginator-surrealdb = { version = "0.2.1", features = ["protocol-ws", "kv-mem"] }
surrealdb = { version = "2.1", features = ["protocol-ws", "kv-mem"] }[dependencies]
paginator-axum = "0.2.1"
axum = "0.7"[dependencies]
paginator-rocket = "0.2.1"
rocket = { version = "0.5", features = ["json"] }[dependencies]
paginator-actix = "0.2.1"
actix-web = "4"use paginator_rs::{PaginationParams, PaginatorBuilder, PaginatorTrait};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
struct User {
id: u32,
name: String,
email: String,
}
// Using builder pattern
let params = PaginatorBuilder::new()
.page(1)
.per_page(20)
.sort_by("name")
.sort_asc()
.build();
// Or create directly
let params = PaginationParams::new(1, 20);use paginator_rs::{FilterValue, PaginatorBuilder};
// Example 1: Simple filtering
let params = PaginatorBuilder::new()
.page(1)
.per_page(20)
.filter_eq("status", FilterValue::String("active".to_string()))
.filter_gt("age", FilterValue::Int(18))
.build();
// Example 2: Advanced filtering with multiple operators
let params = PaginatorBuilder::new()
.filter_in("role", vec![
FilterValue::String("admin".to_string()),
FilterValue::String("moderator".to_string()),
])
.filter_between("created_at",
FilterValue::String("2024-01-01".to_string()),
FilterValue::String("2024-12-31".to_string())
)
.build();
// Example 3: Full-text search
let params = PaginatorBuilder::new()
.search("john", vec!["name".to_string(), "email".to_string()])
.build();
// Example 4: Combined filters and search
let params = PaginatorBuilder::new()
.page(1)
.per_page(10)
.filter_eq("status", FilterValue::String("active".to_string()))
.filter_gt("age", FilterValue::Int(18))
.search("developer", vec!["title".to_string(), "bio".to_string()])
.sort_by("created_at")
.sort_desc()
.build();
// Get generated SQL WHERE clause
if let Some(where_clause) = params.to_sql_where() {
println!("WHERE {}", where_clause);
// Output: WHERE status = 'active' AND age > 18 AND (title ILIKE '%developer%' OR bio ILIKE '%developer%')
}Cursor pagination (keyset pagination) provides better performance and consistency for large datasets compared to offset-based pagination.
use paginator_rs::{PaginatorBuilder, CursorValue, CursorDirection};
// Example 1: First page with cursor support
let params = PaginatorBuilder::new()
.per_page(20)
.sort_by("id")
.sort_asc()
.build();
// Example 2: Next page using cursor (better than offset!)
let params = PaginatorBuilder::new()
.per_page(20)
.sort_by("id")
.cursor_after("id", CursorValue::Int(42))
.build();
// Example 3: Previous page
let params = PaginatorBuilder::new()
.per_page(20)
.sort_by("id")
.cursor_before("id", CursorValue::Int(42))
.build();
// Example 4: Decode from encoded cursor (from API response)
let params = PaginatorBuilder::new()
.per_page(20)
.cursor_from_encoded("eyJmaWVsZCI6ImlkIiwidmFsdWUiOjQyLCJkaXJlY3Rpb24iOiJhZnRlciJ9")
.unwrap()
.build();
// Example 5: Skip COUNT query for better performance
let params = PaginatorBuilder::new()
.per_page(20)
.sort_by("created_at")
.cursor_after("created_at", CursorValue::String("2024-01-01T00:00:00Z".to_string()))
.disable_total_count() // Skip expensive COUNT(*)
.build();Cursor Pagination Benefits:
- β Better performance on large datasets (no OFFSET overhead)
- β Consistent results even with concurrent data modifications
- β No skipped or duplicate rows
- β Works with filters and search
- β Secure Base64-encoded cursor strings
Available Filter Operators:
filter_eq(field, value)- Equal (=)filter_ne(field, value)- Not equal (!=)filter_gt(field, value)- Greater than (>)filter_lt(field, value)- Less than (<)filter_gte(field, value)- Greater than or equal (>=)filter_lte(field, value)- Less than or equal (<=)filter_like(field, pattern)- SQL LIKE pattern matchingfilter_ilike(field, pattern)- Case-insensitive LIKEfilter_in(field, values)- IN arrayfilter_between(field, min, max)- BETWEEN min AND maxfilter_is_null(field)- IS NULLfilter_is_not_null(field)- IS NOT NULL
Search Options:
search(query, fields)- Case-insensitive fuzzy searchsearch_exact(query, fields)- Exact match searchsearch_case_sensitive(query, fields)- Case-sensitive search
use axum::{Router, routing::get};
use paginator_axum::{PaginationQuery, PaginatedJson};
use serde::Serialize;
#[derive(Serialize)]
struct User {
id: u32,
name: String,
}
async fn get_users(
PaginationQuery(params): PaginationQuery,
) -> PaginatedJson<User> {
let users = vec![/* fetch from database */];
// Automatically adds pagination headers
PaginatedJson::new(users, ¶ms, 100)
}
let app = Router::new().route("/users", get(get_users));use paginator_sqlx::postgres::paginate_query;
use paginator_rs::PaginatorBuilder;
use sqlx::PgPool;
#[derive(sqlx::FromRow, serde::Serialize)]
struct User {
id: i32,
name: String,
}
async fn get_users(pool: &PgPool) -> Result<(), Box<dyn std::error::Error>> {
let params = PaginatorBuilder::new()
.page(1)
.per_page(10)
.sort_by("created_at")
.sort_desc()
.build();
let result = paginate_query::<_, User>(
pool,
"SELECT id, name FROM users WHERE active = true",
¶ms,
).await?;
println!("Page {}/{}", result.meta.page, result.meta.total_pages);
println!("Total users: {}", result.meta.total);
Ok(())
}use paginator_sea_orm::PaginateSeaOrm;
use paginator_rs::PaginationParams;
use sea_orm::{EntityTrait, Database};
async fn get_users(db: &DatabaseConnection) -> Result<(), sea_orm::DbErr> {
let params = PaginationParams::new(1, 20);
let result = User::find()
.filter(user::Column::Active.eq(true))
.paginate_with(db, ¶ms)
.await?;
println!("Found {} users", result.data.len());
Ok(())
}use surrealdb::Surreal;
use surrealdb::engine::remote::ws::Ws;
use paginator_surrealdb::{paginate_query, paginate_table, QueryBuilder};
use paginator_rs::PaginatorBuilder;
#[derive(serde::Deserialize, serde::Serialize)]
struct User {
id: String,
name: String,
email: String,
active: bool,
}
async fn get_users() -> Result<(), Box<dyn std::error::Error>> {
// Connect to SurrealDB
let db = Surreal::new::<Ws>("127.0.0.1:8000").await?;
db.use_ns("test").use_db("test").await?;
let params = PaginatorBuilder::new()
.page(1)
.per_page(10)
.sort_by("name")
.sort_asc()
.build();
// Option 1: Using raw query
let result = paginate_query::<User, _>(
&db,
"SELECT * FROM users WHERE active = true",
¶ms,
).await?;
// Option 2: Using table helper
let result = paginate_table::<User, _>(
&db,
"users",
Some("active = true"),
¶ms,
).await?;
// Option 3: Using query builder
let result = QueryBuilder::new()
.select("*")
.from("users")
.where_clause("active = true")
.and("age > 18")
.paginate::<User, _>(&db, ¶ms)
.await?;
println!("Page {}/{}", result.meta.page, result.meta.total_pages);
println!("Total users: {}", result.meta.total);
Ok(())
}use rocket::{get, routes};
use paginator_rocket::{Pagination, PaginatedJson};
#[derive(Serialize)]
struct User {
id: u32,
name: String,
}
#[get("/users")]
async fn get_users(pagination: Pagination) -> PaginatedJson<User> {
let users = vec![/* ... */];
PaginatedJson::new(users, &pagination.params, 100)
}
#[launch]
fn rocket() -> _ {
rocket::build().mount("/api", routes![get_users])
}use actix_web::{get, web, App};
use paginator_actix::{PaginationQuery, PaginatedJson};
#[derive(Serialize)]
struct User {
id: u32,
name: String,
}
#[get("/users")]
async fn get_users(
query: web::Query<PaginationQuery>,
) -> PaginatedJson<User> {
let params = query.as_params();
let users = vec![/* ... */];
PaginatedJson::new(users, ¶ms, 100)
}{
"data": [
{ "id": 1, "name": "Alice", "email": "[email protected]" },
{ "id": 2, "name": "Bob", "email": "[email protected]" }
],
"meta": {
"page": 1,
"per_page": 20,
"total": 100,
"total_pages": 5,
"has_next": true,
"has_prev": false
}
}When using cursor pagination with .disable_total_count():
{
"data": [
{ "id": 43, "name": "Charlie", "email": "[email protected]" },
{ "id": 44, "name": "Diana", "email": "[email protected]" }
],
"meta": {
"page": 1,
"per_page": 20,
"has_next": true,
"has_prev": false,
"next_cursor": "eyJmaWVsZCI6ImlkIiwidmFsdWUiOjQ0LCJkaXJlY3Rpb24iOiJhZnRlciJ9",
"prev_cursor": "eyJmaWVsZCI6ImlkIiwidmFsdWUiOjQzLCJkaXJlY3Rpb24iOiJiZWZvcmUifQ=="
}
}Note: When disable_total_count() is used, total and total_pages fields are omitted from the response for better performance.
X-Total-Count: 100
X-Total-Pages: 5
X-Current-Page: 1
X-Per-Page: 20
Note: X-Total-Count and X-Total-Pages headers are only included when total is available (not using disable_total_count()).
GET /api/users?page=2&per_page=20&sort_by=name&sort_direction=asc
page: Page number (1-indexed, default: 1)per_page: Items per page (default: 20, max: 100)sort_by: Field to sort by (optional)sort_direction:ascordesc(optional)
GET /api/users?page=1&filter=status:eq:active&filter=age:gt:18&filter=role:in:admin,moderator
filter: Filter in formatfield:operator:value- Multiple filters can be combined (AND logic)
Filter Format Examples:
status:eq:active- Equalage:gt:18- Greater thanage:between:18,65- Betweenrole:in:admin,moderator,user- In arrayname:like:%john%- LIKE patterndeleted_at:is_null- IS NULL
GET /api/users?search=john&search_fields=name,email,bio
search: Search query textsearch_fields: Comma-separated list of fields to search in
GET /api/users?page=1&per_page=10&filter=status:eq:active&filter=age:gt:18&search=developer&search_fields=title,bio&sort_by=created_at&sort_direction=desc
use paginator_rs::PaginatorBuilder;
let params = PaginatorBuilder::new()
.page(2)
.per_page(50)
.sort_by("created_at")
.sort_desc()
.build();use paginator_rs::{PaginatorError, PaginatorResult};
// Errors are comprehensive and helpful
match result {
Ok(response) => println!("Success!"),
Err(PaginatorError::InvalidPage(page)) => {
eprintln!("Invalid page: {}. Page must be >= 1", page);
}
Err(PaginatorError::InvalidPerPage(per_page)) => {
eprintln!("Invalid per_page: {}. Must be between 1 and 100", per_page);
}
Err(e) => eprintln!("Error: {}", e),
}- Easy to Use: Builder pattern and sensible defaults
- Easy to Debug: Comprehensive error messages and type safety
- Easy to Maintain: Modular crate structure with clear separation of concerns
All database integrations use parameterized queries with bound parameters to prevent SQL injection attacks:
// β
SAFE: All filter values are bound parameters
let params = PaginatorBuilder::new()
.filter_eq("status", FilterValue::String("'; DROP TABLE users; --".to_string()))
.build();
// The malicious input is safely escaped as a parameter value
// SQL: WHERE status = $1 (with parameter: "'; DROP TABLE users; --")Implementation Details:
paginator-sqlx: Uses SQLx'sQueryBuilderwith.push_bind()for all valuespaginator-sea-orm: Uses SeaORM's type-safe query builderpaginator-surrealdb: Uses SurrealDB's parameterized query API- Filter values, search terms, and sort fields are never concatenated into SQL strings
Cursors are Base64-encoded JSON objects to prevent tampering:
// Cursor structure: { "field": "id", "value": 42, "direction": "after" }
// Encoded: "eyJmaWVsZCI6ImlkIiwidmFsdWUiOjQyLCJkaXJlY3Rpb24iOiJhZnRlciJ9"
// β
Type-safe decoding with validation
let cursor = Cursor::decode(encoded_cursor)?;
// Returns error if cursor is tampered or invalid- β Always validate user input before building pagination parameters
- β
Use type-safe filter values (
FilterValue::String,FilterValue::Int, etc.) - β Cursors are automatically validated during decoding
- β All database queries use parameterized statements
- β No raw SQL concatenation in any integration
Run the examples:
cargo run --package paginator-examples --bin exampleContributions are welcome! Please feel free to submit a Pull Request.
MIT Β© 2025 Maulana Sodiqin
- Repository
- Documentation (coming soon)
- crates.io (coming soon)