Skip to content

Commit 2d1130e

Browse files
committed
Add field-level rename attribute support
1 parent 6f61148 commit 2d1130e

File tree

2 files changed

+152
-25
lines changed

2 files changed

+152
-25
lines changed

shopify_function/tests/derive_deserialize_test.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,103 @@ fn test_derive_deserialize_error() {
3333

3434
TestStruct::deserialize(&root_value).unwrap_err();
3535
}
36+
37+
#[derive(Deserialize, PartialEq, Debug)]
38+
#[shopify_function(rename_all = "camelCase")]
39+
struct TestStructWithRename {
40+
#[shopify_function(rename = "customFieldName")]
41+
field_one: String,
42+
field_two: i32,
43+
#[shopify_function(rename = "ANOTHER_CUSTOM_NAME")]
44+
field_three: bool,
45+
}
46+
47+
#[test]
48+
fn test_derive_deserialize_with_field_rename() {
49+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
50+
"customFieldName": "renamed field",
51+
"fieldTwo": 42,
52+
"ANOTHER_CUSTOM_NAME": true
53+
}));
54+
let root_value = context.input_get().unwrap();
55+
56+
let input = TestStructWithRename::deserialize(&root_value).unwrap();
57+
assert_eq!(
58+
input,
59+
TestStructWithRename {
60+
field_one: "renamed field".to_string(),
61+
field_two: 42,
62+
field_three: true
63+
}
64+
);
65+
}
66+
67+
#[test]
68+
fn test_field_rename_takes_precedence_over_rename_all() {
69+
// Test that field-level rename overrides struct-level rename_all
70+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
71+
"customFieldName": "correct",
72+
"fieldOne": "incorrect", // This should be ignored
73+
"fieldTwo": 10,
74+
"ANOTHER_CUSTOM_NAME": false
75+
}));
76+
let root_value = context.input_get().unwrap();
77+
78+
let input = TestStructWithRename::deserialize(&root_value).unwrap();
79+
assert_eq!(input.field_one, "correct");
80+
assert_eq!(input.field_two, 10);
81+
assert_eq!(input.field_three, false);
82+
}
83+
84+
#[derive(Deserialize, PartialEq, Debug)]
85+
struct TestStructNoRenameAll {
86+
#[shopify_function(rename = "different_name")]
87+
original_name: String,
88+
unchanged_field: i32,
89+
}
90+
91+
#[test]
92+
fn test_field_rename_without_rename_all() {
93+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
94+
"different_name": "works",
95+
"unchanged_field": 99
96+
}));
97+
let root_value = context.input_get().unwrap();
98+
99+
let input = TestStructNoRenameAll::deserialize(&root_value).unwrap();
100+
assert_eq!(
101+
input,
102+
TestStructNoRenameAll {
103+
original_name: "works".to_string(),
104+
unchanged_field: 99
105+
}
106+
);
107+
}
108+
109+
#[derive(Deserialize, PartialEq, Debug, Default)]
110+
struct TestValidAttributes {
111+
#[shopify_function(rename = "custom")]
112+
renamed_field: String,
113+
114+
#[shopify_function(default)]
115+
default_field: Option<i32>,
116+
117+
// Multiple attributes on same field
118+
#[shopify_function(rename = "both", default)]
119+
renamed_and_default: String,
120+
}
121+
122+
#[test]
123+
fn test_valid_attributes_combination() {
124+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
125+
"custom": "renamed value",
126+
// default_field is missing - should use default
127+
"both": null // should use default for null
128+
}));
129+
let root_value = context.input_get().unwrap();
130+
131+
let input = TestValidAttributes::deserialize(&root_value).unwrap();
132+
assert_eq!(input.renamed_field, "renamed value");
133+
assert_eq!(input.default_field, None);
134+
assert_eq!(input.renamed_and_default, String::default());
135+
}

shopify_function_macro/src/lib.rs

Lines changed: 52 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -721,10 +721,14 @@ impl ShopifyFunctionCodeGenerator {
721721
/// 1. The field's value is explicitly `null` in the JSON
722722
/// 2. The field is missing entirely from the JSON object
723723
///
724+
/// - `#[shopify_function(rename = "custom_name")]` - When applied to a field, uses the specified
725+
/// custom name for deserialization instead of the field's Rust name. This takes precedence over
726+
/// any struct-level `rename_all` attribute.
727+
///
724728
/// This is similar to serde's `#[serde(default)]` attribute, allowing structs to handle missing or null
725729
/// fields gracefully by using their default values instead of returning an error.
726730
///
727-
/// Note: Fields that use `#[shopify_function(default)]` must be a type that implements the `Default` trait.
731+
/// Note: Fields that use `#[shopify_function(default)]` must be a type that implements the `Default` trait.
728732
#[proc_macro_derive(Deserialize, attributes(shopify_function))]
729733
pub fn derive_deserialize(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
730734
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
734738
.unwrap_or_else(|error| error.to_compile_error().into())
735739
}
736740

741+
#[derive(Default)]
742+
struct FieldAttributes {
743+
rename: Option<String>,
744+
has_default: bool,
745+
}
746+
747+
fn parse_field_attributes(field: &syn::Field) -> syn::Result<FieldAttributes> {
748+
let mut attributes = FieldAttributes::default();
749+
750+
for attr in field.attrs.iter() {
751+
if attr.path().is_ident("shopify_function") {
752+
attr.parse_nested_meta(|meta| {
753+
if meta.path.is_ident("rename") {
754+
attributes.rename = Some(meta.value()?.parse::<syn::LitStr>()?.value());
755+
Ok(())
756+
} else if meta.path.is_ident("default") {
757+
attributes.has_default = true;
758+
Ok(())
759+
} else {
760+
Err(meta.error("unrecognized field attribute"))
761+
}
762+
})?;
763+
}
764+
}
765+
766+
Ok(attributes)
767+
}
768+
737769
fn derive_deserialize_for_derive_input(input: &syn::DeriveInput) -> syn::Result<syn::ItemImpl> {
738770
match &input.data {
739771
syn::Data::Struct(data) => match &data.fields {
@@ -775,33 +807,28 @@ fn derive_deserialize_for_derive_input(input: &syn::DeriveInput) -> syn::Result<
775807
.iter()
776808
.map(|field| {
777809
let field_name_ident = field.ident.as_ref().expect("Named fields must have identifiers");
778-
let field_name_str = case_style.map_or_else(|| field_name_ident.to_string(), |case_style| {
779-
field_name_ident.to_string().to_case(case_style)
780-
});
781-
let field_name_lit_str = syn::LitStr::new(field_name_str.as_str(), Span::mixed_site());
782810

783-
// Check if field has #[shopify_function(default)] attribute
784-
let has_default = field.attrs.iter().any(|attr| {
785-
if attr.path().is_ident("shopify_function") {
786-
let mut found = false;
787-
let _ = attr.parse_nested_meta(|meta| {
788-
if meta.path.is_ident("default") {
789-
found = true;
790-
}
791-
Ok(())
792-
});
793-
found
794-
} else {
795-
false
811+
let field_attrs = parse_field_attributes(field)?;
812+
813+
let field_name_str = match field_attrs.rename {
814+
Some(custom_name) => custom_name,
815+
None => {
816+
// Fall back to rename_all case transformation or original name
817+
case_style.map_or_else(
818+
|| field_name_ident.to_string(),
819+
|case_style| field_name_ident.to_string().to_case(case_style)
820+
)
796821
}
797-
});
822+
};
798823

799-
if has_default {
824+
let field_name_lit_str = syn::LitStr::new(field_name_str.as_str(), Span::mixed_site());
825+
826+
if field_attrs.has_default {
800827
// For fields with default attribute, check if value is null or missing
801828
// This will use the Default implementation for the field type when either:
802829
// 1. The field is explicitly null in the JSON (we get NanBox::null())
803830
// 2. The field is missing in the JSON (get_obj_prop returns a null value)
804-
parse_quote! {
831+
Ok(parse_quote! {
805832
#field_name_ident: {
806833
let prop = value.get_obj_prop(#field_name_lit_str);
807834
if prop.is_null() {
@@ -810,15 +837,15 @@ fn derive_deserialize_for_derive_input(input: &syn::DeriveInput) -> syn::Result<
810837
shopify_function::wasm_api::Deserialize::deserialize(&prop)?
811838
}
812839
}
813-
}
840+
})
814841
} else {
815842
// For fields without default, use normal deserialization
816-
parse_quote! {
843+
Ok(parse_quote! {
817844
#field_name_ident: shopify_function::wasm_api::Deserialize::deserialize(&value.get_obj_prop(#field_name_lit_str))?
818-
}
845+
})
819846
}
820847
})
821-
.collect();
848+
.collect::<syn::Result<Vec<_>>>()?;
822849

823850
let deserialize_impl = parse_quote! {
824851
impl shopify_function::wasm_api::Deserialize for #name_ident {

0 commit comments

Comments
 (0)