Skip to content

Commit 2cbaa60

Browse files
authored
require-id-when-available check nested selections in fragments (#974)
* fix `require-id-when-available`, check also selections of fragment spread * include fragment name in report message * should work as expected * update snapshots
1 parent b04a8d5 commit 2cbaa60

File tree

9 files changed

+248
-117
lines changed

9 files changed

+248
-117
lines changed

.changeset/neat-eagles-tap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
'@graphql-eslint/eslint-plugin': minor
2+
'@graphql-eslint/eslint-plugin': patch
33
---
44

55
fix: false negative case when nested fragments used in `require-id-when-available` rule

.changeset/nice-pandas-confess.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': patch
3+
---
4+
5+
fix: check nested selections in fragments in `require-id-when-available` rule

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

Lines changed: 123 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
1-
import { ARRAY_DEFAULT_OPTIONS, requireGraphQLSchemaFromContext, requireSiblingsOperations } from '../utils';
2-
import { GraphQLESLintRule, OmitRecursively } from '../types';
3-
import { GraphQLInterfaceType, GraphQLObjectType, Kind, SelectionSetNode } from 'graphql';
1+
import {
2+
ASTNode,
3+
GraphQLInterfaceType,
4+
GraphQLObjectType,
5+
GraphQLOutputType,
6+
Kind,
7+
SelectionSetNode,
8+
TypeInfo,
9+
visit,
10+
visitWithTypeInfo,
11+
} from 'graphql';
12+
import type * as ESTree from 'estree';
413
import { asArray } from '@graphql-tools/utils';
14+
import { ARRAY_DEFAULT_OPTIONS, requireGraphQLSchemaFromContext, requireSiblingsOperations } from '../utils';
15+
import { GraphQLESLintRule, OmitRecursively, ReportDescriptor } from '../types';
516
import { getBaseType, GraphQLESTreeNode } from '../estree-parser';
617

718
export type RequireIdWhenAvailableRuleConfig = { fieldName: string | string[] };
@@ -22,6 +33,7 @@ const englishJoinWords = words => new Intl.ListFormat('en-US', { type: 'disjunct
2233
const rule: GraphQLESLintRule<[RequireIdWhenAvailableRuleConfig], true> = {
2334
meta: {
2435
type: 'suggestion',
36+
// eslint-disable-next-line eslint-plugin/require-meta-has-suggestions
2537
hasSuggestions: true,
2638
docs: {
2739
category: 'Operations',
@@ -93,82 +105,132 @@ const rule: GraphQLESLintRule<[RequireIdWhenAvailableRuleConfig], true> = {
93105
},
94106
},
95107
create(context) {
96-
requireGraphQLSchemaFromContext(RULE_ID, context);
108+
const schema = requireGraphQLSchemaFromContext(RULE_ID, context);
97109
const siblings = requireSiblingsOperations(RULE_ID, context);
98110
const { fieldName = DEFAULT_ID_FIELD_NAME } = context.options[0] || {};
99111
const idNames = asArray(fieldName);
100112

101113
// Check selections only in OperationDefinition,
102114
// skip selections of OperationDefinition and InlineFragment
103115
const selector = 'OperationDefinition SelectionSet[parent.kind!=/(^OperationDefinition|InlineFragment)$/]';
116+
const typeInfo = new TypeInfo(schema);
104117

105-
return {
106-
[selector](node: GraphQLESTreeNode<SelectionSetNode, true>) {
107-
const typeInfo = node.typeInfo();
108-
if (!typeInfo.gqlType) {
109-
return;
110-
}
111-
const rawType = getBaseType(typeInfo.gqlType);
112-
const isObjectType = rawType instanceof GraphQLObjectType;
113-
const isInterfaceType = rawType instanceof GraphQLInterfaceType;
114-
if (!isObjectType && !isInterfaceType) {
115-
return;
118+
function checkFragments(node: GraphQLESTreeNode<SelectionSetNode>): void {
119+
for (const selection of node.selections) {
120+
if (selection.kind !== Kind.FRAGMENT_SPREAD) {
121+
continue;
116122
}
117123

118-
const fields = rawType.getFields();
119-
const hasIdFieldInType = idNames.some(name => fields[name]);
120-
if (!hasIdFieldInType) {
121-
return;
124+
const [foundSpread] = siblings.getFragment(selection.name.value);
125+
if (!foundSpread) {
126+
continue;
122127
}
123-
const checkedFragmentSpreads = new Set<string>();
124128

125-
const hasIdField = ({ selections }: OmitRecursively<SelectionSetNode, 'loc'>): boolean =>
126-
selections.some(selection => {
127-
if (selection.kind === Kind.FIELD) {
128-
return idNames.includes(selection.name.value);
129+
const checkedFragmentSpreads = new Set<string>();
130+
const visitor = visitWithTypeInfo(typeInfo, {
131+
SelectionSet(node, key, parent: ASTNode) {
132+
if (parent.kind === Kind.FRAGMENT_DEFINITION) {
133+
checkedFragmentSpreads.add(parent.name.value);
134+
} else if (parent.kind !== Kind.INLINE_FRAGMENT) {
135+
checkSelections(node, typeInfo.getType(), selection.loc.start, parent, checkedFragmentSpreads);
129136
}
137+
},
138+
});
130139

131-
if (selection.kind === Kind.INLINE_FRAGMENT) {
132-
return hasIdField(selection.selectionSet);
140+
visit(foundSpread.document, visitor);
141+
}
142+
}
143+
144+
function checkSelections(
145+
node: OmitRecursively<SelectionSetNode, 'loc'>,
146+
type: GraphQLOutputType,
147+
// Fragment can be placed in separate file
148+
// Provide actual fragment spread location instead of location in fragment
149+
loc: ESTree.Position,
150+
// Can't access to node.parent in GraphQL AST.Node, so pass as argument
151+
parent: any,
152+
checkedFragmentSpreads = new Set<string>()
153+
): void {
154+
const rawType = getBaseType(type);
155+
const isObjectType = rawType instanceof GraphQLObjectType;
156+
const isInterfaceType = rawType instanceof GraphQLInterfaceType;
157+
158+
if (!isObjectType && !isInterfaceType) {
159+
return;
160+
}
161+
const fields = rawType.getFields();
162+
const hasIdFieldInType = idNames.some(name => fields[name]);
163+
164+
if (!hasIdFieldInType) {
165+
return;
166+
}
167+
168+
function hasIdField({ selections }: typeof node): boolean {
169+
return selections.some(selection => {
170+
if (selection.kind === Kind.FIELD) {
171+
return idNames.includes(selection.name.value);
172+
}
173+
174+
if (selection.kind === Kind.INLINE_FRAGMENT) {
175+
return hasIdField(selection.selectionSet);
176+
}
177+
178+
if (selection.kind === Kind.FRAGMENT_SPREAD) {
179+
const [foundSpread] = siblings.getFragment(selection.name.value);
180+
if (foundSpread) {
181+
const fragmentSpread = foundSpread.document;
182+
checkedFragmentSpreads.add(fragmentSpread.name.value);
183+
return hasIdField(fragmentSpread.selectionSet);
133184
}
185+
}
186+
return false;
187+
});
188+
}
134189

135-
if (selection.kind === Kind.FRAGMENT_SPREAD) {
136-
const [foundSpread] = siblings.getFragment(selection.name.value);
137-
if (foundSpread) {
138-
const fragmentSpread = foundSpread.document;
139-
checkedFragmentSpreads.add(fragmentSpread.name.value);
140-
return hasIdField(fragmentSpread.selectionSet);
141-
}
142-
}
143-
return false;
144-
});
190+
const hasId = hasIdField(node);
145191

146-
if (hasIdField(node)) {
147-
return;
148-
}
192+
checkFragments(node as GraphQLESTreeNode<SelectionSetNode>);
149193

150-
const pluralSuffix = idNames.length > 1 ? 's' : '';
151-
const fieldName = englishJoinWords(idNames.map(name => `\`${name}\``));
152-
const addition =
153-
checkedFragmentSpreads.size === 0
154-
? ''
155-
: ` or add to used fragment${checkedFragmentSpreads.size > 1 ? 's' : ''} ${englishJoinWords(
156-
[...checkedFragmentSpreads].map(name => `\`${name}\``)
157-
)}`;
158-
159-
context.report({
160-
loc: node.loc.start,
161-
messageId: RULE_ID,
162-
data: {
163-
pluralSuffix,
164-
fieldName,
165-
addition,
166-
},
167-
suggest: idNames.map(idName => ({
168-
desc: `Add \`${idName}\` selection`,
169-
fix: fixer => fixer.insertTextBefore((node as any).selections[0], `${idName} `),
170-
})),
171-
});
194+
if (hasId) {
195+
return;
196+
}
197+
198+
const pluralSuffix = idNames.length > 1 ? 's' : '';
199+
const fieldName = englishJoinWords(idNames.map(name => `\`${(parent.alias || parent.name).value}.${name}\``));
200+
201+
const addition =
202+
checkedFragmentSpreads.size === 0
203+
? ''
204+
: ` or add to used fragment${checkedFragmentSpreads.size > 1 ? 's' : ''} ${englishJoinWords(
205+
[...checkedFragmentSpreads].map(name => `\`${name}\``)
206+
)}`;
207+
208+
const problem: ReportDescriptor = {
209+
loc,
210+
messageId: RULE_ID,
211+
data: {
212+
pluralSuffix,
213+
fieldName,
214+
addition,
215+
},
216+
};
217+
218+
// Don't provide suggestions for selections in fragments as fragment can be in a separate file
219+
if ('type' in node) {
220+
problem.suggest = idNames.map(idName => ({
221+
desc: `Add \`${idName}\` selection`,
222+
fix: fixer => fixer.insertTextBefore((node as any).selections[0], `${idName} `),
223+
}));
224+
}
225+
context.report(problem);
226+
}
227+
228+
return {
229+
[selector](node: GraphQLESTreeNode<SelectionSetNode, true>) {
230+
const typeInfo = node.typeInfo();
231+
if (typeInfo.gqlType) {
232+
checkSelections(node, typeInfo.gqlType, node.loc.start, (node as any).parent);
233+
}
172234
},
173235
};
174236
},

packages/plugin/src/rules/selection-set-depth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const rule: GraphQLESLintRule<[SelectionSetDepthRuleConfig]> = {
101101
) {
102102
try {
103103
const rawNode = node.rawNode();
104-
const fragmentsInUse = siblings ? siblings.getFragmentsInUse(rawNode, true) : [];
104+
const fragmentsInUse = siblings ? siblings.getFragmentsInUse(rawNode) : [];
105105
const document: DocumentNode = {
106106
kind: Kind.DOCUMENT,
107107
definitions: [rawNode, ...fragmentsInUse],

packages/plugin/src/sibling-operations.ts

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { resolve } from 'path';
22
import {
33
FragmentDefinitionNode,
4-
FragmentSpreadNode,
54
Kind,
65
OperationDefinitionNode,
76
SelectionSetNode,
@@ -18,15 +17,15 @@ export type OperationSource = { filePath: string; document: OperationDefinitionN
1817

1918
export type SiblingOperations = {
2019
available: boolean;
21-
getOperations(): OperationSource[];
22-
getFragments(): FragmentSource[];
2320
getFragment(fragmentName: string): FragmentSource[];
21+
getFragments(): FragmentSource[];
2422
getFragmentByType(typeName: string): FragmentSource[];
2523
getFragmentsInUse(
2624
baseOperation: OperationDefinitionNode | FragmentDefinitionNode | SelectionSetNode,
27-
recursive: boolean
25+
recursive?: boolean
2826
): FragmentDefinitionNode[];
2927
getOperation(operationName: string): OperationSource[];
28+
getOperations(): OperationSource[];
3029
getOperationByType(operationType: OperationTypeNode): OperationSource[];
3130
};
3231

@@ -91,13 +90,13 @@ export function getSiblingOperations(options: ParserOptions, gqlConfig: GraphQLC
9190

9291
return {
9392
available: false,
94-
getFragments: noopWarn,
95-
getOperations: noopWarn,
9693
getFragment: noopWarn,
94+
getFragments: noopWarn,
9795
getFragmentByType: noopWarn,
96+
getFragmentsInUse: noopWarn,
9897
getOperation: noopWarn,
98+
getOperations: noopWarn,
9999
getOperationByType: noopWarn,
100-
getFragmentsInUse: noopWarn,
101100
};
102101
}
103102

@@ -113,11 +112,11 @@ export function getSiblingOperations(options: ParserOptions, gqlConfig: GraphQLC
113112
const result: FragmentSource[] = [];
114113

115114
for (const source of siblings) {
116-
for (const definition of source.document.definitions || []) {
115+
for (const definition of source.document.definitions) {
117116
if (definition.kind === Kind.FRAGMENT_DEFINITION) {
118117
result.push({
119118
filePath: source.location,
120-
document: definition as FragmentDefinitionNode,
119+
document: definition,
121120
});
122121
}
123122
}
@@ -134,11 +133,11 @@ export function getSiblingOperations(options: ParserOptions, gqlConfig: GraphQLC
134133
const result: OperationSource[] = [];
135134

136135
for (const source of siblings) {
137-
for (const definition of source.document.definitions || []) {
136+
for (const definition of source.document.definitions) {
138137
if (definition.kind === Kind.OPERATION_DEFINITION) {
139138
result.push({
140139
filePath: source.location,
141-
document: definition as OperationDefinitionNode,
140+
document: definition,
142141
});
143142
}
144143
}
@@ -152,25 +151,22 @@ export function getSiblingOperations(options: ParserOptions, gqlConfig: GraphQLC
152151

153152
const collectFragments = (
154153
selectable: SelectionSetNode | OperationDefinitionNode | FragmentDefinitionNode,
155-
recursive = true,
156-
collected: Map<string, FragmentDefinitionNode> = new Map()
154+
recursive,
155+
collected = new Map<string, FragmentDefinitionNode>()
157156
) => {
158157
visit(selectable, {
159-
FragmentSpread(spread: FragmentSpreadNode) {
160-
const name = spread.name.value;
161-
const fragmentInfo = getFragment(name);
158+
FragmentSpread(spread) {
159+
const fragmentName = spread.name.value;
160+
const [fragment] = getFragment(fragmentName);
162161

163-
if (fragmentInfo.length === 0) {
162+
if (!fragment) {
164163
logger.warn(
165-
`Unable to locate fragment named "${name}", please make sure it's loaded using "parserOptions.operations"`
164+
`Unable to locate fragment named "${fragmentName}", please make sure it's loaded using "parserOptions.operations"`
166165
);
167166
return;
168167
}
169-
const fragment = fragmentInfo[0];
170-
const alreadyVisited = collected.has(name);
171-
172-
if (!alreadyVisited) {
173-
collected.set(name, fragment.document);
168+
if (!collected.has(fragmentName)) {
169+
collected.set(fragmentName, fragment.document);
174170
if (recursive) {
175171
collectFragments(fragment.document, recursive, collected);
176172
}
@@ -182,13 +178,13 @@ export function getSiblingOperations(options: ParserOptions, gqlConfig: GraphQLC
182178

183179
siblingOperations = {
184180
available: true,
185-
getFragments,
186-
getOperations,
187181
getFragment,
182+
getFragments,
188183
getFragmentByType: typeName => getFragments().filter(f => f.document.typeCondition?.name?.value === typeName),
184+
getFragmentsInUse: (selectable, recursive = true) => Array.from(collectFragments(selectable, recursive).values()),
189185
getOperation: name => getOperations().filter(o => o.document.name?.value === name),
186+
getOperations,
190187
getOperationByType: type => getOperations().filter(o => o.document.operation === type),
191-
getFragmentsInUse: (selectable, recursive = true) => Array.from(collectFragments(selectable, recursive).values()),
192188
};
193189

194190
siblingOperationsCache.set(siblings, siblingOperations);

0 commit comments

Comments
 (0)