-
Notifications
You must be signed in to change notification settings - Fork 158
feat: add field argument mapping #1373
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 20 commits
ce23ea5
1ebb2c3
ea5c630
73a4ac5
cd47b12
1b8bddc
fdf63d0
a85a37b
546a496
e2c713b
75236fc
e4c44d3
f629d8d
9877606
94178d4
15dbb3f
f7a1c98
40a7750
0fbb313
1166211
1e8d2a5
7d265d7
a43dae9
2462573
0d27f89
55c1a82
f4e3312
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,219 @@ | ||
| package ast_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
|
|
||
| "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" | ||
| ) | ||
|
|
||
| func TestPath_DotDelimitedString(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| path ast.Path | ||
| want string | ||
| wantNoRef string | ||
| }{ | ||
| { | ||
| name: "returns operation type for root query path", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| }, | ||
| want: "query", | ||
| wantNoRef: "query", | ||
| }, | ||
| { | ||
| name: "returns operation type for root mutation path", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("mutation")}, | ||
| }, | ||
| want: "mutation", | ||
| wantNoRef: "mutation", | ||
| }, | ||
| { | ||
| name: "returns operation type for root subscription path", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("subscription")}, | ||
| }, | ||
| want: "subscription", | ||
| wantNoRef: "subscription", | ||
| }, | ||
| { | ||
| name: "converts empty field name to query as fallback", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("")}, | ||
| }, | ||
| want: "query", | ||
| wantNoRef: "query", | ||
| }, | ||
| { | ||
| name: "joins operation and field with dot separator", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("user")}, | ||
| }, | ||
| want: "query.user", | ||
| wantNoRef: "query.user", | ||
| }, | ||
| { | ||
| name: "nested query fields contain all elements in path", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("user")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("name")}, | ||
| }, | ||
| want: "query.user.name", | ||
| wantNoRef: "query.user.name", | ||
| }, | ||
| { | ||
| name: "nested mutation fields contain all elements in path", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("mutation")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("createUser")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("id")}, | ||
| }, | ||
| want: "mutation.createUser.id", | ||
| wantNoRef: "mutation.createUser.id", | ||
| }, | ||
| { | ||
| name: "nested subscription fields contain all elements in path", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("subscription")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("userUpdated")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("name")}, | ||
| }, | ||
| want: "subscription.userUpdated.name", | ||
| wantNoRef: "subscription.userUpdated.name", | ||
| }, | ||
| { | ||
| name: "includes field aliases in path", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("myUser")}, // alias is stored in path | ||
| {Kind: ast.FieldName, FieldName: []byte("email")}, | ||
| }, | ||
| want: "query.myUser.email", | ||
| wantNoRef: "query.myUser.email", | ||
| }, | ||
| { | ||
| name: "array indexes are represented as numbers", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("users")}, | ||
| {Kind: ast.ArrayIndex, ArrayIndex: 0}, | ||
| {Kind: ast.FieldName, FieldName: []byte("name")}, | ||
| }, | ||
| want: "query.users.0.name", | ||
| wantNoRef: "query.users.0.name", | ||
| }, | ||
| { | ||
| name: "multiple array indexes are all included in sequence", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("matrix")}, | ||
| {Kind: ast.ArrayIndex, ArrayIndex: 0}, | ||
| {Kind: ast.ArrayIndex, ArrayIndex: 1}, | ||
| }, | ||
| want: "query.matrix.0.1", | ||
| wantNoRef: "query.matrix.0.1", | ||
| }, | ||
| { | ||
| name: "inline fragments are prefixed with dollar sign and include fragment ref", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("node")}, | ||
| {Kind: ast.InlineFragmentName, FieldName: []byte("User"), FragmentRef: 1}, | ||
| {Kind: ast.FieldName, FieldName: []byte("name")}, | ||
| }, | ||
| want: "query.node.$1User.name", | ||
| wantNoRef: "query.node.$User.name", | ||
| }, | ||
| { | ||
| name: "multiple inline fragments each include their own ref number", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("search")}, | ||
| {Kind: ast.InlineFragmentName, FieldName: []byte("User"), FragmentRef: 1}, | ||
| {Kind: ast.FieldName, FieldName: []byte("profile")}, | ||
| {Kind: ast.InlineFragmentName, FieldName: []byte("PublicProfile"), FragmentRef: 2}, | ||
| {Kind: ast.FieldName, FieldName: []byte("bio")}, | ||
| }, | ||
| want: "query.search.$1User.profile.$2PublicProfile.bio", | ||
| wantNoRef: "query.search.$User.profile.$PublicProfile.bio", | ||
| }, | ||
| { | ||
| name: "inline fragments work in subscription operations", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("subscription")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("messageAdded")}, | ||
| {Kind: ast.InlineFragmentName, FieldName: []byte("TextMessage"), FragmentRef: 1}, | ||
| {Kind: ast.FieldName, FieldName: []byte("text")}, | ||
| }, | ||
| want: "subscription.messageAdded.$1TextMessage.text", | ||
| wantNoRef: "subscription.messageAdded.$TextMessage.text", | ||
| }, | ||
| { | ||
| name: "combines fields, array indexes, and fragments in correct order", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("items")}, | ||
| {Kind: ast.ArrayIndex, ArrayIndex: 0}, | ||
| {Kind: ast.InlineFragmentName, FieldName: []byte("Product"), FragmentRef: 1}, | ||
| {Kind: ast.FieldName, FieldName: []byte("variants")}, | ||
| {Kind: ast.ArrayIndex, ArrayIndex: 2}, | ||
| {Kind: ast.FieldName, FieldName: []byte("price")}, | ||
| }, | ||
| want: "query.items.0.$1Product.variants.2.price", | ||
| wantNoRef: "query.items.0.$Product.variants.2.price", | ||
| }, | ||
| { | ||
| name: "handles deeply nested paths with multiple fragments and arrays", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("organization")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("teams")}, | ||
| {Kind: ast.ArrayIndex, ArrayIndex: 1}, | ||
| {Kind: ast.InlineFragmentName, FieldName: []byte("EngineeringTeam"), FragmentRef: 1}, | ||
| {Kind: ast.FieldName, FieldName: []byte("members")}, | ||
| {Kind: ast.ArrayIndex, ArrayIndex: 0}, | ||
| {Kind: ast.InlineFragmentName, FieldName: []byte("Developer"), FragmentRef: 2}, | ||
| {Kind: ast.FieldName, FieldName: []byte("languages")}, | ||
| }, | ||
| want: "query.organization.teams.1.$1EngineeringTeam.members.0.$2Developer.languages", | ||
| wantNoRef: "query.organization.teams.1.$EngineeringTeam.members.0.$Developer.languages", | ||
| }, | ||
| { | ||
| name: "combines aliases and inline fragments", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("mutation")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("myUser")}, // aliased field | ||
| {Kind: ast.InlineFragmentName, FieldName: []byte("AdminUser"), FragmentRef: 1}, | ||
| {Kind: ast.FieldName, FieldName: []byte("permissions")}, | ||
| }, | ||
| want: "mutation.myUser.$1AdminUser.permissions", | ||
| wantNoRef: "mutation.myUser.$AdminUser.permissions", | ||
| }, | ||
| { | ||
| name: "zero fragment refs are included in output", | ||
| path: ast.Path{ | ||
| {Kind: ast.FieldName, FieldName: []byte("query")}, | ||
| {Kind: ast.FieldName, FieldName: []byte("entity")}, | ||
| {Kind: ast.InlineFragmentName, FieldName: []byte("Node"), FragmentRef: 0}, | ||
| {Kind: ast.FieldName, FieldName: []byte("id")}, | ||
| }, | ||
| want: "query.entity.$0Node.id", | ||
| wantNoRef: "query.entity.$Node.id", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := tt.path.DotDelimitedString() | ||
| assert.Equal(t, tt.want, got) | ||
|
|
||
| gotNoRef := tt.path.DotDelimitedStringWithoutFragmentRefs() | ||
| assert.Equal(t, tt.wantNoRef, gotNoRef) | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -70,7 +70,6 @@ package astnormalization | |
|
|
||
| import ( | ||
| "github.com/wundergraph/graphql-go-tools/v2/pkg/ast" | ||
| "github.com/wundergraph/graphql-go-tools/v2/pkg/astnormalization/uploads" | ||
| "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" | ||
| "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" | ||
| ) | ||
|
|
@@ -245,7 +244,8 @@ func (o *OperationNormalizer) setupOperationWalkers() { | |
|
|
||
| if o.options.extractVariables { | ||
| extractVariablesWalker := astvisitor.NewWalkerWithID(8, "ExtractVariables") | ||
| extractVariables(&extractVariablesWalker) | ||
| // disabling field arg mapping as it's only necessary on the variable normalizer | ||
| extractVariables(&extractVariablesWalker, false) | ||
| o.operationWalkers = append(o.operationWalkers, walkerStage{ | ||
| name: "extractVariables", | ||
| walker: &extractVariablesWalker, | ||
|
|
@@ -353,7 +353,10 @@ type VariablesNormalizer struct { | |
| variablesExtractionVisitor *variablesExtractionVisitor | ||
| } | ||
|
|
||
| func NewVariablesNormalizer() *VariablesNormalizer { | ||
| // NewVariablesNormalizer creates a new variable normalizer. | ||
| // If withFieldArgMapping is true then the normalizer creates a | ||
| // mapping of field arguments as a "side product" when NormalizeOperation is called. | ||
| func NewVariablesNormalizer(withFieldArgMapping bool) *VariablesNormalizer { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what do you think about introducing either an options or config argument? e.g. or just
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed |
||
| // delete unused modifying variables refs, | ||
| // so it is safer to run it sequentially with the extraction | ||
| thirdDeleteUnused := astvisitor.NewWalkerWithID(8, "DeleteUnusedVariables") | ||
|
|
@@ -367,7 +370,7 @@ func NewVariablesNormalizer() *VariablesNormalizer { | |
| detectVariableUsage(&firstDetectUnused, del) | ||
|
|
||
| secondExtract := astvisitor.NewWalkerWithID(8, "ExtractVariables") | ||
| variablesExtractionVisitor := extractVariables(&secondExtract) | ||
| variablesExtractionVisitor := extractVariables(&secondExtract, withFieldArgMapping) | ||
| extractVariablesDefaultValue(&secondExtract) | ||
|
|
||
| fourthCoerce := astvisitor.NewWalkerWithID(0, "VariablesCoercion") | ||
|
|
@@ -382,22 +385,32 @@ func NewVariablesNormalizer() *VariablesNormalizer { | |
| } | ||
| } | ||
|
|
||
| func (v *VariablesNormalizer) NormalizeOperation(operation, definition *ast.Document, report *operationreport.Report) []uploads.UploadPathMapping { | ||
| // NormalizeOperation processes GraphQL operation variables. | ||
| // It detects and removes unused variables, extracts variables from inline values | ||
| // and coerces variable types. It modifies the operation in place and | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it coerses not types, but variable values, we could also mention what part of graphql spec it is https://spec.graphql.org/September2025/#sec-Coercing-Variable-Values
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
| // returns metadata including field argument mappings and upload paths. | ||
| // Field argument mapping is done only when v is configured to do so via NewVariablesNormalizer, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
or when collecting arguments mapping enabled
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is what "when v is configured to do so via NewVariablesNormalizer" wanted to express. I'll rephrase it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done |
||
| // else VariablesNormalizerResult.FieldArgumentMapping will be nil. | ||
| // Any errors encountered during normalization are reported via the report parameter. | ||
| func (v *VariablesNormalizer) NormalizeOperation(operation, definition *ast.Document, report *operationreport.Report) VariablesNormalizerResult { | ||
| v.firstDetectUnused.Walk(operation, definition, report) | ||
| if report.HasErrors() { | ||
| return nil | ||
| return VariablesNormalizerResult{} | ||
| } | ||
| v.secondExtract.Walk(operation, definition, report) | ||
| if report.HasErrors() { | ||
| return nil | ||
| return VariablesNormalizerResult{} | ||
| } | ||
| v.thirdDeleteUnused.Walk(operation, definition, report) | ||
| if report.HasErrors() { | ||
| return nil | ||
| return VariablesNormalizerResult{} | ||
| } | ||
| v.fourthCoerce.Walk(operation, definition, report) | ||
|
|
||
| return v.variablesExtractionVisitor.uploadsPath | ||
| return VariablesNormalizerResult{ | ||
| UploadsMapping: v.variablesExtractionVisitor.uploadsPath, | ||
| FieldArgumentMapping: v.variablesExtractionVisitor.fieldArgumentMapping.result, | ||
| } | ||
| } | ||
|
|
||
| type fragmentCycleVisitor struct { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.