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