Skip to main content

umbral_core/orm/
dynamic.rs

1//! Runtime-typed QuerySet — the ORM's answer to "I know my model at
2//! request time, not compile time."
3//!
4//! `Manager<T>` is parameterised by a `T: Model` so the typed columns
5//! (`post::TITLE`, `post::ID`) carry their `SqlType` and Rust type at
6//! the type level. That's wrong for the admin: it walks the registry
7//! at request time, so the model is a `ModelMeta` value, the column
8//! name is a `String`, and the result row is a `HashMap<String,
9//! String>` (templates can't see typed structs anyway).
10//!
11//! `DynQuerySet` is the parallel surface. It accepts string column
12//! names against a `ModelMeta`, validates them at chain time (unknown
13//! names are silently dropped so a stale `search_fields` config can't
14//! crash a request), and renders to the same `sea_query` machinery
15//! the typed path uses. Decoding goes through `SqlType` dispatch —
16//! [`decode_to_string`] is the new pub helper that mirrors the
17//! admin's private `column_to_string`.
18//!
19//! ## Scope of this first pass
20//!
21//! v0 ships the surface the admin's list / changelist / rows-fragment
22//! handlers need today: `search`, `filter_eq_string`, `order_by_col`,
23//! `limit`, `offset`, `count`, `fetch_as_strings`. INSERT / UPDATE /
24//! DELETE plus a typed `DynValue` enum land as call sites
25//! migrate. Postgres dispatch lands when the admin runs against
26//! Postgres in earnest — for now the Postgres branches panic with a
27//! clear message.
28
29use std::collections::HashMap;
30
31use sea_query::{
32    Alias, Asterisk, Condition, Expr, Func, Order, PostgresQueryBuilder, Query, SqliteQueryBuilder,
33    Value as SeaValue,
34};
35use sea_query_binder::SqlxBinder;
36use sqlx::Row;
37
38use crate::db::{DbPool, pool_for_dispatched};
39use crate::migrate::{Column, ModelMeta};
40use crate::orm::SqlType;
41use crate::orm::write::{WriteError, json_to_sea_value, null_for};
42
43/// Resolve the pool for a dynamic (late-bound) query on `meta`, routing
44/// through the `DatabaseRouter` exactly like the typed path.
45fn resolve_pool_dyn(meta: &crate::migrate::ModelMeta, op: crate::db::RouteOp) -> crate::db::DbPool {
46    let ctx = crate::db::route_context();
47    let r = crate::db::router::router();
48    let alias = match op {
49        crate::db::RouteOp::Read => r.db_for_read(meta, &ctx),
50        crate::db::RouteOp::Write => r.db_for_write(meta, &ctx),
51    };
52    pool_for_dispatched(alias.as_str()).clone()
53}
54
55/// Errors a runtime-typed query can produce.
56///
57/// Carries the structured [`WriteError`] when the failure originates
58/// in the umbral write-validator (form-coercion failures, required-
59/// field misses, future per-field validation), and bare
60/// [`sqlx::Error`] otherwise — DB-driver failures, constraint
61/// violations the validator can't pre-detect, connection drops.
62///
63/// gaps2 #12: prior to this change `DynError` was a bare alias for
64/// `sqlx::Error`, so every `WriteError` that flowed through the
65/// `DynQuerySet` form path was flattened to
66/// `sqlx::Error::Protocol("umbral::orm::write: <message>")` and the
67/// per-field map (`field_errors()` / `non_field_errors()`) was lost
68/// before the admin handler could render it. The enum preserves the
69/// structure all the way to the response surface; the admin's
70/// per-field rendering work (gaps2 #12 part 2) and the `Form<T>`
71/// extractor (gaps2 #19) both consume it directly.
72///
73/// The two-arm shape composes with `?` ergonomically because both
74/// `sqlx::Error` and `WriteError` lift via `From` — handlers can
75/// keep their existing `?` chains and reach for `match` only at the
76/// boundary where the per-field map is rendered.
77#[derive(Debug)]
78pub enum DynError {
79    /// Structured umbral-validator failure (per-field errors,
80    /// validator rules, FK / unique violations the validator
81    /// pre-detected). The carried [`WriteError`] keeps its
82    /// `field_errors()` / `non_field_errors()` accessors.
83    Write(WriteError),
84    /// Bare sqlx failure (driver-level error, connection drop,
85    /// constraint violation the validator didn't catch). Surface
86    /// the message via [`sqlx::Error`]'s own `Display`.
87    Sqlx(sqlx::Error),
88}
89
90impl std::fmt::Display for DynError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        match self {
93            Self::Write(e) => write!(f, "{e}"),
94            Self::Sqlx(e) => write!(f, "{e}"),
95        }
96    }
97}
98
99impl std::error::Error for DynError {
100    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
101        match self {
102            Self::Write(e) => Some(e),
103            Self::Sqlx(e) => Some(e),
104        }
105    }
106}
107
108impl From<sqlx::Error> for DynError {
109    fn from(e: sqlx::Error) -> Self {
110        Self::Sqlx(e)
111    }
112}
113
114impl From<WriteError> for DynError {
115    fn from(e: WriteError) -> Self {
116        Self::Write(e)
117    }
118}
119
120/// A runtime-typed, lazy SQL query against one `ModelMeta`.
121///
122/// Built by [`DynQuerySet::for_meta`]; chain `.search(...)` /
123/// `.filter_eq_string(...)` / `.order_by_col(...)` / `.limit(...)` /
124/// `.offset(...)` to refine; finish with `.count()` or
125/// `.fetch_as_strings()`.
126pub struct DynQuerySet<'a> {
127    meta: &'a ModelMeta,
128    /// Accumulated WHERE clauses, ANDed together at terminal time.
129    /// Stored as `Condition` (not pushed into a `SelectStatement`
130    /// directly) so `count()` and `fetch_as_strings()` can reuse the
131    /// same predicate set against different SELECT projections.
132    where_clauses: Vec<Condition>,
133    order: Vec<(String, bool)>,
134    limit: Option<u64>,
135    offset: Option<u64>,
136    select_cols: Vec<String>,
137    with_deleted: bool,
138    only_deleted: bool,
139    hard_delete: bool,
140    /// FK column names to expand via a batched `IN (...)` lookup
141    /// after the main query — same one-hop semantics as the typed
142    /// `QuerySet::select_related`. Each entry must be a single-hop
143    /// FK column on `meta` (validated when added). When non-empty,
144    /// `fetch_as_json` / `first_as_json` swap the FK integer values
145    /// in the response for the full related-row JSON object.
146    /// Drives the REST plugin's `?include=fk1,fk2` query param.
147    select_related: Vec<String>,
148    /// Names of `#[umbral(privileged)]` columns the caller has authorized to
149    /// write on the JSON path. Empty by default → every privileged column is
150    /// stripped from the insert/update body (default-DENY mass assignment,
151    /// audit_2 H3). The REST/admin layer fills this from the requester's
152    /// authorization (e.g. only a superuser may set `is_superuser`).
153    allow_privileged: Vec<String>,
154    /// `#[umbral(private)]` columns this READ has unlocked. Empty by default → every
155    /// private column is stripped. The read-path twin of `allow_privileged`.
156    allow_private: Vec<String>,
157    /// Bypass the read-side field policy entirely. Database dumps only; see
158    /// [`DynQuerySet::unredacted_for_backup`].
159    unredacted: bool,
160    /// The write-side twin of [`Self::unredacted`]: the incoming values for
161    /// `Masked` columns are ALREADY sealed ciphertext, so bind them verbatim
162    /// instead of sealing again. Database RESTORE only; see
163    /// [`DynQuerySet::presealed`]. gaps4 #2.
164    presealed: bool,
165    /// Skip the app-level create/update validation (required/choices/text-format
166    /// AND the FK-existence SELECT). For a trusted bulk copy from a CONSISTENT
167    /// source (the data-transfer engine): re-validation is redundant, and the
168    /// FK-existence check would reject a legitimate cyclic / not-yet-loaded
169    /// reference. DB constraints still apply. See [`DynQuerySet::trusted`].
170    trusted: bool,
171    /// Reveal ALL hidden columns in serialized output for a caller the app
172    /// has already authorized: include `private` / `secret` columns AND
173    /// decrypt `Masked<T>` to plaintext (not ciphertext). Trusted
174    /// server-side unlock; REST/GraphQL only set it behind a per-resource
175    /// permission gate. See [`DynQuerySet::revealed`].
176    revealed: bool,
177    /// Reveal only these named columns (the per-column form of `revealed`),
178    /// leaving every other hidden column stripped. See
179    /// [`DynQuerySet::reveal`].
180    reveal_cols: Vec<String>,
181}
182
183/// Turn a string that arrived from a URL, a form or an identity into a correctly-TYPED
184/// `serde_json::Value` for `col`.
185///
186/// The JSON twin of [`typed_eq_condition`], and it exists for the same reason. Callers
187/// that need to *write* a value they only have as a string — the parent id in a nested
188/// route, the owner id from the authenticated identity — were reaching for
189///
190/// ```ignore
191/// match s.parse::<i64>() { Ok(n) => Value::from(n), Err(_) => Value::String(s) }
192/// ```
193///
194/// which is a **guess about the column's type made from the shape of the value**. It has
195/// two failure modes, and the second is the nasty one:
196///
197/// - A `Uuid` or `String` pk falls to the string arm and happens to work, so the guess
198///   looks correct in testing.
199/// - A `String` pk whose value is *numeric* (`"12345"`, a Stripe id, an external
200///   reference) is written as a JSON **number** — the wrong type, silently, for the one
201///   row shape nobody thought to test.
202///
203/// Ask the column, not the value. Returns `None` when the string cannot be that column's
204/// type at all; the caller turns that into a 400 or a 404, never into a guess.
205pub fn typed_json_value(col: &Column, value: &str) -> Option<serde_json::Value> {
206    use serde_json::Value as J;
207    match crate::migrate::fk_effective_type(col) {
208        SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::ForeignKey => {
209            value.parse::<i64>().ok().map(J::from)
210        }
211        SqlType::Real | SqlType::Double => value.parse::<f64>().ok().map(J::from),
212        SqlType::Boolean => Some(J::Bool(matches!(value, "true" | "on" | "1"))),
213        // A UUID crosses JSON as its hyphenated string — but it is still VALIDATED here,
214        // so a non-UUID string cannot be written into a UUID column.
215        SqlType::Uuid => uuid::Uuid::parse_str(value)
216            .ok()
217            .map(|u| J::String(u.to_string())),
218        _ => Some(J::String(value.to_string())),
219    }
220}
221
222/// A predicate no row can satisfy (`1 = 0`).
223///
224/// The safe answer whenever a filter cannot be expressed. See gaps3 #56: the unsafe
225/// answer — adding no predicate — silently promotes "match this one row" into "match
226/// every row", which on a DELETE is the whole table.
227pub fn never_matches() -> Condition {
228    Condition::all().add(Expr::val(1).eq(0))
229}
230
231/// Build a typed `col = value` predicate from a **string** value, coercing it to the
232/// column's declared type.
233///
234/// Every value that arrives from a URL, a query string or a form is a string, and the
235/// column it is compared against usually is not. Binding the raw string against an
236/// `INTEGER` column is the quiet failure mode here: SQLite applies column affinity and
237/// silently makes it work, while Postgres has no `integer = text` operator and errors —
238/// so the bug ships green and detonates on the backend that matters. Coerce once, here,
239/// and let every caller share it.
240///
241/// Returns `None` for an unknown column, or a value that cannot be coerced (e.g. `"abc"`
242/// for a BigInt). A `None` obliges the caller to produce NO ROWS — either
243/// [`never_matches`] or a 404. Treating it as "no filter" is the gaps3 #56 bug: it turns
244/// a by-id lookup into a whole-table scan, and a by-id DELETE into a whole-table DELETE.
245pub fn typed_eq_condition(meta: &ModelMeta, col: &str, value: &str) -> Option<Condition> {
246    let meta_col = meta.fields.iter().find(|c| c.name == col)?;
247    let expr = Expr::col(Alias::new(col));
248    // FK-to-non-i64-target columns resolve to their target PK type, so a String/Uuid FK
249    // matches the `_` arm and binds the raw string.
250    let predicate = match crate::migrate::fk_effective_type(meta_col) {
251        SqlType::SmallInt | SqlType::Integer => value.parse::<i32>().ok().map(|v| expr.eq(v)),
252        SqlType::BigInt | SqlType::ForeignKey => value.parse::<i64>().ok().map(|v| expr.eq(v)),
253        SqlType::Real | SqlType::Double => value.parse::<f64>().ok().map(|v| expr.eq(v)),
254        SqlType::Boolean => {
255            let v = matches!(value, "true" | "on" | "1");
256            Some(expr.eq(v))
257        }
258        // UUIDs stored as BLOB in SQLite — parse the string into a typed Uuid so
259        // sea-query-binder emits a blob bind that matches the row.
260        SqlType::Uuid => uuid::Uuid::parse_str(value).ok().map(|u| expr.eq(u)),
261        _ => Some(expr.eq(value.to_string())),
262    };
263    predicate.map(|p| Condition::all().add(p))
264}
265
266/// Which way a [`typed_cmp_condition`] points.
267#[derive(Clone, Copy, Debug, PartialEq, Eq)]
268pub enum Cmp {
269    /// `col > value`
270    Gt,
271    /// `col < value`
272    Lt,
273}
274
275/// Build a typed `col > value` / `col < value` predicate from a **string** value.
276///
277/// The ordering sibling of [`typed_eq_condition`], and it exists for the same reason: the
278/// value arrives as a string (out of a URL, a form, or a **pagination cursor**) and the
279/// column it is compared against usually is not. Bind the raw string against an `INTEGER`
280/// column and SQLite silently coerces it while Postgres refuses — but worse for an
281/// inequality than for an equality, because a *string* comparison against a numeric column
282/// orders lexicographically: `"10" < "9"`. A keyset paginator built on that skips rows and
283/// repeats others, and it does it silently.
284///
285/// Returns `None` for an unknown column or a value that cannot be coerced. As with
286/// `typed_eq_condition`, a `None` obliges the caller to produce NO ROWS — never to drop the
287/// predicate (gaps3 #56).
288pub fn typed_cmp_condition(
289    meta: &ModelMeta,
290    col: &str,
291    value: &str,
292    cmp: Cmp,
293) -> Option<Condition> {
294    let meta_col = meta.fields.iter().find(|c| c.name == col)?;
295    let expr = Expr::col(Alias::new(col));
296    macro_rules! go {
297        ($v:expr) => {
298            match cmp {
299                Cmp::Gt => expr.gt($v),
300                Cmp::Lt => expr.lt($v),
301            }
302        };
303    }
304    let predicate = match crate::migrate::fk_effective_type(meta_col) {
305        SqlType::SmallInt | SqlType::Integer => value.parse::<i32>().ok().map(|v| go!(v)),
306        SqlType::BigInt | SqlType::ForeignKey => value.parse::<i64>().ok().map(|v| go!(v)),
307        SqlType::Real | SqlType::Double => value.parse::<f64>().ok().map(|v| go!(v)),
308        // Booleans and UUIDs have no useful ordering to paginate by: `WHERE is_active > true`
309        // is not a page boundary, it is a mistake. Refuse rather than emit nonsense.
310        SqlType::Boolean | SqlType::Uuid => None,
311        // Text, dates and timestamps all compare correctly as strings in both backends
312        // (ISO-8601 is lexicographically ordered — that is the whole reason to store it).
313        _ => Some(go!(value.to_string())),
314    };
315    predicate.map(|p| Condition::all().add(p))
316}
317
318impl<'a> DynQuerySet<'a> {
319    /// Start a `SELECT` against the model's table. The column list
320    /// defaults to every field in declaration order; restrict it with
321    /// `.select_cols(...)` before fetching when you only want a subset.
322    pub fn for_meta(meta: &'a ModelMeta) -> Self {
323        let select_cols = meta.fields.iter().map(|c| c.name.clone()).collect();
324        Self {
325            meta,
326            where_clauses: Vec::new(),
327            order: Vec::new(),
328            limit: None,
329            offset: None,
330            select_cols,
331            with_deleted: false,
332            only_deleted: false,
333            hard_delete: false,
334            select_related: Vec::new(),
335            allow_privileged: Vec::new(),
336            allow_private: Vec::new(),
337            unredacted: false,
338            presealed: false,
339            trusted: false,
340            revealed: false,
341            reveal_cols: Vec::new(),
342        }
343    }
344
345    /// Unlock specific `#[umbral(private)]` columns for THIS read.
346    ///
347    /// Private columns are stripped from every JSON read by default — they are not even
348    /// SELECTed, so the value never leaves the database. A caller that has established the
349    /// requester may see them opts them back in here:
350    ///
351    /// ```ignore
352    /// // staff endpoint: wholesale cost is legitimately visible
353    /// DynQuerySet::for_meta(&meta)
354    ///     .allow_private(&["cost"])
355    ///     .fetch_as_json().await?;
356    /// ```
357    ///
358    /// Per-field and at the call site ON PURPOSE. The verbosity IS the audit trail:
359    /// `grep -rn allow_private` yields a complete inventory of every place confidential data
360    /// is permitted to leave the process. A per-audience switch ("this whole surface is
361    /// trusted") would be one line, and adding a new `private` field later would silently
362    /// widen it.
363    ///
364    /// Cannot unlock a [`crate::orm::FieldSpec::secret`] column — that tier has no unlock,
365    /// and naming one here does nothing. Names not on the model are ignored. Accumulates.
366    pub fn allow_private(mut self, cols: &[&str]) -> Self {
367        self.allow_private
368            .extend(cols.iter().map(|c| (*c).to_string()));
369        self
370    }
371
372    /// Read EVERY column, including `secret` ones. For database dumps only.
373    ///
374    /// The `secret` tier has no unlock because a client should never receive those bytes.
375    /// A database dump is not a client: `dumpdata` must round-trip `password_hash` or a
376    /// restore locks every user out of their account, and it must round-trip `Masked<T>`
377    /// ciphertext or the encrypted columns come back empty.
378    ///
379    /// So this exists, and it is named the way it is on purpose — it should be jarring to
380    /// read in a request handler, and `grep -rn unredacted_for_backup` should return the
381    /// backup code and nothing else. If it ever shows up on a path that answers HTTP, that
382    /// is the bug.
383    pub fn unredacted_for_backup(mut self) -> Self {
384        self.unredacted = true;
385        self
386    }
387
388    /// The write-side twin of [`Self::unredacted_for_backup`], for RESTORE.
389    ///
390    /// A `dumpdata` writes each `Masked<T>` column's sealed ciphertext (via
391    /// `unredacted_for_backup`). A `loaddata` must store that ciphertext
392    /// VERBATIM — sealing it again produces `seal(ciphertext)`, and `reveal()`
393    /// then returns the inner ciphertext, not the plaintext: the secret is
394    /// silently unrecoverable. This flag tells the insert path the masked
395    /// values are already at-rest form. gaps4 #2.
396    ///
397    /// Named to be jarring in a request handler, exactly like its read twin:
398    /// `grep -rn presealed` should return the restore code and nothing else.
399    pub fn presealed(mut self) -> Self {
400        self.presealed = true;
401        self
402    }
403
404    /// Skip app-level create/update validation on the write. For a trusted bulk
405    /// copy from a CONSISTENT source (the data-transfer engine): the source
406    /// already satisfies every constraint, so re-validating is redundant, and
407    /// the FK-existence SELECT would wrongly reject a cyclic or not-yet-loaded
408    /// reference. Database constraints (NOT NULL, UNIQUE, FK unless deferred)
409    /// still apply, so this trusts the *source*, not the caller's payload.
410    pub fn trusted(mut self) -> Self {
411        self.trusted = true;
412        self
413    }
414
415    /// Whether a column may appear in serialized output from this queryset.
416    ///
417    /// The single place the read-side field policy is decided. Every JSON terminal routes
418    /// through it, so a new terminal inherits the policy instead of having to remember it —
419    /// which is precisely the mistake that let `umbral-graphql` serve password hashes.
420    pub(crate) fn may_serialize(&self, col: &Column) -> bool {
421        if self.unredacted || self.is_revealed(col) {
422            return true;
423        }
424        if crate::orm::secrets::is_secret_column(col) {
425            return false;
426        }
427        if col.private {
428            return self.allow_private.iter().any(|c| c == &col.name);
429        }
430        true
431    }
432
433    /// Whether this column is unlocked by a `revealed()` / `reveal([..])`
434    /// call — the authorized-audience switch that includes it in output and
435    /// decrypts `Masked<T>`.
436    pub(crate) fn is_revealed(&self, col: &Column) -> bool {
437        self.revealed || self.reveal_cols.iter().any(|c| c == &col.name)
438    }
439
440    /// Reveal hidden columns for a caller the application has ALREADY
441    /// authorized: include every `#[umbral(private)]` / `#[umbral(secret)]`
442    /// column in the output AND decrypt `Masked<T>` columns to plaintext
443    /// (instead of the stored ciphertext).
444    ///
445    /// This is the read-side "trusted audience" switch. Unlike
446    /// [`unredacted_for_backup`](Self::unredacted_for_backup) (which is named
447    /// to be jarring because it's for database dumps and emits Masked as
448    /// ciphertext), `revealed` is meant for a *request* surface — but ONLY
449    /// behind an authorization gate. It is unconditional here (trusted
450    /// server-side code); REST and GraphQL call it only when a resource opts
451    /// in AND the caller passes the configured permission, so a plaintext
452    /// secret never reaches an unauthorized client. `grep -rn revealed` is
453    /// the inventory of every reveal path.
454    pub fn revealed(mut self) -> Self {
455        self.revealed = true;
456        self
457    }
458
459    /// The per-column form of [`revealed`](Self::revealed): reveal only the
460    /// named columns (include them + decrypt `Masked<T>`), leaving every
461    /// other hidden column stripped. Accumulates; names not on the model are
462    /// ignored. Same authorization contract as `revealed` — callers gate it.
463    pub fn reveal(mut self, cols: &[&str]) -> Self {
464        self.reveal_cols
465            .extend(cols.iter().map(|c| (*c).to_string()));
466        self
467    }
468
469    /// Post-decode transform applied to one serialized column value: when
470    /// [`revealed`](Self::revealed) is set, a `Masked<T>` column's stored
471    /// ciphertext is decrypted to plaintext. A decrypt failure (no private
472    /// key, corrupt value) leaves the value untouched rather than erroring
473    /// the read. No-op for every non-masked column and when not revealing.
474    pub(crate) fn reveal_value(&self, col: &Column, v: serde_json::Value) -> serde_json::Value {
475        if !self.is_revealed(col) || !crate::orm::write::is_masked_col(col) {
476            return v;
477        }
478        match v.as_str() {
479            Some(ct) if !ct.is_empty() => match crate::orm::masked::ambient_open(ct) {
480                Ok(plain) => serde_json::Value::String(plain),
481                Err(_) => v,
482            },
483            _ => v,
484        }
485    }
486
487    /// `select_cols` minus everything this reader may not see. Not selecting the column at
488    /// all is strictly better than selecting and then dropping it: the confidential value
489    /// never crosses the database boundary, so it cannot be logged, cached, or leaked by a
490    /// panic on the way out.
491    pub(crate) fn visible_select_cols(&self) -> Vec<String> {
492        self.select_cols
493            .iter()
494            .filter(|name| {
495                self.meta
496                    .fields
497                    .iter()
498                    .find(|c| &c.name == *name)
499                    .is_none_or(|c| self.may_serialize(c))
500            })
501            .cloned()
502            .collect()
503    }
504
505    /// Authorize specific `#[umbral(privileged)]` columns for this write.
506    ///
507    /// By default the dynamic JSON write path (`insert_json`/`update_json`)
508    /// strips every privileged column from the body — the default-DENY guard
509    /// against mass-assigning `is_superuser`/`is_staff`/ownership FKs (audit_2
510    /// H3). A caller that has verified the requester is allowed to set those
511    /// fields (e.g. an admin acting as a superuser) opts them back in here:
512    ///
513    /// ```ignore
514    /// // superuser request: allow the privileged fields through
515    /// DynQuerySet::for_meta(&meta)
516    ///     .allow_privileged(&["is_superuser", "is_staff"])
517    ///     .insert_json(&body).await?;
518    /// ```
519    ///
520    /// Names not present on the model are ignored. Calling this repeatedly
521    /// accumulates the allowlist.
522    pub fn allow_privileged(mut self, cols: &[&str]) -> Self {
523        self.allow_privileged
524            .extend(cols.iter().map(|c| c.to_string()));
525        self
526    }
527
528    /// Include soft-deleted rows for models tagged with
529    /// `#[umbral(soft_delete)]`.
530    pub fn with_deleted(mut self) -> Self {
531        self.with_deleted = true;
532        self
533    }
534
535    /// Restrict a soft-delete model to only rows whose `deleted_at` is
536    /// populated.
537    pub fn only_deleted(mut self) -> Self {
538        self.only_deleted = true;
539        self
540    }
541
542    /// Force a real `DELETE` for a soft-delete model.
543    pub fn hard_delete(mut self) -> Self {
544        self.hard_delete = true;
545        self
546    }
547
548    /// The caller's predicates plus whatever the ORM adds implicitly.
549    ///
550    /// **The single clause-composition seam for the dynamic path**, and the twin
551    /// of `QuerySet::implicit_predicates` on the typed path. Everything that
552    /// builds a statement here goes through this (or one of the two wrappers
553    /// below) so admin and REST — which run *entirely* on `DynQuerySet` — cannot
554    /// end up with weaker filtering than the typed API. The row-level tenant
555    /// scope lands here (`docs/specs/row-level-tenancy.md`); scoping only the
556    /// typed QuerySet would be a silent, complete bypass of the admin surface.
557    ///
558    /// `trashed` selects which soft-delete guard to add:
559    /// `None` → follow the queryset's own `with_deleted`/`only_deleted` flags;
560    /// `Some(false)` → force "live rows only"; `Some(true)` → force "trashed only".
561    fn where_clauses_with(&self, trashed: Option<bool>) -> Vec<Condition> {
562        let mut clauses = self.where_clauses.clone();
563        if self.meta.soft_delete {
564            let want_trashed = match trashed {
565                Some(t) => Some(t),
566                None if self.only_deleted => Some(true),
567                None if !self.with_deleted => Some(false),
568                None => None,
569            };
570            match want_trashed {
571                Some(true) => clauses
572                    .push(Condition::all().add(Expr::col(Alias::new("deleted_at")).is_not_null())),
573                Some(false) => clauses
574                    .push(Condition::all().add(Expr::col(Alias::new("deleted_at")).is_null())),
575                None => {}
576            }
577        }
578        clauses
579    }
580
581    fn effective_where_clauses(&self) -> Vec<Condition> {
582        self.where_clauses_with(None)
583    }
584
585    fn live_where_clauses(&self) -> Vec<Condition> {
586        self.where_clauses_with(Some(false))
587    }
588
589    /// Only the trashed rows the caller selected — the restore path. Went
590    /// through a raw `self.where_clauses.clone()` before, the one site that
591    /// bypassed the seam above.
592    fn trashed_where_clauses(&self) -> Vec<Condition> {
593        self.where_clauses_with(Some(true))
594    }
595
596    /// Restrict the SELECT list to the supplied column names. Names
597    /// that don't exist on the model are silently dropped so a stale
598    /// `list_display` config can't crash a request.
599    pub fn select_cols(mut self, cols: &[String]) -> Self {
600        let valid: Vec<String> = cols
601            .iter()
602            .filter(|n| self.meta.fields.iter().any(|c| &c.name == *n))
603            .cloned()
604            .collect();
605        if !valid.is_empty() {
606            self.select_cols = valid;
607        }
608        self
609    }
610
611    /// Expand the named FK columns via a batched `IN (...)` lookup
612    /// after the main query — mirrors the typed
613    /// `QuerySet::select_related` shape (single-hop and `__`-chained
614    /// alike). After this call, every FK field along the chain in
615    /// the response JSON renders as the full related-row object
616    /// instead of the raw integer id. Query budget is
617    /// `1 + len(hops)` per chain regardless of how many parent rows
618    /// came back (no N+1) — gap2 #18.
619    ///
620    /// Names may use either `.` (URL-natural) or `__` (lookup
621    /// style) as the hop separator; both normalize to the
622    /// same canonical chain internally. Mixed separators in one
623    /// token (e.g. `author.profile__org`) are accepted too.
624    ///
625    /// Names that don't exist on the model OR aren't FK columns at
626    /// any hop are silently dropped — the REST plugin's `?include=`
627    /// handler does its own up-front validation with a 400 on
628    /// unknown names, so stale dynamic includes (e.g. an internal
629    /// call site that hardcoded a name that was later renamed) just
630    /// skip without crashing the request.
631    ///
632    /// ```ignore
633    /// DynQuerySet::for_meta(&meta)
634    ///     .select_related_dyn(&["user".into(), "author.profile".into()])
635    ///     .fetch_as_json().await
636    /// ```
637    pub fn select_related_dyn(mut self, fields: &[String]) -> Self {
638        for name in fields {
639            let canonical = normalize_sr_token(name);
640            if validate_sr_chain(self.meta, &canonical).is_none() {
641                continue;
642            }
643            if !self.select_related.iter().any(|n| n == &canonical) {
644                self.select_related.push(canonical);
645            }
646        }
647        self
648    }
649
650    /// Read-side accessor for the resolved select_related list.
651    /// Used by tests + the REST handler's debug-logging path.
652    #[doc(hidden)]
653    pub fn select_related_fields(&self) -> &[String] {
654        &self.select_related
655    }
656
657    /// Add `WHERE (<predicate1> OR <predicate2> OR ...)` for a free-text
658    /// term against the model's searchable columns. Per-column predicate
659    /// depends on the column's [`SqlType`]:
660    ///
661    /// | SqlType | Predicate |
662    /// |---|---|
663    /// | `Text` | `UPPER(col) LIKE '%TERM%'` — case-insensitive substring |
664    /// | `SmallInt` / `Integer` / `BigInt` / `ForeignKey` | `col = term` when `term.parse::<i64>().is_ok()` |
665    /// | `Real` / `Double` | `col = term` when `term.parse::<f64>().is_ok()` |
666    /// | `Boolean` | `col = term` when `term` parses as `true` / `false` |
667    /// | everything else (Date, Time, Uuid, Json, Bytes, Array, …) | skipped |
668    ///
669    /// `fields` controls which columns participate:
670    ///
671    /// - **Non-empty:** restricted to the named columns. Names that
672    ///   don't exist on the model are silently dropped.
673    /// - **Empty:** every column on the model is a candidate; the
674    ///   per-type table above decides which actually contribute. This
675    ///   is the "no `search_fields` configured" default behaviour.
676    ///
677    /// Empty `term` (after trimming) is always a no-op. If the column
678    /// selection results in zero predicates (e.g. `term = "abc"` and
679    /// the only candidate columns are numeric), nothing is appended.
680    pub fn search(mut self, fields: &[String], term: &str) -> Self {
681        let term = term.trim();
682        if term.is_empty() {
683            return self;
684        }
685
686        let restricted = !fields.is_empty();
687        let as_int = term.parse::<i64>().ok();
688        let as_float = term.parse::<f64>().ok();
689        let as_bool = match term.to_ascii_lowercase().as_str() {
690            "true" => Some(true),
691            "false" => Some(false),
692            _ => None,
693        };
694        // Escape LIKE wildcards in the user's term so `%`/`_` are matched
695        // literally, not as wildcards (ORM-1). Paired with `.escape('\\')`.
696        let like_pat = format!("%{}%", crate::orm::escape_like_literal(term)).to_uppercase();
697
698        let mut cond = Condition::any();
699        let mut added = 0;
700        for col in &self.meta.fields {
701            if restricted && !fields.iter().any(|f| f == &col.name) {
702                continue;
703            }
704            let predicate: Option<sea_query::SimpleExpr> = match col.ty {
705                SqlType::Text => Some(
706                    Expr::expr(Func::upper(Expr::col(Alias::new(&col.name))))
707                        .like(sea_query::LikeExpr::new(like_pat.clone()).escape('\\')),
708                ),
709                SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::ForeignKey => {
710                    as_int.map(|n| Expr::col(Alias::new(&col.name)).eq(n))
711                }
712                SqlType::Real | SqlType::Double => {
713                    as_float.map(|n| Expr::col(Alias::new(&col.name)).eq(n))
714                }
715                SqlType::Boolean => as_bool.map(|b| Expr::col(Alias::new(&col.name)).eq(b)),
716                _ => None,
717            };
718            if let Some(p) = predicate {
719                cond = cond.add(p);
720                added += 1;
721            }
722        }
723        if added > 0 {
724            self.where_clauses.push(cond);
725        }
726        self
727    }
728
729    /// Splice an externally-built `sea_query::Condition` into the
730    /// accumulated WHERE clauses. Used by callers that need lookups
731    /// the typed builder methods don't cover (e.g. umbral-rest's
732    /// query-string filter parser produces a `Condition` per
733    /// `field__lookup=value` triple and feeds it in here).
734    pub fn filter_condition(mut self, cond: sea_query::Condition) -> Self {
735        self.where_clauses.push(cond);
736        self
737    }
738
739    /// Add `WHERE <col> IN (?, ?, ...)` for an i64 column (PK / FK).
740    /// Empty `vals` is a no-op; unknown columns are silently dropped.
741    pub fn filter_in_i64(mut self, col: &str, vals: &[i64]) -> Self {
742        if vals.is_empty() || !self.meta.fields.iter().any(|c| c.name == col) {
743            return self;
744        }
745        let cond = Condition::all().add(Expr::col(Alias::new(col)).is_in(vals.iter().copied()));
746        self.where_clauses.push(cond);
747        self
748    }
749
750    /// Filter the parent set down to rows that have an M2M link to at
751    /// least one of `child_ids` through the named M2M field. Emits:
752    ///
753    /// ```sql
754    /// WHERE <pk> IN (
755    ///     SELECT parent_id FROM <parent_table>_<field_name>
756    ///     WHERE child_id IN (?, ?, ...)
757    /// )
758    /// ```
759    ///
760    /// The junction table name follows the framework's
761    /// `{parent_table}_{field_name}` convention (same as
762    /// `set_junction_dynamic` and the migration emitter use). Returns
763    /// `self` unchanged when:
764    ///   - `child_ids` is empty,
765    ///   - no M2M relation with that `field_name` exists on the model,
766    ///   - the parent model has no PK column,
767    ///   - every value in `child_ids` fails to parse as `i64`
768    ///     (M2M PKs are i64 at v1 across the framework).
769    ///
770    /// Use case: admin filter for "products with tag 1 OR tag 2 OR
771    /// tag 3" — call once with all three child ids; the IN subquery
772    /// is one round-trip regardless of selection count.
773    pub fn filter_m2m_contains_any(mut self, field_name: &str, child_ids: &[String]) -> Self {
774        if child_ids.is_empty() {
775            return self;
776        }
777        let Some(rel) = self
778            .meta
779            .m2m_relations
780            .iter()
781            .find(|r| r.field_name == field_name)
782        else {
783            return self;
784        };
785        let Some(pk_col) = self.meta.pk_column() else {
786            return self;
787        };
788        // PK lift Pass B: bind child ids per the M2M target's PK
789        // type, not always i64. Pre-fix, `permissions_permission`
790        // (whose PK is the `codename` String column) couldn't be
791        // filtered via this method because every string id parsed
792        // as `i64::Err` and got dropped. The junction table's
793        // `child_id` column type matches the target's PK type at
794        // DDL emission, so binding correctly here keeps SQLite +
795        // Postgres affinity happy.
796        // PK lift Pass E: cached lookup. Previously cloned the full
797        // model registry per `filter_m2m_contains_any` call.
798        let target_pk_ty = crate::migrate::pk_meta_for_table(&rel.target_table)
799            .map(|(_, ty)| ty)
800            .unwrap_or(SqlType::BigInt);
801        let junction_table = format!("{}_{}", self.meta.table, rel.field_name);
802        let child_id_expr = Expr::col(Alias::new("child_id"));
803        let in_clause: sea_query::SimpleExpr = match target_pk_ty {
804            SqlType::Text | SqlType::Uuid => {
805                // String / UUID PK: bind raw strings. Empty / all-
806                // whitespace tokens drop out (no realistic PK is
807                // blank); everything else goes in verbatim.
808                let bound: Vec<String> = child_ids
809                    .iter()
810                    .filter_map(|s| {
811                        let s = s.trim();
812                        if s.is_empty() {
813                            None
814                        } else {
815                            Some(s.to_string())
816                        }
817                    })
818                    .collect();
819                if bound.is_empty() {
820                    return self.fail_closed();
821                }
822                child_id_expr.is_in(bound)
823            }
824            _ => {
825                // Integer-PK target (default): parse to i64. Same
826                // behaviour as pre-fix; this arm matches the
827                // pre-existing semantics for every shipped model.
828                let parsed: Vec<i64> = child_ids.iter().filter_map(|s| s.parse().ok()).collect();
829                if parsed.is_empty() {
830                    return self.fail_closed();
831                }
832                child_id_expr.is_in(parsed)
833            }
834        };
835        let subq = Query::select()
836            .column(Alias::new("parent_id"))
837            .from(crate::db::router::schema_qualified_table(&junction_table))
838            .and_where(in_clause)
839            .to_owned();
840        let cond =
841            Condition::all().add(Expr::col(Alias::new(pk_col.name.clone())).in_subquery(subq));
842        self.where_clauses.push(cond);
843        self
844    }
845
846    /// Add `WHERE <col> IN (?, ?, ...)` for any column. Each value is
847    /// parsed against the column's [`SqlType`] (same coercion as
848    /// [`Self::filter_eq_string`]) so SQLite's affinity rules see the
849    /// right operand type. Values that fail to parse are dropped from
850    /// the IN list. Empty `vals` (or all-unparseable) is a no-op;
851    /// unknown columns are silently dropped.
852    ///
853    /// Single-value calls degenerate to `<col> = ?` via sea-query's
854    /// `is_in` lowering — callers can use this for both the "one
855    /// selection" and "multi-selection" filter paths and get the
856    /// natural SQL in each case.
857    /// Push a predicate no row satisfies and return — the fail-CLOSED answer.
858    ///
859    /// gaps4 #7: a multi-value filter whose values were SUPPLIED but all failed
860    /// coercion must match zero rows, not drop the predicate. Dropping it turns
861    /// "delete rows 999999" (all garbage ids) into "delete every row" — the
862    /// admin bulk-delete/restore/hard-delete paths run `.delete()` on exactly
863    /// this queryset. The single-value `filter_eq_string` already fails closed
864    /// (gaps3 #56); this is its multi-value twin.
865    fn fail_closed(mut self) -> Self {
866        self.where_clauses.push(never_matches());
867        self
868    }
869
870    pub fn filter_in_strings(mut self, col: &str, vals: &[String]) -> Self {
871        let Some(meta_col) = self.meta.fields.iter().find(|c| c.name == col) else {
872            return self;
873        };
874        if vals.is_empty() {
875            return self;
876        }
877        let expr = Expr::col(Alias::new(col));
878        // Coerce each string value to the column's native type so the
879        // bind kind matches and SQLite's STRICT mode (and Postgres's
880        // type system) accepts the parameter. `fk_effective_type` resolves
881        // a ForeignKey to its target's PK type, so an FK to a String/Uuid
882        // target binds the raw string (via the `_` arm) instead of being
883        // parsed as i64 and dropped.
884        let cond = match crate::migrate::fk_effective_type(meta_col) {
885            SqlType::SmallInt | SqlType::Integer => {
886                let parsed: Vec<i32> = vals.iter().filter_map(|s| s.parse().ok()).collect();
887                if parsed.is_empty() {
888                    return self.fail_closed();
889                }
890                Condition::all().add(expr.is_in(parsed))
891            }
892            SqlType::BigInt | SqlType::ForeignKey => {
893                let parsed: Vec<i64> = vals.iter().filter_map(|s| s.parse().ok()).collect();
894                if parsed.is_empty() {
895                    return self.fail_closed();
896                }
897                Condition::all().add(expr.is_in(parsed))
898            }
899            SqlType::Real | SqlType::Double => {
900                let parsed: Vec<f64> = vals.iter().filter_map(|s| s.parse().ok()).collect();
901                if parsed.is_empty() {
902                    return self.fail_closed();
903                }
904                Condition::all().add(expr.is_in(parsed))
905            }
906            SqlType::Boolean => {
907                let parsed: Vec<bool> = vals
908                    .iter()
909                    .map(|s| matches!(s.as_str(), "true" | "on" | "1"))
910                    .collect();
911                Condition::all().add(expr.is_in(parsed))
912            }
913            // UUIDs are stored as BLOB in SQLite (sqlx Encode<Sqlite> for Uuid
914            // uses .as_bytes()). Binding them as strings would miss every row.
915            // Parse each submitted string into a Uuid and pass the typed vec so
916            // sea-query-binder emits blob binds that match the stored values.
917            SqlType::Uuid => {
918                let parsed: Vec<uuid::Uuid> = vals
919                    .iter()
920                    .filter_map(|s| uuid::Uuid::parse_str(s).ok())
921                    .collect();
922                if parsed.is_empty() {
923                    return self.fail_closed();
924                }
925                Condition::all().add(expr.is_in(parsed))
926            }
927            _ => Condition::all().add(expr.is_in(vals.iter().map(|s| s.to_string()))),
928        };
929        self.where_clauses.push(cond);
930        self
931    }
932
933    /// Add `WHERE <col> = <value>` where the value is parsed against
934    /// the column's `SqlType` so SQLite's affinity rules see the right
935    /// operand type.
936    pub fn filter_eq_string(mut self, col: &str, value: &str) -> Self {
937        // gaps3 #56 — FAIL CLOSED. When the value cannot be coerced to the column's type
938        // (`"abc"` against a BigInt pk) or the column does not exist, this adds a
939        // predicate that matches NOTHING.
940        //
941        // It used to add nothing at all, which is not the same thing and is not even
942        // close: a dropped predicate does not narrow the query, it WIDENS it to the whole
943        // table. The commonest caller is a by-primary-key lookup, so
944        //
945        //     DynQuerySet::for_meta(&model).filter_eq_string("id", "abc").delete()
946        //
947        // lowered to `DELETE FROM widget` with no WHERE — reachable from a plain
948        // `DELETE /api/widget/abc` against any model with an integer pk. Reads returned
949        // an arbitrary row instead of none; updates rewrote every row.
950        //
951        // A filter that cannot be honoured means NO ROWS. Never all of them.
952        match typed_eq_condition(self.meta, col, value) {
953            Some(cond) => self.where_clauses.push(cond),
954            None => self.where_clauses.push(never_matches()),
955        }
956        self
957    }
958
959    /// Add `ORDER BY <col> ASC|DESC`. Unknown columns are silently
960    /// dropped. Multiple calls append (sea-query semantics).
961    pub fn order_by_col(mut self, col: &str, descending: bool) -> Self {
962        if self.meta.fields.iter().any(|c| c.name == col) {
963            self.order.push((col.to_string(), descending));
964        }
965        self
966    }
967
968    /// Set `LIMIT`.
969    pub fn limit(mut self, n: u64) -> Self {
970        self.limit = Some(n);
971        self
972    }
973
974    /// Set `OFFSET`.
975    pub fn offset(mut self, n: u64) -> Self {
976        self.offset = Some(n);
977        self
978    }
979
980    /// Terminal: `SELECT COUNT(*)` with the accumulated WHERE
981    /// clauses. ORDER BY / LIMIT / OFFSET are dropped (irrelevant
982    /// to a count).
983    /// Terminal: does *any* row match the accumulated WHERE?
984    ///
985    /// `SELECT 1 ... LIMIT 1` — it stops at the first hit rather than counting every
986    /// match. The typed `QuerySet` has had this since M1; the dynamic path (which is
987    /// what the admin and REST run on) did not, so callers reached for `count() > 0`
988    /// and paid for a full aggregate to answer a yes/no question. The commonest caller
989    /// is "does the parent row exist" on a nested route, which runs on every single
990    /// request to that endpoint.
991    pub async fn exists(self) -> Result<bool, DynError> {
992        let mut q = Query::select();
993        q.from(crate::db::router::schema_qualified_table(&self.meta.table));
994        q.expr(Expr::val(1));
995        let where_clauses = self.effective_where_clauses();
996        for cond in &where_clauses {
997            q.cond_where(cond.clone());
998        }
999        q.limit(1);
1000
1001        match resolve_pool_dyn(self.meta, crate::db::RouteOp::Read) {
1002            DbPool::Sqlite(pool) => {
1003                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1004                let row = sqlx::query_with(&sql, values).fetch_optional(&pool).await?;
1005                Ok(row.is_some())
1006            }
1007            DbPool::Postgres(pool) => {
1008                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1009                let row = sqlx::query_with(&sql, values).fetch_optional(&pool).await?;
1010                Ok(row.is_some())
1011            }
1012        }
1013    }
1014
1015    pub async fn count(self) -> Result<i64, DynError> {
1016        let mut q = Query::select();
1017        q.from(crate::db::router::schema_qualified_table(&self.meta.table));
1018        q.expr(Func::count(Expr::col(Asterisk)));
1019        let where_clauses = self.effective_where_clauses();
1020        for cond in &where_clauses {
1021            q.cond_where(cond.clone());
1022        }
1023
1024        match resolve_pool_dyn(self.meta, crate::db::RouteOp::Read) {
1025            DbPool::Sqlite(pool) => {
1026                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1027                let row = sqlx::query_with(&sql, values).fetch_one(&pool).await?;
1028                Ok(row.try_get::<i64, _>(0)?)
1029            }
1030            DbPool::Postgres(pool) => {
1031                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1032                let row = sqlx::query_with(&sql, values).fetch_one(&pool).await?;
1033                Ok(row.try_get::<i64, _>(0)?)
1034            }
1035        }
1036    }
1037
1038    /// Terminal: `SELECT DISTINCT <col>` with the accumulated WHERE.
1039    /// Returns each value as a string (via [`decode_to_string`]). LIMIT
1040    /// is honoured; ORDER BY isn't (DISTINCT ordering is whatever the
1041    /// underlying scan yields). Unknown column → empty result.
1042    pub async fn fetch_distinct_strings(self, col: &str) -> Result<Vec<String>, DynError> {
1043        let Some(col_meta) = self.meta.fields.iter().find(|c| c.name == col) else {
1044            return Ok(Vec::new());
1045        };
1046        let mut q = Query::select();
1047        q.distinct();
1048        q.from(crate::db::router::schema_qualified_table(&self.meta.table));
1049        q.column(Alias::new(col));
1050        let where_clauses = self.effective_where_clauses();
1051        for cond in &where_clauses {
1052            q.cond_where(cond.clone());
1053        }
1054        if let Some(n) = self.limit {
1055            q.limit(n);
1056        }
1057
1058        match resolve_pool_dyn(self.meta, crate::db::RouteOp::Read) {
1059            DbPool::Sqlite(pool) => {
1060                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1061                let rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
1062                let mut out = Vec::with_capacity(rows.len());
1063                for row in rows {
1064                    out.push(decode_to_string(&row, col_meta)?);
1065                }
1066                Ok(out)
1067            }
1068            DbPool::Postgres(pool) => {
1069                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1070                let rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
1071                let mut out = Vec::with_capacity(rows.len());
1072                for row in rows {
1073                    out.push(decode_pg_to_string(&row, col_meta)?);
1074                }
1075                Ok(out)
1076            }
1077        }
1078    }
1079
1080    /// Terminal: `DELETE FROM <table>` with the accumulated WHERE.
1081    /// Returns the number of rows affected.
1082    ///
1083    /// gaps #77: pre-collects the affected PKs (one extra SELECT per
1084    /// call) before the DELETE so `bulk_post_delete:<table>` can fire
1085    /// with the actual row ids. Subscribers that need to invalidate
1086    /// caches / write audit-log rows / sync a search index get the
1087    /// list of PKs that just left the table, not just a row count.
1088    /// features #73 — refuse a write against a model backed by a database VIEW.
1089    ///
1090    /// This is the DYNAMIC path, which is what the admin and REST actually run on.
1091    /// Guarding only the typed path would leave the framework's own surfaces able to
1092    /// POST to a view and get back a driver-level error that names neither the model
1093    /// nor the reason — a hole in exactly the place a user is most likely to find it.
1094    fn ensure_writable(&self) -> Result<(), crate::orm::write::WriteError> {
1095        if self.meta.view.is_some() {
1096            return Err(crate::orm::write::WriteError::ReadOnlyView {
1097                table: self.meta.table.clone(),
1098            });
1099        }
1100        Ok(())
1101    }
1102
1103    pub async fn delete(self) -> Result<u64, DynError> {
1104        self.ensure_writable()?;
1105        if self.meta.soft_delete && !self.hard_delete {
1106            return self.soft_delete_update().await;
1107        }
1108        let where_clauses = self.effective_where_clauses();
1109        // gaps3 #54: once the DELETE runs the row is gone — the pre-image IS the
1110        // record. Snapshot before, not after.
1111        let audit_before = if self.meta.audited {
1112            audit_snapshot(self.meta, &where_clauses).await
1113        } else {
1114            Vec::new()
1115        };
1116        // Pre-collect the affected PKs only when the model has a PK
1117        // column (every Model does in practice; the guard handles
1118        // the hypothetical PK-less ModelMeta).
1119        let parent_pks: Vec<serde_json::Value> = match self.meta.pk_column() {
1120            Some(pk_col) => collect_parent_pks(self.meta, pk_col, &where_clauses)
1121                .await
1122                .unwrap_or_default(),
1123            None => Vec::new(),
1124        };
1125
1126        let mut q = Query::delete();
1127        q.from_table(crate::db::router::schema_qualified_table(&self.meta.table));
1128        for cond in &where_clauses {
1129            q.cond_where(cond.clone());
1130        }
1131
1132        let rows_affected = match resolve_pool_dyn(self.meta, crate::db::RouteOp::Write) {
1133            DbPool::Sqlite(pool) => {
1134                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1135                let res = sqlx::query_with(&sql, values).execute(&pool).await?;
1136                res.rows_affected()
1137            }
1138            DbPool::Postgres(pool) => {
1139                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1140                let res = sqlx::query_with(&sql, values).execute(&pool).await?;
1141                res.rows_affected()
1142            }
1143        };
1144
1145        // gaps #77: emit `bulk_post_delete:<table>` with the PKs we
1146        // captured pre-DELETE. Fires even when zero rows matched —
1147        // matches the typed bulk-delete convention (subscribers that
1148        // want to skip empty events filter in their handler).
1149        crate::signals::emit_bulk_post_delete_by_table(&self.meta.table, parent_pks).await;
1150        if self.meta.audited {
1151            let pairs = audit_pairs(self.meta, audit_before, Vec::new());
1152            crate::orm::audit::record_many(self.meta, crate::orm::audit::DELETE, pairs).await;
1153        }
1154        Ok(rows_affected)
1155    }
1156
1157    async fn soft_delete_update(self) -> Result<u64, DynError> {
1158        let where_clauses = self.live_where_clauses();
1159        // gaps3 #54: a soft delete is an UPDATE on the wire, but it is a DELETE to
1160        // anyone reading the log. Record it as one, or the audit trail will not
1161        // say what a human means by "deleted".
1162        let audit_before = if self.meta.audited {
1163            audit_snapshot(self.meta, &where_clauses).await
1164        } else {
1165            Vec::new()
1166        };
1167        let parent_pks: Vec<serde_json::Value> = match self.meta.pk_column() {
1168            Some(pk_col) => collect_parent_pks(self.meta, pk_col, &where_clauses)
1169                .await
1170                .unwrap_or_default(),
1171            None => Vec::new(),
1172        };
1173
1174        // One instant for the whole cascade: parent, children, grandchildren all
1175        // get this exact `deleted_at`, which is what lets `restore()` later undo
1176        // precisely this cascade and leave independently-trashed rows alone.
1177        let at = chrono::Utc::now();
1178        let meta = self.meta.clone();
1179        let clauses = where_clauses.clone();
1180
1181        // Parent + cascade in ONE transaction — a half-applied cascade would be
1182        // its own corruption bug (gaps3 #53).
1183        let rows_affected = crate::db::transaction(move |tx| {
1184            Box::pin(async move {
1185                // Cascade FIRST: the child selector reads the parent's live-rows
1186                // predicate, which stops matching the moment the parent is stamped.
1187                if let Some(pk) = meta.pk_column() {
1188                    let mut sel = Query::select();
1189                    sel.column(Alias::new(&pk.name))
1190                        .from(crate::db::router::schema_qualified_table(&meta.table));
1191                    for cond in &clauses {
1192                        sel.cond_where(cond.clone());
1193                    }
1194                    let mut c = crate::orm::soft_delete_cascade::CascadeConn::from_tx(tx);
1195                    crate::orm::soft_delete_cascade::cascade_soft_delete(&mut c, &meta, sel, at)
1196                        .await?;
1197                }
1198
1199                let mut q = Query::update();
1200                q.table(crate::db::router::schema_qualified_table(&meta.table));
1201                q.value(
1202                    Alias::new("deleted_at"),
1203                    sea_query::Value::ChronoDateTimeUtc(Some(Box::new(at))),
1204                );
1205                for cond in &clauses {
1206                    q.cond_where(cond.clone());
1207                }
1208                let affected = match tx.backend_name() {
1209                    "sqlite" => {
1210                        let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1211                        let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
1212                        sqlx::query_with(&sql, values)
1213                            .execute(&mut **inner)
1214                            .await?
1215                            .rows_affected()
1216                    }
1217                    _ => {
1218                        let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1219                        let inner = tx.as_pg_mut().expect("postgres backend_name");
1220                        sqlx::query_with(&sql, values)
1221                            .execute(&mut **inner)
1222                            .await?
1223                            .rows_affected()
1224                    }
1225                };
1226                Ok::<u64, sqlx::Error>(affected)
1227            })
1228        })
1229        .await?;
1230
1231        crate::signals::emit_bulk_post_delete_by_table(&self.meta.table, parent_pks).await;
1232        if self.meta.audited {
1233            let pairs = audit_pairs(self.meta, audit_before, Vec::new());
1234            crate::orm::audit::record_many(self.meta, crate::orm::audit::DELETE, pairs).await;
1235        }
1236        Ok(rows_affected)
1237    }
1238
1239    /// Terminal: undo a soft-delete — `UPDATE <table> SET deleted_at =
1240    /// NULL` for the rows matching the accumulated WHERE that are
1241    /// currently soft-deleted (`deleted_at IS NOT NULL`). Returns the
1242    /// number of rows restored. A no-op (0 rows) on a model that isn't
1243    /// tagged `soft_delete`, since there is no `deleted_at` column to
1244    /// clear — the caller should gate on `meta.soft_delete` first.
1245    ///
1246    /// This is the inverse of [`Self::delete`] on a soft-delete model:
1247    /// `delete()` stamps `deleted_at = now()`, `restore()` clears it.
1248    /// The admin's "Restore selected" trash action drives this.
1249    pub async fn restore(self) -> Result<u64, DynError> {
1250        if !self.meta.soft_delete {
1251            return Ok(0);
1252        }
1253        // Restrict to the rows the caller selected AND that are
1254        // actually trashed — restoring a live row is a no-op but
1255        // narrowing here keeps the affected-count honest.
1256        let where_clauses = self.trashed_where_clauses();
1257
1258        let parent_pks: Vec<serde_json::Value> = match self.meta.pk_column() {
1259            Some(pk_col) => collect_parent_pks(self.meta, pk_col, &where_clauses)
1260                .await
1261                .unwrap_or_default(),
1262            None => Vec::new(),
1263        };
1264
1265        let meta = self.meta.clone();
1266        let clauses = where_clauses.clone();
1267
1268        let rows_affected = crate::db::transaction(move |tx| {
1269            Box::pin(async move {
1270                // Undo each cascade against ITS OWN deletion instant. Restoring
1271                // three parents trashed on three different days undoes three
1272                // distinct cascades — and a child trashed independently carries a
1273                // different timestamp, so it is left trashed, as the user meant.
1274                // Runs BEFORE the parent is cleared: descendants are located
1275                // through the parent's still-present `deleted_at` (gaps3 #53).
1276                let ats = {
1277                    let mut c = crate::orm::soft_delete_cascade::CascadeConn::from_tx(tx);
1278                    crate::orm::soft_delete_cascade::deleted_at_values(&mut c, &meta, &clauses)
1279                        .await?
1280                };
1281                for at in ats {
1282                    if let Some(sel) =
1283                        crate::orm::soft_delete_cascade::selector_at(&meta, &clauses, at)
1284                    {
1285                        let mut c = crate::orm::soft_delete_cascade::CascadeConn::from_tx(tx);
1286                        crate::orm::soft_delete_cascade::cascade_restore(&mut c, &meta, sel, at)
1287                            .await?;
1288                    }
1289                }
1290
1291                let mut q = Query::update();
1292                q.table(crate::db::router::schema_qualified_table(&meta.table));
1293                q.value(
1294                    Alias::new("deleted_at"),
1295                    sea_query::Value::ChronoDateTimeUtc(None),
1296                );
1297                for cond in &clauses {
1298                    q.cond_where(cond.clone());
1299                }
1300                let affected = match tx.backend_name() {
1301                    "sqlite" => {
1302                        let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1303                        let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
1304                        sqlx::query_with(&sql, values)
1305                            .execute(&mut **inner)
1306                            .await?
1307                            .rows_affected()
1308                    }
1309                    _ => {
1310                        let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1311                        let inner = tx.as_pg_mut().expect("postgres backend_name");
1312                        sqlx::query_with(&sql, values)
1313                            .execute(&mut **inner)
1314                            .await?
1315                            .rows_affected()
1316                    }
1317                };
1318                Ok::<u64, sqlx::Error>(affected)
1319            })
1320        })
1321        .await?;
1322
1323        // Restoring a row is a "save" from the data model's POV — the
1324        // row re-enters the live set — so emit the bulk-post-save
1325        // signal, mirroring how soft-delete emits bulk-post-delete.
1326        crate::signals::emit_bulk_post_save_by_table(&self.meta.table, parent_pks, false).await;
1327        Ok(rows_affected)
1328    }
1329
1330    /// Terminal: `UPDATE <table> SET <col> = <value>` with the
1331    /// accumulated WHERE. The value is parsed against the column's
1332    /// `SqlType` so SQLite affinity sees the right operand. Returns
1333    /// the number of rows affected. Unknown column → 0 rows.
1334    pub async fn update_one(self, col: &str, value: &str) -> Result<u64, DynError> {
1335        self.ensure_writable()?;
1336        let Some(col_meta) = self.meta.fields.iter().find(|c| c.name == col) else {
1337            return Ok(0);
1338        };
1339        // gaps4 #1: default-deny the write-safety flags every OTHER dynamic write
1340        // terminal enforces. `update_one` was the only one that skipped them, and
1341        // the admin inline-cell-edit endpoint drives it — so a staff user could
1342        // POST `is_superuser=true` to their own row and escalate. A privileged
1343        // column needs `.allow_privileged(...)`; a `noform`/`noedit` column is not
1344        // writable from an untrusted single-field request at all. Refuse loudly
1345        // (not a silent no-op) so the caller sees a 4xx rather than a false "saved".
1346        if is_unauthorized_privileged(col_meta, &self.allow_privileged) {
1347            return Err(DynError::Write(WriteError::Validator {
1348                field: col_meta.name.clone(),
1349                message: "privileged column: not writable without explicit authorization"
1350                    .to_string(),
1351            }));
1352        }
1353        if col_meta.noform || col_meta.noedit {
1354            return Err(DynError::Write(WriteError::Validator {
1355                field: col_meta.name.clone(),
1356                message: "column is not editable (noform/noedit)".to_string(),
1357            }));
1358        }
1359        let sea_value = match form_str_to_sea_value(col_meta, value) {
1360            Ok(v) => v,
1361            // gaps2 #12: per-field validator failure (see `update_form`).
1362            Err(e) => {
1363                return Err(DynError::Write(WriteError::Validator {
1364                    field: col_meta.name.clone(),
1365                    message: e.to_string(),
1366                }));
1367            }
1368        };
1369
1370        let mut q = Query::update();
1371        q.table(crate::db::router::schema_qualified_table(&self.meta.table));
1372        q.value(Alias::new(col), sea_value);
1373        let where_clauses = self.effective_where_clauses();
1374        for cond in &where_clauses {
1375            q.cond_where(cond.clone());
1376        }
1377
1378        match resolve_pool_dyn(self.meta, crate::db::RouteOp::Write) {
1379            DbPool::Sqlite(pool) => {
1380                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1381                let res = sqlx::query_with(&sql, values).execute(&pool).await?;
1382                Ok(res.rows_affected())
1383            }
1384            DbPool::Postgres(pool) => {
1385                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1386                let res = sqlx::query_with(&sql, values).execute(&pool).await?;
1387                Ok(res.rows_affected())
1388            }
1389        }
1390    }
1391
1392    /// Terminal: `UPDATE <table> SET <col1> = ?, <col2> = ?, ...` with
1393    /// the accumulated WHERE. Each form value is parsed against its
1394    /// column's `SqlType`. The primary key column is silently dropped
1395    /// from the form (it's the filter, not a target). `skip` lists
1396    /// columns the caller wants excluded (e.g. readonly fields the
1397    /// admin already enforced). Returns rows affected.
1398    pub async fn update_form(
1399        self,
1400        form: &HashMap<String, String>,
1401        skip: &[String],
1402    ) -> Result<u64, DynError> {
1403        self.ensure_writable()?;
1404        let Some(q) = self.build_update_form_query(form, skip)? else {
1405            return Ok(0);
1406        };
1407
1408        match resolve_pool_dyn(self.meta, crate::db::RouteOp::Write) {
1409            DbPool::Sqlite(pool) => {
1410                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1411                let res = sqlx::query_with(&sql, values).execute(&pool).await?;
1412                Ok(res.rows_affected())
1413            }
1414            DbPool::Postgres(pool) => {
1415                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1416                let res = sqlx::query_with(&sql, values).execute(&pool).await?;
1417                Ok(res.rows_affected())
1418            }
1419        }
1420    }
1421
1422    /// Build the `UPDATE` statement (SET clauses + accumulated WHERE)
1423    /// for [`Self::update_form`] / [`Self::update_form_in_tx`]. Returns
1424    /// `None` when no column would be written (the callers translate
1425    /// that into a `0` return). Holds all per-field validation —
1426    /// PK/skip exclusion, `auto_now` refresh, and the structured
1427    /// [`WriteError::Validator`] — so the pool and transaction paths
1428    /// build provably the same statement.
1429    fn build_update_form_query(
1430        &self,
1431        form: &HashMap<String, String>,
1432        skip: &[String],
1433    ) -> Result<Option<sea_query::UpdateStatement>, DynError> {
1434        let mut q = Query::update();
1435        q.table(crate::db::router::schema_qualified_table(&self.meta.table));
1436        let mut any = false;
1437        for col in &self.meta.fields {
1438            if col.primary_key || skip.iter().any(|s| s == &col.name) {
1439                continue;
1440            }
1441            // audit_2 H3: default-deny a `#[umbral(privileged)]` column on the
1442            // untrusted form update path unless explicitly authorized (mirrors
1443            // the JSON path + the insert form path).
1444            if is_unauthorized_privileged(col, &self.allow_privileged) {
1445                continue;
1446            }
1447            // `auto_now` columns refresh on every update — push
1448            // `Utc::now()` regardless of whether the form carried
1449            // the column. `auto_now_add` stays frozen on update
1450            // (fired once at INSERT time); it falls through to the
1451            // standard "form omitted → skip" path below. Mirrors
1452            // `update_json` (line ~1047) so form + JSON write paths
1453            // honor the annotation identically.
1454            // gaps3 #55: the author is SERVER-owned. Stamped unconditionally,
1455            // before the body is even consulted, so a client cannot forge it by
1456            // putting someone else's id in the payload. `auto_user_add` stays
1457            // frozen on update (it fired on create); `auto_user` refreshes.
1458            if col.auto_user {
1459                q.value(
1460                    Alias::new(&col.name),
1461                    crate::orm::write::user_for_column(col.ty),
1462                );
1463                any = true;
1464                continue;
1465            }
1466            if col.auto_now {
1467                q.value(
1468                    Alias::new(&col.name),
1469                    crate::orm::write::now_for_column(col.ty),
1470                );
1471                any = true;
1472                continue;
1473            }
1474            let Some(raw) = form.get(&col.name) else {
1475                continue;
1476            };
1477            // gaps4 #5: a Masked column submitted EMPTY or as the redaction
1478            // marker means "leave the stored secret unchanged" — never re-seal.
1479            // The admin edit form renders a Masked field as an editable (blank)
1480            // input, so every save used to carry `field=""`, which sealed the
1481            // empty string and crypto-shredded the real ciphertext. This honors
1482            // the same no-change contract `Masked`'s own `Deserialize` does,
1483            // but on the dynamic form path that never consulted it.
1484            if crate::orm::write::is_masked_col(col)
1485                && (raw.trim().is_empty() || raw == crate::orm::masked::REDACTED)
1486            {
1487                continue;
1488            }
1489            let sea_value = match form_str_to_sea_value(col, raw) {
1490                Ok(v) => v,
1491                // gaps2 #12: emit a structured per-field validator
1492                // failure so the admin / Form<T> consumer can render
1493                // it under the offending input. The pre-fix path
1494                // flattened to `sqlx::Error::Protocol(...)` and the
1495                // per-field hint was lost.
1496                Err(e) => {
1497                    return Err(DynError::Write(WriteError::Validator {
1498                        field: col.name.clone(),
1499                        message: e.to_string(),
1500                    }));
1501                }
1502            };
1503            q.value(Alias::new(&col.name), sea_value);
1504            any = true;
1505        }
1506        if !any {
1507            return Ok(None);
1508        }
1509        let where_clauses = self.effective_where_clauses();
1510        for cond in &where_clauses {
1511            q.cond_where(cond.clone());
1512        }
1513        Ok(Some(q))
1514    }
1515
1516    /// Transaction-aware sibling of [`Self::update_form`]. Builds and
1517    /// executes the identical `UPDATE` (same per-field validation,
1518    /// `skip` / PK exclusion, `auto_now` refresh, [`WriteError::Validator`]
1519    /// shape, and accumulated WHERE) but runs it on the caller-supplied
1520    /// `tx`. The caller owns `commit` / `rollback`, so the update is
1521    /// uncommitted until they say so — used by the admin to save a
1522    /// parent edit and its inline child changes atomically.
1523    pub async fn update_form_in_tx(
1524        self,
1525        tx: &mut crate::db::Transaction,
1526        form: &HashMap<String, String>,
1527        skip: &[String],
1528    ) -> Result<u64, DynError> {
1529        self.ensure_writable()?;
1530        let Some(q) = self.build_update_form_query(form, skip)? else {
1531            return Ok(0);
1532        };
1533
1534        match tx.backend_name() {
1535            "sqlite" => {
1536                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1537                let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
1538                let res = sqlx::query_with(&sql, values).execute(&mut **inner).await?;
1539                Ok(res.rows_affected())
1540            }
1541            _ => {
1542                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1543                let inner = tx.as_pg_mut().expect("postgres backend_name");
1544                let res = sqlx::query_with(&sql, values).execute(&mut **inner).await?;
1545                Ok(res.rows_affected())
1546            }
1547        }
1548    }
1549
1550    /// Terminal: `INSERT INTO <table> (...) VALUES (...)` from a form
1551    /// map. Auto-increment integer PKs are omitted when the form value
1552    /// is missing or empty (SQLite hands out the next id). Form keys
1553    /// that don't match a column are ignored. `skip` lets the caller
1554    /// drop fields the admin pre-filtered. Returns the new row's primary key in
1555    /// its true shape (see [`InsertedPk`]) — an integer PK comes back as
1556    /// [`InsertedPk::Int`], a String/Uuid PK as [`InsertedPk::Text`] (no more
1557    /// silent `0`), and an empty insert as [`InsertedPk::None`].
1558    pub async fn insert_form(
1559        self,
1560        form: &HashMap<String, String>,
1561        skip: &[String],
1562    ) -> Result<InsertedPk, DynError> {
1563        self.ensure_writable()?;
1564        let Some(mut q) = self.build_insert_form_query(form, skip)? else {
1565            return Ok(InsertedPk::None);
1566        };
1567        let pk_col = self.meta.fields.iter().find(|c| c.primary_key);
1568
1569        match resolve_pool_dyn(self.meta, crate::db::RouteOp::Write) {
1570            DbPool::Sqlite(pool) => {
1571                // Integer PK: `last_insert_rowid` IS the PK, no RETURNING needed.
1572                // Non-integer PK (String/Uuid): the rowid is NOT the key, so ask
1573                // the row to hand its real PK back and decode it in true shape.
1574                match pk_col {
1575                    Some(pk) if !pk_is_integer(pk) => {
1576                        q.returning_col(Alias::new(&pk.name));
1577                        let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
1578                        let row = sqlx::query_with(&sql, vals).fetch_one(&pool).await?;
1579                        Ok(InsertedPk::Text(decode_to_string(&row, pk)?))
1580                    }
1581                    _ => {
1582                        let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
1583                        let res = sqlx::query_with(&sql, vals).execute(&pool).await?;
1584                        Ok(InsertedPk::Int(res.last_insert_rowid()))
1585                    }
1586                }
1587            }
1588            DbPool::Postgres(pool) => {
1589                // Postgres has no last_insert_rowid; RETURNING the PK is the only
1590                // way, and it also lets a non-integer PK come back in true shape.
1591                match pk_col {
1592                    Some(pk) => {
1593                        q.returning_col(Alias::new(&pk.name));
1594                        let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
1595                        let row = sqlx::query_with(&sql, vals).fetch_one(&pool).await?;
1596                        Ok(pg_inserted_pk(&row, pk)?)
1597                    }
1598                    None => {
1599                        let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
1600                        let _ = sqlx::query_with(&sql, vals).execute(&pool).await?;
1601                        Ok(InsertedPk::None)
1602                    }
1603                }
1604            }
1605        }
1606    }
1607
1608    /// Build the `INSERT` statement for [`Self::insert_form`] /
1609    /// [`Self::insert_form_in_tx`]. Returns `None` when no column
1610    /// survives the `skip` / auto-increment-PK filtering (the callers
1611    /// translate that into a `0` return). All per-field validation —
1612    /// auto-now/auto-now-add stamping, the auto-increment PK omission,
1613    /// and the structured [`WriteError::Validator`] on a bad value —
1614    /// lives here so the pool and transaction paths build provably the
1615    /// same statement.
1616    fn build_insert_form_query(
1617        &self,
1618        form: &HashMap<String, String>,
1619        skip: &[String],
1620    ) -> Result<Option<sea_query::InsertStatement>, DynError> {
1621        let mut cols: Vec<&str> = Vec::new();
1622        let mut values: Vec<SeaValue> = Vec::new();
1623        for col in &self.meta.fields {
1624            if skip.iter().any(|s| s == &col.name) {
1625                continue;
1626            }
1627            // audit_2 H3: a `#[umbral(privileged)]` column is never written
1628            // from an untrusted form unless the caller authorized it via
1629            // `allow_privileged` — default-deny mass assignment, same guard the
1630            // JSON path applies, so the admin form and REST agree.
1631            if is_unauthorized_privileged(col, &self.allow_privileged) {
1632                continue;
1633            }
1634            // Auto-increment PK: omit when the form supplies no value
1635            // or an empty one; the backend hands out the next id.
1636            if col.primary_key
1637                && matches!(
1638                    col.ty,
1639                    SqlType::Integer | SqlType::BigInt | SqlType::SmallInt
1640                )
1641                && form.get(&col.name).is_none_or(|v| v.is_empty())
1642            {
1643                continue;
1644            }
1645            // `auto_now_add` / `auto_now` columns: when the form
1646            // omits the field (the post-fix admin shape — these
1647            // columns are hidden from create + edit forms), fill
1648            // with `Utc::now()` here. Mirrors the same handling on
1649            // `insert_json` (line ~836) so the form path and the
1650            // JSON path stay consistent — both honor the annotation
1651            // without the body / form having to carry the value.
1652            // gaps3 #55: server-owned author on create. Ignores whatever the
1653            // form carried — you cannot create a row authored by someone else.
1654            if col.auto_user_add || col.auto_user {
1655                cols.push(&col.name);
1656                values.push(crate::orm::write::user_for_column(col.ty));
1657                continue;
1658            }
1659            if (col.auto_now_add || col.auto_now)
1660                && form.get(&col.name).is_none_or(|v| v.is_empty())
1661            {
1662                cols.push(&col.name);
1663                values.push(crate::orm::write::now_for_column(col.ty));
1664                continue;
1665            }
1666            let raw = form.get(&col.name).map(|s| s.as_str()).unwrap_or("");
1667            let sea_value = match form_str_to_sea_value(col, raw) {
1668                Ok(v) => v,
1669                // gaps2 #12: structured per-field validator failure
1670                // (see the matching site in `update_form`).
1671                Err(e) => {
1672                    return Err(DynError::Write(WriteError::Validator {
1673                        field: col.name.clone(),
1674                        message: e.to_string(),
1675                    }));
1676                }
1677            };
1678            cols.push(&col.name);
1679            values.push(sea_value);
1680        }
1681        if cols.is_empty() {
1682            return Ok(None);
1683        }
1684
1685        let mut q = Query::insert();
1686        q.into_table(crate::db::router::schema_qualified_table(&self.meta.table));
1687        q.columns(cols.iter().map(|c| Alias::new(*c)).collect::<Vec<_>>());
1688        let exprs: Vec<sea_query::SimpleExpr> = values.into_iter().map(Into::into).collect();
1689        q.values_panic(exprs);
1690        Ok(Some(q))
1691    }
1692
1693    /// Transaction-aware sibling of [`Self::insert_form`]. Builds and
1694    /// executes the identical `INSERT` (same `form_str_to_sea_value`
1695    /// per-field validation, same `skip` / auto-increment-PK / auto-now
1696    /// handling, same [`WriteError::Validator`] shape, same returned-PK
1697    /// semantics) but runs it on the caller-supplied `tx` instead of a
1698    /// fresh pool connection. The caller owns `commit` / `rollback`, so
1699    /// the insert is uncommitted until they say so — this is what lets
1700    /// the admin save a parent row and its inline children atomically.
1701    pub async fn insert_form_in_tx(
1702        self,
1703        tx: &mut crate::db::Transaction,
1704        form: &HashMap<String, String>,
1705        skip: &[String],
1706    ) -> Result<InsertedPk, DynError> {
1707        self.ensure_writable()?;
1708        let Some(mut q) = self.build_insert_form_query(form, skip)? else {
1709            return Ok(InsertedPk::None);
1710        };
1711        let pk_col = self.meta.fields.iter().find(|c| c.primary_key);
1712
1713        match tx.backend_name() {
1714            "sqlite" => {
1715                let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
1716                // Same split as the pool path: integer PK via last_insert_rowid,
1717                // non-integer PK via RETURNING so it keeps its true shape.
1718                match pk_col {
1719                    Some(pk) if !pk_is_integer(pk) => {
1720                        q.returning_col(Alias::new(&pk.name));
1721                        let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
1722                        let row = sqlx::query_with(&sql, vals).fetch_one(&mut **inner).await?;
1723                        Ok(InsertedPk::Text(decode_to_string(&row, pk)?))
1724                    }
1725                    _ => {
1726                        let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
1727                        let res = sqlx::query_with(&sql, vals).execute(&mut **inner).await?;
1728                        Ok(InsertedPk::Int(res.last_insert_rowid()))
1729                    }
1730                }
1731            }
1732            _ => {
1733                // Postgres has no last_insert_rowid; RETURNING the PK mirrors the
1734                // pool path exactly and preserves a non-integer PK's shape.
1735                let inner = tx.as_pg_mut().expect("postgres backend_name");
1736                match pk_col {
1737                    Some(pk) => {
1738                        q.returning_col(Alias::new(&pk.name));
1739                        let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
1740                        let row = sqlx::query_with(&sql, vals).fetch_one(&mut **inner).await?;
1741                        Ok(pg_inserted_pk(&row, pk)?)
1742                    }
1743                    None => {
1744                        let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
1745                        let _ = sqlx::query_with(&sql, vals).execute(&mut **inner).await?;
1746                        Ok(InsertedPk::None)
1747                    }
1748                }
1749            }
1750        }
1751    }
1752
1753    /// Terminal: fetch every row, decoding each cell to its string
1754    /// form via [`decode_to_string`]. Returns one `HashMap` per row,
1755    /// keyed by column name, holding only the columns named in
1756    /// `select_cols` (defaults to all).
1757    pub async fn fetch_as_strings(self) -> Result<Vec<HashMap<String, String>>, DynError> {
1758        // Read-side field policy is applied to the SELECT LIST, not to the rows on the way
1759        // out: a column this reader may not see is never fetched, so its value never even
1760        // crosses the database boundary.
1761        let select_cols = self.visible_select_cols();
1762        let mut q = Query::select();
1763        q.from(crate::db::router::schema_qualified_table(&self.meta.table));
1764        for c in &select_cols {
1765            q.column(Alias::new(c));
1766        }
1767        let where_clauses = self.effective_where_clauses();
1768        for cond in &where_clauses {
1769            q.cond_where(cond.clone());
1770        }
1771        for (col, descending) in &self.order {
1772            q.order_by(
1773                Alias::new(col),
1774                if *descending { Order::Desc } else { Order::Asc },
1775            );
1776        }
1777        if let Some(n) = self.limit {
1778            q.limit(n);
1779        }
1780        if let Some(n) = self.offset {
1781            q.offset(n);
1782        }
1783
1784        match resolve_pool_dyn(self.meta, crate::db::RouteOp::Read) {
1785            DbPool::Sqlite(pool) => {
1786                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1787                let rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
1788                let mut out: Vec<HashMap<String, String>> = Vec::with_capacity(rows.len());
1789                for row in rows {
1790                    let mut entry = HashMap::new();
1791                    for col_name in &select_cols {
1792                        if let Some(col_meta) =
1793                            self.meta.fields.iter().find(|c| &c.name == col_name)
1794                        {
1795                            let v = decode_to_string(&row, col_meta)?;
1796                            entry.insert(col_name.clone(), v);
1797                        }
1798                    }
1799                    out.push(entry);
1800                }
1801                Ok(out)
1802            }
1803            DbPool::Postgres(pool) => {
1804                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1805                let rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
1806                let mut out: Vec<HashMap<String, String>> = Vec::with_capacity(rows.len());
1807                for row in rows {
1808                    let mut entry = HashMap::new();
1809                    for col_name in &select_cols {
1810                        if let Some(col_meta) =
1811                            self.meta.fields.iter().find(|c| &c.name == col_name)
1812                        {
1813                            let v = decode_pg_to_string(&row, col_meta)?;
1814                            entry.insert(col_name.clone(), v);
1815                        }
1816                    }
1817                    out.push(entry);
1818                }
1819                Ok(out)
1820            }
1821        }
1822    }
1823
1824    /// Terminal: fetch every row, decoding each cell to a
1825    /// `serde_json::Value` that preserves JSON shape (numbers stay
1826    /// numbers, booleans stay booleans, JSON columns nest verbatim).
1827    /// The right shape for HTTP API responses. Returns one
1828    /// `serde_json::Map` per row, keyed by column name.
1829    pub async fn fetch_as_json(
1830        self,
1831    ) -> Result<Vec<serde_json::Map<String, serde_json::Value>>, DynError> {
1832        // Read-side field policy is applied to the SELECT LIST, not to the rows on the way
1833        // out: a column this reader may not see is never fetched, so its value never even
1834        // crosses the database boundary.
1835        let select_cols = self.visible_select_cols();
1836        let mut q = Query::select();
1837        q.from(crate::db::router::schema_qualified_table(&self.meta.table));
1838        for c in &select_cols {
1839            q.column(Alias::new(c));
1840        }
1841        let where_clauses = self.effective_where_clauses();
1842        for cond in &where_clauses {
1843            q.cond_where(cond.clone());
1844        }
1845        for (col, descending) in &self.order {
1846            q.order_by(
1847                Alias::new(col),
1848                if *descending { Order::Desc } else { Order::Asc },
1849            );
1850        }
1851        if let Some(n) = self.limit {
1852            q.limit(n);
1853        }
1854        if let Some(n) = self.offset {
1855            q.offset(n);
1856        }
1857
1858        let pk_name = self
1859            .meta
1860            .pk_column()
1861            .map(|c| c.name.clone())
1862            .unwrap_or_default();
1863        let selected_cols: Vec<(&String, &Column)> = select_cols
1864            .iter()
1865            .filter_map(|col_name| {
1866                self.meta
1867                    .fields
1868                    .iter()
1869                    .find(|c| &c.name == col_name)
1870                    .map(|col| (col_name, col))
1871            })
1872            .collect();
1873        let mut out: Vec<serde_json::Map<String, serde_json::Value>> =
1874            match resolve_pool_dyn(self.meta, crate::db::RouteOp::Read) {
1875                DbPool::Sqlite(pool) => {
1876                    let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1877                    let rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
1878                    let mut out: Vec<serde_json::Map<String, serde_json::Value>> =
1879                        Vec::with_capacity(rows.len());
1880                    for row in rows {
1881                        let mut entry = serde_json::Map::new();
1882                        for (col_name, col_meta) in &selected_cols {
1883                            let v = self.reveal_value(col_meta, decode_to_json(&row, col_meta)?);
1884                            entry.insert((*col_name).clone(), v);
1885                        }
1886                        out.push(entry);
1887                    }
1888                    out
1889                }
1890                DbPool::Postgres(pool) => {
1891                    let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
1892                    let rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
1893                    let mut out: Vec<serde_json::Map<String, serde_json::Value>> =
1894                        Vec::with_capacity(rows.len());
1895                    for row in rows {
1896                        let mut entry = serde_json::Map::new();
1897                        for (col_name, col_meta) in &selected_cols {
1898                            let v = self.reveal_value(col_meta, decode_pg_to_json(&row, col_meta)?);
1899                            entry.insert((*col_name).clone(), v);
1900                        }
1901                        out.push(entry);
1902                    }
1903                    out
1904                }
1905            };
1906
1907        // M2M echo via one batched IN per relation across every
1908        // parent row in `out`. Replaces the per-row, per-relation
1909        // SELECT that ran inside the row loop above (gap2 #16) —
1910        // query budget drops from `1 + N*M` to `1 + count(M2M
1911        // relations)` regardless of how many parent rows came back.
1912        // Each row picks up its `<relation>: [child_id, ...]`
1913        // array via PK→children grouping, with an empty array
1914        // for parents that have no junction rows (preserves the
1915        // per-row helper's "always echo the key" contract).
1916        if !self.meta.m2m_relations.is_empty() && !out.is_empty() {
1917            hydrate_m2m_batched(self.meta, &pk_name, &mut out).await?;
1918        }
1919
1920        // FK expansion via select_related — one batched
1921        // `IN (...)` per requested FK after the main query, then
1922        // splice the resolved row's JSON in where the integer id
1923        // was. No N+1: each FK costs one round-trip regardless of
1924        // how many parent rows came back. Reuses the same
1925        // `fetch_related_as_json` helper that powers the typed
1926        // `QuerySet::select_related` path so SQLite + Postgres
1927        // dispatch stays in one place.
1928        if !self.select_related.is_empty() && !out.is_empty() {
1929            hydrate_select_related_into(self.meta, &self.select_related, &mut out).await?;
1930        }
1931        Ok(out)
1932    }
1933
1934    /// Terminal: fetch the first row (LIMIT 1) as a JSON object.
1935    /// Returns `None` when the filter matches zero rows.
1936    pub async fn first_as_json(
1937        mut self,
1938    ) -> Result<Option<serde_json::Map<String, serde_json::Value>>, DynError> {
1939        self.limit = Some(1);
1940        let mut rows = self.fetch_as_json().await?;
1941        Ok(rows.pop())
1942    }
1943
1944    /// Column-level read against an EXPLICIT pool (not the ambient router).
1945    /// Mirrors [`Self::fetch_as_json`] — same visibility policy (so
1946    /// `.unredacted_for_backup()` reads every column, secrets included) and the
1947    /// same `reveal_value` decode — but runs on the pool you hand it and does
1948    /// NOT hydrate M2M arrays or `select_related`. Built for the data-transfer
1949    /// engine, which reads from a source pool that is not the app's ambient one.
1950    pub async fn fetch_as_json_on(
1951        self,
1952        pool: &crate::db::DbPool,
1953    ) -> Result<Vec<serde_json::Map<String, serde_json::Value>>, DynError> {
1954        let select_cols = self.visible_select_cols();
1955        let mut q = Query::select();
1956        q.from(crate::db::router::schema_qualified_table(&self.meta.table));
1957        for c in &select_cols {
1958            q.column(Alias::new(c));
1959        }
1960        for cond in &self.effective_where_clauses() {
1961            q.cond_where(cond.clone());
1962        }
1963        for (col, descending) in &self.order {
1964            q.order_by(
1965                Alias::new(col),
1966                if *descending { Order::Desc } else { Order::Asc },
1967            );
1968        }
1969        if let Some(n) = self.limit {
1970            q.limit(n);
1971        }
1972        if let Some(n) = self.offset {
1973            q.offset(n);
1974        }
1975        let selected_cols: Vec<(&String, &Column)> = select_cols
1976            .iter()
1977            .filter_map(|col_name| {
1978                self.meta
1979                    .fields
1980                    .iter()
1981                    .find(|c| &c.name == col_name)
1982                    .map(|col| (col_name, col))
1983            })
1984            .collect();
1985        let out = match pool {
1986            DbPool::Sqlite(pool) => {
1987                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
1988                let rows = sqlx::query_with(&sql, values).fetch_all(pool).await?;
1989                let mut out = Vec::with_capacity(rows.len());
1990                for row in rows {
1991                    let mut entry = serde_json::Map::new();
1992                    for (col_name, col_meta) in &selected_cols {
1993                        let v = self.reveal_value(col_meta, decode_to_json(&row, col_meta)?);
1994                        entry.insert((*col_name).clone(), v);
1995                    }
1996                    out.push(entry);
1997                }
1998                out
1999            }
2000            DbPool::Postgres(pool) => {
2001                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
2002                let rows = sqlx::query_with(&sql, values).fetch_all(pool).await?;
2003                let mut out = Vec::with_capacity(rows.len());
2004                for row in rows {
2005                    let mut entry = serde_json::Map::new();
2006                    for (col_name, col_meta) in &selected_cols {
2007                        let v = self.reveal_value(col_meta, decode_pg_to_json(&row, col_meta)?);
2008                        entry.insert((*col_name).clone(), v);
2009                    }
2010                    out.push(entry);
2011                }
2012                out
2013            }
2014        };
2015        Ok(out)
2016    }
2017
2018    /// Transaction-aware single-row read: `SELECT <cols> ... LIMIT 1` for
2019    /// the accumulated WHERE, run on the open `tx`. Decodes every model
2020    /// column into a JSON map. Used by REST bulk update to read a row back
2021    /// on the same (uncommitted) transaction so the response reflects the
2022    /// in-flight write. Returns `None` when the filter matches no row.
2023    ///
2024    /// Unlike [`Self::fetch_as_json`] this does NOT hydrate M2M arrays or
2025    /// `select_related` — it's the column-level read the bulk write path
2026    /// needs, matching what the single-object PATCH read-back returns.
2027    pub async fn fetch_one_json_in_tx(
2028        self,
2029        tx: &mut crate::db::Transaction,
2030    ) -> Result<Option<serde_json::Map<String, serde_json::Value>>, DynError> {
2031        let mut q = Query::select();
2032        q.from(crate::db::router::schema_qualified_table(&self.meta.table));
2033        for c in &self.meta.fields {
2034            q.column(Alias::new(&c.name));
2035        }
2036        let where_clauses = self.effective_where_clauses();
2037        for cond in &where_clauses {
2038            q.cond_where(cond.clone());
2039        }
2040        q.limit(1);
2041
2042        let out = match tx.backend_name() {
2043            "sqlite" => {
2044                let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
2045                let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
2046                let row = sqlx::query_with(&sql, values)
2047                    .fetch_optional(&mut **inner)
2048                    .await?;
2049                match row {
2050                    Some(row) => {
2051                        let mut entry = serde_json::Map::new();
2052                        for col in self.meta.fields.iter().filter(|c| self.may_serialize(c)) {
2053                            entry.insert(col.name.clone(), decode_to_json(&row, col)?);
2054                        }
2055                        Some(entry)
2056                    }
2057                    None => None,
2058                }
2059            }
2060            _ => {
2061                let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
2062                let inner = tx.as_pg_mut().expect("postgres backend_name");
2063                let row = sqlx::query_with(&sql, values)
2064                    .fetch_optional(&mut **inner)
2065                    .await?;
2066                match row {
2067                    Some(row) => {
2068                        let mut entry = serde_json::Map::new();
2069                        // gaps4 #3: filter through the field-visibility policy, exactly like the
2070                        // SQLite twin above. The PG arm used to iterate every column, leaking
2071                        // secret/private/Masked values on the production backend.
2072                        for col in self.meta.fields.iter().filter(|c| self.may_serialize(c)) {
2073                            entry.insert(col.name.clone(), decode_pg_to_json(&row, col)?);
2074                        }
2075                        Some(entry)
2076                    }
2077                    None => None,
2078                }
2079            }
2080        };
2081        Ok(out)
2082    }
2083
2084    /// Terminal: INSERT one row from a JSON map. Auto-increment integer
2085    /// PKs are omitted when missing or null (the backend assigns).
2086    /// Returns the newly-inserted row as JSON (via RETURNING * on
2087    /// Postgres; via last_insert_rowid → SELECT * on SQLite). The
2088    /// per-column JSON-to-SeaValue coercion goes through the existing
2089    /// `json_to_sea_value` so timestamp / uuid / json paths are the
2090    /// same as the typed Manager::create path.
2091    pub async fn insert_json(
2092        self,
2093        body: &serde_json::Map<String, serde_json::Value>,
2094    ) -> Result<serde_json::Map<String, serde_json::Value>, crate::orm::write::WriteError> {
2095        self.ensure_writable()?;
2096        use crate::orm::write::WriteError;
2097
2098        // Phase -1 — normalise the body (strip `noform`, derive
2099        // `slug_from`). Shared with the tx path.
2100        let body_owned: serde_json::Map<String, serde_json::Value>;
2101        let body: &serde_json::Map<String, serde_json::Value> =
2102            match normalise_insert_body(self.meta, body, &self.allow_privileged) {
2103                Some(owned) => {
2104                    body_owned = owned;
2105                    &body_owned
2106                }
2107                None => body,
2108            };
2109
2110        // Phase 0 — pre-DB validation against the ambient pool. Skipped for a
2111        // `.trusted()` bulk copy from a consistent source (the FK-existence
2112        // check would reject a cyclic / not-yet-loaded reference).
2113        if !self.trusted {
2114            let validation_errors =
2115                crate::orm::validation::validate_on_create(self.meta, body).await;
2116            if !validation_errors.is_empty() {
2117                return Err(WriteError::Multiple {
2118                    errors: validation_errors,
2119                });
2120            }
2121        }
2122
2123        // Phase 1 — build the INSERT + read back the PK shape.
2124        // Shared with the tx path.
2125        let InsertPlan {
2126            mut q,
2127            pk_name,
2128            pk_ty,
2129        } = build_insert_plan(self.meta, body, self.presealed)?;
2130
2131        // gaps #77: fire `pre_save:<table>` for the dynamic-write
2132        // path so REST endpoints and admin form submits surface in
2133        // signal subscribers (audit logs, cache invalidation, search
2134        // index sync). Payload mirrors the typed `Manager::create`
2135        // shape — `{ "instance": <body JSON>, "created": true }`.
2136        crate::signals::emit_pre_save_by_table(
2137            &self.meta.table,
2138            serde_json::Value::Object(body.clone()),
2139            true,
2140        )
2141        .await;
2142
2143        // audit_2 core-orm #2 — run the parent INSERT and the M2M
2144        // junction writes on ONE transaction so a junction failure
2145        // rolls the parent back instead of leaving an orphaned,
2146        // tag-less row durably committed. Previously the parent
2147        // INSERT auto-committed on the pool and the junctions were
2148        // written in a separate transaction; a junction failure then
2149        // returned `Err` while the parent stayed committed. `post_save`
2150        // fires only after the commit succeeds (a subscriber must never
2151        // observe a write that then rolled back — same reason the
2152        // `_in_tx` path skips the signal).
2153        let mut tx = match resolve_pool_dyn(self.meta, crate::db::RouteOp::Write) {
2154            DbPool::Sqlite(pool) => crate::db::begin_sqlite(&pool).await,
2155            DbPool::Postgres(pool) => crate::db::begin_pg(&pool).await,
2156        }?;
2157
2158        let mut out = match tx.backend_name() {
2159            "sqlite" => {
2160                let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
2161                let res = {
2162                    let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
2163                    sqlx::query_with(&sql, vals)
2164                        .execute(&mut **inner)
2165                        .await
2166                        .map_err(|e| classify_or_sqlx(e, body))?
2167                };
2168                // Re-fetch by PK so the caller sees the row as the DB
2169                // stored it (defaults, autoincrement, server-side
2170                // coercion).
2171                let pk_pred = match pk_ty {
2172                    SqlType::Integer | SqlType::BigInt | SqlType::SmallInt => {
2173                        Expr::col(Alias::new(&pk_name)).eq(res.last_insert_rowid())
2174                    }
2175                    _ => {
2176                        // Client-supplied non-integer PK: pull it back
2177                        // from the body.
2178                        let supplied = body
2179                            .get(&pk_name)
2180                            .cloned()
2181                            .unwrap_or(serde_json::Value::Null);
2182                        let sea_value = crate::orm::write::json_to_sea_value(
2183                            pk_ty, &supplied, false, &pk_name, None,
2184                        )?;
2185                        Expr::col(Alias::new(&pk_name)).eq(sea_value)
2186                    }
2187                };
2188                let mut sel = Query::select();
2189                sel.from(crate::db::router::schema_qualified_table(&self.meta.table));
2190                for c in &self.meta.fields {
2191                    sel.column(Alias::new(&c.name));
2192                }
2193                sel.cond_where(Condition::all().add(pk_pred));
2194                let (sel_sql, sel_vals) = sel.build_sqlx(SqliteQueryBuilder);
2195                let mut out = serde_json::Map::new();
2196                {
2197                    let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
2198                    let row = sqlx::query_with(&sel_sql, sel_vals)
2199                        .fetch_one(&mut **inner)
2200                        .await?;
2201                    for col in self.meta.fields.iter().filter(|c| self.may_serialize(c)) {
2202                        out.insert(col.name.clone(), decode_to_json(&row, col)?);
2203                    }
2204                }
2205                out
2206            }
2207            _ => {
2208                // `RETURNING *` fetches every column of the newly-inserted
2209                // row in one round trip. sea-query's chained
2210                // `returning_col` calls don't accumulate, so we use the
2211                // explicit "all columns" variant.
2212                q.returning_all();
2213                let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
2214                let mut out = serde_json::Map::new();
2215                {
2216                    let inner = tx.as_pg_mut().expect("postgres backend_name");
2217                    let row = sqlx::query_with(&sql, vals)
2218                        .fetch_one(&mut **inner)
2219                        .await
2220                        .map_err(|e| classify_or_sqlx(e, body))?;
2221                    // gaps4 #3: filter through the field-visibility policy, exactly like the
2222                    // SQLite twin above. The PG arm used to iterate every column, leaking
2223                    // secret/private/Masked values on the production backend.
2224                    for col in self.meta.fields.iter().filter(|c| self.may_serialize(c)) {
2225                        out.insert(col.name.clone(), decode_pg_to_json(&row, col)?);
2226                    }
2227                }
2228                out
2229            }
2230        };
2231
2232        // Phase 2 — write junction rows for every M2M relation the
2233        // body carried, on the SAME tx. Validation already confirmed
2234        // the array shape + element existence; we just mirror the ids
2235        // into the auto-generated `<table>_<field>` table. A failure
2236        // here drops `tx` (rolling back the parent INSERT) rather than
2237        // orphaning a committed parent.
2238        let pk_value = out.get(&pk_name).cloned();
2239        write_m2m_junctions_in_tx(self.meta, pk_value.as_ref(), body, &mut tx).await?;
2240        // Phase 3 — hydrate M2M arrays back into the response so the
2241        // caller sees `tags: [1, 2]` instead of an empty echo.
2242        hydrate_m2m_into_tx(self.meta, pk_value.as_ref(), &mut out, &mut tx).await?;
2243
2244        tx.commit().await?;
2245
2246        // gaps #77: post_save with the fully-hydrated row — fired only
2247        // after the commit is durable.
2248        crate::signals::emit_post_save_by_table(
2249            &self.meta.table,
2250            serde_json::Value::Object(out.clone()),
2251            true,
2252        )
2253        .await;
2254        // gaps3 #54: the inserted row IS the after-image; there is no before.
2255        // Boxed because the audit row is itself written through `insert_json` —
2256        // a real cycle. It terminates: the audit table is not `audited`, so
2257        // `record` returns before writing another.
2258        Box::pin(crate::orm::audit::record(
2259            self.meta,
2260            &crate::orm::audit::pk_of(self.meta, &out),
2261            crate::orm::audit::CREATE,
2262            None,
2263            Some(&out),
2264        ))
2265        .await;
2266        Ok(out)
2267    }
2268
2269    /// Terminal: INSERT one row from a JSON map ON the passed
2270    /// transaction. The transactional sibling of [`Self::insert_json`]:
2271    /// the INSERT, the PK re-fetch, the M2M junction writes, the M2M
2272    /// read-back, AND the FK-existence validation all execute on `tx`
2273    /// rather than the ambient pool — so a caller can insert a parent
2274    /// and its children on one transaction and have the whole set
2275    /// commit (or roll back) atomically (`planning/orm_fixes.md` #2).
2276    ///
2277    /// Validation runs against the open transaction
2278    /// ([`crate::orm::validation::validate_on_create_in_tx`]) so a
2279    /// child whose FK targets a parent inserted earlier on the same
2280    /// (uncommitted) `tx` resolves. This is what makes a true-atomic
2281    /// nested create possible without the old compensating-delete
2282    /// dance.
2283    ///
2284    /// **Signals.** Unlike the auto-commit path, this does NOT fire
2285    /// `pre_save` / `post_save`. The row isn't durable until the
2286    /// caller commits `tx`, and a subscriber (audit log, cache
2287    /// invalidation, search index) firing before commit could observe
2288    /// — or react to — a write that then rolls back. The caller owns
2289    /// the commit, so the caller owns whatever post-commit signalling
2290    /// it wants. (The typed `Manager::create_in_tx` path makes the
2291    /// same choice for the same reason.)
2292    pub async fn insert_json_in_tx(
2293        self,
2294        body: &serde_json::Map<String, serde_json::Value>,
2295        tx: &mut crate::db::Transaction,
2296    ) -> Result<serde_json::Map<String, serde_json::Value>, crate::orm::write::WriteError> {
2297        self.ensure_writable()?;
2298        use crate::orm::write::WriteError;
2299
2300        // Phase -1 — normalise (shared with the pool path).
2301        let body_owned: serde_json::Map<String, serde_json::Value>;
2302        let body: &serde_json::Map<String, serde_json::Value> =
2303            match normalise_insert_body(self.meta, body, &self.allow_privileged) {
2304                Some(owned) => {
2305                    body_owned = owned;
2306                    &body_owned
2307                }
2308                None => body,
2309            };
2310
2311        // Phase 0 — validation reads through the transaction so an FK
2312        // at an uncommitted parent resolves. Skipped under `.trusted()` (the
2313        // transfer engine copying a consistent source): the FK-existence check
2314        // would reject a cyclic / not-yet-loaded reference even with DB-level
2315        // deferral, since it's an app-level SELECT, not the constraint.
2316        if !self.trusted {
2317            let validation_errors =
2318                crate::orm::validation::validate_on_create_in_tx(self.meta, body, tx).await;
2319            if !validation_errors.is_empty() {
2320                return Err(WriteError::Multiple {
2321                    errors: validation_errors,
2322                });
2323            }
2324        }
2325
2326        // Phase 1 — build the INSERT (shared with the pool path).
2327        let InsertPlan {
2328            mut q,
2329            pk_name,
2330            pk_ty,
2331        } = build_insert_plan(self.meta, body, self.presealed)?;
2332
2333        match tx.backend_name() {
2334            "sqlite" => {
2335                let (sql, vals) = q.build_sqlx(SqliteQueryBuilder);
2336                let res = {
2337                    let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
2338                    sqlx::query_with(&sql, vals)
2339                        .execute(&mut **inner)
2340                        .await
2341                        .map_err(|e| classify_or_sqlx(e, body))?
2342                };
2343                // Re-fetch by PK on the same tx so the caller sees the
2344                // row the DB stored (defaults, autoincrement).
2345                let pk_pred = match pk_ty {
2346                    SqlType::Integer | SqlType::BigInt | SqlType::SmallInt => {
2347                        Expr::col(Alias::new(&pk_name)).eq(res.last_insert_rowid())
2348                    }
2349                    _ => {
2350                        let supplied = body
2351                            .get(&pk_name)
2352                            .cloned()
2353                            .unwrap_or(serde_json::Value::Null);
2354                        let sea_value = crate::orm::write::json_to_sea_value(
2355                            pk_ty, &supplied, false, &pk_name, None,
2356                        )?;
2357                        Expr::col(Alias::new(&pk_name)).eq(sea_value)
2358                    }
2359                };
2360                let mut sel = Query::select();
2361                sel.from(crate::db::router::schema_qualified_table(&self.meta.table));
2362                for c in &self.meta.fields {
2363                    sel.column(Alias::new(&c.name));
2364                }
2365                sel.cond_where(Condition::all().add(pk_pred));
2366                let (sel_sql, sel_vals) = sel.build_sqlx(SqliteQueryBuilder);
2367                let mut out = serde_json::Map::new();
2368                {
2369                    let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
2370                    let row = sqlx::query_with(&sel_sql, sel_vals)
2371                        .fetch_one(&mut **inner)
2372                        .await?;
2373                    for col in self.meta.fields.iter().filter(|c| self.may_serialize(c)) {
2374                        out.insert(col.name.clone(), decode_to_json(&row, col)?);
2375                    }
2376                }
2377                // Phase 2/3 — junction writes + read-back on the tx.
2378                let pk_value = out.get(&pk_name).cloned();
2379                write_m2m_junctions_in_tx(self.meta, pk_value.as_ref(), body, tx).await?;
2380                hydrate_m2m_into_tx(self.meta, pk_value.as_ref(), &mut out, tx).await?;
2381                Ok(out)
2382            }
2383            _ => {
2384                q.returning_all();
2385                let (sql, vals) = q.build_sqlx(PostgresQueryBuilder);
2386                let mut out = serde_json::Map::new();
2387                {
2388                    let inner = tx.as_pg_mut().expect("postgres backend_name");
2389                    let row = sqlx::query_with(&sql, vals)
2390                        .fetch_one(&mut **inner)
2391                        .await
2392                        .map_err(|e| classify_or_sqlx(e, body))?;
2393                    // gaps4 #3: filter through the field-visibility policy, exactly like the
2394                    // SQLite twin above. The PG arm used to iterate every column, leaking
2395                    // secret/private/Masked values on the production backend.
2396                    for col in self.meta.fields.iter().filter(|c| self.may_serialize(c)) {
2397                        out.insert(col.name.clone(), decode_pg_to_json(&row, col)?);
2398                    }
2399                }
2400                let pk_value = out.get(&pk_name).cloned();
2401                write_m2m_junctions_in_tx(self.meta, pk_value.as_ref(), body, tx).await?;
2402                hydrate_m2m_into_tx(self.meta, pk_value.as_ref(), &mut out, tx).await?;
2403                Ok(out)
2404            }
2405        }
2406    }
2407
2408    /// Transaction-aware sibling of [`Self::update_json`]. PATCH semantics —
2409    /// update only the columns present in `body` for the rows matched by the
2410    /// accumulated WHERE — but every statement runs on the open `tx` so a
2411    /// batch of updates commits or rolls back as a unit. M2M arrays in the
2412    /// body are mirrored into junction tables on the same tx. Returns the
2413    /// number of rows touched.
2414    ///
2415    /// Used by REST bulk update (one tx for the whole array). Mirrors the
2416    /// pool path's validation + `noform`/`slug_from`/`auto_now` handling;
2417    /// the only difference is the execution target.
2418    pub async fn update_json_in_tx(
2419        self,
2420        body: &serde_json::Map<String, serde_json::Value>,
2421        tx: &mut crate::db::Transaction,
2422    ) -> Result<u64, crate::orm::write::WriteError> {
2423        self.ensure_writable()?;
2424        use crate::orm::write::WriteError;
2425
2426        // Phase -1 — strip `noform` + unauthorized-`privileged` columns and
2427        // derive `slug_from` (mirrors the pool path).
2428        let body_owned: serde_json::Map<String, serde_json::Value>;
2429        let body: &serde_json::Map<String, serde_json::Value> =
2430            match normalise_update_body(self.meta, body, &self.allow_privileged) {
2431                Some(owned) => {
2432                    body_owned = owned;
2433                    &body_owned
2434                }
2435                None => body,
2436            };
2437
2438        // Phase 0 — pre-DB validation, same shape as `update_json`. FK
2439        // existence reads through the open tx so an FK at an uncommitted
2440        // sibling row in the same batch resolves.
2441        let validation_errors =
2442            crate::orm::validation::validate_on_update_in_tx(self.meta, body, tx).await;
2443        if !validation_errors.is_empty() {
2444            return Err(WriteError::Multiple {
2445                errors: validation_errors,
2446            });
2447        }
2448
2449        let mut q = Query::update();
2450        q.table(crate::db::router::schema_qualified_table(&self.meta.table));
2451        let mut any = false;
2452        for col in &self.meta.fields {
2453            if col.primary_key {
2454                continue;
2455            }
2456            // gaps3 #55: the author is SERVER-owned. Stamped unconditionally,
2457            // before the body is even consulted, so a client cannot forge it by
2458            // putting someone else's id in the payload. `auto_user_add` stays
2459            // frozen on update (it fired on create); `auto_user` refreshes.
2460            if col.auto_user {
2461                q.value(
2462                    Alias::new(&col.name),
2463                    crate::orm::write::user_for_column(col.ty),
2464                );
2465                any = true;
2466                continue;
2467            }
2468            let Some(json) = body.get(&col.name) else {
2469                if col.auto_now {
2470                    let now_value = crate::orm::write::now_for_column(col.ty);
2471                    q.value(Alias::new(&col.name), now_value);
2472                    any = true;
2473                }
2474                continue;
2475            };
2476            validate_numeric_bounds(col, json)?;
2477            if let (Some(fmt), Some(s)) = (col.text_format.as_deref(), json.as_str()) {
2478                if let Err(e) = crate::orm::validators::validate_text_format(fmt, s) {
2479                    return Err(WriteError::Validator {
2480                        field: col.name.clone(),
2481                        message: e.to_string(),
2482                    });
2483                }
2484            }
2485            // gaps3 #34: apply declared trim/lowercase to the incoming string
2486            // before masking / binding, so admin-form + REST writes store the
2487            // canonical value (the typed path is caller-controlled).
2488            let normalized_json = normalize_json_for_col(col, json);
2489            let json = normalized_json.as_ref().unwrap_or(json);
2490            // features #83: app-defined clean/validate hooks. Before masking, so a
2491            // hook sees the plaintext it is meant to inspect.
2492            let cleaned_json = crate::orm::cleaners::apply(&self.meta.table, &col.name, json)?;
2493            let json = cleaned_json.as_ref().unwrap_or(json);
2494            // Masked columns: seal the plaintext before binding so the dynamic
2495            // write path encrypts at rest too (audit_2 core-orm C1).
2496            let sealed = crate::orm::write::seal_masked_json(col, json)?;
2497            let sea_value = crate::orm::write::json_to_sea_value(
2498                col.ty,
2499                sealed.as_ref().unwrap_or(json),
2500                col.nullable,
2501                &col.name,
2502                fk_target_pk_sql_type(col),
2503            )?;
2504            q.value(Alias::new(&col.name), sea_value);
2505            any = true;
2506        }
2507        let touches_m2m = self
2508            .meta
2509            .m2m_relations
2510            .iter()
2511            .any(|r| body.contains_key(&r.field_name));
2512        if !any && !touches_m2m {
2513            return Ok(0);
2514        }
2515        let where_clauses = self.effective_where_clauses();
2516        for cond in &where_clauses {
2517            q.cond_where(cond.clone());
2518        }
2519
2520        // The PKs the WHERE matches — needed for the M2M mirror below. We
2521        // read them on the same tx so the bulk update sees its own
2522        // uncommitted siblings.
2523        let parent_pks: Vec<serde_json::Value> = match self.meta.pk_column() {
2524            Some(pk_col) => collect_parent_pks_in_tx(self.meta, pk_col, &where_clauses, tx).await?,
2525            None => Vec::new(),
2526        };
2527
2528        let mut rows_affected: u64 = 0;
2529        if any {
2530            match tx.backend_name() {
2531                "sqlite" => {
2532                    let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
2533                    let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
2534                    rows_affected = sqlx::query_with(&sql, values)
2535                        .execute(&mut **inner)
2536                        .await
2537                        .map_err(|e| classify_or_sqlx(e, body))?
2538                        .rows_affected();
2539                }
2540                _ => {
2541                    let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
2542                    let inner = tx.as_pg_mut().expect("postgres backend_name");
2543                    rows_affected = sqlx::query_with(&sql, values)
2544                        .execute(&mut **inner)
2545                        .await
2546                        .map_err(|e| classify_or_sqlx(e, body))?
2547                        .rows_affected();
2548                }
2549            }
2550        }
2551        for pk in &parent_pks {
2552            write_m2m_junctions_in_tx(self.meta, Some(pk), body, tx).await?;
2553        }
2554        // The REAL matched-row count — a WHERE that matched nothing must
2555        // report 0, exactly like `update_json` on the pool path. This used to
2556        // return `parent_pks.len().max(1)` whenever any column was set, so a
2557        // zero-row update claimed "1 affected" and a bulk update of a
2558        // nonexistent (or out-of-scope, gaps4 #37) pk answered 200 instead
2559        // of 404. An M2M-only update (no column writes) still reports the
2560        // rows whose junctions were rewritten.
2561        Ok(if any {
2562            rows_affected
2563        } else {
2564            parent_pks.len() as u64
2565        })
2566    }
2567
2568    /// Transaction-aware sibling of [`Self::delete`]. Deletes (or
2569    /// soft-deletes, for a `soft_delete` model) the rows matched by the
2570    /// accumulated WHERE on the open `tx`, so a batch of deletes commits or
2571    /// rolls back as a unit. Returns the number of rows affected.
2572    ///
2573    /// Soft-delete models stamp `deleted_at = now()` (consistent with the
2574    /// pool path / gaps #35) unless [`Self::hard_delete`] was set.
2575    pub async fn delete_in_tx(self, tx: &mut crate::db::Transaction) -> Result<u64, DynError> {
2576        self.ensure_writable()?;
2577        let soft = self.meta.soft_delete && !self.hard_delete;
2578        let where_clauses = if soft {
2579            self.live_where_clauses()
2580        } else {
2581            self.effective_where_clauses()
2582        };
2583
2584        // Build the SQL for the active backend. Soft-delete is an UPDATE
2585        // stamping `deleted_at`; a hard delete is a DELETE. Each statement
2586        // type lowers to `(sql, values)` so the execute arm is uniform.
2587        let table = crate::db::router::schema_qualified_table(&self.meta.table);
2588        let build = |is_sqlite: bool| {
2589            if soft {
2590                let mut u = Query::update();
2591                u.table(table.clone());
2592                u.value(
2593                    Alias::new("deleted_at"),
2594                    sea_query::Value::ChronoDateTimeUtc(Some(Box::new(chrono::Utc::now()))),
2595                );
2596                for cond in &where_clauses {
2597                    u.cond_where(cond.clone());
2598                }
2599                if is_sqlite {
2600                    u.build_sqlx(SqliteQueryBuilder)
2601                } else {
2602                    u.build_sqlx(PostgresQueryBuilder)
2603                }
2604            } else {
2605                let mut d = Query::delete();
2606                d.from_table(table.clone());
2607                for cond in &where_clauses {
2608                    d.cond_where(cond.clone());
2609                }
2610                if is_sqlite {
2611                    d.build_sqlx(SqliteQueryBuilder)
2612                } else {
2613                    d.build_sqlx(PostgresQueryBuilder)
2614                }
2615            }
2616        };
2617
2618        let rows_affected = match tx.backend_name() {
2619            "sqlite" => {
2620                let (sql, values) = build(true);
2621                let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
2622                sqlx::query_with(&sql, values)
2623                    .execute(&mut **inner)
2624                    .await?
2625                    .rows_affected()
2626            }
2627            _ => {
2628                let (sql, values) = build(false);
2629                let inner = tx.as_pg_mut().expect("postgres backend_name");
2630                sqlx::query_with(&sql, values)
2631                    .execute(&mut **inner)
2632                    .await?
2633                    .rows_affected()
2634            }
2635        };
2636        Ok(rows_affected)
2637    }
2638
2639    /// Terminal: PATCH semantics — update only the columns present
2640    /// in `body`. The accumulated WHERE clauses narrow the target
2641    /// row(s). Returns the number of rows affected.
2642    pub async fn update_json(
2643        self,
2644        body: &serde_json::Map<String, serde_json::Value>,
2645    ) -> Result<u64, crate::orm::write::WriteError> {
2646        self.ensure_writable()?;
2647        use crate::orm::write::WriteError;
2648
2649        // Phase -1 — strip `noform` + unauthorized-`privileged` columns
2650        // (server-managed fields the client must not overwrite).
2651        //
2652        // Gap 109: also auto-derive `slug_from` columns when the
2653        // source field is part of the update body (see
2654        // `apply_slug_from`'s update guard for why).
2655        let body_owned: serde_json::Map<String, serde_json::Value>;
2656        let body: &serde_json::Map<String, serde_json::Value> =
2657            match normalise_update_body(self.meta, body, &self.allow_privileged) {
2658                Some(owned) => {
2659                    body_owned = owned;
2660                    &body_owned
2661                }
2662                None => body,
2663            };
2664
2665        // Phase 0 — pre-DB validation. Update-shape: required-
2666        // field check only complains about EXPLICIT blanks
2667        // (preserving the partial-update contract); FK existence
2668        // + choices + M2M shape apply to whatever the body
2669        // carries.
2670        // gaps3 #54: the ORM keeps no pre-image, so an audited update reads the
2671        // rows it is about to change. Paid only by `#[umbral(audited)]` models.
2672        let audit_before = if self.meta.audited {
2673            audit_snapshot(self.meta, &self.live_where_clauses()).await
2674        } else {
2675            Vec::new()
2676        };
2677
2678        let validation_errors = crate::orm::validation::validate_on_update(self.meta, body).await;
2679        if !validation_errors.is_empty() {
2680            return Err(WriteError::Multiple {
2681                errors: validation_errors,
2682            });
2683        }
2684
2685        let mut q = Query::update();
2686        q.table(crate::db::router::schema_qualified_table(&self.meta.table));
2687        let mut any = false;
2688        for col in &self.meta.fields {
2689            if col.primary_key {
2690                continue;
2691            }
2692            // gaps3 #55: the author is SERVER-owned. Stamped unconditionally,
2693            // before the body is even consulted, so a client cannot forge it by
2694            // putting someone else's id in the payload. `auto_user_add` stays
2695            // frozen on update (it fired on create); `auto_user` refreshes.
2696            if col.auto_user {
2697                q.value(
2698                    Alias::new(&col.name),
2699                    crate::orm::write::user_for_column(col.ty),
2700                );
2701                any = true;
2702                continue;
2703            }
2704            let Some(json) = body.get(&col.name) else {
2705                // BUG-5 fix: `auto_now` columns refresh to
2706                // `Utc::now()` on every update, even if the body
2707                // doesn't mention them. `auto_now_add` columns
2708                // stay frozen (they fired on create only).
2709                if col.auto_now {
2710                    let now_value = crate::orm::write::now_for_column(col.ty);
2711                    q.value(Alias::new(&col.name), now_value);
2712                    any = true;
2713                }
2714                continue;
2715            };
2716            validate_numeric_bounds(col, json)?;
2717            // BUG-11/12/13: same wrapper-type pre-validation as
2718            // insert_json.
2719            if let (Some(fmt), Some(s)) = (col.text_format.as_deref(), json.as_str()) {
2720                if let Err(e) = crate::orm::validators::validate_text_format(fmt, s) {
2721                    return Err(WriteError::Validator {
2722                        field: col.name.clone(),
2723                        message: e.to_string(),
2724                    });
2725                }
2726            }
2727            // gaps3 #34: apply declared trim/lowercase to the incoming string
2728            // before masking / binding, so admin-form + REST writes store the
2729            // canonical value (the typed path is caller-controlled).
2730            let normalized_json = normalize_json_for_col(col, json);
2731            let json = normalized_json.as_ref().unwrap_or(json);
2732            // features #83: app-defined clean/validate hooks. Before masking, so a
2733            // hook sees the plaintext it is meant to inspect.
2734            let cleaned_json = crate::orm::cleaners::apply(&self.meta.table, &col.name, json)?;
2735            let json = cleaned_json.as_ref().unwrap_or(json);
2736            // Masked columns: seal the plaintext before binding so the dynamic
2737            // write path encrypts at rest too (audit_2 core-orm C1).
2738            let sealed = crate::orm::write::seal_masked_json(col, json)?;
2739            let sea_value = crate::orm::write::json_to_sea_value(
2740                col.ty,
2741                sealed.as_ref().unwrap_or(json),
2742                col.nullable,
2743                &col.name,
2744                fk_target_pk_sql_type(col),
2745            )?;
2746            q.value(Alias::new(&col.name), sea_value);
2747            any = true;
2748        }
2749        // Detect whether the body wants to touch any M2M
2750        // relations. If so, we'll write junctions *after* the
2751        // UPDATE — and we'll need to know the matched parent
2752        // PKs even when no regular columns are being changed.
2753        let touches_m2m = self
2754            .meta
2755            .m2m_relations
2756            .iter()
2757            .any(|r| body.contains_key(&r.field_name));
2758        if !any && !touches_m2m {
2759            return Ok(0);
2760        }
2761        let where_clauses = self.effective_where_clauses();
2762        for cond in &where_clauses {
2763            q.cond_where(cond.clone());
2764        }
2765
2766        // audit_2 core-orm #2 — run the UPDATE and the M2M junction
2767        // writes on ONE transaction so a junction failure rolls the
2768        // UPDATE back instead of leaving a half-applied write durably
2769        // committed. `bulk_post_save` fires only after commit.
2770        let mut tx = match resolve_pool_dyn(self.meta, crate::db::RouteOp::Write) {
2771            DbPool::Sqlite(pool) => crate::db::begin_sqlite(&pool).await,
2772            DbPool::Postgres(pool) => crate::db::begin_pg(&pool).await,
2773        }?;
2774
2775        // Find every parent_id matched by the filter so we can
2776        // mirror the M2M arrays into each one's junction AND fire
2777        // `bulk_post_save:<table>` with the affected ids (gaps #77).
2778        // Done BEFORE the UPDATE so:
2779        //   - a no-op (`any = false`, `touches_m2m = true`) still
2780        //     gets the M2M write, and
2781        //   - the signal payload carries the exact PK set the WHERE
2782        //     matched, even when the UPDATE itself is a no-op.
2783        // audit_2 core-orm #4 — collect on the SAME transaction with
2784        // the SAME `effective_where_clauses()` the UPDATE uses (which
2785        // adds `deleted_at IS NULL` for soft-delete models), so the
2786        // signal payload and M2M targeting never pick up soft-deleted
2787        // rows the UPDATE itself skips.
2788        let parent_pks: Vec<serde_json::Value> = match self.meta.pk_column() {
2789            Some(pk_col) => {
2790                collect_parent_pks_in_tx(self.meta, pk_col, &where_clauses, &mut tx).await?
2791            }
2792            None => Vec::new(),
2793        };
2794
2795        // audit_2 core-orm #4 — capture the UPDATE's real
2796        // `rows_affected` rather than deriving a matched-count from a
2797        // separate SELECT (which over-counted soft-deleted rows and
2798        // returned 1 for a no-match on PK-less models).
2799        let rows_affected = if any {
2800            match tx.backend_name() {
2801                "sqlite" => {
2802                    let (sql, values) = q.build_sqlx(SqliteQueryBuilder);
2803                    let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
2804                    sqlx::query_with(&sql, values)
2805                        .execute(&mut **inner)
2806                        .await
2807                        .map_err(|e| classify_or_sqlx(e, body))?
2808                        .rows_affected()
2809                }
2810                _ => {
2811                    let (sql, values) = q.build_sqlx(PostgresQueryBuilder);
2812                    let inner = tx.as_pg_mut().expect("postgres backend_name");
2813                    sqlx::query_with(&sql, values)
2814                        .execute(&mut **inner)
2815                        .await
2816                        .map_err(|e| classify_or_sqlx(e, body))?
2817                        .rows_affected()
2818                }
2819            }
2820        } else {
2821            0
2822        };
2823
2824        for pk in &parent_pks {
2825            write_m2m_junctions_in_tx(self.meta, Some(pk), body, &mut tx).await?;
2826        }
2827
2828        tx.commit().await?;
2829
2830        // gaps #77: `bulk_post_save:<table>` fires after commit on the
2831        // dynamic path. `created = false` because this is UPDATE
2832        // (matches the typed bulk-save convention from gap #38). `ids`
2833        // is whatever the effective WHERE matched.
2834        crate::signals::emit_bulk_post_save_by_table(&self.meta.table, parent_pks.clone(), false)
2835            .await;
2836
2837        // gaps3 #54: re-read by PK, not by the caller's filter — an update that
2838        // changed a column the filter matched on (`SET title='b' WHERE
2839        // title='a'`) would find nothing the second time, and the audit row would
2840        // claim the update wiped the data.
2841        if self.meta.audited && !audit_before.is_empty() {
2842            let pks: Vec<serde_json::Value> = parent_pks.clone();
2843            let after = match crate::orm::audit::pk_in_condition(self.meta, &pks) {
2844                Some(cond) => audit_snapshot(self.meta, &[cond]).await,
2845                None => Vec::new(),
2846            };
2847            let pairs = audit_pairs(self.meta, audit_before, after);
2848            crate::orm::audit::record_many(self.meta, crate::orm::audit::UPDATE, pairs).await;
2849        }
2850
2851        // audit_2 core-orm #4 — report the UPDATE's real affected-row
2852        // count. For an M2M-only update (no scalar columns changed)
2853        // there is no UPDATE, so report the number of matched rows
2854        // whose junctions were mirrored.
2855        if any {
2856            Ok(rows_affected)
2857        } else {
2858            Ok(parent_pks.len() as u64)
2859        }
2860    }
2861}
2862
2863/// The primary key of a freshly-inserted row, in its TRUE shape.
2864///
2865/// gaps4 #26 / gap #73: `insert_form` used to return a bare `i64` and hand back
2866/// `0` the moment a model's PK wasn't an integer (String / Uuid). That `0` is a
2867/// silent lie — a caller that redirects to `…/edit/{pk}` or wires a child to the
2868/// new parent would use `0` and address the wrong row (or none). This preserves
2869/// the shape so a non-integer PK round-trips out of the write.
2870#[derive(Debug, Clone, PartialEq, Eq)]
2871pub enum InsertedPk {
2872    /// An auto-increment / integer PK (`last_insert_rowid` on SQLite, `RETURNING`
2873    /// on Postgres).
2874    Int(i64),
2875    /// A String or Uuid PK, in its canonical text form.
2876    Text(String),
2877    /// No PK could be determined — an empty insert (no column survived the
2878    /// `skip` filter) or a table with no primary key.
2879    None,
2880}
2881
2882impl InsertedPk {
2883    /// The integer PK, if this row has one. `None` for a String/Uuid PK or an
2884    /// empty insert — a caller that needs an integer must decide what a
2885    /// non-integer PK means for it rather than silently reading `0`.
2886    pub fn as_i64(&self) -> Option<i64> {
2887        match self {
2888            Self::Int(n) => Some(*n),
2889            _ => None,
2890        }
2891    }
2892
2893    /// True when no PK was produced (empty insert / keyless table).
2894    pub fn is_none(&self) -> bool {
2895        matches!(self, Self::None)
2896    }
2897}
2898
2899impl std::fmt::Display for InsertedPk {
2900    /// The canonical identifier text — what a redirect URL or a child FK wants.
2901    /// `None` renders empty, matching the old `0`-means-nothing callers that
2902    /// checked for an empty string.
2903    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2904        match self {
2905            Self::Int(n) => write!(f, "{n}"),
2906            Self::Text(s) => f.write_str(s),
2907            Self::None => Ok(()),
2908        }
2909    }
2910}
2911
2912/// Is this PK column an auto-increment / integer type (vs. String / Uuid)?
2913fn pk_is_integer(col: &Column) -> bool {
2914    matches!(
2915        col.ty,
2916        SqlType::SmallInt | SqlType::Integer | SqlType::BigInt
2917    )
2918}
2919
2920/// Read a Postgres `RETURNING <pk>` cell into an [`InsertedPk`] of the right
2921/// shape: [`InsertedPk::Int`] for an integer PK, [`InsertedPk::Text`] (decoded
2922/// per the column's declared type) for a String/Uuid PK.
2923fn pg_inserted_pk(row: &sqlx::postgres::PgRow, pk: &Column) -> Result<InsertedPk, DynError> {
2924    if pk_is_integer(pk) {
2925        Ok(InsertedPk::Int(row.try_get::<i64, _>(pk.name.as_str())?))
2926    } else {
2927        Ok(InsertedPk::Text(decode_pg_to_string(row, pk)?))
2928    }
2929}
2930
2931/// Decode a non-null Postgres geometry cell to a GeoJSON `serde_json::Value`
2932/// (the dynamic REST/admin read path). Feature-gated: without `postgis` there
2933/// is no geo codec, and a geometry column can only exist via a snapshot, so a
2934/// dynamic read of one reports the missing feature loudly.
2935#[cfg(feature = "postgis")]
2936fn pg_geometry_geojson(
2937    row: &sqlx::postgres::PgRow,
2938    name: &str,
2939) -> Result<serde_json::Value, sqlx::Error> {
2940    let geom: crate::orm::gis::Geometry = row.try_get(name)?;
2941    crate::orm::gis::geometry_to_geojson(&geom).map_err(|e| sqlx::Error::Decode(e.into()))
2942}
2943
2944#[cfg(not(feature = "postgis"))]
2945fn pg_geometry_geojson(
2946    _row: &sqlx::postgres::PgRow,
2947    _name: &str,
2948) -> Result<serde_json::Value, sqlx::Error> {
2949    Err(sqlx::Error::Decode(
2950        "reading a PostGIS geometry requires the `postgis` cargo feature".into(),
2951    ))
2952}
2953
2954/// Nullable twin of [`pg_geometry_geojson`].
2955#[cfg(feature = "postgis")]
2956fn pg_geometry_geojson_opt(
2957    row: &sqlx::postgres::PgRow,
2958    name: &str,
2959) -> Result<Option<serde_json::Value>, sqlx::Error> {
2960    let geom: Option<crate::orm::gis::Geometry> = row.try_get(name)?;
2961    geom.map(|g| {
2962        crate::orm::gis::geometry_to_geojson(&g).map_err(|e| sqlx::Error::Decode(e.into()))
2963    })
2964    .transpose()
2965}
2966
2967#[cfg(not(feature = "postgis"))]
2968fn pg_geometry_geojson_opt(
2969    _row: &sqlx::postgres::PgRow,
2970    _name: &str,
2971) -> Result<Option<serde_json::Value>, sqlx::Error> {
2972    Err(sqlx::Error::Decode(
2973        "reading a PostGIS geometry requires the `postgis` cargo feature".into(),
2974    ))
2975}
2976
2977/// Decode one SQLite cell to its template-friendly string form.
2978///
2979/// Public so admin-like crates can decode rows they fetched outside
2980/// `DynQuerySet` (typed row paths, ad-hoc joins). The dispatch mirrors
2981/// `bind_form_value`'s parse step in reverse.
2982pub fn decode_to_string(
2983    row: &sqlx::sqlite::SqliteRow,
2984    col: &Column,
2985) -> Result<String, sqlx::Error> {
2986    use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
2987    use serde_json::Value;
2988    use uuid::Uuid;
2989
2990    let name = col.name.as_str();
2991    if col.nullable {
2992        return Ok(match col.ty {
2993            SqlType::SmallInt | SqlType::Integer => row
2994                .try_get::<Option<i32>, _>(name)?
2995                .map_or(String::new(), |v| v.to_string()),
2996            SqlType::BigInt => row
2997                .try_get::<Option<i64>, _>(name)?
2998                .map_or(String::new(), |v| v.to_string()),
2999            SqlType::Real => row
3000                .try_get::<Option<f32>, _>(name)?
3001                .map_or(String::new(), |v| v.to_string()),
3002            SqlType::Double => row
3003                .try_get::<Option<f64>, _>(name)?
3004                .map_or(String::new(), |v| v.to_string()),
3005            SqlType::Boolean => row
3006                .try_get::<Option<bool>, _>(name)?
3007                .map_or(String::new(), |v| {
3008                    if v { "true" } else { "false" }.to_string()
3009                }),
3010            SqlType::Text => row.try_get::<Option<String>, _>(name)?.unwrap_or_default(),
3011            SqlType::Date => row
3012                .try_get::<Option<NaiveDate>, _>(name)?
3013                .map_or(String::new(), |v| v.to_string()),
3014            SqlType::Time => row
3015                .try_get::<Option<NaiveTime>, _>(name)?
3016                .map_or(String::new(), |v| v.to_string()),
3017            SqlType::Timestamptz => row
3018                .try_get::<Option<DateTime<Utc>>, _>(name)?
3019                .map_or(String::new(), |v| v.to_rfc3339()),
3020            SqlType::Timestamp => row
3021                .try_get::<Option<NaiveDateTime>, _>(name)?
3022                .map_or(String::new(), |v| v.to_string()),
3023            SqlType::Uuid => row
3024                .try_get::<Option<Uuid>, _>(name)?
3025                .map_or(String::new(), |v| v.to_string()),
3026            SqlType::Json => row
3027                .try_get::<Option<Value>, _>(name)?
3028                .map_or(String::new(), |v| v.to_string()),
3029            SqlType::Array(_) => panic_array_unsupported(&col.name),
3030            SqlType::Inet
3031            | SqlType::Cidr
3032            | SqlType::MacAddr
3033            | SqlType::Xml
3034            | SqlType::Ltree
3035            | SqlType::Bit
3036            | SqlType::FullText => panic_pg_only_unsupported(&col.name),
3037            // PK lift (review #3): FK columns to a String/Uuid-PK target
3038            // store TEXT/UUID, not BIGINT — decode by the target PK type so
3039            // the admin display path doesn't fail on a non-i64 FK.
3040            SqlType::ForeignKey => match fk_target_pk_sql_type(col) {
3041                Some(SqlType::Text) => row.try_get::<Option<String>, _>(name)?.unwrap_or_default(),
3042                Some(SqlType::Uuid) => row
3043                    .try_get::<Option<Uuid>, _>(name)?
3044                    .map_or(String::new(), |v| v.to_string()),
3045                _ => row
3046                    .try_get::<Option<i64>, _>(name)?
3047                    .map_or(String::new(), |v| v.to_string()),
3048            },
3049            SqlType::Bytes => row
3050                .try_get::<Option<Vec<u8>>, _>(name)?
3051                .map_or(String::new(), |b| hex_encode(&b)),
3052            SqlType::Decimal
3053            | SqlType::BigDecimal
3054            | SqlType::Geometry(_)
3055            | SqlType::Geography(_)
3056            | SqlType::DecimalN(_) => panic_pg_only_unsupported(&col.name),
3057        });
3058    }
3059    Ok(match col.ty {
3060        SqlType::SmallInt | SqlType::Integer => row.try_get::<i32, _>(name)?.to_string(),
3061        SqlType::BigInt => row.try_get::<i64, _>(name)?.to_string(),
3062        SqlType::Real => row.try_get::<f32, _>(name)?.to_string(),
3063        SqlType::Double => row.try_get::<f64, _>(name)?.to_string(),
3064        SqlType::Boolean => if row.try_get::<bool, _>(name)? {
3065            "true"
3066        } else {
3067            "false"
3068        }
3069        .to_string(),
3070        SqlType::Text => row.try_get::<String, _>(name)?,
3071        SqlType::Date => row.try_get::<NaiveDate, _>(name)?.to_string(),
3072        SqlType::Time => row.try_get::<NaiveTime, _>(name)?.to_string(),
3073        SqlType::Timestamptz => row.try_get::<DateTime<Utc>, _>(name)?.to_rfc3339(),
3074        SqlType::Timestamp => row.try_get::<NaiveDateTime, _>(name)?.to_string(),
3075        SqlType::Uuid => row.try_get::<Uuid, _>(name)?.to_string(),
3076        SqlType::Json => row.try_get::<Value, _>(name)?.to_string(),
3077        SqlType::Array(_) => panic_array_unsupported(&col.name),
3078        SqlType::Inet
3079        | SqlType::Cidr
3080        | SqlType::MacAddr
3081        | SqlType::Xml
3082        | SqlType::Ltree
3083        | SqlType::Bit
3084        | SqlType::FullText => panic_pg_only_unsupported(&col.name),
3085        SqlType::ForeignKey => match fk_target_pk_sql_type(col) {
3086            Some(SqlType::Text) => row.try_get::<String, _>(name)?,
3087            Some(SqlType::Uuid) => row.try_get::<Uuid, _>(name)?.to_string(),
3088            _ => row.try_get::<i64, _>(name)?.to_string(),
3089        },
3090        SqlType::Bytes => hex_encode(&row.try_get::<Vec<u8>, _>(name)?),
3091        SqlType::Decimal
3092        | SqlType::BigDecimal
3093        | SqlType::DecimalN(_)
3094        | SqlType::Geometry(_)
3095        | SqlType::Geography(_) => panic_pg_only_unsupported(&col.name),
3096    })
3097}
3098
3099/// Decode one Postgres cell to its template-friendly string form.
3100///
3101/// Decode a Postgres text column, tolerating a value whose column is actually a
3102/// NATIVE ENUM type (`CREATE TYPE ... AS ENUM`). umbral models a closed set as a
3103/// `TEXT`-backed choices column, but a raw / un-migrated source DB stores it as a
3104/// native enum; sqlx's checked `try_get::<String>` rejects the user-defined enum
3105/// OID even though the wire value IS its label text. When the checked decode
3106/// fails, retry with the unchecked path (which skips the type-compatibility
3107/// check and decodes the label bytes) so the choices column reads its label. A
3108/// genuine `TEXT` column always takes the checked path. Closes features #89b.
3109fn pg_text_or_enum(row: &sqlx::postgres::PgRow, name: &str) -> Result<Option<String>, sqlx::Error> {
3110    match row.try_get::<Option<String>, _>(name) {
3111        Ok(v) => Ok(v),
3112        // A native enum OID fails the compatibility check; its bytes are the
3113        // label, so the unchecked decode reads it correctly.
3114        Err(_) => row.try_get_unchecked::<Option<String>, _>(name),
3115    }
3116}
3117
3118/// Sibling of [`decode_to_string`] for the Postgres backend. Same
3119/// dispatch table on `SqlType`; the only difference is the executor
3120/// type (`PgRow` instead of `SqliteRow`) and a handful of types that
3121/// Postgres binds differently — `i32` for SmallInt instead of SQLite's
3122/// affinity-coerced `i32`, native bool, native chrono / uuid /
3123/// serde_json::Value. Array / Inet / Cidr / MacAddr / FullText all
3124/// live on Postgres natively but are decoded as their JSON string
3125/// shape here (the admin templates only need a printable form).
3126pub fn decode_pg_to_string(
3127    row: &sqlx::postgres::PgRow,
3128    col: &Column,
3129) -> Result<String, sqlx::Error> {
3130    use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
3131    use serde_json::Value;
3132    use uuid::Uuid;
3133
3134    let name = col.name.as_str();
3135    if col.nullable {
3136        return Ok(match col.ty {
3137            SqlType::SmallInt => row
3138                .try_get::<Option<i16>, _>(name)?
3139                .map_or(String::new(), |v| v.to_string()),
3140            SqlType::Integer => row
3141                .try_get::<Option<i32>, _>(name)?
3142                .map_or(String::new(), |v| v.to_string()),
3143            SqlType::BigInt => row
3144                .try_get::<Option<i64>, _>(name)?
3145                .map_or(String::new(), |v| v.to_string()),
3146            SqlType::Real => row
3147                .try_get::<Option<f32>, _>(name)?
3148                .map_or(String::new(), |v| v.to_string()),
3149            SqlType::Double => row
3150                .try_get::<Option<f64>, _>(name)?
3151                .map_or(String::new(), |v| v.to_string()),
3152            SqlType::Boolean => row
3153                .try_get::<Option<bool>, _>(name)?
3154                .map_or(String::new(), |v| {
3155                    if v { "true" } else { "false" }.to_string()
3156                }),
3157            SqlType::Text => pg_text_or_enum(row, name)?.unwrap_or_default(),
3158            SqlType::Date => row
3159                .try_get::<Option<NaiveDate>, _>(name)?
3160                .map_or(String::new(), |v| v.to_string()),
3161            SqlType::Time => row
3162                .try_get::<Option<NaiveTime>, _>(name)?
3163                .map_or(String::new(), |v| v.to_string()),
3164            SqlType::Timestamptz => row
3165                .try_get::<Option<DateTime<Utc>>, _>(name)?
3166                .map_or(String::new(), |v| v.to_rfc3339()),
3167            SqlType::Timestamp => row
3168                .try_get::<Option<NaiveDateTime>, _>(name)?
3169                .map_or(String::new(), |v| v.to_string()),
3170            SqlType::Uuid => row
3171                .try_get::<Option<Uuid>, _>(name)?
3172                .map_or(String::new(), |v| v.to_string()),
3173            SqlType::Json => row
3174                .try_get::<Option<Value>, _>(name)?
3175                .map_or(String::new(), |v| v.to_string()),
3176            // Array / network / FullText decode as their printable forms.
3177            // Pg drivers hand back typed Vec / IpNetwork / etc.; we lift
3178            // through a best-effort string decode for now since the admin
3179            // only needs a glance. Decode failures fall through to empty
3180            // string (the admin still renders something useful).
3181            SqlType::Array(_)
3182            | SqlType::Inet
3183            | SqlType::Cidr
3184            | SqlType::MacAddr
3185            | SqlType::Xml
3186            | SqlType::Ltree
3187            | SqlType::Bit
3188            | SqlType::FullText => row
3189                .try_get::<Option<String>, _>(name)
3190                .ok()
3191                .flatten()
3192                .unwrap_or_default(),
3193            // PK lift (review #3): FK to a String/Uuid-PK target is a
3194            // TEXT/native-uuid column on PG — decode by the target PK type.
3195            SqlType::ForeignKey => match fk_target_pk_sql_type(col) {
3196                Some(SqlType::Text) => row.try_get::<Option<String>, _>(name)?.unwrap_or_default(),
3197                Some(SqlType::Uuid) => row
3198                    .try_get::<Option<Uuid>, _>(name)?
3199                    .map_or(String::new(), |v| v.to_string()),
3200                _ => row
3201                    .try_get::<Option<i64>, _>(name)?
3202                    .map_or(String::new(), |v| v.to_string()),
3203            },
3204            SqlType::Bytes => row
3205                .try_get::<Option<Vec<u8>>, _>(name)?
3206                .map_or(String::new(), |b| hex_encode(&b)),
3207            SqlType::Decimal | SqlType::DecimalN(_) => row
3208                .try_get::<Option<rust_decimal::Decimal>, _>(name)?
3209                .map_or(String::new(), |v| v.to_string()),
3210            SqlType::BigDecimal => row
3211                .try_get::<Option<bigdecimal::BigDecimal>, _>(name)?
3212                .map_or(String::new(), |v| v.to_string()),
3213            SqlType::Geometry(_) | SqlType::Geography(_) => {
3214                pg_geometry_geojson_opt(row, name)?.map_or(String::new(), |v| v.to_string())
3215            }
3216        });
3217    }
3218    Ok(match col.ty {
3219        SqlType::SmallInt => row.try_get::<i16, _>(name)?.to_string(),
3220        SqlType::Integer => row.try_get::<i32, _>(name)?.to_string(),
3221        SqlType::BigInt => row.try_get::<i64, _>(name)?.to_string(),
3222        SqlType::Real => row.try_get::<f32, _>(name)?.to_string(),
3223        SqlType::Double => row.try_get::<f64, _>(name)?.to_string(),
3224        SqlType::Boolean => if row.try_get::<bool, _>(name)? {
3225            "true"
3226        } else {
3227            "false"
3228        }
3229        .to_string(),
3230        SqlType::Text => pg_text_or_enum(row, name)?.unwrap_or_default(),
3231        SqlType::Date => row.try_get::<NaiveDate, _>(name)?.to_string(),
3232        SqlType::Time => row.try_get::<NaiveTime, _>(name)?.to_string(),
3233        SqlType::Timestamptz => row.try_get::<DateTime<Utc>, _>(name)?.to_rfc3339(),
3234        SqlType::Timestamp => row.try_get::<NaiveDateTime, _>(name)?.to_string(),
3235        SqlType::Uuid => row.try_get::<Uuid, _>(name)?.to_string(),
3236        SqlType::Json => row.try_get::<Value, _>(name)?.to_string(),
3237        // Same as the nullable branch: lift through best-effort string.
3238        SqlType::Array(_)
3239        | SqlType::Inet
3240        | SqlType::Cidr
3241        | SqlType::MacAddr
3242        | SqlType::Xml
3243        | SqlType::Ltree
3244        | SqlType::Bit
3245        | SqlType::FullText => row.try_get::<String, _>(name).unwrap_or_default(),
3246        SqlType::ForeignKey => match fk_target_pk_sql_type(col) {
3247            Some(SqlType::Text) => row.try_get::<String, _>(name)?,
3248            Some(SqlType::Uuid) => row.try_get::<Uuid, _>(name)?.to_string(),
3249            _ => row.try_get::<i64, _>(name)?.to_string(),
3250        },
3251        SqlType::Bytes => hex_encode(&row.try_get::<Vec<u8>, _>(name)?),
3252        SqlType::Decimal | SqlType::DecimalN(_) => {
3253            row.try_get::<rust_decimal::Decimal, _>(name)?.to_string()
3254        }
3255        SqlType::BigDecimal => row.try_get::<bigdecimal::BigDecimal, _>(name)?.to_string(),
3256        SqlType::Geometry(_) | SqlType::Geography(_) => pg_geometry_geojson(row, name)?.to_string(),
3257    })
3258}
3259
3260/// Decode one SQLite cell to a `serde_json::Value` that preserves the
3261/// column's JSON shape (numbers stay numbers, booleans stay booleans,
3262/// dates render as ISO strings, JSON columns nest verbatim, NULLs
3263/// become `Value::Null`). This is the row → JSON converter the REST
3264/// plugin's auto-CRUD list / detail handlers feed straight into their
3265/// HTTP body.
3266/// Alias-aware sibling of [`decode_to_json`] — same decode logic but
3267/// pulls from a different column name (the aliased name in a JOIN
3268/// SELECT). Used by `QuerySet::join_related` to read child columns
3269/// out of a JOIN row where every child column is exposed as
3270/// `<field>__<col>`. Cheap clone of `Column` because the existing
3271/// decoder is keyed off `col.name.as_str()`.
3272pub fn decode_to_json_aliased(
3273    row: &sqlx::sqlite::SqliteRow,
3274    col: &Column,
3275    alias: &str,
3276) -> Result<serde_json::Value, sqlx::Error> {
3277    let mut aliased = col.clone();
3278    aliased.name = alias.to_string();
3279    decode_to_json(row, &aliased)
3280}
3281
3282/// Postgres counterpart to [`decode_to_json_aliased`].
3283pub fn decode_pg_to_json_aliased(
3284    row: &sqlx::postgres::PgRow,
3285    col: &Column,
3286    alias: &str,
3287) -> Result<serde_json::Value, sqlx::Error> {
3288    let mut aliased = col.clone();
3289    aliased.name = alias.to_string();
3290    decode_pg_to_json(row, &aliased)
3291}
3292
3293/// PK lift Pass A — when `col` is an FK column (`SqlType::ForeignKey`)
3294/// pointing at a model whose PK is a `String` / `Uuid` (not the
3295/// default `i64`), the decoder needs to bind as `String` instead of
3296/// `i64` or sqlx errors with "Rust type i64 not compatible with SQL
3297/// type TEXT".
3298///
3299/// Looks the target table up in the model registry and reads its
3300/// PK column's `SqlType`. Returns `None` when:
3301///   - `col` isn't an FK (caller falls back to the normal arm),
3302///   - the FK has no target (defensive — shouldn't happen in
3303///     practice since the macro always sets `fk_target` on FK
3304///     columns),
3305///   - the target isn't in the registry (only possible when an
3306///     internal call site fires before `App::build()` finishes
3307///     wiring plugins).
3308///
3309/// PK lift Pass E — O(1) lookup via the `pk_meta_for_table` cache
3310/// (was O(n) `Vec<ModelMeta>` clone + linear scan per call). The
3311/// cache initialises lazily on first post-`App::build` call and
3312/// serves from a `HashMap` for every subsequent lookup. In a hot
3313/// decode loop (e.g. 1000 rows × 50 columns × per-FK decode) this
3314/// drops the per-row registry-walk cost from a few milliseconds
3315/// to a single hashmap probe.
3316fn fk_target_pk_sql_type(col: &Column) -> Option<SqlType> {
3317    if !matches!(col.ty, SqlType::ForeignKey) {
3318        return None;
3319    }
3320    let target_table = col.fk_target.as_deref()?;
3321    crate::migrate::pk_meta_for_table(target_table).map(|(_, ty)| ty)
3322}
3323
3324pub fn decode_to_json(
3325    row: &sqlx::sqlite::SqliteRow,
3326    col: &Column,
3327) -> Result<serde_json::Value, sqlx::Error> {
3328    use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
3329    use serde_json::Value;
3330    use uuid::Uuid;
3331
3332    let name = col.name.as_str();
3333    if col.nullable {
3334        return Ok(match col.ty {
3335            SqlType::SmallInt | SqlType::Integer => row
3336                .try_get::<Option<i32>, _>(name)?
3337                .map_or(Value::Null, Value::from),
3338            SqlType::BigInt => row
3339                .try_get::<Option<i64>, _>(name)?
3340                .map_or(Value::Null, Value::from),
3341            SqlType::Real => row
3342                .try_get::<Option<f32>, _>(name)?
3343                .map_or(Value::Null, |v| Value::from(v as f64)),
3344            SqlType::Double => row
3345                .try_get::<Option<f64>, _>(name)?
3346                .map_or(Value::Null, Value::from),
3347            SqlType::Boolean => row
3348                .try_get::<Option<bool>, _>(name)?
3349                .map_or(Value::Null, Value::from),
3350            SqlType::Text => row
3351                .try_get::<Option<String>, _>(name)?
3352                .map_or(Value::Null, Value::from),
3353            SqlType::Date => row
3354                .try_get::<Option<NaiveDate>, _>(name)?
3355                .map_or(Value::Null, |v| Value::from(v.to_string())),
3356            SqlType::Time => row
3357                .try_get::<Option<NaiveTime>, _>(name)?
3358                .map_or(Value::Null, |v| Value::from(v.to_string())),
3359            SqlType::Timestamptz => row
3360                .try_get::<Option<DateTime<Utc>>, _>(name)?
3361                .map_or(Value::Null, |v| Value::from(v.to_rfc3339())),
3362            SqlType::Timestamp => row
3363                .try_get::<Option<NaiveDateTime>, _>(name)?
3364                .map_or(Value::Null, |v| Value::from(v.to_string())),
3365            SqlType::Uuid => row
3366                .try_get::<Option<Uuid>, _>(name)?
3367                .map_or(Value::Null, |v| Value::from(v.to_string())),
3368            SqlType::Json => row
3369                .try_get::<Option<Value>, _>(name)?
3370                .unwrap_or(Value::Null),
3371            SqlType::Array(_) => panic_array_unsupported(&col.name),
3372            SqlType::Inet
3373            | SqlType::Cidr
3374            | SqlType::MacAddr
3375            | SqlType::Xml
3376            | SqlType::Ltree
3377            | SqlType::Bit
3378            | SqlType::FullText => panic_pg_only_unsupported(&col.name),
3379            // PK lift Pass A: FK columns that target a String /
3380            // Uuid PK store their values as TEXT, not BIGINT. Probe
3381            // the target meta to pick the right Rust type for the
3382            // bind.
3383            SqlType::ForeignKey => match fk_target_pk_sql_type(col) {
3384                Some(SqlType::Text) => row
3385                    .try_get::<Option<String>, _>(name)?
3386                    .map_or(Value::Null, Value::from),
3387                Some(SqlType::Uuid) => row
3388                    .try_get::<Option<Uuid>, _>(name)?
3389                    .map_or(Value::Null, |v| Value::from(v.to_string())),
3390                _ => row
3391                    .try_get::<Option<i64>, _>(name)?
3392                    .map_or(Value::Null, Value::from),
3393            },
3394            SqlType::Bytes => row
3395                .try_get::<Option<Vec<u8>>, _>(name)?
3396                .map_or(Value::Null, |b| bytes_to_json(&b)),
3397            SqlType::Decimal
3398            | SqlType::BigDecimal
3399            | SqlType::Geometry(_)
3400            | SqlType::Geography(_)
3401            | SqlType::DecimalN(_) => panic_pg_only_unsupported(&col.name),
3402        });
3403    }
3404    Ok(match col.ty {
3405        SqlType::SmallInt | SqlType::Integer => Value::from(row.try_get::<i32, _>(name)?),
3406        SqlType::BigInt => Value::from(row.try_get::<i64, _>(name)?),
3407        SqlType::Real => Value::from(row.try_get::<f32, _>(name)? as f64),
3408        SqlType::Double => Value::from(row.try_get::<f64, _>(name)?),
3409        SqlType::Boolean => Value::from(row.try_get::<bool, _>(name)?),
3410        SqlType::Text => Value::from(row.try_get::<String, _>(name)?),
3411        SqlType::Date => Value::from(row.try_get::<NaiveDate, _>(name)?.to_string()),
3412        SqlType::Time => Value::from(row.try_get::<NaiveTime, _>(name)?.to_string()),
3413        SqlType::Timestamptz => Value::from(row.try_get::<DateTime<Utc>, _>(name)?.to_rfc3339()),
3414        SqlType::Timestamp => Value::from(row.try_get::<NaiveDateTime, _>(name)?.to_string()),
3415        SqlType::Uuid => Value::from(row.try_get::<Uuid, _>(name)?.to_string()),
3416        SqlType::Json => row.try_get::<Value, _>(name)?,
3417        SqlType::Array(_) => panic_array_unsupported(&col.name),
3418        SqlType::Inet
3419        | SqlType::Cidr
3420        | SqlType::MacAddr
3421        | SqlType::Xml
3422        | SqlType::Ltree
3423        | SqlType::Bit
3424        | SqlType::FullText => panic_pg_only_unsupported(&col.name),
3425        // PK lift Pass A: see the nullable arm above for the same
3426        // String/Uuid target dispatch.
3427        SqlType::ForeignKey => match fk_target_pk_sql_type(col) {
3428            Some(SqlType::Text) => Value::from(row.try_get::<String, _>(name)?),
3429            Some(SqlType::Uuid) => Value::from(row.try_get::<Uuid, _>(name)?.to_string()),
3430            _ => Value::from(row.try_get::<i64, _>(name)?),
3431        },
3432        SqlType::Bytes => bytes_to_json(&row.try_get::<Vec<u8>, _>(name)?),
3433        SqlType::Decimal
3434        | SqlType::BigDecimal
3435        | SqlType::DecimalN(_)
3436        | SqlType::Geometry(_)
3437        | SqlType::Geography(_) => panic_pg_only_unsupported(&col.name),
3438    })
3439}
3440
3441/// Postgres sibling of [`decode_to_json`]. Same dispatch table; the
3442/// only difference is the executor type (`PgRow`) and the i16 path
3443/// for SmallInt (PG binds i16, SQLite affinity-coerces to i32).
3444pub fn decode_pg_to_json(
3445    row: &sqlx::postgres::PgRow,
3446    col: &Column,
3447) -> Result<serde_json::Value, sqlx::Error> {
3448    use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
3449    use serde_json::Value;
3450    use uuid::Uuid;
3451
3452    let name = col.name.as_str();
3453    if col.nullable {
3454        return Ok(match col.ty {
3455            SqlType::SmallInt => row
3456                .try_get::<Option<i16>, _>(name)?
3457                .map_or(Value::Null, Value::from),
3458            SqlType::Integer => row
3459                .try_get::<Option<i32>, _>(name)?
3460                .map_or(Value::Null, Value::from),
3461            SqlType::BigInt => row
3462                .try_get::<Option<i64>, _>(name)?
3463                .map_or(Value::Null, Value::from),
3464            SqlType::Real => row
3465                .try_get::<Option<f32>, _>(name)?
3466                .map_or(Value::Null, |v| Value::from(v as f64)),
3467            SqlType::Double => row
3468                .try_get::<Option<f64>, _>(name)?
3469                .map_or(Value::Null, Value::from),
3470            SqlType::Boolean => row
3471                .try_get::<Option<bool>, _>(name)?
3472                .map_or(Value::Null, Value::from),
3473            SqlType::Text => pg_text_or_enum(row, name)?.map_or(Value::Null, Value::from),
3474            SqlType::Date => row
3475                .try_get::<Option<NaiveDate>, _>(name)?
3476                .map_or(Value::Null, |v| Value::from(v.to_string())),
3477            SqlType::Time => row
3478                .try_get::<Option<NaiveTime>, _>(name)?
3479                .map_or(Value::Null, |v| Value::from(v.to_string())),
3480            SqlType::Timestamptz => row
3481                .try_get::<Option<DateTime<Utc>>, _>(name)?
3482                .map_or(Value::Null, |v| Value::from(v.to_rfc3339())),
3483            SqlType::Timestamp => row
3484                .try_get::<Option<NaiveDateTime>, _>(name)?
3485                .map_or(Value::Null, |v| Value::from(v.to_string())),
3486            SqlType::Uuid => row
3487                .try_get::<Option<Uuid>, _>(name)?
3488                .map_or(Value::Null, |v| Value::from(v.to_string())),
3489            SqlType::Json => row
3490                .try_get::<Option<Value>, _>(name)?
3491                .unwrap_or(Value::Null),
3492            SqlType::Array(_)
3493            | SqlType::Inet
3494            | SqlType::Cidr
3495            | SqlType::MacAddr
3496            | SqlType::Xml
3497            | SqlType::Ltree
3498            | SqlType::Bit
3499            | SqlType::FullText => row
3500                .try_get::<Option<String>, _>(name)
3501                .ok()
3502                .flatten()
3503                .map_or(Value::Null, Value::from),
3504            // PK lift Pass A: see the SQLite path for the same
3505            // String/Uuid target dispatch.
3506            SqlType::ForeignKey => match fk_target_pk_sql_type(col) {
3507                Some(SqlType::Text) => row
3508                    .try_get::<Option<String>, _>(name)?
3509                    .map_or(Value::Null, Value::from),
3510                Some(SqlType::Uuid) => row
3511                    .try_get::<Option<Uuid>, _>(name)?
3512                    .map_or(Value::Null, |v| Value::from(v.to_string())),
3513                _ => row
3514                    .try_get::<Option<i64>, _>(name)?
3515                    .map_or(Value::Null, Value::from),
3516            },
3517            SqlType::Bytes => row
3518                .try_get::<Option<Vec<u8>>, _>(name)?
3519                .map_or(Value::Null, |b| bytes_to_json(&b)),
3520            SqlType::Decimal | SqlType::DecimalN(_) => row
3521                .try_get::<Option<rust_decimal::Decimal>, _>(name)?
3522                .map_or(Value::Null, |v| Value::from(v.to_string())),
3523            SqlType::BigDecimal => row
3524                .try_get::<Option<bigdecimal::BigDecimal>, _>(name)?
3525                .map_or(Value::Null, |v| Value::from(v.to_string())),
3526            SqlType::Geometry(_) | SqlType::Geography(_) => {
3527                pg_geometry_geojson_opt(row, name)?.unwrap_or(Value::Null)
3528            }
3529        });
3530    }
3531    Ok(match col.ty {
3532        SqlType::SmallInt => Value::from(row.try_get::<i16, _>(name)?),
3533        SqlType::Integer => Value::from(row.try_get::<i32, _>(name)?),
3534        SqlType::BigInt => Value::from(row.try_get::<i64, _>(name)?),
3535        SqlType::Real => Value::from(row.try_get::<f32, _>(name)? as f64),
3536        SqlType::Double => Value::from(row.try_get::<f64, _>(name)?),
3537        SqlType::Boolean => Value::from(row.try_get::<bool, _>(name)?),
3538        SqlType::Text => pg_text_or_enum(row, name)?.map_or(Value::Null, Value::from),
3539        SqlType::Date => Value::from(row.try_get::<NaiveDate, _>(name)?.to_string()),
3540        SqlType::Time => Value::from(row.try_get::<NaiveTime, _>(name)?.to_string()),
3541        SqlType::Timestamptz => Value::from(row.try_get::<DateTime<Utc>, _>(name)?.to_rfc3339()),
3542        SqlType::Timestamp => Value::from(row.try_get::<NaiveDateTime, _>(name)?.to_string()),
3543        SqlType::Uuid => Value::from(row.try_get::<Uuid, _>(name)?.to_string()),
3544        SqlType::Json => row.try_get::<Value, _>(name)?,
3545        SqlType::Array(_)
3546        | SqlType::Inet
3547        | SqlType::Cidr
3548        | SqlType::MacAddr
3549        | SqlType::Xml
3550        | SqlType::Ltree
3551        | SqlType::Bit
3552        | SqlType::FullText => row
3553            .try_get::<String, _>(name)
3554            .map(Value::from)
3555            .unwrap_or(Value::Null),
3556        // PK lift Pass A: FK columns dispatch on their target's PK
3557        // type (i64 / String / Uuid).
3558        SqlType::ForeignKey => match fk_target_pk_sql_type(col) {
3559            Some(SqlType::Text) => Value::from(row.try_get::<String, _>(name)?),
3560            Some(SqlType::Uuid) => Value::from(row.try_get::<Uuid, _>(name)?.to_string()),
3561            _ => Value::from(row.try_get::<i64, _>(name)?),
3562        },
3563        SqlType::Bytes => bytes_to_json(&row.try_get::<Vec<u8>, _>(name)?),
3564        SqlType::Decimal | SqlType::DecimalN(_) => {
3565            Value::from(row.try_get::<rust_decimal::Decimal, _>(name)?.to_string())
3566        }
3567        SqlType::BigDecimal => {
3568            Value::from(row.try_get::<bigdecimal::BigDecimal, _>(name)?.to_string())
3569        }
3570        SqlType::Geometry(_) | SqlType::Geography(_) => pg_geometry_geojson(row, name)?,
3571    })
3572}
3573
3574/// Apply a column's declared `#[umbral(trim)]` / `#[umbral(lowercase)]`
3575/// normalization to a string value (gaps3 #34). `trim` runs first (so a
3576/// then-empty value falls into the empty/NULL branch), then `lowercase`.
3577/// No-op when neither flag is set. Only string columns ever carry these flags
3578/// (the derive rejects them elsewhere), so this is unreachable for non-strings.
3579fn normalize_str(col: &Column, s: &str) -> String {
3580    let trimmed = if col.trim { s.trim() } else { s };
3581    if col.lowercase {
3582        trimmed.to_lowercase()
3583    } else {
3584        trimmed.to_string()
3585    }
3586}
3587
3588/// The JSON-path companion to [`normalize_str`]: returns a normalized owned
3589/// `String` value when the column declares trim/lowercase AND the incoming
3590/// JSON is a string, else `None` (the caller keeps the original borrow). A
3591/// non-string JSON value on a normalized column is left untouched — the type
3592/// mismatch surfaces later in `json_to_sea_value` as it would today.
3593fn normalize_json_for_col(col: &Column, v: &serde_json::Value) -> Option<serde_json::Value> {
3594    if !(col.trim || col.lowercase) {
3595        return None;
3596    }
3597    let s = v.as_str()?;
3598    Some(serde_json::Value::String(normalize_str(col, s)))
3599}
3600
3601/// Convert one form-submitted string into a `SeaValue` ready for
3602/// binding. Handles the "empty + nullable" case explicitly so a blank
3603/// form field produces SQL NULL instead of an empty-string mismatch
3604/// for numeric columns. The rest of the conversion delegates to
3605/// [`json_to_sea_value`] by wrapping the value as `JsonValue::String`,
3606/// which already understands "true"/"false" booleans and RFC3339
3607/// timestamps the HTML form layer hands in.
3608fn form_str_to_sea_value(col: &Column, raw: &str) -> Result<SeaValue, WriteError> {
3609    // gaps3 #34: apply declared trim/lowercase before any parsing, so the
3610    // empty/NULL check below sees the post-trim value (a field that trims to
3611    // empty is treated as blank).
3612    let normalized = normalize_str(col, raw);
3613    let raw = normalized.as_str();
3614    if raw.is_empty() {
3615        if col.ty == SqlType::Boolean {
3616            // Unchecked checkbox = false, not NULL.
3617            return Ok(SeaValue::Bool(Some(false)));
3618        }
3619        if col.nullable {
3620            return Ok(null_for(col.ty));
3621        }
3622        return Err(WriteError::RequiredFieldMissing {
3623            field: col.name.clone(),
3624        });
3625    }
3626    // #116: JSON / Array columns must PARSE the form string so the
3627    // typed input becomes a real JsonValue::Object / Array / etc.
3628    // Pre-fix every form value was wrapped as JsonValue::String —
3629    // typing `{"k": 1}` into a JSON textarea stored the literal
3630    // text `"\"{\\\"k\\\": 1}\""` rather than the object.
3631    //
3632    // serde_json::from_str rejects unbalanced braces / missing
3633    // quotes / etc.; we surface that as a WriteError::Validator so
3634    // the admin's inline error renders "Not valid JSON: <reason>"
3635    // instead of either silently storing junk OR crashing the
3636    // write with a raw sqlx Protocol error downstream.
3637    if matches!(col.ty, SqlType::Json | SqlType::Array(_)) {
3638        let parsed: serde_json::Value =
3639            serde_json::from_str(raw).map_err(|e| WriteError::Validator {
3640                field: col.name.clone(),
3641                message: format!("Not valid JSON: {e}"),
3642            })?;
3643        return json_to_sea_value(col.ty, &parsed, col.nullable, &col.name, None);
3644    }
3645    if matches!(col.ty, SqlType::ForeignKey) {
3646        return match fk_target_pk_sql_type(col) {
3647            Some(SqlType::Text) => Ok(SeaValue::String(Some(Box::new(raw.to_string())))),
3648            Some(SqlType::Uuid) => uuid::Uuid::parse_str(raw)
3649                .map(|v| SeaValue::Uuid(Some(Box::new(v))))
3650                .map_err(|_| WriteError::TypeMismatch {
3651                    field: col.name.clone(),
3652                    expected: SqlType::Uuid,
3653                    got: raw.to_string(),
3654                }),
3655            _ => raw
3656                .parse::<i64>()
3657                .map(|v| SeaValue::BigInt(Some(v)))
3658                .map_err(|_| WriteError::TypeMismatch {
3659                    field: col.name.clone(),
3660                    expected: SqlType::BigInt,
3661                    got: raw.to_string(),
3662                }),
3663        };
3664    }
3665    let json = serde_json::Value::String(raw.to_string());
3666    // Masked columns: seal the plaintext form value before binding, so the
3667    // admin form-submit path encrypts at rest too (audit_2 core-orm C1).
3668    if let Some(sealed) = crate::orm::write::seal_masked_json(col, &json)? {
3669        return json_to_sea_value(col.ty, &sealed, col.nullable, &col.name, None);
3670    }
3671    json_to_sea_value(col.ty, &json, col.nullable, &col.name, None)
3672}
3673
3674/// Hex-encode a byte slice, lowercase, no `0x` prefix. The
3675/// human-readable rendering for `SqlType::Bytes` columns when the
3676/// admin / debug tooling asks for a string form.
3677fn hex_encode(bytes: &[u8]) -> String {
3678    let mut out = String::with_capacity(bytes.len() * 2);
3679    for b in bytes {
3680        out.push_str(&format!("{b:02x}"));
3681    }
3682    out
3683}
3684
3685/// Render bytes as a JSON array of u8 numbers. Symmetric with the
3686/// `json_to_sea_value` path that accepts the same shape on input.
3687fn bytes_to_json(bytes: &[u8]) -> serde_json::Value {
3688    serde_json::Value::Array(bytes.iter().map(|b| serde_json::Value::from(*b)).collect())
3689}
3690
3691fn panic_array_unsupported(column: &str) -> ! {
3692    panic!(
3693        "DynQuerySet: column `{column}` is a Postgres-only Array; the \
3694         field/backend system check should have failed boot."
3695    )
3696}
3697
3698fn panic_pg_only_unsupported(column: &str) -> ! {
3699    panic!(
3700        "DynQuerySet: column `{column}` is a Postgres-only network type \
3701         (Inet/Cidr/MacAddr); the field/backend system check should \
3702         have failed boot."
3703    )
3704}
3705
3706/// Classify a sqlx error from an `insert_json` / `update_json`
3707/// SQL execution into a structured `WriteError`. Constraint
3708/// failures are body-aware (the original JSON value is threaded
3709/// into the message); unknown errors fall through to
3710/// `WriteError::Sqlx` and the REST layer renders them as a 500.
3711fn classify_or_sqlx(
3712    e: sqlx::Error,
3713    body: &serde_json::Map<String, serde_json::Value>,
3714) -> crate::orm::write::WriteError {
3715    if let Some(classified) = crate::orm::validation::classify_sql_error(&e, body) {
3716        return classified;
3717    }
3718    crate::orm::write::WriteError::Sqlx(e)
3719}
3720
3721fn validate_numeric_bounds(
3722    col: &Column,
3723    json: &serde_json::Value,
3724) -> Result<(), crate::orm::write::WriteError> {
3725    let Some(n) = json.as_f64() else {
3726        return Ok(());
3727    };
3728    if let Some(min) = col.min {
3729        if n < min as f64 {
3730            return Err(crate::orm::write::WriteError::Validator {
3731                field: col.name.clone(),
3732                message: format!("must be >= {min} (got {n})."),
3733            });
3734        }
3735    }
3736    if let Some(max) = col.max {
3737        if n > max as f64 {
3738            return Err(crate::orm::write::WriteError::Validator {
3739                field: col.name.clone(),
3740                message: format!("must be <= {max} (got {n})."),
3741            });
3742        }
3743    }
3744    Ok(())
3745}
3746
3747/// Convert a JSON PK-shaped value (number or string) into a
3748/// `sea_query::Value` usable as a junction-table binding. Returns
3749/// `None` for shapes we don't know how to bind (arrays, objects,
3750/// booleans) — those won't reach here because
3751/// `validate_m2m_relations` rejects them upstream.
3752fn json_pk_to_sea(v: &serde_json::Value) -> Option<sea_query::Value> {
3753    match v {
3754        serde_json::Value::Number(n) => n.as_i64().map(|i| sea_query::Value::BigInt(Some(i))),
3755        serde_json::Value::String(s) => Some(sea_query::Value::String(Some(Box::new(s.clone())))),
3756        _ => None,
3757    }
3758}
3759
3760/// Read every M2M relation off its junction table and attach
3761/// the resulting `child_id` arrays to `out` under each relation's
3762/// field name. Called from `insert_json` / `update_json`'s read-
3763/// back path so the response JSON includes the relations the
3764/// caller just wrote (otherwise the `tags: [1, 2]` they POSTed
3765/// would never appear in the response, since `M2M<T>` is
3766/// `#[serde(skip)]` on the parent struct).
3767/// Normalize a select_related token: accept both `.` and `__` as
3768/// hop separators (gap2 #18), return the canonical dotted form
3769/// (`author.profile`). Mixed separators in one token are flattened
3770/// the same way (`author.profile__org` → `author.profile.org`).
3771///
3772/// Edge case: a column whose actual name contains `__` (rare; real
3773/// models don't do this) would alias to a dotted chain after this
3774/// pass and fail validation; the caller silently drops it, matching
3775/// the existing "unknown column" behaviour.
3776fn normalize_sr_token(name: &str) -> String {
3777    name.replace("__", ".")
3778}
3779
3780/// Validate a dotted select_related chain (e.g. `"author.profile"`)
3781/// against the model graph. Each hop must be an FK on the prior
3782/// hop's target meta. Returns the per-hop target tables on success
3783/// (same length as `hops.len()`); returns `None` on any failure so
3784/// the caller can drop the token silently — same contract as the
3785/// pre-existing single-hop validation in `select_related_dyn`.
3786///
3787/// Empty chains, missing meta lookups, and non-FK columns all
3788/// return `None`.
3789fn validate_sr_chain(root_meta: &crate::migrate::ModelMeta, chain: &str) -> Option<Vec<String>> {
3790    let hops: Vec<&str> = chain.split('.').filter(|s| !s.is_empty()).collect();
3791    if hops.is_empty() {
3792        return None;
3793    }
3794    let registered = crate::migrate::registered_models();
3795    let mut targets: Vec<String> = Vec::with_capacity(hops.len());
3796    let mut current_table: String = root_meta.table.clone();
3797    let mut current_meta: Option<crate::migrate::ModelMeta> = None;
3798    for hop in &hops {
3799        let meta_ref: &crate::migrate::ModelMeta =
3800            if current_table == root_meta.table && current_meta.is_none() {
3801                root_meta
3802            } else {
3803                current_meta = registered
3804                    .iter()
3805                    .find(|m| m.table == current_table)
3806                    .cloned();
3807                current_meta.as_ref()?
3808            };
3809        let col = meta_ref.fields.iter().find(|c| &c.name == hop)?;
3810        let target = col.fk_target.clone()?;
3811        targets.push(target.clone());
3812        current_table = target;
3813    }
3814    Some(targets)
3815}
3816
3817/// FK expansion for the dynamic-dispatch read path. For each name
3818/// in `sr_fields` (canonical dotted form — `select_related_dyn`
3819/// has already normalized + validated), collect the integer ids
3820/// across `rows`, run one batched `SELECT * FROM <target> WHERE id
3821/// IN (...)` per hop, and splice the resolved chain back where the
3822/// root FK id was. Query budget is `1 + len(hops)` per chain
3823/// regardless of how many parent rows came back. No N+1.
3824///
3825/// Mirrors the typed
3826/// `queryset::hydration::hydrate_select_related_nested` semantics:
3827/// per-hop fetch top-down, then bottom-up embed so the root rows
3828/// carry the full nested chain.
3829///
3830/// Caller has already validated that every name in `sr_fields`
3831/// resolves to an FK chain on `meta` (via `select_related_dyn` →
3832/// [`validate_sr_chain`]).
3833async fn hydrate_select_related_into(
3834    meta: &crate::migrate::ModelMeta,
3835    sr_fields: &[String],
3836    rows: &mut [serde_json::Map<String, serde_json::Value>],
3837) -> Result<(), sqlx::Error> {
3838    let pool = resolve_pool_dyn(meta, crate::db::RouteOp::Read);
3839    for chain in sr_fields {
3840        let hops: Vec<&str> = chain.split('.').filter(|s| !s.is_empty()).collect();
3841        if hops.is_empty() {
3842            continue;
3843        }
3844        let Some(targets) = validate_sr_chain(meta, chain) else {
3845            // select_related_dyn validates up front; if a chain
3846            // slipped through validation but fails here (e.g. an
3847            // unregistered intermediate model — only possible from
3848            // a direct internal caller), skip rather than crash.
3849            continue;
3850        };
3851
3852        // gaps #112 / PK lift Pass A: walk the chain in PK-shape-
3853        // agnostic terms. Each hop's PK column name comes from the
3854        // target meta (could be `"id"` for integer-PK models, but
3855        // also `"codename"` for `permissions_permission`, etc.).
3856        // FK ids and PK lookups round-trip as `serde_json::Value`
3857        // so String / UUID / mixed-PK chains all hydrate without
3858        // the pre-fix `.as_i64()` silently dropping non-integer
3859        // links.
3860        let registered = crate::migrate::registered_models();
3861        let hop_target_pk: Vec<(String, SqlType)> = targets
3862            .iter()
3863            .filter_map(|t| {
3864                registered
3865                    .iter()
3866                    .find(|m| &m.table == t)
3867                    .and_then(|m| m.pk_column().map(|c| (c.name.clone(), c.ty)))
3868            })
3869            .collect();
3870        if hop_target_pk.len() != hops.len() {
3871            // A meta lookup failed mid-chain (only possible from
3872            // an unregistered intermediate model — unreachable in
3873            // practice). Skip the chain rather than crash.
3874            continue;
3875        }
3876        let hop_target_soft_delete: Vec<bool> = targets
3877            .iter()
3878            .map(|t| {
3879                registered
3880                    .iter()
3881                    .find(|m| &m.table == t)
3882                    .is_some_and(|m| m.soft_delete)
3883            })
3884            .collect();
3885
3886        // Phase 1: per-hop fetch, top-down. levels[i] holds the
3887        // related-row JSON objects at depth i, BEFORE any nesting
3888        // is embedded.
3889        let first_field = hops[0];
3890        let mut ids: Vec<serde_json::Value> = Vec::with_capacity(rows.len());
3891        for row in rows.iter() {
3892            let Some(v) = row.get(first_field) else {
3893                continue;
3894            };
3895            if v.is_null() {
3896                continue;
3897            }
3898            ids.push(v.clone());
3899        }
3900        if ids.is_empty() {
3901            continue;
3902        }
3903        dedup_by_pk_key(&mut ids);
3904        let mut levels: Vec<Vec<serde_json::Value>> = Vec::with_capacity(hops.len());
3905        levels.push(
3906            crate::orm::queryset::hydration::fetch_related_as_json_by_pk(
3907                &targets[0],
3908                &hop_target_pk[0].0,
3909                hop_target_pk[0].1,
3910                hop_target_soft_delete[0],
3911                &ids,
3912                &pool,
3913            )
3914            .await?,
3915        );
3916
3917        for hop_idx in 1..hops.len() {
3918            let hop_field = hops[hop_idx];
3919            let hop_target = &targets[hop_idx];
3920            let prev_lvl = &levels[hop_idx - 1];
3921            let mut next_ids: Vec<serde_json::Value> = prev_lvl
3922                .iter()
3923                .filter_map(|r| {
3924                    let v = r.as_object()?.get(hop_field)?;
3925                    if v.is_null() { None } else { Some(v.clone()) }
3926                })
3927                .collect();
3928            if next_ids.is_empty() {
3929                // Chain bottoms out (every prior-level row has
3930                // NULL for this hop). Subsequent hops would also
3931                // be empty; stop here. Earlier levels still embed
3932                // below.
3933                break;
3934            }
3935            dedup_by_pk_key(&mut next_ids);
3936            levels.push(
3937                crate::orm::queryset::hydration::fetch_related_as_json_by_pk(
3938                    hop_target,
3939                    &hop_target_pk[hop_idx].0,
3940                    hop_target_pk[hop_idx].1,
3941                    hop_target_soft_delete[hop_idx],
3942                    &next_ids,
3943                    &pool,
3944                )
3945                .await?,
3946            );
3947        }
3948
3949        // Phase 2: bottom-up embed. For each level from second-
3950        // to-last down to first, splice the next level's matching
3951        // row into the corresponding hop slot. By the time we hit
3952        // level 0 its rows carry the full nested chain.
3953        if levels.len() > 1 {
3954            for i in (0..levels.len() - 1).rev() {
3955                let next_pk_col = &hop_target_pk[i + 1].0;
3956                let next_by_pk: HashMap<String, serde_json::Value> = levels[i + 1]
3957                    .iter()
3958                    .filter_map(|obj| {
3959                        let map = obj.as_object()?;
3960                        let pk_val = map.get(next_pk_col.as_str())?;
3961                        Some((pk_json_key(pk_val), obj.clone()))
3962                    })
3963                    .collect();
3964                let hop_field = hops[i + 1];
3965                for row in levels[i].iter_mut() {
3966                    let Some(map) = row.as_object_mut() else {
3967                        continue;
3968                    };
3969                    let Some(fk_val) = map.get(hop_field) else {
3970                        continue;
3971                    };
3972                    if fk_val.is_null() {
3973                        continue;
3974                    }
3975                    let key = pk_json_key(fk_val);
3976                    if let Some(next_json) = next_by_pk.get(&key) {
3977                        map.insert(hop_field.to_string(), next_json.clone());
3978                    }
3979                }
3980            }
3981        }
3982
3983        // Phase 3: splice level-0 rows (now fully nested) into
3984        // the root rows. Rows pointing at an id that didn't
3985        // resolve (target row deleted between the parent fetch
3986        // and the IN-lookup — a race window) keep the raw FK
3987        // value; the alternative would be silently nulling the
3988        // field which hides a real referential-integrity issue.
3989        let first_pk_col = &hop_target_pk[0].0;
3990        let first_by_pk: HashMap<String, serde_json::Value> = levels
3991            .into_iter()
3992            .next()
3993            .unwrap_or_default()
3994            .into_iter()
3995            .filter_map(|obj| {
3996                let map = obj.as_object()?;
3997                let pk_val = map.get(first_pk_col.as_str())?;
3998                Some((pk_json_key(pk_val), obj.clone()))
3999            })
4000            .collect();
4001        for row in rows.iter_mut() {
4002            let Some(fk_val) = row.get(first_field) else {
4003                continue;
4004            };
4005            if fk_val.is_null() {
4006                continue;
4007            }
4008            let key = pk_json_key(fk_val);
4009            if let Some(resolved) = first_by_pk.get(&key) {
4010                row.insert(first_field.to_string(), resolved.clone());
4011            }
4012        }
4013    }
4014    Ok(())
4015}
4016
4017/// Dedup a `Vec<serde_json::Value>` of PK values by stable string
4018/// key. `serde_json::Value` isn't `Hash`, so the standard
4019/// sort+dedup doesn't apply; the `pk_json_key` namespacing makes
4020/// every Number / String / other land in its own bucket.
4021fn dedup_by_pk_key(ids: &mut Vec<serde_json::Value>) {
4022    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
4023    ids.retain(|v| seen.insert(pk_json_key(v)));
4024}
4025
4026/// Batched M2M echo across every row returned by `fetch_as_json`.
4027/// One `SELECT parent_id, child_id FROM <junction> WHERE parent_id
4028/// IN (...)` per registered M2M relation — query budget is
4029/// `count(meta.m2m_relations)` regardless of how many parent rows
4030/// came back. Replaces the per-row single-parent M2M echo in the
4031/// read loop (gap2 #16) which was a 1+N*M issuer.
4032///
4033/// Each row's `<relation>` key is inserted as an array of `child_id`
4034/// values (integers or strings, matching the junction column's
4035/// declared shape). Parents with no junction rows still get the key
4036/// — initialised to an empty array — so the response shape is
4037/// consistent regardless of link presence (same contract the
4038/// per-row helper already maintained).
4039async fn hydrate_m2m_batched(
4040    meta: &crate::migrate::ModelMeta,
4041    pk_name: &str,
4042    rows: &mut [serde_json::Map<String, serde_json::Value>],
4043) -> Result<(), sqlx::Error> {
4044    if meta.m2m_relations.is_empty() || rows.is_empty() {
4045        return Ok(());
4046    }
4047
4048    // Initialise every row's relation arrays up front so parents
4049    // with zero junction rows still surface the field. Matches the
4050    // per-row helper's behaviour where the `SELECT` returning zero
4051    // rows produced `<rel>: []` rather than omitting the key.
4052    for row in rows.iter_mut() {
4053        for rel in &meta.m2m_relations {
4054            row.insert(rel.field_name.clone(), serde_json::Value::Array(Vec::new()));
4055        }
4056    }
4057
4058    // Collect parent PKs once across all rows, deduped. Skip rows
4059    // missing the PK column or whose PK value isn't a shape the
4060    // junction can bind (numbers + strings; see `json_pk_to_sea`).
4061    let mut parent_sea_vals: Vec<sea_query::Value> = Vec::with_capacity(rows.len());
4062    let mut seen_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
4063    for row in rows.iter() {
4064        let Some(pk_json) = row.get(pk_name) else {
4065            continue;
4066        };
4067        let Some(sea_val) = json_pk_to_sea(pk_json) else {
4068            continue;
4069        };
4070        let key = pk_json_key(pk_json);
4071        if seen_keys.insert(key) {
4072            parent_sea_vals.push(sea_val);
4073        }
4074    }
4075    if parent_sea_vals.is_empty() {
4076        return Ok(());
4077    }
4078
4079    for rel in &meta.m2m_relations {
4080        let junction_table = format!("{}_{}", meta.table, rel.field_name);
4081        let mut sel = Query::select();
4082        sel.from(crate::db::router::schema_qualified_table(&junction_table));
4083        sel.column(Alias::new("parent_id"));
4084        sel.column(Alias::new("child_id"));
4085        sel.and_where(Expr::col(Alias::new("parent_id")).is_in(parent_sea_vals.clone()));
4086
4087        let mut children_by_parent: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
4088        match resolve_pool_dyn(meta, crate::db::RouteOp::Read) {
4089            DbPool::Sqlite(pool) => {
4090                let (sql, values) = sel.build_sqlx(SqliteQueryBuilder);
4091                let db_rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
4092                for r in &db_rows {
4093                    let parent = read_junction_id_sqlite(r, "parent_id")?;
4094                    let child = read_junction_id_sqlite(r, "child_id")?;
4095                    children_by_parent
4096                        .entry(pk_json_key(&parent))
4097                        .or_default()
4098                        .push(child);
4099                }
4100            }
4101            DbPool::Postgres(pool) => {
4102                let (sql, values) = sel.build_sqlx(PostgresQueryBuilder);
4103                let db_rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
4104                for r in &db_rows {
4105                    let parent = read_junction_id_pg(r, "parent_id")?;
4106                    let child = read_junction_id_pg(r, "child_id")?;
4107                    children_by_parent
4108                        .entry(pk_json_key(&parent))
4109                        .or_default()
4110                        .push(child);
4111                }
4112            }
4113        }
4114
4115        for row in rows.iter_mut() {
4116            let Some(pk_json) = row.get(pk_name) else {
4117                continue;
4118            };
4119            let key = pk_json_key(pk_json);
4120            if let Some(children) = children_by_parent.remove(&key) {
4121                row.insert(rel.field_name.clone(), serde_json::Value::Array(children));
4122            }
4123        }
4124    }
4125    Ok(())
4126}
4127
4128/// Stable string key for a parent PK JSON value, used to group
4129/// junction rows under their owning parent in
4130/// [`hydrate_m2m_batched`]. Integers and strings get their own
4131/// disjoint namespaces (`n:42` vs `s:42`) so a numeric PK and a
4132/// string PK that stringify identically never collide.
4133fn pk_json_key(v: &serde_json::Value) -> String {
4134    match v {
4135        serde_json::Value::Number(n) => format!("n:{n}"),
4136        serde_json::Value::String(s) => format!("s:{s}"),
4137        other => format!("o:{other}"),
4138    }
4139}
4140
4141/// Read a junction-table id column as JSON (number or string).
4142/// Junction columns are i64 for integer PKs and TEXT for string /
4143/// uuid PKs; we don't know at compile time which one a relation
4144/// uses, so try i64 first and fall back to String.
4145fn read_junction_id_sqlite(
4146    row: &sqlx::sqlite::SqliteRow,
4147    col: &str,
4148) -> Result<serde_json::Value, sqlx::Error> {
4149    if let Ok(i) = row.try_get::<i64, _>(col) {
4150        return Ok(serde_json::Value::Number(i.into()));
4151    }
4152    let s = row.try_get::<String, _>(col)?;
4153    Ok(serde_json::Value::String(s))
4154}
4155
4156fn read_junction_id_pg(
4157    row: &sqlx::postgres::PgRow,
4158    col: &str,
4159) -> Result<serde_json::Value, sqlx::Error> {
4160    if let Ok(i) = row.try_get::<i64, _>(col) {
4161        return Ok(serde_json::Value::Number(i.into()));
4162    }
4163    let s = row.try_get::<String, _>(col)?;
4164    Ok(serde_json::Value::String(s))
4165}
4166
4167/// Run `SELECT <pk> FROM <table> WHERE <conds>` to find every
4168/// row the dynamic UPDATE would touch. Returns each matched PK
4169/// as the raw JSON value the parent table holds — number for
4170/// integer PKs, string for UUID / String PKs. Used by
4171/// `update_json` so we know which junction-table parent_ids
4172/// to write to even when the body has no regular column changes.
4173/// Snapshot the rows `clauses` selects — the before/after image an audited write
4174/// records (gaps3 #54). Best-effort: a failed snapshot must not fail the write.
4175pub(crate) async fn audit_snapshot(
4176    meta: &crate::migrate::ModelMeta,
4177    clauses: &[Condition],
4178) -> Vec<serde_json::Map<String, serde_json::Value>> {
4179    let mut qs = DynQuerySet::for_meta(meta);
4180    for c in clauses {
4181        qs = qs.filter_condition(c.clone());
4182    }
4183    qs.fetch_as_json().await.unwrap_or_default()
4184}
4185
4186/// Pair each before-row with its after-row by primary key, for `record_many`.
4187#[allow(clippy::type_complexity)]
4188pub(crate) fn audit_pairs(
4189    meta: &crate::migrate::ModelMeta,
4190    before: Vec<serde_json::Map<String, serde_json::Value>>,
4191    after: Vec<serde_json::Map<String, serde_json::Value>>,
4192) -> Vec<(
4193    String,
4194    Option<serde_json::Map<String, serde_json::Value>>,
4195    Option<serde_json::Map<String, serde_json::Value>>,
4196)> {
4197    use crate::orm::audit;
4198    let mut by_pk: std::collections::HashMap<String, serde_json::Map<String, serde_json::Value>> =
4199        after
4200            .into_iter()
4201            .map(|r| (audit::pk_of(meta, &r), r))
4202            .collect();
4203    before
4204        .into_iter()
4205        .map(|b| {
4206            let pk = audit::pk_of(meta, &b);
4207            let a = by_pk.remove(&pk);
4208            (pk, Some(b), a)
4209        })
4210        .collect()
4211}
4212
4213async fn collect_parent_pks(
4214    meta: &crate::migrate::ModelMeta,
4215    pk_col: &crate::migrate::Column,
4216    where_clauses: &[Condition],
4217) -> Result<Vec<serde_json::Value>, crate::orm::write::WriteError> {
4218    let mut sel = Query::select();
4219    sel.from(crate::db::router::schema_qualified_table(&meta.table));
4220    sel.column(Alias::new(&pk_col.name));
4221    for cond in where_clauses {
4222        sel.cond_where(cond.clone());
4223    }
4224    match resolve_pool_dyn(meta, crate::db::RouteOp::Read) {
4225        DbPool::Sqlite(pool) => {
4226            let (sql, values) = sel.build_sqlx(SqliteQueryBuilder);
4227            let rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
4228            rows.iter()
4229                .map(|row| decode_to_json(row, pk_col))
4230                .collect::<Result<Vec<_>, _>>()
4231                .map_err(crate::orm::write::WriteError::Sqlx)
4232        }
4233        DbPool::Postgres(pool) => {
4234            let (sql, values) = sel.build_sqlx(PostgresQueryBuilder);
4235            let rows = sqlx::query_with(&sql, values).fetch_all(&pool).await?;
4236            rows.iter()
4237                .map(|row| decode_pg_to_json(row, pk_col))
4238                .collect::<Result<Vec<_>, _>>()
4239                .map_err(crate::orm::write::WriteError::Sqlx)
4240        }
4241    }
4242}
4243
4244/// Transaction-aware sibling of [`collect_parent_pks`]: reads the matched
4245/// PKs on the open `tx` so a bulk update mid-transaction sees the rows the
4246/// same tx has touched. Used by `update_json_in_tx`.
4247async fn collect_parent_pks_in_tx(
4248    meta: &crate::migrate::ModelMeta,
4249    pk_col: &crate::migrate::Column,
4250    where_clauses: &[Condition],
4251    tx: &mut crate::db::Transaction,
4252) -> Result<Vec<serde_json::Value>, crate::orm::write::WriteError> {
4253    let mut sel = Query::select();
4254    sel.from(crate::db::router::schema_qualified_table(&meta.table));
4255    sel.column(Alias::new(&pk_col.name));
4256    for cond in where_clauses {
4257        sel.cond_where(cond.clone());
4258    }
4259    match tx.backend_name() {
4260        "sqlite" => {
4261            let (sql, values) = sel.build_sqlx(SqliteQueryBuilder);
4262            let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
4263            let rows = sqlx::query_with(&sql, values)
4264                .fetch_all(&mut **inner)
4265                .await?;
4266            rows.iter()
4267                .map(|row| decode_to_json(row, pk_col))
4268                .collect::<Result<Vec<_>, _>>()
4269                .map_err(crate::orm::write::WriteError::Sqlx)
4270        }
4271        _ => {
4272            let (sql, values) = sel.build_sqlx(PostgresQueryBuilder);
4273            let inner = tx.as_pg_mut().expect("postgres backend_name");
4274            let rows = sqlx::query_with(&sql, values)
4275                .fetch_all(&mut **inner)
4276                .await?;
4277            rows.iter()
4278                .map(|row| decode_pg_to_json(row, pk_col))
4279                .collect::<Result<Vec<_>, _>>()
4280                .map_err(crate::orm::write::WriteError::Sqlx)
4281        }
4282    }
4283}
4284
4285/// Mirror each M2M field in `body` into its junction table for
4286/// the given parent PK. Validation has already confirmed array
4287/// shape + child existence, so this is a straight write —
4288/// `set_junction_dynamic` wipes any existing rows for the
4289/// parent and re-inserts the supplied ids inside a transaction.
4290///
4291/// `parent_pk_json` is the JSON value the parent row holds at
4292/// its PK column (read straight off the post-INSERT row). When
4293/// it's `None` or unparseable we silently skip — there's nothing
4294/// to anchor the junction to.
4295/// Phase -1 of the dynamic insert: strip `noform` columns and derive
4296/// any `#[umbral(slug_from = "...")]` columns. Returns `Some(owned)`
4297/// when either rule fired (the caller binds the owned copy) or `None`
4298/// when the body passes through untouched. Shared by `insert_json`
4299/// and `insert_json_in_tx` so the two paths can't drift on what they
4300/// strip / derive before validation runs.
4301/// True when `col` is a privileged column the caller has NOT authorized, so it
4302/// must be stripped from the untrusted JSON write body (audit_2 H3).
4303fn is_unauthorized_privileged(col: &crate::migrate::Column, allow_privileged: &[String]) -> bool {
4304    col.privileged && !allow_privileged.iter().any(|a| a == &col.name)
4305}
4306
4307fn normalise_insert_body(
4308    meta: &crate::migrate::ModelMeta,
4309    body: &serde_json::Map<String, serde_json::Value>,
4310    allow_privileged: &[String],
4311) -> Option<serde_json::Map<String, serde_json::Value>> {
4312    let needs_owned = meta.fields.iter().any(|c| {
4313        c.noform
4314            || c.slug_from.is_some()
4315            || c.auto_uuid
4316            || is_unauthorized_privileged(c, allow_privileged)
4317    });
4318    if !needs_owned {
4319        return None;
4320    }
4321    let mut owned = body.clone();
4322    for col in &meta.fields {
4323        if col.noform || is_unauthorized_privileged(col, allow_privileged) {
4324            owned.remove(&col.name);
4325        }
4326    }
4327    crate::orm::write::apply_slug_from(&meta.fields, &mut owned, false);
4328    crate::orm::write::apply_auto_uuid(&meta.fields, &mut owned, false);
4329    Some(owned)
4330}
4331
4332/// Update-path twin of [`normalise_insert_body`]: strip `noform` and
4333/// unauthorized-`privileged` columns, then derive `slug_from` with the update
4334/// guard. Shared by `update_json` and `update_json_in_tx` so both honour the
4335/// mass-assignment guard identically.
4336fn normalise_update_body(
4337    meta: &crate::migrate::ModelMeta,
4338    body: &serde_json::Map<String, serde_json::Value>,
4339    allow_privileged: &[String],
4340) -> Option<serde_json::Map<String, serde_json::Value>> {
4341    // gaps4 #5 (JSON path): a Masked column submitted EMPTY or as the
4342    // redaction marker means "leave the stored secret unchanged" — strip it
4343    // BEFORE validation so it neither trips `BlankNotAllowed` nor reaches
4344    // the seal (which would re-seal `""` / `"••••••"` over the ciphertext,
4345    // crypto-shredding the secret). The form update path guards this inline.
4346    let masked_no_change = |c: &crate::migrate::Column| {
4347        body.get(&c.name)
4348            .map(|v| crate::orm::write::is_masked_no_change(c, v))
4349            .unwrap_or(false)
4350    };
4351    let needs_owned = meta.fields.iter().any(|c| {
4352        c.noform
4353            || c.slug_from.is_some()
4354            || is_unauthorized_privileged(c, allow_privileged)
4355            || masked_no_change(c)
4356    });
4357    if !needs_owned {
4358        return None;
4359    }
4360    let mut owned = body.clone();
4361    for col in &meta.fields {
4362        if col.noform || is_unauthorized_privileged(col, allow_privileged) || masked_no_change(col)
4363        {
4364            owned.remove(&col.name);
4365        }
4366    }
4367    crate::orm::write::apply_slug_from(&meta.fields, &mut owned, true);
4368    Some(owned)
4369}
4370
4371/// The prepared INSERT plus the PK shape the caller re-fetches by.
4372struct InsertPlan {
4373    q: sea_query::InsertStatement,
4374    pk_name: String,
4375    pk_ty: SqlType,
4376}
4377
4378/// Phase 1 of the dynamic insert: validate min/max + text-format
4379/// wrappers per column, coerce each JSON value to its `SeaValue`, and
4380/// assemble the `Query::insert()`. Auto-increment integer PKs and
4381/// absent-with-default columns are omitted so the backend fills them;
4382/// `auto_now` / `auto_now_add` columns the body omitted are filled
4383/// with `Utc::now()`. Shared by `insert_json` and `insert_json_in_tx`
4384/// so column handling is identical on both paths; the methods differ
4385/// only in which executor runs the statement.
4386fn build_insert_plan(
4387    meta: &crate::migrate::ModelMeta,
4388    body: &serde_json::Map<String, serde_json::Value>,
4389    presealed: bool,
4390) -> Result<InsertPlan, crate::orm::write::WriteError> {
4391    use crate::orm::write::{WriteError, is_default_pk};
4392
4393    let mut cols: Vec<&str> = Vec::new();
4394    let mut values: Vec<SeaValue> = Vec::new();
4395    for col in &meta.fields {
4396        if col.primary_key {
4397            let supplied = body.get(&col.name);
4398            let is_sentinel = match supplied {
4399                None | Some(serde_json::Value::Null) => true,
4400                Some(v) => is_default_pk(col.ty, v),
4401            };
4402            if matches!(
4403                col.ty,
4404                SqlType::Integer | SqlType::BigInt | SqlType::SmallInt
4405            ) && is_sentinel
4406            {
4407                continue;
4408            }
4409        }
4410        // gaps3 #55: the author is server-owned. Stamped before the body is read,
4411        // so a REST POST cannot create a row attributed to another user by
4412        // putting their id in the payload.
4413        if col.auto_user_add || col.auto_user {
4414            cols.push(&col.name);
4415            values.push(crate::orm::write::user_for_column(col.ty));
4416            continue;
4417        }
4418        let Some(json) = body.get(&col.name) else {
4419            if col.auto_now_add || col.auto_now {
4420                let now_value = crate::orm::write::now_for_column(col.ty);
4421                cols.push(&col.name);
4422                values.push(now_value);
4423                continue;
4424            }
4425            continue;
4426        };
4427        if json.is_null() {
4428            continue;
4429        }
4430        validate_numeric_bounds(col, json)?;
4431        if let (Some(fmt), Some(s)) = (col.text_format.as_deref(), json.as_str()) {
4432            if let Err(e) = crate::orm::validators::validate_text_format(fmt, s) {
4433                return Err(WriteError::Validator {
4434                    field: col.name.clone(),
4435                    message: e.to_string(),
4436                });
4437            }
4438        }
4439        // gaps3 #34: apply declared trim/lowercase to the incoming string
4440        // before masking / binding (dynamic write path only).
4441        let normalized_json = normalize_json_for_col(col, json);
4442        let json = normalized_json.as_ref().unwrap_or(json);
4443        // features #83: app-defined clean/validate hooks.
4444        let cleaned_json = crate::orm::cleaners::apply(&meta.table, &col.name, json)?;
4445        let json = cleaned_json.as_ref().unwrap_or(json);
4446        // Masked columns: seal the plaintext before binding (audit_2 core-orm C1).
4447        // gaps4 #2: unless the values are already sealed (a backup RESTORE), in
4448        // which case sealing again double-encrypts and loses the plaintext.
4449        let sealed = if presealed {
4450            None
4451        } else {
4452            crate::orm::write::seal_masked_json(col, json)?
4453        };
4454        let sea_value = crate::orm::write::json_to_sea_value(
4455            col.ty,
4456            sealed.as_ref().unwrap_or(json),
4457            col.nullable,
4458            &col.name,
4459            fk_target_pk_sql_type(col),
4460        )?;
4461        cols.push(&col.name);
4462        values.push(sea_value);
4463    }
4464
4465    let pk_col = meta.fields.iter().find(|c| c.primary_key).ok_or_else(|| {
4466        WriteError::Sqlx(sqlx::Error::Protocol(
4467            "insert_json: model has no PK".to_string(),
4468        ))
4469    })?;
4470    let pk_name = pk_col.name.clone();
4471    let pk_ty = pk_col.ty;
4472
4473    let mut q = Query::insert();
4474    q.into_table(crate::db::router::schema_qualified_table(&meta.table));
4475    q.columns(cols.iter().map(|c| Alias::new(*c)).collect::<Vec<_>>());
4476    let exprs: Vec<sea_query::SimpleExpr> = values.into_iter().map(Into::into).collect();
4477    q.values_panic(exprs);
4478
4479    Ok(InsertPlan { q, pk_name, pk_ty })
4480}
4481
4482/// Transaction-aware sibling of [`write_m2m_junctions`]: mirrors each
4483/// M2M field in `body` into its junction table on the passed `tx`, so
4484/// the junction rows commit / roll back with the parent INSERT.
4485async fn write_m2m_junctions_in_tx(
4486    meta: &crate::migrate::ModelMeta,
4487    parent_pk_json: Option<&serde_json::Value>,
4488    body: &serde_json::Map<String, serde_json::Value>,
4489    tx: &mut crate::db::Transaction,
4490) -> Result<(), crate::orm::write::WriteError> {
4491    if meta.m2m_relations.is_empty() {
4492        return Ok(());
4493    }
4494    let Some(parent_pk_value) = parent_pk_json.and_then(json_pk_to_sea) else {
4495        return Ok(());
4496    };
4497    for rel in &meta.m2m_relations {
4498        let Some(value) = body.get(&rel.field_name) else {
4499            continue;
4500        };
4501        let Some(items) = value.as_array() else {
4502            continue;
4503        };
4504        let mut child_ids: Vec<sea_query::Value> = Vec::with_capacity(items.len());
4505        for item in items {
4506            if item.is_null() {
4507                continue;
4508            }
4509            if let Some(v) = json_pk_to_sea(item) {
4510                child_ids.push(v);
4511            }
4512        }
4513        let junction_table = format!("{}_{}", meta.table, rel.field_name);
4514        crate::orm::m2m::set_junction_dynamic_in_tx(
4515            &junction_table,
4516            parent_pk_value.clone(),
4517            child_ids,
4518            tx,
4519        )
4520        .await
4521        .map_err(crate::orm::write::WriteError::Sqlx)?;
4522    }
4523    Ok(())
4524}
4525
4526/// Transaction-aware sibling of [`hydrate_m2m_into`]: read the just-
4527/// written junction rows back off the SAME `tx` so the response echoes
4528/// the M2M arrays the caller will see post-commit. Reading on the pool
4529/// here would miss the uncommitted junction writes.
4530async fn hydrate_m2m_into_tx(
4531    meta: &crate::migrate::ModelMeta,
4532    parent_pk_json: Option<&serde_json::Value>,
4533    out: &mut serde_json::Map<String, serde_json::Value>,
4534    tx: &mut crate::db::Transaction,
4535) -> Result<(), sqlx::Error> {
4536    if meta.m2m_relations.is_empty() {
4537        return Ok(());
4538    }
4539    let Some(parent_pk_value) = parent_pk_json.and_then(json_pk_to_sea) else {
4540        return Ok(());
4541    };
4542    for rel in &meta.m2m_relations {
4543        let junction_table = format!("{}_{}", meta.table, rel.field_name);
4544        let mut sel = Query::select();
4545        sel.from(crate::db::router::schema_qualified_table(&junction_table));
4546        sel.column(Alias::new("child_id"));
4547        sel.and_where(Expr::col(Alias::new("parent_id")).eq(parent_pk_value.clone()));
4548        let children: Vec<serde_json::Value> = match tx.backend_name() {
4549            "sqlite" => {
4550                let inner = tx.as_sqlite_mut().expect("sqlite backend_name");
4551                let (sql, values) = sel.build_sqlx(SqliteQueryBuilder);
4552                let rows = sqlx::query_with(&sql, values)
4553                    .fetch_all(&mut **inner)
4554                    .await?;
4555                rows.iter()
4556                    .map(|r| {
4557                        r.try_get::<i64, _>("child_id")
4558                            .map(|i| serde_json::Value::Number(i.into()))
4559                            .or_else(|_| {
4560                                r.try_get::<String, _>("child_id")
4561                                    .map(serde_json::Value::String)
4562                            })
4563                    })
4564                    .collect::<Result<Vec<_>, _>>()?
4565            }
4566            _ => {
4567                let inner = tx.as_pg_mut().expect("postgres backend_name");
4568                let (sql, values) = sel.build_sqlx(PostgresQueryBuilder);
4569                let rows = sqlx::query_with(&sql, values)
4570                    .fetch_all(&mut **inner)
4571                    .await?;
4572                rows.iter()
4573                    .map(|r| {
4574                        r.try_get::<i64, _>("child_id")
4575                            .map(|i| serde_json::Value::Number(i.into()))
4576                            .or_else(|_| {
4577                                r.try_get::<String, _>("child_id")
4578                                    .map(serde_json::Value::String)
4579                            })
4580                    })
4581                    .collect::<Result<Vec<_>, _>>()?
4582            }
4583        };
4584        out.insert(rel.field_name.clone(), serde_json::Value::Array(children));
4585    }
4586    Ok(())
4587}
4588
4589// =========================================================================
4590// CSV / tabular import (#61). Coerce string cells to the column's type and
4591// route each row through `insert_json`, so validators / auto_now /
4592// slug_from / FK-existence checks all apply. The CSV *parsing* lives in the
4593// CLI (the `csv` crate); this is the coerce-and-insert half, kept in core
4594// because the type coercion needs `ModelMeta` + `SqlType` + the dynamic
4595// write path.
4596// =========================================================================
4597
4598/// Coerce one raw CSV cell to the `serde_json::Value` shape its column
4599/// expects, so downstream validation (`min`/`max`, choices) sees a typed
4600/// value rather than a string. An empty cell on a nullable column becomes
4601/// `null`. A value that doesn't parse for a numeric/bool column falls back
4602/// to the raw string, letting `insert_json` surface a clear per-row error
4603/// instead of silently dropping data. Text / Date / Time / Uuid / etc.
4604/// pass through as strings — `json_to_sea_value` parses each from there.
4605fn coerce_csv_cell(col: &Column, raw: &str) -> serde_json::Value {
4606    use serde_json::Value;
4607    if raw.is_empty() && col.nullable {
4608        return Value::Null;
4609    }
4610    // gaps3 #59 — resolve a FOREIGN KEY to its target's real primary-key type before
4611    // coercing. This used to take a bare `col.ty` and parse every `SqlType::ForeignKey`
4612    // as an `i64`, so importing a CSV whose FK points at a String-keyed target (a
4613    // permission keyed by codename, a country keyed by ISO code) turned a numeric-looking
4614    // value into a JSON number and bound it into a TEXT column. Every other type-driven
4615    // site in this file already goes through `fk_effective_type`; this one did not.
4616    let ty = crate::migrate::fk_effective_type(col);
4617    match ty {
4618        SqlType::SmallInt | SqlType::Integer | SqlType::BigInt | SqlType::ForeignKey => raw
4619            .parse::<i64>()
4620            .map(Value::from)
4621            .unwrap_or_else(|_| Value::String(raw.to_string())),
4622        SqlType::Real | SqlType::Double => raw
4623            .parse::<f64>()
4624            .ok()
4625            .and_then(serde_json::Number::from_f64)
4626            .map(Value::Number)
4627            .unwrap_or_else(|| Value::String(raw.to_string())),
4628        SqlType::Boolean => match raw.trim().to_ascii_lowercase().as_str() {
4629            "true" | "1" | "t" | "yes" | "y" => Value::Bool(true),
4630            "false" | "0" | "f" | "no" | "n" => Value::Bool(false),
4631            _ => Value::String(raw.to_string()),
4632        },
4633        SqlType::Json => {
4634            serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string()))
4635        }
4636        _ => Value::String(raw.to_string()),
4637    }
4638}
4639
4640/// Outcome of [`import_table_rows`]: how many rows inserted, plus the
4641/// `(line, message)` of every row that failed. Best-effort — a bad row is
4642/// reported and skipped, never fatal — because messy real-world CSVs want
4643/// "tell me which rows are wrong," not an all-or-nothing abort. `line` is
4644/// 1-based over the file (the header is line 1, so the first data row is
4645/// line 2), matching what a spreadsheet shows.
4646#[derive(Debug, Default)]
4647pub struct CsvImportReport {
4648    pub inserted: usize,
4649    pub errors: Vec<(usize, String)>,
4650}
4651
4652/// Insert tabular string rows into `meta`'s table. Each cell is coerced to
4653/// its column's type ([`coerce_csv_cell`]) and the row routes through the
4654/// dynamic write path ([`DynQuerySet::insert_json`]) so every per-row
4655/// framework behaviour (validators, `auto_now`, `slug_from`, FK existence,
4656/// soft-delete) applies exactly as it would for a REST POST.
4657///
4658/// `headers` names the column each cell maps to; a header that matches no
4659/// model field is ignored, so an extra CSV column (or a re-ordered export)
4660/// imports cleanly. Rows commit independently — there is no surrounding
4661/// transaction (the dynamic write path has none; see `orm_fixes.md` #2).
4662pub async fn import_table_rows(
4663    meta: &ModelMeta,
4664    headers: &[String],
4665    rows: &[Vec<String>],
4666) -> CsvImportReport {
4667    let col_for: HashMap<&str, &Column> =
4668        meta.fields.iter().map(|c| (c.name.as_str(), c)).collect();
4669
4670    let mut report = CsvImportReport::default();
4671    for (i, row) in rows.iter().enumerate() {
4672        let mut obj = serde_json::Map::new();
4673        for (header, cell) in headers.iter().zip(row.iter()) {
4674            if let Some(col) = col_for.get(header.as_str()) {
4675                obj.insert(header.clone(), coerce_csv_cell(col, cell));
4676            }
4677        }
4678        match DynQuerySet::for_meta(meta).insert_json(&obj).await {
4679            Ok(_) => report.inserted += 1,
4680            Err(e) => report.errors.push((i + 2, e.to_string())),
4681        }
4682    }
4683    report
4684}
4685
4686#[cfg(test)]
4687mod tests {
4688    use super::form_str_to_sea_value;
4689    use crate::migrate::Column;
4690    use crate::orm::{FkAction, SqlType};
4691    use sea_query::Value as SeaValue;
4692
4693    fn col(name: &str, ty: SqlType, nullable: bool) -> Column {
4694        Column {
4695            name: name.to_string(),
4696            ty,
4697            primary_key: false,
4698            nullable,
4699            fk_target: None,
4700            noform: false,
4701            privileged: false,
4702            private: false,
4703            secret: false,
4704            db_constraint: true,
4705            noedit: false,
4706            auto_user_add: false,
4707            auto_user: false,
4708            is_string_repr: false,
4709            max_length: 0,
4710            choices: Vec::new(),
4711            choice_labels: Vec::new(),
4712            default: String::new(),
4713            is_multichoice: false,
4714            unique: false,
4715            on_delete: FkAction::NoAction,
4716            on_update: FkAction::NoAction,
4717            index: false,
4718            auto_now_add: false,
4719            auto_uuid: false,
4720            auto_now: false,
4721            trim: false,
4722            lowercase: false,
4723            case_insensitive: false,
4724            help: String::new(),
4725            example: String::new(),
4726            widget: None,
4727            supported_backends: Vec::new(),
4728            min: None,
4729            max: None,
4730            text_format: None,
4731            slug_from: None,
4732        }
4733    }
4734
4735    #[test]
4736    fn form_fk_numeric_string_binds_as_bigint() {
4737        let mut plugin = col("plugin", SqlType::ForeignKey, false);
4738        plugin.fk_target = Some("plugin".to_string());
4739
4740        let value = form_str_to_sea_value(&plugin, "1").expect("coerce FK id");
4741
4742        assert_eq!(
4743            value,
4744            SeaValue::BigInt(Some(1)),
4745            "integer-backed FK form values must bind as bigint, not text"
4746        );
4747    }
4748
4749    #[test]
4750    fn nullable_form_fk_blank_binds_as_null_bigint() {
4751        let mut parent = col("parent", SqlType::ForeignKey, true);
4752        parent.fk_target = Some("plugin_comment".to_string());
4753
4754        let value = form_str_to_sea_value(&parent, "").expect("blank nullable FK");
4755
4756        assert_eq!(
4757            value,
4758            SeaValue::BigInt(None),
4759            "blank nullable integer-backed FK should bind SQL NULL"
4760        );
4761    }
4762}