Skip to content

Generate a struct for each operation's variables - #146

Closed
saga-dasgupta wants to merge 2 commits into
Shopify:mainfrom
saga-dasgupta:operation-variables
Closed

saga-dasgupta wants to merge 2 commits into
Shopify:mainfrom
saga-dasgupta:operation-variables

Conversation

@saga-dasgupta

@saga-dasgupta saga-dasgupta commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Based on #147, so the first commit here is #147's. This PR is the second commit, which also adds a #147 test case overriding an input field to a variables struct. I'll rebase once #147 merges.

Typegen generates types for what an operation returns, but not for the variables it declares. A caller building those variables by hand gets no help from the compiler: renaming, adding, removing or retyping a variable in the query still compiles, and only fails once the query runs.

This generates a struct for an operation's variables, next to the operation's own struct in the query module:

query MyArgs($required: String!, $optional: Int, $withDefault: Int! = 1) {
  myArgs(required: $required, optional: $optional, withDefault: $withDefault)
}
pub struct MyArgsVariables {
    pub required: String,
    pub optional: Option<i32>,
    pub with_default: Option<i32>,
}
  • Naming: <Operation>Variables, or RootVariables for an anonymous operation, whose struct is Root. Operations that declare no variables get no struct.
  • Fields: a variable that is non-null with no default is a plain field. Any other variable is an Option, so a variable with a default can be left out.
  • Field types: the same as input object fields. The type-building code moves out of input_object_type_definition.rs into a new input_type.rs that both use. That includes borrowing: with borrow = true, the struct gets an 'a lifetime when a field borrows.
  • CodeGenerator hooks: attributes_for_variables_struct, additional_impls_for_variables_struct and attributes_for_variables_struct_field, modelled on the input object hooks. All three default to no-ops, so existing implementations don't change.
  • bluejay-typegen-macro:
    • derives Clone, PartialEq, Debug and serde's Serialize;
    • renames each field to its GraphQL variable name;
    • skips an optional variable that is None, so the query's default applies instead of an explicit null.
  • Validation: it's a typegen error when a variables struct's name clashes with the struct for a fragment or another operation in the same module, for example query My($x: Int) next to fragment MyVariables on .... Without this check, rustc would report a duplicate definition.

Compatibility

These are cases that compile on main and don't here:

  • A document with an operation that declares variables, alongside a fragment or operation whose struct already has that <Operation>Variables name. This now gets the clash error above.
  • A variable named $self, $Self, $super, $crate or $_ now panics in names::to_ident. These names can't be raw identifiers, so format_ident!("r#self") panics. Fields and input fields with those names already panic the same way on main; variables just didn't become identifiers before. I left this alone to keep the PR focused, but I can add a typegen error for it here if you'd prefer.
  • Two variables whose names map to the same field, such as $fooBar and $foo_bar, now fail with rustc's duplicate field error (E0124). Input objects with such fields already fail the same way.
  • With bluejay-typegen, a custom scalar that is a variable's type, but no input object field's, now needs Clone, PartialEq, Debug and serde's Serialize, which the variables struct derives. Input object fields already need these.

saga-dasgupta added a commit to Shopify/shopify-function-rust that referenced this pull request Oct 5, 2026
A prepare target returns the variables for its run target's input query as
untyped `JSON`. Nothing checked that they matched what the run query declares,
so a renamed, added, or retyped variable deployed fine and failed at checkout,
with the run query resolving nothing.

bluejay now generates a struct for the variables of each operation that
declares them: `<Operation>Variables`, or `RootVariables` for an anonymous
operation, with a field per variable. A non-null variable with no default is a
plain field; any other is an `Option` whose `None` omits it. `typegen`
implements wasm_api's `Serialize` and `Deserialize` for it with the same code
as input objects, so it serializes directly.

The schema does not link a prepare target to its run target, so they are paired
by name: the mutation root's `<target>Prepare` feeds `<target>Run`, and when a
`#[query]` module named `<target>_run` holds a single operation that declares
variables, the prepare result's `variables` field is typed as that operation's
variables struct instead of `JsonValue`. A prepare target that no longer
matches its run query then fails to compile:

- a renamed or removed variable is an unknown field (E0560)
- an added variable is a missing field (E0063)
- a retyped variable, or a hand-written `JsonValue`, is a mismatch (E0308)

This breaks prepare targets that follow the naming convention and build their
`variables` by hand. Prepare targets are in beta. Prepare results that don't
pair keep `JsonValue`.

Depends on Shopify/bluejay#146, patched in from its branch until it ships in a
release.
saga-dasgupta added a commit to Shopify/shopify-function-rust that referenced this pull request Oct 5, 2026
A prepare target returns the variables for its run target's input query as
untyped `JSON`. Nothing checked that they matched what the run query declares,
so a renamed, added, or retyped variable deployed fine and failed at checkout,
with the run query resolving nothing.

bluejay now generates a struct for the variables of each operation that
declares them: `<Operation>Variables`, or `RootVariables` for an anonymous
operation, with a field per variable. A non-null variable with no default is a
plain field; any other is an `Option` whose `None` omits it. `typegen`
implements wasm_api's `Serialize` and `Deserialize` for it with the same code
as input objects, so it serializes directly.

The schema does not link a prepare target to its run target, so they are paired
by name: the mutation root's `<target>Prepare` feeds `<target>Run`, and when a
`#[query]` module named `<target>_run` holds a single operation that declares
variables, the prepare result's `variables` field is typed as that operation's
variables struct instead of `JsonValue`. A prepare target that no longer
matches its run query then fails to compile:

- a renamed or removed variable is an unknown field (E0560)
- an added variable is a missing field (E0063)
- a retyped variable, or a hand-written `JsonValue`, is a mismatch (E0308)

This breaks prepare targets that follow the naming convention and build their
`variables` by hand. Prepare targets are in beta. Prepare results that don't
pair keep `JsonValue`.

Depends on Shopify/bluejay#146, patched in from its branch until it ships in a
release.
saga-dasgupta added a commit to Shopify/shopify-function-rust that referenced this pull request Oct 5, 2026
A prepare target returns the variables for its run target's input query as
untyped `JSON`. Nothing checked that they matched what the run query declares,
so a renamed, added, or retyped variable deployed fine and failed at checkout,
with the run query resolving nothing.

bluejay now generates a struct for the variables of each operation that
declares them: `<Operation>Variables`, or `RootVariables` for an anonymous
operation, with a field per variable. A non-null variable with no default is a
plain field; any other is an `Option` whose `None` omits it. `typegen`
implements wasm_api's `Serialize` and `Deserialize` for it with the same code
as input objects, so it serializes directly.

bluejay's `typegen` also takes `custom_scalar_overrides`, so a prepare result's
`variables` field can be typed as the run query's variables struct instead of
`JsonValue`:

    #[typegen("schema.graphql", custom_scalar_overrides = {
        "CartValidationsGeneratePrepareResult.variables" => cart_validations_generate_run::InputVariables,
    })]

A prepare target that no longer matches its run query then fails to compile:

- a renamed or removed variable is an unknown field (E0560)
- an added variable is a missing field (E0063)
- a retyped variable, or a hand-written `JsonValue`, is a mismatch (E0308)

Depends on Shopify/bluejay#146, patched in from its branch until it ships in a
release.
`typegen` now takes `custom_scalar_overrides`, like `query` does, to
override the type of a custom scalar field of an input object. Paths are
`"InputObject.field"`, and lists and nullability are kept. This lets a
field typed with a catch-all scalar such as `JSON` use a specific Rust
type instead of an untyped value.

Parsing and validation are shared with the `query` option, whose
behaviour doesn't change.
An operation that declares variables now also gets a struct next to its
own in the query module, named `<Operation>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`.

`CodeGenerator` gains `attributes_for_variables_struct`,
`additional_impls_for_variables_struct` and
`attributes_for_variables_struct_field`, so generators can derive or
implement serialization for it. `bluejay-typegen-macro` derives serde's
`Serialize`, and skips an optional variable that is `None` so that its
default applies.

It is an error for a variables struct name to clash with a fragment or
operation struct in the same module.
saga-dasgupta added a commit to Shopify/shopify-function-rust that referenced this pull request Oct 5, 2026
A prepare target returns the variables for its run target's input query as
untyped `JSON`. Nothing checked that they matched what the run query declares,
so a renamed, added, or retyped variable deployed fine and failed at checkout,
with the run query resolving nothing.

bluejay now generates a struct for the variables of each operation that
declares them: `<Operation>Variables`, or `RootVariables` for an anonymous
operation, with a field per variable. A non-null variable with no default is a
plain field; any other is an `Option` whose `None` omits it. `typegen`
implements wasm_api's `Serialize` and `Deserialize` for it with the same code
as input objects, so it serializes directly.

bluejay's `typegen` also takes `custom_scalar_overrides`, so a prepare result's
`variables` field can be typed as the run query's variables struct instead of
`JsonValue`:

    #[typegen("schema.graphql", custom_scalar_overrides = {
        "CartValidationsGeneratePrepareResult.variables" => cart_validations_generate_run::InputVariables,
    })]

A prepare target that no longer matches its run query then fails to compile:

- a renamed or removed variable is an unknown field (E0560)
- an added variable is a missing field (E0063)
- a retyped variable, or a hand-written `JsonValue`, is a mismatch (E0308)

Depends on Shopify/bluejay#146 and Shopify/bluejay#147, patched in from #146's
branch, which has both, until they ship in a release.
@saga-dasgupta

Copy link
Copy Markdown
Contributor Author

Moved to #149, stacked on #148's branch so its diff is only this change.

@saga-dasgupta
saga-dasgupta deleted the operation-variables branch October 6, 2026 14:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant