Skip to content

Commit abcfc14

Browse files
authored
add new rule no-one-place-fragments (#1334)
* add new rule `no-one-place-fragments` * flip incorrect/correct examples * add snapshots
1 parent 0f7afa5 commit abcfc14

File tree

9 files changed

+212
-0
lines changed

9 files changed

+212
-0
lines changed

.changeset/two-avocados-grab.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 new rule `no-one-place-fragments`

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Name            &nbs
3636
[no-duplicate-fields](rules/no-duplicate-fields.md)|Checks for duplicate fields in selection set, variables in operation definition, or in arguments set of a field.|![recommended][]|📦|🚀|💡
3737
[no-fragment-cycles](rules/no-fragment-cycles.md)|A GraphQL fragment is only valid when it does not have cycles in fragments usage.|![recommended][]|📦|🔮|
3838
[no-hashtag-description](rules/no-hashtag-description.md)|Requires to use `"""` or `"` for adding a GraphQL description instead of `#`.|![recommended][]|📄|🚀|💡
39+
[no-one-place-fragments](rules/no-one-place-fragments.md)|Disallow fragments that are used only in one place.|![all][]|📦|🚀|
3940
[no-root-type](rules/no-root-type.md)|Disallow using root types `mutation` and/or `subscription`.||📄|🚀|💡
4041
[no-scalar-result-type-on-mutation](rules/no-scalar-result-type-on-mutation.md)|Avoid scalar result type on mutation type to make sure to return a valid state.|![all][]|📄|🚀|💡
4142
[no-typename-prefix](rules/no-typename-prefix.md)|Enforces users to avoid using the type name in a field name while defining your schema.|![recommended][]|📄|🚀|💡

docs/rules/no-one-place-fragments.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# `no-one-place-fragments`
2+
3+
- Category: `Operations`
4+
- Rule name: `@graphql-eslint/no-one-place-fragments`
5+
- Requires GraphQL Schema: `false` [ℹ️](../../README.md#extended-linting-rules-with-graphql-schema)
6+
- Requires GraphQL Operations: `true`
7+
[ℹ️](../../README.md#extended-linting-rules-with-siblings-operations)
8+
9+
Disallow fragments that are used only in one place.
10+
11+
## Usage Examples
12+
13+
### Incorrect
14+
15+
```graphql
16+
# eslint @graphql-eslint/no-one-place-fragments: 'error'
17+
18+
fragment UserFields on User {
19+
id
20+
}
21+
22+
{
23+
user {
24+
...UserFields
25+
friends {
26+
...UserFields
27+
}
28+
}
29+
}
30+
```
31+
32+
### Correct
33+
34+
```graphql
35+
# eslint @graphql-eslint/no-one-place-fragments: 'error'
36+
37+
fragment UserFields on User {
38+
id
39+
}
40+
41+
{
42+
user {
43+
...UserFields
44+
}
45+
}
46+
```
47+
48+
## Resources
49+
50+
- [Rule source](../../packages/plugin/src/rules/no-one-place-fragments.ts)
51+
- [Test source](../../packages/plugin/tests/no-one-place-fragments.spec.ts)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default {
2323
fragment: 'kebab-case',
2424
},
2525
],
26+
'@graphql-eslint/no-one-place-fragments': 'error',
2627
'@graphql-eslint/unique-fragment-name': 'error',
2728
'@graphql-eslint/unique-operation-name': 'error',
2829
},

packages/plugin/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { rule as noCaseInsensitiveEnumValuesDuplicates } from './no-case-insensi
1414
import { rule as noDeprecated } from './no-deprecated';
1515
import { rule as noDuplicateFields } from './no-duplicate-fields';
1616
import { rule as noHashtagDescription } from './no-hashtag-description';
17+
import { rule as noOnePlaceFragments } from './no-one-place-fragments';
1718
import { rule as noRootType } from './no-root-type';
1819
import { rule as noScalarResultTypeOnMutation } from './no-scalar-result-type-on-mutation';
1920
import { rule as noTypenamePrefix } from './no-typename-prefix';
@@ -48,6 +49,7 @@ export const rules = {
4849
'no-deprecated': noDeprecated,
4950
'no-duplicate-fields': noDuplicateFields,
5051
'no-hashtag-description': noHashtagDescription,
52+
'no-one-place-fragments': noOnePlaceFragments,
5153
'no-root-type': noRootType,
5254
'no-scalar-result-type-on-mutation': noScalarResultTypeOnMutation,
5355
'no-typename-prefix': noTypenamePrefix,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { GraphQLESLintRule } from '../types';
2+
import { requireSiblingsOperations } from '@graphql-eslint/eslint-plugin';
3+
import { CWD } from '../utils';
4+
import { relative } from 'path';
5+
import { GraphQLESTreeNode } from '../estree-converter';
6+
import { NameNode, visit } from 'graphql';
7+
8+
const RULE_ID = 'no-one-place-fragments';
9+
10+
export const rule: GraphQLESLintRule = {
11+
meta: {
12+
type: 'suggestion',
13+
docs: {
14+
category: 'Operations',
15+
description: 'Disallow fragments that are used only in one place.',
16+
url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${RULE_ID}.md`,
17+
examples: [
18+
{
19+
title: 'Incorrect',
20+
code: /* GraphQL */ `
21+
fragment UserFields on User {
22+
id
23+
}
24+
25+
{
26+
user {
27+
...UserFields
28+
friends {
29+
...UserFields
30+
}
31+
}
32+
}
33+
`,
34+
},
35+
{
36+
title: 'Correct',
37+
code: /* GraphQL */ `
38+
fragment UserFields on User {
39+
id
40+
}
41+
42+
{
43+
user {
44+
...UserFields
45+
}
46+
}
47+
`,
48+
},
49+
],
50+
requiresSiblings: true,
51+
},
52+
messages: {
53+
[RULE_ID]: 'Fragment `{{fragmentName}}` used only once. Inline him in "{{filePath}}".',
54+
},
55+
schema: [],
56+
},
57+
create(context) {
58+
const operations = requireSiblingsOperations(RULE_ID, context);
59+
const allDocuments = [...operations.getOperations(), ...operations.getFragments()];
60+
61+
const usedFragmentsMap: Record<string, string[]> = Object.create(null);
62+
63+
for (const { document, filePath } of allDocuments) {
64+
const relativeFilePath = relative(CWD, filePath);
65+
visit(document, {
66+
FragmentSpread({ name }) {
67+
const spreadName = name.value;
68+
usedFragmentsMap[spreadName] ||= [];
69+
usedFragmentsMap[spreadName].push(relativeFilePath);
70+
},
71+
});
72+
}
73+
74+
return {
75+
'FragmentDefinition > Name'(node: GraphQLESTreeNode<NameNode>) {
76+
const fragmentName = node.value;
77+
const fragmentUsage = usedFragmentsMap[fragmentName];
78+
79+
if (fragmentUsage.length === 1) {
80+
context.report({
81+
node,
82+
messageId: RULE_ID,
83+
data: { fragmentName, filePath: fragmentUsage[0] },
84+
});
85+
}
86+
},
87+
};
88+
},
89+
};
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Vitest Snapshot v1
2+
3+
exports[`should error fragment used in one place 1`] = `
4+
#### ⌨️ Code
5+
6+
1 | fragment UserFields on User {
7+
2 | id
8+
3 | firstName
9+
4 | }
10+
11+
#### ❌ Error
12+
13+
> 1 | fragment UserFields on User {
14+
| ^^^^^^^^^^ Fragment \`UserFields\` used only once. Inline him in "-877628611.graphql".
15+
2 | id
16+
`;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
fragment UserFields on User {
2+
id
3+
}
4+
5+
{
6+
user {
7+
...UserFields
8+
friends {
9+
...UserFields
10+
}
11+
}
12+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { join } from 'path';
2+
import { GraphQLRuleTester } from '../src';
3+
import { rule } from '../src/rules/no-one-place-fragments';
4+
5+
const ruleTester = new GraphQLRuleTester();
6+
7+
ruleTester.runGraphQLTests('no-one-place-fragments', rule, {
8+
valid: [
9+
{
10+
name: 'ok when spread 2 times',
11+
code: ruleTester.fromMockFile('no-one-place-fragments.graphql'),
12+
parserOptions: {
13+
operations: join(__dirname, 'mocks/no-one-place-fragments.graphql'),
14+
},
15+
},
16+
],
17+
invalid: [
18+
{
19+
name: 'should error fragment used in one place',
20+
code: ruleTester.fromMockFile('user-fields.graphql'),
21+
errors: [
22+
{ message: 'Fragment `UserFields` used only once. Inline him in "-877628611.graphql".' },
23+
],
24+
parserOptions: {
25+
operations: /* GraphQL */ `
26+
{
27+
user {
28+
...UserFields
29+
}
30+
}
31+
`,
32+
},
33+
},
34+
],
35+
});

0 commit comments

Comments
 (0)