Skip to content

maulanasdqn/paginator-rs

Repository files navigation

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

[dependencies]
paginator-rs = "0.2.1"
paginator-utils = "0.2.1"
serde = { version = "1", features = ["derive"] }

With SQLx (PostgreSQL)

[dependencies]
paginator-sqlx = { version = "0.2.1", features = ["postgres", "runtime-tokio"] }
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio"] }

With SeaORM

[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

[dependencies]
paginator-surrealdb = { version = "0.2.1", features = ["protocol-ws", "kv-mem"] }
surrealdb = { version = "2.1", features = ["protocol-ws", "kv-mem"] }

With Axum

[dependencies]
paginator-axum = "0.2.1"
axum = "0.7"

With Rocket

[dependencies]
paginator-rocket = "0.2.1"
rocket = { version = "0.5", features = ["json"] }

With Actix-web

[dependencies]
paginator-actix = "0.2.1"
actix-web = "4"

πŸš€ Usage Examples

Basic Pagination

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

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.

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

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, &params, 100)
}

let app = Router::new().route("/users", get(get_users));

With SQLx (PostgreSQL)

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",
        &params,
    ).await?;

    println!("Page {}/{}", result.meta.page, result.meta.total_pages);
    println!("Total users: {}", result.meta.total);

    Ok(())
}

With SeaORM

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, &params)
        .await?;

    println!("Found {} users", result.data.len());
    Ok(())
}

With SurrealDB

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",
        &params,
    ).await?;

    // Option 2: Using table helper
    let result = paginate_table::<User, _>(
        &db,
        "users",
        Some("active = true"),
        &params,
    ).await?;

    // Option 3: Using query builder
    let result = QueryBuilder::new()
        .select("*")
        .from("users")
        .where_clause("active = true")
        .and("age > 18")
        .paginate::<User, _>(&db, &params)
        .await?;

    println!("Page {}/{}", result.meta.page, result.meta.total_pages);
    println!("Total users: {}", result.meta.total);

    Ok(())
}

With Rocket

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

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, &params, 100)
}

πŸ§ͺ Response Format

Standard Pagination Response

{
  "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
  }
}

Cursor Pagination Response

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.

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

use paginator_rs::PaginatorBuilder;

let params = PaginatorBuilder::new()
    .page(2)
    .per_page(50)
    .sort_by("created_at")
    .sort_desc()
    .build();

⚠️ Error Handling

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:

// βœ… 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:

// 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:

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

About

A modular Rust library providing a reusable trait for paginating any serializable collection.

Topics

Resources

Stars

Watchers

Forks

Packages

No packages published

Languages