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!(f, "umbral::orm::write: unique constraint on `{f_}` violated")
328                }
329                _ => write!(f, "umbral::orm::write: unique constraint violated"),
330            },
331            WriteError::NotNullViolation { field } => match field {
332                Some(f_) => write!(f, "umbral::orm::write: NOT NULL on `{f_}` violated"),
333                None => write!(f, "umbral::orm::write: NOT NULL violation"),
334            },
335            WriteError::CheckViolation { constraint } => match constraint {
336                Some(c) => write!(f, "umbral::orm::write: CHECK `{c}` violated"),
337                None => write!(f, "umbral::orm::write: CHECK constraint violated"),
338            },
339            WriteError::ForeignKeyViolation { field } => match field {
340                Some(f_) => write!(
341                    f,
342                    "umbral::orm::write: foreign-key constraint on `{f_}` violated"
343                ),
344                None => write!(f, "umbral::orm::write: foreign-key constraint violated"),
345            },
346            WriteError::Multiple { errors } => {
347                write!(f, "umbral::orm::write: {} validation error(s)", errors.len())
348            }
349            WriteError::TypeMismatch {
350                field,
351                expected,
352                got,
353            } => write!(
354                f,
355                "umbral::orm::write: field `{field}` expected `{expected:?}`, got `{got}`",
356            ),
357            WriteError::Validator { field, message } => {
358                write!(f, "umbral::orm::write: field `{field}` {message}")
359            }
360            WriteError::NotAnObject => write!(
361                f,
362                "umbral::orm::write: model didn't serialize to a JSON object — make sure your struct uses a flat field layout",
363            ),
364            WriteError::SerializeFailed(e) => write!(f, "umbral::orm::write: serialize: {e}"),
365            WriteError::Sqlx(e) => write!(f, "umbral::orm::write: sqlx: {e}"),
366            WriteError::UnknownColumn { field } => {
367                write!(f, "umbral::orm::write: unknown column `{field}` on model")
368            }
369        }
370    }
371}
372
373impl std::error::Error for WriteError {}
374
375impl From<sqlx::Error> for WriteError {
376    fn from(e: sqlx::Error) -> Self {
377        Self::Sqlx(e)
378    }
379}
380
381impl From<serde_json::Error> for WriteError {
382    fn from(e: serde_json::Error) -> Self {
383        Self::SerializeFailed(e)
384    }
385}
386
387/// Convert a `serde_json::Value` to a `sea_query::Value` per the
388/// column's declared `SqlType`. The `nullable` flag controls how
389/// `JsonValue::Null` is handled:
390///
391/// - `nullable = true`: NULL is bound (the right SeaValue variant
392///   with `None`).
393/// - `nullable = false`: NULL produces `RequiredFieldMissing`.
394///
395/// String / number coercions follow the HTML-form-and-REST norms:
396/// `"true"` / `"false"` strings coerce to booleans, `"123"` strings
397/// coerce to numbers. RFC 3339 timestamps come through as strings on
398/// JSON inputs (serde_json doesn't have a native datetime).
399///
400/// `fk_target_pk` carries the target PK's `SqlType` for a `ForeignKey`
401/// column (gaps2 #42). This function can't see `fk_target`, so without
402/// it the FK arm couldn't tell an i64-PK FK from a String-PK one and
403/// bound every string-valued FK id as TEXT — which a Postgres `bigint`
404/// FK column rejects (`column "..." is of type bigint but expression is
405/// of type text`). Callers resolve the target PK type and pass it here
406/// (`Some(Text)` / `Some(Uuid)` bind as-is; numeric-PK or unresolved
407/// targets coerce the string → BigInt). `None` for every non-FK column.
408pub fn json_to_sea_value(
409    sql_type: SqlType,
410    value: &JsonValue,
411    nullable: bool,
412    field_name: &str,
413    fk_target_pk: Option<SqlType>,
414) -> Result<SeaValue, WriteError> {
415    // null handling first — applies regardless of expected type.
416    if value.is_null() {
417        if !nullable {
418            return Err(WriteError::RequiredFieldMissing {
419                field: field_name.to_string(),
420            });
421        }
422        return Ok(null_for(sql_type));
423    }
424
425    match sql_type {
426        SqlType::Boolean => coerce_bool(value, field_name),
427        SqlType::SmallInt | SqlType::Integer => {
428            coerce_i32(value, field_name).map(|v| SeaValue::Int(Some(v)))
429        }
430        SqlType::BigInt => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
431        // gaps2 #42: bind a `ForeignKey` id against its TARGET PK's
432        // type, not the JSON value's runtime shape. Before, a
433        // `JsonValue::String("1")` FK id was bound TEXT unconditionally
434        // because this function couldn't see `fk_target` — which a
435        // Postgres `bigint` FK column rejects ("column ... is of type
436        // bigint but expression is of type text"). The caller now
437        // resolves the target PK type (via `fk_target_pk_sql_type` /
438        // `pk_meta_for_table`) and threads it in as `fk_target_pk`:
439        //   - Text-PK target  → bind the id as text;
440        //   - Uuid-PK target  → parse + bind a UUID;
441        //   - numeric-PK target (or unresolved, the common i64 case)
442        //     → coerce the string / number → BigInt.
443        // `coerce_i64` already accepts `JsonValue::String("1")`, so a
444        // numeric string now binds `BigInt(1)`. This mirrors
445        // `form_str_to_sea_value`'s FK arm in `orm/dynamic.rs`.
446        SqlType::ForeignKey => match fk_target_pk {
447            Some(SqlType::Text) => {
448                coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
449            }
450            Some(SqlType::Uuid) => match value {
451                JsonValue::String(s) => uuid::Uuid::parse_str(s)
452                    .map(|u| SeaValue::Uuid(Some(Box::new(u))))
453                    .map_err(|_| WriteError::TypeMismatch {
454                        field: field_name.to_string(),
455                        expected: SqlType::Uuid,
456                        got: s.clone(),
457                    }),
458                _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
459            },
460            _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
461        },
462        SqlType::Real => coerce_f32(value, field_name).map(|v| SeaValue::Float(Some(v))),
463        SqlType::Double => coerce_f64(value, field_name).map(|v| SeaValue::Double(Some(v))),
464        SqlType::Text => {
465            coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
466        }
467        SqlType::Date => {
468            let s = coerce_string(value, field_name)?;
469            let d = chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(|_| {
470                WriteError::TypeMismatch {
471                    field: field_name.to_string(),
472                    expected: sql_type,
473                    got: format!("{value:?}"),
474                }
475            })?;
476            Ok(SeaValue::ChronoDate(Some(Box::new(d))))
477        }
478        SqlType::Time => {
479            let s = coerce_string(value, field_name)?;
480            let t = chrono::NaiveTime::parse_from_str(&s, "%H:%M:%S")
481                .or_else(|_| chrono::NaiveTime::parse_from_str(&s, "%H:%M"))
482                .map_err(|_| WriteError::TypeMismatch {
483                    field: field_name.to_string(),
484                    expected: sql_type,
485                    got: format!("{value:?}"),
486                })?;
487            Ok(SeaValue::ChronoTime(Some(Box::new(t))))
488        }
489        SqlType::Timestamptz => {
490            let s = coerce_string(value, field_name)?;
491            // Accept several wire shapes that real callers send:
492            //   1. RFC3339 with offset / Z — the canonical machine form
493            //      and what serde / API clients emit.
494            //   2. Naive `YYYY-MM-DDTHH:MM:SS` — common for hand-written
495            //      JSON and typical form serializers.
496            //   3. Naive `YYYY-MM-DDTHH:MM` — the literal output of HTML
497            //      `<input type="datetime-local">`. The admin's
498            //      auto-generated forms post exactly this shape, so
499            //      rejecting it broke every Timestamptz field edit.
500            // Gap 106: naive forms are interpreted in the
501            // configured `Settings::time_zone` (falling back to UTC
502            // when the setting is absent), then converted to UTC
503            // for storage. Tz-bearing RFC3339 inputs win regardless
504            // of the project tz — the offset they carry is the
505            // ground truth.
506            let dt = chrono::DateTime::parse_from_rfc3339(&s)
507                .map(|d| d.with_timezone(&chrono::Utc))
508                .or_else(|_| {
509                    chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
510                        .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M"))
511                        .map(|naive| {
512                            crate::timezone::naive_local_to_utc(naive)
513                                .unwrap_or_else(|| naive.and_utc())
514                        })
515                })
516                .map_err(|_| WriteError::TypeMismatch {
517                    field: field_name.to_string(),
518                    expected: sql_type,
519                    got: format!("{value:?}"),
520                })?;
521            Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(dt))))
522        }
523        SqlType::Uuid => {
524            let s = coerce_string(value, field_name)?;
525            let u = uuid::Uuid::parse_str(&s).map_err(|_| WriteError::TypeMismatch {
526                field: field_name.to_string(),
527                expected: sql_type,
528                got: format!("{value:?}"),
529            })?;
530            Ok(SeaValue::Uuid(Some(Box::new(u))))
531        }
532        SqlType::Json => {
533            // Store the JSON as-is so sqlx binds JSON/JSONB with the
534            // backend's typed encoder instead of a plain text parameter.
535            Ok(SeaValue::Json(Some(Box::new(value.clone()))))
536        }
537        // Postgres-only catalogue. Returned as a serialized string;
538        // the per-backend bind layer downstream handles the cast.
539        // These paths are only reachable for PG-bound models (the
540        // field.backend check at App::build blocks SQLite).
541        SqlType::Array(_)
542        | SqlType::Inet
543        | SqlType::Cidr
544        | SqlType::MacAddr
545        // gaps2 #70: XML / LTREE / BIT VARYING are text-backed — the
546        // value arrives as a JSON string and binds as a text parameter;
547        // Postgres applies the column's own cast on the way in.
548        | SqlType::Xml
549        | SqlType::Ltree
550        | SqlType::Bit
551        | SqlType::FullText => Ok(SeaValue::String(Some(Box::new(coerce_string(
552            value, field_name,
553        )?)))),
554        // BLOB / BYTEA. JSON wire shape: an array of u8 numbers, the
555        // natural way to encode a byte string in JSON without picking
556        // a base16/base64 convention at the framework level.
557        // Hex-encoded JSON strings also accepted as a convenience for
558        // human-readable test fixtures.
559        SqlType::Bytes => {
560            coerce_bytes(value, field_name).map(|b| SeaValue::Bytes(Some(Box::new(b))))
561        }
562        // BUG-10: NUMERIC. Accept JSON numbers (round-trip through
563        // f64 — adequate for most reasonable values; truly large
564        // exact decimals come in as strings) AND JSON strings
565        // (canonical for money values). Anything else fails the
566        // typed coerce.
567        SqlType::Decimal => coerce_decimal(value, field_name),
568    }
569}
570
571fn coerce_decimal(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
572    use std::str::FromStr;
573    // Round-trip through the serde_json textual representation —
574    // serde_json::Number prints integers / floats verbatim, so
575    // `n.to_string()` reads back as the same precision the wire
576    // value carried. Avoids the f64 trap of "3.10" arriving as
577    // 3.1000000000000001.
578    let parsed: Option<rust_decimal::Decimal> = match value {
579        JsonValue::String(s) => rust_decimal::Decimal::from_str(s).ok(),
580        JsonValue::Number(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(),
581        _ => None,
582    };
583    parsed
584        .map(|d| SeaValue::Decimal(Some(Box::new(d))))
585        .ok_or_else(|| WriteError::TypeMismatch {
586            field: field_name.to_string(),
587            expected: SqlType::Decimal,
588            got: format!("{value:?}"),
589        })
590}
591
592/// Coerce a `serde_json::Value` to `Vec<u8>`. Accepts:
593///   - `[1, 2, 3, ...]` — JSON array of u8-shaped numbers.
594///   - `"deadbeef"` — lowercase hex string of even length.
595fn coerce_bytes(value: &JsonValue, field_name: &str) -> Result<Vec<u8>, WriteError> {
596    if let Some(arr) = value.as_array() {
597        let mut out = Vec::with_capacity(arr.len());
598        for v in arr {
599            let n = v.as_u64().ok_or_else(|| WriteError::TypeMismatch {
600                field: field_name.to_string(),
601                expected: SqlType::Bytes,
602                got: format!("{v:?}"),
603            })?;
604            if n > 255 {
605                return Err(WriteError::TypeMismatch {
606                    field: field_name.to_string(),
607                    expected: SqlType::Bytes,
608                    got: format!("element {v} out of u8 range"),
609                });
610            }
611            out.push(n as u8);
612        }
613        return Ok(out);
614    }
615    if let Some(s) = value.as_str() {
616        if s.len() % 2 != 0 {
617            return Err(WriteError::TypeMismatch {
618                field: field_name.to_string(),
619                expected: SqlType::Bytes,
620                got: "hex string has odd length".to_string(),
621            });
622        }
623        let mut out = Vec::with_capacity(s.len() / 2);
624        for chunk in s.as_bytes().chunks(2) {
625            let high = hex_nibble(chunk[0]).ok_or_else(|| WriteError::TypeMismatch {
626                field: field_name.to_string(),
627                expected: SqlType::Bytes,
628                got: format!("non-hex char `{}`", chunk[0] as char),
629            })?;
630            let low = hex_nibble(chunk[1]).ok_or_else(|| WriteError::TypeMismatch {
631                field: field_name.to_string(),
632                expected: SqlType::Bytes,
633                got: format!("non-hex char `{}`", chunk[1] as char),
634            })?;
635            out.push((high << 4) | low);
636        }
637        return Ok(out);
638    }
639    Err(WriteError::TypeMismatch {
640        field: field_name.to_string(),
641        expected: SqlType::Bytes,
642        got: format!("{value:?}"),
643    })
644}
645
646fn hex_nibble(b: u8) -> Option<u8> {
647    match b {
648        b'0'..=b'9' => Some(b - b'0'),
649        b'a'..=b'f' => Some(10 + b - b'a'),
650        b'A'..=b'F' => Some(10 + b - b'A'),
651        _ => None,
652    }
653}
654
655/// Build the sea-query value the framework substitutes when an
656/// `auto_now` / `auto_now_add` column needs to be auto-populated.
657/// Used by [`crate::orm::dynamic::DynQuerySet::insert_json`] and
658/// `update_json`. Closes BUG-5 from `bugs/tests/testBugs.md`.
659///
660/// Supported column types: `Timestamptz` (the common case), `Date`,
661/// `Time`. Anything else falls back to the SQL NULL form for that
662/// column type, since a non-time column tagged `#[umbral(auto_now)]`
663/// is a developer mistake — there's no sensible "now" value to
664/// produce. The macro could in principle reject the attribute on
665/// non-time columns at derive time; we defer that polish to the
666/// macro pass where it lands alongside other "wrong attribute on
667/// wrong type" diagnostics.
668/// Gap 109: slug derivation. Lowercases the input, replaces
669/// runs of non-alphanumeric ASCII characters with a single `-`, trims
670/// leading/trailing dashes, and collapses repeated dashes. Empty / pure-
671/// punctuation input returns the empty string.
672///
673/// Mirrors what most slug libraries do for the ASCII path; non-ASCII
674/// characters are dropped (we don't transliterate at v1 — a unicode
675/// transliterator is a heavier dep and our admins typically slugify
676/// English-language titles).
677pub fn slugify(s: &str) -> String {
678    let mut out = String::with_capacity(s.len());
679    let mut last_was_dash = true; // suppresses leading dashes
680    for c in s.chars() {
681        if c.is_ascii_alphanumeric() {
682            for low in c.to_lowercase() {
683                out.push(low);
684            }
685            last_was_dash = false;
686        } else if !last_was_dash {
687            out.push('-');
688            last_was_dash = true;
689        }
690    }
691    // Trim trailing dash if any.
692    while out.ends_with('-') {
693        out.pop();
694    }
695    out
696}
697
698/// Gap 109: walk the body and auto-derive slug columns from their
699/// configured source field where the slug column is missing or empty.
700///
701/// Called from the dynamic insert/update entry points BEFORE validation
702/// so the validator sees the populated slug. The `is_update` flag
703/// constrains the rule: on update, the slug is regenerated only when
704/// the source field is also present in the body. Without that guard,
705/// editing an unrelated column on an existing row would clobber a hand-
706/// tuned slug.
707pub fn apply_slug_from(
708    fields: &[crate::migrate::Column],
709    body: &mut serde_json::Map<String, serde_json::Value>,
710    is_update: bool,
711) {
712    for col in fields {
713        let Some(source) = col.slug_from.as_deref() else {
714            continue;
715        };
716        // Slug already explicitly supplied (non-empty string) — keep it.
717        let explicit = body
718            .get(&col.name)
719            .and_then(|v| v.as_str())
720            .map(|s| !s.is_empty())
721            .unwrap_or(false);
722        if explicit {
723            continue;
724        }
725        // On update, only regenerate when the source field is in the body.
726        let source_value = body.get(source).and_then(|v| v.as_str()).unwrap_or("");
727        if source_value.is_empty() {
728            continue;
729        }
730        if is_update && !body.contains_key(source) {
731            continue;
732        }
733        let slug = slugify(source_value);
734        if slug.is_empty() {
735            continue;
736        }
737        body.insert(col.name.clone(), serde_json::Value::String(slug));
738    }
739}
740
741pub fn now_for_column(sql_type: SqlType) -> SeaValue {
742    let now = chrono::Utc::now();
743    match sql_type {
744        SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(Some(Box::new(now))),
745        SqlType::Date => SeaValue::ChronoDate(Some(Box::new(now.date_naive()))),
746        SqlType::Time => SeaValue::ChronoTime(Some(Box::new(now.time()))),
747        _ => null_for(sql_type),
748    }
749}
750
751/// Sea-query value representing SQL NULL for the given SqlType. The
752/// variant tag matters for sea-query's encoding even when the inner
753/// option is `None`.
754pub(crate) fn null_for(sql_type: SqlType) -> SeaValue {
755    match sql_type {
756        SqlType::Boolean => SeaValue::Bool(None),
757        SqlType::SmallInt | SqlType::Integer => SeaValue::Int(None),
758        SqlType::BigInt | SqlType::ForeignKey => SeaValue::BigInt(None),
759        SqlType::Real => SeaValue::Float(None),
760        SqlType::Double => SeaValue::Double(None),
761        SqlType::Text => SeaValue::String(None),
762        SqlType::Json => SeaValue::Json(None),
763        SqlType::Date => SeaValue::ChronoDate(None),
764        SqlType::Time => SeaValue::ChronoTime(None),
765        SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(None),
766        SqlType::Uuid => SeaValue::Uuid(None),
767        SqlType::Array(_)
768        | SqlType::Inet
769        | SqlType::Cidr
770        | SqlType::MacAddr
771        | SqlType::Xml
772        | SqlType::Ltree
773        | SqlType::Bit
774        | SqlType::FullText => SeaValue::String(None),
775        SqlType::Bytes => SeaValue::Bytes(None),
776        SqlType::Decimal => SeaValue::Decimal(None),
777    }
778}
779
780fn coerce_bool(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
781    match value {
782        JsonValue::Bool(b) => Ok(SeaValue::Bool(Some(*b))),
783        JsonValue::String(s) => match s.as_str() {
784            "true" | "1" | "yes" | "on" => Ok(SeaValue::Bool(Some(true))),
785            "false" | "0" | "no" | "off" | "" => Ok(SeaValue::Bool(Some(false))),
786            _ => Err(WriteError::TypeMismatch {
787                field: field_name.to_string(),
788                expected: SqlType::Boolean,
789                got: format!("{value:?}"),
790            }),
791        },
792        JsonValue::Number(n) => Ok(SeaValue::Bool(Some(n.as_i64() != Some(0)))),
793        _ => Err(WriteError::TypeMismatch {
794            field: field_name.to_string(),
795            expected: SqlType::Boolean,
796            got: format!("{value:?}"),
797        }),
798    }
799}
800
801fn coerce_i32(value: &JsonValue, field_name: &str) -> Result<i32, WriteError> {
802    match value {
803        JsonValue::Number(n) => n
804            .as_i64()
805            .and_then(|i| i32::try_from(i).ok())
806            .ok_or_else(|| WriteError::TypeMismatch {
807                field: field_name.to_string(),
808                expected: SqlType::Integer,
809                got: format!("{value:?}"),
810            }),
811        JsonValue::String(s) => s.parse::<i32>().map_err(|_| WriteError::TypeMismatch {
812            field: field_name.to_string(),
813            expected: SqlType::Integer,
814            got: s.clone(),
815        }),
816        _ => Err(WriteError::TypeMismatch {
817            field: field_name.to_string(),
818            expected: SqlType::Integer,
819            got: format!("{value:?}"),
820        }),
821    }
822}
823
824fn coerce_i64(value: &JsonValue, field_name: &str) -> Result<i64, WriteError> {
825    match value {
826        JsonValue::Number(n) => n.as_i64().ok_or_else(|| WriteError::TypeMismatch {
827            field: field_name.to_string(),
828            expected: SqlType::BigInt,
829            got: format!("{value:?}"),
830        }),
831        JsonValue::String(s) => s.parse::<i64>().map_err(|_| WriteError::TypeMismatch {
832            field: field_name.to_string(),
833            expected: SqlType::BigInt,
834            got: s.clone(),
835        }),
836        _ => Err(WriteError::TypeMismatch {
837            field: field_name.to_string(),
838            expected: SqlType::BigInt,
839            got: format!("{value:?}"),
840        }),
841    }
842}
843
844fn coerce_f32(value: &JsonValue, field_name: &str) -> Result<f32, WriteError> {
845    coerce_f64(value, field_name).map(|v| v as f32)
846}
847
848fn coerce_f64(value: &JsonValue, field_name: &str) -> Result<f64, WriteError> {
849    match value {
850        JsonValue::Number(n) => n.as_f64().ok_or_else(|| WriteError::TypeMismatch {
851            field: field_name.to_string(),
852            expected: SqlType::Double,
853            got: format!("{value:?}"),
854        }),
855        JsonValue::String(s) => s.parse::<f64>().map_err(|_| WriteError::TypeMismatch {
856            field: field_name.to_string(),
857            expected: SqlType::Double,
858            got: s.clone(),
859        }),
860        _ => Err(WriteError::TypeMismatch {
861            field: field_name.to_string(),
862            expected: SqlType::Double,
863            got: format!("{value:?}"),
864        }),
865    }
866}
867
868fn coerce_string(value: &JsonValue, field_name: &str) -> Result<String, WriteError> {
869    match value {
870        JsonValue::String(s) => Ok(s.clone()),
871        JsonValue::Number(n) => Ok(n.to_string()),
872        JsonValue::Bool(b) => Ok(b.to_string()),
873        _ => Err(WriteError::TypeMismatch {
874            field: field_name.to_string(),
875            expected: SqlType::Text,
876            got: format!("{value:?}"),
877        }),
878    }
879}
880
881/// Error type for the signal-firing per-instance write methods
882/// ([`Manager::save`] and [`Manager::delete_instance`]).
883///
884/// Wraps [`WriteError`] for the underlying SQL errors and adds one
885/// framework-level variant for models with no primary key declared.
886#[derive(Debug)]
887pub enum SaveError {
888    /// The model has no field with `primary_key = true`. Returned
889    /// by `save` and `delete_instance` which need the PK to build
890    /// the WHERE clause for UPDATE / DELETE.
891    NoPrimaryKey,
892    /// An underlying write-layer error (type mismatch, sqlx error,
893    /// etc.). See [`WriteError`] for the full variant list.
894    Write(WriteError),
895}
896
897impl std::fmt::Display for SaveError {
898    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
899        match self {
900            SaveError::NoPrimaryKey => write!(
901                f,
902                "umbral::orm::save: model has no primary key — cannot determine INSERT vs UPDATE"
903            ),
904            SaveError::Write(e) => write!(f, "{e}"),
905        }
906    }
907}
908
909impl std::error::Error for SaveError {
910    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
911        match self {
912            SaveError::Write(e) => Some(e),
913            _ => None,
914        }
915    }
916}
917
918impl From<WriteError> for SaveError {
919    fn from(e: WriteError) -> Self {
920        Self::Write(e)
921    }
922}
923
924/// True when this JSON value represents the "default" PK that should
925/// trigger autoincrement rather than be bound as an explicit value.
926///
927/// Conventions:
928/// - Integer PK: 0 is the autoincrement sentinel (matches
929///   SQLite's `INTEGER PRIMARY KEY AUTOINCREMENT`).
930/// - UUID PK: nil / all-zeros UUID is the sentinel.
931/// - String PK: empty string. Users with non-empty string PKs always
932///   supply them; an empty string makes no sense as a real PK.
933pub fn is_default_pk(sql_type: SqlType, value: &JsonValue) -> bool {
934    match (sql_type, value) {
935        (SqlType::SmallInt | SqlType::Integer | SqlType::BigInt, JsonValue::Number(n)) => {
936            n.as_i64() == Some(0) || n.as_u64() == Some(0)
937        }
938        (SqlType::Uuid, JsonValue::String(s)) => {
939            s == "00000000-0000-0000-0000-000000000000" || s.is_empty()
940        }
941        (SqlType::Text, JsonValue::String(s)) => s.is_empty(),
942        _ => false,
943    }
944}
945
946#[cfg(test)]
947mod tests {
948    use super::*;
949    use serde_json::json;
950
951    #[test]
952    fn json_to_sea_value_passes_basic_types() {
953        let v = json_to_sea_value(SqlType::Integer, &json!(42), false, "x", None).unwrap();
954        assert!(matches!(v, SeaValue::Int(Some(42))));
955        let v = json_to_sea_value(SqlType::BigInt, &json!(42), false, "x", None).unwrap();
956        assert!(matches!(v, SeaValue::BigInt(Some(42))));
957        let v = json_to_sea_value(SqlType::Text, &json!("hi"), false, "x", None).unwrap();
958        assert!(matches!(v, SeaValue::String(Some(_))));
959        let v = json_to_sea_value(SqlType::Boolean, &json!(true), false, "x", None).unwrap();
960        assert!(matches!(v, SeaValue::Bool(Some(true))));
961        let v =
962            json_to_sea_value(SqlType::Json, &json!({ "nested": true }), false, "x", None).unwrap();
963        assert!(matches!(v, SeaValue::Json(Some(_))));
964    }
965
966    #[test]
967    fn json_to_sea_value_coerces_string_booleans() {
968        let v = json_to_sea_value(SqlType::Boolean, &json!("true"), false, "x", None).unwrap();
969        assert!(matches!(v, SeaValue::Bool(Some(true))));
970        let v = json_to_sea_value(SqlType::Boolean, &json!("0"), false, "x", None).unwrap();
971        assert!(matches!(v, SeaValue::Bool(Some(false))));
972    }
973
974    #[test]
975    fn json_to_sea_value_rejects_null_on_required_field() {
976        let err = json_to_sea_value(SqlType::Integer, &json!(null), false, "x", None).unwrap_err();
977        assert!(matches!(err, WriteError::RequiredFieldMissing { .. }));
978    }
979
980    #[test]
981    fn json_to_sea_value_accepts_null_on_nullable_field() {
982        let v = json_to_sea_value(SqlType::Integer, &json!(null), true, "x", None).unwrap();
983        assert!(matches!(v, SeaValue::Int(None)));
984        let v = json_to_sea_value(SqlType::Json, &json!(null), true, "x", None).unwrap();
985        assert!(matches!(v, SeaValue::Json(None)));
986    }
987
988    #[test]
989    fn json_to_sea_value_accepts_datetime_local_form_shape() {
990        // RFC3339 with offset — the canonical wire form.
991        let v = json_to_sea_value(
992            SqlType::Timestamptz,
993            &json!("2026-06-03T22:24:00Z"),
994            false,
995            "x",
996            None,
997        )
998        .unwrap();
999        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1000            panic!("expected ChronoDateTimeUtc");
1001        };
1002        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1003
1004        // Naive with seconds — common JSON / HTML-form shape.
1005        let v = json_to_sea_value(
1006            SqlType::Timestamptz,
1007            &json!("2026-06-03T22:24:00"),
1008            false,
1009            "x",
1010            None,
1011        )
1012        .unwrap();
1013        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1014            panic!("expected ChronoDateTimeUtc");
1015        };
1016        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1017
1018        // Naive without seconds — the literal HTML
1019        // `<input type="datetime-local">` shape that the admin's
1020        // auto-generated forms post. This was the regression.
1021        let v = json_to_sea_value(
1022            SqlType::Timestamptz,
1023            &json!("2026-06-03T22:24"),
1024            false,
1025            "x",
1026            None,
1027        )
1028        .unwrap();
1029        let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1030            panic!("expected ChronoDateTimeUtc");
1031        };
1032        assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1033
1034        // Garbage still rejected.
1035        let err = json_to_sea_value(SqlType::Timestamptz, &json!("not a date"), false, "x", None)
1036            .unwrap_err();
1037        assert!(matches!(err, WriteError::TypeMismatch { .. }));
1038    }
1039
1040    #[test]
1041    fn is_default_pk_recognises_zero_int_and_nil_uuid() {
1042        assert!(is_default_pk(SqlType::Integer, &json!(0)));
1043        assert!(is_default_pk(SqlType::BigInt, &json!(0)));
1044        assert!(!is_default_pk(SqlType::BigInt, &json!(42)));
1045        assert!(is_default_pk(
1046            SqlType::Uuid,
1047            &json!("00000000-0000-0000-0000-000000000000")
1048        ));
1049        assert!(!is_default_pk(SqlType::Uuid, &json!("not-zero")));
1050    }
1051}