Skip to content

Commit 34de444

Browse files
authored
Merge pull request #139 from Shopify/default-macro
Add support for default
2 parents 4528ef1 + 1cfb511 commit 34de444

File tree

2 files changed

+214
-2
lines changed

2 files changed

+214
-2
lines changed
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
use shopify_function::prelude::*;
2+
use shopify_function::wasm_api::Deserialize;
3+
4+
#[derive(Deserialize, PartialEq, Debug, Default)]
5+
#[shopify_function(rename_all = "camelCase")]
6+
struct TestStructWithDefault {
7+
// Field with default attribute - will use Default implementation when null
8+
#[shopify_function(default)]
9+
field_a: String,
10+
11+
// Field with default attribute - will use Default implementation when null
12+
#[shopify_function(default)]
13+
field_b: i32,
14+
15+
// Field without default attribute - will error when null
16+
field_c: bool,
17+
}
18+
19+
// Define a struct with more complex default types
20+
#[derive(Deserialize, PartialEq, Debug, Default)]
21+
struct TestComplexDefaults {
22+
// Standard primitive types
23+
#[shopify_function(default)]
24+
integer: i32,
25+
26+
#[shopify_function(default)]
27+
float: f64,
28+
29+
#[shopify_function(default)]
30+
string: String,
31+
32+
#[shopify_function(default)]
33+
boolean: bool,
34+
35+
// Collection types
36+
#[shopify_function(default)]
37+
vector: Vec<i32>,
38+
39+
#[shopify_function(default)]
40+
option: Option<String>,
41+
}
42+
43+
#[test]
44+
fn test_derive_deserialize_with_default() {
45+
// Test with all fields present
46+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
47+
"fieldA": "test",
48+
"fieldB": 1,
49+
"fieldC": true
50+
}));
51+
let root_value = context.input_get().unwrap();
52+
53+
let input = TestStructWithDefault::deserialize(&root_value).unwrap();
54+
assert_eq!(
55+
input,
56+
TestStructWithDefault {
57+
field_a: "test".to_string(),
58+
field_b: 1,
59+
field_c: true
60+
}
61+
);
62+
63+
// Test with default fields set to null
64+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
65+
"fieldA": null,
66+
"fieldB": null,
67+
"fieldC": true
68+
}));
69+
let root_value = context.input_get().unwrap();
70+
71+
let input = TestStructWithDefault::deserialize(&root_value).unwrap();
72+
assert_eq!(
73+
input,
74+
TestStructWithDefault {
75+
field_a: String::default(), // Empty string
76+
field_b: i32::default(), // 0
77+
field_c: true
78+
}
79+
);
80+
81+
// Test with default fields missing
82+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
83+
"fieldC": true
84+
}));
85+
let root_value = context.input_get().unwrap();
86+
87+
// Our implementation is handling missing fields correctly by treating them as null values
88+
let input = TestStructWithDefault::deserialize(&root_value).unwrap();
89+
assert_eq!(
90+
input,
91+
TestStructWithDefault {
92+
field_a: String::default(), // Empty string
93+
field_b: i32::default(), // 0
94+
field_c: true
95+
}
96+
);
97+
}
98+
99+
#[test]
100+
fn test_derive_deserialize_complex_defaults() {
101+
// Test with all fields set to null
102+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
103+
"integer": null,
104+
"float": null,
105+
"string": null,
106+
"boolean": null,
107+
"vector": null,
108+
"option": null
109+
}));
110+
let root_value = context.input_get().unwrap();
111+
112+
let input = TestComplexDefaults::deserialize(&root_value).unwrap();
113+
assert_eq!(
114+
input,
115+
TestComplexDefaults {
116+
integer: 0,
117+
float: 0.0,
118+
string: String::new(),
119+
boolean: false,
120+
vector: Vec::new(),
121+
option: None,
122+
}
123+
);
124+
125+
// Test with values provided
126+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
127+
"integer": 42,
128+
"float": 3.19,
129+
"string": "hello",
130+
"boolean": true,
131+
"vector": [1, 2, 3],
132+
"option": "some value"
133+
}));
134+
let root_value = context.input_get().unwrap();
135+
136+
let input = TestComplexDefaults::deserialize(&root_value).unwrap();
137+
assert_eq!(
138+
input,
139+
TestComplexDefaults {
140+
integer: 42,
141+
float: 3.19,
142+
string: "hello".to_string(),
143+
boolean: true,
144+
vector: vec![1, 2, 3],
145+
option: Some("some value".to_string()),
146+
}
147+
);
148+
}
149+
150+
#[test]
151+
fn test_missing_non_default_field() {
152+
// Missing a required field (field_c)
153+
let context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({
154+
"fieldA": "test",
155+
"fieldB": 1
156+
}));
157+
let root_value = context.input_get().unwrap();
158+
159+
// Should fail because field_c is required
160+
TestStructWithDefault::deserialize(&root_value).unwrap_err();
161+
}

shopify_function_macro/src/lib.rs

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,22 @@ impl ShopifyFunctionCodeGenerator {
704704
}
705705
}
706706

707+
/// Derives the `Deserialize` trait for structs to deserialize values from shopify_function_wasm_api::Value.
708+
///
709+
/// The derive macro supports the following attributes:
710+
///
711+
/// - `#[shopify_function(rename_all = "camelCase")]` - Converts field names from snake_case in Rust
712+
/// to the specified case style ("camelCase", "snake_case", or "kebab-case") when deserializing.
713+
///
714+
/// - `#[shopify_function(default)]` - When applied to a field, uses the `Default` implementation for
715+
/// that field's type if either:
716+
/// 1. The field's value is explicitly `null` in the JSON
717+
/// 2. The field is missing entirely from the JSON object
718+
///
719+
/// This is similar to serde's `#[serde(default)]` attribute, allowing structs to handle missing or null
720+
/// fields gracefully by using their default values instead of returning an error.
721+
///
722+
/// Note: Fields that use `#[shopify_function(default)]` must be a type that implements the `Default` trait.
707723
#[proc_macro_derive(Deserialize, attributes(shopify_function))]
708724
pub fn derive_deserialize(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
709725
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<
758774
field_name_ident.to_string().to_case(case_style)
759775
});
760776
let field_name_lit_str = syn::LitStr::new(field_name_str.as_str(), Span::mixed_site());
761-
parse_quote! {
762-
#field_name_ident: shopify_function::wasm_api::Deserialize::deserialize(&value.get_obj_prop(#field_name_lit_str))?
777+
778+
// Check if field has #[shopify_function(default)] attribute
779+
let has_default = field.attrs.iter().any(|attr| {
780+
if attr.path().is_ident("shopify_function") {
781+
let mut found = false;
782+
let _ = attr.parse_nested_meta(|meta| {
783+
if meta.path.is_ident("default") {
784+
found = true;
785+
}
786+
Ok(())
787+
});
788+
found
789+
} else {
790+
false
791+
}
792+
});
793+
794+
if has_default {
795+
// For fields with default attribute, check if value is null or missing
796+
// This will use the Default implementation for the field type when either:
797+
// 1. The field is explicitly null in the JSON (we get NanBox::null())
798+
// 2. The field is missing in the JSON (get_obj_prop returns a null value)
799+
parse_quote! {
800+
#field_name_ident: {
801+
let prop = value.get_obj_prop(#field_name_lit_str);
802+
if prop.is_null() {
803+
::std::default::Default::default()
804+
} else {
805+
shopify_function::wasm_api::Deserialize::deserialize(&prop)?
806+
}
807+
}
808+
}
809+
} else {
810+
// For fields without default, use normal deserialization
811+
parse_quote! {
812+
#field_name_ident: shopify_function::wasm_api::Deserialize::deserialize(&value.get_obj_prop(#field_name_lit_str))?
813+
}
763814
}
764815
})
765816
.collect();

0 commit comments

Comments
 (0)