floz-orm 0.1.7

A lightweight, typesafe Rust ORM — unifying DAO and DSL from a single schema
Documentation
//! Pagination — `Paginated<T>` result type and `PaginateQuery<T>` builder.
//!
//! Uses the two-query strategy: COUNT for total, then SELECT with LIMIT/OFFSET.

use std::marker::PhantomData;

use crate::db::FlozRowBound;

use crate::db::Executor;
use crate::error::FlozError;
use crate::expr::{Expr, OrderExpr};
use crate::query::quote_table;
use crate::value::Value;

/// The result of a paginated query.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Paginated<T> {
    /// The items on this page.
    pub items: Vec<T>,
    /// Total number of matching rows across all pages.
    pub total: i64,
    /// Current page number (1-indexed).
    pub page: i64,
    /// Items per page.
    pub per_page: i64,
    /// Total number of pages.
    pub total_pages: i64,
    /// Whether there is a next page.
    pub has_next: bool,
    /// Whether there is a previous page.
    pub has_prev: bool,
}

impl<T> Paginated<T> {
    /// Construct a `Paginated` result, computing derived fields.
    pub fn new(items: Vec<T>, total: i64, page: i64, per_page: i64) -> Self {
        let total_pages = if per_page > 0 && total > 0 {
            (total + per_page - 1) / per_page
        } else {
            0
        };
        Self {
            items,
            total,
            page,
            per_page,
            total_pages,
            has_next: page < total_pages,
            has_prev: page > 1,
        }
    }
}

/// Builder for paginated queries.
///
/// ```ignore
/// let page = User::paginate()
///     .page(3)
///     .per_page(20)
///     .where_(UserTable::active.eq(true))
///     .order_by(UserTable::created_at.desc())
///     .execute(&db)
///     .await?;
/// ```
pub struct PaginateQuery<T> {
    table: String,
    filter: Option<Expr>,
    order: Vec<OrderExpr>,
    page: i64,
    per_page: i64,
    _phantom: PhantomData<T>,
}

impl<T> PaginateQuery<T> {
    /// Create a new paginate query for the given table.
    pub fn new(table: &str) -> Self {
        Self {
            table: table.to_string(),
            filter: None,
            order: Vec::new(),
            page: 1,
            per_page: 25,
            _phantom: PhantomData,
        }
    }

    /// Set the page number (1-indexed). Values < 1 are clamped to 1.
    pub fn page(mut self, page: i64) -> Self {
        self.page = if page < 1 { 1 } else { page };
        self
    }

    /// Set items per page. Values < 1 are clamped to 1.
    pub fn per_page(mut self, per_page: i64) -> Self {
        self.per_page = if per_page < 1 { 1 } else { per_page };
        self
    }

    /// Add a WHERE filter.
    pub fn where_(mut self, expr: Expr) -> Self {
        self.filter = Some(expr);
        self
    }

    /// Add an ORDER BY clause.
    pub fn order_by(mut self, order: OrderExpr) -> Self {
        self.order.push(order);
        self
    }

    /// Execute the paginated query: runs a COUNT then a SELECT.
    pub async fn execute(self, db: &impl Executor) -> Result<Paginated<T>, FlozError>
    where
        T: Send + Unpin + FlozRowBound,
    {
        let quoted = quote_table(&self.table);
        let offset = (self.page - 1) * self.per_page;

        // ── Query 1: COUNT ──
        let mut count_sql = format!("SELECT COUNT(*) as count FROM {}", quoted);
        let mut count_params: Vec<Value> = Vec::new();
        let mut count_idx = 0usize;
        if let Some(ref filter) = self.filter {
            count_sql.push_str(" WHERE ");
            filter.to_sql(&mut count_sql, &mut count_params, &mut count_idx);
        }

        let row: CountRow = db.fetch_one(&count_sql, count_params).await?;
        let total = row.count;

        // ── Query 2: SELECT with LIMIT/OFFSET ──
        let mut data_sql = format!("SELECT * FROM {}", quoted);
        let mut data_params: Vec<Value> = Vec::new();
        let mut data_idx = 0usize;
        if let Some(ref filter) = self.filter {
            data_sql.push_str(" WHERE ");
            filter.to_sql(&mut data_sql, &mut data_params, &mut data_idx);
        }

        // ORDER BY
        if !self.order.is_empty() {
            data_sql.push_str(" ORDER BY ");
            for (i, ord) in self.order.iter().enumerate() {
                if i > 0 {
                    data_sql.push_str(", ");
                }
                ord.to_sql(&mut data_sql);
            }
        }

        // LIMIT + OFFSET
        data_idx += 1;
        data_sql.push_str(&format!(" LIMIT ${}", data_idx));
        data_params.push(Value::BigInt(self.per_page));
        data_idx += 1;
        data_sql.push_str(&format!(" OFFSET ${}", data_idx));
        data_params.push(Value::BigInt(offset));

        let items: Vec<T> = db.fetch_all(&data_sql, data_params).await?;

        Ok(Paginated::new(items, total, self.page, self.per_page))
    }
}

/// Internal struct for COUNT(*) result.
#[derive(Debug, sqlx::FromRow)]
struct CountRow {
    count: i64,
}

// ═══════════════════════════════════════════════════════════════
// Unit Tests
// ═══════════════════════════════════════════════════════════════

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn paginated_basic() {
        let p: Paginated<()> = Paginated::new(vec![(); 10], 50, 1, 10);
        assert_eq!(p.total, 50);
        assert_eq!(p.page, 1);
        assert_eq!(p.per_page, 10);
        assert_eq!(p.total_pages, 5);
        assert!(p.has_next);
        assert!(!p.has_prev);
    }

    #[test]
    fn paginated_last_page() {
        let p: Paginated<()> = Paginated::new(vec![(); 5], 25, 3, 10);
        assert_eq!(p.total_pages, 3);
        assert!(!p.has_next);
        assert!(p.has_prev);
    }

    #[test]
    fn paginated_middle_page() {
        let p: Paginated<()> = Paginated::new(vec![(); 10], 30, 2, 10);
        assert_eq!(p.total_pages, 3);
        assert!(p.has_next);
        assert!(p.has_prev);
    }

    #[test]
    fn paginated_empty() {
        let p: Paginated<()> = Paginated::new(vec![], 0, 1, 10);
        assert_eq!(p.total_pages, 0);
        assert!(!p.has_next);
        assert!(!p.has_prev);
    }

    #[test]
    fn paginated_single_page() {
        let p: Paginated<()> = Paginated::new(vec![(); 3], 3, 1, 10);
        assert_eq!(p.total_pages, 1);
        assert!(!p.has_next);
        assert!(!p.has_prev);
    }

    #[test]
    fn paginated_remainder_page() {
        let p: Paginated<()> = Paginated::new(vec![(); 1], 21, 3, 10);
        assert_eq!(p.total_pages, 3);
        assert!(!p.has_next);
        assert!(p.has_prev);
    }

    #[test]
    fn paginate_query_clamps_page() {
        let q = PaginateQuery::<()>::new("test").page(0);
        assert_eq!(q.page, 1);
    }

    #[test]
    fn paginate_query_clamps_per_page() {
        let q = PaginateQuery::<()>::new("test").per_page(-5);
        assert_eq!(q.per_page, 1);
    }

    #[test]
    fn paginate_query_defaults() {
        let q = PaginateQuery::<()>::new("users");
        assert_eq!(q.page, 1);
        assert_eq!(q.per_page, 25);
        assert!(q.filter.is_none());
        assert!(q.order.is_empty());
    }
}