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