arcature-data 2026.2.0

Arcature high-level data layer: explicit-ownership model/query ergonomics over SeaORM/SQLx, N+1 detection, and migration lint.
Documentation
//! Pagination: a typed [`Page<T>`] that runs the count + page query in one
//! call, replacing the application-code pagination the dogfood previously did
//! by hand (loading all rows and slicing a `Vec`).
//!
//! `Query::paginate(per_page).page(n).fetch()` returns a [`Page`] carrying
//! the rows, the total item count, and the total page count — computed by the
//! database, not the application. Pages are **1-based** (page 0 is a typed
//! error, never a silent empty result).

use arcature_db::Db;
use arcature_db::sea_orm;
use arcature_db::sea_orm::PaginatorTrait;

use crate::error::{DataError, PaginationError};

/// A page of results from a paginated query.
///
/// Returned by [`Paginated::fetch`]. `page` and `per_page` echo the request;
/// `total` is the total matching row count (from `COUNT(*)`); `num_pages` is the
/// derived page count (`total.div_ceil(per_page)`). The database computes all
/// three — the application never slices a full `Vec`.
#[derive(Debug, Clone)]
pub struct Page<T> {
    /// The rows on this page.
    pub rows: Vec<T>,
    /// The 1-based page number that was requested.
    pub page: u64,
    /// The per-page size that was requested.
    pub per_page: u64,
    /// The total number of matching rows across all pages.
    pub total: u64,
    /// The total number of pages (`total.div_ceil(per_page)`; 0 when `total` is 0).
    pub num_pages: u64,
}

impl<T> Page<T> {
    /// `true` when this page holds no rows (the last page of an empty result,
    /// or a page number past the end).
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.rows.is_empty()
    }
}

/// A paginated query awaiting a page number and a fetch.
///
/// Construct via [`crate::Query::paginate`]. Chain `.page(n)` (1-based) then
/// `.fetch()`, or call `.fetch()` directly for page 1.
pub struct Paginated<'db, E>
where
    E: sea_orm::EntityTrait,
{
    db: &'db Db,
    select: sea_orm::Select<E>,
    per_page: u64,
    page: u64,
}

impl<'db, E> Paginated<'db, E>
where
    E: sea_orm::EntityTrait,
{
    pub(crate) fn new(db: &'db Db, select: sea_orm::Select<E>, per_page: u64) -> Self {
        Self {
            db,
            select,
            per_page,
            page: 1,
        }
    }

    /// Select a 1-based page number. Page 0 is rejected at [`Paginated::fetch`]
    /// time with a typed [`PaginationError::PageMustBePositive`].
    #[must_use]
    pub fn page(mut self, page: u64) -> Self {
        self.page = page;
        self
    }

    /// Run the count + page query and return a typed [`Page`].
    ///
    /// # Errors
    ///
    /// Returns [`DataError::Pagination`] if `page` is 0 or `per_page` is 0,
    /// and [`DataError::Database`] if the underlying SeaORM queries fail.
    pub async fn fetch(self) -> Result<Page<E::Model>, DataError>
    where
        E::Model: sea_orm::FromQueryResult + Send + Sync,
    {
        if self.per_page == 0 {
            return Err(PaginationError::PerPageMustBePositive.into());
        }
        if self.page == 0 {
            return Err(PaginationError::PageMustBePositive.into());
        }

        let paginator = self.select.paginate(self.db.orm(), self.per_page);
        let total = paginator.num_items().await.map_err(DataError::from)?;
        let num_pages = total.div_ceil(self.per_page);
        let rows = paginator
            .fetch_page(self.page - 1)
            .await
            .map_err(DataError::from)?;
        Ok(Page {
            rows,
            page: self.page,
            per_page: self.per_page,
            total,
            num_pages,
        })
    }
}