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 is_masked_no_change(col: &crate::migrate::Column, value: &JsonValue) -> bool {
515 is_masked_col(col)
516 && value
517 .as_str()
518 .map(|s| s.is_empty() || s == crate::orm::masked::REDACTED)
519 .unwrap_or(false)
520}
521
522pub fn seal_masked_json(
529 col: &crate::migrate::Column,
530 value: &JsonValue,
531) -> Result<Option<JsonValue>, WriteError> {
532 if !is_masked_col(col) || value.is_null() {
533 return Ok(None);
534 }
535 let plain = coerce_string(value, &col.name)?;
536 let sealed = crate::orm::masked::ambient_seal(&plain).map_err(|e| WriteError::Validator {
537 field: col.name.clone(),
538 message: format!("could not seal masked field: {e}"),
539 })?;
540 Ok(Some(JsonValue::String(sealed)))
541}
542
543pub fn json_to_sea_value(
544 sql_type: SqlType,
545 value: &JsonValue,
546 nullable: bool,
547 field_name: &str,
548 fk_target_pk: Option<SqlType>,
549) -> Result<SeaValue, WriteError> {
550 if value.is_null() {
552 if !nullable {
553 return Err(WriteError::RequiredFieldMissing {
554 field: field_name.to_string(),
555 });
556 }
557 return Ok(null_for(sql_type));
558 }
559
560 match sql_type {
561 SqlType::Boolean => coerce_bool(value, field_name),
562 SqlType::SmallInt | SqlType::Integer => {
563 coerce_i32(value, field_name).map(|v| SeaValue::Int(Some(v)))
564 }
565 SqlType::BigInt => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
566 SqlType::ForeignKey => match fk_target_pk {
582 Some(SqlType::Text) => {
583 coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
584 }
585 Some(SqlType::Uuid) => match value {
586 JsonValue::String(s) => uuid::Uuid::parse_str(s)
587 .map(|u| SeaValue::Uuid(Some(Box::new(u))))
588 .map_err(|_| WriteError::TypeMismatch {
589 field: field_name.to_string(),
590 expected: SqlType::Uuid,
591 got: s.clone(),
592 }),
593 _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
594 },
595 _ => coerce_i64(value, field_name).map(|v| SeaValue::BigInt(Some(v))),
596 },
597 SqlType::Real => coerce_f32(value, field_name).map(|v| SeaValue::Float(Some(v))),
598 SqlType::Double => coerce_f64(value, field_name).map(|v| SeaValue::Double(Some(v))),
599 SqlType::Text => {
600 coerce_string(value, field_name).map(|s| SeaValue::String(Some(Box::new(s))))
601 }
602 SqlType::Date => {
603 let s = coerce_string(value, field_name)?;
604 let d = chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").map_err(|_| {
605 WriteError::TypeMismatch {
606 field: field_name.to_string(),
607 expected: sql_type,
608 got: format!("{value:?}"),
609 }
610 })?;
611 Ok(SeaValue::ChronoDate(Some(Box::new(d))))
612 }
613 SqlType::Time => {
614 let s = coerce_string(value, field_name)?;
615 let t = chrono::NaiveTime::parse_from_str(&s, "%H:%M:%S%.f")
621 .or_else(|_| chrono::NaiveTime::parse_from_str(&s, "%H:%M:%S"))
622 .or_else(|_| chrono::NaiveTime::parse_from_str(&s, "%H:%M"))
623 .map_err(|_| WriteError::TypeMismatch {
624 field: field_name.to_string(),
625 expected: sql_type,
626 got: format!("{value:?}"),
627 })?;
628 Ok(SeaValue::ChronoTime(Some(Box::new(t))))
629 }
630 SqlType::Timestamptz => {
631 let s = coerce_string(value, field_name)?;
632 if let Ok(offset_bearing) = chrono::DateTime::parse_from_rfc3339(&s) {
648 let utc = offset_bearing.with_timezone(&chrono::Utc);
649 return Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(utc))));
650 }
651
652 let naive = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S")
653 .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M"))
654 .map_err(|_| WriteError::TypeMismatch {
655 field: field_name.to_string(),
656 expected: sql_type,
657 got: format!("{value:?}"),
658 })?;
659
660 let tz = crate::timezone::active_tz();
667 let dt = crate::timezone::naive_local_to_utc_checked(naive).map_err(|e| match e {
668 crate::timezone::LocalTimeError::Ambiguous { earlier, later } => {
669 WriteError::AmbiguousLocalTime {
670 field: field_name.to_string(),
671 value: s.clone(),
672 tz: tz.name().to_string(),
673 earlier: earlier.to_rfc3339(),
674 later: later.to_rfc3339(),
675 }
676 }
677 crate::timezone::LocalTimeError::Nonexistent => WriteError::NonexistentLocalTime {
678 field: field_name.to_string(),
679 value: s.clone(),
680 tz: tz.name().to_string(),
681 },
682 })?;
683 Ok(SeaValue::ChronoDateTimeUtc(Some(Box::new(dt))))
684 }
685 SqlType::Timestamp => {
689 let s = coerce_string(value, field_name)?;
690 let naive = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.f")
691 .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S"))
692 .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M"))
693 .or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.f"))
694 .map_err(|_| WriteError::TypeMismatch {
695 field: field_name.to_string(),
696 expected: sql_type,
697 got: format!("{value:?}"),
698 })?;
699 Ok(SeaValue::ChronoDateTime(Some(Box::new(naive))))
700 }
701 SqlType::Uuid => {
702 let s = coerce_string(value, field_name)?;
703 let u = uuid::Uuid::parse_str(&s).map_err(|_| WriteError::TypeMismatch {
704 field: field_name.to_string(),
705 expected: sql_type,
706 got: format!("{value:?}"),
707 })?;
708 Ok(SeaValue::Uuid(Some(Box::new(u))))
709 }
710 SqlType::Json => {
711 Ok(SeaValue::Json(Some(Box::new(value.clone()))))
714 }
715 SqlType::Array(_)
720 | SqlType::Inet
721 | SqlType::Cidr
722 | SqlType::MacAddr
723 | SqlType::Xml
727 | SqlType::Ltree
728 | SqlType::Bit
729 | SqlType::FullText => Ok(SeaValue::String(Some(Box::new(coerce_string(
730 value, field_name,
731 )?)))),
732 SqlType::Bytes => {
738 coerce_bytes(value, field_name).map(|b| SeaValue::Bytes(Some(Box::new(b))))
739 }
740 SqlType::Decimal | SqlType::DecimalN(_) => coerce_decimal(value, field_name),
746 SqlType::BigDecimal => coerce_bigdecimal(value, field_name),
747 SqlType::Geometry(spec) | SqlType::Geography(spec) => {
751 coerce_geometry(value, field_name, spec.srid)
752 }
753 }
754}
755
756#[cfg(feature = "postgis")]
759fn coerce_geometry(value: &JsonValue, field_name: &str, srid: i32) -> Result<SeaValue, WriteError> {
760 let ewkt =
761 crate::orm::gis::coerce_to_ewkt(value, srid).map_err(|e| WriteError::TypeMismatch {
762 field: field_name.to_string(),
763 expected: SqlType::Geometry(crate::orm::GeometrySpec {
764 kind: crate::orm::GeometryKind::Geometry,
765 srid,
766 }),
767 got: e,
768 })?;
769 Ok(SeaValue::String(Some(Box::new(ewkt))))
770}
771
772#[cfg(not(feature = "postgis"))]
776fn coerce_geometry(
777 _value: &JsonValue,
778 field_name: &str,
779 srid: i32,
780) -> Result<SeaValue, WriteError> {
781 Err(WriteError::TypeMismatch {
782 field: field_name.to_string(),
783 expected: SqlType::Geometry(crate::orm::GeometrySpec {
784 kind: crate::orm::GeometryKind::Geometry,
785 srid,
786 }),
787 got: "writing a PostGIS geometry requires the `postgis` cargo feature".to_string(),
788 })
789}
790
791fn coerce_decimal(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
792 use std::str::FromStr;
793 let parsed: Option<rust_decimal::Decimal> = match value {
799 JsonValue::String(s) => rust_decimal::Decimal::from_str(s).ok(),
800 JsonValue::Number(n) => rust_decimal::Decimal::from_str(&n.to_string()).ok(),
801 _ => None,
802 };
803 parsed
804 .map(|d| SeaValue::Decimal(Some(Box::new(d))))
805 .ok_or_else(|| WriteError::TypeMismatch {
806 field: field_name.to_string(),
807 expected: SqlType::Decimal,
808 got: format!("{value:?}"),
809 })
810}
811
812fn coerce_bigdecimal(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
818 use std::str::FromStr;
819 let parsed: Option<bigdecimal::BigDecimal> = match value {
820 JsonValue::String(s) => bigdecimal::BigDecimal::from_str(s).ok(),
821 JsonValue::Number(n) => bigdecimal::BigDecimal::from_str(&n.to_string()).ok(),
822 _ => None,
823 };
824 parsed
825 .map(|d| SeaValue::BigDecimal(Some(Box::new(d))))
826 .ok_or_else(|| WriteError::TypeMismatch {
827 field: field_name.to_string(),
828 expected: SqlType::BigDecimal,
829 got: format!("{value:?}"),
830 })
831}
832
833fn coerce_bytes(value: &JsonValue, field_name: &str) -> Result<Vec<u8>, WriteError> {
837 if let Some(arr) = value.as_array() {
838 let mut out = Vec::with_capacity(arr.len());
839 for v in arr {
840 let n = v.as_u64().ok_or_else(|| WriteError::TypeMismatch {
841 field: field_name.to_string(),
842 expected: SqlType::Bytes,
843 got: format!("{v:?}"),
844 })?;
845 if n > 255 {
846 return Err(WriteError::TypeMismatch {
847 field: field_name.to_string(),
848 expected: SqlType::Bytes,
849 got: format!("element {v} out of u8 range"),
850 });
851 }
852 out.push(n as u8);
853 }
854 return Ok(out);
855 }
856 if let Some(s) = value.as_str() {
857 if s.len() % 2 != 0 {
858 return Err(WriteError::TypeMismatch {
859 field: field_name.to_string(),
860 expected: SqlType::Bytes,
861 got: "hex string has odd length".to_string(),
862 });
863 }
864 let mut out = Vec::with_capacity(s.len() / 2);
865 for chunk in s.as_bytes().chunks(2) {
866 let high = hex_nibble(chunk[0]).ok_or_else(|| WriteError::TypeMismatch {
867 field: field_name.to_string(),
868 expected: SqlType::Bytes,
869 got: format!("non-hex char `{}`", chunk[0] as char),
870 })?;
871 let low = hex_nibble(chunk[1]).ok_or_else(|| WriteError::TypeMismatch {
872 field: field_name.to_string(),
873 expected: SqlType::Bytes,
874 got: format!("non-hex char `{}`", chunk[1] as char),
875 })?;
876 out.push((high << 4) | low);
877 }
878 return Ok(out);
879 }
880 Err(WriteError::TypeMismatch {
881 field: field_name.to_string(),
882 expected: SqlType::Bytes,
883 got: format!("{value:?}"),
884 })
885}
886
887fn hex_nibble(b: u8) -> Option<u8> {
888 match b {
889 b'0'..=b'9' => Some(b - b'0'),
890 b'a'..=b'f' => Some(10 + b - b'a'),
891 b'A'..=b'F' => Some(10 + b - b'A'),
892 _ => None,
893 }
894}
895
896pub fn slugify(s: &str) -> String {
919 let mut out = String::with_capacity(s.len());
920 let mut last_was_dash = true; for c in s.chars() {
922 if c.is_ascii_alphanumeric() {
923 for low in c.to_lowercase() {
924 out.push(low);
925 }
926 last_was_dash = false;
927 } else if !last_was_dash {
928 out.push('-');
929 last_was_dash = true;
930 }
931 }
932 while out.ends_with('-') {
934 out.pop();
935 }
936 out
937}
938
939pub fn apply_slug_from(
949 fields: &[crate::migrate::Column],
950 body: &mut serde_json::Map<String, serde_json::Value>,
951 is_update: bool,
952) {
953 apply_slug_core(
954 fields
955 .iter()
956 .map(|c| (c.name.as_str(), c.slug_from.as_deref())),
957 body,
958 is_update,
959 );
960}
961
962pub fn apply_slug_from_specs(
970 fields: &[crate::orm::FieldSpec],
971 body: &mut serde_json::Map<String, serde_json::Value>,
972 is_update: bool,
973) {
974 apply_slug_core(
975 fields.iter().map(|f| (f.name, f.slug_from)),
976 body,
977 is_update,
978 );
979}
980
981fn apply_slug_core<'a>(
985 cols: impl Iterator<Item = (&'a str, Option<&'a str>)>,
986 body: &mut serde_json::Map<String, serde_json::Value>,
987 is_update: bool,
988) {
989 for (name, slug_from) in cols {
990 let Some(source) = slug_from else {
991 continue;
992 };
993 let explicit = body
995 .get(name)
996 .and_then(|v| v.as_str())
997 .map(|s| !s.is_empty())
998 .unwrap_or(false);
999 if explicit {
1000 continue;
1001 }
1002 let source_value = body
1004 .get(source)
1005 .and_then(|v| v.as_str())
1006 .unwrap_or("")
1007 .to_string();
1008 if source_value.is_empty() {
1009 continue;
1010 }
1011 if is_update && !body.contains_key(source) {
1012 continue;
1013 }
1014 let slug = slugify(&source_value);
1015 if slug.is_empty() {
1016 continue;
1017 }
1018 body.insert(name.to_string(), serde_json::Value::String(slug));
1019 }
1020}
1021
1022pub fn apply_auto_uuid(
1029 fields: &[crate::migrate::Column],
1030 body: &mut serde_json::Map<String, serde_json::Value>,
1031 is_update: bool,
1032) {
1033 if is_update {
1034 return;
1035 }
1036 const NIL: &str = "00000000-0000-0000-0000-000000000000";
1037 for col in fields {
1038 if !col.auto_uuid {
1039 continue;
1040 }
1041 let supplied = body
1043 .get(&col.name)
1044 .and_then(|v| v.as_str())
1045 .map(|s| !s.is_empty() && s != NIL)
1046 .unwrap_or(false);
1047 if supplied {
1048 continue;
1049 }
1050 body.insert(
1051 col.name.clone(),
1052 serde_json::Value::String(uuid::Uuid::new_v4().to_string()),
1053 );
1054 }
1055}
1056
1057pub fn now_for_column(sql_type: SqlType) -> SeaValue {
1058 let now = chrono::Utc::now();
1059 match sql_type {
1060 SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(Some(Box::new(now))),
1061 SqlType::Timestamp => SeaValue::ChronoDateTime(Some(Box::new(now.naive_utc()))),
1063 SqlType::Date => SeaValue::ChronoDate(Some(Box::new(now.date_naive()))),
1064 SqlType::Time => SeaValue::ChronoTime(Some(Box::new(now.time()))),
1065 _ => null_for(sql_type),
1066 }
1067}
1068
1069pub fn user_for_column(sql_type: SqlType) -> SeaValue {
1082 let Some(id) = crate::db::route_context::current_user_id() else {
1083 return null_for(sql_type);
1084 };
1085 match sql_type {
1086 SqlType::SmallInt | SqlType::Integer => match id.parse::<i32>() {
1087 Ok(v) => SeaValue::Int(Some(v)),
1088 Err(_) => null_for(sql_type),
1089 },
1090 SqlType::BigInt => match id.parse::<i64>() {
1091 Ok(v) => SeaValue::BigInt(Some(v)),
1092 Err(_) => null_for(sql_type),
1093 },
1094 SqlType::Uuid => match id.parse::<uuid::Uuid>() {
1095 Ok(v) => SeaValue::Uuid(Some(Box::new(v))),
1096 Err(_) => null_for(sql_type),
1097 },
1098 SqlType::Text => SeaValue::String(Some(Box::new(id))),
1099 _ => null_for(sql_type),
1103 }
1104}
1105
1106pub fn normalize_json(trim: bool, lowercase: bool, v: &JsonValue) -> Option<JsonValue> {
1119 if !(trim || lowercase) {
1120 return None;
1121 }
1122 let s = v.as_str()?;
1123 let s = if trim { s.trim() } else { s };
1124 let out = if lowercase {
1125 s.to_lowercase()
1126 } else {
1127 s.to_string()
1128 };
1129 Some(JsonValue::String(out))
1130}
1131
1132pub(crate) fn null_for(sql_type: SqlType) -> SeaValue {
1136 match sql_type {
1137 SqlType::Boolean => SeaValue::Bool(None),
1138 SqlType::SmallInt | SqlType::Integer => SeaValue::Int(None),
1139 SqlType::BigInt | SqlType::ForeignKey => SeaValue::BigInt(None),
1140 SqlType::Real => SeaValue::Float(None),
1141 SqlType::Double => SeaValue::Double(None),
1142 SqlType::Text => SeaValue::String(None),
1143 SqlType::Json => SeaValue::Json(None),
1144 SqlType::Date => SeaValue::ChronoDate(None),
1145 SqlType::Time => SeaValue::ChronoTime(None),
1146 SqlType::Timestamptz => SeaValue::ChronoDateTimeUtc(None),
1147 SqlType::Timestamp => SeaValue::ChronoDateTime(None),
1148 SqlType::Uuid => SeaValue::Uuid(None),
1149 SqlType::Array(_)
1150 | SqlType::Inet
1151 | SqlType::Cidr
1152 | SqlType::MacAddr
1153 | SqlType::Xml
1154 | SqlType::Ltree
1155 | SqlType::Bit
1156 | SqlType::FullText => SeaValue::String(None),
1157 SqlType::Bytes => SeaValue::Bytes(None),
1158 SqlType::Decimal | SqlType::DecimalN(_) => SeaValue::Decimal(None),
1159 SqlType::BigDecimal => SeaValue::BigDecimal(None),
1160 SqlType::Geometry(_) | SqlType::Geography(_) => SeaValue::String(None),
1162 }
1163}
1164
1165fn coerce_bool(value: &JsonValue, field_name: &str) -> Result<SeaValue, WriteError> {
1166 match value {
1167 JsonValue::Bool(b) => Ok(SeaValue::Bool(Some(*b))),
1168 JsonValue::String(s) => match s.as_str() {
1169 "true" | "1" | "yes" | "on" => Ok(SeaValue::Bool(Some(true))),
1170 "false" | "0" | "no" | "off" | "" => Ok(SeaValue::Bool(Some(false))),
1171 _ => Err(WriteError::TypeMismatch {
1172 field: field_name.to_string(),
1173 expected: SqlType::Boolean,
1174 got: format!("{value:?}"),
1175 }),
1176 },
1177 JsonValue::Number(n) => Ok(SeaValue::Bool(Some(n.as_i64() != Some(0)))),
1178 _ => Err(WriteError::TypeMismatch {
1179 field: field_name.to_string(),
1180 expected: SqlType::Boolean,
1181 got: format!("{value:?}"),
1182 }),
1183 }
1184}
1185
1186fn coerce_i32(value: &JsonValue, field_name: &str) -> Result<i32, WriteError> {
1187 match value {
1188 JsonValue::Number(n) => n
1189 .as_i64()
1190 .and_then(|i| i32::try_from(i).ok())
1191 .ok_or_else(|| WriteError::TypeMismatch {
1192 field: field_name.to_string(),
1193 expected: SqlType::Integer,
1194 got: format!("{value:?}"),
1195 }),
1196 JsonValue::String(s) => s.parse::<i32>().map_err(|_| WriteError::TypeMismatch {
1197 field: field_name.to_string(),
1198 expected: SqlType::Integer,
1199 got: s.clone(),
1200 }),
1201 _ => Err(WriteError::TypeMismatch {
1202 field: field_name.to_string(),
1203 expected: SqlType::Integer,
1204 got: format!("{value:?}"),
1205 }),
1206 }
1207}
1208
1209fn coerce_i64(value: &JsonValue, field_name: &str) -> Result<i64, WriteError> {
1210 match value {
1211 JsonValue::Number(n) => n.as_i64().ok_or_else(|| WriteError::TypeMismatch {
1212 field: field_name.to_string(),
1213 expected: SqlType::BigInt,
1214 got: format!("{value:?}"),
1215 }),
1216 JsonValue::String(s) => s.parse::<i64>().map_err(|_| WriteError::TypeMismatch {
1217 field: field_name.to_string(),
1218 expected: SqlType::BigInt,
1219 got: s.clone(),
1220 }),
1221 _ => Err(WriteError::TypeMismatch {
1222 field: field_name.to_string(),
1223 expected: SqlType::BigInt,
1224 got: format!("{value:?}"),
1225 }),
1226 }
1227}
1228
1229fn coerce_f32(value: &JsonValue, field_name: &str) -> Result<f32, WriteError> {
1230 coerce_f64(value, field_name).map(|v| v as f32)
1231}
1232
1233fn coerce_f64(value: &JsonValue, field_name: &str) -> Result<f64, WriteError> {
1234 match value {
1235 JsonValue::Number(n) => n.as_f64().ok_or_else(|| WriteError::TypeMismatch {
1236 field: field_name.to_string(),
1237 expected: SqlType::Double,
1238 got: format!("{value:?}"),
1239 }),
1240 JsonValue::String(s) => s.parse::<f64>().map_err(|_| WriteError::TypeMismatch {
1241 field: field_name.to_string(),
1242 expected: SqlType::Double,
1243 got: s.clone(),
1244 }),
1245 _ => Err(WriteError::TypeMismatch {
1246 field: field_name.to_string(),
1247 expected: SqlType::Double,
1248 got: format!("{value:?}"),
1249 }),
1250 }
1251}
1252
1253fn coerce_string(value: &JsonValue, field_name: &str) -> Result<String, WriteError> {
1254 match value {
1255 JsonValue::String(s) => Ok(s.clone()),
1256 JsonValue::Number(n) => Ok(n.to_string()),
1257 JsonValue::Bool(b) => Ok(b.to_string()),
1258 _ => Err(WriteError::TypeMismatch {
1259 field: field_name.to_string(),
1260 expected: SqlType::Text,
1261 got: format!("{value:?}"),
1262 }),
1263 }
1264}
1265
1266#[derive(Debug)]
1272pub enum SaveError {
1273 NoPrimaryKey,
1277 Write(WriteError),
1280}
1281
1282impl std::fmt::Display for SaveError {
1283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1284 match self {
1285 SaveError::NoPrimaryKey => write!(
1286 f,
1287 "umbral::orm::save: model has no primary key — cannot determine INSERT vs UPDATE"
1288 ),
1289 SaveError::Write(e) => write!(f, "{e}"),
1290 }
1291 }
1292}
1293
1294impl std::error::Error for SaveError {
1295 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1296 match self {
1297 SaveError::Write(e) => Some(e),
1298 _ => None,
1299 }
1300 }
1301}
1302
1303impl From<WriteError> for SaveError {
1304 fn from(e: WriteError) -> Self {
1305 Self::Write(e)
1306 }
1307}
1308
1309pub fn is_default_pk(sql_type: SqlType, value: &JsonValue) -> bool {
1319 match (sql_type, value) {
1320 (SqlType::SmallInt | SqlType::Integer | SqlType::BigInt, JsonValue::Number(n)) => {
1321 n.as_i64() == Some(0) || n.as_u64() == Some(0)
1322 }
1323 (SqlType::Uuid, JsonValue::String(s)) => {
1324 s == "00000000-0000-0000-0000-000000000000" || s.is_empty()
1325 }
1326 (SqlType::Text, JsonValue::String(s)) => s.is_empty(),
1327 _ => false,
1328 }
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use super::*;
1334 use serde_json::json;
1335
1336 #[test]
1337 fn json_to_sea_value_passes_basic_types() {
1338 let v = json_to_sea_value(SqlType::Integer, &json!(42), false, "x", None).unwrap();
1339 assert!(matches!(v, SeaValue::Int(Some(42))));
1340 let v = json_to_sea_value(SqlType::BigInt, &json!(42), false, "x", None).unwrap();
1341 assert!(matches!(v, SeaValue::BigInt(Some(42))));
1342 let v = json_to_sea_value(SqlType::Text, &json!("hi"), false, "x", None).unwrap();
1343 assert!(matches!(v, SeaValue::String(Some(_))));
1344 let v = json_to_sea_value(SqlType::Boolean, &json!(true), false, "x", None).unwrap();
1345 assert!(matches!(v, SeaValue::Bool(Some(true))));
1346 let v =
1347 json_to_sea_value(SqlType::Json, &json!({ "nested": true }), false, "x", None).unwrap();
1348 assert!(matches!(v, SeaValue::Json(Some(_))));
1349 }
1350
1351 #[test]
1352 fn json_to_sea_value_coerces_string_booleans() {
1353 let v = json_to_sea_value(SqlType::Boolean, &json!("true"), false, "x", None).unwrap();
1354 assert!(matches!(v, SeaValue::Bool(Some(true))));
1355 let v = json_to_sea_value(SqlType::Boolean, &json!("0"), false, "x", None).unwrap();
1356 assert!(matches!(v, SeaValue::Bool(Some(false))));
1357 }
1358
1359 #[test]
1360 fn json_to_sea_value_rejects_null_on_required_field() {
1361 let err = json_to_sea_value(SqlType::Integer, &json!(null), false, "x", None).unwrap_err();
1362 assert!(matches!(err, WriteError::RequiredFieldMissing { .. }));
1363 }
1364
1365 #[test]
1366 fn json_to_sea_value_accepts_null_on_nullable_field() {
1367 let v = json_to_sea_value(SqlType::Integer, &json!(null), true, "x", None).unwrap();
1368 assert!(matches!(v, SeaValue::Int(None)));
1369 let v = json_to_sea_value(SqlType::Json, &json!(null), true, "x", None).unwrap();
1370 assert!(matches!(v, SeaValue::Json(None)));
1371 }
1372
1373 #[test]
1374 fn json_to_sea_value_accepts_datetime_local_form_shape() {
1375 let v = json_to_sea_value(
1377 SqlType::Timestamptz,
1378 &json!("2026-06-03T22:24:00Z"),
1379 false,
1380 "x",
1381 None,
1382 )
1383 .unwrap();
1384 let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1385 panic!("expected ChronoDateTimeUtc");
1386 };
1387 assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1388
1389 let v = json_to_sea_value(
1391 SqlType::Timestamptz,
1392 &json!("2026-06-03T22:24:00"),
1393 false,
1394 "x",
1395 None,
1396 )
1397 .unwrap();
1398 let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1399 panic!("expected ChronoDateTimeUtc");
1400 };
1401 assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1402
1403 let v = json_to_sea_value(
1407 SqlType::Timestamptz,
1408 &json!("2026-06-03T22:24"),
1409 false,
1410 "x",
1411 None,
1412 )
1413 .unwrap();
1414 let SeaValue::ChronoDateTimeUtc(Some(dt)) = v else {
1415 panic!("expected ChronoDateTimeUtc");
1416 };
1417 assert_eq!(dt.to_rfc3339(), "2026-06-03T22:24:00+00:00");
1418
1419 let err = json_to_sea_value(SqlType::Timestamptz, &json!("not a date"), false, "x", None)
1421 .unwrap_err();
1422 assert!(matches!(err, WriteError::TypeMismatch { .. }));
1423 }
1424
1425 #[test]
1426 fn is_default_pk_recognises_zero_int_and_nil_uuid() {
1427 assert!(is_default_pk(SqlType::Integer, &json!(0)));
1428 assert!(is_default_pk(SqlType::BigInt, &json!(0)));
1429 assert!(!is_default_pk(SqlType::BigInt, &json!(42)));
1430 assert!(is_default_pk(
1431 SqlType::Uuid,
1432 &json!("00000000-0000-0000-0000-000000000000")
1433 ));
1434 assert!(!is_default_pk(SqlType::Uuid, &json!("not-zero")));
1435 }
1436}