Skip to main content

shared_framework/data/
query.rs

1//! Query building, per-request options, and paginated results.
2//!
3//! Provides [`QueryData`] (a SeaORM `Select` wrapper with pagination and
4//! relation-hydration steps), [`DeleteQueryData`] for bulk deletes,
5//! [`RepositoryOptions`] for limit/cursor/user/correlation settings,
6//! [`PageResult`] for cursor pages, and [`CursorData`] for opaque cursors.
7//!
8//! Build a [`QueryData`], optionally attach hydration with
9//! [`QueryData::with_traverser`], then run it through
10//! [`crate::data::PersistentRepository`] (`get_all`/`get_many`/`get_one`/
11//! `get_paginated_view` and the `*_traversed` variants).
12//!
13//! Cursors are base64-encoded JSON holding a `limit` and an `id` cursor.
14//! Selects filter on `id` in the direction recorded by the ordering
15//! (ascending uses `id > cursor`, descending uses `id < cursor`);
16//! deletes always use `id > cursor`.
17
18use crate::data::BaseEntity;
19use crate::logging::correlation::CorrelationContext;
20use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
21use chrono::Utc;
22use sea_orm::prelude::DateTimeWithTimeZone;
23use sea_orm::{entity::EntityLoaderTrait, DatabaseTransaction, EntityTrait};
24use serde::{Deserialize, Serialize};
25use std::sync::Arc;
26
27/// Default user type used when no caller identity is supplied.
28///
29/// Implements [`BaseEntity`] with `id` 0, a nil UUID, and current timestamps.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub struct NoUser;
32
33impl BaseEntity for NoUser {
34    type LoaderType = ();
35
36    fn load() -> Self::LoaderType {}
37
38    fn id(&self) -> i64 {
39        0
40    }
41    fn uid(&self) -> uuid::Uuid {
42        uuid::Uuid::nil()
43    }
44    fn created_at(&self) -> DateTimeWithTimeZone {
45        Utc::now().into()
46    }
47    fn updated_at(&self) -> DateTimeWithTimeZone {
48        Utc::now().into()
49    }
50}
51
52/// Opaque pagination cursor carrying the page size and last-seen `id`.
53///
54/// Encoded as base64 JSON by [`CursorData::encode`]; decoded back by
55/// [`CursorData::decode`], which returns `None` for malformed input.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct CursorData {
58    /// Page size the cursor was issued for.
59    pub limit: usize,
60    /// Last-seen row `id`; the next page continues after (or before) it.
61    pub cursor: i64,
62}
63
64impl CursorData {
65    /// Encodes this cursor as a base64 JSON string.
66    pub fn encode(&self) -> String {
67        let json = serde_json::to_string(self).unwrap_or_default();
68        BASE64.encode(json.as_bytes())
69    }
70    /// Decodes a string produced by [`CursorData::encode`].
71    ///
72    /// Returns `None` when the input is not valid base64, not valid UTF-8,
73    /// or not valid cursor JSON.
74    pub fn decode(s: &str) -> Option<Self> {
75        let bytes = BASE64.decode(s).ok()?;
76        let json = String::from_utf8(bytes).ok()?;
77        serde_json::from_str(&json).ok()
78    }
79}
80
81/// Per-request settings for repository queries.
82///
83/// `U` is the caller identity type and must implement [`BaseEntity`]; it
84/// defaults to [`NoUser`]. An identity can be supplied either as a full
85/// entity (`user`) or as a raw id (`user_id`).
86///
87/// An optional database transaction can be attached via
88/// [`RepositoryOptions::with_transaction`]. When present, repository
89/// operations run against that transaction; otherwise they use the
90/// repository's own connection.
91#[derive(Debug, Clone)]
92pub struct RepositoryOptions<U = NoUser>
93where
94    U: BaseEntity + Clone + Send + Sync + 'static,
95{
96    /// Maximum rows for limited reads; default is 15.
97    pub limit: usize,
98    /// Opaque cursor from a previous page, if continuing pagination.
99    pub cursor: Option<String>,
100    /// Whether selects use `SELECT DISTINCT`.
101    pub distinct: bool,
102    /// Detach flag carried alongside the query; default is `true`.
103    pub detach: bool,
104    /// Caller identity as an entity, if supplied.
105    pub user: Option<U>,
106    /// Caller identity as a raw id, if supplied.
107    pub user_id: Option<i64>,
108    /// Request correlation propagated with the query, if supplied.
109    pub correlation: Option<Arc<CorrelationContext>>,
110    /// Optional transaction used as the query executor when set.
111    ///
112    /// Held behind an [`Arc`] so the options stay cheaply cloneable even
113    /// though [`DatabaseTransaction`] itself is neither `Clone` nor `Send`-free
114    /// to move. Share with `Arc::new(txn)` — note a begun transaction cannot
115    /// be cloned out of SeaORM, so callers typically wrap it at begin time
116    /// (see [`RepositoryOptions::with_transaction`]).
117    pub txn: Option<Arc<DatabaseTransaction>>,
118}
119
120impl<U> Default for RepositoryOptions<U>
121where
122    U: BaseEntity + Clone + Send + Sync + 'static,
123{
124    fn default() -> Self {
125        Self {
126            limit: 15,
127            cursor: None,
128            distinct: false,
129            detach: true,
130            user: None,
131            user_id: None,
132            correlation: None,
133            txn: None,
134        }
135    }
136}
137
138impl<U> RepositoryOptions<U>
139where
140    U: BaseEntity + Clone + Send + Sync + 'static,
141{
142    /// Creates options with defaults: limit 15, no cursor, `distinct` false, `detach` true.
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    /// Sets the maximum rows for limited reads.
148    pub fn with_limit(mut self, limit: usize) -> Self {
149        self.limit = limit;
150        self
151    }
152
153    /// Sets the pagination cursor to continue from.
154    pub fn with_cursor(mut self, cursor: impl Into<String>) -> Self {
155        self.cursor = Some(cursor.into());
156        self
157    }
158
159    /// Sets the caller identity as an entity; also records its `id` as `user_id`.
160    pub fn with_user(mut self, user: U) -> Self {
161        self.user_id = Some(user.id());
162        self.user = Some(user);
163        self
164    }
165
166    /// Sets the caller identity as a raw id without an entity.
167    pub fn with_user_id(mut self, user_id: i64) -> Self {
168        self.user_id = Some(user_id);
169        self
170    }
171
172    /// Attaches request correlation context to the options.
173    pub fn with_correlation(mut self, ctx: Arc<CorrelationContext>) -> Self {
174        self.correlation = Some(ctx);
175        self
176    }
177
178    /// Enables `SELECT DISTINCT` for the query.
179    pub fn distinct(mut self) -> Self {
180        self.distinct = true;
181        self
182    }
183
184    /// Sets the detach flag carried alongside the query.
185    pub fn with_detach(mut self, detach: bool) -> Self {
186        self.detach = detach;
187        self
188    }
189
190    /// Attaches a transaction used as the executor for database operations.
191    ///
192    /// When set, repository methods run against this transaction instead of
193    /// the repository's own connection. When unset, they fall back to the
194    /// repository connection.
195    pub fn with_transaction(mut self, txn: Arc<DatabaseTransaction>) -> Self {
196        self.txn = Some(txn);
197        self
198    }
199
200    /// Alias for [`RepositoryOptions::with_transaction`].
201    pub fn with_txn(mut self, txn: Arc<DatabaseTransaction>) -> Self {
202        self.txn = Some(txn);
203        self
204    }
205
206    /// Removes any attached transaction, restoring execution on the repository connection.
207    pub fn without_transaction(mut self) -> Self {
208        self.txn = None;
209        self
210    }
211
212    /// Returns the attached transaction, if any.
213    pub fn transaction(&self) -> Option<&DatabaseTransaction> {
214        self.txn.as_deref()
215    }
216
217    /// Returns true when a transaction is attached.
218    pub fn has_transaction(&self) -> bool {
219        self.txn.is_some()
220    }
221
222    /// Returns the effective caller id: `user_id` when set, else the `user` entity id.
223    pub fn user_id(&self) -> Option<i64> {
224        self.user_id.or_else(|| self.user.as_ref().map(|u| u.id()))
225    }
226
227    /// Overlays `other` on top of `self`, joining two parameter sets as you please.
228    ///
229    /// Scalar fields (`limit`, `distinct`, `detach`) are taken from `other`;
230    /// optional fields (`cursor`, `user`, `user_id`, `correlation`, `txn`) fall back
231    /// to `self` when `other` leaves them unset. A replaced `user` entity
232    /// clears a stale inherited `user_id` (the id is still derivable from the
233    /// entity via [`RepositoryOptions::user_id`]).
234    ///
235    /// Typical use: request-derived base joined with caller overrides —
236    /// `RepositoryOptions::from_ctx(ctx).join(RepositoryOptions::new().with_limit(50))`.
237    pub fn join(mut self, other: Self) -> Self {
238        self.limit = other.limit;
239        self.distinct = other.distinct;
240        self.detach = other.detach;
241        self.cursor = other.cursor.or(self.cursor);
242        if other.user.is_some() {
243            self.user = other.user;
244            // Keep the id consistent with the newly joined entity unless the
245            // caller explicitly joined an id alongside it.
246            if other.user_id.is_none() {
247                self.user_id = None;
248            } else {
249                self.user_id = other.user_id;
250            }
251        } else {
252            self.user_id = other.user_id.or(self.user_id);
253        }
254        self.correlation = other.correlation.or(self.correlation);
255        self.txn = other.txn.or(self.txn);
256        self
257    }
258}
259
260// Convenience for NoUser — with_user_id without entity
261impl RepositoryOptions<NoUser> {
262    /// Builds options from the request context: pagination limit and cursor,
263    /// the numeric user id when the context carries one, and the correlation itself.
264    /// Join caller overrides on top via [`RepositoryOptions::join`].
265    pub fn from_ctx(ctx: Arc<CorrelationContext>) -> Self {
266        Self {
267            limit: ctx.pagination_limit(),
268            cursor: ctx.pagination_cursor(),
269            user_id: ctx.user_id().and_then(|s| s.parse().ok()),
270            correlation: Some(ctx),
271            ..Self::default()
272        }
273    }
274
275    /// Replaces the [`NoUser`] identity with the given entity, keeping other settings.
276    pub fn with_user_entity<U>(self, user: U) -> RepositoryOptions<U>
277    where
278        U: BaseEntity + Clone + Send + Sync + 'static,
279    {
280        RepositoryOptions {
281            limit: self.limit,
282            cursor: self.cursor,
283            distinct: self.distinct,
284            detach: self.detach,
285            user: Some(user),
286            user_id: None,
287            correlation: self.correlation,
288            txn: self.txn,
289        }
290    }
291}
292
293/// One cursor page of rows.
294///
295/// `T` is the row model type. `data` serializes as `items`, `has_next` as
296/// `hasNext`, `next_cursor` as `next`, `total` as `count`, and `limit` as `limit`.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct PageResult<T> {
299    /// Rows in this page, serialized as `items`.
300    #[serde(rename = "items")]
301    pub data: Vec<T>,
302
303    /// Whether another page follows, serialized as `hasNext`.
304    #[serde(rename = "hasNext")]
305    pub has_next: bool,
306
307    /// Cursor that produced this page, if any.
308    #[serde(rename = "cursor", skip_serializing_if = "Option::is_none")]
309    pub cursor: Option<String>,
310
311    /// Cursor for the next page (`None` when `has_next` is false), serialized as `next`.
312    #[serde(rename = "next", skip_serializing_if = "Option::is_none")]
313    pub next_cursor: Option<String>,
314
315    /// Total matching rows ignoring cursor and limit, serialized as `count`.
316    #[serde(rename = "count")]
317    pub total: i64,
318
319    /// Page size used for this page.
320    #[serde(rename = "limit")]
321    pub limit: usize,
322}
323
324impl<T> PageResult<T> {
325    /// Returns the rows in this page.
326    pub fn items(&self) -> &[T] {
327        &self.data
328    }
329    /// Returns the page size recorded in the next-page cursor, if present and valid.
330    pub fn limit(&self) -> Option<usize> {
331        self.next_cursor
332            .as_ref()
333            .and_then(|c| CursorData::decode(c))
334            .map(|d| d.limit)
335    }
336}
337
338/// Builder wrapping a SeaORM entity loader with pagination and hydration settings.
339///
340/// `E` is the SeaORM entity being queried; its model must implement [`BaseEntity`]
341/// so cursor pagination can use `id` comparisons. `order_asc` records the
342/// cursor direction (`Some(true)` ascending, `Some(false)` descending).
343pub struct QueryData<E>
344where
345    E: EntityTrait,
346    E::ModelEx: BaseEntity,
347    <E::ModelEx as BaseEntity>::LoaderType: EntityLoaderTrait<E, ModelEx = E::ModelEx>,
348{
349    /// The entity loader used to load relations for this entity type.
350    pub loader: <E::ModelEx as BaseEntity>::LoaderType,
351
352    /// Limit/cursor/distinct/detach settings applied at execution time.
353    pub opts: RepositoryOptions<NoUser>,
354
355    /// Recorded ordering direction for cursor filtering, if any ordering was set.
356    pub order_asc: Option<bool>,
357}
358
359impl<E> QueryData<E>
360where
361    E: EntityTrait,
362    E::ModelEx: BaseEntity,
363    <E::ModelEx as BaseEntity>::LoaderType: EntityLoaderTrait<E, ModelEx = E::ModelEx>,
364{
365    /// Wraps an entity loader with default options and no ordering.
366    pub fn new(loader: <E::ModelEx as BaseEntity>::LoaderType) -> Self {
367        Self {
368            loader,
369            opts: RepositoryOptions::default(),
370            order_asc: None,
371        }
372    }
373
374    /// Copies limit, cursor, distinct, detach, correlation, and transaction settings from the given options.
375    pub fn with_options<U>(mut self, opts: RepositoryOptions<U>) -> Self
376    where
377        U: BaseEntity + Clone + Send + Sync + 'static,
378    {
379        self.opts.limit = opts.limit;
380        self.opts.cursor = opts.cursor;
381        self.opts.distinct = opts.distinct;
382        self.opts.detach = opts.detach;
383        self.opts.correlation = opts.correlation;
384        self.opts.txn = opts.txn;
385        self
386    }
387
388    /// Orders by `id` and records the direction for cursor filtering.
389    pub fn order_by(mut self, asc: bool) -> Self {
390        if asc {
391            self.loader = self.loader.order_by_id_asc();
392        } else {
393            self.loader = self.loader.order_by_id_desc();
394        }
395        self.order_asc = Some(asc);
396        self
397    }
398
399    /// Orders by ascending `id` and records ascending cursor direction.
400    pub fn order_by_asc(mut self) -> Self {
401        self.loader = self.loader.order_by_id_asc();
402        self.order_asc = Some(true);
403        self
404    }
405
406    /// Orders by descending `id` and records descending cursor direction.
407    pub fn order_by_desc(mut self) -> Self {
408        self.loader = self.loader.order_by_id_desc();
409        self.order_asc = Some(false);
410        self
411    }
412
413    /// Marks the select distinct and records the distinct flag in options.
414    pub fn distinct(mut self) -> Self {
415        self.opts.distinct = true;
416        self
417    }
418
419    /// Applies a filter to the loader via the given closure.
420    pub fn filter<F>(mut self, f: F) -> Self
421    where
422        F: FnOnce(<E::ModelEx as BaseEntity>::LoaderType) -> <E::ModelEx as BaseEntity>::LoaderType,
423    {
424        self.loader = f(self.loader);
425        self
426    }
427
428    /// Sets the select limit and records it in options.
429    pub fn with_limit(mut self, limit: u64) -> Self {
430        self.opts.limit = limit as usize;
431        self
432    }
433}
434
435/// Bulk-delete wrapper around a SeaORM `DeleteMany`.
436///
437/// `E` is the SeaORM entity being deleted. Cursor filtering for deletes
438/// always uses `id > cursor`, regardless of ordering.
439pub struct DeleteQueryData<E: EntityTrait> {
440    /// The underlying delete being built.
441    pub delete: sea_orm::DeleteMany<E>,
442}
443
444impl<E: EntityTrait> DeleteQueryData<E> {
445    /// Wraps a SeaORM delete-many for repository execution.
446    pub fn new(delete: sea_orm::DeleteMany<E>) -> Self {
447        Self { delete }
448    }
449
450    /// Applies a filter to the delete via the given closure.
451    pub fn filter<F>(mut self, f: F) -> Self
452    where
453        F: FnOnce(sea_orm::DeleteMany<E>) -> sea_orm::DeleteMany<E>,
454    {
455        self.delete = f(self.delete);
456        self
457    }
458}