Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ce23ea5
feat: store field arg maps to variable normalization
dkorittki Jan 26, 2026
1ebb2c3
chore: reduce two functions to one
dkorittki Jan 27, 2026
ea5c630
fix: handle fragments
dkorittki Jan 28, 2026
73a4ac5
fix: support literal values
dkorittki Jan 29, 2026
cd47b12
chore: move mapping to operation normalizer
dkorittki Jan 29, 2026
1b8bddc
Revert "chore: move mapping to operation normalizer"
dkorittki Jan 29, 2026
fdf63d0
fix: expose literal values as astjson object
dkorittki Jan 30, 2026
a85a37b
chore: remove fragment support
dkorittki Feb 2, 2026
546a496
feat: make field arg mapping configurable
dkorittki Feb 2, 2026
e2c713b
Merge branch 'master'
dkorittki Feb 2, 2026
75236fc
Merge branch 'master'
dkorittki Feb 9, 2026
e4c44d3
fix: record mapping for already extracted literals
dkorittki Feb 10, 2026
f629d8d
fix: use valid operation for test
dkorittki Feb 10, 2026
9877606
fix: support inline fragments
dkorittki Feb 11, 2026
94178d4
Merge branch 'master'
dkorittki Feb 12, 2026
15dbb3f
fix: add missing argument after main merge
dkorittki Feb 12, 2026
f7a1c98
chore: preserve original method signature
dkorittki Feb 12, 2026
40a7750
chore: add path string creation tests
dkorittki Feb 12, 2026
0fbb313
chore: add godoc to variable normalizer methods
dkorittki Feb 12, 2026
1166211
chore: use string concat on path build instead of Sprintf
dkorittki Feb 12, 2026
1e8d2a5
Merge branch 'master' into dominik/eng-8826-add-field-argument-mappin…
dkorittki Feb 19, 2026
7d265d7
chore: update godoc/comments + result type name
dkorittki Feb 19, 2026
a43dae9
chore: seperate file for VariablesNormalizer
dkorittki Feb 19, 2026
2462573
chore: Add options type for VariablesNormalizer
dkorittki Feb 19, 2026
0d27f89
fix: fix broken tests
dkorittki Feb 19, 2026
55c1a82
Merge branch 'master' into dominik/eng-8826-add-field-argument-mappin…
dkorittki Feb 23, 2026
f4e3312
Merge branch 'master' into dominik/eng-8826-add-field-argument-mappin…
dkorittki Mar 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion v2/pkg/ast/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,20 @@ func (p Path) String() string {
return out
}

// DotDelimitedString returns a dot-separated string representation of the path.
// Inline fragments include their reference number, e.g., "query.user.$1User.name".
func (p Path) DotDelimitedString() string {
return p.dotDelimitedString(true)
}

// DotDelimitedStringWithoutFragmentRefs returns a dot-separated string representation of the path.
// Unlike DotDelimitedString, inline fragments omit their reference number,
// e.g., "query.user.$User.name".
func (p Path) DotDelimitedStringWithoutFragmentRefs() string {
return p.dotDelimitedString(false)
}

func (p Path) dotDelimitedString(includeFragmentRefs bool) string {
builder := strings.Builder{}

toGrow := 0
Expand Down Expand Up @@ -160,7 +173,9 @@ func (p Path) DotDelimitedString() string {
}
case InlineFragmentName:
builder.WriteString(InlineFragmentPathPrefix)
builder.WriteString(strconv.Itoa(p[i].FragmentRef))
if includeFragmentRefs {
builder.WriteString(strconv.Itoa(p[i].FragmentRef))
}
builder.WriteString(unsafebytes.BytesToString(p[i].FieldName))
}
}
Expand Down
219 changes: 219 additions & 0 deletions v2/pkg/ast/path_test.go
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)
})
}
}
59 changes: 2 additions & 57 deletions v2/pkg/astnormalization/astnormalization.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -345,61 +345,6 @@ func (o *OperationNormalizer) NormalizeNamedOperation(operation, definition *ast
}
}

type VariablesNormalizer struct {
firstDetectUnused *astvisitor.Walker
secondExtract *astvisitor.Walker
thirdDeleteUnused *astvisitor.Walker
fourthCoerce *astvisitor.Walker
variablesExtractionVisitor *variablesExtractionVisitor
}

func NewVariablesNormalizer() *VariablesNormalizer {
// delete unused modifying variables refs,
// so it is safer to run it sequentially with the extraction
thirdDeleteUnused := astvisitor.NewWalkerWithID(8, "DeleteUnusedVariables")
del := deleteUnusedVariables(&thirdDeleteUnused)

// register variable usage detection on the first stage
// and pass usage information to the deletion visitor
// so it keeps variables that are defined but not used at all
// ensuring that validation can still catch them
firstDetectUnused := astvisitor.NewWalkerWithID(8, "DetectVariableUsage")
detectVariableUsage(&firstDetectUnused, del)

secondExtract := astvisitor.NewWalkerWithID(8, "ExtractVariables")
variablesExtractionVisitor := extractVariables(&secondExtract)
extractVariablesDefaultValue(&secondExtract)

fourthCoerce := astvisitor.NewWalkerWithID(0, "VariablesCoercion")
inputCoercionForList(&fourthCoerce)

return &VariablesNormalizer{
firstDetectUnused: &firstDetectUnused,
secondExtract: &secondExtract,
thirdDeleteUnused: &thirdDeleteUnused,
fourthCoerce: &fourthCoerce,
variablesExtractionVisitor: variablesExtractionVisitor,
}
}

func (v *VariablesNormalizer) NormalizeOperation(operation, definition *ast.Document, report *operationreport.Report) []uploads.UploadPathMapping {
v.firstDetectUnused.Walk(operation, definition, report)
if report.HasErrors() {
return nil
}
v.secondExtract.Walk(operation, definition, report)
if report.HasErrors() {
return nil
}
v.thirdDeleteUnused.Walk(operation, definition, report)
if report.HasErrors() {
return nil
}
v.fourthCoerce.Walk(operation, definition, report)

return v.variablesExtractionVisitor.uploadsPath
}

type fragmentCycleVisitor struct {
*astvisitor.Walker

Expand Down
Loading