Skip to main content

umbral_core/orm/
write.rs

1//! Model write-side primitives — INSERT, UPDATE, DELETE.
2//!
3//! This module owns the conversion path **JSON value → sea_query::Value**
4//! that lets the write methods on `Manager` and `QuerySet` accept
5//! either a serialized model instance (for create / bulk_create) or a
6//! `serde_json::Map<String, Value>` of column-name → value pairs (for
7//! `update_values`). Both shapes converge on the same per-SqlType
8//! dispatch and the same SQL generation through sea-query.
9//!
10//! ## Why JSON in the middle
11//!
12//! Users derive `serde::Serialize` on their models for REST anyway,
13//! so `Manager::create(instance)` can call `serde_json::to_value`
14//! once and then dispatch each field against its column's `SqlType`.
15//! No second derive macro or custom trait method is required.
16//!
17//! For `QuerySet::update_values(map)` the caller is already producing
18//! a `Map<String, Value>` (often from request bodies — admin form
19//! posts, REST PATCH payloads), so accepting that shape directly is
20//! the least-friction surface.
21//!
22//! Both paths share [`json_to_sea_value`], so the per-type
23//! conversion is written once.
24//!
25//! ## Why not just bind through sqlx directly
26//!
27//! Binding JSON values straight to a `sqlx::query::Query` ties you to
28//! one driver: the `?` placeholders the SQLite driver expects don't
29//! work on Postgres, so that shortcut is sqlite-only. The umbral-core
30//! write methods support both backends, so they go through sea-query's
31//! typed `Value` enum, which `build_sqlx` then binds against whichever
32//! backend the resolved pool dictates. The `umbral-rest` plugin's
33//! dynamic writes route through `DynQuerySet::insert_json` /
34//! `update_json`, which land here — so REST is backend-agnostic too.
35
36use sea_query::Value as SeaValue;
37use serde_json::Value as JsonValue;
38
39use crate::orm::SqlType;
40
41/// Errors that can surface when converting JSON values to bindable
42/// sea-query values, when pre-validating against the schema, or
43/// when the write itself fails. Every variant that the REST /
44/// admin plugins surface as a per-field error has its own
45/// structured shape so the boundary translation is a `match`, not
46/// a string parse.
47#[derive(Debug)]
48pub enum WriteError {
49    /// A non-nullable field received a JSON `null` (or was absent on
50    /// create). Names the offending field.
51    RequiredFieldMissing { field: String },
52    /// A non-nullable text field received an empty string where a
53    /// meaningful value was required (a non-blank text column).
54    /// Surfaced by pre-validation in `insert_json`.
55    BlankNotAllowed { field: String },
56    /// A foreign-key column references a row that doesn't exist in
57    /// the target table. Pre-validated against the live DB before
58    /// the INSERT/UPDATE so the response keys the error under the
59    /// FK column with the offending value.
60    ForeignKeyNotFound {
61        field: String,
62        target_table: String,
63        value: serde_json::Value,
64    },
65    /// DB-side UNIQUE constraint failure. `field` is `Some(col)` when
66    /// the message / constraint name names the column (SQLite
67    /// always; Postgres via the `<table>_<col>_key` convention);
68    /// `None` for unparseable cases. `value` carries the offending
69    /// JSON value when the original body is still available.
70    UniqueViolation {
71        field: Option<String>,
72        value: Option<serde_json::Value>,
73    },
74    /// DB-side NOT NULL constraint failure (caller bypassed pre-
75    /// validation, e.g. via a raw transaction).
76    NotNullViolation { field: Option<String> },
77    /// A naive datetime (no offset) landed on the DST overlap hour, where the
78    /// clocks go back and the same wall-clock reading happens twice. There is no
79    /// way to know which instant the caller meant, so the write is refused —
80    /// silently picking one corrupts half the rows, and picking neither (storing
81    /// the reading as if it were UTC) invents a third. Carries both candidates so
82    /// the message can offer them. gaps3 #42.
83    AmbiguousLocalTime {
84        field: String,
85        value: String,
86        tz: String,
87        earlier: String,
88        later: String,
89    },
90    /// A naive datetime landed in the DST spring-forward gap, where the local
91    /// clock jumps (`02:00 → 03:00`) and the reading never occurs. gaps3 #42.
92    NonexistentLocalTime {
93        field: String,
94        value: String,
95        tz: String,
96    },
97    /// DB-side CHECK constraint failure. Carries the constraint
98    /// name when the engine surfaces it (Postgres does; SQLite
99    /// gives just a generic message).
100    CheckViolation { constraint: Option<String> },
101    /// DB-side foreign-key constraint failure that pre-validation
102    /// missed (rare — typically a race where the target row was
103    /// deleted between the existence check and the INSERT).
104    ForeignKeyViolation { field: Option<String> },
105    /// Multiple validation errors at once. Surfaced by
106    /// `insert_json` when required + FK checks both fire, so the
107    /// caller can render every problem in one response.
108    Multiple { errors: Vec<WriteError> },
109    /// The JSON value couldn't be coerced to the column's SqlType.
110    /// e.g. a string body where an integer was expected.
111    TypeMismatch {
112        field: String,
113        expected: SqlType,
114        got: String,
115    },
116    /// Format validator (`#[umbral(slug)]` / `email` / `url` /
117    /// `min = N` / `max = N`) rejected the value.
118    Validator { field: String, message: String },
119    /// `serde_json` couldn't serialize the instance to a JSON
120    /// object (the only shape `Manager::create` accepts).
121    NotAnObject,
122    /// The model isn't `Serialize`. Surfaced by the trait bound on
123    /// `Manager::create`; not actually constructable from runtime.
124    /// Kept here for completeness so the variant exists in the docs.
125    SerializeFailed(serde_json::Error),
126    /// sqlx error during the write. Wraps the driver-level cause.
127    Sqlx(sqlx::Error),
128    /// `update_values` received a column name that doesn't exist on
129    /// the model. Caught early before SQL is built.
130    UnknownColumn { field: String },
131    /// features #73 — a write was attempted against a model backed by a database
132    /// VIEW (`#[umbral(view = "...")]`).
133    ///
134    /// The database would reject this too, but only after the ORM had built and sent
135    /// the statement, and what comes back is a driver-level "cannot insert into view"
136    /// that names neither the model nor the reason. Refusing it here means the error
137    /// says which model, and why.
138    ReadOnlyView { table: String },
139}
140
141impl WriteError {
142    /// Flatten into a `{field: [messages, ...]}` map.
143    /// Used by the REST plugin to render the 400 body; the admin
144    /// plugin will use the same shape for inline form errors.
145    /// Variants that aren't tied to a specific field (raw sqlx,
146    /// NotAnObject, etc.) produce empty maps — the caller's
147    /// non-field-error envelope covers those.
148    pub fn field_errors(&self) -> std::collections::BTreeMap<String, Vec<String>> {
149        let mut out: std::collections::BTreeMap<String, Vec<String>> =
150            std::collections::BTreeMap::new();
151        self.collect_field_errors(&mut out);
152        out
153    }
154
155    fn collect_field_errors(&self, out: &mut std::collections::BTreeMap<String, Vec<String>>) {
156        use WriteError::*;
157        match self {
158            RequiredFieldMissing { field } => {
159                out.entry(field.clone())
160                    .or_default()
161                    .push("This field is required.".to_string());
162            }
163            BlankNotAllowed { field } => {
164                out.entry(field.clone())
165                    .or_default()
166                    .push("This field cannot be blank.".to_string());
167            }
168            AmbiguousLocalTime {
169                field,
170                tz,
171                earlier,
172                later,
173                ..
174            } => {
175                out.entry(field.clone()).or_default().push(format!(
176                    "This time happens twice in {tz} (the clocks go back): {earlier} or \
177                     {later}. Add a UTC offset to say which you mean."
178                ));
179            }
180            NonexistentLocalTime { field, tz, .. } => {
181                out.entry(field.clone()).or_default().push(format!(
182                    "This time does not exist in {tz} — the clocks go forward across it."
183                ));
184            }
185            ForeignKeyNotFound {
186                field,
187                target_table,
188                value,
189            } => {
190                let value_repr = repr_json_value(value);
191                out.insert(
192                    field.clone(),
193                    vec![format!(
194                        "Referenced {target_table} row with id={value_repr} not found."
195                    )],
196                );
197            }
198            UniqueViolation {
199                field: Some(col),
200                value,
201            } => {
202                let value_repr = value.as_ref().map(repr_json_value);
203                let msg = match value_repr {
204                    Some(v) => format!("A row with {col}={v} already exists."),
205                    None => "A row with this value already exists.".to_string(),
206                };
207                out.insert(col.clone(), vec![msg]);
208            }
209            NotNullViolation { field: Some(col) } => {
210                out.entry(col.clone())
211                    .or_default()
212                    .push("This field is required.".to_string());
213            }
214            ForeignKeyViolation { field: Some(col) } => {
215                out.insert(
216                    col.clone(),
217                    vec!["Referenced row does not exist.".to_string()],
218                );
219            }
220            TypeMismatch {
221                field,
222                expected,
223                got,
224            } => {
225                out.entry(field.clone())
226                    .or_default()
227                    .push(format!("Expected `{expected:?}`, got `{got}`."));
228            }
229            Validator { field, message } => {
230                // An empty field name marks a non-field (whole-form)
231                // validator error — the `From<ValidationErrors>` lift
232                // produces these for cross-field failures. Filing it
233                // under the literal key "" would make it invisible to
234                // every by-field-name consumer (admin inputs, error
235                // spans); `collect_non_field_errors` owns it instead.
236                if !field.is_empty() {
237                    out.entry(field.clone()).or_default().push(message.clone());
238                }
239            }
240            UnknownColumn { field } => {
241                out.entry(field.clone())
242                    .or_default()
243                    .push(format!("Unknown column `{field}` on this model."));
244            }
245            // Not a field problem — no single field is at fault — so it belongs in
246            // the non-field bucket rather than being dropped on the floor.
247            ReadOnlyView { .. } => {}
248            Multiple { errors } => {
249                for e in errors {
250                    e.collect_field_errors(out);
251                }
252            }
253            _ => {
254                // Sqlx fallthrough, NotAnObject, SerializeFailed, and
255                // the `None`-field constraint variants produce no
256                // per-field entry — the caller's non-field-error
257                // envelope handles those.
258            }
259        }
260    }
261
262    /// Non-field-level errors, for the `non_field_errors`
263    /// array. Only populated for the parseable-but-non-keyed
264    /// constraint variants and the multi-error wrapper.
265    pub fn non_field_errors(&self) -> Vec<String> {
266        let mut out: Vec<String> = Vec::new();
267        self.collect_non_field_errors(&mut out);
268        out
269    }
270
271    fn collect_non_field_errors(&self, out: &mut Vec<String>) {
272        use WriteError::*;
273        match self {
274            UniqueViolation { field: None, .. } => {
275                out.push("A row with one or more of these values already exists.".into());
276            }
277            NotNullViolation { field: None } => {
278                out.push("A required field is missing.".into());
279            }
280            ForeignKeyViolation { field: None } => {
281                out.push("One or more foreign-key fields reference rows that don't exist.".into());
282            }
283            CheckViolation { constraint } => {
284                let msg = match constraint {
285                    Some(c) => format!("Check constraint `{c}` failed."),
286                    None => "A check constraint failed.".to_string(),
287                };
288                out.push(msg);
289            }
290            // Empty field name = whole-form validator error (see the
291            // matching skip in `collect_field_errors`).
292            Validator { field, message } if field.is_empty() => {
293                out.push(message.clone());
294            }
295            ReadOnlyView { table } => {
296                out.push(format!(
297                    "`{table}` is a database view and cannot be written to."
298                ));
299            }
300            Multiple { errors } => {
301                for e in errors {
302                    e.collect_non_field_errors(out);
303                }
304            }
305            _ => {}
306        }
307    }
308
309    /// Stable machine-readable code for the boundary layer. REST
310    /// puts this in the `code` field of the 400 body; admin uses
311    /// it to pick an inline error style.
312    pub fn code(&self) -> &'static str {
313        use WriteError::*;
314        match self {
315            RequiredFieldMissing { .. } | BlankNotAllowed { .. } | NotNullViolation { .. } => {
316                "required_field"
317            }
318            ReadOnlyView { .. } => "read_only_view",
319            ForeignKeyNotFound { .. } | ForeignKeyViolation { .. } => "fk_constraint",
320            UniqueViolation { .. } => "unique_constraint",
321            CheckViolation { .. } => "check_constraint",
322            TypeMismatch { .. } => "type_mismatch",
323            // Distinct from `type_mismatch`: the value parsed fine, it just
324            // doesn't name one instant in the project timezone. A client can act
325            // on that by resending with an offset.
326            AmbiguousLocalTime { .. } => "ambiguous_local_time",
327            NonexistentLocalTime { .. } => "nonexistent_local_time",
328            Validator { .. } => "validator_failed",
329            Multiple { .. } => "validation_error",
330            UnknownColumn { .. } => "unknown_column",
331            NotAnObject => "not_an_object",
332            SerializeFailed(_) => "serialize_failed",
333            Sqlx(_) => "database_error",
334        }
335    }
336
337    /// `true` for the variants that represent user-fixable input
338    /// problems (renderable as a 400). `false` for genuine
339    /// infrastructure / serialization failures (which should
340    /// surface as 500s).
341    pub fn is_validation(&self) -> bool {
342        use WriteError::*;
343        !matches!(self, Sqlx(_) | SerializeFailed(_) | NotAnObject)
344    }
345}
346
347/// JSON-value display used inside error messages. Strings are
348/// quoted, numbers / bools / null appear bare, arrays / objects
349/// fall back to compact JSON.
350fn repr_json_value(v: &serde_json::Value) -> String {
351    match v {
352        serde_json::Value::String(s) => format!("'{s}'"),
353        serde_json::Value::Number(n) => n.to_string(),
354        serde_json::Value::Bool(b) => b.to_string(),
355        serde_json::Value::Null => "null".to_string(),
356        _ => serde_json::to_string(v).unwrap_or_else(|_| "(?)".to_string()),
357    }
358}
359
360impl std::fmt::Display for WriteError {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        match self {
363            WriteError::RequiredFieldMissing { field } => write!(
364                f,
365                "umbral::orm::write: required field `{field}` is missing or null"
366            ),
367            WriteError::ReadOnlyView { table } => write!(
368                f,
369                "umbral::orm::write: `{table}` is backed by a database view \
370                 (#[umbral(view = \"...\")]) and is read-only — no insert, update or \
371                 delete can run against it. Write to the underlying table instead."
372            ),
373            WriteError::BlankNotAllowed { field } => {
374                write!(f, "umbral::orm::write: field `{field}` cannot be blank")
375            }
376            WriteError::AmbiguousLocalTime {
377                field,
378                value,
379                tz,
380                earlier,
381                later,
382            } => write!(
383                f,
384                "umbral::orm::write: field `{field}`: local time '{value}' is ambiguous in \
385                 `{tz}` — the clocks go back, so it occurs twice ({earlier} and {later}). \
386                 Send an explicit UTC offset to say which you mean."
387            ),
388            WriteError::NonexistentLocalTime { field, value, tz } => write!(
389                f,
390                "umbral::orm::write: field `{field}`: local time '{value}' does not exist in \
391                 `{tz}` — the clocks go forward across it. Send an explicit UTC offset, or a \
392                 time outside the gap."
393            ),
394            WriteError::ForeignKeyNotFound {
395                field,
396                target_table,
397                value,
398            } => write!(
399                f,
400                "umbral::orm::write: field `{field}` references `{target_table}` row with id={} which does not exist",
401                repr_json_value(value),
402            ),
403            WriteError::UniqueViolation { field, value } => match (field, value) {
404                (Some(f_), Some(v)) => write!(
405                    f,
406                    "umbral::orm::write: unique constraint on `{f_}`={} violated",
407                    repr_json_value(v),
408                ),
409                (Some(f_), None) => {
410                    write!(
411                        f,
412                        "umbral::orm::write: unique constraint on `{f_}` violated"
413                    )
414                }
415                _ => write!(f, "umbral::orm::write: unique constraint violated"),
416            },
417            WriteError::NotNullViolation { field } => match field {
418                Some(f_) => write!(f, "umbral::orm::write: NOT NULL on `{f_}` violated"),
419                None => write!(f, "umbral::orm::write: NOT NULL violation"),
420            },
421            WriteError::CheckViolation { constraint } => match constraint {
422                Some(c) => write!(f, "umbral::orm::write: CHECK `{c}` violated"),
423                None => write!(f, "umbral::orm::write: CHECK constraint violated"),
424            },
425            WriteError::ForeignKeyViolation { field } => match field {
426                Some(f_) => write!(
427                    f,
428                    "umbral::orm::write: foreign-key constraint on `{f_}` violated"
429                ),
430                None => write!(f, "umbral::orm::write: foreign-key constraint violated"),
431            },
432            WriteError::Multiple { errors } => {
433                write!(
434                    f,
435                    "umbral::orm::write: {} validation error(s)",
436                    errors.len()
437                )
438            }
439            WriteError::TypeMismatch {
440                field,
441                expected,
442                got,
443            } => write!(
444                f,
445                "umbral::orm::write: field `{field}` expected `{expected:?}`, got `{got}`",
446            ),
447            WriteError::Validator { field, message } => {
448                write!(f, "umbral::orm::write: field `{field}` {message}")
449            }
450            WriteError::NotAnObject => write!(
451                f,
452                "umbral::orm::write: model didn't serialize to a JSON object — make sure your struct uses a flat field layout",
453            ),
454            WriteError::SerializeFailed(e) => write!(f, "umbral::orm::write: serialize: {e}"),
455            WriteError::Sqlx(e) => write!(f, "umbral::orm::write: sqlx: {e}"),
456            WriteError::UnknownColumn { field } => {
457                write!(f, "umbral::orm::write: unknown column `{field}` on model")
458            }
459        }
460    }
461}
462
463impl std::error::Error for WriteError {}
464
465impl From<sqlx::Error> for WriteError {
466    fn from(e: sqlx::Error) -> Self {
467        Self::Sqlx(e)
468    }
469}
470
471impl From<serde_json::Error> for WriteError {
472    fn from(e: serde_json::Error) -> Self {
473        Self::SerializeFailed(e)
474    }
475}
476
477/// Convert a `serde_json::Value` to a `sea_query::Value` per the
478/// column's declared `SqlType`. The `nullable` flag controls how
479/// `JsonValue::Null` is handled:
480///
481/// - `nullable = true`: NULL is bound (the right SeaValue variant
482///   with `None`).
483/// - `nullable = false`: NULL produces `RequiredFieldMissing`.
484///
485/// String / number coercions follow the HTML-form-and-REST norms:
486/// `"true"` / `"false"` strings coerce to booleans, `"123"` strings
487/// coerce to numbers. RFC 3339 timestamps come through as strings on
488/// JSON inputs (serde_json doesn't have a native datetime).
489///
490/// `fk_target_pk` carries the target PK's `SqlType` for a `ForeignKey`
491/// column (gaps2 #42). This function can't see `fk_target`, so without
492/// it the FK arm couldn't tell an i64-PK FK from a String-PK one and
493/// bound every string-valued FK id as TEXT — which a Postgres `bigint`
494/// FK column rejects (`column "..." is of type bigint but expression is
495/// of type text`). Callers resolve the target PK type and pass it here
496/// (`Some(Text)` / `Some(Uuid)` bind as-is; numeric-PK or unresolved
497/// targets coerce the string → BigInt). `None` for every non-FK column.
498/// True when a column stores encrypt-at-rest `Masked<T>` data. The derive
499/// marks such columns with the (forced, non-overridable) `"masked"` widget on a
500/// `Text` column, so this is a reliable signal on the dynamic write path where
501/// the typed `Masked<T>` sealing (serde `Serialize` / sqlx `Encode`) never runs.
502pub fn is_masked_col(col: &crate::migrate::Column) -> bool {
503    col.ty == SqlType::Text && col.widget.as_deref() == Some("masked")
504}
505
506/// True when a masked column's submitted value carries no new secret — an
507/// empty string or the echoed-back redaction marker (`"••••••"`). Such a
508/// value must be OMITTED from a write, never sealed: sealing `""` /
509/// `"••••••"` over the stored ciphertext crypto-shreds the secret (a
510/// routine edit of an unrelated column would destroy it). Mirrors the
511/// `Masked` `Deserialize` contract (REDACTED / empty → no change) on the
512/// dynamic write path, which never consults it. The form update path
513/// applies the same rule inline (gaps4 #5); this covers the JSON path.
514pub fn is_masked_no_change(col: &crate::migrate::Column, value: &JsonValue) -> bool {
515    is_masked_col(col)
516        && value
517            .as_str()
518            .map(|s| s.is_empty() || s == crate::orm::masked::REDACTED)
519            .unwrap_or(false)
520}
521
522/// Seal a masked column's plaintext before it is bound, so the dynamic REST /
523/// admin write paths (`insert_json`/`update_json`/form-submit) encrypt at rest
524/// — not just the typed `Masked<T>` path (audit_2 core-orm C1). Returns
525/// `Some(ciphertext-json)` to bind when `col` is masked and `value` is a
526/// non-null string; `None` when no sealing applies (bind `value` as-is). A
527/// missing/broken keyring fails the write closed rather than storing plaintext.
528pub fn seal_masked_json(
529    col: &crate::migrate::Column,
530    value: &JsonValue,
531) -> Result<Option<JsonValue>, WriteError> {
532    if !is_masked_col(col) || value.is_null() {
533        return Ok(None);
534    }
535    let plain = coerce_string(value, &col.name)?;
536    let sealed = crate::orm::masked::ambient_seal(&plain).map_err(|e| WriteError::Validator {
537        field: col.name.clone(),
538        message: format!("could not seal masked field: {e}"),
539    })?;
540    Ok(Some(JsonValue::String(sealed)))
541}
542
543pub fn json_to_sea_value(
544    sql_type: SqlType,
545    value: &JsonValue,
546    nullable: bool,
547    field_name: &str,
548    fk_target_pk: Option<SqlType>,
549) -> Result<SeaValue, WriteError> {
550    // null handling first — applies regardless of expected type.
551    if value.is_null() {
552        if !nullable {
553            return Err(WriteError::RequiredFieldMissing {
554                field: field_name.to_string(),
555            });
556        }
557        return Ok(null_for(sql_type));
558    }
559
560    match sql_type {
561        SqlType::Boolean => coerce_bool(value, field_name),
562        SqlType::SmallInt | SqlType::Integer => {
563            coerce_i32(value, field_name).map(|v| SeaValue::Int(Some(v)))
564        }
565        SqlType::BigInt => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
566        // gaps2 #42: bind a `ForeignKey` id against its TARGET PK's
567        // type, not the JSON value's runtime shape. Before, a
568        // `JsonValue::String("1")` FK id was bound TEXT unconditionally
569        // because this function couldn't see `fk_target` — which a
570        // Postgres `bigint` FK column rejects ("column ... is of type
571        // bigint but expression is of type text"). The caller now
572        // resolves the target PK type (via `fk_target_pk_sql_type` /
573        // `pk_meta_for_table`) and threads it in as `fk_target_pk`:
574        //   - Text-PK target  → bind the id as text;
575        //   - Uuid-PK target  → parse + bind a UUID;
576        //   - numeric-PK target (or unresolved, the common i64 case)
577        //     → coerce the string / number → BigInt.
578        // `coerce_i64` already accepts `JsonValue::String("1")`, so a
579        // numeric string now binds `BigInt(1)`. This mirrors
580        // `form_str_to_sea_value`'s FK arm in `orm/dynamic.rs`.
581        SqlType::ForeignKey => match fk_target_pk {
582            Some(SqlType::Text) => {
583                coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
584            }
585            Some(SqlType::Uuid) => match value {
586                JsonValue::String(s) => uuid::Uuid::parse_str(s)
587                    .map(|u| SeaValue::Uuid(Some(Box::new(u))))
588                    .map_err(|_| WriteError::TypeMismatch {
589                        field: field_name.to_string(),
590                        expected: SqlType::Uuid,
591                        got: s.clone(),
592                    }),
593                _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
594            },
595            _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
596        },
597        SqlType::Real => coerce_f32(value, field_name).map(|v| SeaValue::Float(Some(v))),
598        SqlType::Double => coerce_f64(value, field_name).map(|v| SeaValue::Double(Some(v))),
599        SqlType::Text => {
600            coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
601        }
602        SqlType::Date => {
603            let s = coerce_string(value, field_name)?;
604            let d = chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(|_| {
605                WriteError::TypeMismatch {
606                    field: field_name.to_string(),
607                    expected: sql_type,
608                    got: format!("{value:?}"),
609                }
610            })?;
611            Ok(SeaValue::ChronoDate(Some(Box::new(d))))
612        }
613        SqlType::Time => {
614            let s = coerce_string(value, field_name)?;
615            // Accept, in order: a fractional-second form (`%.f` matches
616            // `.123`, `.123456`, `.123456789` — what serde/chrono emit for a
617            // sub-second `NaiveTime`), then whole seconds, then HH:MM. Without
618            // the `%.f` arm a `NaiveTime` carrying microseconds — the default
619            // serde wire shape — could never be written on any backend.
620            let t = chrono::NaiveTime::parse_from_str(&s, "%H:%M:%S%.f")
621                .or_else(|_| chrono::NaiveTime::parse_from_str(&s, "%H:%M:%S"))
622                .or_else(|_| chrono::NaiveTime::parse_from_str(&s, "%H:%M"))
623                .map_err(|_| WriteError::TypeMismatch {
624                    field: field_name.to_string(),
625                    expected: sql_type,
626                    got: format!("{value:?}"),
627                })?;
628            Ok(SeaValue::ChronoTime(Some(Box::new(t))))
629        }
630        SqlType::Timestamptz => {
631            let s = coerce_string(value, field_name)?;
632            // Accept several wire shapes that real callers send:
633            //   1. RFC3339 with offset / Z — the canonical machine form
634            //      and what serde / API clients emit.
635            //   2. Naive `YYYY-MM-DDTHH:MM:SS` — common for hand-written
636            //      JSON and typical form serializers.
637            //   3. Naive `YYYY-MM-DDTHH:MM` — the literal output of HTML
638            //      `<input type="datetime-local">`. The admin's
639            //      auto-generated forms post exactly this shape, so
640            //      rejecting it broke every Timestamptz field edit.
641            // Gap 106: naive forms are interpreted in the
642            // configured `Settings::time_zone` (falling back to UTC
643            // when the setting is absent), then converted to UTC
644            // for storage. Tz-bearing RFC3339 inputs win regardless
645            // of the project tz — the offset they carry is the
646            // ground truth.
647            if let Ok(offset_bearing) = chrono::DateTime::parse_from_rfc3339(&s) {
648                let utc = offset_bearing.with_timezone(&chrono::Utc);
649                return Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(utc))));
650            }
651
652            let naive = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
653                .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M"))
654                .map_err(|_| WriteError::TypeMismatch {
655                    field: field_name.to_string(),
656                    expected: sql_type,
657                    got: format!("{value:?}"),
658                })?;
659
660            // gaps3 #42: a wall-clock reading is not a moment in time. Twice a
661            // year the project timezone maps one reading to two instants, or to
662            // none. This used to `unwrap_or_else(|| naive.and_utc())`, storing
663            // the reading as if it were UTC — not one of the two candidates but
664            // a third instant, hours from what the user meant. Refuse instead,
665            // and tell them to send an offset.
666            let tz = crate::timezone::active_tz();
667            let dt = crate::timezone::naive_local_to_utc_checked(naive).map_err(|e| match e {
668                crate::timezone::LocalTimeError::Ambiguous { earlier, later } => {
669                    WriteError::AmbiguousLocalTime {
670                        field: field_name.to_string(),
671                        value: s.clone(),
672                        tz: tz.name().to_string(),
673                        earlier: earlier.to_rfc3339(),
674                        later: later.to_rfc3339(),
675                    }
676                }
677                crate::timezone::LocalTimeError::Nonexistent => WriteError::NonexistentLocalTime {
678                    field: field_name.to_string(),
679                    value: s.clone(),
680                    tz: tz.name().to_string(),
681                },
682            })?;
683            Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(dt))))
684        }
685        // A naive `TIMESTAMP` (no time zone). Unlike `Timestamptz`, the value is
686        // a wall-clock reading stored verbatim — NO project-timezone conversion,
687        // and an offset-bearing input is rejected rather than silently shifted.
688        SqlType::Timestamp => {
689            let s = coerce_string(value, field_name)?;
690            let naive = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.f")
691                .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S"))
692                .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M"))
693                .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.f"))
694                .map_err(|_| WriteError::TypeMismatch {
695                    field: field_name.to_string(),
696                    expected: sql_type,
697                    got: format!("{value:?}"),
698                })?;
699            Ok(SeaValue::ChronoDateTime(Some(Box::new(naive))))
700        }
701        SqlType::Uuid => {
702            let s = coerce_string(value, field_name)?;
703            let u = uuid::Uuid::parse_str(&s).map_err(|_| WriteError::TypeMismatch {
704                field: field_name.to_string(),
705                expected: sql_type,
706                got: format!("{value:?}"),
707            })?;
708            Ok(SeaValue::Uuid(Some(Box::new(u))))
709        }
710        SqlType::Json => {
711            // Store the JSON as-is so sqlx binds JSON/JSONB with the
712            // backend's typed encoder instead of a plain text parameter.
713            Ok(SeaValue::Json(Some(Box::new(value.clone()))))
714        }
715        // Postgres-only catalogue. Returned as a serialized string;
716        // the per-backend bind layer downstream handles the cast.
717        // These paths are only reachable for PG-bound models (the
718        // field.backend check at App::build blocks SQLite).
719        SqlType::Array(_)
720        | SqlType::Inet
721        | SqlType::Cidr
722        | SqlType::MacAddr
723        // gaps2 #70: XML / LTREE / BIT VARYING are text-backed — the
724        // value arrives as a JSON string and binds as a text parameter;
725        // Postgres applies the column's own cast on the way in.
726        | SqlType::Xml
727        | SqlType::Ltree
728        | SqlType::Bit
729        | SqlType::FullText => Ok(SeaValue::String(Some(Box::new(coerce_string(
730            value, field_name,
731        )?)))),
732        // BLOB / BYTEA. JSON wire shape: an array of u8 numbers, the
733        // natural way to encode a byte string in JSON without picking
734        // a base16/base64 convention at the framework level.
735        // Hex-encoded JSON strings also accepted as a convenience for
736        // human-readable test fixtures.
737        SqlType::Bytes => {
738            coerce_bytes(value, field_name).map(|b| SeaValue::Bytes(Some(Box::new(b))))
739        }
740        // BUG-10: NUMERIC. Accept JSON numbers (round-trip through
741        // f64 — adequate for most reasonable values; truly large
742        // exact decimals come in as strings) AND JSON strings
743        // (canonical for money values). Anything else fails the
744        // typed coerce.
745        SqlType::Decimal | SqlType::DecimalN(_) => coerce_decimal(value, field_name),
746        SqlType::BigDecimal => coerce_bigdecimal(value, field_name),
747        // PostGIS: bind an EWKT string (`SRID=4326;POINT(…)`) as text — the
748        // column's text→geometry cast parses it. The GeoJSON→EWKT conversion
749        // lives behind the `postgis` feature (`coerce_geometry`).
750        SqlType::Geometry(spec) | SqlType::Geography(spec) => {
751            coerce_geometry(value, field_name, spec.srid)
752        }
753    }
754}
755
756/// Convert a geometry write value to an EWKT `SeaValue::String`. Feature-gated:
757/// the geo codec only compiles with `postgis`.
758#[cfg(feature = "postgis")]
759fn coerce_geometry(value: &JsonValue, field_name: &str, srid: i32) -> Result<SeaValue, WriteError> {
760    let ewkt =
761        crate::orm::gis::coerce_to_ewkt(value, srid).map_err(|e| WriteError::TypeMismatch {
762            field: field_name.to_string(),
763            expected: SqlType::Geometry(crate::orm::GeometrySpec {
764                kind: crate::orm::GeometryKind::Geometry,
765                srid,
766            }),
767            got: e,
768        })?;
769    Ok(SeaValue::String(Some(Box::new(ewkt))))
770}
771
772/// Without the `postgis` feature a geometry column can only arrive from a
773/// snapshot/inspectdb (the `Geometry` field type isn't compiled in), so a write
774/// to one is a configuration error rather than a value error.
775#[cfg(not(feature = "postgis"))]
776fn coerce_geometry(
777    _value: &JsonValue,
778    field_name: &str,
779    srid: i32,
780) -> Result<SeaValue, WriteError> {
781    Err(WriteError::TypeMismatch {
782        field: field_name.to_string(),
783        expected: SqlType::Geometry(crate::orm::GeometrySpec {
784            kind: crate::orm::GeometryKind::Geometry,
785            srid,
786        }),
787        got: "writing a PostGIS geometry requires the `postgis` cargo feature".to_string(),
788    })
789}
790
791fn coerce_decimal(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
792    use std::str::FromStr;
793    // Round-trip through the serde_json textual representation —
794    // serde_json::Number prints integers / floats verbatim, so
795    // `n.to_string()` reads back as the same precision the wire
796    // value carried. Avoids the f64 trap of "3.10" arriving as
797    // 3.1000000000000001.
798    let parsed: Option<rust_decimal::Decimal> = match value {
799        JsonValue::String(s) => rust_decimal::Decimal::from_str(s).ok(),
800        JsonValue::Number(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(),
801        _ => None,
802    };
803    parsed
804        .map(|d| SeaValue::Decimal(Some(Box::new(d))))
805        .ok_or_else(|| WriteError::TypeMismatch {
806            field: field_name.to_string(),
807            expected: SqlType::Decimal,
808            got: format!("{value:?}"),
809        })
810}
811
812/// The [`coerce_decimal`] twin for arbitrary-precision `numeric`. Parses the
813/// textual JSON form into a `bigdecimal::BigDecimal`, which — unlike
814/// `rust_decimal::Decimal` — carries as many digits as the value needs, so a
815/// 40-digit `numeric` written through the typed path is not silently truncated
816/// to ~28 significant figures.
817fn coerce_bigdecimal(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
818    use std::str::FromStr;
819    let parsed: Option<bigdecimal::BigDecimal> = match value {
820        JsonValue::String(s) => bigdecimal::BigDecimal::from_str(s).ok(),
821        JsonValue::Number(n) => bigdecimal::BigDecimal::from_str(&n.to_string()).ok(),
822        _ => None,
823    };
824    parsed
825        .map(|d| SeaValue::BigDecimal(Some(Box::new(d))))
826        .ok_or_else(|| WriteError::TypeMismatch {
827            field: field_name.to_string(),
828            expected: SqlType::BigDecimal,
829            got: format!("{value:?}"),
830        })
831}
832
833/// Coerce a `serde_json::Value` to `Vec<u8>`. Accepts:
834///   - `[1, 2, 3, ...]` — JSON array of u8-shaped numbers.
835///   - `"deadbeef"` — lowercase hex string of even length.
836fn coerce_bytes(value: &JsonValue, field_name: &str) -> Result<Vec<u8>, WriteError> {
837    if let Some(arr) = value.as_array() {
838        let mut out = Vec::with_capacity(arr.len());
839        for v in arr {
840            let n = v.as_u64().ok_or_else(|| WriteError::TypeMismatch {
841                field: field_name.to_string(),
842                expected: SqlType::Bytes,
843                got: format!("{v:?}"),
844            })?;
845            if n > 255 {
846                return Err(WriteError::TypeMismatch {
847                    field: field_name.to_string(),
848                    expected: SqlType::Bytes,
849                    got: format!("element {v} out of u8 range"),
850                });
851            }
852            out.push(n as u8);
853        }
854        return Ok(out);
855    }
856    if let Some(s) = value.as_str() {
857        if s.len() % 2 != 0 {
858            return Err(WriteError::TypeMismatch {
859                field: field_name.to_string(),
860                expected: SqlType::Bytes,
861                got: "hex string has odd length".to_string(),
862            });
863        }
864        let mut out = Vec::with_capacity(s.len() / 2);
865        for chunk in s.as_bytes().chunks(2) {
866            let high = hex_nibble(chunk[0]).ok_or_else(|| WriteError::TypeMismatch {
867                field: field_name.to_string(),
868                expected: SqlType::Bytes,
869                got: format!("non-hex char `{}`", chunk[0] as char),
870            })?;
871            let low = hex_nibble(chunk[1]).ok_or_else(|| WriteError::TypeMismatch {
872                field: field_name.to_string(),
873                expected: SqlType::Bytes,
874                got: format!("non-hex char `{}`", chunk[1] as char),
875            })?;
876            out.push((high << 4) | low);
877        }
878        return Ok(out);
879    }
880    Err(WriteError::TypeMismatch {
881        field: field_name.to_string(),
882        expected: SqlType::Bytes,
883        got: format!("{value:?}"),
884    })
885}
886
887fn hex_nibble(b: u8) -> Option<u8> {
888    match b {
889        b'0'..=b'9' => Some(b - b'0'),
890        b'a'..=b'f' => Some(10 + b - b'a'),
891        b'A'..=b'F' => Some(10 + b - b'A'),
892        _ => None,
893    }
894}
895
896/// Build the sea-query value the framework substitutes when an
897/// `auto_now` / `auto_now_add` column needs to be auto-populated.
898/// Used by [`crate::orm::dynamic::DynQuerySet::insert_json`] and
899/// `update_json`. Closes BUG-5 from `bugs/tests/testBugs.md`.
900///
901/// Supported column types: `Timestamptz` (the common case), `Date`,
902/// `Time`. Anything else falls back to the SQL NULL form for that
903/// column type, since a non-time column tagged `#[umbral(auto_now)]`
904/// is a developer mistake — there's no sensible "now" value to
905/// produce. The macro could in principle reject the attribute on
906/// non-time columns at derive time; we defer that polish to the
907/// macro pass where it lands alongside other "wrong attribute on
908/// wrong type" diagnostics.
909/// Gap 109: slug derivation. Lowercases the input, replaces
910/// runs of non-alphanumeric ASCII characters with a single `-`, trims
911/// leading/trailing dashes, and collapses repeated dashes. Empty / pure-
912/// punctuation input returns the empty string.
913///
914/// Mirrors what most slug libraries do for the ASCII path; non-ASCII
915/// characters are dropped (we don't transliterate at v1 — a unicode
916/// transliterator is a heavier dep and our admins typically slugify
917/// English-language titles).
918pub fn slugify(s: &str) -> String {
919    let mut out = String::with_capacity(s.len());
920    let mut last_was_dash = true; // suppresses leading dashes
921    for c in s.chars() {
922        if c.is_ascii_alphanumeric() {
923            for low in c.to_lowercase() {
924                out.push(low);
925            }
926            last_was_dash = false;
927        } else if !last_was_dash {
928            out.push('-');
929            last_was_dash = true;
930        }
931    }
932    // Trim trailing dash if any.
933    while out.ends_with('-') {
934        out.pop();
935    }
936    out
937}
938
939/// Gap 109: walk the body and auto-derive slug columns from their
940/// configured source field where the slug column is missing or empty.
941///
942/// Called from the dynamic insert/update entry points BEFORE validation
943/// so the validator sees the populated slug. The `is_update` flag
944/// constrains the rule: on update, the slug is regenerated only when
945/// the source field is also present in the body. Without that guard,
946/// editing an unrelated column on an existing row would clobber a hand-
947/// tuned slug.
948pub fn apply_slug_from(
949    fields: &[crate::migrate::Column],
950    body: &mut serde_json::Map<String, serde_json::Value>,
951    is_update: bool,
952) {
953    apply_slug_core(
954        fields
955            .iter()
956            .map(|c| (c.name.as_str(), c.slug_from.as_deref())),
957        body,
958        is_update,
959    );
960}
961
962/// `FieldSpec` twin of [`apply_slug_from`], for the TYPED write path
963/// (`objects().create()` / `bulk_create()`), which carries
964/// `&[FieldSpec]` rather than the dynamic path's `&[Column]`. Same safe
965/// rule, one shared core — so typed and dynamic writes derive slugs
966/// identically (a column that auto-slugs through REST must auto-slug
967/// through `create()` too; features #83 closed the same split for
968/// `trim`/`lowercase`).
969pub fn apply_slug_from_specs(
970    fields: &[crate::orm::FieldSpec],
971    body: &mut serde_json::Map<String, serde_json::Value>,
972    is_update: bool,
973) {
974    apply_slug_core(
975        fields.iter().map(|f| (f.name, f.slug_from)),
976        body,
977        is_update,
978    );
979}
980
981/// Shared implementation for [`apply_slug_from`] / [`apply_slug_from_specs`].
982/// Takes `(column_name, slug_from_source)` pairs so `Column` (dynamic
983/// path) and `FieldSpec` (typed path) feed the same logic.
984fn apply_slug_core<'a>(
985    cols: impl Iterator<Item = (&'a str, Option<&'a str>)>,
986    body: &mut serde_json::Map<String, serde_json::Value>,
987    is_update: bool,
988) {
989    for (name, slug_from) in cols {
990        let Some(source) = slug_from else {
991            continue;
992        };
993        // Slug already explicitly supplied (non-empty string) — keep it.
994        let explicit = body
995            .get(name)
996            .and_then(|v| v.as_str())
997            .map(|s| !s.is_empty())
998            .unwrap_or(false);
999        if explicit {
1000            continue;
1001        }
1002        // On update, only regenerate when the source field is in the body.
1003        let source_value = body
1004            .get(source)
1005            .and_then(|v| v.as_str())
1006            .unwrap_or("")
1007            .to_string();
1008        if source_value.is_empty() {
1009            continue;
1010        }
1011        if is_update && !body.contains_key(source) {
1012            continue;
1013        }
1014        let slug = slugify(&source_value);
1015        if slug.is_empty() {
1016            continue;
1017        }
1018        body.insert(name.to_string(), serde_json::Value::String(slug));
1019    }
1020}
1021
1022/// Fill `#[umbral(auto_uuid)]` columns with a fresh random `Uuid::new_v4()` on
1023/// INSERT when the body omits them (or leaves the nil UUID). The write-side
1024/// twin of a DB `gen_random_uuid()` default, but generated in Rust so it works
1025/// identically on SQLite and Postgres. Update is a no-op — a public id is
1026/// stable once assigned. Mirrors [`apply_slug_from`]. v4 (fully random) not v7,
1027/// so a public id can't be ordered back into row-creation sequence.
1028pub fn apply_auto_uuid(
1029    fields: &[crate::migrate::Column],
1030    body: &mut serde_json::Map<String, serde_json::Value>,
1031    is_update: bool,
1032) {
1033    if is_update {
1034        return;
1035    }
1036    const NIL: &str = "00000000-0000-0000-0000-000000000000";
1037    for col in fields {
1038        if !col.auto_uuid {
1039            continue;
1040        }
1041        // An explicitly-supplied, non-nil value is kept — the caller chose it.
1042        let supplied = body
1043            .get(&col.name)
1044            .and_then(|v| v.as_str())
1045            .map(|s| !s.is_empty() && s != NIL)
1046            .unwrap_or(false);
1047        if supplied {
1048            continue;
1049        }
1050        body.insert(
1051            col.name.clone(),
1052            serde_json::Value::String(uuid::Uuid::new_v4().to_string()),
1053        );
1054    }
1055}
1056
1057pub fn now_for_column(sql_type: SqlType) -> SeaValue {
1058    let now = chrono::Utc::now();
1059    match sql_type {
1060        SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(Some(Box::new(now))),
1061        // Naive `TIMESTAMP`: stamp the current wall-clock without an offset.
1062        SqlType::Timestamp => SeaValue::ChronoDateTime(Some(Box::new(now.naive_utc()))),
1063        SqlType::Date => SeaValue::ChronoDate(Some(Box::new(now.date_naive()))),
1064        SqlType::Time => SeaValue::ChronoTime(Some(Box::new(now.time()))),
1065        _ => null_for(sql_type),
1066    }
1067}
1068
1069/// The authenticated caller's id, bound as `sql_type` — what
1070/// `#[umbral(auto_user_add)]` / `#[umbral(auto_user)]` stamp (gaps3 #55).
1071///
1072/// The id travels through [`crate::db::route_context::current_user_id`] as a
1073/// string so the user model's PK shape doesn't leak into the ambient context;
1074/// it is converted back here to whatever the *stamping* column actually is —
1075/// `BigInt` for the usual `ForeignKey<AuthUser>`, `Text` for a slug-keyed user,
1076/// `Uuid` for a uuid-keyed one.
1077///
1078/// No user in scope (a background job, the CLI, an anonymous request) → NULL.
1079/// We stamp nothing rather than invent an author; that is the honest answer, and
1080/// it is why an `auto_user` column must be nullable.
1081pub fn user_for_column(sql_type: SqlType) -> SeaValue {
1082    let Some(id) = crate::db::route_context::current_user_id() else {
1083        return null_for(sql_type);
1084    };
1085    match sql_type {
1086        SqlType::SmallInt | SqlType::Integer => match id.parse::<i32>() {
1087            Ok(v) => SeaValue::Int(Some(v)),
1088            Err(_) => null_for(sql_type),
1089        },
1090        SqlType::BigInt => match id.parse::<i64>() {
1091            Ok(v) => SeaValue::BigInt(Some(v)),
1092            Err(_) => null_for(sql_type),
1093        },
1094        SqlType::Uuid => match id.parse::<uuid::Uuid>() {
1095            Ok(v) => SeaValue::Uuid(Some(Box::new(v))),
1096            Err(_) => null_for(sql_type),
1097        },
1098        SqlType::Text => SeaValue::String(Some(Box::new(id))),
1099        // A non-identity column tagged `auto_user` is a declaration error the
1100        // `model.auto_user` boot check rejects; NULL here so a slipped-through
1101        // case cannot write a nonsense value.
1102        _ => null_for(sql_type),
1103    }
1104}
1105
1106/// Apply `#[umbral(trim)]` / `#[umbral(lowercase)]` to a JSON value.
1107///
1108/// **The declarative normalizers were dyn-path-only.** REST and the admin honoured
1109/// them; `Model::objects().create(user)` did not — so the *same* field normalized
1110/// or didn't depending on who wrote the row, and `alice@x.com` from REST could sit
1111/// beside `  Alice@X.com  ` written by a seed script or a background job. A
1112/// case-insensitive unique index then rejects a legitimate signup, or two accounts
1113/// exist for one human. Declaring the rule on the field has to mean every write
1114/// path obeys it, or the declaration is a lie.
1115///
1116/// Returns `None` when the column declares no normalization (the common case, so
1117/// the caller can skip a clone).
1118pub fn normalize_json(trim: bool, lowercase: bool, v: &JsonValue) -> Option<JsonValue> {
1119    if !(trim || lowercase) {
1120        return None;
1121    }
1122    let s = v.as_str()?;
1123    let s = if trim { s.trim() } else { s };
1124    let out = if lowercase {
1125        s.to_lowercase()
1126    } else {
1127        s.to_string()
1128    };
1129    Some(JsonValue::String(out))
1130}
1131
1132/// Sea-query value representing SQL NULL for the given SqlType. The
1133/// variant tag matters for sea-query's encoding even when the inner
1134/// option is `None`.
1135pub(crate) fn null_for(sql_type: SqlType) -> SeaValue {
1136    match sql_type {
1137        SqlType::Boolean => SeaValue::Bool(None),
1138        SqlType::SmallInt | SqlType::Integer => SeaValue::Int(None),
1139        SqlType::BigInt | SqlType::ForeignKey => SeaValue::BigInt(None),
1140        SqlType::Real => SeaValue::Float(None),
1141        SqlType::Double => SeaValue::Double(None),
1142        SqlType::Text => SeaValue::String(None),
1143        SqlType::Json => SeaValue::Json(None),
1144        SqlType::Date => SeaValue::ChronoDate(None),
1145        SqlType::Time => SeaValue::ChronoTime(None),
1146        SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(None),
1147        SqlType::Timestamp => SeaValue::ChronoDateTime(None),
1148        SqlType::Uuid => SeaValue::Uuid(None),
1149        SqlType::Array(_)
1150        | SqlType::Inet
1151        | SqlType::Cidr
1152        | SqlType::MacAddr
1153        | SqlType::Xml
1154        | SqlType::Ltree
1155        | SqlType::Bit
1156        | SqlType::FullText => SeaValue::String(None),
1157        SqlType::Bytes => SeaValue::Bytes(None),
1158        SqlType::Decimal | SqlType::DecimalN(_) => SeaValue::Decimal(None),
1159        SqlType::BigDecimal => SeaValue::BigDecimal(None),
1160        // Geometry binds as EWKT text, so its NULL is a NULL text.
1161        SqlType::Geometry(_) | SqlType::Geography(_) => SeaValue::String(None),
1162    }
1163}
1164
1165fn coerce_bool(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
1166    match value {
1167        JsonValue::Bool(b) => Ok(SeaValue::Bool(Some(*b))),
1168        JsonValue::String(s) => match s.as_str() {
1169            "true" | "1" | "yes" | "on" => Ok(SeaValue::Bool(Some(true))),
1170            "false" | "0" | "no" | "off" | "" => Ok(SeaValue::Bool(Some(false))),
1171            _ => Err(WriteError::TypeMismatch {
1172                field: field_name.to_string(),
1173                expected: SqlType::Boolean,
1174                got: format!("{value:?}"),
1175            }),
1176        },
1177        JsonValue::Number(n) => Ok(SeaValue::Bool(Some(n.as_i64() != Some(0)))),
1178        _ => Err(WriteError::TypeMismatch {
1179            field: field_name.to_string(),
1180            expected: SqlType::Boolean,
1181            got: format!("{value:?}"),
1182        }),
1183    }
1184}
1185
1186fn coerce_i32(value: &JsonValue, field_name: &str) -> Result<i32, WriteError> {
1187    match value {
1188        JsonValue::Number(n) => n
1189            .as_i64()
1190            .and_then(|i| i32::try_from(i).ok())
1191            .ok_or_else(|| WriteError::TypeMismatch {
1192                field: field_name.to_string(),
1193                expected: SqlType::Integer,
1194                got: format!("{value:?}"),
1195            }),
1196        JsonValue::String(s) => s.parse::<i32>().map_err(|_| WriteError::TypeMismatch {
1197            field: field_name.to_string(),
1198            expected: SqlType::Integer,
1199            got: s.clone(),
1200        }),
1201        _ => Err(WriteError::TypeMismatch {
1202            field: field_name.to_string(),
1203            expected: SqlType::Integer,
1204            got: format!("{value:?}"),
1205        }),
1206    }
1207}
1208
1209fn coerce_i64(value: &JsonValue, field_name: &str) -> Result<i64, WriteError> {
1210    match value {
1211        JsonValue::Number(n) => n.as_i64().ok_or_else(|| WriteError::TypeMismatch {
1212            field: field_name.to_string(),
1213            expected: SqlType::BigInt,
1214            got: format!("{value:?}"),
1215        }),
1216        JsonValue::String(s) => s.parse::<i64>().map_err(|_| WriteError::TypeMismatch {
1217            field: field_name.to_string(),
1218            expected: SqlType::BigInt,
1219            got: s.clone(),
1220        }),
1221        _ => Err(WriteError::TypeMismatch {
1222            field: field_name.to_string(),
1223            expected: SqlType::BigInt,
1224            got: format!("{value:?}"),
1225        }),
1226    }
1227}
1228
1229fn coerce_f32(value: &JsonValue, field_name: &str) -> Result<f32, WriteError> {
1230    coerce_f64(value, field_name).map(|v| v as f32)
1231}
1232
1233fn coerce_f64(value: &JsonValue, field_name: &str) -> Result<f64, WriteError> {
1234    match value {
1235        JsonValue::Number(n) => n.as_f64().ok_or_else(|| WriteError::TypeMismatch {
1236            field: field_name.to_string(),
1237            expected: SqlType::Double,
1238            got: format!("{value:?}"),
1239        }),
1240        JsonValue::String(s) => s.parse::<f64>().map_err(|_| WriteError::TypeMismatch {
1241            field: field_name.to_string(),
1242            expected: SqlType::Double,
1243            got: s.clone(),
1244        }),
1245        _ => Err(WriteError::TypeMismatch {
1246            field: field_name.to_string(),
1247            expected: SqlType::Double,
1248            got: format!("{value:?}"),
1249        }),
1250    }
1251}
1252
1253fn coerce_string(value: &JsonValue, field_name: &str) -> Result<String, WriteError> {
1254    match value {
1255        JsonValue::String(s) => Ok(s.clone()),
1256        JsonValue::Number(n) => Ok(n.to_string()),
1257        JsonValue::Bool(b) => Ok(b.to_string()),
1258        _ => Err(WriteError::TypeMismatch {
1259            field: field_name.to_string(),
1260            expected: SqlType::Text,
1261            got: format!("{value:?}"),
1262        }),
1263    }
1264}
1265
1266/// Error type for the signal-firing per-instance write methods
1267/// ([`Manager::save`] and [`Manager::delete_instance`]).
1268///
1269/// Wraps [`WriteError`] for the underlying SQL errors and adds one
1270/// framework-level variant for models with no primary key declared.
1271#[derive(Debug)]
1272pub enum SaveError {
1273    /// The model has no field with `primary_key = true`. Returned
1274    /// by `save` and `delete_instance` which need the PK to build
1275    /// the WHERE clause for UPDATE / DELETE.
1276    NoPrimaryKey,
1277    /// An underlying write-layer error (type mismatch, sqlx error,
1278    /// etc.). See [`WriteError`] for the full variant list.
1279    Write(WriteError),
1280}
1281
1282impl std::fmt::Display for SaveError {
1283    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1284        match self {
1285            SaveError::NoPrimaryKey => write!(
1286                f,
1287                "umbral::orm::save: model has no primary key — cannot determine INSERT vs UPDATE"
1288            ),
1289            SaveError::Write(e) => write!(f, "{e}"),
1290        }
1291    }
1292}
1293
1294impl std::error::Error for SaveError {
1295    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1296        match self {
1297            SaveError::Write(e) => Some(e),
1298            _ => None,
1299        }
1300    }
1301}
1302
1303impl From<WriteError> for SaveError {
1304    fn from(e: WriteError) -> Self {
1305        Self::Write(e)
1306    }
1307}
1308
1309/// True when this JSON value represents the "default" PK that should
1310/// trigger autoincrement rather than be bound as an explicit value.
1311///
1312/// Conventions:
1313/// - Integer PK: 0 is the autoincrement sentinel (matches
1314///   SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT`).
1315/// - UUID PK: nil / all-zeros UUID is the sentinel.
1316/// - String PK: empty string. Users with non-empty string PKs always
1317///   supply them; an empty string makes no sense as a real PK.
1318pub fn is_default_pk(sql_type: SqlType, value: &JsonValue) -> bool {
1319    match (sql_type, value) {
1320        (SqlType::SmallInt | SqlType::Integer | SqlType::BigInt, JsonValue::Number(n)) => {
1321            n.as_i64() == Some(0) || n.as_u64() == Some(0)
1322        }
1323        (SqlType::Uuid, JsonValue::String(s)) => {
1324            s == "00000000-0000-0000-0000-000000000000" || s.is_empty()
1325        }
1326        (SqlType::Text, JsonValue::String(s)) => s.is_empty(),
1327        _ => false,
1328    }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333    use super::*;
1334    use serde_json::json;
1335
1336    #[test]
1337    fn json_to_sea_value_passes_basic_types() {
1338        let v = json_to_sea_value(SqlType::Integer, &json!(42), false, "x", None).unwrap();
1339        assert!(matches!(v, SeaValue::Int(Some(42))));
1340        let v = json_to_sea_value(SqlType::BigInt, &json!(42), false, "x", None).unwrap();
1341        assert!(matches!(v, SeaValue::BigInt(Some(42))));
1342        let v = json_to_sea_value(SqlType::Text, &json!("hi"), false, "x", None).unwrap();
1343        assert!(matches!(v, SeaValue::String(Some(_))));
1344        let v = json_to_sea_value(SqlType::Boolean, &json!(true), false, "x", None).unwrap();
1345        assert!(matches!(v, SeaValue::Bool(Some(true))));
1346        let v =
1347            json_to_sea_value(SqlType::Json, &json!({ "nested": true }), false, "x", None).unwrap();
1348        assert!(matches!(v, SeaValue::Json(Some(_))));
1349    }
1350
1351    #[test]
1352    fn json_to_sea_value_coerces_string_booleans() {
1353        let v = json_to_sea_value(SqlType::Boolean, &json!("true"), false, "x", None).unwrap();
1354        assert!(matches!(v, SeaValue::Bool(Some(true))));
1355        let v = json_to_sea_value(SqlType::Boolean, &json!("0"), false, "x", None).unwrap();
1356        assert!(matches!(v, SeaValue::Bool(Some(false))));
1357    }
1358
1359    #[test]
1360    fn json_to_sea_value_rejects_null_on_required_field() {
1361        let err = json_to_sea_value(SqlType::Integer, &json!(null), false, "x", None).unwrap_err();
1362        assert!(matches!(err, WriteError::RequiredFieldMissing { .. }));
1363    }
1364
1365    #[test]
1366    fn json_to_sea_value_accepts_null_on_nullable_field() {
1367        let v = json_to_sea_value(SqlType::Integer, &json!(null), true, "x", None).unwrap();
1368        assert!(matches!(v, SeaValue::Int(None)));
1369        let v = json_to_sea_value(SqlType::Json, &json!(null), true, "x", None).unwrap();
1370        assert!(matches!(v, SeaValue::Json(None)));
1371    }
1372
1373    #[test]
1374    fn json_to_sea_value_accepts_datetime_local_form_shape() {
1375        // RFC3339 with offset — the canonical wire form.
1376        let v = json_to_sea_value(
1377            SqlType::Timestamptz,
1378            &json!("2026-06-03T22:24:00Z"),
1379            false,
1380            "x",
1381            None,
1382        )
1383        .unwrap();
1384        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1385            panic!("expected ChronoDateTimeUtc");
1386        };
1387        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1388
1389        // Naive with seconds — common JSON / HTML-form shape.
1390        let v = json_to_sea_value(
1391            SqlType::Timestamptz,
1392            &json!("2026-06-03T22:24:00"),
1393            false,
1394            "x",
1395            None,
1396        )
1397        .unwrap();
1398        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1399            panic!("expected ChronoDateTimeUtc");
1400        };
1401        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1402
1403        // Naive without seconds — the literal HTML
1404        // `<input type="datetime-local">` shape that the admin's
1405        // auto-generated forms post. This was the regression.
1406        let v = json_to_sea_value(
1407            SqlType::Timestamptz,
1408            &json!("2026-06-03T22:24"),
1409            false,
1410            "x",
1411            None,
1412        )
1413        .unwrap();
1414        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1415            panic!("expected ChronoDateTimeUtc");
1416        };
1417        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1418
1419        // Garbage still rejected.
1420        let err = json_to_sea_value(SqlType::Timestamptz, &json!("not a date"), false, "x", None)
1421            .unwrap_err();
1422        assert!(matches!(err, WriteError::TypeMismatch { .. }));
1423    }
1424
1425    #[test]
1426    fn is_default_pk_recognises_zero_int_and_nil_uuid() {
1427        assert!(is_default_pk(SqlType::Integer, &json!(0)));
1428        assert!(is_default_pk(SqlType::BigInt, &json!(0)));
1429        assert!(!is_default_pk(SqlType::BigInt, &json!(42)));
1430        assert!(is_default_pk(
1431            SqlType::Uuid,
1432            &json!("00000000-0000-0000-0000-000000000000")
1433        ));
1434        assert!(!is_default_pk(SqlType::Uuid, &json!("not-zero")));
1435    }
1436}