Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
.DS_Store
pkg/parser/testdata/lotto.graphql
*node_modules*
*vendor*
*vendor*
/.cursorrules
7 changes: 7 additions & 0 deletions v2/pkg/ast/ast_directive.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,10 +135,17 @@ func (d *Document) DirectiveSetsHasCompatibleStreamDirective(left, right []int)
leftRef, leftExists := d.DirectiveWithNameBytes(left, literal.STREAM)
rightRef, rightExists := d.DirectiveWithNameBytes(right, literal.STREAM)

// Both have @stream: they must be equal
if leftExists && rightExists {
return d.DirectivesAreEqual(leftRef, rightRef)
}

// One has @stream, the other doesn't: incompatible
if leftExists != rightExists {
return false
}

// Neither has @stream: compatible
return true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ func (f *inlineFragmentSelectionMergeVisitor) fieldsCanMerge(left, right int) bo
leftDirectives := f.operation.FieldDirectives(left)
rightDirectives := f.operation.FieldDirectives(right)

// For fields with selections, check that all directives are equal
// This ensures @skip, @include, @defer and @stream all match
return f.operation.DirectiveSetsAreEqual(leftDirectives, rightDirectives)
}

Expand Down
128 changes: 128 additions & 0 deletions v2/pkg/astvalidation/operation_rule_defer_stream_on_root_fields.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package astvalidation

import (
"bytes"

"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/lexer/literal"
"github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport"
)

// DeferStreamOnValidOperations validates that defer/stream directives are used on valid operations:
// - Query operations: @defer and @stream are allowed everywhere (root and nested fields)
// - Mutation operations: @defer and @stream are NOT allowed on root fields, but allowed on nested fields
// - Subscription operations: @defer and @stream are NOT allowed anywhere (root or nested fields)
// Directives with if: false are allowed (disabled directives).
// Directives with if: $variable are allowed (dynamic directives that can't be statically determined).
func DeferStreamOnValidOperations() Rule {
return func(walker *astvisitor.Walker) {
visitor := deferStreamOnValidOpsVisitor{
Walker: walker,
}
walker.RegisterEnterDocumentVisitor(&visitor)
walker.RegisterEnterOperationVisitor(&visitor)
walker.RegisterEnterDirectiveVisitor(&visitor)
}
}

type deferStreamOnValidOpsVisitor struct {
*astvisitor.Walker

operation, definition *ast.Document
currentOperationType ast.OperationType
}

func (d *deferStreamOnValidOpsVisitor) EnterDocument(operation, definition *ast.Document) {
d.operation = operation
d.definition = definition
}

func (d *deferStreamOnValidOpsVisitor) EnterOperationDefinition(ref int) {
d.currentOperationType = d.operation.OperationDefinitions[ref].OperationType
}

func (d *deferStreamOnValidOpsVisitor) EnterDirective(ref int) {
directiveName := d.operation.DirectiveNameBytes(ref)

// Only validate @defer and @stream directives
if !bytes.Equal(directiveName, literal.DEFER) && !bytes.Equal(directiveName, literal.STREAM) {
return
}

if ifValue, hasIf := d.operation.DirectiveArgumentValueByName(ref, literal.IF); hasIf {
switch ifValue.Kind {
case ast.ValueKindBoolean:
// If "if: false", the directive is disabled, so it's allowed
if !d.operation.BooleanValue(ifValue.Ref) {
return
}
case ast.ValueKindVariable:
// If if: $variable, we can't statically determine if it's enabled,
// so we allow it (it might be false at runtime)
return
}
}

directivePosition := d.operation.Directives[ref].At

// For subscriptions, @defer and @stream are not allowed anywhere (root or nested)
if d.currentOperationType == ast.OperationTypeSubscription {
d.StopWithExternalErr(operationreport.ErrDeferStreamDirectiveNotAllowedOnSubs(
directiveName,
directivePosition,
))
return
}

// For queries, @defer and @stream are allowed everywhere
if d.currentOperationType == ast.OperationTypeQuery {
return
}

if len(d.Ancestors) == 0 {
return
}
// The directive's immediate parent (the node it's attached to)
ancestor := d.Ancestors[len(d.Ancestors)-1]

// Determine if this is a root level directive
isRootLevel := false

switch ancestor.Kind {
case ast.NodeKindInlineFragment:
// For inline fragments with @defer, check if it's directly in the operation's selection set
// At root level, ancestors should be: [OperationDefinition, SelectionSet, InlineFragment]
// For nested: [OperationDefinition, SelectionSet, Field, ..., SelectionSet, InlineFragment]
if len(d.Ancestors) == 3 {
// Check if pattern is [OperationDefinition, SelectionSet, InlineFragment]
if d.Ancestors[0].Kind == ast.NodeKindOperationDefinition &&
d.Ancestors[1].Kind == ast.NodeKindSelectionSet &&
d.Ancestors[2].Kind == ast.NodeKindInlineFragment {
isRootLevel = true
}
}
case ast.NodeKindField:
// For fields with @stream, check if we're directly in the operation's selection set
// Count how many SelectionSets we've traversed (depth of nesting)
// A root-level field has only one SelectionSet ancestor (the operation's selection set)
selectionSetCount := 0
for _, a := range d.Ancestors {
if a.Kind == ast.NodeKindSelectionSet {
selectionSetCount++
}
}
// If there's only one SelectionSet in the ancestor chain, we're at root level
isRootLevel = selectionSetCount == 1
}

// For mutations, @defer and @stream are not allowed on root fields
if isRootLevel {
operationTypeName := d.currentOperationType.Name()
d.StopWithExternalErr(operationreport.ErrDeferStreamDirectiveNotAllowedOnRootField(
directiveName,
operationTypeName,
directivePosition,
))
}
}
109 changes: 109 additions & 0 deletions v2/pkg/astvalidation/operation_rule_defer_stream_unique_labels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package astvalidation

import (
"bytes"

"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/lexer/literal"
"github.com/wundergraph/graphql-go-tools/v2/pkg/lexer/position"
"github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport"
)

// DeferStreamHaveUniqueLabels validates that defer and stream directive labels are:
// 1. Unique across all defer and stream directives within an operation
// 2. Not using variables (must be static string values)
func DeferStreamHaveUniqueLabels() Rule {
return func(walker *astvisitor.Walker) {
visitor := deferStreamLabelsVisitor{
Walker: walker,
}
walker.RegisterEnterDocumentVisitor(&visitor)
walker.RegisterEnterOperationVisitor(&visitor)
walker.RegisterEnterDirectiveVisitor(&visitor)
}
}

type labelPosition struct {
directiveRef int
position position.Position
}

type deferStreamLabelsVisitor struct {
*astvisitor.Walker

operation, definition *ast.Document

// Track seen labels with their directive refs and positions for duplicate detection.
seenLabels map[string]labelPosition
}

func (d *deferStreamLabelsVisitor) EnterDocument(operation, definition *ast.Document) {
d.operation = operation
d.definition = definition
}

func (d *deferStreamLabelsVisitor) EnterOperationDefinition(ref int) {
d.seenLabels = make(map[string]labelPosition)
}

func (d *deferStreamLabelsVisitor) EnterDirective(ref int) {
directiveName := d.operation.DirectiveNameBytes(ref)

if !bytes.Equal(directiveName, literal.DEFER) && !bytes.Equal(directiveName, literal.STREAM) {
return
}

labelValue, hasLabel := d.operation.DirectiveArgumentValueByName(ref, literal.LABEL)
if !hasLabel {
// No label is okay, directives can be used without labels
return
}

directivePosition := d.operation.Directives[ref].At

// Labels must be static strings, not variables
if labelValue.Kind == ast.ValueKindVariable {
d.StopWithExternalErr(operationreport.ErrDeferStreamDirectiveLabelMustBeStatic(directiveName, directivePosition))
return
}

if labelValue.Kind != ast.ValueKindString {
// This should be caught by other validation rules, but skip if not a string
return
}

if ifValue, hasIf := d.operation.DirectiveArgumentValueByName(ref, literal.IF); hasIf {
switch ifValue.Kind {
case ast.ValueKindBoolean:
// If "if: false", ignore the directive
if !d.operation.BooleanValue(ifValue.Ref) {
return
}
case ast.ValueKindVariable:
// If if: $variable, we can't statically determine if it's enabled,
// so we ignore this until variable's value is provided.
return
}
}

labelString := d.operation.StringValueContentString(labelValue.Ref)

if previous, exists := d.seenLabels[labelString]; exists {
previousDirectiveName := d.operation.DirectiveNameBytes(previous.directiveRef)
d.StopWithExternalErr(operationreport.ErrDeferStreamDirectiveLabelMustBeUnique(
directiveName,
previousDirectiveName,
labelString,
previous.position,
directivePosition,
))
return
}

// Record this label with its position
d.seenLabels[labelString] = labelPosition{
directiveRef: ref,
position: directivePosition,
}
}
60 changes: 40 additions & 20 deletions v2/pkg/astvalidation/operation_rule_field_selection_merging.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ import (
"github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport"
)

// FieldSelectionMerging validates if field selections can be merged
// FieldSelectionMerging returns a validation rule that ensures field selections can be merged.
//
// This rule implements the validation described in the GraphQL specification section 5.3.2:
// "Field Selection Merging". It ensures that when multiple fields with the same response key
// (name or alias) are selected in overlapping selection sets, they can be unambiguously merged
// into a single field in the response.
//
// The rule is applied to each operation and fragment definition in the document.
func FieldSelectionMerging() Rule {
return func(walker *astvisitor.Walker) {
visitor := fieldSelectionMergingVisitor{Walker: walker}
Expand All @@ -31,6 +38,7 @@ type fieldSelectionMergingVisitor struct {
type nonScalarRequirement struct {
path ast.Path
objectName ast.ByteSlice
fieldRef int
fieldTypeRef int
fieldTypeDefinitionNode ast.Node
}
Expand Down Expand Up @@ -107,49 +115,65 @@ func (f *fieldSelectionMergingVisitor) EnterField(ref int) {
if fieldDefinitionTypeNode.Kind != ast.NodeKindScalarTypeDefinition {

matchedRequirements := f.NonScalarRequirementsByPathField(path, objectName)
fieldDefinitionTypeKindPresentInRequirements := false
hasDifferentKindInRequirements := false
for _, i := range matchedRequirements {

if !f.potentiallySameObject(fieldDefinitionTypeNode, f.nonScalarRequirements[i].fieldTypeDefinitionNode) {
// This condition below can never be true because if objects aren't potentially the same,
// and we know objectNames are equal (from the filter), they cannot be not equal at the same time.
// Perhaps this should be an else case or restructured?
if !objectName.Equals(f.nonScalarRequirements[i].objectName) {
f.StopWithExternalErr(operationreport.ErrResponseOfDifferingTypesMustBeOfSameShape(objectName, f.nonScalarRequirements[i].objectName))
return
}
} else if !f.definition.TypesAreCompatibleDeep(f.nonScalarRequirements[i].fieldTypeRef, fieldType) {
left, err := f.definition.PrintTypeBytes(f.nonScalarRequirements[i].fieldTypeRef, nil)
if err != nil {
f.StopWithInternalErr(err)
} else {
// Check stream directive compatibility for non-scalar fields
leftDirectives := f.operation.FieldDirectives(f.nonScalarRequirements[i].fieldRef)
rightDirectives := f.operation.FieldDirectives(ref)
if !f.operation.DirectiveSetsHasCompatibleStreamDirective(leftDirectives, rightDirectives) {
f.StopWithExternalErr(operationreport.ErrConflictingStreamDirectivesOnField(objectName))
return
}
right, err := f.definition.PrintTypeBytes(fieldType, nil)
if err != nil {
f.StopWithInternalErr(err)

if !f.definition.TypesAreCompatibleDeep(f.nonScalarRequirements[i].fieldTypeRef, fieldType) {
left, err := f.definition.PrintTypeBytes(f.nonScalarRequirements[i].fieldTypeRef, nil)
if err != nil {
f.StopWithInternalErr(err)
return
}
right, err := f.definition.PrintTypeBytes(fieldType, nil)
if err != nil {
f.StopWithInternalErr(err)
return
}
f.StopWithExternalErr(operationreport.ErrTypesForFieldMismatch(objectName, left, right))
return
}
f.StopWithExternalErr(operationreport.ErrTypesForFieldMismatch(objectName, left, right))
return
}

if fieldDefinitionTypeNode.Kind != f.nonScalarRequirements[i].fieldTypeDefinitionNode.Kind {
fieldDefinitionTypeKindPresentInRequirements = true
hasDifferentKindInRequirements = true
}
}

if len(matchedRequirements) != 0 && fieldDefinitionTypeKindPresentInRequirements {
if hasDifferentKindInRequirements {
// If we've already checked this field against a requirement with a different Kind,
// we don't need to add it again to requirements.
return
}

f.nonScalarRequirements = append(f.nonScalarRequirements, nonScalarRequirement{
path: path,
objectName: objectName,
fieldRef: ref,
fieldTypeRef: fieldType,
fieldTypeDefinitionNode: fieldDefinitionTypeNode,
})
return
}

matchedRequirements := f.ScalarRequirementsByPathField(path, objectName)
fieldDefinitionTypeKindPresentInRequirements := false
hasDifferentKindInRequirements := false

for _, i := range matchedRequirements {
if f.potentiallySameObject(f.scalarRequirements[i].enclosingTypeDefinition, f.EnclosingTypeDefinition) {
Expand All @@ -175,11 +199,11 @@ func (f *fieldSelectionMergingVisitor) EnterField(ref int) {
}

if fieldDefinitionTypeNode.Kind != f.scalarRequirements[i].fieldTypeDefinitionNode.Kind {
fieldDefinitionTypeKindPresentInRequirements = true
hasDifferentKindInRequirements = true
}
}

if len(matchedRequirements) != 0 && fieldDefinitionTypeKindPresentInRequirements {
if hasDifferentKindInRequirements {
return
}

Expand All @@ -203,7 +227,3 @@ func (f *fieldSelectionMergingVisitor) potentiallySameObject(left, right ast.Nod
return false
}
}

func (f *fieldSelectionMergingVisitor) EnterSelectionSet(_ int) {

}
Loading
Loading