Skip to main content

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    /// gaps3 #54 — `#[umbral(audited)]`. Every write records an `umbral_audit`
301    /// row (who / when / which row / which fields changed).
302    const AUDITED: bool = false;
303
304    /// features #73 — `#[umbral(view = "SELECT ...")]`. When set, this model is
305    /// backed by a database VIEW rather than a table: the migration engine emits
306    /// `CREATE VIEW <table> AS <this SQL>` and never a `CREATE TABLE`.
307    ///
308    /// The struct's fields must line up with the SELECT list — the framework
309    /// cannot check that at compile time (the SQL is an opaque string), so a
310    /// mismatch surfaces as a "no such column" the first time you query it.
311    ///
312    /// A view is **read-only**. Every write path rejects a view model with
313    /// [`WriteError::ReadOnlyView`](crate::orm::write::WriteError::ReadOnlyView)
314    /// before it reaches the database, because the alternative — a driver-level
315    /// error from deep inside the insert — tells you nothing about *why*.
316    const VIEW: Option<&'static str> = None;
317
318    /// features #73 — `#[umbral(materialized_view = "SELECT ...")]`. Implies
319    /// [`VIEW`](Self::VIEW); additionally emits `CREATE MATERIALIZED VIEW`, whose
320    /// rows are computed once and stored until you call
321    /// [`refresh_view`](crate::db::refresh_view).
322    ///
323    /// Postgres-only. SQLite has no materialized views, and rendering one as a
324    /// plain view there would give you a backend that silently recomputes on every
325    /// read — the same query, a different performance contract, and a "works on my
326    /// machine" that only shows up under production load. The `model.materialized_view`
327    /// system check fails the boot instead.
328    const MATERIALIZED: bool = false;
329
330    /// Composite-UNIQUE constraints. Each inner slice names a
331    /// constraint over the listed column names. Set via
332    /// `#[umbral(unique_together = [["a", "b"]])]`. Closes BUG-6 in
333    /// `bugs/tests/testBugs.md`. Default empty; the migration engine
334    /// emits one `UNIQUE (col1, col2)` clause per inner group on
335    /// `CREATE TABLE`.
336    const UNIQUE_TOGETHER: &'static [&'static [&'static str]] = &[];
337
338    /// Multi-column indexes. Each inner slice names an index over
339    /// the listed columns. Set via
340    /// `#[umbral(indexes = [["tenant_id", "created_at"]])]`. Closes
341    /// BUG-7. Default empty; the migration engine emits
342    /// `CREATE INDEX IF NOT EXISTS idx_<table>_<col1>_<col2>` after
343    /// the `CREATE TABLE`. Single-column indexes stay on the field
344    /// attribute (`#[umbral(index)]`).
345    const INDEXES: &'static [&'static [&'static str]] = &[];
346
347    /// Default `ORDER BY` clause, applied when a QuerySet terminates
348    /// without an explicit `order_by`. Each tuple is `(column_name,
349    /// is_descending)`. Set via
350    /// `#[umbral(ordering = ["-published_at", "id"])]` (leading `-`
351    /// flips to DESC). Closes BUG-8. Default empty.
352    const ORDERING: &'static [(&'static str, bool)] = &[];
353
354    /// Field names to STRIP from signal payloads (audit_2 core-app-config #10).
355    /// Set per-field via `#[umbral(signal_skip)]`. The ORM signal emitters
356    /// (`pre/post_save`, `pre/post_delete`, `pre/post_update`) serialize the
357    /// whole row into the `"instance"` payload that fans out to every
358    /// subscriber; a subscriber that logs or persists payloads (the natural
359    /// audit-log shape) would otherwise copy password hashes, tokens, and PII
360    /// into logs / secondary stores. Listed fields are removed from the
361    /// serialized instance before it is emitted. Default empty (full row).
362    const SIGNAL_SKIP_FIELDS: &'static [&'static str] = &[];
363
364    /// Many-to-many relations declared on this model. Each entry names
365    /// a field and its target model. The migration engine uses this to
366    /// auto-generate junction tables; the admin uses it to render M2M
367    /// pickers. Default empty.
368    const M2M_RELATIONS: &'static [M2MRelationSpec] = &[];
369
370    /// Gap #44 — reverse-FK collections declared on this model via
371    /// `#[umbral(reverse_fk = "<fk_col>")] pub <name>: ReverseSet<C>`.
372    /// Each entry tells `prefetch_related` how to fetch the children:
373    /// `SELECT * FROM <target_table> WHERE <fk_column> IN (parent_pks)`
374    /// then group by `<fk_column>` value, populate each parent's
375    /// `ReverseSet.resolved`. Default empty; the macro emits one
376    /// entry per declared `ReverseSet<C>` field.
377    const REVERSE_FK_RELATIONS: &'static [ReverseFkRelationSpec] = &[];
378
379    /// Reverse OneToOne accessors declared on this model via
380    /// `pub <name>: OneToOne<C>` (no umbral attribute required).
381    /// Unlike `REVERSE_FK_RELATIONS`, the FK column on the child is
382    /// not named at macro time — `prefetch_related` looks it up at
383    /// runtime by scanning the child's `FIELDS` for the UNIQUE FK
384    /// pointing back at this model's table. Exactly one match
385    /// required; 0 or 2+ matches surface a loud error naming the
386    /// ambiguity.
387    const ONE_TO_ONE_RELATIONS: &'static [OneToOneRelationSpec] = &[];
388
389    /// Return the primary key of this instance.
390    fn primary_key(&self) -> Self::PrimaryKey;
391}
392
393/// Static metadata for one many-to-many relation declared on a model.
394///
395/// Carried by `Model::M2M_RELATIONS`. The migration engine uses this
396/// to emit `CREATE TABLE` for the junction table; the admin uses it
397/// to know which fields render as multi-select pickers.
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct M2MRelationSpec {
400    /// The Rust field name (e.g. `"tags"`).
401    pub field_name: &'static str,
402    /// The target model's table name (e.g. `"tag"`).
403    pub target_table: &'static str,
404    /// The target model's struct name (e.g. `"Tag"`). Used for reverse
405    /// accessor lookups and OpenAPI schema references.
406    pub target_name: &'static str,
407}
408
409/// Static metadata for one reverse OneToOne field on a model. The
410/// FK column on the child is intentionally omitted — `prefetch_related`
411/// resolves it at runtime by scanning the child's `FIELDS` for the
412/// UNIQUE FK pointing back at `target_table`. Carried by
413/// [`Model::ONE_TO_ONE_RELATIONS`].
414///
415/// Example: `pub struct User { pub profile: OneToOne<Profile>, ... }`
416/// emits one entry: `{ field_name: "profile", target_table:
417/// "profile", target_name: "Profile" }`. At prefetch time the loader
418/// finds the column on Profile (`pub user: ForeignKey<User>` with
419/// `#[umbral(unique)]`) and issues `SELECT * FROM profile WHERE user
420/// IN (parent_pks)`.
421#[derive(Debug, Clone, PartialEq, Eq)]
422pub struct OneToOneRelationSpec {
423    /// The Rust field name on the parent (e.g. `"profile"`).
424    pub field_name: &'static str,
425    /// The child model's table name (e.g. `"profile"`).
426    pub target_table: &'static str,
427    /// The child model's struct name (e.g. `"Profile"`). Reserved
428    /// for symmetry with `M2MRelationSpec` / `ReverseFkRelationSpec`.
429    pub target_name: &'static str,
430}
431
432/// Static metadata for one reverse-FK collection field on a model
433/// (gap #44). Carried by `Model::REVERSE_FK_RELATIONS`.
434///
435/// Example: `pub struct Post` with
436/// `#[umbral(reverse_fk = "post")] pub comment_set: ReverseSet<Comment>`
437/// emits one entry: `{ field_name: "comment_set", target_table:
438/// "comment", target_name: "Comment", fk_column: "post" }`.
439///
440/// `prefetch_related("comment_set")` uses this to issue
441/// `SELECT * FROM comment WHERE post IN (parent_pks)` then group
442/// rows by `post` value, populating each parent's `ReverseSet`.
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct ReverseFkRelationSpec {
445    /// The Rust field name on the parent (e.g. `"comment_set"`).
446    pub field_name: &'static str,
447    /// The child model's table name (e.g. `"comment"`).
448    pub target_table: &'static str,
449    /// The child model's struct name (e.g. `"Comment"`). Reserved
450    /// for symmetry with `M2MRelationSpec`.
451    pub target_name: &'static str,
452    /// Name of the FK column on the child that points back at the
453    /// parent (e.g. `"post"`). The prefetch loader filters on this
454    /// column: `WHERE <fk_column> IN (parent_pks)`.
455    pub fk_column: &'static str,
456    /// Mirrors the CHILD model's `Model::SOFT_DELETE`. `annotate_count`
457    /// folds `AND <child>.deleted_at IS NULL` into the correlated
458    /// count subquery when this is `true`, so a trashed child stops
459    /// inflating the parent's count. Filled by the Model derive from
460    /// `<Child as Model>::SOFT_DELETE`.
461    pub soft_delete: bool,
462}
463
464/// Types that can serve as a model's primary key.
465///
466/// Built-in impls cover the integer widths sea-query has native
467/// `Value` variants for (i8 / i16 / i32 / i64, u8 / u16 / u32 / u64),
468/// `uuid::Uuid`, and `String` (for slug-style keys). The bound is
469/// `Clone + Send + Sync + 'static + Into<sea_query::Value>` — the
470/// `Into<Value>` requirement lets the M2M junction-table CRUD path
471/// bind the PK through sea-query without a per-type adapter, on both
472/// SQLite and Postgres. Closes BUG-16 phase 2.
473///
474/// 128-bit integers (`i128` / `u128`) are deliberately not in the
475/// catalogue: sea-query's `Value` enum has no native variant for them
476/// and neither shipped backend exposes a 128-bit integer column type.
477/// Use `i64` or `String` instead.
478///
479/// User crates extend the catalogue with one line as long as the
480/// custom type already lowers to a `sea_query::Value`:
481///
482/// ```ignore
483/// #[derive(Clone)]
484/// pub struct UserId(pub u64);
485///
486/// impl From<UserId> for sea_query::Value {
487///     fn from(id: UserId) -> Self { id.0.into() }
488/// }
489/// impl umbral::orm::PrimaryKey for UserId {}
490/// ```
491pub trait PrimaryKey:
492    Clone + Send + Sync + 'static + Into<sea_query::Value> + std::fmt::Display
493{
494}
495
496// Integer widths sea-query has Value variants for. Postgres exposes
497// SMALLINT / INT / BIGINT for the signed half; the unsigned widths
498// upcast (sea-query lowers u8/u16/u32 to the next signed width, u64
499// to BIGINT, matching what both backends actually store).
500impl PrimaryKey for i8 {}
501impl PrimaryKey for i16 {}
502impl PrimaryKey for i32 {}
503impl PrimaryKey for i64 {}
504impl PrimaryKey for u8 {}
505impl PrimaryKey for u16 {}
506impl PrimaryKey for u32 {}
507impl PrimaryKey for u64 {}
508
509// Non-integer built-ins. UUIDs and slug-style String keys are the
510// two non-integer shapes the porting catalogue calls out.
511impl PrimaryKey for uuid::Uuid {}
512impl PrimaryKey for String {}
513
514/// Static metadata for one column on a model.
515///
516/// Constructed once per field as a const, lives in `Model::FIELDS`.
517/// Carries enough information for the QuerySet, the system check, and
518/// the migration engine to do their jobs without the model needing any
519/// runtime introspection.
520#[derive(Debug, Clone, Copy, PartialEq, Eq)]
521pub struct FieldSpec {
522    /// The SQL column name — always the Rust field name. Only the table name
523    /// is overridable (via `#[umbral(table = "...")]`); there is no field-level
524    /// column-rename attribute, so the column is whatever the field is called.
525    pub name: &'static str,
526
527    /// The SQL type kind. M2 ships the minimum set needed for the
528    /// hardcoded `Post` model (`BigInt`, `Text`, `Timestamptz`);
529    /// additional variants land as the M3 derive's field-type
530    /// catalogue grows.
531    pub ty: SqlType,
532
533    /// Whether the column is part of the primary key.
534    pub primary_key: bool,
535
536    /// Whether the column accepts SQL NULL. Maps from `Option<T>` in
537    /// the struct definition; the only path to NULL is `Option<T>`,
538    /// per the `04-orm-model-and-fields.md` invariant.
539    pub nullable: bool,
540
541    /// Which backends this field type works on. Empty slice means "all
542    /// backends." Non-empty restricts the field to those listed; the
543    /// M4 boot system check rejects models that use a field on an
544    /// unsupported backend.
545    pub supported_backends: &'static [&'static str],
546
547    /// For `SqlType::ForeignKey` fields: the SQL table name of the
548    /// referenced model (i.e. `T::TABLE`). The migration engine reads
549    /// this at DDL-emit time to produce `REFERENCES "<target>"("id")`.
550    /// `None` for all non-FK fields.
551    pub fk_target: Option<&'static str>,
552
553    /// When `true`, this field is never rendered on any form (create or
554    /// edit) AND the REST plugin drops it from POST/PUT/PATCH request
555    /// bodies before write. This is the framework's "server-managed,
556    /// never accepts client input" flag — `password_hash`,
557    /// `internal_token`, audit timestamps the database owns.
558    /// Set via `#[umbral(noform)]`.
559    ///
560    /// OpenAPI emits `readOnly: true` for `noform` columns so Swagger
561    /// UI / generated clients honour the contract too. If you only
562    /// want the admin to render the field disabled — without affecting
563    /// the REST API or the spec — use `noedit` below.
564    ///
565    /// If `noform` is true, `noedit` is moot (noform takes precedence).
566    pub noform: bool,
567
568    /// When `true`, this is a privileged/server-managed field: the untrusted
569    /// JSON write path (`insert_json`/`update_json` — REST create/update and
570    /// admin form-submit) strips it UNLESS the caller explicitly authorizes it
571    /// via [`crate::orm::dynamic::DynQuerySet::allow_privileged`]. Set via
572    /// `#[umbral(privileged)]`.
573    ///
574    /// This is the default-DENY mass-assignment guard (audit_2 H3): fields like
575    /// `is_superuser` / `is_staff` / ownership FKs stay writable through the
576    /// typed struct path and through an *authorized* dynamic write, but an
577    /// unprivileged client can't set them by smuggling them into a create/update
578    /// body. Unlike `noform`, the field still renders on forms (an admin with
579    /// the right permission legitimately edits it); the guard is on the write,
580    /// not the visibility. OpenAPI is unaffected — the field remains in the
581    /// writable schema, since whether a given caller may set it is a runtime
582    /// authorization decision, not a static contract.
583    pub privileged: bool,
584
585    /// Confidential, but legitimately viewable by SOME callers. Stripped from every
586    /// serialized response (`DynQuerySet`'s JSON reads — which is what REST, GraphQL and the
587    /// admin all sit on) UNLESS that read explicitly unlocks it via
588    /// [`crate::orm::dynamic::DynQuerySet::allow_private`]. Set via `#[umbral(private)]`.
589    ///
590    /// This is the read-path twin of [`Self::privileged`]: default-deny, with an explicit
591    /// unlock at the call site rather than a config flag, so that `grep -rn allow_private`
592    /// is a complete inventory of every place confidential data is permitted to leave.
593    ///
594    /// Wholesale cost, internal notes, another user's email. NOT password hashes — those are
595    /// [`Self::secret`], which has no unlock at all.
596    pub private: bool,
597
598    /// Must never be serialized to a client. By anyone. There is deliberately **no unlock**.
599    /// Set via `#[umbral(secret)]`, and applied automatically to every `Masked<T>` field.
600    ///
601    /// The value of a tier with no escape hatch is that nobody can reach for it at 2am under
602    /// a deadline. An admin that needs to show *whether* a password is set shows "set / not
603    /// set" — never the hash.
604    ///
605    /// The one thing that is not a client: a database dump. `dumpdata` must round-trip
606    /// password hashes or a restore locks every user out, so it reads through the loudly
607    /// named [`crate::orm::dynamic::DynQuerySet::unredacted_for_backup`].
608    pub secret: bool,
609
610    /// For `SqlType::ForeignKey` fields: whether the migration engine
611    /// emits a *physical* `FOREIGN KEY ... REFERENCES` constraint.
612    /// Toggles the physical FK constraint. Set via
613    /// `#[umbral(db_constraint = false)]`; defaults to `true` (today's
614    /// behaviour — emit the constraint).
615    ///
616    /// When `false`, the FK stays a *logical* relation: the column +
617    /// `fk_target` are unchanged, so joins, `select_related`, and the
618    /// app-level `check_fk_row_exists` pre-validation all keep working —
619    /// but no `REFERENCES` clause is rendered. This is the only way to
620    /// model an FK whose target lives on a *different* database (a real
621    /// DB constraint can't span databases). The boot-time guard in
622    /// `App::build` rejects a cross-database FK that has NOT opted out
623    /// via this flag (`BuildError::CrossDatabaseForeignKey`). Closes
624    /// gaps2 #22. Ignored for non-FK fields.
625    pub db_constraint: bool,
626
627    /// When `true`, the admin shows this field disabled on the edit
628    /// form. Pure UX hint — no effect on the REST API or the OpenAPI
629    /// spec; clients can still POST/PUT/PATCH the column normally.
630    /// Set via `#[umbral(noedit)]`.
631    ///
632    /// Use case: a value the user supplies once at signup (`email`,
633    /// `username`) but isn't supposed to change later through the
634    /// admin. The REST API may still accept updates — gate that
635    /// separately via `ResourceConfig::hide(...)` or a permission
636    /// class if you want hard enforcement. To block writes entirely,
637    /// use `noform` instead.
638    ///
639    /// Has no effect when `noform` is also set.
640    pub noedit: bool,
641
642    /// When `true`, this field is the display string for the
643    /// model — the admin uses it as the default label in
644    /// `list_display` when the developer hasn't specified one
645    /// explicitly. Set via `#[umbral(string)]` /
646    /// `#[umbral(string = true)]`. Only meaningful on `String`-typed
647    /// columns; on non-string columns the admin falls back to the PK.
648    pub is_string_repr: bool,
649
650    /// Soft length cap for display. The admin truncates the value at
651    /// this many characters when rendering it in `list_display` so a
652    /// long body doesn't blow out a column. `0` means no truncation.
653    /// Set via `#[umbral(max_length = N)]`.
654    pub max_length: u32,
655
656    /// Closed-set values for a choices column, in declaration order.
657    /// Populated by the `#[derive(Model)]` macro for fields tagged
658    /// `#[umbral(choices)]` by reading `<T as ChoiceField>::VALUES` at
659    /// derive time. Empty slice means "not a choices field" — every
660    /// non-choices column uses the empty default.
661    ///
662    /// The migration engine emits a Postgres `CHECK (col IN (...))`
663    /// constraint when this slice is non-empty; the admin renders a
664    /// `<select>` widget with these as the `<option>` values.
665    pub choices: &'static [&'static str],
666
667    /// Human-readable labels matching `choices` position-for-position.
668    /// Used by the admin to render the `<select>` widget's option text.
669    /// Empty when `choices` is empty.
670    pub choice_labels: &'static [&'static str],
671
672    /// SQL `DEFAULT` clause for this column. Set via
673    /// `#[umbral(default = "...")]` — accepts a string literal that
674    /// the DDL pass passes verbatim into `DEFAULT '<value>'`. Empty
675    /// string means no default. Carried through to the migration
676    /// engine, which emits the `DEFAULT` on both `CREATE TABLE` and
677    /// `ALTER TABLE ADD COLUMN`.
678    pub default: &'static str,
679
680    /// When `true`, this column is a [`MultiChoice<E>`] field: TEXT
681    /// storage holding a CSV of the variants of `E`. The `choices` and
682    /// `choice_labels` slices carry the same metadata as a single-valued
683    /// choices field — the admin uses `is_multichoice` to pick the
684    /// checkbox-chip widget over the `<select>` widget.
685    ///
686    /// [`MultiChoice<E>`]: crate::orm::MultiChoice
687    pub is_multichoice: bool,
688
689    /// When `true`, the migration engine emits a `UNIQUE` constraint
690    /// on this column at `CREATE TABLE` time. Set via
691    /// `#[umbral(unique)]`. Closes gap #65.
692    ///
693    /// Scope at v1: applies to *new* tables only. Toggling `unique`
694    /// on an existing column does not generate an automatic
695    /// `ALTER TABLE ADD CONSTRAINT` — SQLite cannot add a unique
696    /// constraint without rebuilding the table, and the M8 diff
697    /// engine only watches `ty` and `nullable`. Add or remove
698    /// uniqueness on a live table via a hand-written migration
699    /// until the diff engine grows constraint-level ops.
700    ///
701    /// Primary-key columns are already implicitly unique, so this
702    /// flag is a no-op on a PK field. Set it on every other column
703    /// that needs database-enforced uniqueness (`username`,
704    /// `email`, opaque tokens, slugs, etc.) so handler-level
705    /// pre-checks become unnecessary.
706    pub unique: bool,
707
708    /// Referential action emitted on `DELETE` of the FK target row.
709    /// Only meaningful when `ty == ForeignKey`; ignored for every
710    /// other column. Set via `#[umbral(on_delete = "...")]`. Closes
711    /// gap #68. Defaults to `NoAction` so existing migrations
712    /// don't change shape.
713    pub on_delete: FkAction,
714
715    /// Referential action emitted on `UPDATE` of the FK target row's
716    /// primary key. Same FK-only semantics as `on_delete`; almost
717    /// nobody touches this in practice (PKs rarely move) but the
718    /// symmetry matches `REFERENCES ... ON UPDATE ...` and the
719    /// `on_delete` / `on_update` pair. Set via
720    /// `#[umbral(on_update = "...")]`.
721    pub on_update: FkAction,
722
723    /// When `true`, the migration engine emits a single-column
724    /// `CREATE INDEX` statement alongside the `CREATE TABLE`. Set
725    /// via `#[umbral(index)]`. Closes BUG-4 in
726    /// `bugs/tests/testBugs.md`.
727    ///
728    /// Index name convention: `idx_<table>_<column>`. Apps that
729    /// need a custom name, a multi-column index, or a partial
730    /// index write the `CREATE INDEX` by hand in a follow-up
731    /// migration.
732    pub index: bool,
733
734    /// When `true`, the column gets populated with `Utc::now()` at
735    /// row-creation time *only*. Set via `#[umbral(auto_now_add)]`.
736    /// Closes BUG-5 in `bugs/tests/testBugs.md`.
737    ///
738    /// **Where this fires:** the dynamic write path
739    /// (`DynQuerySet::insert_json`, used by `umbral-rest` /
740    /// `umbral-admin`). The typed `Manager::create(instance)` path
741    /// is user-controlled — the caller passes whatever value they
742    /// chose at the struct-init site. v1 scope: the framework
743    /// auto-populates only when the body / form omits the field.
744    pub auto_now_add: bool,
745    /// `#[umbral(auto_user_add)]` — stamp the authenticated caller's id on
746    /// INSERT only. Opt-in by ATTRIBUTE, never by column name: a field you
747    /// happen to call `created_by` with no attribute is yours, untouched.
748    pub auto_user_add: bool,
749    /// `#[umbral(auto_user)]` — stamp the authenticated caller's id on every
750    /// write.
751    pub auto_user: bool,
752
753    /// When `true`, the column gets populated with `Utc::now()` on
754    /// every write (create AND update). Set via `#[umbral(auto_now)]`. Closes
755    /// BUG-5 in `bugs/tests/testBugs.md`.
756    ///
757    /// **Where this fires:** the dynamic write path
758    /// (`DynQuerySet::insert_json` and `update_json`, used by
759    /// `umbral-rest` / `umbral-admin`). The typed paths stay
760    /// user-controlled at v1. Body-supplied values are kept —
761    /// users can override `auto_now` columns on the dynamic
762    /// path, matching the lenient "fill if missing" shape of
763    /// `auto_now_add`. An "always override" shape lands as
764    /// a future v2 toggle if a real consumer asks.
765    pub auto_now: bool,
766
767    /// When `true`, the framework generates a fresh `Uuid::new_v4()` for
768    /// this column at row-creation time when the body/struct omits it (or
769    /// leaves it at the nil UUID). Set via `#[umbral(auto_uuid)]`. The
770    /// who-am-I-publicly twin of `auto_now_add`: a stable, non-sequential
771    /// public identifier that doesn't leak row counts, generated in Rust so
772    /// it works identically on SQLite and Postgres (unlike a
773    /// `gen_random_uuid()` DDL default, which is Postgres-only). Fires on
774    /// BOTH the dynamic write path (REST/admin) and the typed
775    /// `Manager::create` path; an explicitly-supplied non-nil value is kept.
776    pub auto_uuid: bool,
777
778    /// When `true`, the dynamic write path strips leading/trailing whitespace
779    /// from this column's string value before INSERT/UPDATE. Set via
780    /// `#[umbral(trim)]`; only valid on `String` / `Option<String>` fields
781    /// (the derive rejects it elsewhere at compile time).
782    ///
783    /// **Where this fires:** the dynamic write path only
784    /// (`DynQuerySet::insert_json`/`update_json` + the admin form builders),
785    /// exactly like [`auto_now`](Self::auto_now). The typed
786    /// `Manager::create(instance)` path is caller-controlled — normalize there
787    /// yourself (e.g. `umbral_auth::normalize_email`) if you need it. Combines
788    /// with [`lowercase`](Self::lowercase): trim runs first, then lowercase.
789    pub trim: bool,
790
791    /// When `true`, the dynamic write path lowercases this column's string
792    /// value before INSERT/UPDATE. Set via `#[umbral(lowercase)]`; only valid
793    /// on `String` / `Option<String>` fields. Pair with [`unique`](Self::unique)
794    /// to get case-insensitive uniqueness for free (every stored row is already
795    /// lowercased), and with [`trim`](Self::trim) to also drop surrounding
796    /// whitespace. Same dynamic-path-only scope as [`trim`](Self::trim).
797    pub lowercase: bool,
798
799    /// When `true`, the column is **case-insensitive at the database level**:
800    /// comparisons, `UNIQUE`, and lookups treat `Dalmas` and `dalmas` as equal,
801    /// while the *original* casing is preserved in storage. Set via
802    /// `#[umbral(case_insensitive)]`; `String` / `Option<String>` only.
803    ///
804    /// Unlike [`lowercase`](Self::lowercase) (which normalizes the stored value
805    /// and rides a plain UNIQUE), this changes the emitted DDL: Postgres gets a
806    /// `citext` column (the migration auto-creates the `citext` extension),
807    /// SQLite gets `COLLATE NOCASE`. It is schema-affecting, so — like
808    /// [`unique`](Self::unique) — it applies at `CREATE TABLE`; toggling it on an
809    /// existing column needs a hand-written migration.
810    ///
811    /// Caveat: SQLite's `NOCASE` folds ASCII `A–Z` only (not Unicode); a boot
812    /// check warns when this is used on SQLite. Postgres `citext` folds per the
813    /// database collation. Prefer [`lowercase`](Self::lowercase) when you don't
814    /// need to preserve the original casing.
815    pub case_insensitive: bool,
816
817    /// Human-readable column description (help text).
818    /// Set via `#[umbral(help = "...")]`. Flows
819    /// through to:
820    ///
821    /// - The **database itself**, as a Postgres `COMMENT ON COLUMN`
822    ///   (gaps3 #43). SQLite has no comment facility, so it is
823    ///   omitted there. This is the one place `help` is not
824    ///   purely presentational: editing it produces a
825    ///   `SetColumnComment` migration.
826    /// - OpenAPI `description` on the property schema (closes
827    ///   playground-openapi-gaps item 5).
828    /// - Admin form field hint (the small line below the
829    ///   input).
830    /// - TSDoc on the generated TypeScript interface
831    ///   (`umbral typegen`).
832    ///
833    /// Empty string means "no description" — the OpenAPI
834    /// emitter and admin form skip the surrounding markup
835    /// when this is unset, and no comment is emitted.
836    pub help: &'static str,
837
838    /// Presentation hint for form-rendering surfaces. Set via
839    /// `#[umbral(widget = "markdown" | "rte" | "textarea" | ...)]`;
840    /// `None` (the default) means "let the renderer pick by
841    /// `SqlType`". features.md #4.
842    ///
843    /// It is **metadata only** — the column's `SqlType`, DDL, and
844    /// stored value are unchanged. A `widget = "markdown"` field is
845    /// still `TEXT`; the widget only tells the admin (or any plugin
846    /// form) to render a markdown editor instead of a bare
847    /// `<textarea>`, and pairs with the `{{ value | markdown }}`
848    /// filter on the display side. Excluded from the migration diff
849    /// for the same reason `example` is: no DB effect. (`help` used to
850    /// be in that list; it now renders as a Postgres column comment.)
851    ///
852    /// Renderers fall back to the `SqlType`-derived input for any
853    /// widget name they don't recognise, so an unknown widget is a
854    /// soft no-op rather than an error — third-party plugins can ship
855    /// new widget names without the core knowing them.
856    pub widget: Option<&'static str>,
857
858    /// Sample value rendered as OpenAPI `example` on the property
859    /// schema. Set via `#[umbral(example = "...")]`. Closes
860    /// playground-openapi-gaps item 6.
861    ///
862    /// Empty string means no example. Emitted as a JSON string in
863    /// the spec — clients that want typed examples can coerce on
864    /// their end. Pairs naturally with `help` to make a column's
865    /// purpose clear in Swagger UI.
866    pub example: &'static str,
867
868    /// Optional numeric lower bound. Set via `#[umbral(min = N)]`.
869    /// Closes IMP-3 from `bugs/tests/testBugs.md`. Flows to:
870    ///
871    /// - OpenAPI `minimum` on the property schema.
872    /// - REST plugin's dynamic write path pre-validation (400
873    ///   response with a structured message).
874    /// - Future: HTML5 `min` attribute on admin form inputs.
875    ///
876    /// `i64::MIN` sentinel means "no minimum"; the DDL +
877    /// OpenAPI emitters skip the constraint when this is the
878    /// sentinel value. Macro accepts integer literals only at
879    /// v1 (a `Decimal`-aware shape can land when there's a real
880    /// consumer for decimal-typed validators).
881    pub min: Option<i64>,
882
883    /// Optional numeric upper bound. Set via `#[umbral(max = N)]`.
884    /// Mirror of `min`; same plumbing on the OpenAPI / REST /
885    /// admin sides.
886    pub max: Option<i64>,
887
888    /// Constrained-text marker. `None` is a plain `String` /
889    /// `SqlType::Text` column; `Some("slug" | "email" | "url")` is
890    /// one of the validator wrapper types from
891    /// [`crate::orm::validators`]. Closes BUG-11/12/13. Flows to:
892    ///
893    /// - OpenAPI `format: email` / `format: uri` / `pattern` on the
894    ///   property schema (the standard 3.0 markers).
895    /// - REST plugin's dynamic write path: `validate_text_format`
896    ///   pre-checks the body value and returns a structured 400
897    ///   on a bad input.
898    /// - Admin form: HTML5 `type="email"` / `type="url"` widget
899    ///   (when those land).
900    ///
901    /// The marker is set by the macro classifier from the field type
902    /// — `Slug` → `Some("slug")`, `Email` → `Some("email")`,
903    /// `Url` → `Some("url")`. The wrapper type + marker stay in sync
904    /// because they're produced from the same single match arm in
905    /// `umbral-macros::classify_field_type`.
906    pub text_format: Option<&'static str>,
907
908    /// Source column for an auto-derived slug. Set via
909    /// `#[umbral(slug_from = "title")]` on a `Slug` / `String` field;
910    /// names a sibling column on the same model whose value seeds
911    /// this column at write time. Gap 109.
912    ///
913    /// **Where this fires:** the dynamic write path
914    /// ([`crate::orm::DynQuerySet::insert_json`] +
915    /// [`crate::orm::DynQuerySet::update_json`]). On insert, an empty
916    /// or absent slug column is replaced by `slugify(source_value)`
917    /// derived from the source column in the same body. On update,
918    /// the slug is regenerated only when the source column is also
919    /// in the update payload, so callers who edit nothing but the
920    /// slug itself keep their hand-tuned value.
921    ///
922    /// `None` is the default — no auto-derive. The string is a
923    /// column name (snake_case), not a Rust field name, so it must
924    /// match exactly what ends up in `FieldSpec::name`.
925    pub slug_from: Option<&'static str>,
926}
927
928impl FieldSpec {
929    /// A do-nothing `FieldSpec` used only to initialise the fixed-size
930    /// array inside [`concat_field_specs`] before the real specs are
931    /// copied over. Never surfaces in a model's `FIELDS`: every slot is
932    /// overwritten by a genuine spec during the const concat.
933    pub const PLACEHOLDER: FieldSpec = FieldSpec {
934        name: "",
935        ty: SqlType::Integer,
936        primary_key: false,
937        nullable: false,
938        supported_backends: &[],
939        fk_target: None,
940        noform: false,
941        privileged: false,
942        private: false,
943        secret: false,
944        db_constraint: true,
945        noedit: false,
946        is_string_repr: false,
947        max_length: 0,
948        choices: &[],
949        choice_labels: &[],
950        default: "",
951        is_multichoice: false,
952        unique: false,
953        on_delete: FkAction::NoAction,
954        on_update: FkAction::NoAction,
955        index: false,
956        auto_now_add: false,
957        auto_user_add: false,
958        auto_user: false,
959        auto_now: false,
960        auto_uuid: false,
961        trim: false,
962        lowercase: false,
963        case_insensitive: false,
964        help: "",
965        widget: None,
966        example: "",
967        min: None,
968        max: None,
969        text_format: None,
970        slug_from: None,
971    };
972}
973
974/// Concatenate several `&'static [FieldSpec]` slices into one owned
975/// `[FieldSpec; N]` array at compile time. `N` MUST equal the sum of all
976/// `parts` lengths (the `#[derive(Model)]` macro computes it from the
977/// base fields' `BASE_FIELDS.len()` plus the model's own field count).
978///
979/// This is the mechanism that lets a model embedding a [`ModelBase`] via
980/// `#[umbral(flatten)]` splice the base's columns into its own `FIELDS`
981/// const — `FieldSpec` is `Copy`, so the whole thing evaluates in a
982/// `const` context without allocation.
983pub const fn concat_field_specs<const N: usize>(parts: &[&[FieldSpec]]) -> [FieldSpec; N] {
984    let mut out = [FieldSpec::PLACEHOLDER; N];
985    let mut oi = 0;
986    let mut pi = 0;
987    while pi < parts.len() {
988        let part = parts[pi];
989        let mut i = 0;
990        while i < part.len() {
991            out[oi] = part[i];
992            oi += 1;
993            i += 1;
994        }
995        pi += 1;
996    }
997    out
998}
999
1000/// A reusable group of model fields — the umbral equivalent of a Django
1001/// abstract base model. A struct deriving [`ModelBase`](macro@crate::orm::ModelBase)
1002/// declares shared columns once (with the full `#[umbral(...)]` attribute
1003/// set); any [`Model`] embeds it as a nested field marked
1004/// `#[umbral(flatten)]` and inherits those columns as if written inline.
1005///
1006/// The base's columns are spliced into the embedding model's
1007/// [`Model::FIELDS`] via [`concat_field_specs`], so migrations, the SELECT
1008/// list, inserts and auto-stamping all see one flat schema. The embedding
1009/// model reads/writes the base's values through `#[serde(flatten)]` +
1010/// `#[sqlx(flatten)]` on the nested field.
1011pub trait ModelBase {
1012    /// The base's columns, in declaration order, carrying every attribute
1013    /// they were declared with. Spliced into the embedding model's
1014    /// `FIELDS`.
1015    const BASE_FIELDS: &'static [FieldSpec];
1016
1017    /// The name of the base's primary-key column, or `None` when the base
1018    /// declares no primary key. When a model has no PK of its own and
1019    /// embeds exactly one base with `BASE_PK = Some(_)`, that column
1020    /// becomes the model's primary key.
1021    const BASE_PK: Option<&'static str>;
1022
1023    /// The Rust type of the base's primary key (echoing the field type).
1024    /// A dummy `i64` when the base has no PK — never read in that case.
1025    type BasePrimaryKey: PrimaryKey;
1026
1027    /// Read this base value's primary key. Used by the embedding model's
1028    /// `Model::primary_key` when the PK lives on the base. Returns a
1029    /// meaningless default when the base has no PK (never called then).
1030    fn base_primary_key(&self) -> Self::BasePrimaryKey;
1031}
1032
1033/// Referential action emitted in the SQL `REFERENCES ... ON
1034/// {DELETE,UPDATE} <action>` clause. Mirrors the standard SQL set.
1035///
1036/// Copy + 'static so it can live on `FieldSpec` (which is itself
1037/// `Copy` for storage in `&'static [FieldSpec]`).
1038#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
1039pub enum FkAction {
1040    /// SQL `NO ACTION` — the default. The migration engine emits no
1041    /// clause at all (which means "default" on both backends; sqlite
1042    /// and Postgres both default to NO ACTION when omitted).
1043    #[default]
1044    NoAction,
1045    /// SQL `CASCADE` — when the FK target row is deleted/updated,
1046    /// the referencing row is deleted/updated too. The right answer
1047    /// for "owned" relationships (an `AuthToken` follows its
1048    /// owning `AuthUser` to the grave).
1049    Cascade,
1050    /// SQL `RESTRICT` — block the delete/update of the FK target
1051    /// row if any referencing row exists. Checked immediately;
1052    /// doesn't defer to commit. Right for "you can't drop a
1053    /// category that still has products in it."
1054    Restrict,
1055    /// SQL `SET NULL` — null the referencing column. Only valid on
1056    /// nullable FK columns; the migration engine doesn't currently
1057    /// check this at boot, so a mismatched pair (NOT NULL + SET NULL)
1058    /// will fail at FK action time, not at CREATE TABLE.
1059    SetNull,
1060}
1061
1062impl FkAction {
1063    /// SQL keyword for the `ON {DELETE,UPDATE} <kw>` clause.
1064    /// Returns `None` for `NoAction` so the DDL builder can skip
1065    /// the clause entirely (rather than emitting the redundant
1066    /// `NO ACTION` literal).
1067    pub fn sql_keyword(self) -> Option<&'static str> {
1068        match self {
1069            Self::NoAction => None,
1070            Self::Cascade => Some("CASCADE"),
1071            Self::Restrict => Some("RESTRICT"),
1072            Self::SetNull => Some("SET NULL"),
1073        }
1074    }
1075
1076    /// Parse the attribute string supplied to `#[umbral(on_delete = "...")]`.
1077    /// Case-insensitive; accepts both `set_null` and `set null` for
1078    /// the multi-word case so users can write whichever feels
1079    /// natural.
1080    pub fn from_attr_str(s: &str) -> Option<Self> {
1081        match s.to_lowercase().as_str() {
1082            "no_action" | "no action" => Some(Self::NoAction),
1083            "cascade" => Some(Self::Cascade),
1084            "restrict" => Some(Self::Restrict),
1085            "set_null" | "set null" => Some(Self::SetNull),
1086            _ => None,
1087        }
1088    }
1089}
1090
1091/// The SQL type kind of a column.
1092///
1093/// The dialect-specific rendering (`BIGINT` vs `INTEGER` vs whatever
1094/// the backend calls it) is the backend's responsibility, set up by
1095/// the M4 `DatabaseBackend` abstraction. This enum is the abstract
1096/// classification umbral reasons about.
1097///
1098/// The catalogue follows spec 04 §4.1: each variant covers one
1099/// field type. Rust types in the field declaration map to a
1100/// variant via the M3 derive's `classify_field_type`; the table is in
1101/// `umbral-macros/src/lib.rs` alongside the derive.
1102///
1103/// Backend-specific variants (Postgres `Array`, `HStore`, `Jsonb`) land
1104/// at M4 when the system check exists to gate them at boot.
1105#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1106pub enum SqlType {
1107    /// A foreign-key reference to another table. Stored as `i64` (the
1108    /// referenced row's primary key). Renders as `BIGINT REFERENCES
1109    /// "<target_table>"("id")` on both Postgres and SQLite.
1110    ///
1111    /// The referenced table name is carried separately in
1112    /// [`FieldSpec::fk_target`] so this enum stays `Copy`. The migration
1113    /// engine reads `fk_target` at DDL-emit time.
1114    ///
1115    /// Out of scope at v1: non-`i64` FK targets, `ON DELETE` behaviours
1116    /// beyond the default RESTRICT, reverse accessors (`User::posts`),
1117    /// and many-to-many join tables. See `docs/specs/relationships.md`.
1118    ForeignKey,
1119    /// 16-bit signed integer. `i8` / `i16` / `u8` in Rust.
1120    SmallInt,
1121    /// 32-bit signed integer. `i32` / `u16` in Rust.
1122    Integer,
1123    /// 64-bit signed integer. `i64` / `u32` in Rust.
1124    BigInt,
1125    /// 32-bit floating point. `f32` in Rust.
1126    Real,
1127    /// 64-bit floating point. `f64` in Rust.
1128    Double,
1129    /// Boolean. `bool` in Rust.
1130    Boolean,
1131    /// Variable-length string. `String` in Rust.
1132    Text,
1133    /// Date without time. `chrono::NaiveDate` in Rust.
1134    Date,
1135    /// Time without date. `chrono::NaiveTime` in Rust.
1136    Time,
1137    /// Timestamp with timezone. `chrono::DateTime<chrono::Utc>` in Rust.
1138    Timestamptz,
1139    /// Timestamp WITHOUT timezone. `chrono::NaiveDateTime` in Rust; a bare
1140    /// `TIMESTAMP` on Postgres (`DATETIME`/text on SQLite). Distinct from
1141    /// [`Self::Timestamptz`]: sqlx will not decode a Postgres `TIMESTAMP` into a
1142    /// `DateTime<Utc>`, so a schema that stores naive timestamps (Prisma's
1143    /// `DateTime`, some Django configs) needs this to round-trip. No timezone
1144    /// conversion happens at the marshalling boundary — the wall-clock value is
1145    /// stored and read verbatim.
1146    Timestamp,
1147    /// 128-bit UUID. `uuid::Uuid` in Rust.
1148    Uuid,
1149    /// JSON document. `serde_json::Value` in Rust.
1150    ///
1151    /// Cross-backend: Postgres stores native `JSONB` (binary form with
1152    /// index / operator support); SQLite stores `TEXT` (JSON-as-string).
1153    /// `serde_json::Value` round-trips through both via sqlx's `json`
1154    /// feature, so a user model with a `Value` field works on either
1155    /// backend without code changes: a portable JSON field with a
1156    /// portable shape and dialect-specific storage. Native JSONB-only
1157    /// operators (`@>`, `->`, `->>` etc.) are a deferred follow-on
1158    /// landed alongside Postgres-specific column predicates.
1159    Json,
1160    /// Array column. `Vec<T>` in Rust where `T` is one of the
1161    /// [`ArrayElement`] variants.
1162    ///
1163    /// **Postgres-only.** SQLite has no native array type; the M4
1164    /// system check fails at boot if an Array field is registered
1165    /// against the SQLite backend. For portable list storage, declare
1166    /// the field as `serde_json::Value` (the [`Self::Json`] variant)
1167    /// and store a JSON array inside.
1168    ///
1169    /// The inner type is restricted to [`ArrayElement`] rather than
1170    /// `Box<SqlType>` so the outer enum stays `Copy` and `SqlType`
1171    /// values can live in `const FIELDS` slices the derive emits.
1172    /// Multi-dim arrays (`Vec<Vec<T>>`), nullable elements
1173    /// (`Vec<Option<T>>`), and nested JSON arrays (`Vec<Value>`) are
1174    /// out of scope for v1.
1175    Array(ArrayElement),
1176    /// `INET` — Postgres IP address column with optional netmask.
1177    /// Maps to `ipnetwork::IpNetwork` in Rust. **Postgres-only.**
1178    /// Stores a generic IP address.
1179    Inet,
1180    /// `CIDR` — Postgres network address column. Same Rust type as
1181    /// `Inet` (`ipnetwork::IpNetwork`) but with the constraint that
1182    /// the host bits must be zero. **Postgres-only.**
1183    Cidr,
1184    /// `MACADDR` — Postgres MAC address column. Maps to
1185    /// `mac_address::MacAddress` in Rust. **Postgres-only.**
1186    MacAddr,
1187    /// `XML` — Postgres XML document column. Maps to `String` in Rust
1188    /// (umbral stores and round-trips the serialized XML text; it does
1189    /// not parse or validate the document at the framework level —
1190    /// Postgres does that on insert). **Postgres-only.** Reach for this
1191    /// over `Text` only when you want Postgres' `xml` type checking and
1192    /// the `xpath` / `xmlexists` operator surface; otherwise `Text`
1193    /// stores XML strings just fine (XML is otherwise modelled as plain
1194    /// text).
1195    Xml,
1196    /// `LTREE` — Postgres hierarchical label-path column (the `ltree`
1197    /// extension). Maps to `String` in Rust (the dotted path, e.g.
1198    /// `"Top.Science.Astronomy"`). **Postgres-only**, and requires the
1199    /// `ltree` extension (`CREATE EXTENSION ltree`) to be installed in
1200    /// the target database. The umbral migration engine emits the bare
1201    /// `ltree` column type; the extension itself is the operator's
1202    /// responsibility (a hand-written migration or a DB bootstrap step).
1203    Ltree,
1204    /// `BIT VARYING` — Postgres bit-string column. Maps to `String` in
1205    /// Rust (the textual `"0"`/`"1"` representation, e.g. `"101"`).
1206    /// **Postgres-only.** v1 renders as `BIT VARYING` (variable-length);
1207    /// a fixed-width `BIT(n)` needs a hand-written migration after the
1208    /// initial create until a `#[umbral(bit_len = N)]` attribute lands
1209    /// for a real consumer. There is otherwise no dedicated bit-string
1210    /// type; the fallback is plain text.
1211    Bit,
1212    /// `TSVECTOR` — Postgres full-text search lexeme vector. Maps to
1213    /// [`crate::orm::TsVector`] in Rust (a thin newtype around
1214    /// `String` with sqlx Type/Encode/Decode impls). **Postgres-only.**
1215    ///
1216    /// The column is typically populated by a Postgres trigger or
1217    /// `GENERATED ALWAYS AS (to_tsvector(...)) STORED` clause; umbral's
1218    /// migration engine emits the bare `tsvector` type, leaving the
1219    /// population mechanism to the user. Queries against a
1220    /// `FullTextCol` use the `@@` match operator with `to_tsquery` /
1221    /// `websearch_to_tsquery`.
1222    FullText,
1223    /// `BLOB` (SQLite) / `BYTEA` (Postgres) — arbitrary binary payload.
1224    /// Maps to `Vec<u8>` in Rust. Used by anything that stores opaque
1225    /// bytes: file uploads, the cache backend's value column, encrypted
1226    /// envelopes, etc.
1227    ///
1228    /// `Vec<u8>` was previously routed to `SqlType::Array(SmallInt)`
1229    /// because the array detection treated `u8` as a small int. The
1230    /// detection now checks for `Vec<u8>` specifically first and
1231    /// routes to `Bytes`; `Vec<i8>` / `Vec<i16>` still map to
1232    /// `Array(SmallInt)`.
1233    Bytes,
1234    /// `NUMERIC(19, 4)` — fixed-point decimal. Maps to
1235    /// `rust_decimal::Decimal` in Rust. Closes BUG-10 from
1236    /// `bugs/tests/testBugs.md`. Money / price columns must use
1237    /// this, not `f64` (binary float drops cents) or `String`
1238    /// (no DB-level arithmetic).
1239    ///
1240    /// **Postgres-only at v1.** sqlx's `rust_decimal` feature
1241    /// adds Encode/Decode for Postgres `NUMERIC` only; SQLite has
1242    /// no native decimal type (every numeric value is INTEGER /
1243    /// REAL / TEXT affinity). The boot system check rejects
1244    /// Decimal models against SQLite the same way it rejects
1245    /// `Array(_)` — apps deploying to SQLite either pick a
1246    /// portable type (`Real` or `Text` with manual formatting) or
1247    /// use Postgres for the parts of their schema that need
1248    /// decimal arithmetic. A fixed-precision decimal column.
1249    ///
1250    /// **v1 scope.** Precision and scale are fixed at `(19, 4)` —
1251    /// 19 significant digits, 4 after the decimal point. That's
1252    /// enough headroom for currency values up to one quadrillion
1253    /// dollars (with sub-cent precision) and matches sqlx's
1254    /// `Decimal` default. Apps that need a different precision
1255    /// alter the column via a hand-written migration after the
1256    /// initial create. A `#[umbral(precision = N, scale = M)]`
1257    /// attribute lands when there's a real consumer that needs
1258    /// dimensions outside the default.
1259    Decimal,
1260    /// Arbitrary-precision decimal backed by `bigdecimal::BigDecimal`
1261    /// (Postgres `numeric`, unbounded). The sibling of [`SqlType::Decimal`]
1262    /// for values that overflow `rust_decimal`'s ~28-significant-digit
1263    /// ceiling — a `numeric(38, 10)` column, astronomical quantities, or
1264    /// exact math that must not round. Same DDL family as `Decimal`
1265    /// (`numeric` / `numeric(p, s)`), but the Rust codec is `BigDecimal`,
1266    /// which carries as many digits as the value needs.
1267    ///
1268    /// Postgres-only, exactly like `Decimal`: SQLite has no arbitrary-
1269    /// precision numeric type, so the system check rejects a `BigDecimal`
1270    /// field against a SQLite backend at boot.
1271    BigDecimal,
1272    /// A fixed-precision decimal with **caller-chosen** dimensions —
1273    /// `numeric(precision, scale)` — the sibling of [`SqlType::Decimal`]
1274    /// (which is the `(19, 4)` default) for a column that needs different
1275    /// dimensions: `numeric(5, 2)` for a percentage, `numeric(19, 8)` for an
1276    /// FX rate. Produced by `#[umbral(precision = N, scale = M)]` on a
1277    /// `rust_decimal::Decimal` field. Shares the `rust_decimal` codec with
1278    /// `Decimal`, so precision must stay within rust_decimal's ~28-digit
1279    /// ceiling (use `BigDecimal` beyond that). Postgres-only, like `Decimal`.
1280    DecimalN(DecimalSpec),
1281    /// PostGIS `geometry(<kind>, <srid>)` — a planar spatial column.
1282    /// Postgres-only, behind the `postgis` cargo feature. The subtype and
1283    /// SRID travel inside the [`GeometrySpec`] payload (mirroring how
1284    /// `Array(ArrayElement)` nests a `Copy` sub-enum) so `SqlType` stays
1285    /// `Copy` and usable in a `const FIELDS` slice, and so the type and its
1286    /// SRID stay together the way PostGIS itself models `geometry(Point, 4326)`
1287    /// as one column type.
1288    Geometry(GeometrySpec),
1289    /// PostGIS `geography(<kind>, <srid>)` — a spheroidal spatial column where
1290    /// distances are in metres. The spheroidal sibling of [`SqlType::Geometry`].
1291    Geography(GeometrySpec),
1292}
1293
1294/// The dimensions of a [`SqlType::DecimalN`] column — `numeric(precision,
1295/// scale)`. `Copy` so it nests inside `SqlType` and rides in a `const FIELDS`
1296/// slice.
1297#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1298pub struct DecimalSpec {
1299    /// Total number of significant digits (both sides of the point).
1300    pub precision: u16,
1301    /// Digits after the decimal point.
1302    pub scale: u16,
1303}
1304
1305/// The subtype + SRID of a spatial column ([`SqlType::Geometry`] /
1306/// [`SqlType::Geography`]). `Copy` so it nests inside `SqlType` and rides in a
1307/// `const FIELDS` slice.
1308#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1309pub struct GeometrySpec {
1310    /// The constrained geometry subtype (`Point`, `Polygon`, …), or
1311    /// [`GeometryKind::Geometry`] for the unconstrained base type.
1312    pub kind: GeometryKind,
1313    /// Spatial Reference System Identifier. `4326` (WGS84 lon/lat) is the
1314    /// default; `0` means "unspecified SRID" (a `geometry` column with no SRID
1315    /// constraint).
1316    pub srid: i32,
1317}
1318
1319impl GeometrySpec {
1320    /// The common default: unconstrained geometry in WGS84 (SRID 4326).
1321    pub const DEFAULT: GeometrySpec = GeometrySpec {
1322        kind: GeometryKind::Geometry,
1323        srid: 4326,
1324    };
1325}
1326
1327/// The PostGIS geometry subtype constraint. `Geometry` is the unconstrained
1328/// base type (any subtype allowed); the rest pin the column to one shape.
1329#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1330pub enum GeometryKind {
1331    /// The unconstrained base type — any subtype is accepted.
1332    Geometry,
1333    Point,
1334    LineString,
1335    Polygon,
1336    MultiPoint,
1337    MultiLineString,
1338    MultiPolygon,
1339    GeometryCollection,
1340}
1341
1342impl GeometryKind {
1343    /// The PostGIS type-modifier spelling (`Point`, `MultiPolygon`, …) used
1344    /// inside `geometry(<kind>, <srid>)`. `Geometry` (unconstrained) has no
1345    /// modifier and renders the bare `geometry` type.
1346    pub const fn pg_modifier(self) -> &'static str {
1347        match self {
1348            GeometryKind::Geometry => "Geometry",
1349            GeometryKind::Point => "Point",
1350            GeometryKind::LineString => "LineString",
1351            GeometryKind::Polygon => "Polygon",
1352            GeometryKind::MultiPoint => "MultiPoint",
1353            GeometryKind::MultiLineString => "MultiLineString",
1354            GeometryKind::MultiPolygon => "MultiPolygon",
1355            GeometryKind::GeometryCollection => "GeometryCollection",
1356        }
1357    }
1358
1359    /// Parse a `#[umbral(geometry = "...")]` attribute value (case-insensitive)
1360    /// into a kind. Returns `None` for an unknown spelling so the macro can
1361    /// emit a clear compile error.
1362    pub fn from_attr(s: &str) -> Option<GeometryKind> {
1363        Some(match s.to_ascii_lowercase().as_str() {
1364            "geometry" | "" => GeometryKind::Geometry,
1365            "point" => GeometryKind::Point,
1366            "linestring" => GeometryKind::LineString,
1367            "polygon" => GeometryKind::Polygon,
1368            "multipoint" => GeometryKind::MultiPoint,
1369            "multilinestring" => GeometryKind::MultiLineString,
1370            "multipolygon" => GeometryKind::MultiPolygon,
1371            "geometrycollection" => GeometryKind::GeometryCollection,
1372            _ => return None,
1373        })
1374    }
1375}
1376
1377/// Element types valid inside [`SqlType::Array`].
1378///
1379/// A strict subset of the [`SqlType`] catalogue: the value types
1380/// Postgres supports as `T[]` and that umbral knows how to bind / decode
1381/// through sqlx. Stays `Copy` so the outer `SqlType::Array(ArrayElement)`
1382/// remains usable in `const FIELDS` slices.
1383///
1384/// Catalogue:
1385///
1386/// | Variant     | Postgres type | Rust inner type   |
1387/// |-------------|---------------|-------------------|
1388/// | `SmallInt`  | `int2[]`      | `Vec<i16>`        |
1389/// | `Integer`   | `int4[]`      | `Vec<i32>`        |
1390/// | `BigInt`    | `int8[]`      | `Vec<i64>`        |
1391/// | `Real`      | `float4[]`    | `Vec<f32>`        |
1392/// | `Double`    | `float8[]`    | `Vec<f64>`        |
1393/// | `Boolean`   | `bool[]`      | `Vec<bool>`       |
1394/// | `Text`      | `text[]`      | `Vec<String>`     |
1395/// | `Uuid`      | `uuid[]`      | `Vec<uuid::Uuid>` |
1396///
1397/// Other element types (Date / Time / Timestamptz / Json) land as
1398/// follow-ons when there's a real consumer; the binding semantics for
1399/// chrono types as Postgres array elements need a deliberate pass.
1400#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1401pub enum ArrayElement {
1402    SmallInt,
1403    Integer,
1404    BigInt,
1405    Real,
1406    Double,
1407    Boolean,
1408    Text,
1409    Uuid,
1410}
1411
1412impl ArrayElement {
1413    /// Lift this element type back to its [`SqlType`] equivalent. Used
1414    /// when a per-element decision needs to dispatch through the same
1415    /// SqlType match the rest of umbral uses (e.g. picking a
1416    /// `sea_query::ColumnType` for the element).
1417    pub fn to_sql_type(self) -> SqlType {
1418        match self {
1419            ArrayElement::SmallInt => SqlType::SmallInt,
1420            ArrayElement::Integer => SqlType::Integer,
1421            ArrayElement::BigInt => SqlType::BigInt,
1422            ArrayElement::Real => SqlType::Real,
1423            ArrayElement::Double => SqlType::Double,
1424            ArrayElement::Boolean => SqlType::Boolean,
1425            ArrayElement::Text => SqlType::Text,
1426            ArrayElement::Uuid => SqlType::Uuid,
1427        }
1428    }
1429}