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