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/// Seal a masked column's plaintext before it is bound, so the dynamic REST /
507/// admin write paths (`insert_json`/`update_json`/form-submit) encrypt at rest
508/// — not just the typed `Masked<T>` path (audit_2 core-orm C1). Returns
509/// `Some(ciphertext-json)` to bind when `col` is masked and `value` is a
510/// non-null string; `None` when no sealing applies (bind `value` as-is). A
511/// missing/broken keyring fails the write closed rather than storing plaintext.
512pub fn seal_masked_json(
513    col: &crate::migrate::Column,
514    value: &JsonValue,
515) -> Result<Option<JsonValue>, WriteError> {
516    if !is_masked_col(col) || value.is_null() {
517        return Ok(None);
518    }
519    let plain = coerce_string(value, &col.name)?;
520    let sealed = crate::orm::masked::ambient_seal(&plain).map_err(|e| WriteError::Validator {
521        field: col.name.clone(),
522        message: format!("could not seal masked field: {e}"),
523    })?;
524    Ok(Some(JsonValue::String(sealed)))
525}
526
527pub fn json_to_sea_value(
528    sql_type: SqlType,
529    value: &JsonValue,
530    nullable: bool,
531    field_name: &str,
532    fk_target_pk: Option<SqlType>,
533) -> Result<SeaValue, WriteError> {
534    // null handling first — applies regardless of expected type.
535    if value.is_null() {
536        if !nullable {
537            return Err(WriteError::RequiredFieldMissing {
538                field: field_name.to_string(),
539            });
540        }
541        return Ok(null_for(sql_type));
542    }
543
544    match sql_type {
545        SqlType::Boolean => coerce_bool(value, field_name),
546        SqlType::SmallInt | SqlType::Integer => {
547            coerce_i32(value, field_name).map(|v| SeaValue::Int(Some(v)))
548        }
549        SqlType::BigInt => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
550        // gaps2 #42: bind a `ForeignKey` id against its TARGET PK's
551        // type, not the JSON value's runtime shape. Before, a
552        // `JsonValue::String("1")` FK id was bound TEXT unconditionally
553        // because this function couldn't see `fk_target` — which a
554        // Postgres `bigint` FK column rejects ("column ... is of type
555        // bigint but expression is of type text"). The caller now
556        // resolves the target PK type (via `fk_target_pk_sql_type` /
557        // `pk_meta_for_table`) and threads it in as `fk_target_pk`:
558        //   - Text-PK target  → bind the id as text;
559        //   - Uuid-PK target  → parse + bind a UUID;
560        //   - numeric-PK target (or unresolved, the common i64 case)
561        //     → coerce the string / number → BigInt.
562        // `coerce_i64` already accepts `JsonValue::String("1")`, so a
563        // numeric string now binds `BigInt(1)`. This mirrors
564        // `form_str_to_sea_value`'s FK arm in `orm/dynamic.rs`.
565        SqlType::ForeignKey => match fk_target_pk {
566            Some(SqlType::Text) => {
567                coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
568            }
569            Some(SqlType::Uuid) => match value {
570                JsonValue::String(s) => uuid::Uuid::parse_str(s)
571                    .map(|u| SeaValue::Uuid(Some(Box::new(u))))
572                    .map_err(|_| WriteError::TypeMismatch {
573                        field: field_name.to_string(),
574                        expected: SqlType::Uuid,
575                        got: s.clone(),
576                    }),
577                _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
578            },
579            _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
580        },
581        SqlType::Real => coerce_f32(value, field_name).map(|v| SeaValue::Float(Some(v))),
582        SqlType::Double => coerce_f64(value, field_name).map(|v| SeaValue::Double(Some(v))),
583        SqlType::Text => {
584            coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
585        }
586        SqlType::Date => {
587            let s = coerce_string(value, field_name)?;
588            let d = chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(|_| {
589                WriteError::TypeMismatch {
590                    field: field_name.to_string(),
591                    expected: sql_type,
592                    got: format!("{value:?}"),
593                }
594            })?;
595            Ok(SeaValue::ChronoDate(Some(Box::new(d))))
596        }
597        SqlType::Time => {
598            let s = coerce_string(value, field_name)?;
599            let t = chrono::NaiveTime::parse_from_str(&s, "%H:%M:%S")
600                .or_else(|_| chrono::NaiveTime::parse_from_str(&s, "%H:%M"))
601                .map_err(|_| WriteError::TypeMismatch {
602                    field: field_name.to_string(),
603                    expected: sql_type,
604                    got: format!("{value:?}"),
605                })?;
606            Ok(SeaValue::ChronoTime(Some(Box::new(t))))
607        }
608        SqlType::Timestamptz => {
609            let s = coerce_string(value, field_name)?;
610            // Accept several wire shapes that real callers send:
611            //   1. RFC3339 with offset / Z — the canonical machine form
612            //      and what serde / API clients emit.
613            //   2. Naive `YYYY-MM-DDTHH:MM:SS` — common for hand-written
614            //      JSON and typical form serializers.
615            //   3. Naive `YYYY-MM-DDTHH:MM` — the literal output of HTML
616            //      `<input type="datetime-local">`. The admin's
617            //      auto-generated forms post exactly this shape, so
618            //      rejecting it broke every Timestamptz field edit.
619            // Gap 106: naive forms are interpreted in the
620            // configured `Settings::time_zone` (falling back to UTC
621            // when the setting is absent), then converted to UTC
622            // for storage. Tz-bearing RFC3339 inputs win regardless
623            // of the project tz — the offset they carry is the
624            // ground truth.
625            if let Ok(offset_bearing) = chrono::DateTime::parse_from_rfc3339(&s) {
626                let utc = offset_bearing.with_timezone(&chrono::Utc);
627                return Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(utc))));
628            }
629
630            let naive = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
631                .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M"))
632                .map_err(|_| WriteError::TypeMismatch {
633                    field: field_name.to_string(),
634                    expected: sql_type,
635                    got: format!("{value:?}"),
636                })?;
637
638            // gaps3 #42: a wall-clock reading is not a moment in time. Twice a
639            // year the project timezone maps one reading to two instants, or to
640            // none. This used to `unwrap_or_else(|| naive.and_utc())`, storing
641            // the reading as if it were UTC — not one of the two candidates but
642            // a third instant, hours from what the user meant. Refuse instead,
643            // and tell them to send an offset.
644            let tz = crate::timezone::active_tz();
645            let dt = crate::timezone::naive_local_to_utc_checked(naive).map_err(|e| match e {
646                crate::timezone::LocalTimeError::Ambiguous { earlier, later } => {
647                    WriteError::AmbiguousLocalTime {
648                        field: field_name.to_string(),
649                        value: s.clone(),
650                        tz: tz.name().to_string(),
651                        earlier: earlier.to_rfc3339(),
652                        later: later.to_rfc3339(),
653                    }
654                }
655                crate::timezone::LocalTimeError::Nonexistent => WriteError::NonexistentLocalTime {
656                    field: field_name.to_string(),
657                    value: s.clone(),
658                    tz: tz.name().to_string(),
659                },
660            })?;
661            Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(dt))))
662        }
663        SqlType::Uuid => {
664            let s = coerce_string(value, field_name)?;
665            let u = uuid::Uuid::parse_str(&s).map_err(|_| WriteError::TypeMismatch {
666                field: field_name.to_string(),
667                expected: sql_type,
668                got: format!("{value:?}"),
669            })?;
670            Ok(SeaValue::Uuid(Some(Box::new(u))))
671        }
672        SqlType::Json => {
673            // Store the JSON as-is so sqlx binds JSON/JSONB with the
674            // backend's typed encoder instead of a plain text parameter.
675            Ok(SeaValue::Json(Some(Box::new(value.clone()))))
676        }
677        // Postgres-only catalogue. Returned as a serialized string;
678        // the per-backend bind layer downstream handles the cast.
679        // These paths are only reachable for PG-bound models (the
680        // field.backend check at App::build blocks SQLite).
681        SqlType::Array(_)
682        | SqlType::Inet
683        | SqlType::Cidr
684        | SqlType::MacAddr
685        // gaps2 #70: XML / LTREE / BIT VARYING are text-backed — the
686        // value arrives as a JSON string and binds as a text parameter;
687        // Postgres applies the column's own cast on the way in.
688        | SqlType::Xml
689        | SqlType::Ltree
690        | SqlType::Bit
691        | SqlType::FullText => Ok(SeaValue::String(Some(Box::new(coerce_string(
692            value, field_name,
693        )?)))),
694        // BLOB / BYTEA. JSON wire shape: an array of u8 numbers, the
695        // natural way to encode a byte string in JSON without picking
696        // a base16/base64 convention at the framework level.
697        // Hex-encoded JSON strings also accepted as a convenience for
698        // human-readable test fixtures.
699        SqlType::Bytes => {
700            coerce_bytes(value, field_name).map(|b| SeaValue::Bytes(Some(Box::new(b))))
701        }
702        // BUG-10: NUMERIC. Accept JSON numbers (round-trip through
703        // f64 — adequate for most reasonable values; truly large
704        // exact decimals come in as strings) AND JSON strings
705        // (canonical for money values). Anything else fails the
706        // typed coerce.
707        SqlType::Decimal => coerce_decimal(value, field_name),
708    }
709}
710
711fn coerce_decimal(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
712    use std::str::FromStr;
713    // Round-trip through the serde_json textual representation —
714    // serde_json::Number prints integers / floats verbatim, so
715    // `n.to_string()` reads back as the same precision the wire
716    // value carried. Avoids the f64 trap of "3.10" arriving as
717    // 3.1000000000000001.
718    let parsed: Option<rust_decimal::Decimal> = match value {
719        JsonValue::String(s) => rust_decimal::Decimal::from_str(s).ok(),
720        JsonValue::Number(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(),
721        _ => None,
722    };
723    parsed
724        .map(|d| SeaValue::Decimal(Some(Box::new(d))))
725        .ok_or_else(|| WriteError::TypeMismatch {
726            field: field_name.to_string(),
727            expected: SqlType::Decimal,
728            got: format!("{value:?}"),
729        })
730}
731
732/// Coerce a `serde_json::Value` to `Vec<u8>`. Accepts:
733///   - `[1, 2, 3, ...]` — JSON array of u8-shaped numbers.
734///   - `"deadbeef"` — lowercase hex string of even length.
735fn coerce_bytes(value: &JsonValue, field_name: &str) -> Result<Vec<u8>, WriteError> {
736    if let Some(arr) = value.as_array() {
737        let mut out = Vec::with_capacity(arr.len());
738        for v in arr {
739            let n = v.as_u64().ok_or_else(|| WriteError::TypeMismatch {
740                field: field_name.to_string(),
741                expected: SqlType::Bytes,
742                got: format!("{v:?}"),
743            })?;
744            if n > 255 {
745                return Err(WriteError::TypeMismatch {
746                    field: field_name.to_string(),
747                    expected: SqlType::Bytes,
748                    got: format!("element {v} out of u8 range"),
749                });
750            }
751            out.push(n as u8);
752        }
753        return Ok(out);
754    }
755    if let Some(s) = value.as_str() {
756        if s.len() % 2 != 0 {
757            return Err(WriteError::TypeMismatch {
758                field: field_name.to_string(),
759                expected: SqlType::Bytes,
760                got: "hex string has odd length".to_string(),
761            });
762        }
763        let mut out = Vec::with_capacity(s.len() / 2);
764        for chunk in s.as_bytes().chunks(2) {
765            let high = hex_nibble(chunk[0]).ok_or_else(|| WriteError::TypeMismatch {
766                field: field_name.to_string(),
767                expected: SqlType::Bytes,
768                got: format!("non-hex char `{}`", chunk[0] as char),
769            })?;
770            let low = hex_nibble(chunk[1]).ok_or_else(|| WriteError::TypeMismatch {
771                field: field_name.to_string(),
772                expected: SqlType::Bytes,
773                got: format!("non-hex char `{}`", chunk[1] as char),
774            })?;
775            out.push((high << 4) | low);
776        }
777        return Ok(out);
778    }
779    Err(WriteError::TypeMismatch {
780        field: field_name.to_string(),
781        expected: SqlType::Bytes,
782        got: format!("{value:?}"),
783    })
784}
785
786fn hex_nibble(b: u8) -> Option<u8> {
787    match b {
788        b'0'..=b'9' => Some(b - b'0'),
789        b'a'..=b'f' => Some(10 + b - b'a'),
790        b'A'..=b'F' => Some(10 + b - b'A'),
791        _ => None,
792    }
793}
794
795/// Build the sea-query value the framework substitutes when an
796/// `auto_now` / `auto_now_add` column needs to be auto-populated.
797/// Used by [`crate::orm::dynamic::DynQuerySet::insert_json`] and
798/// `update_json`. Closes BUG-5 from `bugs/tests/testBugs.md`.
799///
800/// Supported column types: `Timestamptz` (the common case), `Date`,
801/// `Time`. Anything else falls back to the SQL NULL form for that
802/// column type, since a non-time column tagged `#[umbral(auto_now)]`
803/// is a developer mistake — there's no sensible "now" value to
804/// produce. The macro could in principle reject the attribute on
805/// non-time columns at derive time; we defer that polish to the
806/// macro pass where it lands alongside other "wrong attribute on
807/// wrong type" diagnostics.
808/// Gap 109: slug derivation. Lowercases the input, replaces
809/// runs of non-alphanumeric ASCII characters with a single `-`, trims
810/// leading/trailing dashes, and collapses repeated dashes. Empty / pure-
811/// punctuation input returns the empty string.
812///
813/// Mirrors what most slug libraries do for the ASCII path; non-ASCII
814/// characters are dropped (we don't transliterate at v1 — a unicode
815/// transliterator is a heavier dep and our admins typically slugify
816/// English-language titles).
817pub fn slugify(s: &str) -> String {
818    let mut out = String::with_capacity(s.len());
819    let mut last_was_dash = true; // suppresses leading dashes
820    for c in s.chars() {
821        if c.is_ascii_alphanumeric() {
822            for low in c.to_lowercase() {
823                out.push(low);
824            }
825            last_was_dash = false;
826        } else if !last_was_dash {
827            out.push('-');
828            last_was_dash = true;
829        }
830    }
831    // Trim trailing dash if any.
832    while out.ends_with('-') {
833        out.pop();
834    }
835    out
836}
837
838/// Gap 109: walk the body and auto-derive slug columns from their
839/// configured source field where the slug column is missing or empty.
840///
841/// Called from the dynamic insert/update entry points BEFORE validation
842/// so the validator sees the populated slug. The `is_update` flag
843/// constrains the rule: on update, the slug is regenerated only when
844/// the source field is also present in the body. Without that guard,
845/// editing an unrelated column on an existing row would clobber a hand-
846/// tuned slug.
847pub fn apply_slug_from(
848    fields: &[crate::migrate::Column],
849    body: &mut serde_json::Map<String, serde_json::Value>,
850    is_update: bool,
851) {
852    for col in fields {
853        let Some(source) = col.slug_from.as_deref() else {
854            continue;
855        };
856        // Slug already explicitly supplied (non-empty string) — keep it.
857        let explicit = body
858            .get(&col.name)
859            .and_then(|v| v.as_str())
860            .map(|s| !s.is_empty())
861            .unwrap_or(false);
862        if explicit {
863            continue;
864        }
865        // On update, only regenerate when the source field is in the body.
866        let source_value = body.get(source).and_then(|v| v.as_str()).unwrap_or("");
867        if source_value.is_empty() {
868            continue;
869        }
870        if is_update && !body.contains_key(source) {
871            continue;
872        }
873        let slug = slugify(source_value);
874        if slug.is_empty() {
875            continue;
876        }
877        body.insert(col.name.clone(), serde_json::Value::String(slug));
878    }
879}
880
881pub fn now_for_column(sql_type: SqlType) -> SeaValue {
882    let now = chrono::Utc::now();
883    match sql_type {
884        SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(Some(Box::new(now))),
885        SqlType::Date => SeaValue::ChronoDate(Some(Box::new(now.date_naive()))),
886        SqlType::Time => SeaValue::ChronoTime(Some(Box::new(now.time()))),
887        _ => null_for(sql_type),
888    }
889}
890
891/// The authenticated caller's id, bound as `sql_type` — what
892/// `#[umbral(auto_user_add)]` / `#[umbral(auto_user)]` stamp (gaps3 #55).
893///
894/// The id travels through [`crate::db::route_context::current_user_id`] as a
895/// string so the user model's PK shape doesn't leak into the ambient context;
896/// it is converted back here to whatever the *stamping* column actually is —
897/// `BigInt` for the usual `ForeignKey<AuthUser>`, `Text` for a slug-keyed user,
898/// `Uuid` for a uuid-keyed one.
899///
900/// No user in scope (a background job, the CLI, an anonymous request) → NULL.
901/// We stamp nothing rather than invent an author; that is the honest answer, and
902/// it is why an `auto_user` column must be nullable.
903pub fn user_for_column(sql_type: SqlType) -> SeaValue {
904    let Some(id) = crate::db::route_context::current_user_id() else {
905        return null_for(sql_type);
906    };
907    match sql_type {
908        SqlType::SmallInt | SqlType::Integer => match id.parse::<i32>() {
909            Ok(v) => SeaValue::Int(Some(v)),
910            Err(_) => null_for(sql_type),
911        },
912        SqlType::BigInt => match id.parse::<i64>() {
913            Ok(v) => SeaValue::BigInt(Some(v)),
914            Err(_) => null_for(sql_type),
915        },
916        SqlType::Uuid => match id.parse::<uuid::Uuid>() {
917            Ok(v) => SeaValue::Uuid(Some(Box::new(v))),
918            Err(_) => null_for(sql_type),
919        },
920        SqlType::Text => SeaValue::String(Some(Box::new(id))),
921        // A non-identity column tagged `auto_user` is a declaration error the
922        // `model.auto_user` boot check rejects; NULL here so a slipped-through
923        // case cannot write a nonsense value.
924        _ => null_for(sql_type),
925    }
926}
927
928/// Apply `#[umbral(trim)]` / `#[umbral(lowercase)]` to a JSON value.
929///
930/// **The declarative normalizers were dyn-path-only.** REST and the admin honoured
931/// them; `Model::objects().create(user)` did not — so the *same* field normalized
932/// or didn't depending on who wrote the row, and `alice@x.com` from REST could sit
933/// beside `  Alice@X.com  ` written by a seed script or a background job. A
934/// case-insensitive unique index then rejects a legitimate signup, or two accounts
935/// exist for one human. Declaring the rule on the field has to mean every write
936/// path obeys it, or the declaration is a lie.
937///
938/// Returns `None` when the column declares no normalization (the common case, so
939/// the caller can skip a clone).
940pub fn normalize_json(trim: bool, lowercase: bool, v: &JsonValue) -> Option<JsonValue> {
941    if !(trim || lowercase) {
942        return None;
943    }
944    let s = v.as_str()?;
945    let s = if trim { s.trim() } else { s };
946    let out = if lowercase {
947        s.to_lowercase()
948    } else {
949        s.to_string()
950    };
951    Some(JsonValue::String(out))
952}
953
954/// Sea-query value representing SQL NULL for the given SqlType. The
955/// variant tag matters for sea-query's encoding even when the inner
956/// option is `None`.
957pub(crate) fn null_for(sql_type: SqlType) -> SeaValue {
958    match sql_type {
959        SqlType::Boolean => SeaValue::Bool(None),
960        SqlType::SmallInt | SqlType::Integer => SeaValue::Int(None),
961        SqlType::BigInt | SqlType::ForeignKey => SeaValue::BigInt(None),
962        SqlType::Real => SeaValue::Float(None),
963        SqlType::Double => SeaValue::Double(None),
964        SqlType::Text => SeaValue::String(None),
965        SqlType::Json => SeaValue::Json(None),
966        SqlType::Date => SeaValue::ChronoDate(None),
967        SqlType::Time => SeaValue::ChronoTime(None),
968        SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(None),
969        SqlType::Uuid => SeaValue::Uuid(None),
970        SqlType::Array(_)
971        | SqlType::Inet
972        | SqlType::Cidr
973        | SqlType::MacAddr
974        | SqlType::Xml
975        | SqlType::Ltree
976        | SqlType::Bit
977        | SqlType::FullText => SeaValue::String(None),
978        SqlType::Bytes => SeaValue::Bytes(None),
979        SqlType::Decimal => SeaValue::Decimal(None),
980    }
981}
982
983fn coerce_bool(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
984    match value {
985        JsonValue::Bool(b) => Ok(SeaValue::Bool(Some(*b))),
986        JsonValue::String(s) => match s.as_str() {
987            "true" | "1" | "yes" | "on" => Ok(SeaValue::Bool(Some(true))),
988            "false" | "0" | "no" | "off" | "" => Ok(SeaValue::Bool(Some(false))),
989            _ => Err(WriteError::TypeMismatch {
990                field: field_name.to_string(),
991                expected: SqlType::Boolean,
992                got: format!("{value:?}"),
993            }),
994        },
995        JsonValue::Number(n) => Ok(SeaValue::Bool(Some(n.as_i64() != Some(0)))),
996        _ => Err(WriteError::TypeMismatch {
997            field: field_name.to_string(),
998            expected: SqlType::Boolean,
999            got: format!("{value:?}"),
1000        }),
1001    }
1002}
1003
1004fn coerce_i32(value: &JsonValue, field_name: &str) -> Result<i32, WriteError> {
1005    match value {
1006        JsonValue::Number(n) => n
1007            .as_i64()
1008            .and_then(|i| i32::try_from(i).ok())
1009            .ok_or_else(|| WriteError::TypeMismatch {
1010                field: field_name.to_string(),
1011                expected: SqlType::Integer,
1012                got: format!("{value:?}"),
1013            }),
1014        JsonValue::String(s) => s.parse::<i32>().map_err(|_| WriteError::TypeMismatch {
1015            field: field_name.to_string(),
1016            expected: SqlType::Integer,
1017            got: s.clone(),
1018        }),
1019        _ => Err(WriteError::TypeMismatch {
1020            field: field_name.to_string(),
1021            expected: SqlType::Integer,
1022            got: format!("{value:?}"),
1023        }),
1024    }
1025}
1026
1027fn coerce_i64(value: &JsonValue, field_name: &str) -> Result<i64, WriteError> {
1028    match value {
1029        JsonValue::Number(n) => n.as_i64().ok_or_else(|| WriteError::TypeMismatch {
1030            field: field_name.to_string(),
1031            expected: SqlType::BigInt,
1032            got: format!("{value:?}"),
1033        }),
1034        JsonValue::String(s) => s.parse::<i64>().map_err(|_| WriteError::TypeMismatch {
1035            field: field_name.to_string(),
1036            expected: SqlType::BigInt,
1037            got: s.clone(),
1038        }),
1039        _ => Err(WriteError::TypeMismatch {
1040            field: field_name.to_string(),
1041            expected: SqlType::BigInt,
1042            got: format!("{value:?}"),
1043        }),
1044    }
1045}
1046
1047fn coerce_f32(value: &JsonValue, field_name: &str) -> Result<f32, WriteError> {
1048    coerce_f64(value, field_name).map(|v| v as f32)
1049}
1050
1051fn coerce_f64(value: &JsonValue, field_name: &str) -> Result<f64, WriteError> {
1052    match value {
1053        JsonValue::Number(n) => n.as_f64().ok_or_else(|| WriteError::TypeMismatch {
1054            field: field_name.to_string(),
1055            expected: SqlType::Double,
1056            got: format!("{value:?}"),
1057        }),
1058        JsonValue::String(s) => s.parse::<f64>().map_err(|_| WriteError::TypeMismatch {
1059            field: field_name.to_string(),
1060            expected: SqlType::Double,
1061            got: s.clone(),
1062        }),
1063        _ => Err(WriteError::TypeMismatch {
1064            field: field_name.to_string(),
1065            expected: SqlType::Double,
1066            got: format!("{value:?}"),
1067        }),
1068    }
1069}
1070
1071fn coerce_string(value: &JsonValue, field_name: &str) -> Result<String, WriteError> {
1072    match value {
1073        JsonValue::String(s) => Ok(s.clone()),
1074        JsonValue::Number(n) => Ok(n.to_string()),
1075        JsonValue::Bool(b) => Ok(b.to_string()),
1076        _ => Err(WriteError::TypeMismatch {
1077            field: field_name.to_string(),
1078            expected: SqlType::Text,
1079            got: format!("{value:?}"),
1080        }),
1081    }
1082}
1083
1084/// Error type for the signal-firing per-instance write methods
1085/// ([`Manager::save`] and [`Manager::delete_instance`]).
1086///
1087/// Wraps [`WriteError`] for the underlying SQL errors and adds one
1088/// framework-level variant for models with no primary key declared.
1089#[derive(Debug)]
1090pub enum SaveError {
1091    /// The model has no field with `primary_key = true`. Returned
1092    /// by `save` and `delete_instance` which need the PK to build
1093    /// the WHERE clause for UPDATE / DELETE.
1094    NoPrimaryKey,
1095    /// An underlying write-layer error (type mismatch, sqlx error,
1096    /// etc.). See [`WriteError`] for the full variant list.
1097    Write(WriteError),
1098}
1099
1100impl std::fmt::Display for SaveError {
1101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1102        match self {
1103            SaveError::NoPrimaryKey => write!(
1104                f,
1105                "umbral::orm::save: model has no primary key — cannot determine INSERT vs UPDATE"
1106            ),
1107            SaveError::Write(e) => write!(f, "{e}"),
1108        }
1109    }
1110}
1111
1112impl std::error::Error for SaveError {
1113    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1114        match self {
1115            SaveError::Write(e) => Some(e),
1116            _ => None,
1117        }
1118    }
1119}
1120
1121impl From<WriteError> for SaveError {
1122    fn from(e: WriteError) -> Self {
1123        Self::Write(e)
1124    }
1125}
1126
1127/// True when this JSON value represents the "default" PK that should
1128/// trigger autoincrement rather than be bound as an explicit value.
1129///
1130/// Conventions:
1131/// - Integer PK: 0 is the autoincrement sentinel (matches
1132///   SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT`).
1133/// - UUID PK: nil / all-zeros UUID is the sentinel.
1134/// - String PK: empty string. Users with non-empty string PKs always
1135///   supply them; an empty string makes no sense as a real PK.
1136pub fn is_default_pk(sql_type: SqlType, value: &JsonValue) -> bool {
1137    match (sql_type, value) {
1138        (SqlType::SmallInt | SqlType::Integer | SqlType::BigInt, JsonValue::Number(n)) => {
1139            n.as_i64() == Some(0) || n.as_u64() == Some(0)
1140        }
1141        (SqlType::Uuid, JsonValue::String(s)) => {
1142            s == "00000000-0000-0000-0000-000000000000" || s.is_empty()
1143        }
1144        (SqlType::Text, JsonValue::String(s)) => s.is_empty(),
1145        _ => false,
1146    }
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152    use serde_json::json;
1153
1154    #[test]
1155    fn json_to_sea_value_passes_basic_types() {
1156        let v = json_to_sea_value(SqlType::Integer, &json!(42), false, "x", None).unwrap();
1157        assert!(matches!(v, SeaValue::Int(Some(42))));
1158        let v = json_to_sea_value(SqlType::BigInt, &json!(42), false, "x", None).unwrap();
1159        assert!(matches!(v, SeaValue::BigInt(Some(42))));
1160        let v = json_to_sea_value(SqlType::Text, &json!("hi"), false, "x", None).unwrap();
1161        assert!(matches!(v, SeaValue::String(Some(_))));
1162        let v = json_to_sea_value(SqlType::Boolean, &json!(true), false, "x", None).unwrap();
1163        assert!(matches!(v, SeaValue::Bool(Some(true))));
1164        let v =
1165            json_to_sea_value(SqlType::Json, &json!({ "nested": true }), false, "x", None).unwrap();
1166        assert!(matches!(v, SeaValue::Json(Some(_))));
1167    }
1168
1169    #[test]
1170    fn json_to_sea_value_coerces_string_booleans() {
1171        let v = json_to_sea_value(SqlType::Boolean, &json!("true"), false, "x", None).unwrap();
1172        assert!(matches!(v, SeaValue::Bool(Some(true))));
1173        let v = json_to_sea_value(SqlType::Boolean, &json!("0"), false, "x", None).unwrap();
1174        assert!(matches!(v, SeaValue::Bool(Some(false))));
1175    }
1176
1177    #[test]
1178    fn json_to_sea_value_rejects_null_on_required_field() {
1179        let err = json_to_sea_value(SqlType::Integer, &json!(null), false, "x", None).unwrap_err();
1180        assert!(matches!(err, WriteError::RequiredFieldMissing { .. }));
1181    }
1182
1183    #[test]
1184    fn json_to_sea_value_accepts_null_on_nullable_field() {
1185        let v = json_to_sea_value(SqlType::Integer, &json!(null), true, "x", None).unwrap();
1186        assert!(matches!(v, SeaValue::Int(None)));
1187        let v = json_to_sea_value(SqlType::Json, &json!(null), true, "x", None).unwrap();
1188        assert!(matches!(v, SeaValue::Json(None)));
1189    }
1190
1191    #[test]
1192    fn json_to_sea_value_accepts_datetime_local_form_shape() {
1193        // RFC3339 with offset — the canonical wire form.
1194        let v = json_to_sea_value(
1195            SqlType::Timestamptz,
1196            &json!("2026-06-03T22:24:00Z"),
1197            false,
1198            "x",
1199            None,
1200        )
1201        .unwrap();
1202        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1203            panic!("expected ChronoDateTimeUtc");
1204        };
1205        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1206
1207        // Naive with seconds — common JSON / HTML-form shape.
1208        let v = json_to_sea_value(
1209            SqlType::Timestamptz,
1210            &json!("2026-06-03T22:24:00"),
1211            false,
1212            "x",
1213            None,
1214        )
1215        .unwrap();
1216        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1217            panic!("expected ChronoDateTimeUtc");
1218        };
1219        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1220
1221        // Naive without seconds — the literal HTML
1222        // `<input type="datetime-local">` shape that the admin's
1223        // auto-generated forms post. This was the regression.
1224        let v = json_to_sea_value(
1225            SqlType::Timestamptz,
1226            &json!("2026-06-03T22:24"),
1227            false,
1228            "x",
1229            None,
1230        )
1231        .unwrap();
1232        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1233            panic!("expected ChronoDateTimeUtc");
1234        };
1235        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1236
1237        // Garbage still rejected.
1238        let err = json_to_sea_value(SqlType::Timestamptz, &json!("not a date"), false, "x", None)
1239            .unwrap_err();
1240        assert!(matches!(err, WriteError::TypeMismatch { .. }));
1241    }
1242
1243    #[test]
1244    fn is_default_pk_recognises_zero_int_and_nil_uuid() {
1245        assert!(is_default_pk(SqlType::Integer, &json!(0)));
1246        assert!(is_default_pk(SqlType::BigInt, &json!(0)));
1247        assert!(!is_default_pk(SqlType::BigInt, &json!(42)));
1248        assert!(is_default_pk(
1249            SqlType::Uuid,
1250            &json!("00000000-0000-0000-0000-000000000000")
1251        ));
1252        assert!(!is_default_pk(SqlType::Uuid, &json!("not-zero")));
1253    }
1254}