From 2d1130eaf05342c74d48024020c8cf4c8cc71f6c Mon Sep 17 00:00:00 2001 From: bkspace Date: Thu, 29 May 2025 08:42:31 +0100 Subject: [PATCH] Add field-level rename attribute support --- .../tests/derive_deserialize_test.rs | 100 ++++++++++++++++++ shopify_function_macro/src/lib.rs | 77 +++++++++----- 2 files changed, 152 insertions(+), 25 deletions(-) diff --git a/shopify_function/tests/derive_deserialize_test.rs b/shopify_function/tests/derive_deserialize_test.rs index 559fc5d..0ff7b23 100644 --- a/shopify_function/tests/derive_deserialize_test.rs +++ b/shopify_function/tests/derive_deserialize_test.rs @@ -33,3 +33,103 @@ fn test_derive_deserialize_error() { TestStruct::deserialize(&root_value).unwrap_err(); } + +#[derive(Deserialize, PartialEq, Debug)] +#[shopify_function(rename_all = "camelCase")] +struct TestStructWithRename { + #[shopify_function(rename = "customFieldName")] + field_one: String, + field_two: i32, + #[shopify_function(rename = "ANOTHER_CUSTOM_NAME")] + field_three: bool, +} + +#[test] +fn test_derive_deserialize_with_field_rename() { + let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({ + "customFieldName": "renamed field", + "fieldTwo": 42, + "ANOTHER_CUSTOM_NAME": true + })); + let root_value = context.input_get().unwrap(); + + let input = TestStructWithRename::deserialize(&root_value).unwrap(); + assert_eq!( + input, + TestStructWithRename { + field_one: "renamed field".to_string(), + field_two: 42, + field_three: true + } + ); +} + +#[test] +fn test_field_rename_takes_precedence_over_rename_all() { + // Test that field-level rename overrides struct-level rename_all + let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({ + "customFieldName": "correct", + "fieldOne": "incorrect", // This should be ignored + "fieldTwo": 10, + "ANOTHER_CUSTOM_NAME": false + })); + let root_value = context.input_get().unwrap(); + + let input = TestStructWithRename::deserialize(&root_value).unwrap(); + assert_eq!(input.field_one, "correct"); + assert_eq!(input.field_two, 10); + assert_eq!(input.field_three, false); +} + +#[derive(Deserialize, PartialEq, Debug)] +struct TestStructNoRenameAll { + #[shopify_function(rename = "different_name")] + original_name: String, + unchanged_field: i32, +} + +#[test] +fn test_field_rename_without_rename_all() { + let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({ + "different_name": "works", + "unchanged_field": 99 + })); + let root_value = context.input_get().unwrap(); + + let input = TestStructNoRenameAll::deserialize(&root_value).unwrap(); + assert_eq!( + input, + TestStructNoRenameAll { + original_name: "works".to_string(), + unchanged_field: 99 + } + ); +} + +#[derive(Deserialize, PartialEq, Debug, Default)] +struct TestValidAttributes { + #[shopify_function(rename = "custom")] + renamed_field: String, + + #[shopify_function(default)] + default_field: Option, + + // Multiple attributes on same field + #[shopify_function(rename = "both", default)] + renamed_and_default: String, +} + +#[test] +fn test_valid_attributes_combination() { + let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({ + "custom": "renamed value", + // default_field is missing - should use default + "both": null // should use default for null + })); + let root_value = context.input_get().unwrap(); + + let input = TestValidAttributes::deserialize(&root_value).unwrap(); + assert_eq!(input.renamed_field, "renamed value"); + assert_eq!(input.default_field, None); + assert_eq!(input.renamed_and_default, String::default()); +} diff --git a/shopify_function_macro/src/lib.rs b/shopify_function_macro/src/lib.rs index 5290cbe..c382691 100644 --- a/shopify_function_macro/src/lib.rs +++ b/shopify_function_macro/src/lib.rs @@ -721,10 +721,14 @@ impl ShopifyFunctionCodeGenerator { /// 1. The field's value is explicitly `null` in the JSON /// 2. The field is missing entirely from the JSON object /// +/// - `#[shopify_function(rename = "custom_name")]` - When applied to a field, uses the specified +/// custom name for deserialization instead of the field's Rust name. This takes precedence over +/// any struct-level `rename_all` attribute. +/// /// This is similar to serde's `#[serde(default)]` attribute, allowing structs to handle missing or null /// fields gracefully by using their default values instead of returning an error. /// -/// Note: Fields that use `#[shopify_function(default)]` must be a type that implements the `Default` trait. +/// Note: Fields that use `#[shopify_function(default)]` must be a type that implements the `Default` trait. #[proc_macro_derive(Deserialize, attributes(shopify_function))] pub fn derive_deserialize(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input = syn::parse_macro_input!(input as syn::DeriveInput); @@ -734,6 +738,34 @@ pub fn derive_deserialize(input: proc_macro::TokenStream) -> proc_macro::TokenSt .unwrap_or_else(|error| error.to_compile_error().into()) } +#[derive(Default)] +struct FieldAttributes { + rename: Option, + has_default: bool, +} + +fn parse_field_attributes(field: &syn::Field) -> syn::Result { + let mut attributes = FieldAttributes::default(); + + for attr in field.attrs.iter() { + if attr.path().is_ident("shopify_function") { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("rename") { + attributes.rename = Some(meta.value()?.parse::()?.value()); + Ok(()) + } else if meta.path.is_ident("default") { + attributes.has_default = true; + Ok(()) + } else { + Err(meta.error("unrecognized field attribute")) + } + })?; + } + } + + Ok(attributes) +} + fn derive_deserialize_for_derive_input(input: &syn::DeriveInput) -> syn::Result { match &input.data { syn::Data::Struct(data) => match &data.fields { @@ -775,33 +807,28 @@ fn derive_deserialize_for_derive_input(input: &syn::DeriveInput) -> syn::Result< .iter() .map(|field| { let field_name_ident = field.ident.as_ref().expect("Named fields must have identifiers"); - let field_name_str = case_style.map_or_else(|| field_name_ident.to_string(), |case_style| { - field_name_ident.to_string().to_case(case_style) - }); - let field_name_lit_str = syn::LitStr::new(field_name_str.as_str(), Span::mixed_site()); - // Check if field has #[shopify_function(default)] attribute - let has_default = field.attrs.iter().any(|attr| { - if attr.path().is_ident("shopify_function") { - let mut found = false; - let _ = attr.parse_nested_meta(|meta| { - if meta.path.is_ident("default") { - found = true; - } - Ok(()) - }); - found - } else { - false + let field_attrs = parse_field_attributes(field)?; + + let field_name_str = match field_attrs.rename { + Some(custom_name) => custom_name, + None => { + // Fall back to rename_all case transformation or original name + case_style.map_or_else( + || field_name_ident.to_string(), + |case_style| field_name_ident.to_string().to_case(case_style) + ) } - }); + }; - if has_default { + let field_name_lit_str = syn::LitStr::new(field_name_str.as_str(), Span::mixed_site()); + + if field_attrs.has_default { // For fields with default attribute, check if value is null or missing // This will use the Default implementation for the field type when either: // 1. The field is explicitly null in the JSON (we get NanBox::null()) // 2. The field is missing in the JSON (get_obj_prop returns a null value) - parse_quote! { + Ok(parse_quote! { #field_name_ident: { let prop = value.get_obj_prop(#field_name_lit_str); if prop.is_null() { @@ -810,15 +837,15 @@ fn derive_deserialize_for_derive_input(input: &syn::DeriveInput) -> syn::Result< shopify_function::wasm_api::Deserialize::deserialize(&prop)? } } - } + }) } else { // For fields without default, use normal deserialization - parse_quote! { + Ok(parse_quote! { #field_name_ident: shopify_function::wasm_api::Deserialize::deserialize(&value.get_obj_prop(#field_name_lit_str))? - } + }) } }) - .collect(); + .collect::>>()?; let deserialize_impl = parse_quote! { impl shopify_function::wasm_api::Deserialize for #name_ident {