Skip to main content

modkit_db/odata/
pager.rs

1//! Minimal fluent builder for combining Secure ORM scoping with `OData` pagination.
2//!
3//! This module provides `OPager`, a small ergonomic builder that:
4//! - Applies security scope via `Entity::find().secure().scope_with(&scope)`
5//! - Applies `OData` filter + cursor + order + limit via `paginate_with_odata`
6//! - Keeps all existing types without introducing facades or macros
7//!
8//! # Quick Start
9//!
10//! ```ignore
11//! use modkit_db::odata::{FieldMap, FieldKind, pager::OPager};
12//! use modkit_db::secure::DBRunner;
13//! use modkit_security::AccessScope;
14//! use modkit_odata::{ODataQuery, SortDir, Page, Error as ODataError};
15//!
16//! // Define field mappings once (typically as a static or const)
17//! fn user_field_map() -> FieldMap<user::Entity> {
18//!     FieldMap::new()
19//!         .insert("id", user::Column::Id, FieldKind::Uuid)
20//!         .insert("name", user::Column::Name, FieldKind::String)
21//!         .insert("email", user::Column::Email, FieldKind::String)
22//!         .insert("created_at", user::Column::CreatedAt, FieldKind::DateTimeUtc)
23//! }
24//!
25//! // In your repository or service layer
26//! pub async fn list_users(
27//!     conn: &impl DBRunner,
28//!     scope: &AccessScope,
29//!     q: &ODataQuery,
30//! ) -> Result<Page<UserDto>, ODataError> {
31//!     OPager::<user::Entity, _>::new(scope, conn, &user_field_map())
32//!         .tiebreaker("created_at", SortDir::Desc)
33//!         .limits(50, 500)
34//!         .fetch(q, |model| UserDto {
35//!             id: model.id,
36//!             name: model.name,
37//!             email: model.email,
38//!         })
39//!         .await
40//! }
41//! ```
42//!
43//! # Complete Example
44//!
45//! ```ignore
46//! use modkit_db::odata::{FieldMap, FieldKind, pager::OPager};
47//! use modkit_db::secure::{DBRunner, ScopableEntity};
48//! use modkit_security::AccessScope;
49//! use modkit_odata::{ODataQuery, SortDir};
50//! use sea_orm::entity::prelude::*;
51//! use uuid::Uuid;
52//!
53//! // 1. Define your entity with Scopable
54//! #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Scopable)]
55//! #[sea_orm(table_name = "users")]
56//! #[secure(tenant_col = "tenant_id")]
57//! pub struct Model {
58//!     #[sea_orm(primary_key)]
59//!     pub id: Uuid,
60//!     pub tenant_id: Uuid,
61//!     pub name: String,
62//!     pub email: String,
63//!     pub created_at: DateTime<Utc>,
64//! }
65//!
66//! // 2. Define field mappings
67//! static USER_FIELD_MAP: Lazy<FieldMap<Entity>> = Lazy::new(|| {
68//!     FieldMap::new()
69//!         .insert("id", Column::Id, FieldKind::Uuid)
70//!         .insert("name", Column::Name, FieldKind::String)
71//!         .insert("email", Column::Email, FieldKind::String)
72//!         .insert("created_at", Column::CreatedAt, FieldKind::DateTimeUtc)
73//! });
74//!
75//! // 3. Use in your service - services receive &impl DBRunner as a parameter
76//! pub async fn list_users(
77//!     conn: &impl DBRunner,
78//!     scope: &AccessScope,
79//!     odata_query: &ODataQuery,
80//! ) -> Result<Page<UserDto>, ODataError> {
81//!     OPager::<Entity, _>::new(scope, conn, &USER_FIELD_MAP)
82//!         .tiebreaker("id", SortDir::Desc)
83//!         .limits(25, 1000)
84//!         .fetch(odata_query, |m| UserDto {
85//!             id: m.id,
86//!             name: m.name,
87//!             email: m.email,
88//!         })
89//!         .await
90//! }
91//! ```
92//!
93//! # Security
94//!
95//! `OPager` automatically enforces tenant isolation and access control:
96//! - Security scope is applied before any filters
97//! - Empty scopes result in deny-all (no data returned)
98//! - All queries are scoped by the `SecurityCtx` provided
99//!
100//! # Performance
101//!
102//! - Uses cursor-based pagination for efficient large dataset traversal
103//! - Fetches limit+1 rows to detect "has more" without separate COUNT query
104//! - Applies filters at the database level (not in application memory)
105//! - Supports indexed columns via field mappings for optimal query performance
106
107use crate::odata::{FieldMap, LimitCfg, paginate_with_odata};
108use crate::secure::{DBRunner, ScopableEntity, SecureEntityExt};
109use modkit_odata::{Error as ODataError, ODataQuery, Page, SortDir};
110use modkit_security::AccessScope;
111use sea_orm::{ColumnTrait, EntityTrait};
112
113/// Minimal fluent builder for Secure + `OData` pagination.
114///
115/// This builder combines security-scoped queries with `OData` pagination
116/// in a single, ergonomic interface. It enforces tenant isolation and
117/// access control while providing cursor-based pagination with filtering
118/// and ordering.
119///
120/// # Type Parameters
121///
122/// - `E`: The `SeaORM` entity type (must implement `ScopableEntity`)
123/// - `C`: The secure database capability (e.g. `&SecureConn` or `&SecureTx`)
124///
125/// # Usage
126///
127/// ```ignore
128/// OPager::<UserEntity, _>::new(db, ctx, db, &FMAP)
129///   .tiebreaker("id", SortDir::Desc)  // optional, defaults to ("id", Desc)
130///   .limits(25, 1000)                  // optional, defaults to (25, 1000)
131///   .fetch(&query, |m| dto_from(m))
132///   .await
133/// ```
134///
135/// # Default Behavior
136///
137/// - Tiebreaker: `("id", SortDir::Desc)` - ensures stable pagination
138/// - Limits: `{ default: 25, max: 1000 }` - reasonable defaults for most APIs
139#[must_use]
140pub struct OPager<'a, E, C>
141where
142    E: EntityTrait,
143    E::Column: ColumnTrait + Copy,
144    C: DBRunner,
145{
146    scope: &'a AccessScope,
147    conn: &'a C,
148    fmap: &'a FieldMap<E>,
149    tiebreaker: (&'a str, SortDir),
150    limits: LimitCfg,
151}
152
153impl<'a, E, C> OPager<'a, E, C>
154where
155    E: EntityTrait,
156    E::Column: ColumnTrait + Copy,
157    C: DBRunner,
158{
159    /// Construct a new pager over a secured, scoped Select<E>.
160    ///
161    /// # Parameters
162    ///
163    /// - `scope`: Security scope defining access boundaries (tenant/resource)
164    /// - `conn`: Database connection runner for executing queries
165    /// - `fmap`: Field map defining `OData` field → entity column mappings
166    ///
167    /// # Example
168    ///
169    /// ```ignore
170    /// let pager = OPager::<UserEntity, _>::new(
171    ///     &scope,
172    ///     &conn,
173    ///     &USER_FIELD_MAP
174    /// );
175    /// ```
176    pub fn new(scope: &'a AccessScope, conn: &'a C, fmap: &'a FieldMap<E>) -> Self {
177        Self {
178            scope,
179            conn,
180            fmap,
181            // Sane defaults that work for most use cases
182            tiebreaker: ("id", SortDir::Desc),
183            limits: LimitCfg {
184                default: 25,
185                max: 1000,
186            },
187        }
188    }
189
190    /// Override the default tiebreaker ("id", Desc).
191    ///
192    /// The tiebreaker ensures stable, deterministic pagination by providing
193    /// a final sort key when the primary order has duplicate values.
194    ///
195    /// # Parameters
196    ///
197    /// - `field`: The field name (as defined in the `FieldMap`) to use as tiebreaker
198    /// - `dir`: Sort direction for the tiebreaker field
199    ///
200    /// # Example
201    ///
202    /// ```ignore
203    /// pager.tiebreaker("created_at", SortDir::Asc)
204    /// ```
205    pub fn tiebreaker(mut self, field: &'a str, dir: SortDir) -> Self {
206        self.tiebreaker = (field, dir);
207        self
208    }
209
210    /// Override default/max limits (defaults: 25/1000).
211    ///
212    /// Controls pagination limits:
213    /// - `default`: Used when client doesn't specify a limit
214    /// - `max`: Maximum limit value (client requests clamped to this)
215    ///
216    /// # Parameters
217    ///
218    /// - `default`: Default page size (if client doesn't specify)
219    /// - `max`: Maximum allowed page size (requests clamped to this)
220    ///
221    /// # Example
222    ///
223    /// ```ignore
224    /// pager.limits(10, 100)  // Smaller pages for this endpoint
225    /// ```
226    pub fn limits(mut self, default: u64, max: u64) -> Self {
227        self.limits = LimitCfg { default, max };
228        self
229    }
230
231    /// Execute paging and map models to domain DTOs.
232    ///
233    /// This is the terminal operation that:
234    /// 1. Applies security scope (tenant/resource filtering)
235    /// 2. Applies `OData` filter (if present in query)
236    /// 3. Applies cursor-based pagination
237    /// 4. Fetches limit+1 rows (to detect "has more")
238    /// 5. Maps entity models to domain DTOs
239    /// 6. Returns a `Page<D>` with items and pagination metadata
240    ///
241    /// # Type Parameters
242    ///
243    /// - `D`: The domain DTO type (result of mapping)
244    /// - `F`: Mapper function from `E::Model` to `D`
245    ///
246    /// # Parameters
247    ///
248    /// - `q`: `OData` query containing filter, order, cursor, and limit
249    /// - `map`: Function to convert entity models to domain DTOs
250    ///
251    /// # Errors
252    ///
253    /// Returns `ODataError` if:
254    /// - Security scope cannot be applied
255    /// - `OData` filter is invalid
256    /// - Database query fails
257    /// - Cursor is malformed or inconsistent
258    ///
259    /// # Example
260    ///
261    /// ```ignore
262    /// let page: Page<UserDto> = pager
263    ///     .fetch(&odata_query, |model| UserDto {
264    ///         id: model.id,
265    ///         name: model.name,
266    ///         email: model.email,
267    ///     })
268    ///     .await?;
269    /// ```
270    pub async fn fetch<D, F>(self, q: &ODataQuery, map: F) -> Result<Page<D>, ODataError>
271    where
272        E: ScopableEntity,
273        F: Fn(E::Model) -> D + Copy,
274    {
275        // Apply security scope first - this enforces tenant isolation
276        let select = E::find().secure().scope_with(self.scope).inner;
277
278        // Now apply OData filters, cursor, order, and limits
279        paginate_with_odata::<E, D, _, _>(
280            select,
281            self.conn,
282            q,
283            self.fmap,
284            self.tiebreaker,
285            self.limits,
286            map,
287        )
288        .await
289    }
290}