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