Skip to content

Add support for default #139

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 22, 2025
Merged
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
161 changes: 161 additions & 0 deletions shopify_function/tests/derive_deserialize_default_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
use shopify_function::prelude::*;
use shopify_function::wasm_api::Deserialize;

#[derive(Deserialize, PartialEq, Debug, Default)]
#[shopify_function(rename_all = "camelCase")]
struct TestStructWithDefault {
// Field with default attribute - will use Default implementation when null
#[shopify_function(default)]
field_a: String,

// Field with default attribute - will use Default implementation when null
#[shopify_function(default)]
field_b: i32,

// Field without default attribute - will error when null
field_c: bool,
}

// Define a struct with more complex default types
#[derive(Deserialize, PartialEq, Debug, Default)]
struct TestComplexDefaults {
// Standard primitive types
#[shopify_function(default)]
integer: i32,

#[shopify_function(default)]
float: f64,

#[shopify_function(default)]
string: String,

#[shopify_function(default)]
boolean: bool,

// Collection types
#[shopify_function(default)]
vector: Vec<i32>,

#[shopify_function(default)]
option: Option<String>,
}

#[test]
fn test_derive_deserialize_with_default() {
// Test with all fields present
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
"fieldA": "test",
"fieldB": 1,
"fieldC": true
}));
let root_value = context.input_get().unwrap();

let input = TestStructWithDefault::deserialize(&root_value).unwrap();
assert_eq!(
input,
TestStructWithDefault {
field_a: "test".to_string(),
field_b: 1,
field_c: true
}
);

// Test with default fields set to null
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
"fieldA": null,
"fieldB": null,
"fieldC": true
}));
let root_value = context.input_get().unwrap();

let input = TestStructWithDefault::deserialize(&root_value).unwrap();
assert_eq!(
input,
TestStructWithDefault {
field_a: String::default(), // Empty string
field_b: i32::default(), // 0
field_c: true
}
);

// Test with default fields missing
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
"fieldC": true
}));
let root_value = context.input_get().unwrap();

// Our implementation is handling missing fields correctly by treating them as null values
let input = TestStructWithDefault::deserialize(&root_value).unwrap();
assert_eq!(
input,
TestStructWithDefault {
field_a: String::default(), // Empty string
field_b: i32::default(), // 0
field_c: true
}
);
}

#[test]
fn test_derive_deserialize_complex_defaults() {
// Test with all fields set to null
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
"integer": null,
"float": null,
"string": null,
"boolean": null,
"vector": null,
"option": null
}));
let root_value = context.input_get().unwrap();

let input = TestComplexDefaults::deserialize(&root_value).unwrap();
assert_eq!(
input,
TestComplexDefaults {
integer: 0,
float: 0.0,
string: String::new(),
boolean: false,
vector: Vec::new(),
option: None,
}
);

// Test with values provided
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
"integer": 42,
"float": 3.19,
"string": "hello",
"boolean": true,
"vector": [1, 2, 3],
"option": "some value"
}));
let root_value = context.input_get().unwrap();

let input = TestComplexDefaults::deserialize(&root_value).unwrap();
assert_eq!(
input,
TestComplexDefaults {
integer: 42,
float: 3.19,
string: "hello".to_string(),
boolean: true,
vector: vec![1, 2, 3],
option: Some("some value".to_string()),
}
);
}

#[test]
fn test_missing_non_default_field() {
// Missing a required field (field_c)
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
"fieldA": "test",
"fieldB": 1
}));
let root_value = context.input_get().unwrap();

// Should fail because field_c is required
TestStructWithDefault::deserialize(&root_value).unwrap_err();
}
55 changes: 53 additions & 2 deletions shopify_function_macro/src/lib.rs
Original file line number Diff line number Diff line change
@@ -704,6 +704,22 @@ impl ShopifyFunctionCodeGenerator {
}
}

/// Derives the `Deserialize` trait for structs to deserialize values from shopify_function_wasm_api::Value.
///
/// The derive macro supports the following attributes:
///
/// - `#[shopify_function(rename_all = "camelCase")]` - Converts field names from snake_case in Rust
/// to the specified case style ("camelCase", "snake_case", or "kebab-case") when deserializing.
///
/// - `#[shopify_function(default)]` - When applied to a field, uses the `Default` implementation for
/// that field's type if either:
/// 1. The field's value is explicitly `null` in the JSON
/// 2. The field is missing entirely from the JSON object
///
/// 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.
#[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);
@@ -758,8 +774,43 @@ fn derive_deserialize_for_derive_input(input: &syn::DeriveInput) -> syn::Result<
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());
parse_quote! {
#field_name_ident: shopify_function::wasm_api::Deserialize::deserialize(&value.get_obj_prop(#field_name_lit_str))?

// 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
}
});

if 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! {
#field_name_ident: {
let prop = value.get_obj_prop(#field_name_lit_str);
if prop.is_null() {
::std::default::Default::default()
} else {
shopify_function::wasm_api::Deserialize::deserialize(&prop)?
}
}
}
} else {
// For fields without default, use normal deserialization
parse_quote! {
#field_name_ident: shopify_function::wasm_api::Deserialize::deserialize(&value.get_obj_prop(#field_name_lit_str))?
}
}
})
.collect();