From 83079a84c520dd4a290284fa92364f0dc134cfdf Mon Sep 17 00:00:00 2001 From: Rob Richard Date: Wed, 20 May 2026 08:55:30 -0700 Subject: [PATCH] Rewrite DeferStreamDirectiveOnRootFieldRule to catch usage in fragments with abstract types --- ...eferStreamDirectiveOnRootFieldRule-test.ts | 61 +++++++- .../DeferStreamDirectiveOnRootFieldRule.ts | 144 +++++++++++++----- 2 files changed, 167 insertions(+), 38 deletions(-) diff --git a/src/validation/__tests__/DeferStreamDirectiveOnRootFieldRule-test.ts b/src/validation/__tests__/DeferStreamDirectiveOnRootFieldRule-test.ts index 72725b88c9..679d8592e0 100644 --- a/src/validation/__tests__/DeferStreamDirectiveOnRootFieldRule-test.ts +++ b/src/validation/__tests__/DeferStreamDirectiveOnRootFieldRule-test.ts @@ -24,19 +24,26 @@ const schema = buildSchema(` sender: String } - type SubscriptionRoot { + interface Root { + rootField: Message + } + + type SubscriptionRoot implements Root { subscriptionField: Message subscriptionListField: [Message] + rootField: Message } - type MutationRoot { + type MutationRoot implements Root { mutationField: Message mutationListField: [Message] + rootField: Message } - type QueryRoot { + type QueryRoot implements Root { message: Message messages: [Message] + rootField: Message } schema { @@ -76,6 +83,13 @@ describe('Validate: Defer/Stream directive on root field', () => { expectErrors(` mutation { ...rootFragment @defer + ...otherFragment + } + fragment otherFragment on MutationRoot { + ...rootFragment + mutationListField { + body + } } fragment rootFragment on MutationRoot { mutationField { @@ -107,7 +121,26 @@ describe('Validate: Defer/Stream directive on root field', () => { }, ]); }); - + it('Defer fragment spread on root mutation field interface', () => { + expectErrors(` + mutation { + ...rootFragment + } + fragment rootFragment on Root { + ... @defer { + rootField { + body + } + } + } + `).toDeepEqual([ + { + message: + 'Defer directive cannot be used on root mutation type "MutationRoot".', + locations: [{ line: 6, column: 13 }], + }, + ]); + }); it('Defer fragment spread on nested mutation field', () => { expectValid(` mutation { @@ -120,6 +153,26 @@ describe('Validate: Defer/Stream directive on root field', () => { `); }); + it('Defer fragment spread on root subscription field interface', () => { + expectErrors(` + subscription { + ...rootFragment + } + fragment rootFragment on Root { + ... @defer { + rootField { + body + } + } + } + `).toDeepEqual([ + { + message: + 'Defer directive cannot be used on root subscription type "SubscriptionRoot".', + locations: [{ line: 6, column: 13 }], + }, + ]); + }); it('Defer fragment spread on root subscription field', () => { expectErrors(` subscription { diff --git a/src/validation/rules/DeferStreamDirectiveOnRootFieldRule.ts b/src/validation/rules/DeferStreamDirectiveOnRootFieldRule.ts index 37c3d184a5..63051e7d55 100644 --- a/src/validation/rules/DeferStreamDirectiveOnRootFieldRule.ts +++ b/src/validation/rules/DeferStreamDirectiveOnRootFieldRule.ts @@ -2,8 +2,18 @@ import { GraphQLError } from '../../error/GraphQLError.ts'; +import type { + FragmentDefinitionNode, + FragmentSpreadNode, + InlineFragmentNode, + OperationDefinitionNode, + OperationTypeNode, + SelectionSetNode, +} from '../../language/ast.ts'; +import { Kind } from '../../language/kinds.ts'; import type { ASTVisitor } from '../../language/visitor.ts'; +import type { GraphQLObjectType } from '../../type/definition.ts'; import { GraphQLDeferDirective, GraphQLStreamDirective, @@ -47,46 +57,112 @@ export function DeferStreamDirectiveOnRootFieldRule( context: ValidationContext, ): ASTVisitor { return { - Directive(node) { - const mutationType = context.getSchema().getMutationType(); - const subscriptionType = context.getSchema().getSubscriptionType(); - const parentType = context.getParentType(); - if (parentType && node.name.value === GraphQLDeferDirective.name) { - if (mutationType && parentType === mutationType) { - context.reportError( - new GraphQLError( - `Defer directive cannot be used on root mutation type "${parentType}".`, - { nodes: node }, - ), - ); - } - if (subscriptionType && parentType === subscriptionType) { - context.reportError( - new GraphQLError( - `Defer directive cannot be used on root subscription type "${parentType}".`, - { nodes: node }, - ), - ); + OperationDefinition(node: OperationDefinitionNode) { + const document = context.getDocument(); + const fragments = new Map(); + + for (const definition of document.definitions) { + if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragments.set(definition.name.value, definition); } } - if (parentType && node.name.value === GraphQLStreamDirective.name) { - if (mutationType && parentType === mutationType) { - context.reportError( - new GraphQLError( - `Stream directive cannot be used on root mutation type "${parentType}".`, - { nodes: node }, - ), - ); - } - if (subscriptionType && parentType === subscriptionType) { + if (node.operation !== 'subscription' && node.operation !== 'mutation') { + return; + } + const schema = context.getSchema(); + const rootType = schema.getRootType(node.operation); + if (rootType) { + forbidDeferStream({ + context, + operationType: node.operation, + rootType, + fragments, + selectionSet: node.selectionSet, + visitedFragments: new Set(), + }); + } + }, + }; +} + +function forbidDeferStream({ + context, + operationType, + rootType, + fragments, + selectionSet, + visitedFragments, +}: { + context: ValidationContext; + operationType: OperationTypeNode; + rootType: GraphQLObjectType; + fragments: Map; + selectionSet: SelectionSetNode; + visitedFragments: Set; +}) { + for (const selection of selectionSet.selections) { + if (selection.kind === 'Field') { + const stream = selection.directives?.find( + (d) => d.name.value === GraphQLStreamDirective.name, + ); + if (stream) { + context.reportError( + new GraphQLError( + `Stream directive cannot be used on root ${operationType} type "${rootType}".`, + { nodes: stream }, + ), + ); + } + } else if (selection.kind === 'FragmentSpread') { + const fragmentName = selection.name.value; + if (visitedFragments.has(fragmentName)) { + continue; + } + const fragment = fragments.get(fragmentName); + if (fragment) { + const defer = getDeferDirective(selection); + if (defer !== undefined) { context.reportError( new GraphQLError( - `Stream directive cannot be used on root subscription type "${parentType}".`, - { nodes: node }, + `Defer directive cannot be used on root ${operationType} type "${rootType}".`, + { nodes: defer }, ), ); } + forbidDeferStream({ + context, + operationType, + rootType, + fragments, + selectionSet: fragment.selectionSet, + visitedFragments, + }); } - }, - }; + visitedFragments.add(fragmentName); + } else if (selection.kind === 'InlineFragment') { + const defer = getDeferDirective(selection); + if (defer !== undefined) { + context.reportError( + new GraphQLError( + `Defer directive cannot be used on root ${operationType} type "${rootType}".`, + { nodes: defer }, + ), + ); + } + forbidDeferStream({ + context, + operationType, + rootType, + fragments, + selectionSet: selection.selectionSet, + visitedFragments, + }); + } + } +} + +function getDeferDirective(fragment: FragmentSpreadNode | InlineFragmentNode) { + return fragment.directives?.find( + (d) => d.name.value === GraphQLDeferDirective.name, + ); }