Skip to content

Commit 58b5bfd

Browse files
jycouetdimaMachina
andauthored
Fix/nested fragments for require-id-when-available (#962)
* 🚸 NEW: tests for nested fragments * 🐛 FIX: nested fragment for rule: require-id-when-available * improve rule improve report message add failing test fix test add test fragment spread inside inline fragments n level fix test add another tests simplify code fix `Intl.ListFormat` typing * add changeset * update snapshots Co-authored-by: Dimitri POSTOLOV <[email protected]>
1 parent b86425f commit 58b5bfd

File tree

5 files changed

+269
-47
lines changed

5 files changed

+269
-47
lines changed

.changeset/neat-eagles-tap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-eslint/eslint-plugin': minor
3+
---
4+
5+
fix: false negative case when nested fragments used in `require-id-when-available` rule

packages/plugin/src/rules/require-id-when-available.ts

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
1-
import { getLocation, requireGraphQLSchemaFromContext, requireSiblingsOperations } from '../utils';
1+
import { requireGraphQLSchemaFromContext, requireSiblingsOperations } from '../utils';
22
import { GraphQLESLintRule } from '../types';
3-
import { GraphQLInterfaceType, GraphQLObjectType, Kind, SelectionNode, SelectionSetNode } from 'graphql';
3+
import { GraphQLInterfaceType, GraphQLObjectType, Kind, SelectionSetNode } from 'graphql';
44
import { asArray } from '@graphql-tools/utils';
55
import { getBaseType, GraphQLESTreeNode } from '../estree-parser';
66

77
export type RequireIdWhenAvailableRuleConfig = { fieldName: string | string[] };
88

99
const RULE_ID = 'require-id-when-available';
10-
const MESSAGE_ID = 'REQUIRE_ID_WHEN_AVAILABLE';
1110
const DEFAULT_ID_FIELD_NAME = 'id';
1211

12+
declare namespace Intl {
13+
class ListFormat {
14+
constructor(locales: string, options: any);
15+
16+
public format: (items: [string]) => string;
17+
}
18+
}
19+
20+
const englishJoinWords = words => new Intl.ListFormat('en-US', { type: 'disjunction' }).format(words);
21+
1322
const rule: GraphQLESLintRule<[RequireIdWhenAvailableRuleConfig], true> = {
1423
meta: {
1524
type: 'suggestion',
@@ -59,10 +68,7 @@ const rule: GraphQLESLintRule<[RequireIdWhenAvailableRuleConfig], true> = {
5968
recommended: true,
6069
},
6170
messages: {
62-
[MESSAGE_ID]: [
63-
`Field {{ fieldName }} must be selected when it's available on a type. Please make sure to include it in your selection set!`,
64-
`If you are using fragments, make sure that all used fragments {{ checkedFragments }}specifies the field {{ fieldName }}.`,
65-
].join('\n'),
71+
[RULE_ID]: `Field{{ pluralSuffix }} {{ fieldName }} must be selected when it's available on a type.\nInclude it in your selection set{{ addition }}.`,
6672
},
6773
schema: {
6874
definitions: {
@@ -95,11 +101,9 @@ const rule: GraphQLESLintRule<[RequireIdWhenAvailableRuleConfig], true> = {
95101
const { fieldName = DEFAULT_ID_FIELD_NAME } = context.options[0] || {};
96102
const idNames = asArray(fieldName);
97103

98-
const isFound = (s: GraphQLESTreeNode<SelectionNode> | SelectionNode) =>
99-
s.kind === Kind.FIELD && idNames.includes(s.name.value);
100-
101-
// Skip check selections in FragmentDefinition
102-
const selector = 'OperationDefinition SelectionSet[parent.kind!=OperationDefinition]';
104+
// Check selections only in OperationDefinition,
105+
// skip selections of OperationDefinition and InlineFragment
106+
const selector = 'OperationDefinition SelectionSet[parent.kind!=/(^OperationDefinition|InlineFragment)$/]';
103107

104108
return {
105109
[selector](node: GraphQLESTreeNode<SelectionSetNode, true>) {
@@ -121,39 +125,56 @@ const rule: GraphQLESLintRule<[RequireIdWhenAvailableRuleConfig], true> = {
121125
}
122126
const checkedFragmentSpreads = new Set<string>();
123127

124-
for (const selection of node.selections) {
125-
if (isFound(selection)) {
126-
return;
127-
}
128-
if (selection.kind === Kind.INLINE_FRAGMENT && selection.selectionSet.selections.some(isFound)) {
129-
return;
130-
}
131-
if (selection.kind === Kind.FRAGMENT_SPREAD) {
132-
const [foundSpread] = siblings.getFragment(selection.name.value);
133-
if (foundSpread) {
134-
checkedFragmentSpreads.add(foundSpread.document.name.value);
135-
if (foundSpread.document.selectionSet.selections.some(isFound)) {
136-
return;
128+
function checkSelections(selections): boolean {
129+
let hasIdField = false;
130+
for (const selection of selections) {
131+
if (hasIdField) {
132+
return true;
133+
}
134+
135+
if (selection.kind === Kind.FIELD) {
136+
hasIdField = idNames.includes(selection.name.value);
137+
continue;
138+
}
139+
140+
if (selection.kind === Kind.FRAGMENT_SPREAD) {
141+
const [foundSpread] = siblings.getFragment(selection.name.value);
142+
if (foundSpread) {
143+
const fragmentSpread = foundSpread.document;
144+
checkedFragmentSpreads.add(fragmentSpread.name.value);
145+
hasIdField = checkSelections(fragmentSpread.selectionSet.selections);
137146
}
147+
continue;
148+
}
149+
150+
if (selection.kind === Kind.INLINE_FRAGMENT) {
151+
hasIdField = checkSelections(selection.selectionSet.selections);
138152
}
139153
}
154+
return hasIdField;
140155
}
141156

142-
const { parent } = node as any;
143-
const hasIdFieldInInterfaceSelectionSet =
144-
parent?.kind === Kind.INLINE_FRAGMENT &&
145-
parent.parent?.kind === Kind.SELECTION_SET &&
146-
parent.parent.selections.some(isFound);
147-
if (hasIdFieldInInterfaceSelectionSet) {
157+
const idFound = checkSelections(node.selections);
158+
if (idFound) {
148159
return;
149160
}
150161

162+
const pluralSuffix = idNames.length > 1 ? 's' : '';
163+
const fieldName = englishJoinWords(idNames.map(name => `\`${name}\``));
164+
const addition =
165+
checkedFragmentSpreads.size === 0
166+
? ''
167+
: ` or add to used fragment${checkedFragmentSpreads.size > 1 ? 's' : ''} ${englishJoinWords(
168+
[...checkedFragmentSpreads].map(name => `\`${name}\``)
169+
)}`;
170+
151171
context.report({
152-
loc: getLocation(node.loc),
153-
messageId: MESSAGE_ID,
172+
loc: node.loc.start,
173+
messageId: RULE_ID,
154174
data: {
155-
checkedFragments: checkedFragmentSpreads.size === 0 ? '' : `(${[...checkedFragmentSpreads].join(', ')}) `,
156-
fieldName: idNames.map(name => `"${name}"`).join(' or '),
175+
pluralSuffix,
176+
fieldName,
177+
addition,
157178
},
158179
});
159180
},

packages/plugin/tests/__snapshots__/examples.spec.ts.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,8 @@ Array [
209209
ruleId: @graphql-eslint/no-anonymous-operations,
210210
},
211211
Object {
212-
message: Field "id" must be selected when it's available on a type. Please make sure to include it in your selection set!
213-
If you are using fragments, make sure that all used fragments specifies the field "id".,
212+
message: Field \`id\` must be selected when it's available on a type.
213+
Include it in your selection set.,
214214
ruleId: @graphql-eslint/require-id-when-available,
215215
},
216216
],

packages/plugin/tests/__snapshots__/require-id-when-available.spec.ts.snap

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,36 @@ exports[` 1`] = `
44
❌ Error 1/1
55
66
> 1 | { hasId { name } }
7-
| ^ Field "id" must be selected when it's available on a type. Please make sure to include it in your selection set!
8-
If you are using fragments, make sure that all used fragments specifies the field "id".
7+
| ^ Field \`id\` must be selected when it's available on a type.
8+
Include it in your selection set.
99
`;
1010

1111
exports[` 2`] = `
1212
❌ Error 1/1
1313
1414
> 1 | { hasId { id } }
15-
| ^ Field "name" must be selected when it's available on a type. Please make sure to include it in your selection set!
16-
If you are using fragments, make sure that all used fragments specifies the field "name".
15+
| ^ Field \`name\` must be selected when it's available on a type.
16+
Include it in your selection set.
1717
`;
1818

1919
exports[` 3`] = `
2020
❌ Error 1/1
2121
2222
> 1 | { hasId { name } }
23-
| ^ Field "id" or "_id" must be selected when it's available on a type. Please make sure to include it in your selection set!
24-
If you are using fragments, make sure that all used fragments specifies the field "id" or "_id".
23+
| ^ Fields \`id\` or \`_id\` must be selected when it's available on a type.
24+
Include it in your selection set.
25+
`;
26+
27+
exports[` 4`] = `
28+
❌ Error 1/1
29+
30+
1 |
31+
2 | query User {
32+
> 3 | user {
33+
| ^ Field \`id\` must be selected when it's available on a type.
34+
Include it in your selection set or add to used fragments \`UserFullFields\`, \`UserMediumFields\`, or \`UserLightFields\`.
35+
4 | ...UserFullFields
36+
5 | }
37+
6 | }
38+
7 |
2539
`;

0 commit comments

Comments
 (0)