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}