# paginator-rs
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.
## โจ Features
### Core Features
- ๐ฏ **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
### Advanced Features
- ๐ **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
### Database Integrations
- **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
### Web Framework Integrations
- **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
## ๐งฑ Workspace Structure
```
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
```
## ๐ฆ Installation
### Core Library
```toml
[dependencies]
paginator-rs = "0.2.1"
paginator-utils = "0.2.1"
serde = { version = "1", features = ["derive"] }
```
### With SQLx (PostgreSQL)
```toml
[dependencies]
paginator-sqlx = { version = "0.2.1", features = ["postgres", "runtime-tokio"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio"] }
```
### With SeaORM
```toml
[dependencies]
paginator-sea-orm = { version = "0.2.1", features = ["sqlx-postgres", "runtime-tokio"] }
sea-orm = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio"] }
```
### With SurrealDB
```toml
[dependencies]
paginator-surrealdb = { version = "0.2.1", features = ["protocol-ws", "kv-mem"] }
surrealdb = { version = "2.1", features = ["protocol-ws", "kv-mem"] }
```
### With Axum
```toml
[dependencies]
paginator-axum = "0.2.1"
axum = "0.7"
```
### With Rocket
```toml
[dependencies]
paginator-rocket = "0.2.1"
rocket = { version = "0.5", features = ["json"] }
```
### With Actix-web
```toml
[dependencies]
paginator-actix = "0.2.1"
actix-web = "4"
```
## ๐ Usage Examples
### Basic Pagination
```rust
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);
```
### Filtering & Search
```rust
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-Based Pagination
Cursor pagination (keyset pagination) provides better performance and consistency for large datasets compared to offset-based pagination.
```rust
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 matching
- `filter_ilike(field, pattern)` - Case-insensitive LIKE
- `filter_in(field, values)` - IN array
- `filter_between(field, min, max)` - BETWEEN min AND max
- `filter_is_null(field)` - IS NULL
- `filter_is_not_null(field)` - IS NOT NULL
**Search Options:**
- `search(query, fields)` - Case-insensitive fuzzy search
- `search_exact(query, fields)` - Exact match search
- `search_case_sensitive(query, fields)` - Case-sensitive search
### With Axum
```rust
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));
```
### With SQLx (PostgreSQL)
```rust
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(())
}
```
### With SeaORM
```rust
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(())
}
```
### With SurrealDB
```rust
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(())
}
```
### With Rocket
```rust
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])
}
```
### With Actix-web
```rust
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)
}
```
## ๐งช Response Format
### Standard Pagination Response
```json
{
"data": [
{ "id": 1, "name": "Alice", "email": "alice@example.com" },
{ "id": 2, "name": "Bob", "email": "bob@example.com" }
],
"meta": {
"page": 1,
"per_page": 20,
"total": 100,
"total_pages": 5,
"has_next": true,
"has_prev": false
}
}
```
### Cursor Pagination Response
When using cursor pagination with `.disable_total_count()`:
```json
{
"data": [
{ "id": 43, "name": "Charlie", "email": "charlie@example.com" },
{ "id": 44, "name": "Diana", "email": "diana@example.com" }
],
"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.
### HTTP Headers (Web Framework Integrations)
```
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()`).
## ๐ฏ Query Parameters
### Basic Pagination & Sorting
```
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`: `asc` or `desc` (optional)
### With Filters
```
GET /api/users?page=1&filter=status:eq:active&filter=age:gt:18&filter=role:in:admin,moderator
```
- `filter`: Filter in format `field:operator:value`
- Multiple filters can be combined (AND logic)
**Filter Format Examples:**
- `status:eq:active` - Equal
- `age:gt:18` - Greater than
- `age:between:18,65` - Between
- `role:in:admin,moderator,user` - In array
- `name:like:%john%` - LIKE pattern
- `deleted_at:is_null` - IS NULL
### With Search
```
GET /api/users?search=john&search_fields=name,email,bio
```
- `search`: Search query text
- `search_fields`: Comma-separated list of fields to search in
### Combined Example
```
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
```
## ๐ง Builder Pattern
```rust
use paginator_rs::PaginatorBuilder;
let params = PaginatorBuilder::new()
.page(2)
.per_page(50)
.sort_by("created_at")
.sort_desc()
.build();
```
## โ ๏ธ Error Handling
```rust
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),
}
```
## ๐๏ธ Architecture
- **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
## ๐ Security
### SQL Injection Prevention
All database integrations use **parameterized queries** with bound parameters to prevent SQL injection attacks:
```rust
// โ
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's `QueryBuilder` with `.push_bind()` for all values
- `paginator-sea-orm`: Uses SeaORM's type-safe query builder
- `paginator-surrealdb`: Uses SurrealDB's parameterized query API
- Filter values, search terms, and sort fields are never concatenated into SQL strings
### Secure Cursor Encoding
Cursors are Base64-encoded JSON objects to prevent tampering:
```rust
// 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
```
### Best Practices
- โ
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
## ๐ Examples
Run the examples:
```bash
cargo run --package paginator-examples --bin example
```
## ๐ค Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## ๐ License
MIT ยฉ 2025 Maulana Sodiqin
## ๐ Links
- [Repository](https://github.com/maulanasdqn/paginator-rs)
- [Documentation](https://docs.rs/paginator-rs) (coming soon)
- [crates.io](https://crates.io/crates/paginator-rs) (coming soon)