Skip to main content

umbral_core/orm/
dynamic.rs

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