diff --git a/v2/pkg/astnormalization/variables_mapper.go b/v2/pkg/astnormalization/variables_mapper.go new file mode 100644 index 000000000..f7afbbadc --- /dev/null +++ b/v2/pkg/astnormalization/variables_mapper.go @@ -0,0 +1,31 @@ +package astnormalization + +import ( + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +type VariablesMapper struct { + walker *astvisitor.Walker + variablesMappingVisitor *variablesMappingVisitor +} + +func NewVariablesMapper() *VariablesMapper { + walker := astvisitor.NewDefaultWalker() + mapper := remapVariables(&walker) + + return &VariablesMapper{ + walker: &walker, + variablesMappingVisitor: mapper, + } +} + +func (v *VariablesMapper) NormalizeOperation(operation, definition *ast.Document, report *operationreport.Report) map[string]string { + v.walker.Walk(operation, definition, report) + if report.HasErrors() { + return nil + } + + return v.variablesMappingVisitor.mapping +} diff --git a/v2/pkg/astnormalization/variables_mapper_test.go b/v2/pkg/astnormalization/variables_mapper_test.go new file mode 100644 index 000000000..64cd4d939 --- /dev/null +++ b/v2/pkg/astnormalization/variables_mapper_test.go @@ -0,0 +1,456 @@ +package astnormalization + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" + "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeprinter" + "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" +) + +func TestVariablesMapper(t *testing.T) { + definition := unsafeparser.ParseGraphqlDocumentStringWithBaseSchema(` + type Object { + id: ID! + name: String! + echo(value: String!): String! + echoTwo(value: String!): String! + copy(input: InputObject!): Object! + } + + input InputObject { + id: ID! + name: String! + } + + type Query { + object(id: ID!): Object + } + + type Mutation { + updateObject(name: String!): Object! + } + + type Subscription { + subscribe(id: ID!): Object! + }`) + + variablesMapper := NewVariablesMapper() + + normalizer := NewWithOpts( + WithRemoveNotMatchingOperationDefinitions(), + WithInlineFragmentSpreads(), + WithRemoveFragmentDefinitions(), + WithRemoveUnusedVariables(), + ) + variablesNormalizer := NewVariablesNormalizer() + + testCases := []struct { + name string + input string + output string + variablesMapping map[string]string + }{ + { + name: "1.1 Simple external variable (query)", + input: ` + query MyQuery($varOne: ID!) { + object(id: $varOne) { + name + } + }`, + output: ` + query MyQuery($a: ID!) { + object(id: $a) { + name + } + }`, + variablesMapping: map[string]string{ + "a": "varOne", + }, + }, + { + name: "1.2 Simple external variable (mutation)", + input: ` + mutation MyMutation($varOne: String!) { + updateObject(name: $varOne) { + name + } + }`, + output: ` + mutation MyMutation($a: String!) { + updateObject(name: $a) { + name + } + }`, + variablesMapping: map[string]string{ + "a": "varOne", + }, + }, + { + name: "1.3 Simple external variable (subscription)", + input: ` + subscription MySubscription($varOne: ID!) { + subscribe(id: $varOne) { + name + } + }`, + output: ` + subscription MySubscription($a: ID!) { + subscribe(id: $a) { + name + } + }`, + variablesMapping: map[string]string{ + "a": "varOne", + }, + }, + { + name: "2 Simple inline variable", + input: ` + query MyQuery { + object(id: "abc123") { + name + } + }`, + output: ` + query MyQuery($a: ID!) { + object(id: $a) { + name + } + }`, + variablesMapping: map[string]string{ + "a": "a", + }, + }, + { + name: "3.1 Colliding external variable", + input: ` + query MyQuery($a: ID!) { + object(id: $a) { + name + } + }`, + output: ` + query MyQuery($a: ID!) { + object(id: $a) { + name + } + }`, + variablesMapping: map[string]string{ + "a": "a", + }, + }, + { + name: "3.2 Colliding external variable used in 2 places", + input: ` + query MyQuery($a: String!) { + echo(id: $a) + echoTwo(id: $a) + }`, + output: ` + query MyQuery($a: String!) { + echo(id: $a) + echoTwo(id: $a) + }`, + variablesMapping: map[string]string{ + "a": "a", + }, + }, + { + name: "3.3 Colliding external variable along with inline values", + input: ` + query MyQuery($a: String!) { + object(id: 1) { + echo(value: "Hello World") + echoTwo(value: $a) + } + }`, + output: ` + query MyQuery($a: ID!, $b: String!, $c: String!) { + object(id: $a) { + echo(value: $b) + echoTwo(value: $c) + } + }`, + + variablesMapping: map[string]string{ + "a": "b", + "b": "c", + "c": "a", + }, + }, + { + name: "3.4 Colliding external variables", + input: ` + query MyQuery($b: String!, $e: String! $c: ID!) { + object(id: $c) { + echo(value: $e) + echoTwo(value: $b) + } + }`, + output: ` + query MyQuery($a: ID!, $b: String!, $c: String!) { + object(id: $a) { + echo(value: $b) + echoTwo(value: $c) + } + }`, + + variablesMapping: map[string]string{ + "a": "c", + "b": "e", + "c": "b", + }, + }, + { + name: "3.5 all inline values", + input: ` + query MyQuery { + object(id: 1) { + echo(value: "Hello") + echoTwo(value: "World") + } + }`, + output: ` + query MyQuery($a: ID!, $b: String!, $c: String!){ + object(id: $a) { + echo(value: $b) + echoTwo(value: $c) + } + }`, + + variablesMapping: map[string]string{ + "a": "a", + "b": "b", + "c": "c", + }, + }, + { + name: "4 Inline variable and external variable", + input: ` + query MyQuery($varOne: ID!) { + object(id: $varOne) { + name + echo(value: "Hello World!") + } + }`, + output: ` + query MyQuery($a: ID! $b: String!) { + object(id: $a) { + name + echo(value: $b) + } + }`, + + variablesMapping: map[string]string{ + "a": "varOne", + "b": "a", + }, + }, + { + name: "5.1 Multiple external variables", + input: ` + query MyQuery($varOne: ID! $varTwo: String!) { + object(id: $varOne) { + name + echo(value: $varTwo) + } + }`, + output: ` + query MyQuery($a: ID! $b: String!) { + object(id: $a) { + name + echo(value: $b) + } + }`, + variablesMapping: map[string]string{ + "a": "varOne", + "b": "varTwo", + }, + }, + { + name: "6 Multiple colliding external variables", + input: ` + query MyQuery($a: String! $b: ID!) { + object(id: $b) { + echo(value: $a) + name + } + }`, + output: ` + query MyQuery($a: ID! $b: String!) { + object(id: $a) { + echo(value: $b) + name + } + }`, + variablesMapping: map[string]string{ + "a": "b", + "b": "a", + }, + }, + { + name: "7 multiple inline variables", + input: ` + query MyQuery { + object(id: "abc123") { + echo(value: "Hello World!") + name + } + }`, + output: ` + query MyQuery($a: ID! $b: String!) { + object(id: $a) { + echo(value: $b) + name + } + }`, + variablesMapping: map[string]string{ + "a": "a", + "b": "b", + }, + }, + { + name: "8 Inline variable with multiple colliding external variables", + input: ` + query MyQuery($a: ID! $b: String! ) { + object(id: $a) { + name + copy(input: { id: "abc123", name: "MyObject"}), + echo(value: $b) + } + }`, + output: ` + query MyQuery($a: ID! $b: InputObject! $c: String!) { + object(id: $a) { + name + copy(input: $b), + echo(value: $c) + } + }`, + variablesMapping: map[string]string{ + "a": "a", + "b": "c", + "c": "b", + }, + }, + { + name: "9 Inline variable with multiple colliding external variables", + input: ` + query MyQuery($a: ID! $b: String! $c: String! ) { + object(id: $a) { + name + copy(input: { id: "abc123", name: $b}) + echo(value: $c) + } + }`, + output: ` + query MyQuery($a: ID! $b: InputObject! $c: String! ) { + object(id: $a) { + name + copy(input: $b) + echo(value: $c) + } + }`, + variablesMapping: map[string]string{ + "a": "a", + "b": "d", + "c": "c", + }, + }, + { + name: "10 Colliding external variable with multiple inline variables", + input: ` + query MyQuery($a: ID!) { + object(id: $a) { + name + copy(input: { id: "abc123", name: "MyObject" }) + echo(value: "Hello World") + } + }`, + output: ` + query MyQuery($a: ID! $b: InputObject! $c: String! ) { + object(id: $a) { + name + copy(input: $b) + echo(value: $c) + } + }`, + variablesMapping: map[string]string{ + "a": "a", + "b": "b", + "c": "c", + }, + }, + { + name: "11 Reused external variable", + input: ` + query MyQuery($varOne: String!) { + object(id: 1) { + echo(value: $varOne) + echoTwo(value: $varOne) + } + }`, + output: ` + query MyQuery($a: ID!, $b: String!) { + object(id: $a) { + echo(value: $b) + echoTwo(value: $b) + } + }`, + variablesMapping: map[string]string{ + "a": "a", + "b": "varOne", + }, + }, + { + name: "12 Reused inline value", + input: ` + query MyQuery { + object(id: 1) { + echo(value: 12) + echoTwo(value: 12) + } + }`, + output: ` + query MyQuery ($a: ID!, $b: String!) { + object(id: $a) { + echo(value: $b) + echoTwo(value: $b) + } + }`, + variablesMapping: map[string]string{ + "a": "a", + "b": "b", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + operation := unsafeparser.ParseGraphqlDocumentString(tc.input) + report := &operationreport.Report{} + + normalizer.NormalizeNamedOperation(&operation, &definition, operation.OperationDefinitionNameBytes(0), report) + require.False(t, report.HasErrors()) + fmt.Println("normalized", unsafeprinter.PrettyPrint(&operation)) + + variablesNormalizer.NormalizeOperation(&operation, &definition, report) + require.False(t, report.HasErrors()) + fmt.Println("variables normalized", unsafeprinter.PrettyPrint(&operation)) + + mapping := variablesMapper.NormalizeOperation(&operation, &definition, report) + require.False(t, report.HasErrors()) + + expectedOut := unsafeprinter.Prettify(tc.output) + printedOperation := unsafeprinter.PrettyPrint(&operation) + assert.Equal(t, expectedOut, printedOperation) + assert.Equal(t, tc.variablesMapping, mapping) + }) + } +} diff --git a/v2/pkg/astnormalization/variables_mapping.go b/v2/pkg/astnormalization/variables_mapping.go new file mode 100644 index 000000000..4e3df1071 --- /dev/null +++ b/v2/pkg/astnormalization/variables_mapping.go @@ -0,0 +1,183 @@ +package astnormalization + +import ( + "cmp" + "math" + "slices" + + "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" +) + +func remapVariables(walker *astvisitor.Walker) *variablesMappingVisitor { + visitor := &variablesMappingVisitor{ + Walker: walker, + } + walker.RegisterDocumentVisitor(visitor) + walker.RegisterEnterOperationVisitor(visitor) + walker.RegisterEnterArgumentVisitor(visitor) + return visitor +} + +// VariablesMapper is a visitor which remaps variables in the operation to have more cache hits for the queries of the same shape +// but with different variables/inline values combinations +// e.g. +// +// query MyQuery($a: String!, $b: String!) { +// field(a: $a, b: $b) +// } +// +// query MyQuery($e: String!, $d: String!) { +// field(a: $e, b: $d) +// } +// +// query MyQuery($b: String!, $a: String!) { +// field(a: $a, b: $b) +// } +// +// query MyQuery { +// field(a: "a", b: "b") +// } +// +// query MyQuery($b: String!) { +// field(a: "a", b: $b) +// } +// +// query MyQuery($a: String!) { +// field(a: $a, b: "b") +// } +// +// All of the example queries above will be normalized to the same query: +// +// query MyQuery($a: String!, $b: String!) { +// field(a: $a, b: $b) +// } +// +// The important consideration - the main requirement is to have same amount of variables/inline values used in a query in the same places +// otherwise the queries will be considered different +// e.g. field(a: "a", b: "a") will be the same as field(a: $a, b: $a) but different from field(a: $a, b: $b) or field(a: $a, b: $a) +type variablesMappingVisitor struct { + *astvisitor.Walker + operation, definition *ast.Document + mapping map[string]string + variables []*variableItem + operationRef int +} + +type variableItem struct { + variableName string + valueRefs []int + variableDefinitionRef int +} + +func (v *variablesMappingVisitor) LeaveDocument(operation, definition *ast.Document) { + for _, variableItem := range v.variables { + mappingName := v.generateUnusedVariableMappingName() + v.mapping[string(mappingName)] = variableItem.variableName + + newVariableName := v.operation.Input.AppendInputBytes(mappingName) + + // set new variable name for all variable values + for _, variableValueRef := range variableItem.valueRefs { + v.operation.VariableValues[variableValueRef].Name = newVariableName + } + + // set new variable name for variable definition + v.operation.VariableValues[v.operation.VariableDefinitions[variableItem.variableDefinitionRef].VariableValue.Ref].Name = newVariableName + } + + // After remapping the variables we will get the sequential variable names, which could be stable sorted + // And it will be important to sort the variable definitions of operation by name to ensure that the order is deterministic + // which allows to produce the same query string for the queries with different order of variables used in the same places + // e.g. + // query MyQuery($e: String!, $d: String!) { + // field(a: $e, b: $d) + // } + // + // query MyQuery($b: String!, $a: String!) { + // field(a: $a, b: $b) + // } + // both of this queries will be normalized to the same query: + // query MyQuery($a: String!, $b: String!) { + // field(a: $a, b: $b) + // } + slices.SortFunc(v.operation.OperationDefinitions[v.operationRef].VariableDefinitions.Refs, func(i, j int) int { + return cmp.Compare( + v.operation.VariableValueNameString(v.operation.VariableDefinitions[i].VariableValue.Ref), + v.operation.VariableValueNameString(v.operation.VariableDefinitions[j].VariableValue.Ref), + ) + }) +} + +func (v *variablesMappingVisitor) EnterArgument(ref int) { + if v.operation.Arguments[ref].Value.Kind != ast.ValueKindVariable { + return + } + if len(v.Ancestors) == 0 || v.Ancestors[0].Kind != ast.NodeKindOperationDefinition { + return + } + + varValueRef := v.operation.Arguments[ref].Value.Ref + varNameBytes := v.operation.VariableValueNameBytes(varValueRef) + + variableDefinitionRef, exists := v.operation.VariableDefinitionByNameAndOperation(v.operationRef, varNameBytes) + if !exists { + return + } + + // explicitly convert to string to convert unsafe + varName := string(varNameBytes) + + // here we collect occurrences of the variables in the operation in depth-first order + // if the variable is the same we save the ref to the variable value + // if we haven't seen the variable - we save the ref to the variable definition and its name + idx := slices.IndexFunc(v.variables, func(i *variableItem) bool { + return i.variableName == varName + }) + if idx == -1 { + v.variables = append(v.variables, &variableItem{ + variableName: varName, + valueRefs: []int{varValueRef}, + variableDefinitionRef: variableDefinitionRef, + }) + return + } + + v.variables[idx].valueRefs = append(v.variables[idx].valueRefs, varValueRef) +} + +func (v *variablesMappingVisitor) EnterDocument(operation, definition *ast.Document) { + v.operation, v.definition = operation, definition + v.mapping = make(map[string]string, len(operation.VariableDefinitions)) + v.variables = make([]*variableItem, 0, len(operation.VariableDefinitions)) +} + +func (v *variablesMappingVisitor) EnterOperationDefinition(ref int) { + v.operationRef = ref +} + +const alphabet = `abcdefghijklmnopqrstuvwxyz` + +// generateUnusedVariableMappingName generates a new name for the variable mapping +// right now it will generate the next variable names +// 0-25: a, b, c, ..., z +// 26-51: aa, bb, cc, ..., zz +// 52-77: aaa, bbb, ccc, ..., zzz +func (v *variablesMappingVisitor) generateUnusedVariableMappingName() []byte { + var i, k int64 + + for i = 1; i < math.MaxInt16; i++ { + out := make([]byte, i) + for j := range alphabet { + for k = 0; k < i; k++ { + out[k] = alphabet[j] + } + _, exists := v.mapping[string(out)] + if !exists { + return out + } + } + } + + return nil +} diff --git a/v2/pkg/astvisitor/visitor.go b/v2/pkg/astvisitor/visitor.go index 18ad0c6d5..b256dabb1 100644 --- a/v2/pkg/astvisitor/visitor.go +++ b/v2/pkg/astvisitor/visitor.go @@ -104,6 +104,11 @@ func NewWalker(ancestorSize int) Walker { } } +// NewDefaultWalker returns a fully initialized Walker with the default ancestor size of 8 +func NewDefaultWalker() Walker { + return NewWalker(8) +} + var ( walkerPool = sync.Pool{} ) diff --git a/v2/pkg/engine/resolve/context.go b/v2/pkg/engine/resolve/context.go index 68871cb87..8e97d714d 100644 --- a/v2/pkg/engine/resolve/context.go +++ b/v2/pkg/engine/resolve/context.go @@ -9,6 +9,7 @@ import ( "time" "github.com/wundergraph/astjson" + "github.com/wundergraph/graphql-go-tools/v2/pkg/engine/datasource/httpclient" ) @@ -18,6 +19,7 @@ type Context struct { Files []httpclient.File Request Request RenameTypeNames []RenameTypeName + RemapVariables map[string]string TracingOptions TraceOptions RateLimitOptions RateLimitOptions ExecutionOptions ExecutionOptions @@ -146,6 +148,14 @@ func (c *Context) clone(ctx context.Context) *Context { cpy.Files = append([]httpclient.File(nil), c.Files...) cpy.Request.Header = c.Request.Header.Clone() cpy.RenameTypeNames = append([]RenameTypeName(nil), c.RenameTypeNames...) + + if c.RemapVariables != nil { + cpy.RemapVariables = make(map[string]string, len(c.RemapVariables)) + for k, v := range c.RemapVariables { + cpy.RemapVariables[k] = v + } + } + return &cpy } @@ -155,6 +165,7 @@ func (c *Context) Free() { c.Files = nil c.Request.Header = nil c.RenameTypeNames = nil + c.RemapVariables = nil c.TracingOptions.DisableAll() c.Extensions = nil c.subgraphErrors = nil diff --git a/v2/pkg/engine/resolve/inputtemplate.go b/v2/pkg/engine/resolve/inputtemplate.go index 5b661d677..0706a7483 100644 --- a/v2/pkg/engine/resolve/inputtemplate.go +++ b/v2/pkg/engine/resolve/inputtemplate.go @@ -121,7 +121,15 @@ func (i *InputTemplate) renderResolvableObjectVariable(ctx context.Context, obje } func (i *InputTemplate) renderContextVariable(ctx *Context, segment TemplateSegment, preparedInput *bytes.Buffer) (variableWasUndefined bool, err error) { - value := ctx.Variables.Get(segment.VariableSourcePath...) + 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...) if value == nil { _, _ = preparedInput.Write(literal.NULL) return true, nil diff --git a/v2/pkg/engine/resolve/inputtemplate_test.go b/v2/pkg/engine/resolve/inputtemplate_test.go index b78e28ae5..c78a680f3 100644 --- a/v2/pkg/engine/resolve/inputtemplate_test.go +++ b/v2/pkg/engine/resolve/inputtemplate_test.go @@ -11,6 +11,51 @@ import ( "github.com/wundergraph/astjson" ) +func TestInputTemplate_VariablesRemapping(t *testing.T) { + runTest := func(t *testing.T, variables string, sourcePath []string, remap map[string]string, expectErr bool, expected string) { + t.Helper() + + template := InputTemplate{ + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: sourcePath, + Renderer: NewJSONVariableRenderer(), + }, + }, + } + ctx := &Context{ + Variables: astjson.MustParseBytes([]byte(variables)), + RemapVariables: remap, + } + buf := &bytes.Buffer{} + err := template.Render(ctx, nil, buf) + if expectErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + out := buf.String() + assert.Equal(t, expected, out) + } + + t.Run("maping", func(t *testing.T) { + t.Run("a to foo", func(t *testing.T) { + runTest(t, `{"foo":"bar"}`, []string{"a"}, map[string]string{"a": "foo"}, false, `"bar"`) + }) + t.Run("a to a", func(t *testing.T) { + runTest(t, `{"a":true}`, []string{"a"}, map[string]string{"a": "a"}, false, "true") + }) + t.Run("no mapping", func(t *testing.T) { + runTest(t, `{"a":true}`, []string{"a"}, map[string]string{}, false, "true") + }) + t.Run("no variable value", func(t *testing.T) { + runTest(t, `{}`, []string{"a"}, map[string]string{"a": "x"}, false, `{"undefined":["a"]}`) + }) + }) +} + func TestInputTemplate_Render(t *testing.T) { runTest := func(t *testing.T, initRenderer initTestVariableRenderer, variables string, sourcePath []string, expectErr bool, expected string) { t.Helper() diff --git a/v2/pkg/engine/resolve/resolve_test.go b/v2/pkg/engine/resolve/resolve_test.go index 56ce79859..54727feac 100644 --- a/v2/pkg/engine/resolve/resolve_test.go +++ b/v2/pkg/engine/resolve/resolve_test.go @@ -4692,6 +4692,80 @@ func TestResolver_WithHeader(t *testing.T) { } } +func TestResolver_WithVariableRemapping(t *testing.T) { + cases := []struct { + name, variable string + remap map[string]string + variables *astjson.Value + expectedOutput string + }{ + {"a to foo", "a", map[string]string{"a": "foo"}, astjson.MustParseBytes([]byte(`{"foo":"Wunderbar"}`)), `Wunderbar`}, + {"a to a", "a", map[string]string{"a": "a"}, astjson.MustParseBytes([]byte(`{"a":"WunderWunderbar"}`)), `WunderWunderbar`}, + {"no mapping", "foo", map[string]string{}, astjson.MustParseBytes([]byte(`{"foo":"BarDeWunder"}`)), `BarDeWunder`}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rCtx, cancel := context.WithCancel(context.Background()) + defer cancel() + resolver := newResolver(rCtx) + + ctx := &Context{ + ctx: context.Background(), + Variables: tc.variables, + RemapVariables: tc.remap, + } + + ctrl := gomock.NewController(t) + fakeService := NewMockDataSource(ctrl) + fakeService.EXPECT(). + Load(gomock.Any(), gomock.Any(), gomock.AssignableToTypeOf(&bytes.Buffer{})). + Do(func(ctx context.Context, input []byte, w io.Writer) (err error) { + actual := string(input) + assert.Equal(t, tc.expectedOutput, actual) + _, err = w.Write([]byte(`{"bar":"baz"}`)) + return + }). + Return(nil) + + out := &bytes.Buffer{} + res := &GraphQLResponse{ + Info: &GraphQLResponseInfo{ + OperationType: ast.OperationTypeQuery, + }, + Fetches: SingleWithPath(&SingleFetch{ + FetchConfiguration: FetchConfiguration{ + DataSource: fakeService, + }, + InputTemplate: InputTemplate{ + Segments: []TemplateSegment{ + { + SegmentType: VariableSegmentType, + VariableKind: ContextVariableKind, + VariableSourcePath: []string{tc.variable}, + Renderer: NewPlainVariableRenderer(), + }, + }, + }, + }, "query"), + Data: &Object{ + Fields: []*Field{ + { + Name: []byte("bar"), + Value: &String{ + Path: []string{"bar"}, + }, + }, + }, + }, + } + _, err := resolver.ResolveGraphQLResponse(ctx, res, nil, out) + assert.NoError(t, err) + assert.Equal(t, `{"data":{"bar":"baz"}}`, out.String()) + }) + } +} + type SubscriptionRecorder struct { buf *bytes.Buffer messages []string diff --git a/v2/pkg/variablesvalidation/variablesvalidation.go b/v2/pkg/variablesvalidation/variablesvalidation.go index 641003370..67da38ad9 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/errorcodes" "github.com/wundergraph/graphql-go-tools/v2/pkg/federation" @@ -81,6 +82,11 @@ func NewVariablesValidator(options VariablesValidatorOptions) *VariablesValidato } } +func (v *VariablesValidator) ValidateWithRemap(operation, definition *ast.Document, variables []byte, variablesMap map[string]string) error { + v.visitor.variablesMap = variablesMap + return v.Validate(operation, definition, variables) +} + func (v *VariablesValidator) Validate(operation, definition *ast.Document, variables []byte) error { v.visitor.definition = definition v.visitor.operation = operation @@ -106,6 +112,7 @@ type variablesVisitor struct { currentVariableValue *astjson.Value path []pathItem opts VariablesValidatorOptions + variablesMap map[string]string } func (v *variablesVisitor) renderPath() string { @@ -159,7 +166,14 @@ func (v *variablesVisitor) EnterVariableDefinition(ref int) { varTypeRef := v.operation.VariableDefinitions[ref].Type varName := v.operation.VariableValueNameBytes(v.operation.VariableDefinitions[ref].VariableValue.Ref) - value := v.variables.Get(unsafebytes.BytesToString(varName)) + variableNameStr := unsafebytes.BytesToString(varName) + if v.variablesMap != nil { + if mappedName, ok := v.variablesMap[variableNameStr]; ok { + variableNameStr = mappedName + } + } + + value := v.variables.Get(variableNameStr) v.path = v.path[:0] v.pushObjectPath(varName)