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}