Skip to content

Commit d6e959b

Browse files
benjieyaacovCR
authored andcommitted
Forbid @Skip and @include directives in subscription root selection (graphql#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 c676828 commit d6e959b

File tree

4 files changed

+131
-26
lines changed

4 files changed

+131
-26
lines changed

src/execution/collectFields.ts

Lines changed: 64 additions & 15 deletions
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,
@@ -22,7 +23,10 @@ import { typeFromAST } from '../utilities/typeFromAST.js';
2223

2324
import type { GraphQLVariableSignature } from './getVariableSignature.js';
2425
import type { VariableValues } from './values.js';
25-
import { getDirectiveValues, getFragmentVariableValues } from './values.js';
26+
import {
27+
experimentalGetArgumentValues,
28+
getFragmentVariableValues,
29+
} from './values.js';
2630

2731
export interface FieldDetails {
2832
node: FieldNode;
@@ -45,6 +49,8 @@ interface CollectFieldsContext {
4549
runtimeType: GraphQLObjectType;
4650
visitedFragmentNames: Set<string>;
4751
hideSuggestions: boolean;
52+
forbiddenDirectiveInstances: Array<DirectiveNode>;
53+
forbidSkipAndInclude: boolean;
4854
}
4955

5056
/**
@@ -64,7 +70,11 @@ export function collectFields(
6470
runtimeType: GraphQLObjectType,
6571
selectionSet: SelectionSetNode,
6672
hideSuggestions: boolean,
67-
): GroupedFieldSet {
73+
forbidSkipAndInclude = false,
74+
): {
75+
groupedFieldSet: GroupedFieldSet;
76+
forbiddenDirectiveInstances: ReadonlyArray<DirectiveNode>;
77+
} {
6878
const groupedFieldSet = new AccumulatorMap<string, FieldDetails>();
6979
const context: CollectFieldsContext = {
7080
schema,
@@ -73,10 +83,15 @@ export function collectFields(
7383
runtimeType,
7484
visitedFragmentNames: new Set(),
7585
hideSuggestions,
86+
forbiddenDirectiveInstances: [],
87+
forbidSkipAndInclude,
7688
};
7789

7890
collectFieldsImpl(context, selectionSet, groupedFieldSet);
79-
return groupedFieldSet;
91+
return {
92+
groupedFieldSet,
93+
forbiddenDirectiveInstances: context.forbiddenDirectiveInstances,
94+
};
8095
}
8196

8297
/**
@@ -105,6 +120,8 @@ export function collectSubfields(
105120
runtimeType: returnType,
106121
visitedFragmentNames: new Set(),
107122
hideSuggestions,
123+
forbiddenDirectiveInstances: [],
124+
forbidSkipAndInclude: false,
108125
};
109126
const subGroupedFieldSet = new AccumulatorMap<string, FieldDetails>();
110127

@@ -143,7 +160,12 @@ function collectFieldsImpl(
143160
switch (selection.kind) {
144161
case Kind.FIELD: {
145162
if (
146-
!shouldIncludeNode(selection, variableValues, fragmentVariableValues)
163+
!shouldIncludeNode(
164+
context,
165+
selection,
166+
variableValues,
167+
fragmentVariableValues,
168+
)
147169
) {
148170
continue;
149171
}
@@ -156,6 +178,7 @@ function collectFieldsImpl(
156178
case Kind.INLINE_FRAGMENT: {
157179
if (
158180
!shouldIncludeNode(
181+
context,
159182
selection,
160183
variableValues,
161184
fragmentVariableValues,
@@ -179,7 +202,12 @@ function collectFieldsImpl(
179202

180203
if (
181204
visitedFragmentNames.has(fragName) ||
182-
!shouldIncludeNode(selection, variableValues, fragmentVariableValues)
205+
!shouldIncludeNode(
206+
context,
207+
selection,
208+
variableValues,
209+
fragmentVariableValues,
210+
)
183211
) {
184212
continue;
185213
}
@@ -222,26 +250,47 @@ function collectFieldsImpl(
222250
* directives, where `@skip` has higher precedence than `@include`.
223251
*/
224252
function shouldIncludeNode(
253+
context: CollectFieldsContext,
225254
node: FragmentSpreadNode | FieldNode | InlineFragmentNode,
226255
variableValues: VariableValues,
227256
fragmentVariableValues: VariableValues | undefined,
228257
): boolean {
229-
const skip = getDirectiveValues(
230-
GraphQLSkipDirective,
231-
node,
232-
variableValues,
233-
fragmentVariableValues,
258+
const skipDirectiveNode = node.directives?.find(
259+
(directive) => directive.name.value === GraphQLSkipDirective.name,
234260
);
261+
if (skipDirectiveNode && context.forbidSkipAndInclude) {
262+
context.forbiddenDirectiveInstances.push(skipDirectiveNode);
263+
return false;
264+
}
265+
const skip = skipDirectiveNode
266+
? experimentalGetArgumentValues(
267+
skipDirectiveNode,
268+
GraphQLSkipDirective.args,
269+
variableValues,
270+
fragmentVariableValues,
271+
context.hideSuggestions,
272+
)
273+
: undefined;
235274
if (skip?.if === true) {
236275
return false;
237276
}
238277

239-
const include = getDirectiveValues(
240-
GraphQLIncludeDirective,
241-
node,
242-
variableValues,
243-
fragmentVariableValues,
278+
const includeDirectiveNode = node.directives?.find(
279+
(directive) => directive.name.value === GraphQLIncludeDirective.name,
244280
);
281+
if (includeDirectiveNode && context.forbidSkipAndInclude) {
282+
context.forbiddenDirectiveInstances.push(includeDirectiveNode);
283+
return false;
284+
}
285+
const include = includeDirectiveNode
286+
? experimentalGetArgumentValues(
287+
includeDirectiveNode,
288+
GraphQLIncludeDirective.args,
289+
variableValues,
290+
fragmentVariableValues,
291+
context.hideSuggestions,
292+
)
293+
: undefined;
245294
if (include?.if === false) {
246295
return false;
247296
}

src/execution/execute.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ export function executeQueryOrMutationOrSubscriptionEvent(
283283
);
284284
}
285285

286-
const groupedFieldSet = collectFields(
286+
const { groupedFieldSet } = collectFields(
287287
schema,
288288
fragments,
289289
variableValues,
@@ -1622,7 +1622,7 @@ function executeSubscription(
16221622
);
16231623
}
16241624

1625-
const groupedFieldSet = collectFields(
1625+
const { groupedFieldSet } = collectFields(
16261626
schema,
16271627
fragments,
16281628
variableValues,

src/validation/__tests__/SingleFieldSubscriptionsRule-test.ts

Lines changed: 42 additions & 0 deletions
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 {

src/validation/rules/SingleFieldSubscriptionsRule.ts

Lines changed: 23 additions & 9 deletions
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)