diff --git a/sea-orm-macros/src/derives/entity_model.rs b/sea-orm-macros/src/derives/entity_model.rs index 8a877fa618..0e270dc0c7 100644 --- a/sea-orm-macros/src/derives/entity_model.rs +++ b/sea-orm-macros/src/derives/entity_model.rs @@ -69,6 +69,7 @@ pub fn expand_derive_entity_model( let mut model_ex = false; let mut rename_all: Option = None; let mut serde_rename_all: Option = None; + let mut serde_rename_all_serialize: Option = None; // Parse #[serde(rename_all = "...")] at struct level attrs @@ -80,12 +81,16 @@ pub fn expand_derive_entity_model( if let Ok(lit) = meta.value().and_then(|v| v.parse::()) { // #[serde(rename_all = "camelCase")] serde_rename_all = CaseStyle::from_str(&lit.value()).ok(); + serde_rename_all_serialize = serde_rename_all; } else { // #[serde(rename_all(serialize = "...", deserialize = "..."))] meta.parse_nested_meta(|nested| { if nested.path.is_ident("deserialize") { let lit: LitStr = nested.value()?.parse()?; serde_rename_all = CaseStyle::from_str(&lit.value()).ok(); + } else if nested.path.is_ident("serialize") { + let lit: LitStr = nested.value()?.parse()?; + serde_rename_all_serialize = CaseStyle::from_str(&lit.value()).ok(); } else { consume_meta(nested); } @@ -169,6 +174,8 @@ pub fn expand_derive_entity_model( let mut auto_increment: Option = None; #[cfg(feature = "with-json")] let mut columns_json_keys: Punctuated<_, Comma> = Punctuated::new(); + #[cfg(feature = "with-json")] + let mut columns_json_keys_serialize: Punctuated<_, Comma> = Punctuated::new(); if table_iden { if let Some(table_name) = &table_name { @@ -211,6 +218,8 @@ pub fn expand_derive_entity_model( let mut seaography_ignore = false; #[cfg(feature = "with-json")] let mut serde_rename: Option = None; + #[cfg(feature = "with-json")] + let mut serde_rename_serialize: Option = None; let mut column_name = if let Some(case_style) = rename_all { Some(field_name.convert_case(Some(case_style))) @@ -356,12 +365,16 @@ pub fn expand_derive_entity_model( { // #[serde(rename = "xxx")] serde_rename = Some(lit.value()); + serde_rename_serialize = serde_rename.clone(); } else { // #[serde(rename(serialize = "...", deserialize = "..."))] meta.parse_nested_meta(|nested| { if nested.path.is_ident("deserialize") { let lit: LitStr = nested.value()?.parse()?; serde_rename = Some(lit.value()); + } else if nested.path.is_ident("serialize") { + let lit: LitStr = nested.value()?.parse()?; + serde_rename_serialize = Some(lit.value()); } else { consume_meta(nested); } @@ -383,6 +396,13 @@ pub fn expand_derive_entity_model( serde_rename_all, ); + #[cfg(feature = "with-json")] + let json_key_name_serialize = serde_deserialize_name( + &original_field_name, + serde_rename_serialize.as_deref(), + serde_rename_all_serialize, + ); + if let Some(enum_name) = enum_name { field_name = enum_name; } @@ -416,6 +436,11 @@ pub fn expand_derive_entity_model( columns_json_keys.push(quote! { Self::#field_name => #json_key_name }); + + #[cfg(feature = "with-json")] + columns_json_keys_serialize.push(quote! { + Self::#field_name => #json_key_name_serialize + }); } if is_primary_key { @@ -579,6 +604,12 @@ pub fn expand_derive_entity_model( #columns_json_keys } } + + fn serialize_json_key(&self) -> &'static str { + match self { + #columns_json_keys_serialize + } + } } #[cfg(not(feature = "with-json"))] diff --git a/sea-orm-sync/src/entity/column.rs b/sea-orm-sync/src/entity/column.rs index d366829678..c13f695a2a 100644 --- a/sea-orm-sync/src/entity/column.rs +++ b/sea-orm-sync/src/entity/column.rs @@ -532,11 +532,17 @@ pub trait ColumnTrait: IdenStatic + Iterable + FromStr { cast_enum_as(val, &self.def(), save_enum_as) } - /// JSON key used for this column when (de)serializing the model. + /// JSON key used for this column when deserializing the model. #[cfg(feature = "with-json")] fn json_key(&self) -> &'static str { self.as_str() } + + /// JSON key used for this column when serializing the model. + #[cfg(feature = "with-json")] + fn serialize_json_key(&self) -> &'static str { + self.as_str() + } } /// Extension methods on [`ColumnType`] for building [`ColumnDef`]s and diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index 085935f94e..f568cd35bf 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -508,22 +508,38 @@ pub trait ActiveModelTrait: Clone + Debug { ))); }; - let dummy_am = Self::default_values(); + let dummy_model = Self::default_values().try_into_model().map_err(json_err)?; + let dummy_value = serde_json::to_value(dummy_model).map_err(json_err)?; + let serde_json::Value::Object(dummy_value) = dummy_value else { + return Err(DbErr::Json(format!( + "invalid type: expected JSON object for dummy model for {}", + <::Entity as IdenStatic>::as_str(&Default::default()) + ))); + }; + + let ser_de_map = <::Column>::iter() + .map(|col| (col.serialize_json_key(), col.json_key())) + .collect::>(); + + let mut merged = dummy_value + .into_iter() + .map(|(key, val)| { + // map seralized keys into deserialize keys + let new_key = ser_de_map + .get(key.as_str()) + .map(ToString::to_string) + .unwrap_or(key); + (new_key, val) + }) + .collect::>(); + let len = <::Column>::iter().len(); // Mark down which attribute exists in the JSON object let mut json_keys = Vec::with_capacity(len); - let mut merged = serde_json::Map::with_capacity(len); - for col in <::Column>::iter() { let key = col.json_key(); let has_key = input.contains_key(key); json_keys.push((col, has_key)); - match dummy_am.get(col) { - ActiveValue::Unchanged(value) | ActiveValue::Set(value) => { - merged.insert(key.to_owned(), sea_query::sea_value_to_json_value(&value)); - } - _ => {} - } } merged.append(&mut input); diff --git a/src/entity/column.rs b/src/entity/column.rs index d366829678..c13f695a2a 100644 --- a/src/entity/column.rs +++ b/src/entity/column.rs @@ -532,11 +532,17 @@ pub trait ColumnTrait: IdenStatic + Iterable + FromStr { cast_enum_as(val, &self.def(), save_enum_as) } - /// JSON key used for this column when (de)serializing the model. + /// JSON key used for this column when deserializing the model. #[cfg(feature = "with-json")] fn json_key(&self) -> &'static str { self.as_str() } + + /// JSON key used for this column when serializing the model. + #[cfg(feature = "with-json")] + fn serialize_json_key(&self) -> &'static str { + self.as_str() + } } /// Extension methods on [`ColumnType`] for building [`ColumnDef`]s and diff --git a/src/tests_cfg/serde_datetimes_active_models.rs b/src/tests_cfg/serde_datetimes_active_models.rs new file mode 100644 index 0000000000..15b497b556 --- /dev/null +++ b/src/tests_cfg/serde_datetimes_active_models.rs @@ -0,0 +1,102 @@ +/// Regression test for [https://github.com/SeaQL/sea-orm/issues/3175] +/// `ActiveModel::from_json` failed to deserialize time columns to `NotSet` +/// when the field was missing from the JSON payload, because the +/// trait-default implementation round-tripped through the model after +/// merging SQL-literal dummy values. + +#[cfg(feature = "with-time")] +mod time_model { + use crate as sea_orm; + use sea_orm::entity::prelude::*; + use serde::{Serialize, Deserialize}; + + #[sea_orm::model] + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)] + #[sea_orm(table_name = "time")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: i32, + #[serde(with = "time::serde::timestamp")] + pub created_at: TimeDateTimeWithTimeZone, + } + + impl ActiveModelBehavior for ActiveModel {} +} + +#[cfg(feature = "with-chrono")] +mod chrono_model { + use crate as sea_orm; + use sea_orm::entity::prelude::*; + use serde::{Serialize, Deserialize}; + + #[sea_orm::model] + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, DeriveEntityModel)] + #[sea_orm(table_name = "time")] + pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: i32, + #[serde(rename = "tstamp", with = "chrono::serde::ts_seconds")] + pub created_at: ChronoDateTimeUtc, + } + + impl ActiveModelBehavior for ActiveModel {} +} + +mod test { + use super::{time_model::ActiveModel as TimeAM, chrono_model::ActiveModel as ChronoAM}; + use crate::{ActiveValue, entity::ActiveModelTrait}; + + #[test] + #[cfg(feature = "with-time")] + fn test_from_json_missing_time_field_is_not_set() { + let json = serde_json::json!({ + "id": 1, + }); + + let am = TimeAM::from_json(json).unwrap(); + + assert_eq!(am.id, ActiveValue::Set(1)); + assert_eq!(am.created_at, ActiveValue::NotSet); + } + + #[test] + #[cfg(feature = "with-time")] + fn test_from_json_present_time_field_is_set() { + let json = serde_json::json!({ + "id": 1, + "created_at": 1704067200, + }); + + let am = TimeAM::from_json(json).unwrap(); + + assert_eq!(am.id, ActiveValue::Set(1)); + assert!(matches!(am.created_at, ActiveValue::Set(_))); + } + + #[test] + #[cfg(feature = "with-chrono")] + fn test_from_json_missing_chrono_field_is_not_set() { + let json = serde_json::json!({ + "id": 1, + }); + + let am = ChronoAM::from_json(json).unwrap(); + + assert_eq!(am.id, ActiveValue::Set(1)); + assert_eq!(am.created_at, ActiveValue::NotSet); + } + + #[test] + #[cfg(feature = "with-chrono")] + fn test_from_json_present_chrono_field_is_set() { + let json = serde_json::json!({ + "id": 1, + "tstamp": 1704067200, + }); + + let am = ChronoAM::from_json(json).unwrap(); + + assert_eq!(am.id, ActiveValue::Set(1)); + assert!(matches!(am.created_at, ActiveValue::Set(_))); + } +}