Skip to content

Commit 9080d75

Browse files
authored
Forbid @Skip and @include directives in subscription root selection (#3974)
This is an implementation of graphql/graphql-spec#860 The spec calls for a separate `CollectSubscriptionFields` algorithm due to executing without `variableValues`; but I figured that maintenance would be easier with the algorithms synchronized. Please feel free to make any changes you need to this PR; it's just to get the ball rolling. Related GraphQL WG action item (from 2021!): graphql/graphql-wg#695
1 parent f17d05a commit 9080d75

File tree

3 files changed

+128
-23
lines changed

3 files changed

+128
-23
lines changed

Diff for: src/execution/collectFields.ts

+63-14
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AccumulatorMap } from '../jsutils/AccumulatorMap.js';
22
import type { ObjMap } from '../jsutils/ObjMap.js';
33

44
import type {
5+
DirectiveNode,
56
FieldNode,
67
FragmentDefinitionNode,
78
FragmentSpreadNode,
@@ -23,7 +24,11 @@ import { typeFromAST } from '../utilities/typeFromAST.js';
2324

2425
import type { GraphQLVariableSignature } from './getVariableSignature.js';
2526
import type { VariableValues } from './values.js';
26-
import { getDirectiveValues, getFragmentVariableValues } from './values.js';
27+
import {
28+
experimentalGetArgumentValues,
29+
getDirectiveValues,
30+
getFragmentVariableValues,
31+
} from './values.js';
2732

2833
export interface DeferUsage {
2934
label: string | undefined;
@@ -52,6 +57,8 @@ interface CollectFieldsContext {
5257
runtimeType: GraphQLObjectType;
5358
visitedFragmentNames: Set<string>;
5459
hideSuggestions: boolean;
60+
forbiddenDirectiveInstances: Array<DirectiveNode>;
61+
forbidSkipAndInclude: boolean;
5562
}
5663

5764
/**
@@ -71,9 +78,11 @@ export function collectFields(
7178
runtimeType: GraphQLObjectType,
7279
selectionSet: SelectionSetNode,
7380
hideSuggestions: boolean,
81+
forbidSkipAndInclude = false,
7482
): {
7583
groupedFieldSet: GroupedFieldSet;
7684
newDeferUsages: ReadonlyArray<DeferUsage>;
85+
forbiddenDirectiveInstances: ReadonlyArray<DirectiveNode>;
7786
} {
7887
const groupedFieldSet = new AccumulatorMap<string, FieldDetails>();
7988
const newDeferUsages: Array<DeferUsage> = [];
@@ -84,10 +93,16 @@ export function collectFields(
8493
runtimeType,
8594
visitedFragmentNames: new Set(),
8695
hideSuggestions,
96+
forbiddenDirectiveInstances: [],
97+
forbidSkipAndInclude,
8798
};
8899

89100
collectFieldsImpl(context, selectionSet, groupedFieldSet, newDeferUsages);
90-
return { groupedFieldSet, newDeferUsages };
101+
return {
102+
groupedFieldSet,
103+
newDeferUsages,
104+
forbiddenDirectiveInstances: context.forbiddenDirectiveInstances,
105+
};
91106
}
92107

93108
/**
@@ -119,6 +134,8 @@ export function collectSubfields(
119134
runtimeType: returnType,
120135
visitedFragmentNames: new Set(),
121136
hideSuggestions,
137+
forbiddenDirectiveInstances: [],
138+
forbidSkipAndInclude: false,
122139
};
123140
const subGroupedFieldSet = new AccumulatorMap<string, FieldDetails>();
124141
const newDeferUsages: Array<DeferUsage> = [];
@@ -166,7 +183,12 @@ function collectFieldsImpl(
166183
switch (selection.kind) {
167184
case Kind.FIELD: {
168185
if (
169-
!shouldIncludeNode(selection, variableValues, fragmentVariableValues)
186+
!shouldIncludeNode(
187+
context,
188+
selection,
189+
variableValues,
190+
fragmentVariableValues,
191+
)
170192
) {
171193
continue;
172194
}
@@ -180,6 +202,7 @@ function collectFieldsImpl(
180202
case Kind.INLINE_FRAGMENT: {
181203
if (
182204
!shouldIncludeNode(
205+
context,
183206
selection,
184207
variableValues,
185208
fragmentVariableValues,
@@ -224,7 +247,12 @@ function collectFieldsImpl(
224247

225248
if (
226249
visitedFragmentNames.has(fragName) ||
227-
!shouldIncludeNode(selection, variableValues, fragmentVariableValues)
250+
!shouldIncludeNode(
251+
context,
252+
selection,
253+
variableValues,
254+
fragmentVariableValues,
255+
)
228256
) {
229257
continue;
230258
}
@@ -320,26 +348,47 @@ function getDeferUsage(
320348
* directives, where `@skip` has higher precedence than `@include`.
321349
*/
322350
function shouldIncludeNode(
351+
context: CollectFieldsContext,
323352
node: FragmentSpreadNode | FieldNode | InlineFragmentNode,
324353
variableValues: VariableValues,
325354
fragmentVariableValues: VariableValues | undefined,
326355
): boolean {
327-
const skip = getDirectiveValues(
328-
GraphQLSkipDirective,
329-
node,
330-
variableValues,
331-
fragmentVariableValues,
356+
const skipDirectiveNode = node.directives?.find(
357+
(directive) => directive.name.value === GraphQLSkipDirective.name,
332358
);
359+
if (skipDirectiveNode && context.forbidSkipAndInclude) {
360+
context.forbiddenDirectiveInstances.push(skipDirectiveNode);
361+
return false;
362+
}
363+
const skip = skipDirectiveNode
364+
? experimentalGetArgumentValues(
365+
skipDirectiveNode,
366+
GraphQLSkipDirective.args,
367+
variableValues,
368+
fragmentVariableValues,
369+
context.hideSuggestions,
370+
)
371+
: undefined;
333372
if (skip?.if === true) {
334373
return false;
335374
}
336375

337-
const include = getDirectiveValues(
338-
GraphQLIncludeDirective,
339-
node,
340-
variableValues,
341-
fragmentVariableValues,
376+
const includeDirectiveNode = node.directives?.find(
377+
(directive) => directive.name.value === GraphQLIncludeDirective.name,
342378
);
379+
if (includeDirectiveNode && context.forbidSkipAndInclude) {
380+
context.forbiddenDirectiveInstances.push(includeDirectiveNode);
381+
return false;
382+
}
383+
const include = includeDirectiveNode
384+
? experimentalGetArgumentValues(
385+
includeDirectiveNode,
386+
GraphQLIncludeDirective.args,
387+
variableValues,
388+
fragmentVariableValues,
389+
context.hideSuggestions,
390+
)
391+
: undefined;
343392
if (include?.if === false) {
344393
return false;
345394
}

Diff for: src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts

+42
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,48 @@ describe('Validate: Subscriptions with single field', () => {
286286
]);
287287
});
288288

289+
it('fails with @skip or @include directive', () => {
290+
expectErrors(`
291+
subscription RequiredRuntimeValidation($bool: Boolean!) {
292+
newMessage @include(if: $bool) {
293+
body
294+
sender
295+
}
296+
disallowedSecondRootField @skip(if: $bool)
297+
}
298+
`).toDeepEqual([
299+
{
300+
message:
301+
'Subscription "RequiredRuntimeValidation" must not use `@skip` or `@include` directives in the top level selection.',
302+
locations: [
303+
{ line: 3, column: 20 },
304+
{ line: 7, column: 35 },
305+
],
306+
},
307+
]);
308+
});
309+
310+
it('fails with @skip or @include directive in anonymous subscription', () => {
311+
expectErrors(`
312+
subscription ($bool: Boolean!) {
313+
newMessage @include(if: $bool) {
314+
body
315+
sender
316+
}
317+
disallowedSecondRootField @skip(if: $bool)
318+
}
319+
`).toDeepEqual([
320+
{
321+
message:
322+
'Anonymous Subscription must not use `@skip` or `@include` directives in the top level selection.',
323+
locations: [
324+
{ line: 3, column: 20 },
325+
{ line: 7, column: 35 },
326+
],
327+
},
328+
]);
329+
});
330+
289331
it('skips if not subscription type', () => {
290332
const emptySchema = buildSchema(`
291333
type Query {

Diff for: src/validation/rules/SingleFieldSubscriptionsRule.ts

+23-9
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ function toNodes(fieldDetailsList: FieldDetailsList): ReadonlyArray<FieldNode> {
2323
* Subscriptions must only include a non-introspection field.
2424
*
2525
* A GraphQL subscription is valid only if it contains a single root field and
26-
* that root field is not an introspection field.
26+
* that root field is not an introspection field. `@skip` and `@include`
27+
* directives are forbidden.
2728
*
2829
* See https://spec.graphql.org/draft/#sec-Single-root-field
2930
*/
@@ -45,14 +46,27 @@ export function SingleFieldSubscriptionsRule(
4546
fragments[definition.name.value] = { definition };
4647
}
4748
}
48-
const { groupedFieldSet } = collectFields(
49-
schema,
50-
fragments,
51-
variableValues,
52-
subscriptionType,
53-
node.selectionSet,
54-
context.hideSuggestions,
55-
);
49+
const { groupedFieldSet, forbiddenDirectiveInstances } =
50+
collectFields(
51+
schema,
52+
fragments,
53+
variableValues,
54+
subscriptionType,
55+
node.selectionSet,
56+
context.hideSuggestions,
57+
true,
58+
);
59+
if (forbiddenDirectiveInstances.length > 0) {
60+
context.reportError(
61+
new GraphQLError(
62+
operationName != null
63+
? `Subscription "${operationName}" must not use \`@skip\` or \`@include\` directives in the top level selection.`
64+
: 'Anonymous Subscription must not use `@skip` or `@include` directives in the top level selection.',
65+
{ nodes: forbiddenDirectiveInstances },
66+
),
67+
);
68+
return;
69+
}
5670
if (groupedFieldSet.size > 1) {
5771
const fieldDetailsLists = [...groupedFieldSet.values()];
5872
const extraFieldDetailsLists = fieldDetailsLists.slice(1);

0 commit comments

Comments
 (0)