1use sea_query::Value as SeaValue;
37use serde_json::Value as JsonValue;
38
39use crate::orm::SqlType;
40
41#[derive(Debug)]
48pub enum WriteError {
49 RequiredFieldMissing { field: String },
52 BlankNotAllowed { field: String },
56 ForeignKeyNotFound {
61 field: String,
62 target_table: String,
63 value: serde_json::Value,
64 },
65 UniqueViolation {
71 field: Option<String>,
72 value: Option<serde_json::Value>,
73 },
74 NotNullViolation { field: Option<String> },
77 AmbiguousLocalTime {
84 field: String,
85 value: String,
86 tz: String,
87 earlier: String,
88 later: String,
89 },
90 NonexistentLocalTime {
93 field: String,
94 value: String,
95 tz: String,
96 },
97 CheckViolation { constraint: Option<String> },
101 ForeignKeyViolation { field: Option<String> },
105 Multiple { errors: Vec<WriteError> },
109 TypeMismatch {
112 field: String,
113 expected: SqlType,
114 got: String,
115 },
116 Validator { field: String, message: String },
119 NotAnObject,
122 SerializeFailed(serde_json::Error),
126 Sqlx(sqlx::Error),
128 UnknownColumn { field: String },
131 ReadOnlyView { table: String },
139}
140
141impl WriteError {
142 pub fn field_errors(&self) -> std::collections::BTreeMap<String, Vec<String>> {
149 let mut out: std::collections::BTreeMap<String, Vec<String>> =
150 std::collections::BTreeMap::new();
151 self.collect_field_errors(&mut out);
152 out
153 }
154
155 fn collect_field_errors(&self, out: &mut std::collections::BTreeMap<String, Vec<String>>) {
156 use WriteError::*;
157 match self {
158 RequiredFieldMissing { field } => {
159 out.entry(field.clone())
160 .or_default()
161 .push("This field is required.".to_string());
162 }
163 BlankNotAllowed { field } => {
164 out.entry(field.clone())
165 .or_default()
166 .push("This field cannot be blank.".to_string());
167 }
168 AmbiguousLocalTime {
169 field,
170 tz,
171 earlier,
172 later,
173 ..
174 } => {
175 out.entry(field.clone()).or_default().push(format!(
176 "This time happens twice in {tz} (the clocks go back): {earlier} or \
177 {later}. Add a UTC offset to say which you mean."
178 ));
179 }
180 NonexistentLocalTime { field, tz, .. } => {
181 out.entry(field.clone()).or_default().push(format!(
182 "This time does not exist in {tz} — the clocks go forward across it."
183 ));
184 }
185 ForeignKeyNotFound {
186 field,
187 target_table,
188 value,
189 } => {
190 let value_repr = repr_json_value(value);
191 out.insert(
192 field.clone(),
193 vec![format!(
194 "Referenced {target_table} row with id={value_repr} not found."
195 )],
196 );
197 }
198 UniqueViolation {
199 field: Some(col),
200 value,
201 } => {
202 let value_repr = value.as_ref().map(repr_json_value);
203 let msg = match value_repr {
204 Some(v) => format!("A row with {col}={v} already exists."),
205 None => "A row with this value already exists.".to_string(),
206 };
207 out.insert(col.clone(), vec![msg]);
208 }
209 NotNullViolation { field: Some(col) } => {
210 out.entry(col.clone())
211 .or_default()
212 .push("This field is required.".to_string());
213 }
214 ForeignKeyViolation { field: Some(col) } => {
215 out.insert(
216 col.clone(),
217 vec!["Referenced row does not exist.".to_string()],
218 );
219 }
220 TypeMismatch {
221 field,
222 expected,
223 got,
224 } => {
225 out.entry(field.clone())
226 .or_default()
227 .push(format!("Expected `{expected:?}`, got `{got}`."));
228 }
229 Validator { field, message } => {
230 if !field.is_empty() {
237 out.entry(field.clone()).or_default().push(message.clone());
238 }
239 }
240 UnknownColumn { field } => {
241 out.entry(field.clone())
242 .or_default()
243 .push(format!("Unknown column `{field}` on this model."));
244 }
245 ReadOnlyView { .. } => {}
248 Multiple { errors } => {
249 for e in errors {
250 e.collect_field_errors(out);
251 }
252 }
253 _ => {
254 }
259 }
260 }
261
262 pub fn non_field_errors(&self) -> Vec<String> {
266 let mut out: Vec<String> = Vec::new();
267 self.collect_non_field_errors(&mut out);
268 out
269 }
270
271 fn collect_non_field_errors(&self, out: &mut Vec<String>) {
272 use WriteError::*;
273 match self {
274 UniqueViolation { field: None, .. } => {
275 out.push("A row with one or more of these values already exists.".into());
276 }
277 NotNullViolation { field: None } => {
278 out.push("A required field is missing.".into());
279 }
280 ForeignKeyViolation { field: None } => {
281 out.push("One or more foreign-key fields reference rows that don't exist.".into());
282 }
283 CheckViolation { constraint } => {
284 let msg = match constraint {
285 Some(c) => format!("Check constraint `{c}` failed."),
286 None => "A check constraint failed.".to_string(),
287 };
288 out.push(msg);
289 }
290 Validator { field, message } if field.is_empty() => {
293 out.push(message.clone());
294 }
295 ReadOnlyView { table } => {
296 out.push(format!(
297 "`{table}` is a database view and cannot be written to."
298 ));
299 }
300 Multiple { errors } => {
301 for e in errors {
302 e.collect_non_field_errors(out);
303 }
304 }
305 _ => {}
306 }
307 }
308
309 pub fn code(&self) -> &'static str {
313 use WriteError::*;
314 match self {
315 RequiredFieldMissing { .. } | BlankNotAllowed { .. } | NotNullViolation { .. } => {
316 "required_field"
317 }
318 ReadOnlyView { .. } => "read_only_view",
319 ForeignKeyNotFound { .. } | ForeignKeyViolation { .. } => "fk_constraint",
320 UniqueViolation { .. } => "unique_constraint",
321 CheckViolation { .. } => "check_constraint",
322 TypeMismatch { .. } => "type_mismatch",
323 AmbiguousLocalTime { .. } => "ambiguous_local_time",
327 NonexistentLocalTime { .. } => "nonexistent_local_time",
328 Validator { .. } => "validator_failed",
329 Multiple { .. } => "validation_error",
330 UnknownColumn { .. } => "unknown_column",
331 NotAnObject => "not_an_object",
332 SerializeFailed(_) => "serialize_failed",
333 Sqlx(_) => "database_error",
334 }
335 }
336
337 pub fn is_validation(&self) -> bool {
342 use WriteError::*;
343 !matches!(self, Sqlx(_) | SerializeFailed(_) | NotAnObject)
344 }
345}
346
347fn repr_json_value(v: &serde_json::Value) -> String {
351 match v {
352 serde_json::Value::String(s) => format!("'{s}'"),
353 serde_json::Value::Number(n) => n.to_string(),
354 serde_json::Value::Bool(b) => b.to_string(),
355 serde_json::Value::Null => "null".to_string(),
356 _ => serde_json::to_string(v).unwrap_or_else(|_| "(?)".to_string()),
357 }
358}
359
360impl std::fmt::Display for WriteError {
361 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362 match self {
363 WriteError::RequiredFieldMissing { field } => write!(
364 f,
365 "umbral::orm::write: required field `{field}` is missing or null"
366 ),
367 WriteError::ReadOnlyView { table } => write!(
368 f,
369 "umbral::orm::write: `{table}` is backed by a database view \
370 (#[umbral(view = \"...\")]) and is read-only — no insert, update or \
371 delete can run against it. Write to the underlying table instead."
372 ),
373 WriteError::BlankNotAllowed { field } => {
374 write!(f, "umbral::orm::write: field `{field}` cannot be blank")
375 }
376 WriteError::AmbiguousLocalTime {
377 field,
378 value,
379 tz,
380 earlier,
381 later,
382 } => write!(
383 f,
384 "umbral::orm::write: field `{field}`: local time '{value}' is ambiguous in \
385 `{tz}` — the clocks go back, so it occurs twice ({earlier} and {later}). \
386 Send an explicit UTC offset to say which you mean."
387 ),
388 WriteError::NonexistentLocalTime { field, value, tz } => write!(
389 f,
390 "umbral::orm::write: field `{field}`: local time '{value}' does not exist in \
391 `{tz}` — the clocks go forward across it. Send an explicit UTC offset, or a \
392 time outside the gap."
393 ),
394 WriteError::ForeignKeyNotFound {
395 field,
396 target_table,
397 value,
398 } => write!(
399 f,
400 "umbral::orm::write: field `{field}` references `{target_table}` row with id={} which does not exist",
401 repr_json_value(value),
402 ),
403 WriteError::UniqueViolation { field, value } => match (field, value) {
404 (Some(f_), Some(v)) => write!(
405 f,
406 "umbral::orm::write: unique constraint on `{f_}`={} violated",
407 repr_json_value(v),
408 ),
409 (Some(f_), None) => {
410 write!(
411 f,
412 "umbral::orm::write: unique constraint on `{f_}` violated"
413 )
414 }
415 _ => write!(f, "umbral::orm::write: unique constraint violated"),
416 },
417 WriteError::NotNullViolation { field } => match field {
418 Some(f_) => write!(f, "umbral::orm::write: NOT NULL on `{f_}` violated"),
419 None => write!(f, "umbral::orm::write: NOT NULL violation"),
420 },
421 WriteError::CheckViolation { constraint } => match constraint {
422 Some(c) => write!(f, "umbral::orm::write: CHECK `{c}` violated"),
423 None => write!(f, "umbral::orm::write: CHECK constraint violated"),
424 },
425 WriteError::ForeignKeyViolation { field } => match field {
426 Some(f_) => write!(
427 f,
428 "umbral::orm::write: foreign-key constraint on `{f_}` violated"
429 ),
430 None => write!(f, "umbral::orm::write: foreign-key constraint violated"),
431 },
432 WriteError::Multiple { errors } => {
433 write!(
434 f,
435 "umbral::orm::write: {} validation error(s)",
436 errors.len()
437 )
438 }
439 WriteError::TypeMismatch {
440 field,
441 expected,
442 got,
443 } => write!(
444 f,
445 "umbral::orm::write: field `{field}` expected `{expected:?}`, got `{got}`",
446 ),
447 WriteError::Validator { field, message } => {
448 write!(f, "umbral::orm::write: field `{field}` {message}")
449 }
450 WriteError::NotAnObject => write!(
451 f,
452 "umbral::orm::write: model didn't serialize to a JSON object — make sure your struct uses a flat field layout",
453 ),
454 WriteError::SerializeFailed(e) => write!(f, "umbral::orm::write: serialize: {e}"),
455 WriteError::Sqlx(e) => write!(f, "umbral::orm::write: sqlx: {e}"),
456 WriteError::UnknownColumn { field } => {
457 write!(f, "umbral::orm::write: unknown column `{field}` on model")
458 }
459 }
460 }
461}
462
463impl std::error::Error for WriteError {}
464
465impl From<sqlx::Error> for WriteError {
466 fn from(e: sqlx::Error) -> Self {
467 Self::Sqlx(e)
468 }
469}
470
471impl From<serde_json::Error> for WriteError {
472 fn from(e: serde_json::Error) -> Self {
473 Self::SerializeFailed(e)
474 }
475}
476
477pub fn is_masked_col(col: &crate::migrate::Column) -> bool {
503 col.ty == SqlType::Text && col.widget.as_deref() == Some("masked")
504}
505
506pub fn seal_masked_json(
513 col: &crate::migrate::Column,
514 value: &JsonValue,
515) -> Result<Option<JsonValue>, WriteError> {
516 if !is_masked_col(col) || value.is_null() {
517 return Ok(None);
518 }
519 let plain = coerce_string(value, &col.name)?;
520 let sealed = crate::orm::masked::ambient_seal(&plain).map_err(|e| WriteError::Validator {
521 field: col.name.clone(),
522 message: format!("could not seal masked field: {e}"),
523 })?;
524 Ok(Some(JsonValue::String(sealed)))
525}
526
527pub fn json_to_sea_value(
528 sql_type: SqlType,
529 value: &JsonValue,
530 nullable: bool,
531 field_name: &str,
532 fk_target_pk: Option<SqlType>,
533) -> Result<SeaValue, WriteError> {
534 if value.is_null() {
536 if !nullable {
537 return Err(WriteError::RequiredFieldMissing {
538 field: field_name.to_string(),
539 });
540 }
541 return Ok(null_for(sql_type));
542 }
543
544 match sql_type {
545 SqlType::Boolean => coerce_bool(value, field_name),
546 SqlType::SmallInt | SqlType::Integer => {
547 coerce_i32(value, field_name).map(|v| SeaValue::Int(Some(v)))
548 }
549 SqlType::BigInt => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
550 SqlType::ForeignKey => match fk_target_pk {
566 Some(SqlType::Text) => {
567 coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
568 }
569 Some(SqlType::Uuid) => match value {
570 JsonValue::String(s) => uuid::Uuid::parse_str(s)
571 .map(|u| SeaValue::Uuid(Some(Box::new(u))))
572 .map_err(|_| WriteError::TypeMismatch {
573 field: field_name.to_string(),
574 expected: SqlType::Uuid,
575 got: s.clone(),
576 }),
577 _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
578 },
579 _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
580 },
581 SqlType::Real => coerce_f32(value, field_name).map(|v| SeaValue::Float(Some(v))),
582 SqlType::Double => coerce_f64(value, field_name).map(|v| SeaValue::Double(Some(v))),
583 SqlType::Text => {
584 coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
585 }
586 SqlType::Date => {
587 let s = coerce_string(value, field_name)?;
588 let d = chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(|_| {
589 WriteError::TypeMismatch {
590 field: field_name.to_string(),
591 expected: sql_type,
592 got: format!("{value:?}"),
593 }
594 })?;
595 Ok(SeaValue::ChronoDate(Some(Box::new(d))))
596 }
597 SqlType::Time => {
598 let s = coerce_string(value, field_name)?;
599 let t = chrono::NaiveTime::parse_from_str(&s, "%H:%M:%S")
600 .or_else(|_| chrono::NaiveTime::parse_from_str(&s, "%H:%M"))
601 .map_err(|_| WriteError::TypeMismatch {
602 field: field_name.to_string(),
603 expected: sql_type,
604 got: format!("{value:?}"),
605 })?;
606 Ok(SeaValue::ChronoTime(Some(Box::new(t))))
607 }
608 SqlType::Timestamptz => {
609 let s = coerce_string(value, field_name)?;
610 if let Ok(offset_bearing) = chrono::DateTime::parse_from_rfc3339(&s) {
626 let utc = offset_bearing.with_timezone(&chrono::Utc);
627 return Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(utc))));
628 }
629
630 let naive = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
631 .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M"))
632 .map_err(|_| WriteError::TypeMismatch {
633 field: field_name.to_string(),
634 expected: sql_type,
635 got: format!("{value:?}"),
636 })?;
637
638 let tz = crate::timezone::active_tz();
645 let dt = crate::timezone::naive_local_to_utc_checked(naive).map_err(|e| match e {
646 crate::timezone::LocalTimeError::Ambiguous { earlier, later } => {
647 WriteError::AmbiguousLocalTime {
648 field: field_name.to_string(),
649 value: s.clone(),
650 tz: tz.name().to_string(),
651 earlier: earlier.to_rfc3339(),
652 later: later.to_rfc3339(),
653 }
654 }
655 crate::timezone::LocalTimeError::Nonexistent => WriteError::NonexistentLocalTime {
656 field: field_name.to_string(),
657 value: s.clone(),
658 tz: tz.name().to_string(),
659 },
660 })?;
661 Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(dt))))
662 }
663 SqlType::Uuid => {
664 let s = coerce_string(value, field_name)?;
665 let u = uuid::Uuid::parse_str(&s).map_err(|_| WriteError::TypeMismatch {
666 field: field_name.to_string(),
667 expected: sql_type,
668 got: format!("{value:?}"),
669 })?;
670 Ok(SeaValue::Uuid(Some(Box::new(u))))
671 }
672 SqlType::Json => {
673 Ok(SeaValue::Json(Some(Box::new(value.clone()))))
676 }
677 SqlType::Array(_)
682 | SqlType::Inet
683 | SqlType::Cidr
684 | SqlType::MacAddr
685 | SqlType::Xml
689 | SqlType::Ltree
690 | SqlType::Bit
691 | SqlType::FullText => Ok(SeaValue::String(Some(Box::new(coerce_string(
692 value, field_name,
693 )?)))),
694 SqlType::Bytes => {
700 coerce_bytes(value, field_name).map(|b| SeaValue::Bytes(Some(Box::new(b))))
701 }
702 SqlType::Decimal => coerce_decimal(value, field_name),
708 }
709}
710
711fn coerce_decimal(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
712 use std::str::FromStr;
713 let parsed: Option<rust_decimal::Decimal> = match value {
719 JsonValue::String(s) => rust_decimal::Decimal::from_str(s).ok(),
720 JsonValue::Number(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(),
721 _ => None,
722 };
723 parsed
724 .map(|d| SeaValue::Decimal(Some(Box::new(d))))
725 .ok_or_else(|| WriteError::TypeMismatch {
726 field: field_name.to_string(),
727 expected: SqlType::Decimal,
728 got: format!("{value:?}"),
729 })
730}
731
732fn coerce_bytes(value: &JsonValue, field_name: &str) -> Result<Vec<u8>, WriteError> {
736 if let Some(arr) = value.as_array() {
737 let mut out = Vec::with_capacity(arr.len());
738 for v in arr {
739 let n = v.as_u64().ok_or_else(|| WriteError::TypeMismatch {
740 field: field_name.to_string(),
741 expected: SqlType::Bytes,
742 got: format!("{v:?}"),
743 })?;
744 if n > 255 {
745 return Err(WriteError::TypeMismatch {
746 field: field_name.to_string(),
747 expected: SqlType::Bytes,
748 got: format!("element {v} out of u8 range"),
749 });
750 }
751 out.push(n as u8);
752 }
753 return Ok(out);
754 }
755 if let Some(s) = value.as_str() {
756 if s.len() % 2 != 0 {
757 return Err(WriteError::TypeMismatch {
758 field: field_name.to_string(),
759 expected: SqlType::Bytes,
760 got: "hex string has odd length".to_string(),
761 });
762 }
763 let mut out = Vec::with_capacity(s.len() / 2);
764 for chunk in s.as_bytes().chunks(2) {
765 let high = hex_nibble(chunk[0]).ok_or_else(|| WriteError::TypeMismatch {
766 field: field_name.to_string(),
767 expected: SqlType::Bytes,
768 got: format!("non-hex char `{}`", chunk[0] as char),
769 })?;
770 let low = hex_nibble(chunk[1]).ok_or_else(|| WriteError::TypeMismatch {
771 field: field_name.to_string(),
772 expected: SqlType::Bytes,
773 got: format!("non-hex char `{}`", chunk[1] as char),
774 })?;
775 out.push((high << 4) | low);
776 }
777 return Ok(out);
778 }
779 Err(WriteError::TypeMismatch {
780 field: field_name.to_string(),
781 expected: SqlType::Bytes,
782 got: format!("{value:?}"),
783 })
784}
785
786fn hex_nibble(b: u8) -> Option<u8> {
787 match b {
788 b'0'..=b'9' => Some(b - b'0'),
789 b'a'..=b'f' => Some(10 + b - b'a'),
790 b'A'..=b'F' => Some(10 + b - b'A'),
791 _ => None,
792 }
793}
794
795pub fn slugify(s: &str) -> String {
818 let mut out = String::with_capacity(s.len());
819 let mut last_was_dash = true; for c in s.chars() {
821 if c.is_ascii_alphanumeric() {
822 for low in c.to_lowercase() {
823 out.push(low);
824 }
825 last_was_dash = false;
826 } else if !last_was_dash {
827 out.push('-');
828 last_was_dash = true;
829 }
830 }
831 while out.ends_with('-') {
833 out.pop();
834 }
835 out
836}
837
838pub fn apply_slug_from(
848 fields: &[crate::migrate::Column],
849 body: &mut serde_json::Map<String, serde_json::Value>,
850 is_update: bool,
851) {
852 for col in fields {
853 let Some(source) = col.slug_from.as_deref() else {
854 continue;
855 };
856 let explicit = body
858 .get(&col.name)
859 .and_then(|v| v.as_str())
860 .map(|s| !s.is_empty())
861 .unwrap_or(false);
862 if explicit {
863 continue;
864 }
865 let source_value = body.get(source).and_then(|v| v.as_str()).unwrap_or("");
867 if source_value.is_empty() {
868 continue;
869 }
870 if is_update && !body.contains_key(source) {
871 continue;
872 }
873 let slug = slugify(source_value);
874 if slug.is_empty() {
875 continue;
876 }
877 body.insert(col.name.clone(), serde_json::Value::String(slug));
878 }
879}
880
881pub fn now_for_column(sql_type: SqlType) -> SeaValue {
882 let now = chrono::Utc::now();
883 match sql_type {
884 SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(Some(Box::new(now))),
885 SqlType::Date => SeaValue::ChronoDate(Some(Box::new(now.date_naive()))),
886 SqlType::Time => SeaValue::ChronoTime(Some(Box::new(now.time()))),
887 _ => null_for(sql_type),
888 }
889}
890
891pub fn user_for_column(sql_type: SqlType) -> SeaValue {
904 let Some(id) = crate::db::route_context::current_user_id() else {
905 return null_for(sql_type);
906 };
907 match sql_type {
908 SqlType::SmallInt | SqlType::Integer => match id.parse::<i32>() {
909 Ok(v) => SeaValue::Int(Some(v)),
910 Err(_) => null_for(sql_type),
911 },
912 SqlType::BigInt => match id.parse::<i64>() {
913 Ok(v) => SeaValue::BigInt(Some(v)),
914 Err(_) => null_for(sql_type),
915 },
916 SqlType::Uuid => match id.parse::<uuid::Uuid>() {
917 Ok(v) => SeaValue::Uuid(Some(Box::new(v))),
918 Err(_) => null_for(sql_type),
919 },
920 SqlType::Text => SeaValue::String(Some(Box::new(id))),
921 _ => null_for(sql_type),
925 }
926}
927
928pub fn normalize_json(trim: bool, lowercase: bool, v: &JsonValue) -> Option<JsonValue> {
941 if !(trim || lowercase) {
942 return None;
943 }
944 let s = v.as_str()?;
945 let s = if trim { s.trim() } else { s };
946 let out = if lowercase {
947 s.to_lowercase()
948 } else {
949 s.to_string()
950 };
951 Some(JsonValue::String(out))
952}
953
954pub(crate) fn null_for(sql_type: SqlType) -> SeaValue {
958 match sql_type {
959 SqlType::Boolean => SeaValue::Bool(None),
960 SqlType::SmallInt | SqlType::Integer => SeaValue::Int(None),
961 SqlType::BigInt | SqlType::ForeignKey => SeaValue::BigInt(None),
962 SqlType::Real => SeaValue::Float(None),
963 SqlType::Double => SeaValue::Double(None),
964 SqlType::Text => SeaValue::String(None),
965 SqlType::Json => SeaValue::Json(None),
966 SqlType::Date => SeaValue::ChronoDate(None),
967 SqlType::Time => SeaValue::ChronoTime(None),
968 SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(None),
969 SqlType::Uuid => SeaValue::Uuid(None),
970 SqlType::Array(_)
971 | SqlType::Inet
972 | SqlType::Cidr
973 | SqlType::MacAddr
974 | SqlType::Xml
975 | SqlType::Ltree
976 | SqlType::Bit
977 | SqlType::FullText => SeaValue::String(None),
978 SqlType::Bytes => SeaValue::Bytes(None),
979 SqlType::Decimal => SeaValue::Decimal(None),
980 }
981}
982
983fn coerce_bool(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
984 match value {
985 JsonValue::Bool(b) => Ok(SeaValue::Bool(Some(*b))),
986 JsonValue::String(s) => match s.as_str() {
987 "true" | "1" | "yes" | "on" => Ok(SeaValue::Bool(Some(true))),
988 "false" | "0" | "no" | "off" | "" => Ok(SeaValue::Bool(Some(false))),
989 _ => Err(WriteError::TypeMismatch {
990 field: field_name.to_string(),
991 expected: SqlType::Boolean,
992 got: format!("{value:?}"),
993 }),
994 },
995 JsonValue::Number(n) => Ok(SeaValue::Bool(Some(n.as_i64() != Some(0)))),
996 _ => Err(WriteError::TypeMismatch {
997 field: field_name.to_string(),
998 expected: SqlType::Boolean,
999 got: format!("{value:?}"),
1000 }),
1001 }
1002}
1003
1004fn coerce_i32(value: &JsonValue, field_name: &str) -> Result<i32, WriteError> {
1005 match value {
1006 JsonValue::Number(n) => n
1007 .as_i64()
1008 .and_then(|i| i32::try_from(i).ok())
1009 .ok_or_else(|| WriteError::TypeMismatch {
1010 field: field_name.to_string(),
1011 expected: SqlType::Integer,
1012 got: format!("{value:?}"),
1013 }),
1014 JsonValue::String(s) => s.parse::<i32>().map_err(|_| WriteError::TypeMismatch {
1015 field: field_name.to_string(),
1016 expected: SqlType::Integer,
1017 got: s.clone(),
1018 }),
1019 _ => Err(WriteError::TypeMismatch {
1020 field: field_name.to_string(),
1021 expected: SqlType::Integer,
1022 got: format!("{value:?}"),
1023 }),
1024 }
1025}
1026
1027fn coerce_i64(value: &JsonValue, field_name: &str) -> Result<i64, WriteError> {
1028 match value {
1029 JsonValue::Number(n) => n.as_i64().ok_or_else(|| WriteError::TypeMismatch {
1030 field: field_name.to_string(),
1031 expected: SqlType::BigInt,
1032 got: format!("{value:?}"),
1033 }),
1034 JsonValue::String(s) => s.parse::<i64>().map_err(|_| WriteError::TypeMismatch {
1035 field: field_name.to_string(),
1036 expected: SqlType::BigInt,
1037 got: s.clone(),
1038 }),
1039 _ => Err(WriteError::TypeMismatch {
1040 field: field_name.to_string(),
1041 expected: SqlType::BigInt,
1042 got: format!("{value:?}"),
1043 }),
1044 }
1045}
1046
1047fn coerce_f32(value: &JsonValue, field_name: &str) -> Result<f32, WriteError> {
1048 coerce_f64(value, field_name).map(|v| v as f32)
1049}
1050
1051fn coerce_f64(value: &JsonValue, field_name: &str) -> Result<f64, WriteError> {
1052 match value {
1053 JsonValue::Number(n) => n.as_f64().ok_or_else(|| WriteError::TypeMismatch {
1054 field: field_name.to_string(),
1055 expected: SqlType::Double,
1056 got: format!("{value:?}"),
1057 }),
1058 JsonValue::String(s) => s.parse::<f64>().map_err(|_| WriteError::TypeMismatch {
1059 field: field_name.to_string(),
1060 expected: SqlType::Double,
1061 got: s.clone(),
1062 }),
1063 _ => Err(WriteError::TypeMismatch {
1064 field: field_name.to_string(),
1065 expected: SqlType::Double,
1066 got: format!("{value:?}"),
1067 }),
1068 }
1069}
1070
1071fn coerce_string(value: &JsonValue, field_name: &str) -> Result<String, WriteError> {
1072 match value {
1073 JsonValue::String(s) => Ok(s.clone()),
1074 JsonValue::Number(n) => Ok(n.to_string()),
1075 JsonValue::Bool(b) => Ok(b.to_string()),
1076 _ => Err(WriteError::TypeMismatch {
1077 field: field_name.to_string(),
1078 expected: SqlType::Text,
1079 got: format!("{value:?}"),
1080 }),
1081 }
1082}
1083
1084#[derive(Debug)]
1090pub enum SaveError {
1091 NoPrimaryKey,
1095 Write(WriteError),
1098}
1099
1100impl std::fmt::Display for SaveError {
1101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1102 match self {
1103 SaveError::NoPrimaryKey => write!(
1104 f,
1105 "umbral::orm::save: model has no primary key — cannot determine INSERT vs UPDATE"
1106 ),
1107 SaveError::Write(e) => write!(f, "{e}"),
1108 }
1109 }
1110}
1111
1112impl std::error::Error for SaveError {
1113 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1114 match self {
1115 SaveError::Write(e) => Some(e),
1116 _ => None,
1117 }
1118 }
1119}
1120
1121impl From<WriteError> for SaveError {
1122 fn from(e: WriteError) -> Self {
1123 Self::Write(e)
1124 }
1125}
1126
1127pub fn is_default_pk(sql_type: SqlType, value: &JsonValue) -> bool {
1137 match (sql_type, value) {
1138 (SqlType::SmallInt | SqlType::Integer | SqlType::BigInt, JsonValue::Number(n)) => {
1139 n.as_i64() == Some(0) || n.as_u64() == Some(0)
1140 }
1141 (SqlType::Uuid, JsonValue::String(s)) => {
1142 s == "00000000-0000-0000-0000-000000000000" || s.is_empty()
1143 }
1144 (SqlType::Text, JsonValue::String(s)) => s.is_empty(),
1145 _ => false,
1146 }
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151 use super::*;
1152 use serde_json::json;
1153
1154 #[test]
1155 fn json_to_sea_value_passes_basic_types() {
1156 let v = json_to_sea_value(SqlType::Integer, &json!(42), false, "x", None).unwrap();
1157 assert!(matches!(v, SeaValue::Int(Some(42))));
1158 let v = json_to_sea_value(SqlType::BigInt, &json!(42), false, "x", None).unwrap();
1159 assert!(matches!(v, SeaValue::BigInt(Some(42))));
1160 let v = json_to_sea_value(SqlType::Text, &json!("hi"), false, "x", None).unwrap();
1161 assert!(matches!(v, SeaValue::String(Some(_))));
1162 let v = json_to_sea_value(SqlType::Boolean, &json!(true), false, "x", None).unwrap();
1163 assert!(matches!(v, SeaValue::Bool(Some(true))));
1164 let v =
1165 json_to_sea_value(SqlType::Json, &json!({ "nested": true }), false, "x", None).unwrap();
1166 assert!(matches!(v, SeaValue::Json(Some(_))));
1167 }
1168
1169 #[test]
1170 fn json_to_sea_value_coerces_string_booleans() {
1171 let v = json_to_sea_value(SqlType::Boolean, &json!("true"), false, "x", None).unwrap();
1172 assert!(matches!(v, SeaValue::Bool(Some(true))));
1173 let v = json_to_sea_value(SqlType::Boolean, &json!("0"), false, "x", None).unwrap();
1174 assert!(matches!(v, SeaValue::Bool(Some(false))));
1175 }
1176
1177 #[test]
1178 fn json_to_sea_value_rejects_null_on_required_field() {
1179 let err = json_to_sea_value(SqlType::Integer, &json!(null), false, "x", None).unwrap_err();
1180 assert!(matches!(err, WriteError::RequiredFieldMissing { .. }));
1181 }
1182
1183 #[test]
1184 fn json_to_sea_value_accepts_null_on_nullable_field() {
1185 let v = json_to_sea_value(SqlType::Integer, &json!(null), true, "x", None).unwrap();
1186 assert!(matches!(v, SeaValue::Int(None)));
1187 let v = json_to_sea_value(SqlType::Json, &json!(null), true, "x", None).unwrap();
1188 assert!(matches!(v, SeaValue::Json(None)));
1189 }
1190
1191 #[test]
1192 fn json_to_sea_value_accepts_datetime_local_form_shape() {
1193 let v = json_to_sea_value(
1195 SqlType::Timestamptz,
1196 &json!("2026-06-03T22:24:00Z"),
1197 false,
1198 "x",
1199 None,
1200 )
1201 .unwrap();
1202 let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1203 panic!("expected ChronoDateTimeUtc");
1204 };
1205 assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1206
1207 let v = json_to_sea_value(
1209 SqlType::Timestamptz,
1210 &json!("2026-06-03T22:24:00"),
1211 false,
1212 "x",
1213 None,
1214 )
1215 .unwrap();
1216 let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1217 panic!("expected ChronoDateTimeUtc");
1218 };
1219 assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1220
1221 let v = json_to_sea_value(
1225 SqlType::Timestamptz,
1226 &json!("2026-06-03T22:24"),
1227 false,
1228 "x",
1229 None,
1230 )
1231 .unwrap();
1232 let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1233 panic!("expected ChronoDateTimeUtc");
1234 };
1235 assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1236
1237 let err = json_to_sea_value(SqlType::Timestamptz, &json!("not a date"), false, "x", None)
1239 .unwrap_err();
1240 assert!(matches!(err, WriteError::TypeMismatch { .. }));
1241 }
1242
1243 #[test]
1244 fn is_default_pk_recognises_zero_int_and_nil_uuid() {
1245 assert!(is_default_pk(SqlType::Integer, &json!(0)));
1246 assert!(is_default_pk(SqlType::BigInt, &json!(0)));
1247 assert!(!is_default_pk(SqlType::BigInt, &json!(42)));
1248 assert!(is_default_pk(
1249 SqlType::Uuid,
1250 &json!("00000000-0000-0000-0000-000000000000")
1251 ));
1252 assert!(!is_default_pk(SqlType::Uuid, &json!("not-zero")));
1253 }
1254}