umbral_core/orm/mod.rs
1//! The ORM: declarative models, typed queries, and SQL generation.
2//!
3//! At M1 the design is intentionally narrow: one hardcoded model (`Post`),
4//! a single QuerySet type backed by sea-query, and basic predicates. No
5//! `Model` trait abstraction yet (that's M2), no derive macro (that's M3),
6//! no joins / aggregates / relations (later milestones). See
7//! `docs/specs/03-orm-querysets.md` for the target shape and the
8//! M1→M2→M3 progression.
9//!
10//! Module layout:
11//!
12//! - `post` — the hardcoded `Post` struct and its sibling column module.
13//! - `column` — column types (`StrCol`, `IntCol`, `NullableDateTimeCol`,
14//! etc.) carrying inherent methods that build `Predicate`s.
15//! - `queryset` — `QuerySet<T>` and `Manager<T>`, the chainable / lazy
16//! SQL builder plus its terminal methods.
17//!
18//! The shared types — `Predicate<T>` and `OrderExpr<T>` — live here in
19//! `mod.rs` so both `column` and `queryset` can reach them without
20//! crossing each other.
21
22pub mod aggregate;
23pub mod audit;
24pub mod choices;
25pub mod cleaners;
26pub mod column;
27pub mod dynamic;
28pub mod expr;
29pub mod file_field;
30pub mod foreign_key;
31pub mod forms_runtime;
32/// PostGIS spatial value type. Behind the `postgis` feature: a non-geo app
33/// compiles none of the geo stack. See [`gis::Geometry`].
34#[cfg(feature = "postgis")]
35pub mod gis;
36pub mod m2m;
37pub mod masked;
38pub mod model;
39pub mod multichoice;
40pub mod one_to_one;
41pub mod post;
42pub mod queryset;
43pub mod reverse_accessor;
44pub mod reverse_set;
45pub mod search;
46pub mod secrets;
47pub mod soft_delete_cascade;
48pub mod tsvector;
49pub mod validation;
50pub mod validators;
51pub mod write;
52
53use std::marker::PhantomData;
54use std::ops::{BitAnd, BitOr};
55
56pub use aggregate::{Aggregate, AggregateKind};
57
58/// Canonical string key for a primary-key (or FK) value, for bucketing
59/// relation children by their parent's PK in a `HashMap` / `HashSet`.
60///
61/// `serde_json::Value` is not `Hash`, and the relation-hydration paths
62/// need to group children by parent PK whatever the PK type — `i64`,
63/// `String`, `uuid::Uuid`. This is the **PK-agnostic** replacement for the
64/// historical `i64` keys: the value is namespaced by shape (`n:` number,
65/// `s:` string, `o:` other) so a numeric `42` and the string `"42"` never
66/// collide in the same bucket. Pairs with
67/// [`Model::pk_as_json`](crate::orm::Model::pk_as_json) and
68/// [`HydrateRelated::fk_id_for`](crate::orm::HydrateRelated::fk_id_for),
69/// both of which return a `serde_json::Value`.
70pub fn pk_key(value: &serde_json::Value) -> String {
71 match value {
72 serde_json::Value::Number(n) => format!("n:{n}"),
73 serde_json::Value::String(s) => format!("s:{s}"),
74 other => format!("o:{other}"),
75 }
76}
77
78/// Escape SQL `LIKE` wildcards in a user-supplied **literal** substring.
79///
80/// `contains` / `startswith` / `icontains` / the REST `__contains`
81/// family treat their argument as a literal to find, then wrap it in
82/// structural `%`. Without escaping, a user typing `%`, `_` or `\` would
83/// inject wildcards into the pattern — a search for `"100%"` matches
84/// every row starting with `100`, and `"a_b"` matches `axb` (ORM-1).
85/// This backslash-escapes the three LIKE metacharacters; the caller then
86/// adds its own structural `%` and pairs the predicate with
87/// `LikeExpr::escape('\\')` so the database honours the escape. Not SQL
88/// injection (the pattern is still a bound parameter) — a match-semantics
89/// correctness fix. The user-facing `.like()` / `.ilike()` builders take
90/// a raw pattern on purpose and must NOT call this.
91pub fn escape_like_literal(s: &str) -> String {
92 let mut out = String::with_capacity(s.len());
93 for ch in s.chars() {
94 if matches!(ch, '\\' | '%' | '_') {
95 out.push('\\');
96 }
97 out.push(ch);
98 }
99 out
100}
101
102/// A typed wrapper around a `sea_query::SelectStatement` for use in
103/// `col IN (SELECT col FROM ...)` predicates (gap #26).
104///
105/// Built by [`QuerySet::into_subquery`] or
106/// [`Manager::into_subquery`]; consumed by `IntCol::in_subquery` /
107/// `ForeignKeyCol::in_subquery` to produce a `Predicate`. The inner
108/// SelectStatement only knows the projected column the caller
109/// requested.
110pub struct Subquery {
111 inner: sea_query::SelectStatement,
112}
113
114impl Subquery {
115 /// Construct from a `SelectStatement` (internal — the
116 /// QuerySet/Manager helpers are the supported entry points).
117 pub(crate) fn from_select(inner: sea_query::SelectStatement) -> Self {
118 Self { inner }
119 }
120
121 /// Consume the wrapper and hand back the inner SelectStatement
122 /// — sea-query's `in_subquery` builder takes ownership.
123 pub(crate) fn into_statement(self) -> sea_query::SelectStatement {
124 self.inner
125 }
126}
127pub use choices::ChoiceField;
128pub use dynamic::{
129 Cmp, CsvImportReport, DynError, DynQuerySet, InsertedPk, decode_to_string, import_table_rows,
130 never_matches, typed_cmp_condition, typed_eq_condition, typed_json_value,
131};
132pub use expr::{F, FColExt, FExpr, Q};
133pub use file_field::{FileField, ImageField};
134pub use foreign_key::ForeignKey;
135pub use m2m::{M2M, load_junction_selection, set_junction_dynamic, set_junction_dynamic_in_tx};
136pub use masked::{MaskError, MaskKeyring, Masked, set_mask_keyring};
137pub use model::{
138 ArrayElement, DecimalSpec, FieldSpec, FkAction, GeometryKind, GeometrySpec, HydrateRelated,
139 M2MRelationSpec, Model, ModelBase, OneToOneRelationSpec, PrimaryKey, ReverseFkRelationSpec,
140 SqlType, concat_field_specs,
141};
142pub use multichoice::MultiChoice;
143pub use one_to_one::OneToOne;
144pub use post::Post;
145pub use queryset::{GetError, JoinKind, Manager, QuerySet, QuerySetTx, TryForEachError};
146pub use reverse_accessor::{ReverseError, ReverseRelations};
147pub use reverse_set::ReverseSet;
148pub use search::{Search, SearchHit, SearchSources, Searchable};
149pub use tsvector::TsVector;
150pub use validators::{Email, Slug, Url, ValidatorError, validate_text_format};
151pub use write::{SaveError, slugify};
152
153/// A typed boolean condition on rows of `T`.
154///
155/// Built by inherent methods on the column types in `column` and passed
156/// to `QuerySet::filter` / `QuerySet::exclude` to constrain a query. The
157/// type parameter `T` ties the predicate to its model so a `Predicate<Post>`
158/// can't accidentally be applied to a `QuerySet<Comment>`.
159///
160/// `Clone` is implemented manually (rather than derived) so the bound does
161/// not bleed onto `T` — `sea_query::SimpleExpr` is `Clone` regardless of
162/// whether `T` is. The `get_or_create` / `update_or_create` convergence path
163/// needs to re-issue the same predicate after a `UniqueViolation` re-fetch.
164pub struct Predicate<T> {
165 /// The default condition. Renders correctly on Postgres and on
166 /// any backend whose operators match sea-query's defaults.
167 pub(crate) cond: sea_query::SimpleExpr,
168 /// Optional SQLite-specific override. Set by predicates that need
169 /// dialect-specific rendering — Phase 4.2.2 JSON operators are the
170 /// first consumer (`json_extract` instead of Postgres's `->` /
171 /// `->>`). When `None`, `cond` is used for both backends. The
172 /// QuerySet picks at terminal time based on the resolved pool
173 /// variant.
174 pub(crate) cond_sqlite: Option<sea_query::SimpleExpr>,
175 _phantom: PhantomData<T>,
176}
177
178impl<T> Predicate<T> {
179 /// Build a `col = value` predicate by column name. Use when the
180 /// column constant isn't reachable at the call site — typically
181 /// generic-over-`T` helper functions in plugin code (e.g.
182 /// `authenticate<U: UserModel>` filtering on `"username"` without
183 /// knowing `U`'s column module).
184 ///
185 /// The typed sibling-module path (`my_model::USERNAME.eq(...)`) is
186 /// preferred when you have a concrete `T`, because it catches typos
187 /// at compile time. This constructor is the escape hatch for
188 /// genuinely-generic code.
189 pub fn col_eq(col: &'static str, value: impl Into<sea_query::Value>) -> Self {
190 let expr = sea_query::Expr::col(sea_query::Alias::new(col)).eq(value);
191 Self::new(expr)
192 }
193
194 pub(crate) fn new(cond: sea_query::SimpleExpr) -> Self {
195 Self {
196 cond,
197 cond_sqlite: None,
198 _phantom: PhantomData,
199 }
200 }
201
202 /// Construct a predicate that renders differently on each backend.
203 /// Phase 4.2.2's JSON-operator path uses this to ship one
204 /// predicate that resolves to `col -> 'a' ->> 'b'` under Postgres
205 /// and `json_extract(col, '$.a.b')` under SQLite.
206 pub(crate) fn new_with_sqlite(
207 cond: sea_query::SimpleExpr,
208 cond_sqlite: sea_query::SimpleExpr,
209 ) -> Self {
210 Self {
211 cond,
212 cond_sqlite: Some(cond_sqlite),
213 _phantom: PhantomData,
214 }
215 }
216
217 /// Pick the SimpleExpr appropriate for `backend_name` (`"sqlite"`
218 /// or `"postgres"`). Falls back to the default `cond` when no
219 /// SQLite override is set or the backend isn't SQLite. Cloning
220 /// the SimpleExpr is cheap (it's a tree of small enum values).
221 pub(crate) fn cond_for(&self, backend_name: &str) -> sea_query::SimpleExpr {
222 match backend_name {
223 "sqlite" => self
224 .cond_sqlite
225 .clone()
226 .unwrap_or_else(|| self.cond.clone()),
227 _ => self.cond.clone(),
228 }
229 }
230}
231
232/// Manual `Clone` for `Predicate<T>`.
233///
234/// `sea_query::SimpleExpr` is `Clone` regardless of `T`, so we implement the
235/// trait by hand rather than deriving it. A derived impl would add an
236/// unnecessary `T: Clone` bound that would propagate to every QuerySet caller.
237impl<T> Clone for Predicate<T> {
238 fn clone(&self) -> Self {
239 Self {
240 cond: self.cond.clone(),
241 cond_sqlite: self.cond_sqlite.clone(),
242 _phantom: PhantomData,
243 }
244 }
245}
246
247/// Compose two predicates with logical AND. Both per-backend variants
248/// combine element-wise — if either side has a SQLite override, the
249/// combined predicate carries the AND of (lhs's sqlite-or-default)
250/// with (rhs's sqlite-or-default). When neither side overrides, the
251/// combined predicate keeps `cond_sqlite = None` so the default render
252/// path stays uniform.
253impl<T> BitAnd for Predicate<T> {
254 type Output = Predicate<T>;
255 fn bitand(self, rhs: Predicate<T>) -> Predicate<T> {
256 let any_sqlite_override = self.cond_sqlite.is_some() || rhs.cond_sqlite.is_some();
257 let combined_sqlite = if any_sqlite_override {
258 let lhs_sql = self
259 .cond_sqlite
260 .clone()
261 .unwrap_or_else(|| self.cond.clone());
262 let rhs_sql = rhs.cond_sqlite.clone().unwrap_or_else(|| rhs.cond.clone());
263 Some(lhs_sql.and(rhs_sql))
264 } else {
265 None
266 };
267 Predicate {
268 cond: self.cond.and(rhs.cond),
269 cond_sqlite: combined_sqlite,
270 _phantom: PhantomData,
271 }
272 }
273}
274
275/// Compose two predicates with logical OR. Same backend-variant story
276/// as [`BitAnd`].
277impl<T> BitOr for Predicate<T> {
278 type Output = Predicate<T>;
279 fn bitor(self, rhs: Predicate<T>) -> Predicate<T> {
280 let any_sqlite_override = self.cond_sqlite.is_some() || rhs.cond_sqlite.is_some();
281 let combined_sqlite = if any_sqlite_override {
282 let lhs_sql = self
283 .cond_sqlite
284 .clone()
285 .unwrap_or_else(|| self.cond.clone());
286 let rhs_sql = rhs.cond_sqlite.clone().unwrap_or_else(|| rhs.cond.clone());
287 Some(lhs_sql.or(rhs_sql))
288 } else {
289 None
290 };
291 Predicate {
292 cond: self.cond.or(rhs.cond),
293 cond_sqlite: combined_sqlite,
294 _phantom: PhantomData,
295 }
296 }
297}
298
299/// An ordering directive for one column.
300///
301/// Built by `.asc()` / `.desc()` on a column constant and passed to
302/// `QuerySet::order_by`. The type parameter `T` ties the directive to its
303/// model the same way `Predicate<T>` does.
304pub struct OrderExpr<T> {
305 pub(crate) column: &'static str,
306 pub(crate) descending: bool,
307 _phantom: PhantomData<T>,
308}
309
310impl<T> OrderExpr<T> {
311 pub(crate) fn new(column: &'static str, descending: bool) -> Self {
312 Self {
313 column,
314 descending,
315 _phantom: PhantomData,
316 }
317 }
318}