diff --git a/.gitignore b/.gitignore index 960ca0c8d5..ddccb5cd88 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ .DS_Store pkg/parser/testdata/lotto.graphql *node_modules* -*vendor* \ No newline at end of file +*vendor* +/.cursorrules diff --git a/v2/pkg/ast/ast_directive.go b/v2/pkg/ast/ast_directive.go index 9b70b521ab..b4d4f16649 100644 --- a/v2/pkg/ast/ast_directive.go +++ b/v2/pkg/ast/ast_directive.go @@ -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 } diff --git a/v2/pkg/astnormalization/inline_fragment_selection_merging.go b/v2/pkg/astnormalization/inline_fragment_selection_merging.go index 118c9eb53c..8a54ca0fb5 100644 --- a/v2/pkg/astnormalization/inline_fragment_selection_merging.go +++ b/v2/pkg/astnormalization/inline_fragment_selection_merging.go @@ -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) } diff --git a/v2/pkg/astvalidation/operation_rule_defer_stream_on_root_fields.go b/v2/pkg/astvalidation/operation_rule_defer_stream_on_root_fields.go new file mode 100644 index 0000000000..d75e918207 --- /dev/null +++ b/v2/pkg/astvalidation/operation_rule_defer_stream_on_root_fields.go @@ -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, + )) + } +} diff --git a/v2/pkg/astvalidation/operation_rule_defer_stream_unique_labels.go b/v2/pkg/astvalidation/operation_rule_defer_stream_unique_labels.go new file mode 100644 index 0000000000..a9f19d1406 --- /dev/null +++ b/v2/pkg/astvalidation/operation_rule_defer_stream_unique_labels.go @@ -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, + } +} diff --git a/v2/pkg/astvalidation/operation_rule_field_selection_merging.go b/v2/pkg/astvalidation/operation_rule_field_selection_merging.go index 9e762aa32c..e35e9b1247 100644 --- a/v2/pkg/astvalidation/operation_rule_field_selection_merging.go +++ b/v2/pkg/astvalidation/operation_rule_field_selection_merging.go @@ -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} @@ -31,6 +38,7 @@ type fieldSelectionMergingVisitor struct { type nonScalarRequirement struct { path ast.Path objectName ast.ByteSlice + fieldRef int fieldTypeRef int fieldTypeDefinitionNode ast.Node } @@ -107,41 +115,57 @@ 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 remove altogether? 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, }) @@ -149,7 +173,7 @@ func (f *fieldSelectionMergingVisitor) EnterField(ref int) { } matchedRequirements := f.ScalarRequirementsByPathField(path, objectName) - fieldDefinitionTypeKindPresentInRequirements := false + hasDifferentKindInRequirements := false for _, i := range matchedRequirements { if f.potentiallySameObject(f.scalarRequirements[i].enclosingTypeDefinition, f.EnclosingTypeDefinition) { @@ -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 } @@ -203,7 +227,3 @@ func (f *fieldSelectionMergingVisitor) potentiallySameObject(left, right ast.Nod return false } } - -func (f *fieldSelectionMergingVisitor) EnterSelectionSet(_ int) { - -} diff --git a/v2/pkg/astvalidation/operation_rule_stream_on_list_fields_only.go b/v2/pkg/astvalidation/operation_rule_stream_on_list_fields_only.go new file mode 100644 index 0000000000..79913fd458 --- /dev/null +++ b/v2/pkg/astvalidation/operation_rule_stream_on_list_fields_only.go @@ -0,0 +1,86 @@ +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" +) + +// StreamAppliedToListFieldsOnly validates that the stream directive is used on list fields +func StreamAppliedToListFieldsOnly() Rule { + return func(walker *astvisitor.Walker) { + visitor := streamAppliedToListFieldsVisitor{ + Walker: walker, + } + walker.RegisterEnterDocumentVisitor(&visitor) + walker.RegisterEnterDirectiveVisitor(&visitor) + } +} + +type streamAppliedToListFieldsVisitor struct { + *astvisitor.Walker + + operation, definition *ast.Document +} + +func (s *streamAppliedToListFieldsVisitor) EnterDocument(operation, definition *ast.Document) { + s.operation = operation + s.definition = definition +} + +func (s *streamAppliedToListFieldsVisitor) EnterDirective(ref int) { + directiveName := s.operation.DirectiveNameBytes(ref) + + // Only validate @stream directives + if !bytes.Equal(directiveName, literal.STREAM) { + return + } + + // Validate initialCount argument if present + initialCountValue, hasCount := s.operation.DirectiveArgumentValueByName(ref, literal.INITIAL_COUNT) + if hasCount { + if initialCountValue.Kind == ast.ValueKindInteger { + initialCount := s.operation.IntValueAsInt32(initialCountValue.Ref) + if initialCount < 0 { + directivePosition := s.operation.Directives[ref].At + s.StopWithExternalErr(operationreport.ErrStreamInitialCountMustBeNonNegative(directiveName, directivePosition)) + return + } + } + } + + if len(s.Ancestors) == 0 { + return + } + ancestor := s.Ancestors[len(s.Ancestors)-1] + + // Get the field definition from the schema + // We need to walk up the type definitions to find the field + fieldName := s.operation.FieldNameBytes(ancestor.Ref) + // Find the enclosing type by looking at TypeDefinitions in the walker. + // Start from the item before the last one of typeDefinitions. + var fieldDefinition int + var exists bool + for i := len(s.TypeDefinitions) - 2; i >= 0; i-- { + fieldDefinition, exists = s.definition.NodeFieldDefinitionByName(s.TypeDefinitions[i], fieldName) + if exists { + break + } + } + + if !exists { + // If the field doesn't exist in the schema, that's a different validation error + // Skip this check + return + } + + fieldTypeRef := s.definition.FieldDefinitionType(fieldDefinition) + + if !s.definition.TypeIsList(fieldTypeRef) { + directivePosition := s.operation.Directives[ref].At + s.StopWithExternalErr(operationreport.ErrStreamDirectiveOnNonListField(directiveName, fieldName, directivePosition)) + } +} diff --git a/v2/pkg/astvalidation/operation_validation_test.go b/v2/pkg/astvalidation/operation_validation_test.go index 57e056f3e0..10cb61332a 100644 --- a/v2/pkg/astvalidation/operation_validation_test.go +++ b/v2/pkg/astvalidation/operation_validation_test.go @@ -12,6 +12,7 @@ import ( "github.com/wundergraph/graphql-go-tools/v2/pkg/astnormalization" "github.com/wundergraph/graphql-go-tools/v2/pkg/astparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/astprinter" + "github.com/wundergraph/graphql-go-tools/v2/pkg/astvisitor" "github.com/wundergraph/graphql-go-tools/v2/pkg/errorcodes" "github.com/wundergraph/graphql-go-tools/v2/pkg/internal/unsafeparser" "github.com/wundergraph/graphql-go-tools/v2/pkg/operationreport" @@ -1551,7 +1552,9 @@ func TestExecutionValidation(t *testing.T) { doesKnowCommand(dogCommand: SIT) doesKnowCommand }`, - FieldSelectionMerging(), Invalid) + FieldSelectionMerging(), + Invalid, + withValidationErrors(`differing fields for objectName 'doesKnowCommand'`)) }) t.Run("111", func(t *testing.T) { run(t, ` @@ -4176,6 +4179,704 @@ type Query { }) }) }) + + t.Run("defer/stream directive", func(t *testing.T) { + allRules := []Rule{ + DeferStreamOnValidOperations(), + DeferStreamHaveUniqueLabels(), + DirectivesAreInValidLocations(), + StreamAppliedToListFieldsOnly(), + } + + t.Run("labels", func(t *testing.T) { + t.Run("defer directive with unique labels", func(t *testing.T) { + run(t, ` + query { + dog { + ...dogFragment @defer(label: "dogLabel") + ...otherFragment @defer(label: "otherLabel") + } + } + fragment dogFragment on Dog { name } + fragment otherFragment on Dog { nickname } + `, DeferStreamHaveUniqueLabels(), Valid) + }) + + t.Run("stream directive with unique labels", func(t *testing.T) { + run(t, ` + query { + dog { + extras @stream(label: "extrasStream") { string } + mustExtras @stream(label: "mustExtrasStream") { string } + } + } + `, DeferStreamHaveUniqueLabels(), Valid) + }) + + t.Run("defer and stream with same label", func(t *testing.T) { + run(t, ` + query { + dog { + ...dogFragment @defer(label: "sameLabel") + extras @stream(label: "sameLabel") { string } + } + } + fragment dogFragment on Dog { name } + `, DeferStreamHaveUniqueLabels(), + Invalid, + withValidationErrors(`directive "@stream" label "sameLabel" must be unique, but was already used on "@defer" directive`), + ) + }) + t.Run("defer directives with same label", func(t *testing.T) { + run(t, ` + query { + dog { + ...dogFragment @defer(label: "duplicateLabel") + ...otherFragment @defer(label: "duplicateLabel") + } + } + fragment dogFragment on Dog { name } + fragment otherFragment on Dog { nickname } + `, DeferStreamHaveUniqueLabels(), + Invalid, + withDisableNormalization(), + withValidationErrors(`directive "@defer" label "duplicateLabel" must be unique`)) + }) + t.Run("multiple stream directives with same label", func(t *testing.T) { + run(t, ` + query { + dog { + extras @stream(label: "duplicateLabel") { string } + mustExtras @stream(label: "duplicateLabel") { string } + } + }`, DeferStreamHaveUniqueLabels(), + Invalid, + withValidationErrors(`directive "@stream" label "duplicateLabel" must be unique`)) + }) + t.Run("defer without label", func(t *testing.T) { + run(t, ` + query { + dog { + ...dogFragmentA @defer(label: "fragA") + ...dogFragmentB @defer + } + } + fragment dogFragmentA on Dog { name } + fragment dogFragmentB on Dog { nickname } + `, DeferStreamHaveUniqueLabels(), Valid) + }) + t.Run("stream without label", func(t *testing.T) { + run(t, ` + query { + dog { + extras @stream { string } + } + }`, DeferStreamHaveUniqueLabels(), Valid) + }) + t.Run("defer directive with variable label", func(t *testing.T) { + run(t, ` + query($label: String) { + dog { + ...dogFragment @defer(label: $label) + } + } + fragment dogFragment on Dog { name } + `, DeferStreamHaveUniqueLabels(), + Invalid, + withValidationErrors(`directive "@defer" label argument must be a static string value, not a variable`)) + }) + t.Run("stream directive with variable label", func(t *testing.T) { + run(t, ` + query($label: String) { + dog { + extras @stream(label: $label) { string } + } + } + `, DeferStreamHaveUniqueLabels(), + Invalid, + withValidationErrors(`directive "@stream" label argument must be a static string value, not a variable`)) + }) + t.Run("duplicate labels with one disabled defer", func(t *testing.T) { + runManyRules(t, ` + query { + dog { + ...fragment1 @defer(label: "a", if: false) + ...fragment2 @defer(label: "a") + } + } + fragment fragment1 on Dog { name } + fragment fragment2 on Dog { nickname } + `, Valid, allRules...) + }) + t.Run("duplicate labels with one optional defer", func(t *testing.T) { + runManyRules(t, ` + query q($b: Boolean) { + dog { + ...fragment1 @defer(label: "a", if: $b) + ...fragment2 @defer(label: "a") + } + } + fragment fragment1 on Dog { name } + fragment fragment2 on Dog { nickname } + `, Valid, allRules...) + }) + }) + + t.Run("on operations", func(t *testing.T) { + // on queries + t.Run("defer inline fragment spread on root query field", func(t *testing.T) { + run(t, ` + query { + ... @defer { + dog { name } + } + } + `, DeferStreamOnValidOperations(), Valid) + }) + t.Run("defer inline fragment spread on nested query field", func(t *testing.T) { + run(t, ` + query { + dog { + ... @defer { + extra { string } + } + } + } + `, DeferStreamOnValidOperations(), Valid) + }) + t.Run("defer fragment spread on root query field", func(t *testing.T) { + run(t, ` + query { + ...rootFragment @defer + } + fragment rootFragment on Query { + extras { string } + }`, DeferStreamOnValidOperations(), Valid) + }) + t.Run("stream on root query field", func(t *testing.T) { + run(t, ` + query { + extras @stream { string } + }`, DeferStreamOnValidOperations(), Valid) + }) + t.Run("stream field on fragment on root query field", func(t *testing.T) { + run(t, ` + query { + ...rootFragment + } + fragment rootFragment on Query { + extras @stream { string } + }`, DeferStreamOnValidOperations(), Valid) + }) + + // on mutations + t.Run("defer inline fragment spread on root mutation field", func(t *testing.T) { + run(t, ` + mutation { + ... @defer { + mutateDog { name } + } + }`, DeferStreamOnValidOperations(), Invalid) + }) + t.Run("defer fragment spread on nested mutation field", func(t *testing.T) { + run(t, ` + mutation { + mutateDog { + ... @defer { + extra { string } + } + } + }`, DeferStreamOnValidOperations(), Valid) + }) + t.Run("defer fragment spread on root mutation field", func(t *testing.T) { + run(t, ` + mutation { + ...rootFragment @defer + } + fragment rootFragment on Mutation { + extras { string } + }`, DeferStreamOnValidOperations(), Invalid, + withValidationErrors(`directive "@defer" is not allowed on root fields of mutation operations`)) + }) + + t.Run("non-defer inline fragment spread on root mutation field", func(t *testing.T) { + run(t, ` + mutation { + ... @defer (if: false) { + mutateDog { name } + } + }`, DeferStreamOnValidOperations(), Valid) + }) + t.Run("stream field on root mutation field", func(t *testing.T) { + run(t, ` + mutation { + mutateDogs @stream { name } + }`, DeferStreamOnValidOperations(), Invalid) + }) + t.Run("disabled stream on root mutation field", func(t *testing.T) { + run(t, ` + mutation { + mutateDogs @stream (if: false) { name } + }`, DeferStreamOnValidOperations(), Valid) + }) + + // on subscriptions + t.Run("defer inline fragment spread on root subscription field", func(t *testing.T) { + run(t, ` + subscription { + ... @defer { + subscribeDog { name } + } + }`, DeferStreamOnValidOperations(), Invalid) + }) + t.Run("defer inline fragment spread on nested subscription field", func(t *testing.T) { + run(t, ` + subscription { + newMessage { + ... @defer { + body + } + } + }`, DeferStreamOnValidOperations(), Invalid) + }) + t.Run("defer inline nested fragment spreads on subscription field", func(t *testing.T) { + run(t, ` + subscription { + newMessage { + ... frag1 + } + } + fragment frag1 on Message { ...frag2 } + fragment frag2 on Message { ...frag3 } + fragment frag3 on Message { + ... @defer { body } + }`, + DeferStreamOnValidOperations(), + Invalid, + withValidationErrors(`directive "@defer" is not allowed on subscription operations`)) + }) + t.Run("non-defer inline fragment spread on root subscription field", func(t *testing.T) { + run(t, ` + subscription { + ... @defer (if: false) { + subscribeDog { name } + } + }`, DeferStreamOnValidOperations(), Valid) + }) + t.Run("stream field on root subscription field", func(t *testing.T) { + run(t, ` + subscription { + subscribeDog @stream { name } + }`, DeferStreamOnValidOperations(), Invalid) + }) + t.Run("stream on nested subscription field", func(t *testing.T) { + run(t, ` + subscription { + subscribeDogs { + extras @stream { string } + } + }`, DeferStreamOnValidOperations(), Invalid) + }) + t.Run("disabled stream on root subscription field", func(t *testing.T) { + run(t, ` + subscription { + subscribeDog @stream (if: false) { name } + }`, DeferStreamOnValidOperations(), Valid) + }) + + t.Run("defer with variable if argument on query", func(t *testing.T) { + run(t, ` + query($shouldDefer: Boolean!) { + ... @defer(if: $shouldDefer) { + dog { name } + } + }`, DeferStreamOnValidOperations(), Valid) + }) + t.Run("defer with variable if argument on subscription", func(t *testing.T) { + run(t, ` + subscription($shouldDefer: Boolean!) { + ... @defer(if: $shouldDefer) { + dog { name } + } + }`, DeferStreamOnValidOperations(), Valid) + }) + }) + + t.Run("stream with lists only", func(t *testing.T) { + t.Run("stream with positive initialCount argument", func(t *testing.T) { + run(t, ` + query { + dog { + extras @stream(initialCount: 5) { string } + } + }`, StreamAppliedToListFieldsOnly(), Valid) + }) + t.Run("stream with negative initialCount argument", func(t *testing.T) { + run(t, ` + query { + dog { + extras @stream(initialCount: -1) { string } + } + }`, StreamAppliedToListFieldsOnly(), Invalid) + }) + t.Run("stream on list field", func(t *testing.T) { + run(t, ` + query { + dog { + extras @stream { string } + } + }`, StreamAppliedToListFieldsOnly(), Valid) + }) + t.Run("stream on root list field", func(t *testing.T) { + run(t, ` + query { + extras @stream { name } + }`, StreamAppliedToListFieldsOnly(), Valid) + }) + t.Run("stream on non-list field", func(t *testing.T) { + run(t, ` + query { + dog @stream { name } + }`, StreamAppliedToListFieldsOnly(), Invalid) + }) + t.Run("stream on scalar field", func(t *testing.T) { + run(t, ` + query { + dog { name @stream } + }`, StreamAppliedToListFieldsOnly(), Invalid) + }) + }) + + t.Run("valid location", func(t *testing.T) { + t.Run("defer on inline fragment", func(t *testing.T) { + run(t, ` + query { + pet { + ... on Dog @defer { name } + } + }`, DirectivesAreInValidLocations(), Valid) + }) + t.Run("defer on fragment spread", func(t *testing.T) { + run(t, ` + query { + dog { + ...dogFragment @defer + } + } + fragment dogFragment on Dog { name }`, + DirectivesAreInValidLocations(), Valid) + }) + t.Run("defer on field", func(t *testing.T) { + run(t, ` + query { + dog @defer { name } + }`, + DirectivesAreInValidLocations(), + Invalid, + withValidationErrors("defer not allowed on node of kind: FIELD")) + }) + t.Run("stream on fragment spread", func(t *testing.T) { + run(t, ` + query { + ...extrasFrag @stream + } + fragment extrasFrag on Query { + extras { + ... on DogExtra { string } + ... on CatExtra { string2 } + } + }`, + DirectivesAreInValidLocations(), + Invalid, + withValidationErrors("stream not allowed on node of kind: INLINE_FRAGMENT")) + }) + }) + + t.Run("complex", func(t *testing.T) { + t.Run("nested defer directives", func(t *testing.T) { + runManyRules(t, ` + query { + dog { + ... @defer { + name + extra { + ... @defer { string } + } + } + } + }`, Valid, allRules...) + }) + t.Run("defer and stream in same query", func(t *testing.T) { + runManyRules(t, ` + query { + dog { + ... @defer { name } + extras @stream { string } + } + }`, Valid, allRules...) + }) + t.Run("stream on multiple fields with unique labels", func(t *testing.T) { + runManyRules(t, ` + query { + dog { + extra { + mustStrings @stream(label: "dogExtraStream") + } + extras @stream(label: "dogExtras") { + string + } + } + cat { + extra { + mustStrings @stream(label: "catExtraStrings") + } + } + extras @stream(label: "rootExtras") { + strings @stream(label: "extrasStrings") + } + }`, Valid, allRules...) + }) + + t.Run("deeply nested defer with unique labels", func(t *testing.T) { + runManyRules(t, ` + query { + dog { + ... @defer(label: "level1") { + name + extras { + ... @defer(label: "level2") { + string + ... fragment1 @defer(label: "level3") + } + } + } + } + } + fragment fragment1 on Dog { + mustExtra + ... @defer(label: "level4") { + mustExtras + } + }`, Valid, allRules...) + }) + t.Run("deeply nested defer with duplicate labels", func(t *testing.T) { + run(t, ` + query { + dog { + ... @defer(label: "level1") { + name + extras { + ... @defer(label: "level2") { + string + ... fragment1 + } + } + } + } + } + fragment fragment1 on Dog { + mustExtra + ... @defer(label: "level1") { + mustExtras + } + }`, DeferStreamHaveUniqueLabels(), Invalid) + }) + t.Run("defer on typed inline fragment", func(t *testing.T) { + runManyRules(t, ` + query { + extras { + ... on CatExtra @defer { + string1 + } + ... on DogExtra @defer { + string2 + } + } + }`, Valid, allRules...) + }) + + }) + + t.Run("stream merging", func(t *testing.T) { + t.Run("same stream directives supported", func(t *testing.T) { + run(t, ` + query { dog { ...dogFragment } } + fragment dogFragment on Dog { + extras @stream(label: "same", initialCount: 1) + extras @stream(label: "same", initialCount: 1) + }`, FieldSelectionMerging(), Valid) + }) + t.Run("different stream directive label", func(t *testing.T) { + run(t, ` + query { dog { ...dogFragment } } + fragment dogFragment on Dog { + extras @stream(label: "one", initialCount: 1) + extras @stream(label: "two", initialCount: 1) + }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`found conflicting stream directives on the same field`)) + }) + t.Run("different stream directive label separate fragments", func(t *testing.T) { + run(t, ` + query { dog { + ...dogFragment1 + ...dogFragment2 + } } + fragment dogFragment1 on Dog { + extras @stream(label: "one", initialCount: 1) + } + fragment dogFragment2 on Dog { + extras @stream(label: "two", initialCount: 1) + }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`found conflicting stream directives on the same field`)) + }) + t.Run("different stream directive initialCount", func(t *testing.T) { + run(t, ` + query { dog { ...dogFragment } } + fragment dogFragment on Dog { + extras @stream(label: "same", initialCount: 1) + extras @stream(label: "same", initialCount: 5) + }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`found conflicting stream directives on the same field`)) + }) + t.Run("different stream directive: first missing args", func(t *testing.T) { + run(t, ` + query { dog { ...dogFragment } } + fragment dogFragment on Dog { + extras @stream + extras @stream(label: "two", initialCount: 5) + }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`found conflicting stream directives on the same field`)) + }) + t.Run("different stream directive: second missing args", func(t *testing.T) { + run(t, ` + query { dog { ...dogFragment } } + fragment dogFragment on Dog { + extras @stream(label: "one", initialCount: 1) + extras @stream + }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`found conflicting stream directives on the same field`)) + }) + t.Run("mix of stream and no stream", func(t *testing.T) { + run(t, ` + query { dog { ...dogFragment } } + fragment dogFragment on Dog { + extras + extras @stream + }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`found conflicting stream directives on the same field`)) + }) + t.Run("different stream directive both missing args", func(t *testing.T) { + run(t, ` + query { dog { ...dogFragment } } + fragment dogFragment on Dog { + extras @stream + extras @stream + }`, FieldSelectionMerging(), Valid) + }) + t.Run("on union type, different members, same field name", func(t *testing.T) { + run(t, ` + query { + extras { + ... on DogExtra { + strings @stream(label: "dog") + } + ... on CatExtra { + strings @stream(label: "cat") + } + } + }`, FieldSelectionMerging(), Valid) + }) + t.Run("on same field with different aliases", func(t *testing.T) { + run(t, ` + query { dog { + list1: extras @stream(label: "one") + list2: extras @stream(label: "two") + } }`, FieldSelectionMerging(), Valid) + }) + t.Run("on same field with same alias different streams", func(t *testing.T) { + run(t, ` + query { dog { + list: extras @stream(label: "one") + list: extras @stream(label: "two") + } }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`found conflicting stream directives on the same field`)) + }) + t.Run("on inline fragments of same type", func(t *testing.T) { + run(t, ` + query { dog { + ... on Dog { + extras @stream(label: "one") + } + ... on Dog { + extras @stream(label: "two") + } + } }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`found conflicting stream directives on the same field`)) + }) + t.Run("nested stream directive conflicts", func(t *testing.T) { + run(t, ` + query { dog { + extra { + strings @stream(label: "one") + } + extra { + strings @stream(label: "two") + } + } }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`differing fields for objectName 'strings'`)) + }) + t.Run("multiple no-stream selections with one stream", func(t *testing.T) { + run(t, ` + query { dog { + extras + extras + extras @stream(label: "one") + } }`, FieldSelectionMerging(), Invalid, + withValidationErrors(`found conflicting stream directives on the same field`)) + }) + + // conditionals in streams are not implemented yet, some tests are disabled for now. + // + // Streams with "if:false" should be just ignored. + // t.Run("with different if conditions", func(t *testing.T) { + // run(t, ` + // query { dog { ...dogFragment } } + // fragment dogFragment on Dog { + // extras @stream(label: "same", initialCount: 1, if: true) + // extras @stream(label: "same", initialCount: 1, if: false) + // }`, FieldSelectionMerging(), Valid) + // }) + // + // Streams with "if" set to variables, should have matching other fields. + // t.Run("with variable if conditions", func(t *testing.T) { + // run(t, ` + // query($cond1: Boolean!, $cond2: Boolean!) { + // dog { + // extras @stream(label: "same", initialCount: 1, if: $cond1) + // extras @stream(label: "same", initialCount: 1, if: $cond2) + // } + // }`, FieldSelectionMerging(), Valid) + // }) + // + // "if: true" in streams can be omitted and merged without it + // t.Run("with if true vs no if argument", func(t *testing.T) { + // run(t, ` + // query { dog { + // extras @stream(label: "same", initialCount: 1, if: true) + // extras @stream(label: "same", initialCount: 1) + // } }`, FieldSelectionMerging(), Valid) + // }) + // + // streams with "if: false" should be ignored + // t.Run("with if false vs no stream", func(t *testing.T) { + // run(t, ` + // query { dog { + // extras + // extras @stream(label: "one", if: false) + // } }`, FieldSelectionMerging(), Valid) + // }) + }) + }) } func TestValidationEdgeCases(t *testing.T) { @@ -4921,9 +5622,18 @@ func TestValidateFieldSelection(t *testing.T) { }) } +// Placeholder rule functions for defer/stream validation - these need to be implemented +func DeferStreamComplexRule() Rule { + // TODO: Implement complex validation + return func(walker *astvisitor.Walker) { + // Implementation needed + } +} + var testDefinition = ` directive @tag(name: String) on FIELD -directive @stream(label: String) on FIELD +directive @stream(label: String, initialCount: Int, if: Boolean = true) on FIELD +directive @defer(label: String, if: Boolean = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT schema { query: Query @@ -4938,6 +5648,7 @@ type Message { type Subscription { subscribeDog: Dog + subscribeDogs: [Dog] newMessage: Message foo: String bar: String @@ -4946,6 +5657,7 @@ type Subscription { type Mutation { mutateDog: Dog + mutateDogs: [Dog] } input ComplexInput { name: String, owner: String, optionalListOfOptionalStrings: [String]} @@ -5011,6 +5723,7 @@ type Query { findDogNonOptional(complex: ComplexNonOptionalInput): Dog booleanList(booleanListArg: [Boolean!]): Boolean extra: Extra + extras: [Extra!]! nested(input: NestedInput): Boolean args: Arguments } diff --git a/v2/pkg/lexer/literal/literal.go b/v2/pkg/lexer/literal/literal.go index 8c57db74c2..e47b5ab793 100644 --- a/v2/pkg/lexer/literal/literal.go +++ b/v2/pkg/lexer/literal/literal.go @@ -67,6 +67,8 @@ var ( SKIP = []byte("skip") DEFER = []byte("defer") STREAM = []byte("stream") + LABEL = []byte("label") + INITIAL_COUNT = []byte("initialCount") SCHEMA = []byte("schema") EXTEND = []byte("extend") SCALAR = []byte("scalar") diff --git a/v2/pkg/operationreport/externalerror.go b/v2/pkg/operationreport/externalerror.go index 8de9805f5c..c16ec1bafc 100644 --- a/v2/pkg/operationreport/externalerror.go +++ b/v2/pkg/operationreport/externalerror.go @@ -158,6 +158,11 @@ func ErrDifferingFieldsOnPotentiallySameType(objectName ast.ByteSlice) (err Exte return err } +func ErrConflictingStreamDirectivesOnField(fieldName ast.ByteSlice) (err ExternalError) { + err.Message = fmt.Sprintf("found conflicting stream directives on the same field '%s'", fieldName) + return err +} + func ErrFieldSelectionOnLeaf(enumTypeName ast.ByteSlice, typeName string, position position.Position) (err ExternalError) { err.Message = fmt.Sprintf(`Field "%s" must not have a selection since type "%s" has no subfields.`, enumTypeName, typeName) err.Locations = LocationsFromPosition(position) @@ -402,12 +407,59 @@ func ErrDirectiveNotAllowedOnNode(directiveName, nodeKindName ast.ByteSlice) (er func ErrDirectiveMustBeUniquePerLocation(directiveName ast.ByteSlice, position, duplicatePosition position.Position) (err ExternalError) { err.Message = fmt.Sprintf(`The directive "@%s" can only be used once at this location.`, directiveName) - if duplicatePosition.LineStart < position.LineStart || duplicatePosition.CharStart < position.CharStart { - err.Locations = LocationsFromPosition(duplicatePosition, position) + err.Locations = orderedLocationsFromPositions(position, duplicatePosition) + + return err +} + +func orderedLocationsFromPositions(posA, posB position.Position) (locations []Location) { + // Order by (line, column) non-descending. + if posA.LineStart < posB.LineStart || (posA.LineStart == posB.LineStart && posA.CharStart < posB.CharStart) { + return LocationsFromPosition(posA, posB) } else { - err.Locations = LocationsFromPosition(position, duplicatePosition) + return LocationsFromPosition(posB, posA) } +} + +func ErrStreamDirectiveOnNonListField(directiveName, fieldName ast.ByteSlice, directivePosition position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(`directive "@%s" can only be used on list fields, but field "%s" is not a list`, + directiveName, fieldName) + err.Locations = LocationsFromPosition(directivePosition) + return err +} + +func ErrDeferStreamDirectiveLabelMustBeStatic(directiveName ast.ByteSlice, directivePosition position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(`directive "@%s" label argument must be a static string value, not a variable`, + directiveName) + err.Locations = LocationsFromPosition(directivePosition) + return err +} + +func ErrDeferStreamDirectiveLabelMustBeUnique(directiveNameA, directiveNameB ast.ByteSlice, label string, posA, posB position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(`directive "@%s" label "%s" must be unique, but was already used on "@%s" directive`, + directiveNameA, label, directiveNameB) + err.Locations = orderedLocationsFromPositions(posA, posB) + return err +} + +func ErrStreamInitialCountMustBeNonNegative(directiveName ast.ByteSlice, directivePosition position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(`directive "@%s" has invalid initialCount argument: must be non-negative`, + directiveName) + err.Locations = LocationsFromPosition(directivePosition) + return err +} + +func ErrDeferStreamDirectiveNotAllowedOnSubs(directiveName ast.ByteSlice, directivePosition position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(`directive "@%s" is not allowed on subscription operations`, + directiveName) + err.Locations = LocationsFromPosition(directivePosition) + return err +} +func ErrDeferStreamDirectiveNotAllowedOnRootField(directiveName ast.ByteSlice, operationType string, directivePosition position.Position) (err ExternalError) { + err.Message = fmt.Sprintf(`directive "@%s" is not allowed on root fields of %s operations`, + directiveName, operationType) + err.Locations = LocationsFromPosition(directivePosition) return err }