umbral_core/orm/model.rs
1//! The `Model` trait: the abstraction every umbral model implements.
2//!
3//! At M2 the trait is implemented by hand (`impl Model for Post` lives
4//! in `post.rs`). At M3 the same impl is generated by a
5//! `#[derive(Model)]` proc macro. M4 hooks into `FIELDS` for the
6//! field/backend compatibility system check. M5 hooks into `FIELDS` for
7//! the migration engine's snapshot diff. The trait is intentionally
8//! narrow at M2 — primary-key type, table name, field metadata, and
9//! that's it.
10//!
11//! Through Phase 2 of the Postgres rollout `Model` carried
12//! `for<'r> sqlx::FromRow<'r, SqliteRow>` as a supertrait so the
13//! QuerySet terminals could blanket on `T: Model`. Phase 2.5 drops
14//! that supertrait: the user struct still uses `#[derive(sqlx::FromRow)]`
15//! (which emits a generic `impl<'r, R: Row> FromRow<'r, R>` covering
16//! both SQLite and Postgres rows), and the QuerySet terminals carry
17//! the FromRow bound on the method, not the trait — so the same
18//! `Manager<T>` works on either backend.
19//!
20//! See `docs/specs/04-orm-model-and-fields.md` for the target shape and
21//! the M2→M3→M4→M5 progression.
22
23/// Trait for eagerly hydrating `ForeignKey<U>.resolved` fields by name.
24///
25/// `#[derive(Model)]` emits this impl for every model. Models with no FK
26/// fields get a no-op impl; models with FK fields get a `hydrate_fk` body
27/// that matches on `field_name` and a `fk_id_for` body that returns the raw
28/// FK integer for a named field.
29///
30/// The `select_related` machinery in `QuerySet` calls these two methods in
31/// sequence: first `fk_id_for` to collect the IDs to batch-fetch, then
32/// `hydrate_fk` with the fetched JSON to populate `ForeignKey<U>.resolved`.
33///
34/// `U` must implement `serde::Deserialize` for `hydrate_fk` to succeed.
35/// All umbral models already derive `Deserialize`, so this bound is always
36/// satisfied in practice.
37pub trait HydrateRelated {
38 /// Return the raw FK value stored in the field named `field_name`,
39 /// or `None` if the field doesn't exist on this model or is not a FK.
40 ///
41 /// Used by `select_related` to collect all FK ids from the result
42 /// set before running the batch `IN (...)` lookup.
43 ///
44 /// PK lift Pass D: returns `Option<serde_json::Value>` (was
45 /// `Option<i64>`) so FK targets keyed by `String` / `Uuid` /
46 /// composite codename flow through the typed select_related
47 /// path. The macro emits `serde_json::to_value(self.<field>.id())`
48 /// — works for any `Serialize` PK type without per-target
49 /// specialization. Integer-PK targets carry through as
50 /// `Value::Number`; the existing i64 hot path is unchanged at
51 /// the JSON layer.
52 fn fk_id_for(&self, field_name: &str) -> Option<serde_json::Value>;
53
54 /// Set `ForeignKey<U>.resolved` for the field named `field_name` by
55 /// deserialising `row` as the target model type.
56 ///
57 /// A field name that doesn't match any FK on this model is silently
58 /// ignored (a noop). Deserialisation errors are also silently swallowed —
59 /// the FK keeps its raw-integer form without a resolved object. This
60 /// is intentional: a bad `select_related` name is a
61 /// programming error caught in tests, not a runtime panic.
62 fn hydrate_fk(&mut self, field_name: &str, row: &serde_json::Value);
63
64 /// Set the `parent_id` cache on every `M2M<U>` field this model
65 /// owns. Closes the second BUG-16 gap: without this, `m2m.add(&t)`
66 /// silently writes a junction row with `parent_id = 0` because the
67 /// macro skips M2M fields in the `FromRow` decode path.
68 ///
69 /// Called by QuerySet terminals after each row is decoded. The
70 /// macro emits a body that walks the model's M2M fields and calls
71 /// `set_parent_id(self.<pk>)` on each — so loading a `Group`
72 /// gives every `M2M<U>` slot on it the right `parent_id` to write
73 /// against.
74 ///
75 /// Default: no-op. The macro-emitted body shadows this for any
76 /// model that declares an M2M field. A model with no M2M fields
77 /// inherits the default and pays nothing.
78 ///
79 /// PK-agnostic: the macro sets each `M2M<Child, P>` field's parent id
80 /// from `self.<pk>` via the typed [`crate::orm::M2M::set_parent_id`],
81 /// so it works for **any** parent PK type — `i64`, `String`,
82 /// `uuid::Uuid`. A non-i64-PK parent declares the field with the
83 /// matching `P` (e.g. `M2M<Student, String>`); `P` defaults to `i64`.
84 fn set_m2m_parent_ids(&mut self) {}
85
86 /// Return this row's primary key as a `serde_json::Value`, whatever
87 /// the PK type — `i64`, `String`, `uuid::Uuid`, a custom newtype.
88 /// The relation-hydration paths (`prefetch_related`, reverse-FK and
89 /// reverse-OneToOne collection) bucket children by the parent's PK,
90 /// and keying those buckets on a `Value` (canonicalised via
91 /// [`crate::orm::pk_key`]) lets UUID- and slug-PK models flow through
92 /// too, not just i64.
93 ///
94 /// Default: `None`. The `#[derive(Model)]` macro emits an override for
95 /// every model that returns `to_value(&self.<pk>)` — so a hand-written
96 /// `Model` impl that doesn't override it simply opts out of the
97 /// Value-keyed hydration (a forgive-and-skip posture).
98 fn pk_as_json(&self) -> Option<serde_json::Value> {
99 None
100 }
101
102 /// Attach a list of pre-fetched child rows to the named `M2M<U>`
103 /// field's `resolved` slot. Called by `QuerySet::prefetch_related`
104 /// (gap #19) after a batched JOIN through the junction table
105 /// returns one Vec<U> per parent.
106 ///
107 /// `rows` carries the child rows as JSON objects ready for
108 /// `serde_json::from_value::<U>(...)`. Decoding failures (e.g. a
109 /// row that doesn't match the target struct shape) silently drop
110 /// that one row from the resolved set — same forgive-and-continue
111 /// posture as `hydrate_fk` for `select_related`.
112 ///
113 /// A field name that doesn't match any M2M field on this model is
114 /// a no-op. The macro-emitted body pattern-matches the M2M fields
115 /// declared on this struct; the default below is empty so models
116 /// without M2M fields pay nothing.
117 fn set_m2m_resolved_json(&mut self, _field_name: &str, _rows: Vec<serde_json::Value>) {}
118
119 /// Gap #44 — attach a list of pre-fetched child rows to the
120 /// named `ReverseSet<C>` field's `resolved` slot. Counterpart
121 /// to `set_m2m_resolved_json` but for reverse-FK collections
122 /// (one parent, many children pointing at it via a FK column).
123 ///
124 /// Called by `QuerySet::prefetch_related` after the batched
125 /// `SELECT * FROM <child> WHERE <fk_col> IN (parent_pks)` query
126 /// returns child rows grouped by `<fk_col>` value.
127 ///
128 /// `rows` carries the child rows as JSON objects ready for
129 /// `serde_json::from_value::<C>(...)`. Decoding failures
130 /// silently drop that one row — same forgive-and-continue
131 /// posture as the M2M variant.
132 ///
133 /// A field name that doesn't match any `ReverseSet` field on
134 /// this model is a no-op. The macro-emitted body pattern-
135 /// matches the ReverseSet fields declared on this struct; the
136 /// default below is empty so models without reverse-FK fields
137 /// pay nothing.
138 fn set_reverse_fk_resolved_json(&mut self, _field_name: &str, _rows: Vec<serde_json::Value>) {}
139
140 /// Reverse-OneToOne counterpart to
141 /// `set_reverse_fk_resolved_json`. Called by `prefetch_related`
142 /// with `Some(child_json)` when the runtime FK lookup found
143 /// exactly one matching child, or `None` when no child matched
144 /// (the slot still flips `is_loaded()` to `true`).
145 ///
146 /// Default: no-op. The macro emits per-field arms for any model
147 /// declaring `pub <name>: OneToOne<C>` fields.
148 fn set_one_to_one_resolved_json(&mut self, _field_name: &str, _row: Option<serde_json::Value>) {
149 }
150
151 /// Move form-staged M2M pending ids from `self` into `dest`,
152 /// field by field. The typed `create()` builds its INSERT from the
153 /// caller's instance, then reads a *fresh* row back from the DB
154 /// (carrying the autoincremented PK) — the pending ids staged by the
155 /// Form derive live on the caller's instance, not the readback row.
156 /// This hook transfers them across so `write_pending_m2m` on the
157 /// readback row (which has the real parent_id seeded) finds them.
158 /// Default: no-op for models with no M2M fields.
159 fn take_pending_m2m_into(&mut self, _dest: &mut Self) {}
160
161 /// Flush form-staged M2M selections to their junction tables after
162 /// the parent row was inserted. The macro emits a body that walks
163 /// this model's M2M fields, reads `parent_id` + `junction_table`
164 /// (seeded by `set_m2m_parent_ids`) and the pending child ids, and
165 /// calls `set_junction_dynamic`. Default: no-op for models with no
166 /// M2M fields.
167 ///
168 /// Async + boxed (rather than `#[async_trait]` on the whole trait)
169 /// so `HydrateRelated`'s existing non-async methods stay as they
170 /// are. Junction writes hit the DB, so this is kept off the hot
171 /// decode path — only the typed `create()` calls it.
172 fn write_pending_m2m<'a>(
173 &'a mut self,
174 ) -> std::pin::Pin<
175 Box<
176 dyn std::future::Future<Output = Result<(), crate::orm::write::WriteError>> + Send + 'a,
177 >,
178 > {
179 Box::pin(async { Ok(()) })
180 }
181}
182
183/// The trait every model implements.
184///
185/// Read at runtime to build queries (`T::TABLE`, `T::FIELDS`), at boot
186/// to validate field/backend compatibility (M4), and at migration time
187/// to diff against the last snapshot (M5).
188///
189/// `Model` is metadata-only — it carries no row-materialization bound.
190/// QuerySet terminals add `for<'r> FromRow<'r, R>` for the row type
191/// they need at the call site (sqlite or postgres). User structs pick
192/// up both impls via a single `#[derive(sqlx::FromRow)]` because
193/// sqlx's derive emits a generic-over-`R` impl.
194pub trait Model: Sized + Send + Sync + Unpin + 'static {
195 /// The primary-key type. M2 supports `i64` only; UUID lands later.
196 type PrimaryKey: PrimaryKey;
197
198 /// The struct name, used by the migration engine (M5) to label
199 /// snapshot entries and to map autodetected operations back to
200 /// the model that produced them. The M3 derive emits the struct
201 /// ident verbatim ("Post", "Comment", etc.).
202 const NAME: &'static str;
203
204 /// The SQL table name. M3's derive defaults this to the
205 /// `snake_case` of the struct name unless `#[umbral(table = "...")]`
206 /// overrides it.
207 const TABLE: &'static str;
208
209 /// The SQL table name as a call — `UserProfile::table_name()` → `"profile"`.
210 ///
211 /// A convenience over the [`TABLE`](Self::TABLE) associated const so callers
212 /// never hardcode the table string (which can diverge from the struct name,
213 /// e.g. `UserProfile` → `profile`) and don't need the
214 /// `<UserProfile as Model>::TABLE` turbofish. With `Model` in scope (it's in
215 /// the prelude) `UserProfile::table_name()` resolves directly.
216 fn table_name() -> &'static str {
217 Self::TABLE
218 }
219
220 /// The app label (the owning plugin's name) this model belongs to.
221 ///
222 /// Sourced from `#[umbral(plugin = "...")]`; defaults to `"app"` (the
223 /// registry's default key) when the attribute is absent. Authoritative
224 /// for permission codenames (gaps2 #80g): `umbral-permissions` reads this
225 /// to build `<app_label>.<verb>_<model>` codenames, instead of splitting
226 /// the table name at the first `_` (which collided distinct models).
227 const APP_LABEL: &'static str = "app";
228
229 /// Static metadata for every field on the model.
230 ///
231 /// One [`FieldSpec`] per field, in declaration order. Read by the
232 /// QuerySet (to build the SELECT column list), by the system check
233 /// (M4) for field/backend compatibility, and by the migration
234 /// engine (M5) for snapshot diffing.
235 const FIELDS: &'static [FieldSpec];
236
237 /// Human-readable display name for this model, used by the admin
238 /// sidebar as the default label. Defaults to `Self::NAME`.
239 ///
240 /// Override via `#[umbral(display = "Users")]` on the struct.
241 const DISPLAY: &'static str = Self::NAME;
242
243 /// Lucide icon slug shown next to this model in the admin sidebar.
244 /// Defaults to `"database"`. Any valid Lucide icon name works; unknown
245 /// names are silently ignored by Lucide at render time.
246 ///
247 /// Override via `#[umbral(icon = "users")]` on the struct.
248 const ICON: &'static str = "database";
249
250 /// Database alias this model lives on, when the app registers more
251 /// than one pool via `AppBuilder::database(...)`. `None` (the
252 /// default) means "use whatever the owning plugin chose via
253 /// `Plugin::database()`, or `\"default\"` if neither side
254 /// overrode."
255 ///
256 /// Override via `#[umbral(database = "analytics")]` on the struct.
257 /// Per-model wins over per-plugin — useful for a single plugin
258 /// that owns one model on the primary DB and another on an
259 /// archive/analytics DB.
260 const DATABASE: Option<&'static str> = None;
261
262 /// Single-row-marker. When `true`, the admin auto-redirects the
263 /// list view to the (sole) row's edit form, hides the "+ New"
264 /// button, and surfaces the model as a settings-style screen.
265 /// The single-row settings model pattern. Set via
266 /// `#[umbral(singleton)]` on the struct. Closes BUG-9 in
267 /// `bugs/tests/testBugs.md`.
268 ///
269 /// Default `false`. Default-row seeding (so the first admin
270 /// visit doesn't 404) is the user's responsibility — typically
271 /// a one-liner in `Plugin::on_ready` that calls
272 /// `T::objects().create(T::default()).await` if the count is
273 /// zero. A future framework helper could automate that; for v1
274 /// the trait const is enough to let admin and any third-party
275 /// tool know the model is singleton-shaped.
276 const SINGLETON: bool = false;
277
278 /// Feature #72 — soft-delete marker. Set via
279 /// `#[umbral(soft_delete)]` on the struct. When true, the
280 /// framework treats this model as having a `deleted_at:
281 /// Option<DateTime<Utc>>` column (which the user MUST declare
282 /// — derive macros can't add fields to the input struct), and:
283 ///
284 /// - Every `QuerySet<T>` terminal auto-injects
285 /// `WHERE deleted_at IS NULL` so soft-deleted rows are
286 /// invisible by default.
287 /// - `Manager::delete_instance(&row)` and `QuerySet::delete()`
288 /// issue `UPDATE table SET deleted_at = NOW() WHERE ...`
289 /// instead of a hard `DELETE FROM table WHERE ...`.
290 /// - Callers who actually want the soft-deleted rows (admin
291 /// trash views, audit dumps, undelete flows) opt back in
292 /// per-query via `.with_deleted()` or `.only_deleted()`.
293 /// - Callers who need a hard DELETE (GDPR purge, etc.) use
294 /// `.hard_delete()` to bypass the soft path on a per-call
295 /// basis.
296 ///
297 /// Default false so existing models compile unchanged.
298 const SOFT_DELETE: bool = false;
299
300 /// Composite-UNIQUE constraints. Each inner slice names a
301 /// constraint over the listed column names. Set via
302 /// `#[umbral(unique_together = [["a", "b"]])]`. Closes BUG-6 in
303 /// `bugs/tests/testBugs.md`. Default empty; the migration engine
304 /// emits one `UNIQUE (col1, col2)` clause per inner group on
305 /// `CREATE TABLE`.
306 const UNIQUE_TOGETHER: &'static [&'static [&'static str]] = &[];
307
308 /// Multi-column indexes. Each inner slice names an index over
309 /// the listed columns. Set via
310 /// `#[umbral(indexes = [["tenant_id", "created_at"]])]`. Closes
311 /// BUG-7. Default empty; the migration engine emits
312 /// `CREATE INDEX IF NOT EXISTS idx_<table>_<col1>_<col2>` after
313 /// the `CREATE TABLE`. Single-column indexes stay on the field
314 /// attribute (`#[umbral(index)]`).
315 const INDEXES: &'static [&'static [&'static str]] = &[];
316
317 /// Default `ORDER BY` clause, applied when a QuerySet terminates
318 /// without an explicit `order_by`. Each tuple is `(column_name,
319 /// is_descending)`. Set via
320 /// `#[umbral(ordering = ["-published_at", "id"])]` (leading `-`
321 /// flips to DESC). Closes BUG-8. Default empty.
322 const ORDERING: &'static [(&'static str, bool)] = &[];
323
324 /// Field names to STRIP from signal payloads (audit_2 core-app-config #10).
325 /// Set per-field via `#[umbral(signal_skip)]`. The ORM signal emitters
326 /// (`pre/post_save`, `pre/post_delete`, `pre/post_update`) serialize the
327 /// whole row into the `"instance"` payload that fans out to every
328 /// subscriber; a subscriber that logs or persists payloads (the natural
329 /// audit-log shape) would otherwise copy password hashes, tokens, and PII
330 /// into logs / secondary stores. Listed fields are removed from the
331 /// serialized instance before it is emitted. Default empty (full row).
332 const SIGNAL_SKIP_FIELDS: &'static [&'static str] = &[];
333
334 /// Many-to-many relations declared on this model. Each entry names
335 /// a field and its target model. The migration engine uses this to
336 /// auto-generate junction tables; the admin uses it to render M2M
337 /// pickers. Default empty.
338 const M2M_RELATIONS: &'static [M2MRelationSpec] = &[];
339
340 /// Gap #44 — reverse-FK collections declared on this model via
341 /// `#[umbral(reverse_fk = "<fk_col>")] pub <name>: ReverseSet<C>`.
342 /// Each entry tells `prefetch_related` how to fetch the children:
343 /// `SELECT * FROM <target_table> WHERE <fk_column> IN (parent_pks)`
344 /// then group by `<fk_column>` value, populate each parent's
345 /// `ReverseSet.resolved`. Default empty; the macro emits one
346 /// entry per declared `ReverseSet<C>` field.
347 const REVERSE_FK_RELATIONS: &'static [ReverseFkRelationSpec] = &[];
348
349 /// Reverse OneToOne accessors declared on this model via
350 /// `pub <name>: OneToOne<C>` (no umbral attribute required).
351 /// Unlike `REVERSE_FK_RELATIONS`, the FK column on the child is
352 /// not named at macro time — `prefetch_related` looks it up at
353 /// runtime by scanning the child's `FIELDS` for the UNIQUE FK
354 /// pointing back at this model's table. Exactly one match
355 /// required; 0 or 2+ matches surface a loud error naming the
356 /// ambiguity.
357 const ONE_TO_ONE_RELATIONS: &'static [OneToOneRelationSpec] = &[];
358
359 /// Return the primary key of this instance.
360 fn primary_key(&self) -> Self::PrimaryKey;
361}
362
363/// Static metadata for one many-to-many relation declared on a model.
364///
365/// Carried by `Model::M2M_RELATIONS`. The migration engine uses this
366/// to emit `CREATE TABLE` for the junction table; the admin uses it
367/// to know which fields render as multi-select pickers.
368#[derive(Debug, Clone, PartialEq, Eq)]
369pub struct M2MRelationSpec {
370 /// The Rust field name (e.g. `"tags"`).
371 pub field_name: &'static str,
372 /// The target model's table name (e.g. `"tag"`).
373 pub target_table: &'static str,
374 /// The target model's struct name (e.g. `"Tag"`). Used for reverse
375 /// accessor lookups and OpenAPI schema references.
376 pub target_name: &'static str,
377}
378
379/// Static metadata for one reverse OneToOne field on a model. The
380/// FK column on the child is intentionally omitted — `prefetch_related`
381/// resolves it at runtime by scanning the child's `FIELDS` for the
382/// UNIQUE FK pointing back at `target_table`. Carried by
383/// [`Model::ONE_TO_ONE_RELATIONS`].
384///
385/// Example: `pub struct User { pub profile: OneToOne<Profile>, ... }`
386/// emits one entry: `{ field_name: "profile", target_table:
387/// "profile", target_name: "Profile" }`. At prefetch time the loader
388/// finds the column on Profile (`pub user: ForeignKey<User>` with
389/// `#[umbral(unique)]`) and issues `SELECT * FROM profile WHERE user
390/// IN (parent_pks)`.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct OneToOneRelationSpec {
393 /// The Rust field name on the parent (e.g. `"profile"`).
394 pub field_name: &'static str,
395 /// The child model's table name (e.g. `"profile"`).
396 pub target_table: &'static str,
397 /// The child model's struct name (e.g. `"Profile"`). Reserved
398 /// for symmetry with `M2MRelationSpec` / `ReverseFkRelationSpec`.
399 pub target_name: &'static str,
400}
401
402/// Static metadata for one reverse-FK collection field on a model
403/// (gap #44). Carried by `Model::REVERSE_FK_RELATIONS`.
404///
405/// Example: `pub struct Post` with
406/// `#[umbral(reverse_fk = "post")] pub comment_set: ReverseSet<Comment>`
407/// emits one entry: `{ field_name: "comment_set", target_table:
408/// "comment", target_name: "Comment", fk_column: "post" }`.
409///
410/// `prefetch_related("comment_set")` uses this to issue
411/// `SELECT * FROM comment WHERE post IN (parent_pks)` then group
412/// rows by `post` value, populating each parent's `ReverseSet`.
413#[derive(Debug, Clone, PartialEq, Eq)]
414pub struct ReverseFkRelationSpec {
415 /// The Rust field name on the parent (e.g. `"comment_set"`).
416 pub field_name: &'static str,
417 /// The child model's table name (e.g. `"comment"`).
418 pub target_table: &'static str,
419 /// The child model's struct name (e.g. `"Comment"`). Reserved
420 /// for symmetry with `M2MRelationSpec`.
421 pub target_name: &'static str,
422 /// Name of the FK column on the child that points back at the
423 /// parent (e.g. `"post"`). The prefetch loader filters on this
424 /// column: `WHERE <fk_column> IN (parent_pks)`.
425 pub fk_column: &'static str,
426 /// Mirrors the CHILD model's `Model::SOFT_DELETE`. `annotate_count`
427 /// folds `AND <child>.deleted_at IS NULL` into the correlated
428 /// count subquery when this is `true`, so a trashed child stops
429 /// inflating the parent's count. Filled by the Model derive from
430 /// `<Child as Model>::SOFT_DELETE`.
431 pub soft_delete: bool,
432}
433
434/// Types that can serve as a model's primary key.
435///
436/// Built-in impls cover the integer widths sea-query has native
437/// `Value` variants for (i8 / i16 / i32 / i64, u8 / u16 / u32 / u64),
438/// `uuid::Uuid`, and `String` (for slug-style keys). The bound is
439/// `Clone + Send + Sync + 'static + Into<sea_query::Value>` — the
440/// `Into<Value>` requirement lets the M2M junction-table CRUD path
441/// bind the PK through sea-query without a per-type adapter, on both
442/// SQLite and Postgres. Closes BUG-16 phase 2.
443///
444/// 128-bit integers (`i128` / `u128`) are deliberately not in the
445/// catalogue: sea-query's `Value` enum has no native variant for them
446/// and neither shipped backend exposes a 128-bit integer column type.
447/// Use `i64` or `String` instead.
448///
449/// User crates extend the catalogue with one line as long as the
450/// custom type already lowers to a `sea_query::Value`:
451///
452/// ```ignore
453/// #[derive(Clone)]
454/// pub struct UserId(pub u64);
455///
456/// impl From<UserId> for sea_query::Value {
457/// fn from(id: UserId) -> Self { id.0.into() }
458/// }
459/// impl umbral::orm::PrimaryKey for UserId {}
460/// ```
461pub trait PrimaryKey:
462 Clone + Send + Sync + 'static + Into<sea_query::Value> + std::fmt::Display
463{
464}
465
466// Integer widths sea-query has Value variants for. Postgres exposes
467// SMALLINT / INT / BIGINT for the signed half; the unsigned widths
468// upcast (sea-query lowers u8/u16/u32 to the next signed width, u64
469// to BIGINT, matching what both backends actually store).
470impl PrimaryKey for i8 {}
471impl PrimaryKey for i16 {}
472impl PrimaryKey for i32 {}
473impl PrimaryKey for i64 {}
474impl PrimaryKey for u8 {}
475impl PrimaryKey for u16 {}
476impl PrimaryKey for u32 {}
477impl PrimaryKey for u64 {}
478
479// Non-integer built-ins. UUIDs and slug-style String keys are the
480// two non-integer shapes the porting catalogue calls out.
481impl PrimaryKey for uuid::Uuid {}
482impl PrimaryKey for String {}
483
484/// Static metadata for one column on a model.
485///
486/// Constructed once per field as a const, lives in `Model::FIELDS`.
487/// Carries enough information for the QuerySet, the system check, and
488/// the migration engine to do their jobs without the model needing any
489/// runtime introspection.
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub struct FieldSpec {
492 /// The SQL column name — always the Rust field name. Only the table name
493 /// is overridable (via `#[umbral(table = "...")]`); there is no field-level
494 /// column-rename attribute, so the column is whatever the field is called.
495 pub name: &'static str,
496
497 /// The SQL type kind. M2 ships the minimum set needed for the
498 /// hardcoded `Post` model (`BigInt`, `Text`, `Timestamptz`);
499 /// additional variants land as the M3 derive's field-type
500 /// catalogue grows.
501 pub ty: SqlType,
502
503 /// Whether the column is part of the primary key.
504 pub primary_key: bool,
505
506 /// Whether the column accepts SQL NULL. Maps from `Option<T>` in
507 /// the struct definition; the only path to NULL is `Option<T>`,
508 /// per the `04-orm-model-and-fields.md` invariant.
509 pub nullable: bool,
510
511 /// Which backends this field type works on. Empty slice means "all
512 /// backends." Non-empty restricts the field to those listed; the
513 /// M4 boot system check rejects models that use a field on an
514 /// unsupported backend.
515 pub supported_backends: &'static [&'static str],
516
517 /// For `SqlType::ForeignKey` fields: the SQL table name of the
518 /// referenced model (i.e. `T::TABLE`). The migration engine reads
519 /// this at DDL-emit time to produce `REFERENCES "<target>"("id")`.
520 /// `None` for all non-FK fields.
521 pub fk_target: Option<&'static str>,
522
523 /// When `true`, this field is never rendered on any form (create or
524 /// edit) AND the REST plugin drops it from POST/PUT/PATCH request
525 /// bodies before write. This is the framework's "server-managed,
526 /// never accepts client input" flag — `password_hash`,
527 /// `internal_token`, audit timestamps the database owns.
528 /// Set via `#[umbral(noform)]`.
529 ///
530 /// OpenAPI emits `readOnly: true` for `noform` columns so Swagger
531 /// UI / generated clients honour the contract too. If you only
532 /// want the admin to render the field disabled — without affecting
533 /// the REST API or the spec — use `noedit` below.
534 ///
535 /// If `noform` is true, `noedit` is moot (noform takes precedence).
536 pub noform: bool,
537
538 /// When `true`, this is a privileged/server-managed field: the untrusted
539 /// JSON write path (`insert_json`/`update_json` — REST create/update and
540 /// admin form-submit) strips it UNLESS the caller explicitly authorizes it
541 /// via [`crate::orm::dynamic::DynQuerySet::allow_privileged`]. Set via
542 /// `#[umbral(privileged)]`.
543 ///
544 /// This is the default-DENY mass-assignment guard (audit_2 H3): fields like
545 /// `is_superuser` / `is_staff` / ownership FKs stay writable through the
546 /// typed struct path and through an *authorized* dynamic write, but an
547 /// unprivileged client can't set them by smuggling them into a create/update
548 /// body. Unlike `noform`, the field still renders on forms (an admin with
549 /// the right permission legitimately edits it); the guard is on the write,
550 /// not the visibility. OpenAPI is unaffected — the field remains in the
551 /// writable schema, since whether a given caller may set it is a runtime
552 /// authorization decision, not a static contract.
553 pub privileged: bool,
554
555 /// For `SqlType::ForeignKey` fields: whether the migration engine
556 /// emits a *physical* `FOREIGN KEY ... REFERENCES` constraint.
557 /// Toggles the physical FK constraint. Set via
558 /// `#[umbral(db_constraint = false)]`; defaults to `true` (today's
559 /// behaviour — emit the constraint).
560 ///
561 /// When `false`, the FK stays a *logical* relation: the column +
562 /// `fk_target` are unchanged, so joins, `select_related`, and the
563 /// app-level `check_fk_row_exists` pre-validation all keep working —
564 /// but no `REFERENCES` clause is rendered. This is the only way to
565 /// model an FK whose target lives on a *different* database (a real
566 /// DB constraint can't span databases). The boot-time guard in
567 /// `App::build` rejects a cross-database FK that has NOT opted out
568 /// via this flag (`BuildError::CrossDatabaseForeignKey`). Closes
569 /// gaps2 #22. Ignored for non-FK fields.
570 pub db_constraint: bool,
571
572 /// When `true`, the admin shows this field disabled on the edit
573 /// form. Pure UX hint — no effect on the REST API or the OpenAPI
574 /// spec; clients can still POST/PUT/PATCH the column normally.
575 /// Set via `#[umbral(noedit)]`.
576 ///
577 /// Use case: a value the user supplies once at signup (`email`,
578 /// `username`) but isn't supposed to change later through the
579 /// admin. The REST API may still accept updates — gate that
580 /// separately via `ResourceConfig::hide(...)` or a permission
581 /// class if you want hard enforcement. To block writes entirely,
582 /// use `noform` instead.
583 ///
584 /// Has no effect when `noform` is also set.
585 pub noedit: bool,
586
587 /// When `true`, this field is the display string for the
588 /// model — the admin uses it as the default label in
589 /// `list_display` when the developer hasn't specified one
590 /// explicitly. Set via `#[umbral(string)]` /
591 /// `#[umbral(string = true)]`. Only meaningful on `String`-typed
592 /// columns; on non-string columns the admin falls back to the PK.
593 pub is_string_repr: bool,
594
595 /// Soft length cap for display. The admin truncates the value at
596 /// this many characters when rendering it in `list_display` so a
597 /// long body doesn't blow out a column. `0` means no truncation.
598 /// Set via `#[umbral(max_length = N)]`.
599 pub max_length: u32,
600
601 /// Closed-set values for a choices column, in declaration order.
602 /// Populated by the `#[derive(Model)]` macro for fields tagged
603 /// `#[umbral(choices)]` by reading `<T as ChoiceField>::VALUES` at
604 /// derive time. Empty slice means "not a choices field" — every
605 /// non-choices column uses the empty default.
606 ///
607 /// The migration engine emits a Postgres `CHECK (col IN (...))`
608 /// constraint when this slice is non-empty; the admin renders a
609 /// `<select>` widget with these as the `<option>` values.
610 pub choices: &'static [&'static str],
611
612 /// Human-readable labels matching `choices` position-for-position.
613 /// Used by the admin to render the `<select>` widget's option text.
614 /// Empty when `choices` is empty.
615 pub choice_labels: &'static [&'static str],
616
617 /// SQL `DEFAULT` clause for this column. Set via
618 /// `#[umbral(default = "...")]` — accepts a string literal that
619 /// the DDL pass passes verbatim into `DEFAULT '<value>'`. Empty
620 /// string means no default. Carried through to the migration
621 /// engine, which emits the `DEFAULT` on both `CREATE TABLE` and
622 /// `ALTER TABLE ADD COLUMN`.
623 pub default: &'static str,
624
625 /// When `true`, this column is a [`MultiChoice<E>`] field: TEXT
626 /// storage holding a CSV of the variants of `E`. The `choices` and
627 /// `choice_labels` slices carry the same metadata as a single-valued
628 /// choices field — the admin uses `is_multichoice` to pick the
629 /// checkbox-chip widget over the `<select>` widget.
630 ///
631 /// [`MultiChoice<E>`]: crate::orm::MultiChoice
632 pub is_multichoice: bool,
633
634 /// When `true`, the migration engine emits a `UNIQUE` constraint
635 /// on this column at `CREATE TABLE` time. Set via
636 /// `#[umbral(unique)]`. Closes gap #65.
637 ///
638 /// Scope at v1: applies to *new* tables only. Toggling `unique`
639 /// on an existing column does not generate an automatic
640 /// `ALTER TABLE ADD CONSTRAINT` — SQLite cannot add a unique
641 /// constraint without rebuilding the table, and the M8 diff
642 /// engine only watches `ty` and `nullable`. Add or remove
643 /// uniqueness on a live table via a hand-written migration
644 /// until the diff engine grows constraint-level ops.
645 ///
646 /// Primary-key columns are already implicitly unique, so this
647 /// flag is a no-op on a PK field. Set it on every other column
648 /// that needs database-enforced uniqueness (`username`,
649 /// `email`, opaque tokens, slugs, etc.) so handler-level
650 /// pre-checks become unnecessary.
651 pub unique: bool,
652
653 /// Referential action emitted on `DELETE` of the FK target row.
654 /// Only meaningful when `ty == ForeignKey`; ignored for every
655 /// other column. Set via `#[umbral(on_delete = "...")]`. Closes
656 /// gap #68. Defaults to `NoAction` so existing migrations
657 /// don't change shape.
658 pub on_delete: FkAction,
659
660 /// Referential action emitted on `UPDATE` of the FK target row's
661 /// primary key. Same FK-only semantics as `on_delete`; almost
662 /// nobody touches this in practice (PKs rarely move) but the
663 /// symmetry matches `REFERENCES ... ON UPDATE ...` and the
664 /// `on_delete` / `on_update` pair. Set via
665 /// `#[umbral(on_update = "...")]`.
666 pub on_update: FkAction,
667
668 /// When `true`, the migration engine emits a single-column
669 /// `CREATE INDEX` statement alongside the `CREATE TABLE`. Set
670 /// via `#[umbral(index)]`. Closes BUG-4 in
671 /// `bugs/tests/testBugs.md`.
672 ///
673 /// Index name convention: `idx_<table>_<column>`. Apps that
674 /// need a custom name, a multi-column index, or a partial
675 /// index write the `CREATE INDEX` by hand in a follow-up
676 /// migration.
677 pub index: bool,
678
679 /// When `true`, the column gets populated with `Utc::now()` at
680 /// row-creation time *only*. Set via `#[umbral(auto_now_add)]`.
681 /// Closes BUG-5 in `bugs/tests/testBugs.md`.
682 ///
683 /// **Where this fires:** the dynamic write path
684 /// (`DynQuerySet::insert_json`, used by `umbral-rest` /
685 /// `umbral-admin`). The typed `Manager::create(instance)` path
686 /// is user-controlled — the caller passes whatever value they
687 /// chose at the struct-init site. v1 scope: the framework
688 /// auto-populates only when the body / form omits the field.
689 pub auto_now_add: bool,
690
691 /// When `true`, the column gets populated with `Utc::now()` on
692 /// every write (create AND update). Set via `#[umbral(auto_now)]`. Closes
693 /// BUG-5 in `bugs/tests/testBugs.md`.
694 ///
695 /// **Where this fires:** the dynamic write path
696 /// (`DynQuerySet::insert_json` and `update_json`, used by
697 /// `umbral-rest` / `umbral-admin`). The typed paths stay
698 /// user-controlled at v1. Body-supplied values are kept —
699 /// users can override `auto_now` columns on the dynamic
700 /// path, matching the lenient "fill if missing" shape of
701 /// `auto_now_add`. An "always override" shape lands as
702 /// a future v2 toggle if a real consumer asks.
703 pub auto_now: bool,
704
705 /// When `true`, the dynamic write path strips leading/trailing whitespace
706 /// from this column's string value before INSERT/UPDATE. Set via
707 /// `#[umbral(trim)]`; only valid on `String` / `Option<String>` fields
708 /// (the derive rejects it elsewhere at compile time).
709 ///
710 /// **Where this fires:** the dynamic write path only
711 /// (`DynQuerySet::insert_json`/`update_json` + the admin form builders),
712 /// exactly like [`auto_now`](Self::auto_now). The typed
713 /// `Manager::create(instance)` path is caller-controlled — normalize there
714 /// yourself (e.g. `umbral_auth::normalize_email`) if you need it. Combines
715 /// with [`lowercase`](Self::lowercase): trim runs first, then lowercase.
716 pub trim: bool,
717
718 /// When `true`, the dynamic write path lowercases this column's string
719 /// value before INSERT/UPDATE. Set via `#[umbral(lowercase)]`; only valid
720 /// on `String` / `Option<String>` fields. Pair with [`unique`](Self::unique)
721 /// to get case-insensitive uniqueness for free (every stored row is already
722 /// lowercased), and with [`trim`](Self::trim) to also drop surrounding
723 /// whitespace. Same dynamic-path-only scope as [`trim`](Self::trim).
724 pub lowercase: bool,
725
726 /// When `true`, the column is **case-insensitive at the database level**:
727 /// comparisons, `UNIQUE`, and lookups treat `Dalmas` and `dalmas` as equal,
728 /// while the *original* casing is preserved in storage. Set via
729 /// `#[umbral(case_insensitive)]`; `String` / `Option<String>` only.
730 ///
731 /// Unlike [`lowercase`](Self::lowercase) (which normalizes the stored value
732 /// and rides a plain UNIQUE), this changes the emitted DDL: Postgres gets a
733 /// `citext` column (the migration auto-creates the `citext` extension),
734 /// SQLite gets `COLLATE NOCASE`. It is schema-affecting, so — like
735 /// [`unique`](Self::unique) — it applies at `CREATE TABLE`; toggling it on an
736 /// existing column needs a hand-written migration.
737 ///
738 /// Caveat: SQLite's `NOCASE` folds ASCII `A–Z` only (not Unicode); a boot
739 /// check warns when this is used on SQLite. Postgres `citext` folds per the
740 /// database collation. Prefer [`lowercase`](Self::lowercase) when you don't
741 /// need to preserve the original casing.
742 pub case_insensitive: bool,
743
744 /// Human-readable column description (help text).
745 /// Set via `#[umbral(help = "...")]`. Flows
746 /// through to:
747 ///
748 /// - OpenAPI `description` on the property schema (closes
749 /// playground-openapi-gaps item 5).
750 /// - Admin form field hint (the small line below the
751 /// input).
752 /// - Doc-comment-style introspection for any future code
753 /// generator.
754 ///
755 /// Empty string means "no description" — the OpenAPI
756 /// emitter and admin form skip the surrounding markup
757 /// when this is unset.
758 pub help: &'static str,
759
760 /// Presentation hint for form-rendering surfaces. Set via
761 /// `#[umbral(widget = "markdown" | "rte" | "textarea" | ...)]`;
762 /// `None` (the default) means "let the renderer pick by
763 /// `SqlType`". features.md #4.
764 ///
765 /// It is **metadata only** — the column's `SqlType`, DDL, and
766 /// stored value are unchanged. A `widget = "markdown"` field is
767 /// still `TEXT`; the widget only tells the admin (or any plugin
768 /// form) to render a markdown editor instead of a bare
769 /// `<textarea>`, and pairs with the `{{ value | markdown }}`
770 /// filter on the display side. Excluded from the migration diff
771 /// for the same reason `help` / `example` are: no DB effect.
772 ///
773 /// Renderers fall back to the `SqlType`-derived input for any
774 /// widget name they don't recognise, so an unknown widget is a
775 /// soft no-op rather than an error — third-party plugins can ship
776 /// new widget names without the core knowing them.
777 pub widget: Option<&'static str>,
778
779 /// Sample value rendered as OpenAPI `example` on the property
780 /// schema. Set via `#[umbral(example = "...")]`. Closes
781 /// playground-openapi-gaps item 6.
782 ///
783 /// Empty string means no example. Emitted as a JSON string in
784 /// the spec — clients that want typed examples can coerce on
785 /// their end. Pairs naturally with `help` to make a column's
786 /// purpose clear in Swagger UI.
787 pub example: &'static str,
788
789 /// Optional numeric lower bound. Set via `#[umbral(min = N)]`.
790 /// Closes IMP-3 from `bugs/tests/testBugs.md`. Flows to:
791 ///
792 /// - OpenAPI `minimum` on the property schema.
793 /// - REST plugin's dynamic write path pre-validation (400
794 /// response with a structured message).
795 /// - Future: HTML5 `min` attribute on admin form inputs.
796 ///
797 /// `i64::MIN` sentinel means "no minimum"; the DDL +
798 /// OpenAPI emitters skip the constraint when this is the
799 /// sentinel value. Macro accepts integer literals only at
800 /// v1 (a `Decimal`-aware shape can land when there's a real
801 /// consumer for decimal-typed validators).
802 pub min: Option<i64>,
803
804 /// Optional numeric upper bound. Set via `#[umbral(max = N)]`.
805 /// Mirror of `min`; same plumbing on the OpenAPI / REST /
806 /// admin sides.
807 pub max: Option<i64>,
808
809 /// Constrained-text marker. `None` is a plain `String` /
810 /// `SqlType::Text` column; `Some("slug" | "email" | "url")` is
811 /// one of the validator wrapper types from
812 /// [`crate::orm::validators`]. Closes BUG-11/12/13. Flows to:
813 ///
814 /// - OpenAPI `format: email` / `format: uri` / `pattern` on the
815 /// property schema (the standard 3.0 markers).
816 /// - REST plugin's dynamic write path: `validate_text_format`
817 /// pre-checks the body value and returns a structured 400
818 /// on a bad input.
819 /// - Admin form: HTML5 `type="email"` / `type="url"` widget
820 /// (when those land).
821 ///
822 /// The marker is set by the macro classifier from the field type
823 /// — `Slug` → `Some("slug")`, `Email` → `Some("email")`,
824 /// `Url` → `Some("url")`. The wrapper type + marker stay in sync
825 /// because they're produced from the same single match arm in
826 /// `umbral-macros::classify_field_type`.
827 pub text_format: Option<&'static str>,
828
829 /// Source column for an auto-derived slug. Set via
830 /// `#[umbral(slug_from = "title")]` on a `Slug` / `String` field;
831 /// names a sibling column on the same model whose value seeds
832 /// this column at write time. Gap 109.
833 ///
834 /// **Where this fires:** the dynamic write path
835 /// ([`crate::orm::DynQuerySet::insert_json`] +
836 /// [`crate::orm::DynQuerySet::update_json`]). On insert, an empty
837 /// or absent slug column is replaced by `slugify(source_value)`
838 /// derived from the source column in the same body. On update,
839 /// the slug is regenerated only when the source column is also
840 /// in the update payload, so callers who edit nothing but the
841 /// slug itself keep their hand-tuned value.
842 ///
843 /// `None` is the default — no auto-derive. The string is a
844 /// column name (snake_case), not a Rust field name, so it must
845 /// match exactly what ends up in `FieldSpec::name`.
846 pub slug_from: Option<&'static str>,
847}
848
849/// Referential action emitted in the SQL `REFERENCES ... ON
850/// {DELETE,UPDATE} <action>` clause. Mirrors the standard SQL set.
851///
852/// Copy + 'static so it can live on `FieldSpec` (which is itself
853/// `Copy` for storage in `&'static [FieldSpec]`).
854#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
855pub enum FkAction {
856 /// SQL `NO ACTION` — the default. The migration engine emits no
857 /// clause at all (which means "default" on both backends; sqlite
858 /// and Postgres both default to NO ACTION when omitted).
859 #[default]
860 NoAction,
861 /// SQL `CASCADE` — when the FK target row is deleted/updated,
862 /// the referencing row is deleted/updated too. The right answer
863 /// for "owned" relationships (an `AuthToken` follows its
864 /// owning `AuthUser` to the grave).
865 Cascade,
866 /// SQL `RESTRICT` — block the delete/update of the FK target
867 /// row if any referencing row exists. Checked immediately;
868 /// doesn't defer to commit. Right for "you can't drop a
869 /// category that still has products in it."
870 Restrict,
871 /// SQL `SET NULL` — null the referencing column. Only valid on
872 /// nullable FK columns; the migration engine doesn't currently
873 /// check this at boot, so a mismatched pair (NOT NULL + SET NULL)
874 /// will fail at FK action time, not at CREATE TABLE.
875 SetNull,
876}
877
878impl FkAction {
879 /// SQL keyword for the `ON {DELETE,UPDATE} <kw>` clause.
880 /// Returns `None` for `NoAction` so the DDL builder can skip
881 /// the clause entirely (rather than emitting the redundant
882 /// `NO ACTION` literal).
883 pub fn sql_keyword(self) -> Option<&'static str> {
884 match self {
885 Self::NoAction => None,
886 Self::Cascade => Some("CASCADE"),
887 Self::Restrict => Some("RESTRICT"),
888 Self::SetNull => Some("SET NULL"),
889 }
890 }
891
892 /// Parse the attribute string supplied to `#[umbral(on_delete = "...")]`.
893 /// Case-insensitive; accepts both `set_null` and `set null` for
894 /// the multi-word case so users can write whichever feels
895 /// natural.
896 pub fn from_attr_str(s: &str) -> Option<Self> {
897 match s.to_lowercase().as_str() {
898 "no_action" | "no action" => Some(Self::NoAction),
899 "cascade" => Some(Self::Cascade),
900 "restrict" => Some(Self::Restrict),
901 "set_null" | "set null" => Some(Self::SetNull),
902 _ => None,
903 }
904 }
905}
906
907/// The SQL type kind of a column.
908///
909/// The dialect-specific rendering (`BIGINT` vs `INTEGER` vs whatever
910/// the backend calls it) is the backend's responsibility, set up by
911/// the M4 `DatabaseBackend` abstraction. This enum is the abstract
912/// classification umbral reasons about.
913///
914/// The catalogue follows spec 04 §4.1: each variant covers one
915/// field type. Rust types in the field declaration map to a
916/// variant via the M3 derive's `classify_field_type`; the table is in
917/// `umbral-macros/src/lib.rs` alongside the derive.
918///
919/// Backend-specific variants (Postgres `Array`, `HStore`, `Jsonb`) land
920/// at M4 when the system check exists to gate them at boot.
921#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
922pub enum SqlType {
923 /// A foreign-key reference to another table. Stored as `i64` (the
924 /// referenced row's primary key). Renders as `BIGINT REFERENCES
925 /// "<target_table>"("id")` on both Postgres and SQLite.
926 ///
927 /// The referenced table name is carried separately in
928 /// [`FieldSpec::fk_target`] so this enum stays `Copy`. The migration
929 /// engine reads `fk_target` at DDL-emit time.
930 ///
931 /// Out of scope at v1: non-`i64` FK targets, `ON DELETE` behaviours
932 /// beyond the default RESTRICT, reverse accessors (`User::posts`),
933 /// and many-to-many join tables. See `docs/specs/relationships.md`.
934 ForeignKey,
935 /// 16-bit signed integer. `i8` / `i16` / `u8` in Rust.
936 SmallInt,
937 /// 32-bit signed integer. `i32` / `u16` in Rust.
938 Integer,
939 /// 64-bit signed integer. `i64` / `u32` in Rust.
940 BigInt,
941 /// 32-bit floating point. `f32` in Rust.
942 Real,
943 /// 64-bit floating point. `f64` in Rust.
944 Double,
945 /// Boolean. `bool` in Rust.
946 Boolean,
947 /// Variable-length string. `String` in Rust.
948 Text,
949 /// Date without time. `chrono::NaiveDate` in Rust.
950 Date,
951 /// Time without date. `chrono::NaiveTime` in Rust.
952 Time,
953 /// Timestamp with timezone. `chrono::DateTime<chrono::Utc>` in Rust.
954 Timestamptz,
955 /// 128-bit UUID. `uuid::Uuid` in Rust.
956 Uuid,
957 /// JSON document. `serde_json::Value` in Rust.
958 ///
959 /// Cross-backend: Postgres stores native `JSONB` (binary form with
960 /// index / operator support); SQLite stores `TEXT` (JSON-as-string).
961 /// `serde_json::Value` round-trips through both via sqlx's `json`
962 /// feature, so a user model with a `Value` field works on either
963 /// backend without code changes: a portable JSON field with a
964 /// portable shape and dialect-specific storage. Native JSONB-only
965 /// operators (`@>`, `->`, `->>` etc.) are a deferred follow-on
966 /// landed alongside Postgres-specific column predicates.
967 Json,
968 /// Array column. `Vec<T>` in Rust where `T` is one of the
969 /// [`ArrayElement`] variants.
970 ///
971 /// **Postgres-only.** SQLite has no native array type; the M4
972 /// system check fails at boot if an Array field is registered
973 /// against the SQLite backend. For portable list storage, declare
974 /// the field as `serde_json::Value` (the [`Self::Json`] variant)
975 /// and store a JSON array inside.
976 ///
977 /// The inner type is restricted to [`ArrayElement`] rather than
978 /// `Box<SqlType>` so the outer enum stays `Copy` and `SqlType`
979 /// values can live in `const FIELDS` slices the derive emits.
980 /// Multi-dim arrays (`Vec<Vec<T>>`), nullable elements
981 /// (`Vec<Option<T>>`), and nested JSON arrays (`Vec<Value>`) are
982 /// out of scope for v1.
983 Array(ArrayElement),
984 /// `INET` — Postgres IP address column with optional netmask.
985 /// Maps to `ipnetwork::IpNetwork` in Rust. **Postgres-only.**
986 /// Stores a generic IP address.
987 Inet,
988 /// `CIDR` — Postgres network address column. Same Rust type as
989 /// `Inet` (`ipnetwork::IpNetwork`) but with the constraint that
990 /// the host bits must be zero. **Postgres-only.**
991 Cidr,
992 /// `MACADDR` — Postgres MAC address column. Maps to
993 /// `mac_address::MacAddress` in Rust. **Postgres-only.**
994 MacAddr,
995 /// `XML` — Postgres XML document column. Maps to `String` in Rust
996 /// (umbral stores and round-trips the serialized XML text; it does
997 /// not parse or validate the document at the framework level —
998 /// Postgres does that on insert). **Postgres-only.** Reach for this
999 /// over `Text` only when you want Postgres' `xml` type checking and
1000 /// the `xpath` / `xmlexists` operator surface; otherwise `Text`
1001 /// stores XML strings just fine (XML is otherwise modelled as plain
1002 /// text).
1003 Xml,
1004 /// `LTREE` — Postgres hierarchical label-path column (the `ltree`
1005 /// extension). Maps to `String` in Rust (the dotted path, e.g.
1006 /// `"Top.Science.Astronomy"`). **Postgres-only**, and requires the
1007 /// `ltree` extension (`CREATE EXTENSION ltree`) to be installed in
1008 /// the target database. The umbral migration engine emits the bare
1009 /// `ltree` column type; the extension itself is the operator's
1010 /// responsibility (a hand-written migration or a DB bootstrap step).
1011 Ltree,
1012 /// `BIT VARYING` — Postgres bit-string column. Maps to `String` in
1013 /// Rust (the textual `"0"`/`"1"` representation, e.g. `"101"`).
1014 /// **Postgres-only.** v1 renders as `BIT VARYING` (variable-length);
1015 /// a fixed-width `BIT(n)` needs a hand-written migration after the
1016 /// initial create until a `#[umbral(bit_len = N)]` attribute lands
1017 /// for a real consumer. There is otherwise no dedicated bit-string
1018 /// type; the fallback is plain text.
1019 Bit,
1020 /// `TSVECTOR` — Postgres full-text search lexeme vector. Maps to
1021 /// [`crate::orm::TsVector`] in Rust (a thin newtype around
1022 /// `String` with sqlx Type/Encode/Decode impls). **Postgres-only.**
1023 ///
1024 /// The column is typically populated by a Postgres trigger or
1025 /// `GENERATED ALWAYS AS (to_tsvector(...)) STORED` clause; umbral's
1026 /// migration engine emits the bare `tsvector` type, leaving the
1027 /// population mechanism to the user. Queries against a
1028 /// `FullTextCol` use the `@@` match operator with `to_tsquery` /
1029 /// `websearch_to_tsquery`.
1030 FullText,
1031 /// `BLOB` (SQLite) / `BYTEA` (Postgres) — arbitrary binary payload.
1032 /// Maps to `Vec<u8>` in Rust. Used by anything that stores opaque
1033 /// bytes: file uploads, the cache backend's value column, encrypted
1034 /// envelopes, etc.
1035 ///
1036 /// `Vec<u8>` was previously routed to `SqlType::Array(SmallInt)`
1037 /// because the array detection treated `u8` as a small int. The
1038 /// detection now checks for `Vec<u8>` specifically first and
1039 /// routes to `Bytes`; `Vec<i8>` / `Vec<i16>` still map to
1040 /// `Array(SmallInt)`.
1041 Bytes,
1042 /// `NUMERIC(19, 4)` — fixed-point decimal. Maps to
1043 /// `rust_decimal::Decimal` in Rust. Closes BUG-10 from
1044 /// `bugs/tests/testBugs.md`. Money / price columns must use
1045 /// this, not `f64` (binary float drops cents) or `String`
1046 /// (no DB-level arithmetic).
1047 ///
1048 /// **Postgres-only at v1.** sqlx's `rust_decimal` feature
1049 /// adds Encode/Decode for Postgres `NUMERIC` only; SQLite has
1050 /// no native decimal type (every numeric value is INTEGER /
1051 /// REAL / TEXT affinity). The boot system check rejects
1052 /// Decimal models against SQLite the same way it rejects
1053 /// `Array(_)` — apps deploying to SQLite either pick a
1054 /// portable type (`Real` or `Text` with manual formatting) or
1055 /// use Postgres for the parts of their schema that need
1056 /// decimal arithmetic. A fixed-precision decimal column.
1057 ///
1058 /// **v1 scope.** Precision and scale are fixed at `(19, 4)` —
1059 /// 19 significant digits, 4 after the decimal point. That's
1060 /// enough headroom for currency values up to one quadrillion
1061 /// dollars (with sub-cent precision) and matches sqlx's
1062 /// `Decimal` default. Apps that need a different precision
1063 /// alter the column via a hand-written migration after the
1064 /// initial create. A `#[umbral(precision = N, scale = M)]`
1065 /// attribute lands when there's a real consumer that needs
1066 /// dimensions outside the default.
1067 Decimal,
1068}
1069
1070/// Element types valid inside [`SqlType::Array`].
1071///
1072/// A strict subset of the [`SqlType`] catalogue: the value types
1073/// Postgres supports as `T[]` and that umbral knows how to bind / decode
1074/// through sqlx. Stays `Copy` so the outer `SqlType::Array(ArrayElement)`
1075/// remains usable in `const FIELDS` slices.
1076///
1077/// Catalogue:
1078///
1079/// | Variant | Postgres type | Rust inner type |
1080/// |-------------|---------------|-------------------|
1081/// | `SmallInt` | `int2[]` | `Vec<i16>` |
1082/// | `Integer` | `int4[]` | `Vec<i32>` |
1083/// | `BigInt` | `int8[]` | `Vec<i64>` |
1084/// | `Real` | `float4[]` | `Vec<f32>` |
1085/// | `Double` | `float8[]` | `Vec<f64>` |
1086/// | `Boolean` | `bool[]` | `Vec<bool>` |
1087/// | `Text` | `text[]` | `Vec<String>` |
1088/// | `Uuid` | `uuid[]` | `Vec<uuid::Uuid>` |
1089///
1090/// Other element types (Date / Time / Timestamptz / Json) land as
1091/// follow-ons when there's a real consumer; the binding semantics for
1092/// chrono types as Postgres array elements need a deliberate pass.
1093#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1094pub enum ArrayElement {
1095 SmallInt,
1096 Integer,
1097 BigInt,
1098 Real,
1099 Double,
1100 Boolean,
1101 Text,
1102 Uuid,
1103}
1104
1105impl ArrayElement {
1106 /// Lift this element type back to its [`SqlType`] equivalent. Used
1107 /// when a per-element decision needs to dispatch through the same
1108 /// SqlType match the rest of umbral uses (e.g. picking a
1109 /// `sea_query::ColumnType` for the element).
1110 pub fn to_sql_type(self) -> SqlType {
1111 match self {
1112 ArrayElement::SmallInt => SqlType::SmallInt,
1113 ArrayElement::Integer => SqlType::Integer,
1114 ArrayElement::BigInt => SqlType::BigInt,
1115 ArrayElement::Real => SqlType::Real,
1116 ArrayElement::Double => SqlType::Double,
1117 ArrayElement::Boolean => SqlType::Boolean,
1118 ArrayElement::Text => SqlType::Text,
1119 ArrayElement::Uuid => SqlType::Uuid,
1120 }
1121 }
1122}