diff --git a/Cargo.lock b/Cargo.lock index c4d8de2..0eca5d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,9 +111,9 @@ checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" [[package]] name = "bluejay-core" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50aa6831e7fed962546653a284439a7cc65d24aa91a0a733249c2a2306ae18b3" +checksum = "8b3a305ee3448b41672b9c1351cb135a1ec309f3b685ad9f3a8da38490a7ce5c" dependencies = [ "enum-as-inner", "itertools", @@ -123,9 +123,9 @@ dependencies = [ [[package]] name = "bluejay-parser" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfca647f161b017b222bc18b889436f058d00161fccdaa88ab20bb1400875f8" +checksum = "c1f1e8dc53b532a8d6d1ec8e976687aa24dfa0480e7b19ba30c6afceb8d14e09" dependencies = [ "ariadne", "bluejay-core", @@ -137,9 +137,9 @@ dependencies = [ [[package]] name = "bluejay-typegen-codegen" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5932fbf09ed40a87521341a4c67108cb75a0b3962a3c011759fd623a6dc2c4bb" +checksum = "a7653ff4e3aa944f29440322b2d4f982ea76531be86cc1b26772d274da389314" dependencies = [ "bluejay-core", "bluejay-parser", @@ -153,14 +153,13 @@ dependencies = [ [[package]] name = "bluejay-validator" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e72c362c99a7ef4760cd9c07bebb69698cd63e89cad21c268f756a1f61b1c6c" +checksum = "4c20fb1d457c98eed619de1edacfed26b459afa20e5536eec1a5a37fb3f6457c" dependencies = [ "bluejay-core", "bluejay-parser", "itertools", - "paste", "seq-macro", "serde_json", ] diff --git a/example_with_targets/prepare.graphql b/example_with_targets/prepare.graphql new file mode 100644 index 0000000..83748a0 --- /dev/null +++ b/example_with_targets/prepare.graphql @@ -0,0 +1,3 @@ +query Input { + id +} diff --git a/example_with_targets/run.graphql b/example_with_targets/run.graphql new file mode 100644 index 0000000..ad8129c --- /dev/null +++ b/example_with_targets/run.graphql @@ -0,0 +1,17 @@ +query Input( + $selector: LineSelector! + $first: Int + $country: CountryCode + $minimumQuantity: Int! = 1 + $tags: [String] +) { + matchingLines( + selector: $selector + first: $first + country: $country + minimumQuantity: $minimumQuantity + tags: $tags + ) { + id + } +} diff --git a/example_with_targets/schema.graphql b/example_with_targets/schema.graphql index 13e90b1..3b072a9 100644 --- a/example_with_targets/schema.graphql +++ b/example_with_targets/schema.graphql @@ -61,6 +61,11 @@ A void type that can be used to return a null value from a mutation. """ scalar Void +""" +Arbitrary JSON. +""" +scalar JSON + """ A cart line representing an item in the cart. """ @@ -87,6 +92,17 @@ type Input { optionalArrayOfArrays: [[String!]!] optionalArrayOfOptionalArrays: [[String!]] cartLines: [CartLine!] @restrictTarget(only: ["test.target-cart"]) + """ + The cart lines that match a selector. Its arguments are what `test.target-run` takes as + variables from `test.target-prepare`. + """ + matchingLines( + selector: LineSelector! + first: Int + country: CountryCode + minimumQuantity: Int! = 1 + tags: [String] + ): [CartLine!]! @restrictTarget(only: ["test.target-run"]) } """ @@ -122,6 +138,26 @@ type MutationRoot { """ result: FunctionTargetCartResult! ): Void! + + """ + The function for API target Prepare. + """ + targetPrepare( + """ + The result of calling the function for API target Prepare. + """ + result: FunctionTargetPrepareResult! + ): Void! + + """ + The function for API target Run. + """ + targetRun( + """ + The result of calling the function for API target Run. + """ + result: FunctionTargetRunResult! + ): Void! } """ @@ -152,6 +188,42 @@ input FunctionTargetCartResult { totalQuantity: Int! } +""" +The result of API target Prepare. +""" +input FunctionTargetPrepareResult { + """ + The variables for the input query of API target Run. + """ + variables: JSON! +} + +""" +The result of API target Run. +""" +input FunctionTargetRunResult { + lineCount: Int! +} + +""" +Selects cart lines by ID, or by a filter. +""" +input LineSelector @oneOf { + id: ID + filter: LineFilter +} + +input LineFilter { + titles: [String!] + match: LineMatch! = ALL + minimumPrice: Decimal +} + +enum LineMatch { + ALL + ANY +} + input Operation @oneOf { doThis: This doThat: That diff --git a/example_with_targets/shopify.function.extension.toml b/example_with_targets/shopify.function.extension.toml index c2a5fc6..7db6122 100644 --- a/example_with_targets/shopify.function.extension.toml +++ b/example_with_targets/shopify.function.extension.toml @@ -14,3 +14,11 @@ input_query = "a.graphql" target = "test.target-b" export = "function_b" input_query = "b.graphql" + +[[targeting]] +target = "test.target-prepare" +input_query = "prepare.graphql" + +[[targeting]] +target = "test.target-run" +input_query = "run.graphql" diff --git a/example_with_targets/src/main.rs b/example_with_targets/src/main.rs index 07aad52..e994f1c 100644 --- a/example_with_targets/src/main.rs +++ b/example_with_targets/src/main.rs @@ -1,6 +1,7 @@ use std::process; use shopify_function::prelude::*; +use shopify_function::scalars::Decimal; use shopify_function::Result; #[allow(dead_code)] @@ -8,7 +9,9 @@ use shopify_function::Result; #[shopify_function(rename_all = "camelCase")] struct Configuration {} -#[typegen("./schema.graphql", enums_as_str = ["CountryCode"])] +#[typegen("./schema.graphql", enums_as_str = ["CountryCode"], custom_scalar_overrides = { + "FunctionTargetPrepareResult.variables" => target_run::InputVariables, +})] mod schema { #[query("./a.graphql")] pub mod target_a {} @@ -18,6 +21,12 @@ mod schema { #[query("./cart.graphql")] pub mod target_cart {} + + #[query("./prepare.graphql")] + pub mod target_prepare {} + + #[query("./run.graphql")] + pub mod target_run {} } #[shopify_function] @@ -62,6 +71,32 @@ fn target_cart(input: schema::target_cart::Input) -> Result Result { + let variables = schema::target_run::InputVariables { + selector: schema::LineSelector::Filter(schema::LineFilter { + titles: Some(vec![input.id().clone()]), + r#match: Some(schema::LineMatch::Any), + minimum_price: Some(Decimal(1.5)), + }), + first: Some(10), + country: None, + minimum_quantity: None, + tags: Some(vec![Some("sale".to_string()), None]), + }; + + Ok(schema::FunctionTargetPrepareResult { variables }) +} + +#[shopify_function] +fn target_run(input: schema::target_run::Input) -> Result { + Ok(schema::FunctionTargetRunResult { + line_count: input.matching_lines().len() as i32, + }) +} + fn main() { log!("Invoke a named export"); process::abort() diff --git a/example_with_targets/src/tests.rs b/example_with_targets/src/tests.rs index 42455d9..f617ba0 100644 --- a/example_with_targets/src/tests.rs +++ b/example_with_targets/src/tests.rs @@ -84,6 +84,79 @@ fn test_one_of_input_object_serialization_writes_active_variant() -> Result<()> Ok(()) } +#[test] +fn test_target_prepare_returns_the_variables_run_declares() -> Result<()> { + let result = run_function_with_input( + target_prepare, + r#" + { + "id": "gid://shopify/CartLine/1" + } + "#, + )?; + + // `None` omits `country` and `minimumQuantity`. + assert_eq!( + serde_json::json!({ + "variables": { + "selector": { + "filter": { + "titles": ["gid://shopify/CartLine/1"], + "match": "ANY", + "minimumPrice": "1.5" + } + }, + "first": 10, + "tags": ["sale", null] + } + }), + serialize_to_json(&result)? + ); + Ok(()) +} + +#[test] +fn test_variables_serialization_omits_none_variables() -> Result<()> { + let variables = crate::schema::target_run::InputVariables { + selector: crate::schema::LineSelector::Id("gid://shopify/CartLine/1".to_string()), + first: None, + country: Some("CA".to_string()), + minimum_quantity: Some(2), + tags: None, + }; + + assert_eq!( + serde_json::json!({ + "selector": { "id": "gid://shopify/CartLine/1" }, + "country": "CA", + "minimumQuantity": 2 + }), + serialize_to_json(&variables)? + ); + Ok(()) +} + +#[test] +fn test_target_run_counts_matching_lines() -> Result<()> { + let result = run_function_with_input( + target_run, + r#" + { + "matchingLines": [ + { "id": "gid://shopify/CartLine/1" }, + { "id": "gid://shopify/CartLine/2" } + ] + } + "#, + )?; + + assert_eq!( + crate::schema::FunctionTargetRunResult { line_count: 2 }, + result + ); + Ok(()) +} + fn serialize_to_json(value: &T) -> Result { let mut context = Context::new_with_input(serde_json::json!({})); value.serialize(&mut context)?; diff --git a/integration_tests/tests/integration_test.rs b/integration_tests/tests/integration_test.rs index f9379d9..3f3f97d 100644 --- a/integration_tests/tests/integration_test.rs +++ b/integration_tests/tests/integration_test.rs @@ -67,6 +67,34 @@ fn test_example_with_targets_target_b() -> Result<()> { Ok(()) } +#[test] +fn test_example_with_targets_target_prepare() -> Result<()> { + let path = EXAMPLE_WITH_TARGETS_RESULT + .as_ref() + .map_err(|e| anyhow::anyhow!("Failed to prepare example: {}", e))?; + let input = serde_json::json!({ + "id": "gid://shopify/CartLine/1" + }); + let result = run_example(path.clone(), "target_prepare", input)?; + assert_eq!( + result.output, + serde_json::json!({ + "variables": { + "selector": { + "filter": { + "titles": ["gid://shopify/CartLine/1"], + "match": "ANY", + "minimumPrice": "1.5" + } + }, + "first": 10, + "tags": ["sale", null] + } + }) + ); + Ok(()) +} + #[test] fn test_example_with_panic() -> Result<()> { let path = EXAMPLE_WITH_TARGETS_RESULT @@ -80,7 +108,7 @@ fn test_example_with_panic() -> Result<()> { .unwrap_err() .to_string(); let expected_err = - "Function runner returned non-zero exit code: exit status: 1, logs: panicked at example_with_targets/src/main.rs:48:5:\nSomething went wrong\nerror while executing at wasm backtrace:"; + "Function runner returned non-zero exit code: exit status: 1, logs: panicked at example_with_targets/src/main.rs:57:5:\nSomething went wrong\nerror while executing at wasm backtrace:"; assert!( err.contains(expected_err), "Expected error message to contain:\n`{expected_err}`\nbut was:\n`{err}`" diff --git a/shopify_function/src/lib.rs b/shopify_function/src/lib.rs index 2a062f3..2b57a01 100644 --- a/shopify_function/src/lib.rs +++ b/shopify_function/src/lib.rs @@ -71,5 +71,114 @@ macro_rules! eprintln { pub use shopify_function_wasm_api as wasm_api; +/// Each `compile_fail` case differs in one way from the first case, which compiles. Stable rustdoc +/// doesn't check error codes, so the first case is what keeps the others from failing for an +/// unrelated reason. +/// +/// ``` +/// # use shopify_function::prelude::*; +/// # #[typegen("tests/fixtures/prepare_schema.graphql", enums_as_str = ["__TypeKind"], custom_scalar_overrides = { +/// # "TargetPrepareResult.variables" => target_run::InputVariables, +/// # })] +/// # mod schema { +/// # #[query("tests/fixtures/prepare_run.graphql")] +/// # pub mod target_run {} +/// # } +/// # fn main() {} +/// fn prepare() -> schema::TargetPrepareResult { +/// schema::TargetPrepareResult { +/// variables: schema::target_run::InputVariables { +/// handle: "tier-a".to_string(), +/// first: Some(10), +/// }, +/// } +/// } +/// ``` +/// +/// ```compile_fail,E0308 +/// # use shopify_function::prelude::*; +/// # use shopify_function::scalars::JsonValue; +/// # use std::collections::BTreeMap; +/// # #[typegen("tests/fixtures/prepare_schema.graphql", enums_as_str = ["__TypeKind"], custom_scalar_overrides = { +/// # "TargetPrepareResult.variables" => target_run::InputVariables, +/// # })] +/// # mod schema { +/// # #[query("tests/fixtures/prepare_run.graphql")] +/// # pub mod target_run {} +/// # } +/// # fn main() {} +/// // A hand-written `JsonValue`. +/// fn prepare() -> schema::TargetPrepareResult { +/// schema::TargetPrepareResult { +/// variables: JsonValue::Object(BTreeMap::from([( +/// "handle".to_string(), +/// JsonValue::String("tier-a".to_string()), +/// )])), +/// } +/// } +/// ``` +/// +/// ```compile_fail,E0063 +/// # use shopify_function::prelude::*; +/// # #[typegen("tests/fixtures/prepare_schema.graphql", enums_as_str = ["__TypeKind"], custom_scalar_overrides = { +/// # "TargetPrepareResult.variables" => target_run::InputVariables, +/// # })] +/// # mod schema { +/// # #[query("tests/fixtures/prepare_run.graphql")] +/// # pub mod target_run {} +/// # } +/// # fn main() {} +/// // `$handle` is declared but not set. +/// fn prepare() -> schema::TargetPrepareResult { +/// schema::TargetPrepareResult { +/// variables: schema::target_run::InputVariables { first: Some(10) }, +/// } +/// } +/// ``` +/// +/// ```compile_fail,E0560 +/// # use shopify_function::prelude::*; +/// # #[typegen("tests/fixtures/prepare_schema.graphql", enums_as_str = ["__TypeKind"], custom_scalar_overrides = { +/// # "TargetPrepareResult.variables" => target_run::InputVariables, +/// # })] +/// # mod schema { +/// # #[query("tests/fixtures/prepare_run.graphql")] +/// # pub mod target_run {} +/// # } +/// # fn main() {} +/// // `$tierHandle` is not what the query declares. +/// fn prepare() -> schema::TargetPrepareResult { +/// schema::TargetPrepareResult { +/// variables: schema::target_run::InputVariables { +/// tier_handle: "tier-a".to_string(), +/// first: Some(10), +/// }, +/// } +/// } +/// ``` +/// +/// ```compile_fail,E0308 +/// # use shopify_function::prelude::*; +/// # #[typegen("tests/fixtures/prepare_schema.graphql", enums_as_str = ["__TypeKind"], custom_scalar_overrides = { +/// # "TargetPrepareResult.variables" => target_run::InputVariables, +/// # })] +/// # mod schema { +/// # #[query("tests/fixtures/prepare_run.graphql")] +/// # pub mod target_run {} +/// # } +/// # fn main() {} +/// // `$first` is an `Int`. +/// fn prepare() -> schema::TargetPrepareResult { +/// schema::TargetPrepareResult { +/// variables: schema::target_run::InputVariables { +/// handle: "tier-a".to_string(), +/// first: Some("10".to_string()), +/// }, +/// } +/// } +/// ``` +#[cfg(doctest)] +pub struct PrepareVariablesDoctests; + #[cfg(test)] mod tests {} diff --git a/shopify_function/tests/fixtures/prepare_run.graphql b/shopify_function/tests/fixtures/prepare_run.graphql new file mode 100644 index 0000000..297a4e3 --- /dev/null +++ b/shopify_function/tests/fixtures/prepare_run.graphql @@ -0,0 +1,3 @@ +query Input($handle: String!, $first: Int) { + lines(handle: $handle, first: $first) +} diff --git a/shopify_function/tests/fixtures/prepare_schema.graphql b/shopify_function/tests/fixtures/prepare_schema.graphql new file mode 100644 index 0000000..7d9a1fe --- /dev/null +++ b/shopify_function/tests/fixtures/prepare_schema.graphql @@ -0,0 +1,9 @@ +scalar JSON + +type Query { + lines(handle: String!, first: Int): [String!]! +} + +input TargetPrepareResult { + variables: JSON! +} diff --git a/shopify_function/tests/macro_hygiene_test.rs b/shopify_function/tests/macro_hygiene_test.rs index fdf30e9..90ab629 100644 --- a/shopify_function/tests/macro_hygiene_test.rs +++ b/shopify_function/tests/macro_hygiene_test.rs @@ -23,9 +23,18 @@ use shopify_function::wasm_api::Deserialize; union Option = Some | None + input Vec { + value: String + } + + enum Box { + A + } + type Query { result: Result! option: Option! + filtered(vec: Vec, list: [Vec!], box: Box!): String } ], enums_as_str = ["__TypeKind"])] mod schema { @@ -52,6 +61,13 @@ mod schema { } ])] pub mod query {} + + #[query([ + query Filtered($vec: Vec, $list: [Vec!], $box: Box!) { + filtered(vec: $vec, list: $list, box: $box) + } + ])] + pub mod filtered {} } #[test] @@ -79,3 +95,33 @@ fn test_macro_hygiene() { schema::query::query::Option::Some(_) )); } + +#[test] +fn test_variables_macro_hygiene() { + let variables = schema::filtered::FilteredVariables { + vec: None, + list: Some(vec![schema::Vec { + value: Some("test".to_string()), + }]), + r#box: schema::Box::A, + }; + + let mut context = shopify_function::wasm_api::Context::new_with_input(serde_json::json!({})); + shopify_function::wasm_api::Serialize::serialize(&variables, &mut context).unwrap(); + let output = context.finalize_output_and_return().unwrap(); + + assert_eq!( + serde_json::json!({ + "list": [{ "value": "test" }], + "box": "A", + }), + output + ); + + let context = shopify_function::wasm_api::Context::new_with_input(output); + let value = context.input_get().unwrap(); + assert_eq!( + variables, + schema::filtered::FilteredVariables::deserialize(&value).unwrap() + ); +} diff --git a/shopify_function/tests/prepare_variables_test.rs b/shopify_function/tests/prepare_variables_test.rs new file mode 100644 index 0000000..76f33e6 --- /dev/null +++ b/shopify_function/tests/prepare_variables_test.rs @@ -0,0 +1,153 @@ +use shopify_function::prelude::*; +use shopify_function::wasm_api::{self, Context, Deserialize, Serialize}; + +/// The type the schema module aliases `Money` to. +#[derive(Debug, PartialEq, Clone)] +pub struct Money(pub String); + +impl Serialize for Money { + fn serialize(&self, context: &mut Context) -> Result<(), wasm_api::write::Error> { + context.write_utf8_str(&self.0) + } +} + +impl Deserialize for Money { + fn deserialize(value: &wasm_api::Value) -> Result { + Ok(Self(String::deserialize(value)?)) + } +} + +#[typegen([ + scalar JSON + scalar Money + + type Query { + lines(first: Int!): [String!]! + priced(range: PriceRange): [String!]! + tree(node: Node): Boolean! + } + + input PriceRange { + min: Money + } + + input Node { + value: Int! + children: [Node!] + } + + input OptionalPrepareResult { + variables: JSON + } + + input PricedPrepareResult { + variables: JSON! + } + + input TreePrepareResult { + variables: JSON! + } +], enums_as_str = ["__TypeKind"], custom_scalar_overrides = { + "OptionalPrepareResult.variables" => optional_run::InputVariables, + "PricedPrepareResult.variables" => priced_run::InputVariables, + "TreePrepareResult.variables" => tree_run::TreeVariables, +})] +mod schema { + pub type Money = super::Money; + + #[query([ + query Input($first: Int! = 10) { + lines(first: $first) + } + ])] + pub mod optional_run {} + + #[query([ + query Input($range: PriceRange) { + priced(range: $range) + } + ])] + pub mod priced_run {} + + #[query([ + query Tree($node: Node) { + tree(node: $node) + } + ])] + pub mod tree_run {} +} + +fn serialize_to_json(value: &T) -> serde_json::Value { + let mut context = Context::new_with_input(serde_json::json!({})); + value.serialize(&mut context).unwrap(); + context.finalize_output_and_return().unwrap() +} + +fn deserialize_from_json(json: serde_json::Value) -> T { + let context = Context::new_with_input(json); + T::deserialize(&context.input_get().unwrap()).unwrap() +} + +#[test] +fn test_nullable_prepare_variables_are_optional() { + assert_eq!( + serde_json::json!({}), + serialize_to_json(&schema::OptionalPrepareResult { variables: None }) + ); + // `None` omits `$first`, so the query's default applies. + assert_eq!( + serde_json::json!({ "variables": {} }), + serialize_to_json(&schema::OptionalPrepareResult { + variables: Some(schema::optional_run::InputVariables { first: None }), + }) + ); + assert_eq!( + serde_json::json!({ "variables": { "first": 1 } }), + serialize_to_json(&schema::OptionalPrepareResult { + variables: Some(schema::optional_run::InputVariables { first: Some(1) }), + }) + ); +} + +#[test] +fn test_prepare_variables_serialize_aliased_custom_scalars_with_their_own_impl() { + assert_eq!( + serde_json::json!({ "variables": { "range": { "min": "1.5" } } }), + serialize_to_json(&schema::PricedPrepareResult { + variables: schema::priced_run::InputVariables { + range: Some(schema::PriceRange { + min: Some(Money("1.5".to_string())), + }), + }, + }) + ); +} + +#[test] +fn test_prepare_variables_round_trip() { + let result = schema::TreePrepareResult { + variables: schema::tree_run::TreeVariables { + node: Some(schema::Node { + value: 1, + children: Some(vec![schema::Node { + value: 2, + children: None, + }]), + }), + }, + }; + let json = serde_json::json!({ + "variables": { + "node": { + "value": 1, + "children": [{ "value": 2 }] + } + } + }); + + assert_eq!(json, serialize_to_json(&result)); + assert_eq!( + result, + deserialize_from_json::(json) + ); +} diff --git a/shopify_function_macro/Cargo.toml b/shopify_function_macro/Cargo.toml index 154c395..dbe6011 100644 --- a/shopify_function_macro/Cargo.toml +++ b/shopify_function_macro/Cargo.toml @@ -13,6 +13,6 @@ proc-macro = true syn = { version = "3.0", features = ["full"] } quote = "1.0" proc-macro2 = "1.0.106" -bluejay-typegen-codegen = "0.4.0" -bluejay-core = "0.4.0" +bluejay-typegen-codegen = "0.5.0" +bluejay-core = "0.5.0" convert_case = "0.11" diff --git a/shopify_function_macro/src/lib.rs b/shopify_function_macro/src/lib.rs index b663671..199ba5c 100644 --- a/shopify_function_macro/src/lib.rs +++ b/shopify_function_macro/src/lib.rs @@ -4,6 +4,7 @@ use bluejay_core::{ definition::{ EnumTypeDefinition, EnumValueDefinition, InputObjectTypeDefinition, InputValueDefinition, }, + executable::{OperationDefinition, VariableDefinition}, AsIter, }; use bluejay_typegen_codegen::{ @@ -133,6 +134,9 @@ mod kw { /// _enums_as_str_: Optional list of enum names for which the generated code should use string types instead of /// a fully formed enum. Defaults to `["LanguageCode", "CountryCode", "CurrencyCode"]`. /// +/// _custom_scalar_overrides_: Map from `"InputObject.field"` to the type to use for that input object field instead of +/// its custom scalar, such as a prepare result's `variables`. See [variables](#variables). +/// /// ### Trait implementations /// /// By default, will implement `PartialEq`, and `Debug` for all input and enum types. Enums will also implement `Copy`. @@ -170,6 +174,39 @@ mod kw { /// ``` /// Any type path that does not start with `::` is assumed to be relative to the schema definition module. /// +/// ##### Variables +/// +/// An operation that declares variables also gets a struct for them next to its own in the query module, named +/// `Variables`, or `RootVariables` for an anonymous operation, with a field per variable. A variable that is +/// non-null with no default is a plain field. Any other variable is an `Option`, where `None` omits it, so the query's +/// default applies if it has one. +/// +/// A prepare target returns the variables of the run target it feeds, which the schema types as `JSON`. To type them as +/// the run query's variables struct instead, override the prepare result's `variables` field with +/// `custom_scalar_overrides` on `typegen`. For a run query holding `query Input($ruleSetHandle: String) { ... }`: +/// ```ignore +/// #[typegen("schema.graphql", custom_scalar_overrides = { +/// "CartValidationsGeneratePrepareResult.variables" => cart_validations_generate_run::InputVariables, +/// })] +/// mod schema { +/// #[query("src/cart_validations_generate_run.graphql")] +/// pub mod cart_validations_generate_run {} +/// } +/// +/// #[shopify_function] +/// fn cart_validations_generate_prepare( +/// input: schema::cart_validations_generate_prepare::Input, +/// ) -> Result { +/// Ok(schema::CartValidationsGeneratePrepareResult { +/// variables: Some(schema::cart_validations_generate_run::InputVariables { +/// rule_set_handle: Some("rules-default".to_string()), +/// }), +/// }) +/// } +/// ``` +/// Then adding, removing, renaming, or retyping a variable in the run query, or returning a hand-written `JsonValue`, +/// is a compile error in the prepare target, rather than a run query that resolves nothing. +/// /// ### Naming /// /// To generate idiomatic Rust code, some renaming of types, enum variants, and fields is performed. Types are @@ -492,87 +529,13 @@ impl CodeGenerator for ShopifyFunctionCodeGenerator { #[allow(unused_variables)] input_object_type_definition: &impl InputObjectTypeDefinition, ) -> Vec { let name_ident = names::type_ident(input_object_type_definition.name()); - - let field_statements: Vec = input_object_type_definition + let fields: Vec<(&str, bool)> = input_object_type_definition .input_field_definitions() .iter() - .flat_map(|ivd| { - let field_name_ident = names::field_ident(ivd.name()); - let field_name_lit_str = syn::LitStr::new(ivd.name(), Span::mixed_site()); - - if ivd.is_required() { - vec![ - parse_quote! { - context.write_utf8_str(#field_name_lit_str)?; - }, - parse_quote! { - self.#field_name_ident.serialize(context)?; - }, - ] - } else { - vec![parse_quote! { - if let ::std::option::Option::Some(value) = &self.#field_name_ident { - context.write_utf8_str(#field_name_lit_str)?; - value.serialize(context)?; - } - }] - } - }) + .map(|ivd| (ivd.name(), ivd.is_required())) .collect(); - let num_required_fields = input_object_type_definition - .input_field_definitions() - .iter() - .filter(|ivd| ivd.is_required()) - .count(); - - let optional_field_count_terms: Vec = input_object_type_definition - .input_field_definitions() - .iter() - .filter(|ivd| !ivd.is_required()) - .map(|ivd| { - let field_name_ident = names::field_ident(ivd.name()); - parse_quote! { ::std::primitive::usize::from(self.#field_name_ident.is_some()) } - }) - .collect(); - - let serialize_impl = parse_quote! { - impl shopify_function::wasm_api::Serialize for #name_ident { - fn serialize(&self, context: &mut shopify_function::wasm_api::Context) -> ::std::result::Result<(), shopify_function::wasm_api::write::Error> { - let field_count: ::std::primitive::usize = #num_required_fields #(+ #optional_field_count_terms)*; - - context.write_object( - |context| { - #(#field_statements)* - ::std::result::Result::Ok(()) - }, - field_count, - ) - } - } - }; - - let field_values: Vec = input_object_type_definition - .input_field_definitions() - .iter() - .map(|ivd| { - let field_name_ident = names::field_ident(ivd.name()); - let field_name_lit_str = syn::LitStr::new(ivd.name(), Span::mixed_site()); - parse_quote! { #field_name_ident: shopify_function::wasm_api::Deserialize::deserialize(&value.get_obj_prop(#field_name_lit_str))? } - }) - .collect(); - - let deserialize_impl = parse_quote! { - impl shopify_function::wasm_api::Deserialize for #name_ident { - fn deserialize(value: &shopify_function::wasm_api::Value) -> ::std::result::Result { - ::std::result::Result::Ok(Self { - #(#field_values),* - }) - } - } - }; - - vec![serialize_impl, deserialize_impl] + Self::object_impls(&name_ident, &fields) } fn additional_impls_for_one_of_input_object( @@ -679,9 +642,116 @@ impl CodeGenerator for ShopifyFunctionCodeGenerator { parse_quote! { #[derive(::std::fmt::Debug, ::std::cmp::PartialEq, ::std::clone::Clone)] }, ] } + + fn attributes_for_variables_struct( + &self, + _operation_definition: &impl OperationDefinition, + ) -> Vec { + vec![ + parse_quote! { #[derive(::std::fmt::Debug, ::std::cmp::PartialEq, ::std::clone::Clone)] }, + ] + } + + // `Deserialize` is needed by the impls of an input object with a field overridden to this struct. + fn additional_impls_for_variables_struct( + &self, + operation_definition: &impl OperationDefinition, + ) -> Vec { + let operation_definition = operation_definition.as_ref(); + let name_ident = names::variables_type_ident(operation_definition.name()); + let fields: Vec<(&str, bool)> = operation_definition + .variable_definitions() + .map(|variable_definitions| { + variable_definitions + .iter() + .map(|vd| (vd.variable(), vd.is_required())) + .collect() + }) + .unwrap_or_default(); + + Self::object_impls(&name_ident, &fields) + } } impl ShopifyFunctionCodeGenerator { + /// `Serialize` and `Deserialize` for a struct with a field per `(name, required)` in `fields`, where an optional + /// field is an `Option` that is omitted when it is `None`. + fn object_impls(name_ident: &syn::Ident, fields: &[(&str, bool)]) -> Vec { + let field_statements: Vec = fields + .iter() + .flat_map(|&(name, required)| { + let field_name_ident = names::field_ident(name); + let field_name_lit_str = syn::LitStr::new(name, Span::mixed_site()); + + if required { + vec![ + parse_quote! { + context.write_utf8_str(#field_name_lit_str)?; + }, + parse_quote! { + self.#field_name_ident.serialize(context)?; + }, + ] + } else { + vec![parse_quote! { + if let ::std::option::Option::Some(value) = &self.#field_name_ident { + context.write_utf8_str(#field_name_lit_str)?; + value.serialize(context)?; + } + }] + } + }) + .collect(); + + let num_required_fields = fields.iter().filter(|&&(_, required)| required).count(); + + let optional_field_count_terms: Vec = fields + .iter() + .filter(|&&(_, required)| !required) + .map(|&(name, _)| { + let field_name_ident = names::field_ident(name); + parse_quote! { ::std::primitive::usize::from(self.#field_name_ident.is_some()) } + }) + .collect(); + + let serialize_impl = parse_quote! { + impl shopify_function::wasm_api::Serialize for #name_ident { + fn serialize(&self, context: &mut shopify_function::wasm_api::Context) -> ::std::result::Result<(), shopify_function::wasm_api::write::Error> { + let field_count: ::std::primitive::usize = #num_required_fields #(+ #optional_field_count_terms)*; + + context.write_object( + |context| { + #(#field_statements)* + ::std::result::Result::Ok(()) + }, + field_count, + ) + } + } + }; + + let field_values: Vec = fields + .iter() + .map(|&(name, _)| { + let field_name_ident = names::field_ident(name); + let field_name_lit_str = syn::LitStr::new(name, Span::mixed_site()); + parse_quote! { #field_name_ident: shopify_function::wasm_api::Deserialize::deserialize(&value.get_obj_prop(#field_name_lit_str))? } + }) + .collect(); + + let deserialize_impl = parse_quote! { + impl shopify_function::wasm_api::Deserialize for #name_ident { + fn deserialize(value: &shopify_function::wasm_api::Value) -> ::std::result::Result { + ::std::result::Result::Ok(Self { + #(#field_values),* + }) + } + } + }; + + vec![serialize_impl, deserialize_impl] + } + fn type_for_field( executable_struct: &ExecutableStruct, r#type: &WrappedExecutableType,