From 35c2ca997fa3d413a21389ba98035d35dba82e60 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Wed, 20 May 2026 11:36:57 +0300 Subject: [PATCH 1/6] fix: use remapped variables in cost calculation --- execution/engine/execution_engine.go | 7 +- execution/graphql/request.go | 23 +++--- v2/pkg/engine/plan/cost.go | 70 +++++++++---------- v2/pkg/engine/resolve/context.go | 6 ++ v2/pkg/engine/resolve/inputtemplate.go | 12 +--- v2/pkg/engine/variables/variables.go | 47 +++++++++++++ .../variablesvalidation.go | 1 - 7 files changed, 103 insertions(+), 63 deletions(-) create mode 100644 v2/pkg/engine/variables/variables.go diff --git a/execution/engine/execution_engine.go b/execution/engine/execution_engine.go index 9d441eee9e..1cafb7204a 100644 --- a/execution/engine/execution_engine.go +++ b/execution/engine/execution_engine.go @@ -215,13 +215,14 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques if report.HasErrors() { return report } + varSet := execContext.resolveContext.VariableSet() if costCalculator != nil { - costCalculator.ValidateSliceArguments(execContext.resolveContext.Variables, &report) + costCalculator.ValidateSliceArguments(varSet, &report) if report.HasErrors() { return report } } - operation.ComputeEstimatedCost(costCalculator, execContext.resolveContext.Variables) + operation.ComputeEstimatedCost(costCalculator, varSet) if execContext.resolveContext.TracingOptions.Enable && !execContext.resolveContext.TracingOptions.ExcludePlannerStats { planningTime := resolve.GetDurationNanoSinceTraceStart(execContext.resolveContext.Context()) - tracePlanStart @@ -240,7 +241,7 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques return err } if resp != nil { - operation.ComputeActualCost(costCalculator, execContext.resolveContext.Variables, execContext.resolveContext.ActualListSizes) + operation.ComputeActualCost(costCalculator, varSet, execContext.resolveContext.ActualListSizes) } return nil case *plan.SubscriptionResponsePlan: diff --git a/execution/graphql/request.go b/execution/graphql/request.go index bccffebd67..3d751bd335 100644 --- a/execution/graphql/request.go +++ b/execution/graphql/request.go @@ -6,12 +6,11 @@ import ( "io" "net/http" - "github.com/wundergraph/astjson" - "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/variables" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) @@ -23,10 +22,10 @@ const ( type OperationType ast.OperationType const ( - OperationTypeUnknown OperationType = OperationType(ast.OperationTypeUnknown) - OperationTypeQuery OperationType = OperationType(ast.OperationTypeQuery) - OperationTypeMutation OperationType = OperationType(ast.OperationTypeMutation) - OperationTypeSubscription OperationType = OperationType(ast.OperationTypeSubscription) + OperationTypeUnknown = OperationType(ast.OperationTypeUnknown) + OperationTypeQuery = OperationType(ast.OperationTypeQuery) + OperationTypeMutation = OperationType(ast.OperationTypeMutation) + OperationTypeSubscription = OperationType(ast.OperationTypeSubscription) ) var ( @@ -196,11 +195,11 @@ func (r *Request) OperationType() (OperationType, error) { return OperationTypeUnknown, nil } -func (r *Request) ComputeEstimatedCost(calc *plan.CostCalculator, variables *astjson.Value) { +func (r *Request) ComputeEstimatedCost(calc *plan.CostCalculator, vars variables.Set) { if calc != nil { - r.estimatedCost = calc.EstimateCost(variables) + r.estimatedCost = calc.EstimateCost(vars) // Debugging of cost trees. Uncomment to debug. - // fmt.Println(calc.DebugPrint(variables, nil)) + // fmt.Println(calc.DebugPrint(vars, nil)) } else { r.estimatedCost = 0 } @@ -210,11 +209,11 @@ func (r *Request) EstimatedCost() int { return r.estimatedCost } -func (r *Request) ComputeActualCost(calc *plan.CostCalculator, variables *astjson.Value, actualListSizes map[string]int) { +func (r *Request) ComputeActualCost(calc *plan.CostCalculator, vars variables.Set, actualListSizes map[string]int) { if calc != nil { - r.actualCost = calc.ActualCost(variables, actualListSizes) + r.actualCost = calc.ActualCost(vars, actualListSizes) // Debugging of cost trees. Uncomment to debug. - // fmt.Println(calc.DebugPrint(variables, actualListSizes)) + // fmt.Println(calc.DebugPrint(vars, actualListSizes)) } else { r.actualCost = 0 } diff --git a/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index a7089dc974..db9adeb72b 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -32,6 +32,7 @@ import ( "github.com/wundergraph/astjson" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/variables" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) @@ -89,7 +90,7 @@ type FieldListSize struct { // multiplier returns the multiplier based on arguments and variables. // It picks the maximum value among slicing arguments, otherwise it tries to use AssumedSize. // If neither is available, it falls back to defaultListSize. -func (ls *FieldListSize) multiplier(args map[string]ArgumentInfo, vars *astjson.Value, defaultListSize int) int { +func (ls *FieldListSize) multiplier(args map[string]ArgumentInfo, vars variables.Set, defaultListSize int) int { multiplier := -1 for _, slicingArg := range ls.SlicingArguments { value, found := ls.resolveSlicingArg(slicingArg, args, vars) @@ -112,7 +113,7 @@ func (ls *FieldListSize) multiplier(args map[string]ArgumentInfo, vars *astjson. // It falls back to SlicingArgumentDefaults when no value is provided. // The slicingArg may be a simple argument name or a dot-path into an input object argument. // An explicitly provided [null] value in variables overrides the default value in schema. -func (ls *FieldListSize) resolveSlicingArg(slicingArg string, args map[string]ArgumentInfo, vars *astjson.Value) (int, bool) { +func (ls *FieldListSize) resolveSlicingArg(slicingArg string, args map[string]ArgumentInfo, vars variables.Set) (int, bool) { defaultValue, hasDefault := ls.SlicingArgumentDefaults[slicingArg] if strings.Contains(slicingArg, ".") { value := extractSlicingArgValue(slicingArg, args, vars) @@ -144,10 +145,7 @@ func (ls *FieldListSize) resolveSlicingArg(slicingArg string, args map[string]Ar // extractSlicingArgValue extracts a value from variables using slicingArg that contains // a string in the format: "....." -func extractSlicingArgValue(slicingArg string, args map[string]ArgumentInfo, vars *astjson.Value) *astjson.Value { - if vars == nil { - return nil - } +func extractSlicingArgValue(slicingArg string, args map[string]ArgumentInfo, vars variables.Set) *astjson.Value { path := strings.Split(slicingArg, ".") inputArg := path[0] arg, found := args[inputArg] @@ -282,11 +280,11 @@ type inputObjectField struct { // inputFieldsCost computes the cost of input object fields from the variable value. // It handles both single objects and arrays of objects. -func (arg *ArgumentInfo) inputFieldsCost(variables *astjson.Value, weights map[FieldCoordinate]*FieldCost) int { +func (arg *ArgumentInfo) inputFieldsCost(vars variables.Set, weights map[FieldCoordinate]*FieldCost) int { if !arg.hasVariable { return 0 } - varValue := variables.Get(arg.varName) + varValue := vars.Get(arg.varName) if varValue == nil { return 0 } @@ -322,7 +320,7 @@ func (node *CostTreeNode) maxWeightImplementingField(config *DataSourceCostConfi return maxWeight } -func (node *CostTreeNode) maxMultiplierImplementingField(config *DataSourceCostConfig, fieldName string, arguments map[string]ArgumentInfo, vars *astjson.Value, defaultListSize int) *FieldListSize { +func (node *CostTreeNode) maxMultiplierImplementingField(config *DataSourceCostConfig, fieldName string, arguments map[string]ArgumentInfo, vars variables.Set, defaultListSize int) *FieldListSize { var maxMultiplier int var maxListSize *FieldListSize for _, implTypeName := range node.implementingTypeNames { @@ -403,17 +401,17 @@ func (node *CostTreeNode) maxDirectiveArgumentWeightsImplementingFields(config * // When it is positive, then its value is used as a fallback value of list sizes for the estimated cost. // When it is negative, then it computes the actual cost. And it uses the actualListSizes map. // For actual cost, multipliers are computed as averages (totalCount/parentCount). -func (node *CostTreeNode) cost(configs map[DSHash]*DataSourceCostConfig, variables *astjson.Value, defaultListSize int, actualListSizes map[string]int) int { +func (node *CostTreeNode) cost(configs map[DSHash]*DataSourceCostConfig, vars variables.Set, defaultListSize int, actualListSizes map[string]int) int { if node == nil { return 0 } - fieldCost, argsCost, directivesCost, multiplier := node.costsAndMultiplier(configs, variables, defaultListSize, actualListSizes) + fieldCost, argsCost, directivesCost, multiplier := node.costsAndMultiplier(configs, vars, defaultListSize, actualListSizes) // Sum children costs var childrenCost int for _, child := range node.children { - childrenCost += child.cost(configs, variables, defaultListSize, actualListSizes) + childrenCost += child.cost(configs, vars, defaultListSize, actualListSizes) } // We enforce multiplier=1 for non-list fields. @@ -458,7 +456,7 @@ func (node *CostTreeNode) cost(configs map[DSHash]*DataSourceCostConfig, variabl // When estimating cost, it picks the highest multiplier among different data sources. // Also, it picks the maximum field weight of implementing types and then // the maximum among slicing arguments. -func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostConfig, variables *astjson.Value, defaultListSize int, actualListSizes map[string]int) (fieldCost, argsCost, directivesCost int, multiplier float64) { +func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostConfig, vars variables.Set, defaultListSize int, actualListSizes map[string]int) (fieldCost, argsCost, directivesCost int, multiplier float64) { if len(node.dataSourceHashes) <= 0 { // no data source is responsible for this field return @@ -497,7 +495,7 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC fieldWeight = parent.maxWeightImplementingField(dsCostConfig, node.fieldCoords.FieldName) // If this field has listSize defined, then do not look into implementing types. if isEstimation && listSize == nil && node.returnsListType { - listSize = parent.maxMultiplierImplementingField(dsCostConfig, node.fieldCoords.FieldName, node.arguments, variables, defaultListSize) + listSize = parent.maxMultiplierImplementingField(dsCostConfig, node.fieldCoords.FieldName, node.arguments, vars, defaultListSize) } } @@ -536,7 +534,7 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC // Input objects always add field-level costs, as the spec says. // For other types, the explicit argument weight replaces the default type weight. if arg.isInputObject { - argsCost += arg.inputFieldsCost(variables, dsCostConfig.Weights) + argsCost += arg.inputFieldsCost(vars, dsCostConfig.Weights) } else if !argumentWeightFound { if arg.isSimple { argsCost += dsCostConfig.EnumScalarTypeWeight(arg.typeName) @@ -566,7 +564,7 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC // Pick the maximum multiplier of all data sources. if listSize != nil { - m := float64(listSize.multiplier(node.arguments, variables, defaultListSize)) + m := float64(listSize.multiplier(node.arguments, vars, defaultListSize)) // If this node returns a list of abstract types, then it could have listSize defined. // Spec allows defining listSize on the fields of interfaces. if m > multiplier { @@ -586,7 +584,7 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC if sf != node.fieldCoords.FieldName { continue } - m := float64(parentLS.multiplier(parent.arguments, variables, defaultListSize)) + m := float64(parentLS.multiplier(parent.arguments, vars, defaultListSize)) if m > multiplier { multiplier = m } @@ -603,7 +601,7 @@ func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostC dsCostConfig, parent.fieldCoords.FieldName, node.fieldCoords.FieldName, ) for _, implLS := range implementing { - m := float64(implLS.multiplier(parent.arguments, variables, defaultListSize)) + m := float64(implLS.multiplier(parent.arguments, vars, defaultListSize)) if m > multiplier { multiplier = m } @@ -724,9 +722,9 @@ func NewCostCalculator(config Configuration) *CostCalculator { } // EstimateCost returns the calculated total static cost. -// config should be static per process or instance. variables could change between requests. -func (c *CostCalculator) EstimateCost(variables *astjson.Value) int { - return c.tree.cost(c.costConfigs, variables, c.defaultListSize, nil) +// config should be static per process or instance. vars could change between requests. +func (c *CostCalculator) EstimateCost(vars variables.Set) int { + return c.tree.cost(c.costConfigs, vars, c.defaultListSize, nil) } const ( @@ -734,18 +732,18 @@ const ( ) // ActualCost returns the actual cost of the operation that is based on the actual sizes of lists. -func (c *CostCalculator) ActualCost(variables *astjson.Value, actualListSizes map[string]int) int { - return c.tree.cost(c.costConfigs, variables, actualCostMode, actualListSizes) +func (c *CostCalculator) ActualCost(vars variables.Set, actualListSizes map[string]int) int { + return c.tree.cost(c.costConfigs, vars, actualCostMode, actualListSizes) } // ValidateSliceArguments checks that all fields with slicingArguments and // requireOneSlicingArgument are valid against the arguments passed to those fields. // Violations are collected as external errors into the report. -func (c *CostCalculator) ValidateSliceArguments(variables *astjson.Value, report *operationreport.Report) { - c.tree.validateSliceArguments(c.costConfigs, variables, report) +func (c *CostCalculator) ValidateSliceArguments(vars variables.Set, report *operationreport.Report) { + c.tree.validateSliceArguments(c.costConfigs, vars, report) } -func (node *CostTreeNode) validateSliceArguments(configs map[DSHash]*DataSourceCostConfig, variables *astjson.Value, report *operationreport.Report) { +func (node *CostTreeNode) validateSliceArguments(configs map[DSHash]*DataSourceCostConfig, vars variables.Set, report *operationreport.Report) { if node == nil { return } @@ -771,7 +769,7 @@ func (node *CostTreeNode) validateSliceArguments(configs map[DSHash]*DataSourceC // The engine has all inlined literals converted to variables at this stage. // No need to check for literals. for _, slicingArg := range listSize.SlicingArguments { - if _, found := listSize.resolveSlicingArg(slicingArg, node.arguments, variables); found { + if _, found := listSize.resolveSlicingArg(slicingArg, node.arguments, vars); found { count++ } } @@ -796,7 +794,7 @@ func (node *CostTreeNode) validateSliceArguments(configs map[DSHash]*DataSourceC } for _, child := range node.children { - child.validateSliceArguments(configs, variables, report) + child.validateSliceArguments(configs, vars, report) } } @@ -818,7 +816,7 @@ func (node *CostTreeNode) buildASTPath() ast.Path { // DebugPrint prints the cost tree structure for debugging purposes. // It shows each node's field coordinate, costs, multipliers, and computed totals. -func (c *CostCalculator) DebugPrint(variables *astjson.Value, actualListSizes map[string]int) string { +func (c *CostCalculator) DebugPrint(vars variables.Set, actualListSizes map[string]int) string { if c.tree == nil || len(c.tree.children) == 0 { return "" } @@ -833,12 +831,12 @@ func (c *CostCalculator) DebugPrint(variables *astjson.Value, actualListSizes ma sb.WriteString("Estimated Cost Tree Debug\n") sb.WriteString("=========================\n") } - c.tree.children[0].debugPrint(&sb, costConfigs, variables, defaultListSize, actualListSizes, 0) + c.tree.children[0].debugPrint(&sb, costConfigs, vars, defaultListSize, actualListSizes, 0) return sb.String() } // debugPrint recursively prints a node and its children with indentation. -func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*DataSourceCostConfig, variables *astjson.Value, defaultListSize int, actualListSizes map[string]int, depth int) { +func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*DataSourceCostConfig, vars variables.Set, defaultListSize int, actualListSizes map[string]int, depth int) { // implementation is a bit crude and redundant, we could skip calculating nodes all over again. // but it should suffice for debugging tests. if node == nil { @@ -872,7 +870,7 @@ func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*Da sb.WriteString("\n") // Compute costs for this node to display in debug output - fieldCost, argsCost, dirsCost, multiplier := node.costsAndMultiplier(configs, variables, defaultListSize, actualListSizes) + fieldCost, argsCost, dirsCost, multiplier := node.costsAndMultiplier(configs, vars, defaultListSize, actualListSizes) // We enforce multiplier=1 for non-list fields. if multiplier == 0 && !node.returnsListType { multiplier = 1 @@ -899,12 +897,12 @@ func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*Da var argStrs []string for name, arg := range node.arguments { if arg.hasVariable { - if variables == nil { + if vars.IsEmpty() { // actual cost argStrs = append(argStrs, fmt.Sprintf("%s=$%s", name, arg.varName)) } else { // estimated cost - v := variables.Get(arg.varName) + v := vars.Get(arg.varName) argStrs = append(argStrs, fmt.Sprintf("%s=%s($%s)", name, v, arg.varName)) } } else { @@ -921,10 +919,10 @@ func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*Da // This is somewhat redundant, but it should not be used in production. // If there is a need to present a cost tree to the user, // printing should be embedded into the tree calculation process. - subtreeCost := node.cost(configs, variables, defaultListSize, actualListSizes) + subtreeCost := node.cost(configs, vars, defaultListSize, actualListSizes) fmt.Fprintf(sb, "%s subCost=%d\n", indent, subtreeCost) for _, child := range node.children { - child.debugPrint(sb, configs, variables, defaultListSize, actualListSizes, depth+1) + child.debugPrint(sb, configs, vars, defaultListSize, actualListSizes, depth+1) } } diff --git a/v2/pkg/engine/resolve/context.go b/v2/pkg/engine/resolve/context.go index 0b3e626f1b..7bbb37a10f 100644 --- a/v2/pkg/engine/resolve/context.go +++ b/v2/pkg/engine/resolve/context.go @@ -10,6 +10,7 @@ import ( "time" "github.com/wundergraph/astjson" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/variables" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" ) @@ -20,6 +21,7 @@ type Context struct { // Variables contains the variables to be used to render values of variables for the subgraph. // Resolver takes into account RemapVariables for variable names. + // Recommented read-only use via variables.Set returned by VariableSet(). Variables *astjson.Value // RemapVariables contains a map from new names to old names. When variables are renamed, @@ -323,6 +325,10 @@ func (c *Context) Free() { c.ActualListSizes = nil } +func (c *Context) VariableSet() variables.Set { + return variables.NewSet(c.Variables, c.RemapVariables) +} + type traceStartKey struct{} type TraceInfo struct { diff --git a/v2/pkg/engine/resolve/inputtemplate.go b/v2/pkg/engine/resolve/inputtemplate.go index e0fc97aa69..96f7c8b378 100644 --- a/v2/pkg/engine/resolve/inputtemplate.go +++ b/v2/pkg/engine/resolve/inputtemplate.go @@ -133,20 +133,10 @@ func (i *InputTemplate) renderResolvableObjectVariable(ctx context.Context, obje } func (i *InputTemplate) renderContextVariable(ctx *Context, segment TemplateSegment, preparedInput InputTemplateWriter) (variableWasUndefined bool, err error) { - variableSourcePath := segment.VariableSourcePath - if len(variableSourcePath) == 1 && ctx.RemapVariables != nil { - nameToUse, hasMapping := ctx.RemapVariables[variableSourcePath[0]] - if hasMapping && nameToUse != variableSourcePath[0] { - variableSourcePath = []string{nameToUse} - } - } - - value := ctx.Variables.Get(variableSourcePath...) + value := ctx.VariableSet().Get(segment.VariableSourcePath...) if value == nil { _, _ = preparedInput.Write(literal.NULL) return true, nil - } else if value.Type() == astjson.TypeNull { - return false, segment.Renderer.RenderVariable(ctx.Context(), value, preparedInput) } return false, segment.Renderer.RenderVariable(ctx.Context(), value, preparedInput) } diff --git a/v2/pkg/engine/variables/variables.go b/v2/pkg/engine/variables/variables.go new file mode 100644 index 0000000000..33182734bc --- /dev/null +++ b/v2/pkg/engine/variables/variables.go @@ -0,0 +1,47 @@ +// Package variables provides a request-scoped view over GraphQL operation +// variables that transparently honors variable remapping introduced during +// operation normalization. +package variables + +import "github.com/wundergraph/astjson" + +// Set is a read-side view of request variables. +// +// The zero value is valid and behaves as an empty set: Get returns nil for any +// name. Set is intended to be passed by value; it carries only two +// pointers and is safe to copy. +type Set struct { + variables *astjson.Value + remap map[string]string +} + +// NewSet returns a Set that reads variable values from vars, +// using remap (new -> old name) to translate post-normalization variable names +// back to the original keys present in vars. +// Either argument may be nil; a nil remap means no translation is performed. +func NewSet(vars *astjson.Value, remap map[string]string) Set { + return Set{variables: vars, remap: remap} +} + +// Get walks the variable tree along path. path[0] is the variable name and +// is translated through the remap if an entry exists. +// Subsequent elements are walked as nested keys on the resulting JSON value. +// Returns nil if the set is empty, the path is empty, or any segment is missing. +func (v Set) Get(path ...string) *astjson.Value { + if v.variables == nil || len(path) == 0 { + return nil + } + head := path[0] + if orig, ok := v.remap[head]; ok { + head = orig + } + val := v.variables.Get(head) + if val == nil || len(path) == 1 { + return val + } + return val.Get(path[1:]...) +} + +func (v Set) IsEmpty() bool { + return v.variables == nil +} diff --git a/v2/pkg/variablesvalidation/variablesvalidation.go b/v2/pkg/variablesvalidation/variablesvalidation.go index b1af4f40e6..a31281dcbf 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation.go +++ b/v2/pkg/variablesvalidation/variablesvalidation.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/wundergraph/astjson" - "github.com/wundergraph/graphql-go-tools/v2/pkg/apollocompatibility" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" From ca40d8630d7d9a2bd457bfbbaee072e574b39b9a Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Wed, 20 May 2026 17:36:03 +0300 Subject: [PATCH 2/6] go lint --- v2/pkg/engine/resolve/context.go | 2 +- v2/pkg/variablesvalidation/variablesvalidation.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/v2/pkg/engine/resolve/context.go b/v2/pkg/engine/resolve/context.go index 7bbb37a10f..e1029fae45 100644 --- a/v2/pkg/engine/resolve/context.go +++ b/v2/pkg/engine/resolve/context.go @@ -10,9 +10,9 @@ import ( "time" "github.com/wundergraph/astjson" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/variables" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/variables" ) // Context should not ever be initialized directly, and should be initialized via the NewContext function diff --git a/v2/pkg/variablesvalidation/variablesvalidation.go b/v2/pkg/variablesvalidation/variablesvalidation.go index a31281dcbf..b1af4f40e6 100644 --- a/v2/pkg/variablesvalidation/variablesvalidation.go +++ b/v2/pkg/variablesvalidation/variablesvalidation.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/wundergraph/astjson" + "github.com/wundergraph/graphql-go-tools/v2/pkg/apollocompatibility" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" From 4846ba8cf7a14870e617b508009276e0142444c0 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Thu, 21 May 2026 15:24:28 +0300 Subject: [PATCH 3/6] move variables.Set to resolve.VariablesView --- execution/engine/execution_engine.go | 8 +++--- execution/graphql/request.go | 5 ++-- v2/pkg/engine/plan/cost.go | 28 +++++++++---------- v2/pkg/engine/resolve/context.go | 7 ++--- v2/pkg/engine/resolve/inputtemplate.go | 2 +- .../variables_view.go} | 22 +++++++-------- 6 files changed, 35 insertions(+), 37 deletions(-) rename v2/pkg/engine/{variables/variables.go => resolve/variables_view.go} (61%) diff --git a/execution/engine/execution_engine.go b/execution/engine/execution_engine.go index 1cafb7204a..5c6ed070a4 100644 --- a/execution/engine/execution_engine.go +++ b/execution/engine/execution_engine.go @@ -215,14 +215,14 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques if report.HasErrors() { return report } - varSet := execContext.resolveContext.VariableSet() + varsView := execContext.resolveContext.VariablesView() if costCalculator != nil { - costCalculator.ValidateSliceArguments(varSet, &report) + costCalculator.ValidateSliceArguments(varsView, &report) if report.HasErrors() { return report } } - operation.ComputeEstimatedCost(costCalculator, varSet) + operation.ComputeEstimatedCost(costCalculator, varsView) if execContext.resolveContext.TracingOptions.Enable && !execContext.resolveContext.TracingOptions.ExcludePlannerStats { planningTime := resolve.GetDurationNanoSinceTraceStart(execContext.resolveContext.Context()) - tracePlanStart @@ -241,7 +241,7 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques return err } if resp != nil { - operation.ComputeActualCost(costCalculator, varSet, execContext.resolveContext.ActualListSizes) + operation.ComputeActualCost(costCalculator, varsView, execContext.resolveContext.ActualListSizes) } return nil case *plan.SubscriptionResponsePlan: diff --git a/execution/graphql/request.go b/execution/graphql/request.go index 3d751bd335..755a8fd1b9 100644 --- a/execution/graphql/request.go +++ b/execution/graphql/request.go @@ -10,7 +10,6 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/plan" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/variables" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) @@ -195,7 +194,7 @@ func (r *Request) OperationType() (OperationType, error) { return OperationTypeUnknown, nil } -func (r *Request) ComputeEstimatedCost(calc *plan.CostCalculator, vars variables.Set) { +func (r *Request) ComputeEstimatedCost(calc *plan.CostCalculator, vars resolve.VariablesView) { if calc != nil { r.estimatedCost = calc.EstimateCost(vars) // Debugging of cost trees. Uncomment to debug. @@ -209,7 +208,7 @@ func (r *Request) EstimatedCost() int { return r.estimatedCost } -func (r *Request) ComputeActualCost(calc *plan.CostCalculator, vars variables.Set, actualListSizes map[string]int) { +func (r *Request) ComputeActualCost(calc *plan.CostCalculator, vars resolve.VariablesView, actualListSizes map[string]int) { if calc != nil { r.actualCost = calc.ActualCost(vars, actualListSizes) // Debugging of cost trees. Uncomment to debug. diff --git a/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index db9adeb72b..26e29553a9 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -32,7 +32,7 @@ import ( "github.com/wundergraph/astjson" "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/variables" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/resolve" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" ) @@ -90,7 +90,7 @@ type FieldListSize struct { // multiplier returns the multiplier based on arguments and variables. // It picks the maximum value among slicing arguments, otherwise it tries to use AssumedSize. // If neither is available, it falls back to defaultListSize. -func (ls *FieldListSize) multiplier(args map[string]ArgumentInfo, vars variables.Set, defaultListSize int) int { +func (ls *FieldListSize) multiplier(args map[string]ArgumentInfo, vars resolve.VariablesView, defaultListSize int) int { multiplier := -1 for _, slicingArg := range ls.SlicingArguments { value, found := ls.resolveSlicingArg(slicingArg, args, vars) @@ -113,7 +113,7 @@ func (ls *FieldListSize) multiplier(args map[string]ArgumentInfo, vars variables // It falls back to SlicingArgumentDefaults when no value is provided. // The slicingArg may be a simple argument name or a dot-path into an input object argument. // An explicitly provided [null] value in variables overrides the default value in schema. -func (ls *FieldListSize) resolveSlicingArg(slicingArg string, args map[string]ArgumentInfo, vars variables.Set) (int, bool) { +func (ls *FieldListSize) resolveSlicingArg(slicingArg string, args map[string]ArgumentInfo, vars resolve.VariablesView) (int, bool) { defaultValue, hasDefault := ls.SlicingArgumentDefaults[slicingArg] if strings.Contains(slicingArg, ".") { value := extractSlicingArgValue(slicingArg, args, vars) @@ -145,7 +145,7 @@ func (ls *FieldListSize) resolveSlicingArg(slicingArg string, args map[string]Ar // extractSlicingArgValue extracts a value from variables using slicingArg that contains // a string in the format: "....." -func extractSlicingArgValue(slicingArg string, args map[string]ArgumentInfo, vars variables.Set) *astjson.Value { +func extractSlicingArgValue(slicingArg string, args map[string]ArgumentInfo, vars resolve.VariablesView) *astjson.Value { path := strings.Split(slicingArg, ".") inputArg := path[0] arg, found := args[inputArg] @@ -280,7 +280,7 @@ type inputObjectField struct { // inputFieldsCost computes the cost of input object fields from the variable value. // It handles both single objects and arrays of objects. -func (arg *ArgumentInfo) inputFieldsCost(vars variables.Set, weights map[FieldCoordinate]*FieldCost) int { +func (arg *ArgumentInfo) inputFieldsCost(vars resolve.VariablesView, weights map[FieldCoordinate]*FieldCost) int { if !arg.hasVariable { return 0 } @@ -320,7 +320,7 @@ func (node *CostTreeNode) maxWeightImplementingField(config *DataSourceCostConfi return maxWeight } -func (node *CostTreeNode) maxMultiplierImplementingField(config *DataSourceCostConfig, fieldName string, arguments map[string]ArgumentInfo, vars variables.Set, defaultListSize int) *FieldListSize { +func (node *CostTreeNode) maxMultiplierImplementingField(config *DataSourceCostConfig, fieldName string, arguments map[string]ArgumentInfo, vars resolve.VariablesView, defaultListSize int) *FieldListSize { var maxMultiplier int var maxListSize *FieldListSize for _, implTypeName := range node.implementingTypeNames { @@ -401,7 +401,7 @@ func (node *CostTreeNode) maxDirectiveArgumentWeightsImplementingFields(config * // When it is positive, then its value is used as a fallback value of list sizes for the estimated cost. // When it is negative, then it computes the actual cost. And it uses the actualListSizes map. // For actual cost, multipliers are computed as averages (totalCount/parentCount). -func (node *CostTreeNode) cost(configs map[DSHash]*DataSourceCostConfig, vars variables.Set, defaultListSize int, actualListSizes map[string]int) int { +func (node *CostTreeNode) cost(configs map[DSHash]*DataSourceCostConfig, vars resolve.VariablesView, defaultListSize int, actualListSizes map[string]int) int { if node == nil { return 0 } @@ -456,7 +456,7 @@ func (node *CostTreeNode) cost(configs map[DSHash]*DataSourceCostConfig, vars va // When estimating cost, it picks the highest multiplier among different data sources. // Also, it picks the maximum field weight of implementing types and then // the maximum among slicing arguments. -func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostConfig, vars variables.Set, defaultListSize int, actualListSizes map[string]int) (fieldCost, argsCost, directivesCost int, multiplier float64) { +func (node *CostTreeNode) costsAndMultiplier(configs map[DSHash]*DataSourceCostConfig, vars resolve.VariablesView, defaultListSize int, actualListSizes map[string]int) (fieldCost, argsCost, directivesCost int, multiplier float64) { if len(node.dataSourceHashes) <= 0 { // no data source is responsible for this field return @@ -723,7 +723,7 @@ func NewCostCalculator(config Configuration) *CostCalculator { // EstimateCost returns the calculated total static cost. // config should be static per process or instance. vars could change between requests. -func (c *CostCalculator) EstimateCost(vars variables.Set) int { +func (c *CostCalculator) EstimateCost(vars resolve.VariablesView) int { return c.tree.cost(c.costConfigs, vars, c.defaultListSize, nil) } @@ -732,18 +732,18 @@ const ( ) // ActualCost returns the actual cost of the operation that is based on the actual sizes of lists. -func (c *CostCalculator) ActualCost(vars variables.Set, actualListSizes map[string]int) int { +func (c *CostCalculator) ActualCost(vars resolve.VariablesView, actualListSizes map[string]int) int { return c.tree.cost(c.costConfigs, vars, actualCostMode, actualListSizes) } // ValidateSliceArguments checks that all fields with slicingArguments and // requireOneSlicingArgument are valid against the arguments passed to those fields. // Violations are collected as external errors into the report. -func (c *CostCalculator) ValidateSliceArguments(vars variables.Set, report *operationreport.Report) { +func (c *CostCalculator) ValidateSliceArguments(vars resolve.VariablesView, report *operationreport.Report) { c.tree.validateSliceArguments(c.costConfigs, vars, report) } -func (node *CostTreeNode) validateSliceArguments(configs map[DSHash]*DataSourceCostConfig, vars variables.Set, report *operationreport.Report) { +func (node *CostTreeNode) validateSliceArguments(configs map[DSHash]*DataSourceCostConfig, vars resolve.VariablesView, report *operationreport.Report) { if node == nil { return } @@ -816,7 +816,7 @@ func (node *CostTreeNode) buildASTPath() ast.Path { // DebugPrint prints the cost tree structure for debugging purposes. // It shows each node's field coordinate, costs, multipliers, and computed totals. -func (c *CostCalculator) DebugPrint(vars variables.Set, actualListSizes map[string]int) string { +func (c *CostCalculator) DebugPrint(vars resolve.VariablesView, actualListSizes map[string]int) string { if c.tree == nil || len(c.tree.children) == 0 { return "" } @@ -836,7 +836,7 @@ func (c *CostCalculator) DebugPrint(vars variables.Set, actualListSizes map[stri } // debugPrint recursively prints a node and its children with indentation. -func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*DataSourceCostConfig, vars variables.Set, defaultListSize int, actualListSizes map[string]int, depth int) { +func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*DataSourceCostConfig, vars resolve.VariablesView, defaultListSize int, actualListSizes map[string]int, depth int) { // implementation is a bit crude and redundant, we could skip calculating nodes all over again. // but it should suffice for debugging tests. if node == nil { diff --git a/v2/pkg/engine/resolve/context.go b/v2/pkg/engine/resolve/context.go index e1029fae45..4b7391e002 100644 --- a/v2/pkg/engine/resolve/context.go +++ b/v2/pkg/engine/resolve/context.go @@ -12,7 +12,6 @@ import ( "github.com/wundergraph/astjson" "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" - "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/variables" ) // Context should not ever be initialized directly, and should be initialized via the NewContext function @@ -21,7 +20,7 @@ type Context struct { // Variables contains the variables to be used to render values of variables for the subgraph. // Resolver takes into account RemapVariables for variable names. - // Recommented read-only use via variables.Set returned by VariableSet(). + // Recommented read-only use via variables.VariablesView returned by VariablesView(). Variables *astjson.Value // RemapVariables contains a map from new names to old names. When variables are renamed, @@ -325,8 +324,8 @@ func (c *Context) Free() { c.ActualListSizes = nil } -func (c *Context) VariableSet() variables.Set { - return variables.NewSet(c.Variables, c.RemapVariables) +func (c *Context) VariablesView() VariablesView { + return NewVariablesView(c.Variables, c.RemapVariables) } type traceStartKey struct{} diff --git a/v2/pkg/engine/resolve/inputtemplate.go b/v2/pkg/engine/resolve/inputtemplate.go index 96f7c8b378..80ca5a936b 100644 --- a/v2/pkg/engine/resolve/inputtemplate.go +++ b/v2/pkg/engine/resolve/inputtemplate.go @@ -133,7 +133,7 @@ func (i *InputTemplate) renderResolvableObjectVariable(ctx context.Context, obje } func (i *InputTemplate) renderContextVariable(ctx *Context, segment TemplateSegment, preparedInput InputTemplateWriter) (variableWasUndefined bool, err error) { - value := ctx.VariableSet().Get(segment.VariableSourcePath...) + value := ctx.VariablesView().Get(segment.VariableSourcePath...) if value == nil { _, _ = preparedInput.Write(literal.NULL) return true, nil diff --git a/v2/pkg/engine/variables/variables.go b/v2/pkg/engine/resolve/variables_view.go similarity index 61% rename from v2/pkg/engine/variables/variables.go rename to v2/pkg/engine/resolve/variables_view.go index 33182734bc..8921e8e86c 100644 --- a/v2/pkg/engine/variables/variables.go +++ b/v2/pkg/engine/resolve/variables_view.go @@ -1,33 +1,33 @@ // Package variables provides a request-scoped view over GraphQL operation // variables that transparently honors variable remapping introduced during // operation normalization. -package variables +package resolve import "github.com/wundergraph/astjson" -// Set is a read-side view of request variables. +// VariablesView is a read-side view of request variables. // // The zero value is valid and behaves as an empty set: Get returns nil for any -// name. Set is intended to be passed by value; it carries only two +// name. VariablesView is intended to be passed by value; it carries only two // pointers and is safe to copy. -type Set struct { +type VariablesView struct { variables *astjson.Value remap map[string]string } -// NewSet returns a Set that reads variable values from vars, +// NewVariablesView returns a VariablesView that reads variable values from vars, // using remap (new -> old name) to translate post-normalization variable names // back to the original keys present in vars. // Either argument may be nil; a nil remap means no translation is performed. -func NewSet(vars *astjson.Value, remap map[string]string) Set { - return Set{variables: vars, remap: remap} +func NewVariablesView(vars *astjson.Value, remap map[string]string) VariablesView { + return VariablesView{variables: vars, remap: remap} } -// Get walks the variable tree along path. path[0] is the variable name and -// is translated through the remap if an entry exists. +// Get extracts value from the variables view using the keyed path. +// path[0] is the variable name and is translated through the remap if an entry exists. // Subsequent elements are walked as nested keys on the resulting JSON value. // Returns nil if the set is empty, the path is empty, or any segment is missing. -func (v Set) Get(path ...string) *astjson.Value { +func (v VariablesView) Get(path ...string) *astjson.Value { if v.variables == nil || len(path) == 0 { return nil } @@ -42,6 +42,6 @@ func (v Set) Get(path ...string) *astjson.Value { return val.Get(path[1:]...) } -func (v Set) IsEmpty() bool { +func (v VariablesView) IsEmpty() bool { return v.variables == nil } From 93102fef9d83b3f397c6ed98b169e50086d3ce9d Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Thu, 21 May 2026 15:36:01 +0300 Subject: [PATCH 4/6] comment a null behavior --- v2/pkg/engine/plan/cost.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index 26e29553a9..c1845bf3d5 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -153,6 +153,10 @@ func extractSlicingArgValue(slicingArg string, args map[string]ArgumentInfo, var return nil } value := vars.Get(arg.varName) + // Walk nested keys manually rather than passing the full path to vars.Get: + // we must return an explicit TypeNull encountered mid-path; + // the caller can distinguish "explicit null in variables" (overrides schema default) + // from "missing" (uses schema default). Calling Get on a null collapses both cases. for _, key := range path[1:] { if value == nil || value.Type() == astjson.TypeNull { return value From 6bdf5cc7e187a19ebbfef636da315d6bda3e25e0 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Thu, 21 May 2026 15:42:55 +0300 Subject: [PATCH 5/6] remove stale comments --- v2/pkg/engine/plan/cost.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/v2/pkg/engine/plan/cost.go b/v2/pkg/engine/plan/cost.go index c1845bf3d5..4f7cef6899 100644 --- a/v2/pkg/engine/plan/cost.go +++ b/v2/pkg/engine/plan/cost.go @@ -902,10 +902,8 @@ func (node *CostTreeNode) debugPrint(sb *strings.Builder, configs map[DSHash]*Da for name, arg := range node.arguments { if arg.hasVariable { if vars.IsEmpty() { - // actual cost argStrs = append(argStrs, fmt.Sprintf("%s=$%s", name, arg.varName)) } else { - // estimated cost v := vars.Get(arg.varName) argStrs = append(argStrs, fmt.Sprintf("%s=%s($%s)", name, v, arg.varName)) } From a59d00e152855c1357fa1b167affc3a48b58c135 Mon Sep 17 00:00:00 2001 From: Yury Smolski <140245+ysmolski@users.noreply.github.com> Date: Thu, 21 May 2026 17:59:53 +0300 Subject: [PATCH 6/6] remap vars in execution tests --- execution/engine/execution_engine.go | 21 +++++++++++++++++++-- execution/engine/execution_engine_test.go | 12 ++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/execution/engine/execution_engine.go b/execution/engine/execution_engine.go index 5c6ed070a4..8101a4d481 100644 --- a/execution/engine/execution_engine.go +++ b/execution/engine/execution_engine.go @@ -181,12 +181,28 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques } } - // Validate user-supplied and extracted variables against the operation. + // Remap operation variables to canonical names. This mirrors what the cosmo + // router does so that downstream code (planner, cost calc, resolver) always + // goes through VariablesView/RemapVariables when reading variables. + var remapVariables map[string]string + if normalize { + var remapReport operationreport.Report + remapVariables = astnormalization.NewVariablesMapper().NormalizeOperation( + operation.Document(), e.config.schema.Document(), &remapReport, + ) + if remapReport.HasErrors() { + return remapReport + } + } + + // Validate user-supplied and extracted variables against the (remapped) operation. + // ValidateWithRemap translates renamed names back to originals for both JSON lookup + // and error messages, so users still see their declared variable names in errors. if len(operation.Variables) > 0 && operation.Variables[0] == '{' { validator := variablesvalidation.NewVariablesValidator(variablesvalidation.VariablesValidatorOptions{ ApolloCompatibilityFlags: e.apolloCompatibilityFlags, }) - if err := validator.Validate(operation.Document(), e.config.schema.Document(), operation.Variables); err != nil { + if err := validator.ValidateWithRemap(operation.Document(), e.config.schema.Document(), operation.Variables, remapVariables); err != nil { return err } } @@ -195,6 +211,7 @@ func (e *ExecutionEngine) Execute(ctx context.Context, operation *graphql.Reques execContext.setContext(ctx) execContext.setVariables(operation.Variables) execContext.setRequest(operation.InternalRequest()) + execContext.resolveContext.RemapVariables = remapVariables for i := range options { options[i](execContext) diff --git a/execution/engine/execution_engine_test.go b/execution/engine/execution_engine_test.go index 760c6d84db..34ae7212fe 100644 --- a/execution/engine/execution_engine_test.go +++ b/execution/engine/execution_engine_test.go @@ -1428,7 +1428,7 @@ func TestExecutionEngine_Execute(t *testing.T) { testNetHttpClient(t, roundTripperTestCase{ expectedHost: "example.com", expectedPath: "/", - expectedBody: `{"query":"query($heroNames: [String!]!){heroes(names: $heroNames)}","variables":{"heroNames":["Luke Skywalker","R2-D2"]}}`, + expectedBody: `{"query":"query($a: [String!]!){heroes(names: $a)}","variables":{"a":["Luke Skywalker","R2-D2"]}}`, sendResponseBody: `{"data":{"heroes":["Human","Droid"]}}`, sendStatusCode: 200, }), @@ -1494,7 +1494,7 @@ func TestExecutionEngine_Execute(t *testing.T) { testNetHttpClient(t, roundTripperTestCase{ expectedHost: "example.com", expectedPath: "/", - expectedBody: `{"query":"query($heroNames: [String!], $height: String){heroes(names: $heroNames, height: $height)}","variables":{"height":null}}`, + expectedBody: `{"query":"query($a: [String!], $b: String){heroes(names: $a, height: $b)}","variables":{"b":null}}`, sendResponseBody: `{"data":{"heroes":[]}}`, sendStatusCode: 200, }), @@ -1749,7 +1749,7 @@ func TestExecutionEngine_Execute(t *testing.T) { testNetHttpClient(t, roundTripperTestCase{ expectedHost: "example.com", expectedPath: "/", - expectedBody: `{"query":"query($ids: [Int]){charactersByIds(ids: $ids){name}}","variables":{"ids":[1]}}`, + expectedBody: `{"query":"query($a: [Int]){charactersByIds(ids: $a){name}}","variables":{"a":[1]}}`, sendResponseBody: `{"data":{"charactersByIds":[{"name": "Luke"}]}}`, sendStatusCode: 200, }), @@ -1879,7 +1879,7 @@ func TestExecutionEngine_Execute(t *testing.T) { testNetHttpClient(t, roundTripperTestCase{ expectedHost: "example.com", expectedPath: "/", - expectedBody: `{"query":"query($name: String!, $nameOptional: String){hero(name: $name) hero2: hero(name: $nameOptional)}","variables":{"nameOptional":"R2D2","name":"R2D2"}}`, + expectedBody: `{"query":"query($a: String!, $b: String){hero(name: $a) hero2: hero(name: $b)}","variables":{"b":"R2D2","a":"R2D2"}}`, sendResponseBody: `{"data":{"hero":"R2D2","hero2":"R2D2"}}`, sendStatusCode: 200, }), @@ -1942,7 +1942,7 @@ func TestExecutionEngine_Execute(t *testing.T) { testNetHttpClient(t, roundTripperTestCase{ expectedHost: "example.com", expectedPath: "/", - expectedBody: `{"query":"query($name: String!, $nameOptional: String){hero(name: $name) hero2: hero(name: $nameOptional)}","variables":{"nameOptional":"Skywalker","name":"Luke"}}`, + expectedBody: `{"query":"query($a: String!, $b: String){hero(name: $a) hero2: hero(name: $b)}","variables":{"b":"Skywalker","a":"Luke"}}`, sendResponseBody: `{"data":{"hero":"R2D2","hero2":"R2D2"}}`, sendStatusCode: 200, }), @@ -2004,7 +2004,7 @@ func TestExecutionEngine_Execute(t *testing.T) { testNetHttpClient(t, roundTripperTestCase{ expectedHost: "example.com", expectedPath: "/", - expectedBody: `{"query":"query($name: String!, $nameOptional: String!){hero: heroDefault(name: $name) hero2: heroDefault(name: $nameOptional) hero3: heroDefaultRequired(name: $name) hero4: heroDefaultRequired(name: $nameOptional)}","variables":{"nameOptional":"R2D2","name":"R2D2"}}`, + expectedBody: `{"query":"query($a: String!, $b: String!){hero: heroDefault(name: $a) hero2: heroDefault(name: $b) hero3: heroDefaultRequired(name: $a) hero4: heroDefaultRequired(name: $b)}","variables":{"b":"R2D2","a":"R2D2"}}`, sendResponseBody: `{"data":{"hero":"R2D2","hero2":"R2D2","hero3":"R2D2","hero4":"R2D2"}}`, sendStatusCode: 200, }),