Skip to content

Commit 85d842e

Browse files
Dimitri POSTOLOVdimitri
andauthored
🎉 New rule no-unused-fields (#451)
Co-authored-by: dimitri <[email protected]>
1 parent f939276 commit 85d842e

File tree

10 files changed

+402
-1
lines changed

10 files changed

+402
-1
lines changed

.changeset/mean-lies-invite.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+
add `no-unused-fields` rule

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
- [`avoid-typename-prefix`](./rules/avoid-typename-prefix.md)
55
- [`no-unreachable-types`](./rules/no-unreachable-types.md)
6+
- [`no-unused-fields`](./rules/no-unused-fields.md)
67
- [`no-deprecated`](./rules/no-deprecated.md)
78
- [`unique-fragment-name`](./rules/unique-fragment-name.md)
89
- [`unique-operation-name`](./rules/unique-operation-name.md)

docs/rules/no-unused-fields.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# `no-unused-fields`
2+
3+
- Category: `Best Practices`
4+
- Rule name: `@graphql-eslint/no-unused-fields`
5+
- Requires GraphQL Schema: `true` [ℹ️](../../README.md#extended-linting-rules-with-graphql-schema)
6+
- Requires GraphQL Operations: `true` [ℹ️](../../README.md#extended-linting-rules-with-siblings-operations)
7+
8+
Requires all fields to be used at some level by siblings operations
9+
10+
## Usage Examples
11+
12+
### Incorrect
13+
14+
```graphql
15+
# eslint @graphql-eslint/no-unused-fields: ["error"]
16+
17+
type User {
18+
id: ID!
19+
name: String
20+
someUnusedField: String
21+
}
22+
23+
type Query {
24+
me: User
25+
}
26+
27+
query {
28+
me {
29+
id
30+
name
31+
}
32+
}
33+
```
34+
35+
### Correct
36+
37+
```graphql
38+
# eslint @graphql-eslint/no-unused-fields: ["error"]
39+
40+
type User {
41+
id: ID!
42+
name: String
43+
}
44+
45+
type Query {
46+
me: User
47+
}
48+
49+
query {
50+
me {
51+
id
52+
name
53+
}
54+
}
55+
```

packages/plugin/src/graphql-ast.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
isListType,
1717
isNonNullType,
1818
GraphQLDirective,
19+
TypeInfo,
20+
visit,
21+
visitWithTypeInfo,
1922
} from 'graphql';
23+
import { SiblingOperations } from './sibling-operations';
2024

2125
export function createReachableTypesService(schema: GraphQLSchema): () => Set<string>;
2226
export function createReachableTypesService(schema?: GraphQLSchema): () => Set<string> | null {
@@ -128,3 +132,62 @@ export function collectReachableTypes(schema: GraphQLSchema): Set<string> {
128132
return false;
129133
}
130134
}
135+
136+
export type FieldsCache = Record<string, Set<string>>;
137+
138+
export function createUsedFieldsService(schema: GraphQLSchema, operations: SiblingOperations): () => FieldsCache | null {
139+
if (!schema || !operations) {
140+
return () => null;
141+
}
142+
143+
let cache: FieldsCache = null;
144+
145+
return () => {
146+
if (!cache) {
147+
cache = collectUsedFields(schema, operations);
148+
}
149+
150+
return cache;
151+
};
152+
}
153+
154+
export function collectUsedFields(schema: GraphQLSchema, operations: SiblingOperations): FieldsCache {
155+
const cache: FieldsCache = {};
156+
157+
const addField = (typeName, fieldName) => {
158+
const fieldType = cache[typeName] ?? (cache[typeName] = new Set());
159+
fieldType.add(fieldName);
160+
};
161+
162+
const typeInfo = new TypeInfo(schema);
163+
164+
const visitor = visitWithTypeInfo(typeInfo, {
165+
Field: {
166+
enter(node) {
167+
const fieldDef = typeInfo.getFieldDef();
168+
169+
if (!fieldDef) {
170+
// skip visiting this node if field is not defined in schema
171+
return false;
172+
}
173+
174+
const parent = typeInfo.getParentType();
175+
const fieldName = node.name.value;
176+
addField(parent.name, fieldName);
177+
178+
return undefined;
179+
},
180+
},
181+
});
182+
183+
const allDocuments = [
184+
...operations.getOperations(),
185+
...operations.getFragments(),
186+
];
187+
188+
for (const { document } of allDocuments) {
189+
visit(document, visitor);
190+
}
191+
192+
return cache;
193+
}

packages/plugin/src/parser.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { extractTokens } from './utils';
77
import { getSchema } from './schema';
88
import { getSiblingOperations } from './sibling-operations';
99
import { loadGraphqlConfig } from './graphql-config';
10-
import { createReachableTypesService } from './graphql-ast';
10+
import { createReachableTypesService, createUsedFieldsService } from './graphql-ast';
1111

1212
export function parse(code: string, options?: ParserOptions): Linter.ESLintParseResult['ast'] {
1313
return parseForESLint(code, options).ast;
@@ -22,6 +22,7 @@ export function parseForESLint(code: string, options?: ParserOptions): GraphQLES
2222
schema,
2323
siblingOperations,
2424
getReachableTypes: createReachableTypesService(schema),
25+
getUsedFields: createUsedFieldsService(schema, siblingOperations),
2526
};
2627

2728
try {

packages/plugin/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import noUnreachableTypes from './no-unreachable-types';
2+
import noUnusedFields from './no-unused-fields';
23
import noAnonymousOperations from './no-anonymous-operations';
34
import noOperationNameSuffix from './no-operation-name-suffix';
45
import requireDeprecationReason from './require-deprecation-reason';
@@ -22,6 +23,7 @@ import { GRAPHQL_JS_VALIDATIONS } from './graphql-js-validation';
2223
export const rules = {
2324
'avoid-typename-prefix': avoidTypenamePrefix,
2425
'no-unreachable-types': noUnreachableTypes,
26+
'no-unused-fields': noUnusedFields,
2527
'no-deprecated': noDeprecated,
2628
'unique-fragment-name': uniqueFragmentName,
2729
'unique-operation-name': uniqueOperationName,
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { GraphQLESLintRule } from '../types';
2+
import { requireUsedFieldsFromContext } from '../utils';
3+
4+
const UNUSED_FIELD = 'UNUSED_FIELD';
5+
const ruleName = 'no-unused-fields';
6+
7+
const rule: GraphQLESLintRule = {
8+
meta: {
9+
messages: {
10+
[UNUSED_FIELD]: `Field "{{fieldName}}" is unused`,
11+
},
12+
docs: {
13+
description: `Requires all fields to be used at some level by siblings operations`,
14+
category: 'Best Practices',
15+
url: `https://github.com/dotansimha/graphql-eslint/blob/master/docs/rules/${ruleName}.md`,
16+
requiresSiblings: true,
17+
requiresSchema: true,
18+
examples: [
19+
{
20+
title: 'Incorrect',
21+
code: /* GraphQL */ `
22+
type User {
23+
id: ID!
24+
name: String
25+
someUnusedField: String
26+
}
27+
28+
type Query {
29+
me: User
30+
}
31+
32+
query {
33+
me {
34+
id
35+
name
36+
}
37+
}
38+
`,
39+
},
40+
{
41+
title: 'Correct',
42+
code: /* GraphQL */ `
43+
type User {
44+
id: ID!
45+
name: String
46+
}
47+
48+
type Query {
49+
me: User
50+
}
51+
52+
query {
53+
me {
54+
id
55+
name
56+
}
57+
}
58+
`,
59+
},
60+
],
61+
},
62+
fixable: 'code',
63+
type: 'suggestion',
64+
},
65+
create(context) {
66+
const sourceCode = context.getSourceCode();
67+
const usedFields = requireUsedFieldsFromContext(ruleName, context);
68+
69+
return {
70+
FieldDefinition(node) {
71+
const fieldName = node.name.value;
72+
const parentTypeName = (node as any).parent.name.value;
73+
74+
const isUsed = usedFields[parentTypeName]?.has(fieldName);
75+
76+
if (isUsed) {
77+
return;
78+
}
79+
80+
context.report({
81+
node,
82+
messageId: UNUSED_FIELD,
83+
data: { fieldName },
84+
fix(fixer) {
85+
const tokenBefore = (sourceCode as any).getTokenBefore(node);
86+
const tokenAfter = (sourceCode as any).getTokenAfter(node);
87+
const isEmptyType = tokenBefore.type === '{' && tokenAfter.type === '}';
88+
89+
if (isEmptyType) {
90+
// Remove type
91+
const { parent } = node as any;
92+
const parentBeforeToken = sourceCode.getTokenBefore(parent);
93+
return parentBeforeToken
94+
? fixer.removeRange([parentBeforeToken.range[1], parent.range[1]])
95+
: fixer.remove(parent);
96+
}
97+
98+
// Remove whitespace before token
99+
return fixer.removeRange([tokenBefore.range[1], node.range[1]]);
100+
},
101+
});
102+
},
103+
};
104+
},
105+
};
106+
107+
export default rule;

packages/plugin/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ASTNode, GraphQLSchema } from 'graphql';
44
import { GraphQLParseOptions } from '@graphql-tools/utils';
55
import { GraphQLESlintRuleListener } from './testkit';
66
import { SiblingOperations } from './sibling-operations';
7+
import { FieldsCache } from './graphql-ast';
78

89
export interface ParserOptions {
910
schema?: string | string[];
@@ -19,6 +20,7 @@ export type ParserServices = {
1920
hasTypeInfo: boolean;
2021
schema: GraphQLSchema | null;
2122
getReachableTypes: () => Set<string> | null;
23+
getUsedFields: () => FieldsCache | null;
2224
};
2325

2426
export type GraphQLESLintParseResult = Linter.ESLintParseResult & {

packages/plugin/src/utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Source, Lexer, GraphQLSchema, Token, DocumentNode } from 'graphql';
22
import { GraphQLESlintRuleContext } from './types';
33
import { AST } from 'eslint';
44
import { SiblingOperations } from './sibling-operations';
5+
import { FieldsCache } from './graphql-ast';
56

67
export function requireSiblingsOperations(ruleName: string, context: GraphQLESlintRuleContext<any>): SiblingOperations {
78
if (!context || !context.parserServices) {
@@ -57,6 +58,25 @@ export function requireReachableTypesFromContext(
5758
return context.parserServices.getReachableTypes();
5859
}
5960

61+
export function requireUsedFieldsFromContext(
62+
ruleName: string,
63+
context: GraphQLESlintRuleContext<any>
64+
): FieldsCache {
65+
if (!context || !context.parserServices) {
66+
throw new Error(
67+
`Rule '${ruleName}' requires 'parserOptions.schema' to be set. See http://bit.ly/graphql-eslint-schema for more info`
68+
);
69+
}
70+
71+
if (!context.parserServices.schema) {
72+
throw new Error(
73+
`Rule '${ruleName}' requires 'parserOptions.schema' to be set and schema to be loaded. See http://bit.ly/graphql-eslint-schema for more info`
74+
);
75+
}
76+
77+
return context.parserServices.getUsedFields();
78+
}
79+
6080
function getLexer(source: Source): Lexer {
6181
// GraphQL v14
6282
const gqlLanguage = require('graphql/language');

0 commit comments

Comments
 (0)