Skip to content

Commit 0a571bb

Browse files
Add require-nullable-result-in-root lint rule (#1657)
Co-authored-by: Dimitri POSTOLOV <[email protected]>
1 parent 496fc36 commit 0a571bb

File tree

10 files changed

+263
-0
lines changed

10 files changed

+263
-0
lines changed

.changeset/dull-tables-mix.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+
Add `require-nullable-result-in-root` rule to report on non-null fields in root types

packages/plugin/src/configs/schema-all.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default {
1818
'@graphql-eslint/require-deprecation-date': 'error',
1919
'@graphql-eslint/require-field-of-type-query-in-mutation-result': 'error',
2020
'@graphql-eslint/require-nullable-fields-with-oneof': 'error',
21+
'@graphql-eslint/require-nullable-result-in-root': 'error',
2122
'@graphql-eslint/require-type-pattern-with-oneof': 'error',
2223
},
2324
};

packages/plugin/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { rule as requireFieldOfTypeQueryInMutationResult } from './require-field
3131
import { rule as requireIdWhenAvailable } from './require-id-when-available.js';
3232
import { rule as requireImportFragment } from './require-import-fragment.js';
3333
import { rule as requireNullableFieldsWithOneof } from './require-nullable-fields-with-oneof.js';
34+
import { rule as requireNullableResultInRoot } from './require-nullable-result-in-root.js';
3435
import { rule as requireTypePatternWithOneof } from './require-type-pattern-with-oneof.js';
3536
import { rule as selectionSetDepth } from './selection-set-depth.js';
3637
import { rule as strictIdInTypes } from './strict-id-in-types.js';
@@ -67,6 +68,7 @@ export const rules = {
6768
'require-id-when-available': requireIdWhenAvailable,
6869
'require-import-fragment': requireImportFragment,
6970
'require-nullable-fields-with-oneof': requireNullableFieldsWithOneof,
71+
'require-nullable-result-in-root': requireNullableResultInRoot,
7072
'require-type-pattern-with-oneof': requireTypePatternWithOneof,
7173
'selection-set-depth': selectionSetDepth,
7274
'strict-id-in-types': strictIdInTypes,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Kind, ObjectTypeDefinitionNode } from 'graphql';
2+
import { GraphQLESLintRule } from '../types.js';
3+
import { getNodeName, requireGraphQLSchemaFromContext, truthy } from '../utils.js';
4+
import { GraphQLESTreeNode } from '../estree-converter/index.js';
5+
6+
const RULE_ID = 'require-nullable-result-in-root';
7+
8+
export const rule: GraphQLESLintRule = {
9+
meta: {
10+
type: 'suggestion',
11+
hasSuggestions: true,
12+
docs: {
13+
category: 'Schema',
14+
description: 'Require nullable fields in root types.',
15+
url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${RULE_ID}.md`,
16+
requiresSchema: true,
17+
examples: [
18+
{
19+
title: 'Incorrect',
20+
code: /* GraphQL */ `
21+
type Query {
22+
user: User!
23+
}
24+
`,
25+
},
26+
{
27+
title: 'Correct',
28+
code: /* GraphQL */ `
29+
type Query {
30+
foo: User
31+
baz: [User]!
32+
bar: [User!]!
33+
}
34+
`,
35+
},
36+
],
37+
},
38+
messages: {
39+
[RULE_ID]: 'Unexpected non-null result {{ resultType }} in {{ rootType }}',
40+
},
41+
schema: [],
42+
},
43+
create(context) {
44+
const schema = requireGraphQLSchemaFromContext(RULE_ID, context);
45+
const rootTypeNames = new Set(
46+
[schema.getQueryType(), schema.getMutationType(), schema.getSubscriptionType()]
47+
.filter(truthy)
48+
.map(type => type.name),
49+
);
50+
const sourceCode = context.getSourceCode();
51+
52+
return {
53+
'ObjectTypeDefinition,ObjectTypeExtension'(
54+
node: GraphQLESTreeNode<ObjectTypeDefinitionNode>,
55+
) {
56+
if (!rootTypeNames.has(node.name.value)) return;
57+
58+
for (const field of node.fields || []) {
59+
if (
60+
field.gqlType.type !== Kind.NON_NULL_TYPE ||
61+
field.gqlType.gqlType.type !== Kind.NAMED_TYPE
62+
)
63+
continue;
64+
const name = field.gqlType.gqlType.name.value;
65+
const type = schema.getType(name);
66+
const resultType = type ? getNodeName(type.astNode as any) : '';
67+
68+
context.report({
69+
node: field.gqlType,
70+
messageId: RULE_ID,
71+
data: {
72+
resultType,
73+
rootType: getNodeName(node),
74+
},
75+
suggest: [
76+
{
77+
desc: `Make ${resultType} nullable`,
78+
fix(fixer) {
79+
const text = sourceCode.getText(field.gqlType as any);
80+
81+
return fixer.replaceText(field.gqlType as any, text.replace('!', ''));
82+
},
83+
},
84+
],
85+
});
86+
}
87+
},
88+
};
89+
},
90+
};

packages/plugin/src/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export function displayNodeName(node: GraphQLESTreeNode<ASTNode>): string {
154154
export function getNodeName(node: GraphQLESTreeNode<ASTNode>): string {
155155
switch (node.kind) {
156156
case Kind.OBJECT_TYPE_DEFINITION:
157+
case Kind.OBJECT_TYPE_EXTENSION:
157158
case Kind.INTERFACE_TYPE_DEFINITION:
158159
case Kind.ENUM_TYPE_DEFINITION:
159160
case Kind.SCALAR_TYPE_DEFINITION:
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`Invalid #1 1`] = `
4+
#### ⌨️ Code
5+
6+
1 | type Query {
7+
2 | user: User!
8+
3 | }
9+
4 | type User {
10+
5 | id: ID!
11+
6 | }
12+
13+
#### ❌ Error
14+
15+
1 | type Query {
16+
> 2 | user: User!
17+
| ^^^^ Unexpected non-null result type "User" in type "Query"
18+
3 | }
19+
20+
#### 💡 Suggestion: Make type "User" nullable
21+
22+
1 | type Query {
23+
2 | user: User
24+
3 | }
25+
4 | type User {
26+
5 | id: ID!
27+
6 | }
28+
`;
29+
30+
exports[`should work with extend query 1`] = `
31+
#### ⌨️ Code
32+
33+
1 | type MyMutation
34+
2 | extend type MyMutation {
35+
3 | user: User!
36+
4 | }
37+
5 | interface User {
38+
6 | id: ID!
39+
7 | }
40+
8 | schema {
41+
9 | mutation: MyMutation
42+
10 | }
43+
44+
#### ❌ Error
45+
46+
2 | extend type MyMutation {
47+
> 3 | user: User!
48+
| ^^^^ Unexpected non-null result interface "User" in type "MyMutation"
49+
4 | }
50+
51+
#### 💡 Suggestion: Make interface "User" nullable
52+
53+
1 | type MyMutation
54+
2 | extend type MyMutation {
55+
3 | user: User
56+
4 | }
57+
5 | interface User {
58+
6 | id: ID!
59+
7 | }
60+
8 | schema {
61+
9 | mutation: MyMutation
62+
10 | }
63+
`;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { GraphQLRuleTester, ParserOptions } from '../src';
2+
import { rule } from '../src/rules/require-nullable-result-in-root';
3+
4+
const ruleTester = new GraphQLRuleTester();
5+
6+
function useSchema(code: string): { code: string; parserOptions: Omit<ParserOptions, 'filePath'> } {
7+
return {
8+
code,
9+
parserOptions: { schema: code },
10+
};
11+
}
12+
13+
ruleTester.runGraphQLTests('require-nullable-result-in-root', rule, {
14+
valid: [
15+
{
16+
...useSchema(/* GraphQL */ `
17+
type Query {
18+
foo: User
19+
baz: [User]!
20+
bar: [User!]!
21+
}
22+
type User {
23+
id: ID!
24+
}
25+
`),
26+
},
27+
],
28+
invalid: [
29+
{
30+
...useSchema(/* GraphQL */ `
31+
type Query {
32+
user: User!
33+
}
34+
type User {
35+
id: ID!
36+
}
37+
`),
38+
errors: 1,
39+
},
40+
{
41+
name: 'should work with extend query',
42+
...useSchema(/* GraphQL */ `
43+
type MyMutation
44+
extend type MyMutation {
45+
user: User!
46+
}
47+
interface User {
48+
id: ID!
49+
}
50+
schema {
51+
mutation: MyMutation
52+
}
53+
`),
54+
errors: 1,
55+
},
56+
],
57+
});

website/src/pages/rules/_meta.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"require-description": "",
3232
"require-field-of-type-query-in-mutation-result": "",
3333
"require-nullable-fields-with-oneof": "",
34+
"require-nullable-result-in-root": "",
3435
"require-type-pattern-with-oneof": "",
3536
"strict-id-in-types": "",
3637
"unique-directive-names": "",

website/src/pages/rules/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Each rule has emojis denoting:
5959
| [require-id-when-available](/rules/require-id-when-available) | Enforce selecting specific fields when they are available on the GraphQL type. | ![recommended][] | 📦 | 🚀 | 💡 |
6060
| [require-import-fragment](/rules/require-import-fragment) | Require fragments to be imported via an import expression. | | 📦 | 🚀 | 💡 |
6161
| [require-nullable-fields-with-oneof](/rules/require-nullable-fields-with-oneof) | Require `input` or `type` fields to be non-nullable with `@oneOf` directive. | ![all][] | 📄 | 🚀 |
62+
| [require-nullable-result-in-root](/rules/require-nullable-result-in-root) | Require nullable fields in root types. | ![all][] | 📄 | 🚀 | 💡 |
6263
| [require-type-pattern-with-oneof](/rules/require-type-pattern-with-oneof) | Enforce types with `@oneOf` directive have `error` and `ok` fields. | ![all][] | 📄 | 🚀 |
6364
| [scalar-leafs](/rules/scalar-leafs) | A GraphQL document is valid only if all leaf fields (fields without sub selections) are of scalar or enum types. | ![recommended][] | 📦 | 🔮 | 💡 |
6465
| [selection-set-depth](/rules/selection-set-depth) | Limit the complexity of the GraphQL operations solely by their depth. Based on [graphql-depth-limit](https://npmjs.com/package/graphql-depth-limit). | ![recommended][] | 📦 | 🚀 | 💡 |
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# `require-nullable-result-in-root`
2+
3+
💡 This rule provides
4+
[suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions)
5+
6+
- Category: `Schema`
7+
- Rule name: `@graphql-eslint/require-nullable-result-in-root`
8+
- Requires GraphQL Schema: `true`
9+
[ℹ️](/docs/getting-started#extended-linting-rules-with-graphql-schema)
10+
- Requires GraphQL Operations: `false`
11+
[ℹ️](/docs/getting-started#extended-linting-rules-with-siblings-operations)
12+
13+
Require nullable fields in root types.
14+
15+
## Usage Examples
16+
17+
### Incorrect
18+
19+
```graphql
20+
# eslint @graphql-eslint/require-nullable-result-in-root: 'error'
21+
22+
type Query {
23+
user: User!
24+
}
25+
```
26+
27+
### Correct
28+
29+
```graphql
30+
# eslint @graphql-eslint/require-nullable-result-in-root: 'error'
31+
32+
type Query {
33+
foo: User
34+
baz: [User]!
35+
bar: [User!]!
36+
}
37+
```
38+
39+
## Resources
40+
41+
- [Rule source](https://github.com/B2o5T/graphql-eslint/tree/master/packages/plugin/src/rules/require-nullable-result-in-root.ts)
42+
- [Test source](https://github.com/B2o5T/graphql-eslint/tree/master/packages/plugin/tests/require-nullable-result-in-root.spec.ts)

0 commit comments

Comments
 (0)