Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions shopify_function/tests/derive_deserialize_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i32>,

// 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());
}
77 changes: 52 additions & 25 deletions shopify_function_macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<String>,
has_default: bool,
}

fn parse_field_attributes(field: &syn::Field) -> syn::Result<FieldAttributes> {
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::<syn::LitStr>()?.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<syn::ItemImpl> {
match &input.data {
syn::Data::Struct(data) => match &data.fields {
Expand Down Expand Up @@ -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() {
Expand All @@ -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::<syn::Result<Vec<_>>>()?;

let deserialize_impl = parse_quote! {
impl shopify_function::wasm_api::Deserialize for #name_ident {
Expand Down