diff --git a/crates/jacquard-codegen-tests/src/lib.rs b/crates/jacquard-codegen-tests/src/lib.rs index 5077c1bc4..6bb34f926 100644 --- a/crates/jacquard-codegen-tests/src/lib.rs +++ b/crates/jacquard-codegen-tests/src/lib.rs @@ -108,6 +108,38 @@ mod tests { let _: Option = None; } + #[test] + fn generated_record_dag_cbor_roundtrip_is_byte_stable() { + use jacquard_common::{DefaultStr, deps::smol_str::SmolStr}; + + type Record = super::pretty::test_collision::collection::CollectionRecord; + let record = Record { + created_at: None, + items: vec![super::pretty::test_collision::collection::Collection { + count: None, + label: SmolStr::new("nested"), + extra_data: None, + }], + name: SmolStr::new("test"), + extra_data: None, + }; + let bytes = serde_ipld_dagcbor::to_vec(&record).expect("serialize generated record"); + let decoded: Record = + serde_ipld_dagcbor::from_slice(&bytes).expect("deserialize generated record"); + let reencoded = + serde_ipld_dagcbor::to_vec(&decoded).expect("re-serialize generated record"); + + assert_eq!( + decoded.extra_data, None, + "$type must not leak into extra_data" + ); + assert_eq!( + decoded.items[0].extra_data, None, + "empty object extra_data must remain None" + ); + assert_eq!(reencoded, bytes, "generated record encoding must be stable"); + } + // -- Macro mode type accessibility (same types, different module root) -- #[test] diff --git a/crates/jacquard-lexicon/src/codegen/structs.rs b/crates/jacquard-lexicon/src/codegen/structs.rs index 332457214..643c20071 100644 --- a/crates/jacquard-lexicon/src/codegen/structs.rs +++ b/crates/jacquard-lexicon/src/codegen/structs.rs @@ -139,6 +139,10 @@ impl<'c> CodeGenerator<'c> { let is_none_path = resolved.option_is_none_path(); let extra_data_type = resolved.option_type(quote! { #btree_map<#smolstr_type, #data_type> }); + let deserialize_extra_data_name = + format!("deserialize_{}_extra_data", type_name.to_snake_case()); + let deserialize_extra_data = + syn::Ident::new(&deserialize_extra_data_name, proc_macro2::Span::call_site()); let struct_def = quote! { #doc #derive_attr @@ -147,10 +151,35 @@ impl<'c> CodeGenerator<'c> { ))] pub struct #ident { #fields - #[serde(flatten, default, skip_serializing_if = #is_none_path)] + #[serde( + flatten, + default, + deserialize_with = #deserialize_extra_data_name, + skip_serializing_if = #is_none_path + )] pub extra_data: #extra_data_type, } }; + let extra_data_deserializer = quote! { + fn #deserialize_extra_data<'de, S, D>( + deserializer: D, + ) -> Result<#extra_data_type, D::Error> + where + S: #bosstr_path + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, + { + let mut data = <#extra_data_type as serde::Deserialize<'de>>::deserialize( + deserializer, + )?; + if let Some(extra_data) = &mut data { + extra_data.remove("$type"); + if extra_data.is_empty() { + data = None; + } + } + Ok(data) + } + }; // Generate custom builder if needed. let builder = if has_builder { @@ -281,6 +310,7 @@ impl<'c> CodeGenerator<'c> { }; let internals = quote! { + #extra_data_deserializer #(#default_fns)* #manual_default #nested_internals @@ -349,16 +379,37 @@ impl<'c> CodeGenerator<'c> { let is_none_path = resolved.option_is_none_path(); let extra_data_type = resolved.option_type(quote! { #btree_map<#smolstr_type, #data_type> }); + let deserialize_extra_data_name = + format!("deserialize_{}_extra_data", type_name.to_snake_case()); + let deserialize_extra_data = + syn::Ident::new(&deserialize_extra_data_name, proc_macro2::Span::call_site()); let struct_def = quote! { #doc #derive_attr #[serde(rename_all = "camelCase", bound(deserialize = #serde_de_bound))] pub struct #ident { #fields - #[serde(flatten, default, skip_serializing_if = #is_none_path)] + #[serde( + flatten, + default, + deserialize_with = #deserialize_extra_data_name, + skip_serializing_if = #is_none_path + )] pub extra_data: #extra_data_type, } }; + let extra_data_deserializer = quote! { + fn #deserialize_extra_data<'de, S, D>( + deserializer: D, + ) -> Result<#extra_data_type, D::Error> + where + S: #bosstr_path + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, + { + let data = <#extra_data_type as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) + } + }; // Generate custom builder if needed. let builder = if has_builder { @@ -400,6 +451,7 @@ impl<'c> CodeGenerator<'c> { }; let internals = quote! { + #extra_data_deserializer #(#default_fns)* #manual_default #nested_internals