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    /// DB-side CHECK constraint failure. Carries the constraint
78    /// name when the engine surfaces it (Postgres does; SQLite
79    /// gives just a generic message).
80    CheckViolation { constraint: Option<String> },
81    /// DB-side foreign-key constraint failure that pre-validation
82    /// missed (rare — typically a race where the target row was
83    /// deleted between the existence check and the INSERT).
84    ForeignKeyViolation { field: Option<String> },
85    /// Multiple validation errors at once. Surfaced by
86    /// `insert_json` when required + FK checks both fire, so the
87    /// caller can render every problem in one response.
88    Multiple { errors: Vec<WriteError> },
89    /// The JSON value couldn't be coerced to the column's SqlType.
90    /// e.g. a string body where an integer was expected.
91    TypeMismatch {
92        field: String,
93        expected: SqlType,
94        got: String,
95    },
96    /// Format validator (`#[umbral(slug)]` / `email` / `url` /
97    /// `min = N` / `max = N`) rejected the value.
98    Validator { field: String, message: String },
99    /// `serde_json` couldn't serialize the instance to a JSON
100    /// object (the only shape `Manager::create` accepts).
101    NotAnObject,
102    /// The model isn't `Serialize`. Surfaced by the trait bound on
103    /// `Manager::create`; not actually constructable from runtime.
104    /// Kept here for completeness so the variant exists in the docs.
105    SerializeFailed(serde_json::Error),
106    /// sqlx error during the write. Wraps the driver-level cause.
107    Sqlx(sqlx::Error),
108    /// `update_values` received a column name that doesn't exist on
109    /// the model. Caught early before SQL is built.
110    UnknownColumn { field: String },
111}
112
113impl WriteError {
114    /// Flatten into a `{field: [messages, ...]}` map.
115    /// Used by the REST plugin to render the 400 body; the admin
116    /// plugin will use the same shape for inline form errors.
117    /// Variants that aren't tied to a specific field (raw sqlx,
118    /// NotAnObject, etc.) produce empty maps — the caller's
119    /// non-field-error envelope covers those.
120    pub fn field_errors(&self) -> std::collections::BTreeMap<String, Vec<String>> {
121        let mut out: std::collections::BTreeMap<String, Vec<String>> =
122            std::collections::BTreeMap::new();
123        self.collect_field_errors(&mut out);
124        out
125    }
126
127    fn collect_field_errors(&self, out: &mut std::collections::BTreeMap<String, Vec<String>>) {
128        use WriteError::*;
129        match self {
130            RequiredFieldMissing { field } => {
131                out.entry(field.clone())
132                    .or_default()
133                    .push("This field is required.".to_string());
134            }
135            BlankNotAllowed { field } => {
136                out.entry(field.clone())
137                    .or_default()
138                    .push("This field cannot be blank.".to_string());
139            }
140            ForeignKeyNotFound {
141                field,
142                target_table,
143                value,
144            } => {
145                let value_repr = repr_json_value(value);
146                out.insert(
147                    field.clone(),
148                    vec![format!(
149                        "Referenced {target_table} row with id={value_repr} not found."
150                    )],
151                );
152            }
153            UniqueViolation {
154                field: Some(col),
155                value,
156            } => {
157                let value_repr = value.as_ref().map(repr_json_value);
158                let msg = match value_repr {
159                    Some(v) => format!("A row with {col}={v} already exists."),
160                    None => "A row with this value already exists.".to_string(),
161                };
162                out.insert(col.clone(), vec![msg]);
163            }
164            NotNullViolation { field: Some(col) } => {
165                out.entry(col.clone())
166                    .or_default()
167                    .push("This field is required.".to_string());
168            }
169            ForeignKeyViolation { field: Some(col) } => {
170                out.insert(
171                    col.clone(),
172                    vec!["Referenced row does not exist.".to_string()],
173                );
174            }
175            TypeMismatch {
176                field,
177                expected,
178                got,
179            } => {
180                out.entry(field.clone())
181                    .or_default()
182                    .push(format!("Expected `{expected:?}`, got `{got}`."));
183            }
184            Validator { field, message } => {
185                // An empty field name marks a non-field (whole-form)
186                // validator error — the `From<ValidationErrors>` lift
187                // produces these for cross-field failures. Filing it
188                // under the literal key "" would make it invisible to
189                // every by-field-name consumer (admin inputs, error
190                // spans); `collect_non_field_errors` owns it instead.
191                if !field.is_empty() {
192                    out.entry(field.clone()).or_default().push(message.clone());
193                }
194            }
195            UnknownColumn { field } => {
196                out.entry(field.clone())
197                    .or_default()
198                    .push(format!("Unknown column `{field}` on this model."));
199            }
200            Multiple { errors } => {
201                for e in errors {
202                    e.collect_field_errors(out);
203                }
204            }
205            _ => {
206                // Sqlx fallthrough, NotAnObject, SerializeFailed, and
207                // the `None`-field constraint variants produce no
208                // per-field entry — the caller's non-field-error
209                // envelope handles those.
210            }
211        }
212    }
213
214    /// Non-field-level errors, for the `non_field_errors`
215    /// array. Only populated for the parseable-but-non-keyed
216    /// constraint variants and the multi-error wrapper.
217    pub fn non_field_errors(&self) -> Vec<String> {
218        let mut out: Vec<String> = Vec::new();
219        self.collect_non_field_errors(&mut out);
220        out
221    }
222
223    fn collect_non_field_errors(&self, out: &mut Vec<String>) {
224        use WriteError::*;
225        match self {
226            UniqueViolation { field: None, .. } => {
227                out.push("A row with one or more of these values already exists.".into());
228            }
229            NotNullViolation { field: None } => {
230                out.push("A required field is missing.".into());
231            }
232            ForeignKeyViolation { field: None } => {
233                out.push("One or more foreign-key fields reference rows that don't exist.".into());
234            }
235            CheckViolation { constraint } => {
236                let msg = match constraint {
237                    Some(c) => format!("Check constraint `{c}` failed."),
238                    None => "A check constraint failed.".to_string(),
239                };
240                out.push(msg);
241            }
242            // Empty field name = whole-form validator error (see the
243            // matching skip in `collect_field_errors`).
244            Validator { field, message } if field.is_empty() => {
245                out.push(message.clone());
246            }
247            Multiple { errors } => {
248                for e in errors {
249                    e.collect_non_field_errors(out);
250                }
251            }
252            _ => {}
253        }
254    }
255
256    /// Stable machine-readable code for the boundary layer. REST
257    /// puts this in the `code` field of the 400 body; admin uses
258    /// it to pick an inline error style.
259    pub fn code(&self) -> &'static str {
260        use WriteError::*;
261        match self {
262            RequiredFieldMissing { .. } | BlankNotAllowed { .. } | NotNullViolation { .. } => {
263                "required_field"
264            }
265            ForeignKeyNotFound { .. } | ForeignKeyViolation { .. } => "fk_constraint",
266            UniqueViolation { .. } => "unique_constraint",
267            CheckViolation { .. } => "check_constraint",
268            TypeMismatch { .. } => "type_mismatch",
269            Validator { .. } => "validator_failed",
270            Multiple { .. } => "validation_error",
271            UnknownColumn { .. } => "unknown_column",
272            NotAnObject => "not_an_object",
273            SerializeFailed(_) => "serialize_failed",
274            Sqlx(_) => "database_error",
275        }
276    }
277
278    /// `true` for the variants that represent user-fixable input
279    /// problems (renderable as a 400). `false` for genuine
280    /// infrastructure / serialization failures (which should
281    /// surface as 500s).
282    pub fn is_validation(&self) -> bool {
283        use WriteError::*;
284        !matches!(self, Sqlx(_) | SerializeFailed(_) | NotAnObject)
285    }
286}
287
288/// JSON-value display used inside error messages. Strings are
289/// quoted, numbers / bools / null appear bare, arrays / objects
290/// fall back to compact JSON.
291fn repr_json_value(v: &serde_json::Value) -> String {
292    match v {
293        serde_json::Value::String(s) => format!("'{s}'"),
294        serde_json::Value::Number(n) => n.to_string(),
295        serde_json::Value::Bool(b) => b.to_string(),
296        serde_json::Value::Null => "null".to_string(),
297        _ => serde_json::to_string(v).unwrap_or_else(|_| "(?)".to_string()),
298    }
299}
300
301impl std::fmt::Display for WriteError {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        match self {
304            WriteError::RequiredFieldMissing { field } => write!(
305                f,
306                "umbral::orm::write: required field `{field}` is missing or null"
307            ),
308            WriteError::BlankNotAllowed { field } => {
309                write!(f, "umbral::orm::write: field `{field}` cannot be blank")
310            }
311            WriteError::ForeignKeyNotFound {
312                field,
313                target_table,
314                value,
315            } => write!(
316                f,
317                "umbral::orm::write: field `{field}` references `{target_table}` row with id={} which does not exist",
318                repr_json_value(value),
319            ),
320            WriteError::UniqueViolation { field, value } => match (field, value) {
321                (Some(f_), Some(v)) => write!(
322                    f,
323                    "umbral::orm::write: unique constraint on `{f_}`={} violated",
324                    repr_json_value(v),
325                ),
326                (Some(f_), None) => {
327                    write!(
328                        f,
329                        "umbral::orm::write: unique constraint on `{f_}` violated"
330                    )
331                }
332                _ => write!(f, "umbral::orm::write: unique constraint violated"),
333            },
334            WriteError::NotNullViolation { field } => match field {
335                Some(f_) => write!(f, "umbral::orm::write: NOT NULL on `{f_}` violated"),
336                None => write!(f, "umbral::orm::write: NOT NULL violation"),
337            },
338            WriteError::CheckViolation { constraint } => match constraint {
339                Some(c) => write!(f, "umbral::orm::write: CHECK `{c}` violated"),
340                None => write!(f, "umbral::orm::write: CHECK constraint violated"),
341            },
342            WriteError::ForeignKeyViolation { field } => match field {
343                Some(f_) => write!(
344                    f,
345                    "umbral::orm::write: foreign-key constraint on `{f_}` violated"
346                ),
347                None => write!(f, "umbral::orm::write: foreign-key constraint violated"),
348            },
349            WriteError::Multiple { errors } => {
350                write!(
351                    f,
352                    "umbral::orm::write: {} validation error(s)",
353                    errors.len()
354                )
355            }
356            WriteError::TypeMismatch {
357                field,
358                expected,
359                got,
360            } => write!(
361                f,
362                "umbral::orm::write: field `{field}` expected `{expected:?}`, got `{got}`",
363            ),
364            WriteError::Validator { field, message } => {
365                write!(f, "umbral::orm::write: field `{field}` {message}")
366            }
367            WriteError::NotAnObject => write!(
368                f,
369                "umbral::orm::write: model didn't serialize to a JSON object — make sure your struct uses a flat field layout",
370            ),
371            WriteError::SerializeFailed(e) => write!(f, "umbral::orm::write: serialize: {e}"),
372            WriteError::Sqlx(e) => write!(f, "umbral::orm::write: sqlx: {e}"),
373            WriteError::UnknownColumn { field } => {
374                write!(f, "umbral::orm::write: unknown column `{field}` on model")
375            }
376        }
377    }
378}
379
380impl std::error::Error for WriteError {}
381
382impl From<sqlx::Error> for WriteError {
383    fn from(e: sqlx::Error) -> Self {
384        Self::Sqlx(e)
385    }
386}
387
388impl From<serde_json::Error> for WriteError {
389    fn from(e: serde_json::Error) -> Self {
390        Self::SerializeFailed(e)
391    }
392}
393
394/// Convert a `serde_json::Value` to a `sea_query::Value` per the
395/// column's declared `SqlType`. The `nullable` flag controls how
396/// `JsonValue::Null` is handled:
397///
398/// - `nullable = true`: NULL is bound (the right SeaValue variant
399///   with `None`).
400/// - `nullable = false`: NULL produces `RequiredFieldMissing`.
401///
402/// String / number coercions follow the HTML-form-and-REST norms:
403/// `"true"` / `"false"` strings coerce to booleans, `"123"` strings
404/// coerce to numbers. RFC 3339 timestamps come through as strings on
405/// JSON inputs (serde_json doesn't have a native datetime).
406///
407/// `fk_target_pk` carries the target PK's `SqlType` for a `ForeignKey`
408/// column (gaps2 #42). This function can't see `fk_target`, so without
409/// it the FK arm couldn't tell an i64-PK FK from a String-PK one and
410/// bound every string-valued FK id as TEXT — which a Postgres `bigint`
411/// FK column rejects (`column "..." is of type bigint but expression is
412/// of type text`). Callers resolve the target PK type and pass it here
413/// (`Some(Text)` / `Some(Uuid)` bind as-is; numeric-PK or unresolved
414/// targets coerce the string → BigInt). `None` for every non-FK column.
415/// True when a column stores encrypt-at-rest `Masked<T>` data. The derive
416/// marks such columns with the (forced, non-overridable) `"masked"` widget on a
417/// `Text` column, so this is a reliable signal on the dynamic write path where
418/// the typed `Masked<T>` sealing (serde `Serialize` / sqlx `Encode`) never runs.
419pub fn is_masked_col(col: &crate::migrate::Column) -> bool {
420    col.ty == SqlType::Text && col.widget.as_deref() == Some("masked")
421}
422
423/// Seal a masked column's plaintext before it is bound, so the dynamic REST /
424/// admin write paths (`insert_json`/`update_json`/form-submit) encrypt at rest
425/// — not just the typed `Masked<T>` path (audit_2 core-orm C1). Returns
426/// `Some(ciphertext-json)` to bind when `col` is masked and `value` is a
427/// non-null string; `None` when no sealing applies (bind `value` as-is). A
428/// missing/broken keyring fails the write closed rather than storing plaintext.
429pub fn seal_masked_json(
430    col: &crate::migrate::Column,
431    value: &JsonValue,
432) -> Result<Option<JsonValue>, WriteError> {
433    if !is_masked_col(col) || value.is_null() {
434        return Ok(None);
435    }
436    let plain = coerce_string(value, &col.name)?;
437    let sealed = crate::orm::masked::ambient_seal(&plain).map_err(|e| WriteError::Validator {
438        field: col.name.clone(),
439        message: format!("could not seal masked field: {e}"),
440    })?;
441    Ok(Some(JsonValue::String(sealed)))
442}
443
444pub fn json_to_sea_value(
445    sql_type: SqlType,
446    value: &JsonValue,
447    nullable: bool,
448    field_name: &str,
449    fk_target_pk: Option<SqlType>,
450) -> Result<SeaValue, WriteError> {
451    // null handling first — applies regardless of expected type.
452    if value.is_null() {
453        if !nullable {
454            return Err(WriteError::RequiredFieldMissing {
455                field: field_name.to_string(),
456            });
457        }
458        return Ok(null_for(sql_type));
459    }
460
461    match sql_type {
462        SqlType::Boolean => coerce_bool(value, field_name),
463        SqlType::SmallInt | SqlType::Integer => {
464            coerce_i32(value, field_name).map(|v| SeaValue::Int(Some(v)))
465        }
466        SqlType::BigInt => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
467        // gaps2 #42: bind a `ForeignKey` id against its TARGET PK's
468        // type, not the JSON value's runtime shape. Before, a
469        // `JsonValue::String("1")` FK id was bound TEXT unconditionally
470        // because this function couldn't see `fk_target` — which a
471        // Postgres `bigint` FK column rejects ("column ... is of type
472        // bigint but expression is of type text"). The caller now
473        // resolves the target PK type (via `fk_target_pk_sql_type` /
474        // `pk_meta_for_table`) and threads it in as `fk_target_pk`:
475        //   - Text-PK target  → bind the id as text;
476        //   - Uuid-PK target  → parse + bind a UUID;
477        //   - numeric-PK target (or unresolved, the common i64 case)
478        //     → coerce the string / number → BigInt.
479        // `coerce_i64` already accepts `JsonValue::String("1")`, so a
480        // numeric string now binds `BigInt(1)`. This mirrors
481        // `form_str_to_sea_value`'s FK arm in `orm/dynamic.rs`.
482        SqlType::ForeignKey => match fk_target_pk {
483            Some(SqlType::Text) => {
484                coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
485            }
486            Some(SqlType::Uuid) => match value {
487                JsonValue::String(s) => uuid::Uuid::parse_str(s)
488                    .map(|u| SeaValue::Uuid(Some(Box::new(u))))
489                    .map_err(|_| WriteError::TypeMismatch {
490                        field: field_name.to_string(),
491                        expected: SqlType::Uuid,
492                        got: s.clone(),
493                    }),
494                _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
495            },
496            _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
497        },
498        SqlType::Real => coerce_f32(value, field_name).map(|v| SeaValue::Float(Some(v))),
499        SqlType::Double => coerce_f64(value, field_name).map(|v| SeaValue::Double(Some(v))),
500        SqlType::Text => {
501            coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
502        }
503        SqlType::Date => {
504            let s = coerce_string(value, field_name)?;
505            let d = chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(|_| {
506                WriteError::TypeMismatch {
507                    field: field_name.to_string(),
508                    expected: sql_type,
509                    got: format!("{value:?}"),
510                }
511            })?;
512            Ok(SeaValue::ChronoDate(Some(Box::new(d))))
513        }
514        SqlType::Time => {
515            let s = coerce_string(value, field_name)?;
516            let t = chrono::NaiveTime::parse_from_str(&s, "%H:%M:%S")
517                .or_else(|_| chrono::NaiveTime::parse_from_str(&s, "%H:%M"))
518                .map_err(|_| WriteError::TypeMismatch {
519                    field: field_name.to_string(),
520                    expected: sql_type,
521                    got: format!("{value:?}"),
522                })?;
523            Ok(SeaValue::ChronoTime(Some(Box::new(t))))
524        }
525        SqlType::Timestamptz => {
526            let s = coerce_string(value, field_name)?;
527            // Accept several wire shapes that real callers send:
528            //   1. RFC3339 with offset / Z — the canonical machine form
529            //      and what serde / API clients emit.
530            //   2. Naive `YYYY-MM-DDTHH:MM:SS` — common for hand-written
531            //      JSON and typical form serializers.
532            //   3. Naive `YYYY-MM-DDTHH:MM` — the literal output of HTML
533            //      `<input type="datetime-local">`. The admin's
534            //      auto-generated forms post exactly this shape, so
535            //      rejecting it broke every Timestamptz field edit.
536            // Gap 106: naive forms are interpreted in the
537            // configured `Settings::time_zone` (falling back to UTC
538            // when the setting is absent), then converted to UTC
539            // for storage. Tz-bearing RFC3339 inputs win regardless
540            // of the project tz — the offset they carry is the
541            // ground truth.
542            let dt = chrono::DateTime::parse_from_rfc3339(&s)
543                .map(|d| d.with_timezone(&chrono::Utc))
544                .or_else(|_| {
545                    chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
546                        .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M"))
547                        .map(|naive| {
548                            crate::timezone::naive_local_to_utc(naive)
549                                .unwrap_or_else(|| naive.and_utc())
550                        })
551                })
552                .map_err(|_| WriteError::TypeMismatch {
553                    field: field_name.to_string(),
554                    expected: sql_type,
555                    got: format!("{value:?}"),
556                })?;
557            Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(dt))))
558        }
559        SqlType::Uuid => {
560            let s = coerce_string(value, field_name)?;
561            let u = uuid::Uuid::parse_str(&s).map_err(|_| WriteError::TypeMismatch {
562                field: field_name.to_string(),
563                expected: sql_type,
564                got: format!("{value:?}"),
565            })?;
566            Ok(SeaValue::Uuid(Some(Box::new(u))))
567        }
568        SqlType::Json => {
569            // Store the JSON as-is so sqlx binds JSON/JSONB with the
570            // backend's typed encoder instead of a plain text parameter.
571            Ok(SeaValue::Json(Some(Box::new(value.clone()))))
572        }
573        // Postgres-only catalogue. Returned as a serialized string;
574        // the per-backend bind layer downstream handles the cast.
575        // These paths are only reachable for PG-bound models (the
576        // field.backend check at App::build blocks SQLite).
577        SqlType::Array(_)
578        | SqlType::Inet
579        | SqlType::Cidr
580        | SqlType::MacAddr
581        // gaps2 #70: XML / LTREE / BIT VARYING are text-backed — the
582        // value arrives as a JSON string and binds as a text parameter;
583        // Postgres applies the column's own cast on the way in.
584        | SqlType::Xml
585        | SqlType::Ltree
586        | SqlType::Bit
587        | SqlType::FullText => Ok(SeaValue::String(Some(Box::new(coerce_string(
588            value, field_name,
589        )?)))),
590        // BLOB / BYTEA. JSON wire shape: an array of u8 numbers, the
591        // natural way to encode a byte string in JSON without picking
592        // a base16/base64 convention at the framework level.
593        // Hex-encoded JSON strings also accepted as a convenience for
594        // human-readable test fixtures.
595        SqlType::Bytes => {
596            coerce_bytes(value, field_name).map(|b| SeaValue::Bytes(Some(Box::new(b))))
597        }
598        // BUG-10: NUMERIC. Accept JSON numbers (round-trip through
599        // f64 — adequate for most reasonable values; truly large
600        // exact decimals come in as strings) AND JSON strings
601        // (canonical for money values). Anything else fails the
602        // typed coerce.
603        SqlType::Decimal => coerce_decimal(value, field_name),
604    }
605}
606
607fn coerce_decimal(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
608    use std::str::FromStr;
609    // Round-trip through the serde_json textual representation —
610    // serde_json::Number prints integers / floats verbatim, so
611    // `n.to_string()` reads back as the same precision the wire
612    // value carried. Avoids the f64 trap of "3.10" arriving as
613    // 3.1000000000000001.
614    let parsed: Option<rust_decimal::Decimal> = match value {
615        JsonValue::String(s) => rust_decimal::Decimal::from_str(s).ok(),
616        JsonValue::Number(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(),
617        _ => None,
618    };
619    parsed
620        .map(|d| SeaValue::Decimal(Some(Box::new(d))))
621        .ok_or_else(|| WriteError::TypeMismatch {
622            field: field_name.to_string(),
623            expected: SqlType::Decimal,
624            got: format!("{value:?}"),
625        })
626}
627
628/// Coerce a `serde_json::Value` to `Vec<u8>`. Accepts:
629///   - `[1, 2, 3, ...]` — JSON array of u8-shaped numbers.
630///   - `"deadbeef"` — lowercase hex string of even length.
631fn coerce_bytes(value: &JsonValue, field_name: &str) -> Result<Vec<u8>, WriteError> {
632    if let Some(arr) = value.as_array() {
633        let mut out = Vec::with_capacity(arr.len());
634        for v in arr {
635            let n = v.as_u64().ok_or_else(|| WriteError::TypeMismatch {
636                field: field_name.to_string(),
637                expected: SqlType::Bytes,
638                got: format!("{v:?}"),
639            })?;
640            if n > 255 {
641                return Err(WriteError::TypeMismatch {
642                    field: field_name.to_string(),
643                    expected: SqlType::Bytes,
644                    got: format!("element {v} out of u8 range"),
645                });
646            }
647            out.push(n as u8);
648        }
649        return Ok(out);
650    }
651    if let Some(s) = value.as_str() {
652        if s.len() % 2 != 0 {
653            return Err(WriteError::TypeMismatch {
654                field: field_name.to_string(),
655                expected: SqlType::Bytes,
656                got: "hex string has odd length".to_string(),
657            });
658        }
659        let mut out = Vec::with_capacity(s.len() / 2);
660        for chunk in s.as_bytes().chunks(2) {
661            let high = hex_nibble(chunk[0]).ok_or_else(|| WriteError::TypeMismatch {
662                field: field_name.to_string(),
663                expected: SqlType::Bytes,
664                got: format!("non-hex char `{}`", chunk[0] as char),
665            })?;
666            let low = hex_nibble(chunk[1]).ok_or_else(|| WriteError::TypeMismatch {
667                field: field_name.to_string(),
668                expected: SqlType::Bytes,
669                got: format!("non-hex char `{}`", chunk[1] as char),
670            })?;
671            out.push((high << 4) | low);
672        }
673        return Ok(out);
674    }
675    Err(WriteError::TypeMismatch {
676        field: field_name.to_string(),
677        expected: SqlType::Bytes,
678        got: format!("{value:?}"),
679    })
680}
681
682fn hex_nibble(b: u8) -> Option<u8> {
683    match b {
684        b'0'..=b'9' => Some(b - b'0'),
685        b'a'..=b'f' => Some(10 + b - b'a'),
686        b'A'..=b'F' => Some(10 + b - b'A'),
687        _ => None,
688    }
689}
690
691/// Build the sea-query value the framework substitutes when an
692/// `auto_now` / `auto_now_add` column needs to be auto-populated.
693/// Used by [`crate::orm::dynamic::DynQuerySet::insert_json`] and
694/// `update_json`. Closes BUG-5 from `bugs/tests/testBugs.md`.
695///
696/// Supported column types: `Timestamptz` (the common case), `Date`,
697/// `Time`. Anything else falls back to the SQL NULL form for that
698/// column type, since a non-time column tagged `#[umbral(auto_now)]`
699/// is a developer mistake — there's no sensible "now" value to
700/// produce. The macro could in principle reject the attribute on
701/// non-time columns at derive time; we defer that polish to the
702/// macro pass where it lands alongside other "wrong attribute on
703/// wrong type" diagnostics.
704/// Gap 109: slug derivation. Lowercases the input, replaces
705/// runs of non-alphanumeric ASCII characters with a single `-`, trims
706/// leading/trailing dashes, and collapses repeated dashes. Empty / pure-
707/// punctuation input returns the empty string.
708///
709/// Mirrors what most slug libraries do for the ASCII path; non-ASCII
710/// characters are dropped (we don't transliterate at v1 — a unicode
711/// transliterator is a heavier dep and our admins typically slugify
712/// English-language titles).
713pub fn slugify(s: &str) -> String {
714    let mut out = String::with_capacity(s.len());
715    let mut last_was_dash = true; // suppresses leading dashes
716    for c in s.chars() {
717        if c.is_ascii_alphanumeric() {
718            for low in c.to_lowercase() {
719                out.push(low);
720            }
721            last_was_dash = false;
722        } else if !last_was_dash {
723            out.push('-');
724            last_was_dash = true;
725        }
726    }
727    // Trim trailing dash if any.
728    while out.ends_with('-') {
729        out.pop();
730    }
731    out
732}
733
734/// Gap 109: walk the body and auto-derive slug columns from their
735/// configured source field where the slug column is missing or empty.
736///
737/// Called from the dynamic insert/update entry points BEFORE validation
738/// so the validator sees the populated slug. The `is_update` flag
739/// constrains the rule: on update, the slug is regenerated only when
740/// the source field is also present in the body. Without that guard,
741/// editing an unrelated column on an existing row would clobber a hand-
742/// tuned slug.
743pub fn apply_slug_from(
744    fields: &[crate::migrate::Column],
745    body: &mut serde_json::Map<String, serde_json::Value>,
746    is_update: bool,
747) {
748    for col in fields {
749        let Some(source) = col.slug_from.as_deref() else {
750            continue;
751        };
752        // Slug already explicitly supplied (non-empty string) — keep it.
753        let explicit = body
754            .get(&col.name)
755            .and_then(|v| v.as_str())
756            .map(|s| !s.is_empty())
757            .unwrap_or(false);
758        if explicit {
759            continue;
760        }
761        // On update, only regenerate when the source field is in the body.
762        let source_value = body.get(source).and_then(|v| v.as_str()).unwrap_or("");
763        if source_value.is_empty() {
764            continue;
765        }
766        if is_update && !body.contains_key(source) {
767            continue;
768        }
769        let slug = slugify(source_value);
770        if slug.is_empty() {
771            continue;
772        }
773        body.insert(col.name.clone(), serde_json::Value::String(slug));
774    }
775}
776
777pub fn now_for_column(sql_type: SqlType) -> SeaValue {
778    let now = chrono::Utc::now();
779    match sql_type {
780        SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(Some(Box::new(now))),
781        SqlType::Date => SeaValue::ChronoDate(Some(Box::new(now.date_naive()))),
782        SqlType::Time => SeaValue::ChronoTime(Some(Box::new(now.time()))),
783        _ => null_for(sql_type),
784    }
785}
786
787/// Sea-query value representing SQL NULL for the given SqlType. The
788/// variant tag matters for sea-query's encoding even when the inner
789/// option is `None`.
790pub(crate) fn null_for(sql_type: SqlType) -> SeaValue {
791    match sql_type {
792        SqlType::Boolean => SeaValue::Bool(None),
793        SqlType::SmallInt | SqlType::Integer => SeaValue::Int(None),
794        SqlType::BigInt | SqlType::ForeignKey => SeaValue::BigInt(None),
795        SqlType::Real => SeaValue::Float(None),
796        SqlType::Double => SeaValue::Double(None),
797        SqlType::Text => SeaValue::String(None),
798        SqlType::Json => SeaValue::Json(None),
799        SqlType::Date => SeaValue::ChronoDate(None),
800        SqlType::Time => SeaValue::ChronoTime(None),
801        SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(None),
802        SqlType::Uuid => SeaValue::Uuid(None),
803        SqlType::Array(_)
804        | SqlType::Inet
805        | SqlType::Cidr
806        | SqlType::MacAddr
807        | SqlType::Xml
808        | SqlType::Ltree
809        | SqlType::Bit
810        | SqlType::FullText => SeaValue::String(None),
811        SqlType::Bytes => SeaValue::Bytes(None),
812        SqlType::Decimal => SeaValue::Decimal(None),
813    }
814}
815
816fn coerce_bool(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
817    match value {
818        JsonValue::Bool(b) => Ok(SeaValue::Bool(Some(*b))),
819        JsonValue::String(s) => match s.as_str() {
820            "true" | "1" | "yes" | "on" => Ok(SeaValue::Bool(Some(true))),
821            "false" | "0" | "no" | "off" | "" => Ok(SeaValue::Bool(Some(false))),
822            _ => Err(WriteError::TypeMismatch {
823                field: field_name.to_string(),
824                expected: SqlType::Boolean,
825                got: format!("{value:?}"),
826            }),
827        },
828        JsonValue::Number(n) => Ok(SeaValue::Bool(Some(n.as_i64() != Some(0)))),
829        _ => Err(WriteError::TypeMismatch {
830            field: field_name.to_string(),
831            expected: SqlType::Boolean,
832            got: format!("{value:?}"),
833        }),
834    }
835}
836
837fn coerce_i32(value: &JsonValue, field_name: &str) -> Result<i32, WriteError> {
838    match value {
839        JsonValue::Number(n) => n
840            .as_i64()
841            .and_then(|i| i32::try_from(i).ok())
842            .ok_or_else(|| WriteError::TypeMismatch {
843                field: field_name.to_string(),
844                expected: SqlType::Integer,
845                got: format!("{value:?}"),
846            }),
847        JsonValue::String(s) => s.parse::<i32>().map_err(|_| WriteError::TypeMismatch {
848            field: field_name.to_string(),
849            expected: SqlType::Integer,
850            got: s.clone(),
851        }),
852        _ => Err(WriteError::TypeMismatch {
853            field: field_name.to_string(),
854            expected: SqlType::Integer,
855            got: format!("{value:?}"),
856        }),
857    }
858}
859
860fn coerce_i64(value: &JsonValue, field_name: &str) -> Result<i64, WriteError> {
861    match value {
862        JsonValue::Number(n) => n.as_i64().ok_or_else(|| WriteError::TypeMismatch {
863            field: field_name.to_string(),
864            expected: SqlType::BigInt,
865            got: format!("{value:?}"),
866        }),
867        JsonValue::String(s) => s.parse::<i64>().map_err(|_| WriteError::TypeMismatch {
868            field: field_name.to_string(),
869            expected: SqlType::BigInt,
870            got: s.clone(),
871        }),
872        _ => Err(WriteError::TypeMismatch {
873            field: field_name.to_string(),
874            expected: SqlType::BigInt,
875            got: format!("{value:?}"),
876        }),
877    }
878}
879
880fn coerce_f32(value: &JsonValue, field_name: &str) -> Result<f32, WriteError> {
881    coerce_f64(value, field_name).map(|v| v as f32)
882}
883
884fn coerce_f64(value: &JsonValue, field_name: &str) -> Result<f64, WriteError> {
885    match value {
886        JsonValue::Number(n) => n.as_f64().ok_or_else(|| WriteError::TypeMismatch {
887            field: field_name.to_string(),
888            expected: SqlType::Double,
889            got: format!("{value:?}"),
890        }),
891        JsonValue::String(s) => s.parse::<f64>().map_err(|_| WriteError::TypeMismatch {
892            field: field_name.to_string(),
893            expected: SqlType::Double,
894            got: s.clone(),
895        }),
896        _ => Err(WriteError::TypeMismatch {
897            field: field_name.to_string(),
898            expected: SqlType::Double,
899            got: format!("{value:?}"),
900        }),
901    }
902}
903
904fn coerce_string(value: &JsonValue, field_name: &str) -> Result<String, WriteError> {
905    match value {
906        JsonValue::String(s) => Ok(s.clone()),
907        JsonValue::Number(n) => Ok(n.to_string()),
908        JsonValue::Bool(b) => Ok(b.to_string()),
909        _ => Err(WriteError::TypeMismatch {
910            field: field_name.to_string(),
911            expected: SqlType::Text,
912            got: format!("{value:?}"),
913        }),
914    }
915}
916
917/// Error type for the signal-firing per-instance write methods
918/// ([`Manager::save`] and [`Manager::delete_instance`]).
919///
920/// Wraps [`WriteError`] for the underlying SQL errors and adds one
921/// framework-level variant for models with no primary key declared.
922#[derive(Debug)]
923pub enum SaveError {
924    /// The model has no field with `primary_key = true`. Returned
925    /// by `save` and `delete_instance` which need the PK to build
926    /// the WHERE clause for UPDATE / DELETE.
927    NoPrimaryKey,
928    /// An underlying write-layer error (type mismatch, sqlx error,
929    /// etc.). See [`WriteError`] for the full variant list.
930    Write(WriteError),
931}
932
933impl std::fmt::Display for SaveError {
934    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
935        match self {
936            SaveError::NoPrimaryKey => write!(
937                f,
938                "umbral::orm::save: model has no primary key — cannot determine INSERT vs UPDATE"
939            ),
940            SaveError::Write(e) => write!(f, "{e}"),
941        }
942    }
943}
944
945impl std::error::Error for SaveError {
946    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
947        match self {
948            SaveError::Write(e) => Some(e),
949            _ => None,
950        }
951    }
952}
953
954impl From<WriteError> for SaveError {
955    fn from(e: WriteError) -> Self {
956        Self::Write(e)
957    }
958}
959
960/// True when this JSON value represents the "default" PK that should
961/// trigger autoincrement rather than be bound as an explicit value.
962///
963/// Conventions:
964/// - Integer PK: 0 is the autoincrement sentinel (matches
965///   SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT`).
966/// - UUID PK: nil / all-zeros UUID is the sentinel.
967/// - String PK: empty string. Users with non-empty string PKs always
968///   supply them; an empty string makes no sense as a real PK.
969pub fn is_default_pk(sql_type: SqlType, value: &JsonValue) -> bool {
970    match (sql_type, value) {
971        (SqlType::SmallInt | SqlType::Integer | SqlType::BigInt, JsonValue::Number(n)) => {
972            n.as_i64() == Some(0) || n.as_u64() == Some(0)
973        }
974        (SqlType::Uuid, JsonValue::String(s)) => {
975            s == "00000000-0000-0000-0000-000000000000" || s.is_empty()
976        }
977        (SqlType::Text, JsonValue::String(s)) => s.is_empty(),
978        _ => false,
979    }
980}
981
982#[cfg(test)]
983mod tests {
984    use super::*;
985    use serde_json::json;
986
987    #[test]
988    fn json_to_sea_value_passes_basic_types() {
989        let v = json_to_sea_value(SqlType::Integer, &json!(42), false, "x", None).unwrap();
990        assert!(matches!(v, SeaValue::Int(Some(42))));
991        let v = json_to_sea_value(SqlType::BigInt, &json!(42), false, "x", None).unwrap();
992        assert!(matches!(v, SeaValue::BigInt(Some(42))));
993        let v = json_to_sea_value(SqlType::Text, &json!("hi"), false, "x", None).unwrap();
994        assert!(matches!(v, SeaValue::String(Some(_))));
995        let v = json_to_sea_value(SqlType::Boolean, &json!(true), false, "x", None).unwrap();
996        assert!(matches!(v, SeaValue::Bool(Some(true))));
997        let v =
998            json_to_sea_value(SqlType::Json, &json!({ "nested": true }), false, "x", None).unwrap();
999        assert!(matches!(v, SeaValue::Json(Some(_))));
1000    }
1001
1002    #[test]
1003    fn json_to_sea_value_coerces_string_booleans() {
1004        let v = json_to_sea_value(SqlType::Boolean, &json!("true"), false, "x", None).unwrap();
1005        assert!(matches!(v, SeaValue::Bool(Some(true))));
1006        let v = json_to_sea_value(SqlType::Boolean, &json!("0"), false, "x", None).unwrap();
1007        assert!(matches!(v, SeaValue::Bool(Some(false))));
1008    }
1009
1010    #[test]
1011    fn json_to_sea_value_rejects_null_on_required_field() {
1012        let err = json_to_sea_value(SqlType::Integer, &json!(null), false, "x", None).unwrap_err();
1013        assert!(matches!(err, WriteError::RequiredFieldMissing { .. }));
1014    }
1015
1016    #[test]
1017    fn json_to_sea_value_accepts_null_on_nullable_field() {
1018        let v = json_to_sea_value(SqlType::Integer, &json!(null), true, "x", None).unwrap();
1019        assert!(matches!(v, SeaValue::Int(None)));
1020        let v = json_to_sea_value(SqlType::Json, &json!(null), true, "x", None).unwrap();
1021        assert!(matches!(v, SeaValue::Json(None)));
1022    }
1023
1024    #[test]
1025    fn json_to_sea_value_accepts_datetime_local_form_shape() {
1026        // RFC3339 with offset — the canonical wire form.
1027        let v = json_to_sea_value(
1028            SqlType::Timestamptz,
1029            &json!("2026-06-03T22:24:00Z"),
1030            false,
1031            "x",
1032            None,
1033        )
1034        .unwrap();
1035        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1036            panic!("expected ChronoDateTimeUtc");
1037        };
1038        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1039
1040        // Naive with seconds — common JSON / HTML-form shape.
1041        let v = json_to_sea_value(
1042            SqlType::Timestamptz,
1043            &json!("2026-06-03T22:24:00"),
1044            false,
1045            "x",
1046            None,
1047        )
1048        .unwrap();
1049        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1050            panic!("expected ChronoDateTimeUtc");
1051        };
1052        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1053
1054        // Naive without seconds — the literal HTML
1055        // `<input type="datetime-local">` shape that the admin's
1056        // auto-generated forms post. This was the regression.
1057        let v = json_to_sea_value(
1058            SqlType::Timestamptz,
1059            &json!("2026-06-03T22:24"),
1060            false,
1061            "x",
1062            None,
1063        )
1064        .unwrap();
1065        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1066            panic!("expected ChronoDateTimeUtc");
1067        };
1068        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1069
1070        // Garbage still rejected.
1071        let err = json_to_sea_value(SqlType::Timestamptz, &json!("not a date"), false, "x", None)
1072            .unwrap_err();
1073        assert!(matches!(err, WriteError::TypeMismatch { .. }));
1074    }
1075
1076    #[test]
1077    fn is_default_pk_recognises_zero_int_and_nil_uuid() {
1078        assert!(is_default_pk(SqlType::Integer, &json!(0)));
1079        assert!(is_default_pk(SqlType::BigInt, &json!(0)));
1080        assert!(!is_default_pk(SqlType::BigInt, &json!(42)));
1081        assert!(is_default_pk(
1082            SqlType::Uuid,
1083            &json!("00000000-0000-0000-0000-000000000000")
1084        ));
1085        assert!(!is_default_pk(SqlType::Uuid, &json!("not-zero")));
1086    }
1087}