Skip to content

Conversation

@lopert
Copy link
Collaborator

@lopert lopert commented Oct 7, 2025

Fixes https://github.com/shop/issues-shopifyvm/issues/660

This PR reworks validation in order to support more graphql query features, such as aliasing, variables, fragments.

I also ended up reorganizing our test directory.


The fixture validation has been refactored into two functions

validateFixtureInputStructure(queryAST, schema, fixtureData)

Approach: Parallel Tree Traversal

Recursively walks through the query AST and fixture data simultaneously, comparing structure at each level:

  • Schema-aware traversal: Uses GraphQL schema to determine if types are abstract (unions/interfaces) or concrete, which dictates how fragments are processed
  • Fragment handling: Converts fragment spreads to inline fragments, then either matches fixture to one fragment (for abstract types) or merges all fragments (for concrete types)
  • Field-by-field comparison: At each object level, checks that fixture keys match query selections - detecting both extra and missing fields
  • Query generation: Builds a normalized GraphQL query string during traversal, resolving aliases and preserving structure

validateFixtureInputTypes(generatedQuery, schema, fixtureData, variables?)

Validates that the fixture's types match the schema by executing the query against fixture data:

Approach: GraphQL Execution Engine

Executes the normalized query against fixture data using GraphQL's built-in type validation.

  • Leverages GraphQL.js: Uses the standard GraphQL execution engine which handles all type coercion and validation rules
  • Custom resolvers: Injects field resolver that knows how to look up aliased fields in the original fixture data
  • Custom type resolver: Injects type resolver that infers concrete types for abstract types by matching fixture fields
  • Standard validation: All scalar validation, nullability checks, and type requirements are handled by GraphQL execution
  • Returns execution result: Provides the data and any type errors encountered during execution

@lopert lopert marked this pull request as ready for review October 7, 2025 19:46
@lopert lopert requested a review from adampetro October 7, 2025 19:46
Copy link
Contributor

@adampetro adampetro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if there is a simpler overall approach that will eliminate a lot of this complexity. Can we not just traverse the input query AST alongside the JSON value and validate as we go?

// Match alias pattern: word followed by colon and whitespace and another word
// But only when followed by whitespace, opening brace, or closing brace
// This avoids matching colons inside strings or arguments
return graphqlString.replace(/\b(\w+):\s*(\w+)(?=[\s{},])/g, '$2');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like it has the potential to miss a number of edge cases. I would much prefer if we could manipulate the AST instead of doing regex operations.

Additionally, how do we expect to handle the case of the same field selected multiple times with aliases? For example:

{
  discountNode {
    metafield1: metafield(key: "my-key1", namespace: "my-namespace") { jsonValue }
    metafield2: metafield(key: "my-key2", namespace: "my-namespace") { jsonValue }
  }
}

if (selection.kind === 'Field') {
const field = selection as FieldNode;
const actualFieldName = field.name.value;
const aliasName = field.alias?.value || actualFieldName;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: it's not an alias name if we fallback to actualFieldName. I would call it responseKey or something like that.

@lopert lopert force-pushed the lopert.handle-iq-aliases branch from b1009c4 to cc4beb1 Compare October 9, 2025 18:56
if (queryOperation.variableDefinitions && queryOperation.variableDefinitions.length > 0) {
const varDefs = queryOperation.variableDefinitions.map(varDef => {
const varName = varDef.variable.name.value;
const varType = print(varDef.type);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this a print?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the print function from graphqljs. We're using it to convert from AST for to a consistent string format. Example:

  // Variable definition in query AST:
  // $itemId: ID!

  varDef.variable.name.value  // => "itemId"
  varDef.type                 // => NonNullType { type: NamedType { name: "ID" } }
  print(varDef.type)          // => "ID!"

if (fieldNode.arguments && fieldNode.arguments.length > 0) {
const args = fieldNode.arguments.map(arg => {
const argName = arg.name.value;
const argValue = print(arg.value);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same. Here's another example with args:

  // Field with arguments:
  // metafield(namespace: "$app:config", key: "setting1", limit: 10, enabled: true, id: $itemId)

  arg.name.value     // => "namespace"
  arg.value          // => StringValue { value: "$app:config" }
  print(arg.value)   // => "\"$app:config\"" (with quotes!)

  arg.name.value     // => "limit"
  arg.value          // => IntValue { value: 10 }
  print(arg.value)   // => "10"

  arg.name.value     // => "enabled"
  arg.value          // => BooleanValue { value: true }
  print(arg.value)   // => "true"

  arg.name.value     // => "id"
  arg.value          // => Variable { name: { value: "itemId" } }
  print(arg.value)   // => "$itemId"

  // Result: "metafield(namespace: \"$app:config\", key: "setting1", limit: 10, enabled: true, id: $itemId)"

@lopert lopert changed the title Handle aliases Handle more advanced input queries Oct 15, 2025
@lopert lopert force-pushed the lopert.handle-iq-aliases branch from 0a04b23 to e5590c1 Compare October 15, 2025 21:20
* - expectedOutput: Object - The output data from payload.output
* - expectedOutput: Object - The output data from payload.output
* - target: string - The target string from payload.target
* - inputQueryVariables: Object (optional) - Variable values from payload.inputQueryVariables
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need variable values? As a user I would be very confused because the variable values wouldn't affect the execution at all given that I'm also providing the result of the input query?

Comment on lines +158 to +160
// Calculate paths correctly for when used as a dependency:
// __dirname = /path/to/function/tests/node_modules/function-testing-helpers/src/methods
// Go up 5 levels to get to function directory: ../../../../../ = /path/to/function
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this always the case? Is there not a scenario with workspaces where the dependencies are downloaded higher up in the hierarchy?

if (wasmPath) {
// Use user-provided WASM path
resolvedWasmPath = path.resolve(wasmPath);
if (!fs.existsSync(resolvedWasmPath)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it standard to use these sync functions in an async function?

}
} else {
// Check Rust build output location
const rustWasmPath = path.join(functionDir, 'target', 'wasm32-wasip1', 'release', `${functionName}.wasm`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is another case where workspaces might complicate things. It's also not a guarantee that they are using wasm32-wasip1. We are starting to move away from this with the latest version of the shopify_function Rust crate to get off of wasi.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this change be split into a different PR? It seems like switching to invoke function-runner directly is not related to handling more advanced input queries?

}

/**
* Run a function by calling function-runner directly (bypassing Shopify CLI)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets not include this yet because we dont get usage analytics if we use function-runner directly.

@lopert lopert marked this pull request as draft October 16, 2025 17:23
@lopert
Copy link
Collaborator Author

lopert commented Oct 16, 2025

Sorry I should have posted about the runFunction direct changes. I just added those on top of this to have a faster way to iterate through tests since going through the CLI invocation was taking too much time.

I should have branched off of this and kept that change separate.

For now, I've put this back into draft as I revisit things after discussing with @adampetro .
I'm going to come up with an alternative that doesn't perform type validation by invoking graphql, but instead does it as part of the traversal.

@lopert
Copy link
Collaborator Author

lopert commented Oct 27, 2025

Closing in favour of the visitor approach that was merged here: #26

@lopert lopert closed this Oct 27, 2025
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.

3 participants