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 CheckViolation { constraint: Option<String> },
81 ForeignKeyViolation { field: Option<String> },
85 Multiple { errors: Vec<WriteError> },
89 TypeMismatch {
92 field: String,
93 expected: SqlType,
94 got: String,
95 },
96 Validator { field: String, message: String },
99 NotAnObject,
102 SerializeFailed(serde_json::Error),
106 Sqlx(sqlx::Error),
108 UnknownColumn { field: String },
111}
112
113impl WriteError {
114 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 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 }
211 }
212 }
213
214 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 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 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 pub fn is_validation(&self) -> bool {
283 use WriteError::*;
284 !matches!(self, Sqlx(_) | SerializeFailed(_) | NotAnObject)
285 }
286}
287
288fn 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
394pub fn is_masked_col(col: &crate::migrate::Column) -> bool {
420 col.ty == SqlType::Text && col.widget.as_deref() == Some("masked")
421}
422
423pub 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 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 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 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 Ok(SeaValue::Json(Some(Box::new(value.clone()))))
572 }
573 SqlType::Array(_)
578 | SqlType::Inet
579 | SqlType::Cidr
580 | SqlType::MacAddr
581 | 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 SqlType::Bytes => {
596 coerce_bytes(value, field_name).map(|b| SeaValue::Bytes(Some(Box::new(b))))
597 }
598 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 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
628fn 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
691pub fn slugify(s: &str) -> String {
714 let mut out = String::with_capacity(s.len());
715 let mut last_was_dash = true; 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 while out.ends_with('-') {
729 out.pop();
730 }
731 out
732}
733
734pub 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 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 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
787pub(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#[derive(Debug)]
923pub enum SaveError {
924 NoPrimaryKey,
928 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
960pub 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 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 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 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 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}