From 20cd21795916b3a58791ebb1450b4ac47ce06d04 Mon Sep 17 00:00:00 2001 From: Izzy McCabe Date: Mon, 24 Aug 2026 11:19:15 -0700 Subject: [PATCH 1/7] Most elegant way I could figure to fix from_json --- sea-orm-macros/src/derives/entity_model.rs | 29 ++++++++++++++++++++++ src/entity/active_model.rs | 29 ++++++++++++++++------ src/entity/column.rs | 6 +++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/sea-orm-macros/src/derives/entity_model.rs b/sea-orm-macros/src/derives/entity_model.rs index 8a877fa618..193478d912 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 @@ -86,6 +87,9 @@ pub fn expand_derive_entity_model( 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 +173,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 +217,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))) @@ -362,6 +370,9 @@ pub fn expand_derive_entity_model( 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 +394,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 +434,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 +602,12 @@ pub fn expand_derive_entity_model( #columns_json_keys } } + + fn json_key_serialize(&self) -> &'static str { + match self { + #columns_json_keys_serialize + } + } } #[cfg(not(feature = "with-json"))] diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index 085935f94e..48bafa8b55 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -509,21 +509,36 @@ pub trait ActiveModelTrait: Clone + Debug { }; let dummy_am = Self::default_values(); + let dummy_model = dummy_am.clone().try_into_model().map_err(json_err)?; + let dummy_value = serde_json::to_value(dummy_model).map_err(json_err)?; + 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); + let serde_json::Value::Object(merged) = 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 mut ser_de_map = <::Column>::iter() + .map(|col| (col.json_key_serialize(), col.json_key())) + .collect::>(); + + let mut merged = merged + .into_iter() + .map(|(key, val)| { + // map seralized keys into deserialize keys + let new_key = ser_de_map.remove(key.as_str()).map(ToString::to_string).unwrap_or(key); + (new_key, val) + }) + .collect::>(); 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..58d10b78f8 100644 --- a/src/entity/column.rs +++ b/src/entity/column.rs @@ -537,6 +537,12 @@ pub trait ColumnTrait: IdenStatic + Iterable + FromStr { fn json_key(&self) -> &'static str { self.as_str() } + + /// JSON key used for this column when serializing the model. + #[cfg(feature = "with-json")] + fn json_key_serialize(&self) -> &'static str { + self.as_str() + } } /// Extension methods on [`ColumnType`] for building [`ColumnDef`]s and From 3dc97d84e12a9854b79f0e14e12c3638d5872d9a Mon Sep 17 00:00:00 2001 From: Izzy McCabe Date: Mon, 24 Aug 2026 11:34:14 -0700 Subject: [PATCH 2/7] cargo fmt --- src/entity/active_model.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index 48bafa8b55..b221815e37 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -524,16 +524,19 @@ pub trait ActiveModelTrait: Clone + Debug { let mut ser_de_map = <::Column>::iter() .map(|col| (col.json_key_serialize(), col.json_key())) - .collect::>(); + .collect::>(); let mut merged = merged .into_iter() .map(|(key, val)| { // map seralized keys into deserialize keys - let new_key = ser_de_map.remove(key.as_str()).map(ToString::to_string).unwrap_or(key); + let new_key = ser_de_map + .remove(key.as_str()) + .map(ToString::to_string) + .unwrap_or(key); (new_key, val) }) - .collect::>(); + .collect::>(); for col in <::Column>::iter() { let key = col.json_key(); From 3634d1094a1618b04e40b817a6e1adccf1125cbe Mon Sep 17 00:00:00 2001 From: Izzy McCabe Date: Mon, 24 Aug 2026 11:46:17 -0700 Subject: [PATCH 3/7] remove duplication of the comment in test --- .../serde_datetimes_active_models.rs | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/tests_cfg/serde_datetimes_active_models.rs 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(_))); + } +} From d82b49913f57fd24b4e0e829618555538d97d5b8 Mon Sep 17 00:00:00 2001 From: Izzy McCabe Date: Mon, 24 Aug 2026 12:50:59 -0700 Subject: [PATCH 4/7] add json_key_serialize to sea_orm_sync --- sea-orm-sync/src/entity/column.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/sea-orm-sync/src/entity/column.rs b/sea-orm-sync/src/entity/column.rs index d366829678..58d10b78f8 100644 --- a/sea-orm-sync/src/entity/column.rs +++ b/sea-orm-sync/src/entity/column.rs @@ -537,6 +537,12 @@ pub trait ColumnTrait: IdenStatic + Iterable + FromStr { fn json_key(&self) -> &'static str { self.as_str() } + + /// JSON key used for this column when serializing the model. + #[cfg(feature = "with-json")] + fn json_key_serialize(&self) -> &'static str { + self.as_str() + } } /// Extension methods on [`ColumnType`] for building [`ColumnDef`]s and From 46a64d2e10fdeb595eaaea2a2512fc2f3b63f272 Mon Sep 17 00:00:00 2001 From: Izzy McCabe Date: Tue, 25 Aug 2026 10:27:50 -0700 Subject: [PATCH 5/7] changes from review --- sea-orm-macros/src/derives/entity_model.rs | 4 +++- src/entity/active_model.rs | 20 +++++++++----------- src/entity/column.rs | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/sea-orm-macros/src/derives/entity_model.rs b/sea-orm-macros/src/derives/entity_model.rs index 193478d912..1bdbc7deee 100644 --- a/sea-orm-macros/src/derives/entity_model.rs +++ b/sea-orm-macros/src/derives/entity_model.rs @@ -81,6 +81,7 @@ 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.clone(); } else { // #[serde(rename_all(serialize = "...", deserialize = "..."))] meta.parse_nested_meta(|nested| { @@ -364,6 +365,7 @@ 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| { @@ -603,7 +605,7 @@ pub fn expand_derive_entity_model( } } - fn json_key_serialize(&self) -> &'static str { + fn serialize_json_key(&self) -> &'static str { match self { #columns_json_keys_serialize } diff --git a/src/entity/active_model.rs b/src/entity/active_model.rs index b221815e37..f568cd35bf 100644 --- a/src/entity/active_model.rs +++ b/src/entity/active_model.rs @@ -508,36 +508,34 @@ pub trait ActiveModelTrait: Clone + Debug { ))); }; - let dummy_am = Self::default_values(); - let dummy_model = dummy_am.clone().try_into_model().map_err(json_err)?; + 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 len = <::Column>::iter().len(); - // Mark down which attribute exists in the JSON object - let mut json_keys = Vec::with_capacity(len); - let serde_json::Value::Object(merged) = dummy_value else { + 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 mut ser_de_map = <::Column>::iter() - .map(|col| (col.json_key_serialize(), col.json_key())) + let ser_de_map = <::Column>::iter() + .map(|col| (col.serialize_json_key(), col.json_key())) .collect::>(); - let mut merged = merged + let mut merged = dummy_value .into_iter() .map(|(key, val)| { // map seralized keys into deserialize keys let new_key = ser_de_map - .remove(key.as_str()) + .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); for col in <::Column>::iter() { let key = col.json_key(); let has_key = input.contains_key(key); diff --git a/src/entity/column.rs b/src/entity/column.rs index 58d10b78f8..c13f695a2a 100644 --- a/src/entity/column.rs +++ b/src/entity/column.rs @@ -532,7 +532,7 @@ 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() @@ -540,7 +540,7 @@ pub trait ColumnTrait: IdenStatic + Iterable + FromStr { /// JSON key used for this column when serializing the model. #[cfg(feature = "with-json")] - fn json_key_serialize(&self) -> &'static str { + fn serialize_json_key(&self) -> &'static str { self.as_str() } } From 50e614fdc4f6b3190dea2845049ed5b58dbe1485 Mon Sep 17 00:00:00 2001 From: Izzy McCabe Date: Tue, 25 Aug 2026 10:30:24 -0700 Subject: [PATCH 6/7] remove clone on copy value --- sea-orm-macros/src/derives/entity_model.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sea-orm-macros/src/derives/entity_model.rs b/sea-orm-macros/src/derives/entity_model.rs index 1bdbc7deee..0e270dc0c7 100644 --- a/sea-orm-macros/src/derives/entity_model.rs +++ b/sea-orm-macros/src/derives/entity_model.rs @@ -81,7 +81,7 @@ 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.clone(); + serde_rename_all_serialize = serde_rename_all; } else { // #[serde(rename_all(serialize = "...", deserialize = "..."))] meta.parse_nested_meta(|nested| { From 3208dab7c3bf2a64719d9b625395651c308afadd Mon Sep 17 00:00:00 2001 From: Izzy McCabe Date: Tue, 25 Aug 2026 10:41:44 -0700 Subject: [PATCH 7/7] fix sea-orm-sync again :p --- sea-orm-sync/src/entity/column.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sea-orm-sync/src/entity/column.rs b/sea-orm-sync/src/entity/column.rs index 58d10b78f8..c13f695a2a 100644 --- a/sea-orm-sync/src/entity/column.rs +++ b/sea-orm-sync/src/entity/column.rs @@ -532,7 +532,7 @@ 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() @@ -540,7 +540,7 @@ pub trait ColumnTrait: IdenStatic + Iterable + FromStr { /// JSON key used for this column when serializing the model. #[cfg(feature = "with-json")] - fn json_key_serialize(&self) -> &'static str { + fn serialize_json_key(&self) -> &'static str { self.as_str() } }