Skip to content

Commit 44f0d73

Browse files
author
Dimitri POSTOLOV
authored
fix false negative cases for no-unreachable-types rule (#555)
1 parent 9232f09 commit 44f0d73

File tree

6 files changed

+203
-318
lines changed

6 files changed

+203
-318
lines changed

.changeset/great-beans-design.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 false negative cases for `no-unreachable-types` rule

packages/plugin/src/graphql-ast.ts

Lines changed: 46 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,5 @@
1-
import {
2-
GraphQLSchema,
3-
GraphQLFieldMap,
4-
GraphQLInputFieldMap,
5-
GraphQLField,
6-
GraphQLInputField,
7-
GraphQLInputType,
8-
GraphQLOutputType,
9-
GraphQLNamedType,
10-
GraphQLInterfaceType,
11-
GraphQLArgument,
12-
GraphQLDirective,
13-
isObjectType,
14-
isInterfaceType,
15-
isUnionType,
16-
isInputObjectType,
17-
isListType,
18-
isNonNullType,
19-
TypeInfo,
20-
visit,
21-
visitWithTypeInfo,
22-
} from 'graphql';
1+
import { GraphQLSchema, TypeInfo, ASTKindToNode, Visitor, visit, visitWithTypeInfo, parse } from 'graphql';
2+
import { printSchemaWithDirectives } from '@graphql-tools/utils';
233
import { SiblingOperations } from './sibling-operations';
244

255
export type ReachableTypes = Set<string>;
@@ -30,100 +10,52 @@ export function getReachableTypes(schema: GraphQLSchema): ReachableTypes {
3010
// We don't want cache reachableTypes on test environment
3111
// Otherwise reachableTypes will be same for all tests
3212
if (process.env.NODE_ENV !== 'test' && reachableTypesCache) {
33-
return reachableTypesCache
13+
return reachableTypesCache;
3414
}
15+
// 👀 `printSchemaWithDirectives` keep all custom directives and `printSchema` from `graphql` not
16+
const printedSchema = printSchemaWithDirectives(schema); // Returns a string representation of the schema
17+
const astNode = parse(printedSchema); // Transforms the string into ASTNode
18+
const cache = Object.create(null);
3519

36-
const reachableTypes: ReachableTypes = new Set();
37-
38-
collectFromDirectives(schema.getDirectives());
39-
collectFrom(schema.getQueryType());
40-
collectFrom(schema.getMutationType());
41-
collectFrom(schema.getSubscriptionType());
42-
43-
reachableTypesCache = reachableTypes;
44-
return reachableTypesCache;
45-
46-
function collectFromDirectives(directives: readonly GraphQLDirective[]): void {
47-
for (const directive of directives || []) {
48-
reachableTypes.add(directive.name);
49-
directive.args.forEach(collectFromArgument);
50-
}
51-
}
52-
53-
function collectFrom(type?: GraphQLNamedType): void {
54-
if (type && shouldCollect(type.name)) {
55-
if (isObjectType(type)) {
56-
collectFromFieldMap(type.getFields());
57-
collectFromInterfaces(type.getInterfaces());
58-
} else if (isInterfaceType(type)) {
59-
collectFromFieldMap(type.getFields());
60-
collectFromInterfaces(type.getInterfaces());
61-
collectFromImplementations(type);
62-
} else if (isUnionType(type)) {
63-
type.getTypes().forEach(collectFrom);
64-
} else if (isInputObjectType(type)) {
65-
collectFromInputFieldMap(type.getFields());
66-
}
67-
}
68-
}
69-
70-
function collectFromFieldMap(fieldMap: GraphQLFieldMap<any, any>): void {
71-
for (const fieldName in fieldMap) {
72-
collectFromField(fieldMap[fieldName]);
73-
}
74-
}
75-
76-
function collectFromField(field: GraphQLField<any, any>): void {
77-
collectFromOutputType(field.type);
78-
field.args.forEach(collectFromArgument);
79-
}
80-
81-
function collectFromArgument(arg: GraphQLArgument): void {
82-
collectFromInputType(arg.type);
83-
}
84-
85-
function collectFromInputFieldMap(fieldMap: GraphQLInputFieldMap): void {
86-
for (const fieldName in fieldMap) {
87-
collectFromInputField(fieldMap[fieldName]);
88-
}
89-
}
90-
91-
function collectFromInputField(field: GraphQLInputField): void {
92-
collectFromInputType(field.type);
93-
}
94-
95-
function collectFromInterfaces(interfaces: GraphQLInterfaceType[]): void {
96-
if (interfaces) {
97-
interfaces.forEach(collectFrom);
20+
const collect = (nodeType: any): void => {
21+
let node = nodeType;
22+
while (node.type) {
23+
node = node.type;
9824
}
99-
}
100-
101-
function collectFromOutputType(output: GraphQLOutputType): void {
102-
collectFrom(schema.getType(resolveName(output)));
103-
}
25+
const typeName = node.name.value;
26+
cache[typeName] ??= 0;
27+
cache[typeName] += 1;
28+
};
10429

105-
function collectFromInputType(input: GraphQLInputType): void {
106-
collectFrom(schema.getType(resolveName(input)));
107-
}
30+
const visitor: Visitor<ASTKindToNode> = {
31+
SchemaDefinition(node) {
32+
node.operationTypes.forEach(collect);
33+
},
34+
ObjectTypeDefinition(node) {
35+
[node, ...node.interfaces].forEach(collect);
36+
},
37+
UnionTypeDefinition(node) {
38+
[node, ...node.types].forEach(collect);
39+
},
40+
InputObjectTypeDefinition: collect,
41+
InterfaceTypeDefinition: collect,
42+
ScalarTypeDefinition: collect,
43+
InputValueDefinition: collect,
44+
DirectiveDefinition: collect,
45+
EnumTypeDefinition: collect,
46+
FieldDefinition: collect,
47+
Directive: collect,
48+
};
10849

109-
function collectFromImplementations(type: GraphQLInterfaceType): void {
110-
schema.getPossibleTypes(type).forEach(collectFrom);
111-
}
50+
visit(astNode, visitor);
11251

113-
function resolveName(type: GraphQLOutputType | GraphQLInputType) {
114-
if (isListType(type) || isNonNullType(type)) {
115-
return resolveName(type.ofType);
116-
}
117-
return type.name;
118-
}
52+
const operationTypeNames = new Set(['Query', 'Mutation', 'Subscription']);
53+
const usedTypes = Object.entries(cache)
54+
.filter(([typeName, usedCount]) => usedCount > 1 || operationTypeNames.has(typeName))
55+
.map(([typeName]) => typeName);
11956

120-
function shouldCollect(name: string): boolean {
121-
if (reachableTypes.has(name)) {
122-
return false;
123-
}
124-
reachableTypes.add(name);
125-
return true;
126-
}
57+
reachableTypesCache = new Set(usedTypes);
58+
return reachableTypesCache;
12759
}
12860

12961
export type UsedFields = Record<string, Set<string>>;
@@ -136,41 +68,30 @@ export function getUsedFields(schema: GraphQLSchema, operations: SiblingOperatio
13668
if (process.env.NODE_ENV !== 'test' && usedFieldsCache) {
13769
return usedFieldsCache;
13870
}
139-
14071
const usedFields: UsedFields = {};
141-
142-
const addField = (typeName, fieldName) => {
143-
const fieldType = usedFields[typeName] ?? (usedFields[typeName] = new Set());
144-
fieldType.add(fieldName);
145-
};
146-
14772
const typeInfo = new TypeInfo(schema);
73+
const allDocuments = [...operations.getOperations(), ...operations.getFragments()];
14874

14975
const visitor = visitWithTypeInfo(typeInfo, {
15076
Field: {
151-
enter(node) {
77+
enter(node): false | void {
15278
const fieldDef = typeInfo.getFieldDef();
153-
15479
if (!fieldDef) {
15580
// skip visiting this node if field is not defined in schema
15681
return false;
15782
}
158-
159-
const parent = typeInfo.getParentType();
83+
const parentTypeName = typeInfo.getParentType().name;
16084
const fieldName = node.name.value;
161-
addField(parent.name, fieldName);
16285

163-
return undefined;
86+
usedFields[parentTypeName] ??= new Set();
87+
usedFields[parentTypeName].add(fieldName);
16488
},
16589
},
16690
});
16791

168-
const allDocuments = [...operations.getOperations(), ...operations.getFragments()];
169-
17092
for (const { document } of allDocuments) {
17193
visit(document, visitor);
17294
}
173-
17495
usedFieldsCache = usedFields;
17596
return usedFieldsCache;
17697
}

packages/plugin/src/rules/no-unreachable-types.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { GraphQLESLintRule } from '../types';
22
import { requireReachableTypesFromContext } from '../utils';
33

44
const UNREACHABLE_TYPE = 'UNREACHABLE_TYPE';
5+
const RULE_NAME = 'no-unreachable-types';
56

67
const rule: GraphQLESLintRule = {
78
meta: {
@@ -11,7 +12,7 @@ const rule: GraphQLESLintRule = {
1112
docs: {
1213
description: `Requires all types to be reachable at some level by root level fields.`,
1314
category: 'Best Practices',
14-
url: `https://github.com/dotansimha/graphql-eslint/blob/master/docs/rules/no-unreachable-types.md`,
15+
url: `https://github.com/dotansimha/graphql-eslint/blob/master/docs/rules/${RULE_NAME}.md`,
1516
requiresSchema: true,
1617
examples: [
1718
{
@@ -46,18 +47,17 @@ const rule: GraphQLESLintRule = {
4647
type: 'suggestion',
4748
},
4849
create(context) {
49-
function ensureReachability(node) {
50+
const reachableTypes = requireReachableTypesFromContext(RULE_NAME, context);
51+
52+
function ensureReachability(node): void {
5053
const typeName = node.name.value;
51-
const reachableTypes = requireReachableTypesFromContext('no-unreachable-types', context);
5254

5355
if (!reachableTypes.has(typeName)) {
5456
context.report({
5557
node,
5658
messageId: UNREACHABLE_TYPE,
57-
data: {
58-
typeName,
59-
},
60-
fix: fixer => fixer.removeRange(node.range),
59+
data: { typeName },
60+
fix: fixer => fixer.remove(node),
6161
});
6262
}
6363
}

packages/plugin/src/sibling-operations.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ export type SiblingOperations = {
3131
};
3232

3333
const handleVirtualPath = (documents: Source[]): Source[] => {
34-
const filepathMap: Record<string, number> = {};
34+
const filepathMap: Record<string, number> = Object.create(null);
3535

3636
return documents.map(source => {
3737
const { location } = source;
3838
if (['.gql', '.graphql'].some(extension => location.endsWith(extension))) {
3939
return source;
4040
}
41-
filepathMap[location] = filepathMap[location] ?? -1;
41+
filepathMap[location] ??= -1;
4242
const index = filepathMap[location] += 1;
4343
return {
4444
...source,
@@ -65,16 +65,17 @@ const getSiblings = (filePath: string, gqlConfig: GraphQLConfig): Source[] => {
6565
return operationsCache.get(documentsKey);
6666
}
6767

68-
const siblings = projectForFile.loadDocumentsSync(projectForFile.documents, {
68+
const documents = projectForFile.loadDocumentsSync(projectForFile.documents, {
6969
skipGraphQLImport: true,
7070
});
71+
const siblings = handleVirtualPath(documents)
7172
operationsCache.set(documentsKey, siblings);
7273

7374
return siblings;
7475
};
7576

7677
export function getSiblingOperations(options: ParserOptions, gqlConfig: GraphQLConfig): SiblingOperations {
77-
const siblings = handleVirtualPath(getSiblings(options.filePath, gqlConfig));
78+
const siblings = getSiblings(options.filePath, gqlConfig);
7879

7980
if (siblings.length === 0) {
8081
let printed = false;

packages/plugin/tests/mocks/graphql-server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class TestGraphQLServer {
4545
}
4646
}
4747

48-
parseData(req: IncomingMessage): Promise<any | string> {
48+
private parseData(req: IncomingMessage): Promise<any | string> {
4949
return new Promise(resolve => {
5050
let data = '';
5151
req.on('data', chunk => {

0 commit comments

Comments
 (0)