Skip to content
Closed
Show file tree
Hide file tree
Changes from 20 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)
})
}
}
31 changes: 22 additions & 9 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 @@ -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 {
Comment thread
Noroth marked this conversation as resolved.
Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.

type VariablesNormalizerOptions struct {
  withFieldArgMapping bool
}

WithFieldArgMapping()

or just

type VariablesNormalizerConfig struct {
  WithFieldArgMapping bool
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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")
Expand All @@ -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")
Expand All @@ -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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

when v is configured
when VariablesNormalizer

or when collecting arguments mapping enabled

Copy link
Copy Markdown
Contributor Author

@dkorittki dkorittki Feb 19, 2026

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 {
Expand Down
Loading