Skip to content

Commit

Permalink
add new rule no-one-place-fragments (#1334)
Browse files Browse the repository at this point in the history
* add new rule `no-one-place-fragments`

* flip incorrect/correct examples

* add snapshots
  • Loading branch information
dimaMachina committed Dec 22, 2022
1 parent 0f7afa5 commit abcfc14
Show file tree
Hide file tree
Showing 9 changed files with 212 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/two-avocados-grab.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-eslint/eslint-plugin': minor
---

add new rule `no-one-place-fragments`
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Name            &nbs
[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][]|📦|🚀|💡
[no-fragment-cycles](rules/no-fragment-cycles.md)|A GraphQL fragment is only valid when it does not have cycles in fragments usage.|![recommended][]|📦|🔮|
[no-hashtag-description](rules/no-hashtag-description.md)|Requires to use `"""` or `"` for adding a GraphQL description instead of `#`.|![recommended][]|📄|🚀|💡
[no-one-place-fragments](rules/no-one-place-fragments.md)|Disallow fragments that are used only in one place.|![all][]|📦|🚀|
[no-root-type](rules/no-root-type.md)|Disallow using root types `mutation` and/or `subscription`.||📄|🚀|💡
[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][]|📄|🚀|💡
[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][]|📄|🚀|💡
Expand Down
51 changes: 51 additions & 0 deletions docs/rules/no-one-place-fragments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# `no-one-place-fragments`

- Category: `Operations`
- Rule name: `@graphql-eslint/no-one-place-fragments`
- Requires GraphQL Schema: `false` [ℹ️](../../README.md#extended-linting-rules-with-graphql-schema)
- Requires GraphQL Operations: `true`
[ℹ️](../../README.md#extended-linting-rules-with-siblings-operations)

Disallow fragments that are used only in one place.

## Usage Examples

### Incorrect

```graphql
# eslint @graphql-eslint/no-one-place-fragments: 'error'

fragment UserFields on User {
id
}

{
user {
...UserFields
friends {
...UserFields
}
}
}
```

### Correct

```graphql
# eslint @graphql-eslint/no-one-place-fragments: 'error'

fragment UserFields on User {
id
}

{
user {
...UserFields
}
}
```

## Resources

- [Rule source](../../packages/plugin/src/rules/no-one-place-fragments.ts)
- [Test source](../../packages/plugin/tests/no-one-place-fragments.spec.ts)
1 change: 1 addition & 0 deletions packages/plugin/src/configs/operations-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default {
fragment: 'kebab-case',
},
],
'@graphql-eslint/no-one-place-fragments': 'error',
'@graphql-eslint/unique-fragment-name': 'error',
'@graphql-eslint/unique-operation-name': 'error',
},
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { rule as noCaseInsensitiveEnumValuesDuplicates } from './no-case-insensi
import { rule as noDeprecated } from './no-deprecated';
import { rule as noDuplicateFields } from './no-duplicate-fields';
import { rule as noHashtagDescription } from './no-hashtag-description';
import { rule as noOnePlaceFragments } from './no-one-place-fragments';
import { rule as noRootType } from './no-root-type';
import { rule as noScalarResultTypeOnMutation } from './no-scalar-result-type-on-mutation';
import { rule as noTypenamePrefix } from './no-typename-prefix';
Expand Down Expand Up @@ -48,6 +49,7 @@ export const rules = {
'no-deprecated': noDeprecated,
'no-duplicate-fields': noDuplicateFields,
'no-hashtag-description': noHashtagDescription,
'no-one-place-fragments': noOnePlaceFragments,
'no-root-type': noRootType,
'no-scalar-result-type-on-mutation': noScalarResultTypeOnMutation,
'no-typename-prefix': noTypenamePrefix,
Expand Down
89 changes: 89 additions & 0 deletions packages/plugin/src/rules/no-one-place-fragments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { GraphQLESLintRule } from '../types';
import { requireSiblingsOperations } from '@graphql-eslint/eslint-plugin';
import { CWD } from '../utils';
import { relative } from 'path';
import { GraphQLESTreeNode } from '../estree-converter';
import { NameNode, visit } from 'graphql';

const RULE_ID = 'no-one-place-fragments';

export const rule: GraphQLESLintRule = {
meta: {
type: 'suggestion',
docs: {
category: 'Operations',
description: 'Disallow fragments that are used only in one place.',
url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${RULE_ID}.md`,
examples: [
{
title: 'Incorrect',
code: /* GraphQL */ `
fragment UserFields on User {
id
}
{
user {
...UserFields
friends {
...UserFields
}
}
}
`,
},
{
title: 'Correct',
code: /* GraphQL */ `
fragment UserFields on User {
id
}
{
user {
...UserFields
}
}
`,
},
],
requiresSiblings: true,
},
messages: {
[RULE_ID]: 'Fragment `{{fragmentName}}` used only once. Inline him in "{{filePath}}".',
},
schema: [],
},
create(context) {
const operations = requireSiblingsOperations(RULE_ID, context);
const allDocuments = [...operations.getOperations(), ...operations.getFragments()];

const usedFragmentsMap: Record<string, string[]> = Object.create(null);

for (const { document, filePath } of allDocuments) {
const relativeFilePath = relative(CWD, filePath);
visit(document, {
FragmentSpread({ name }) {
const spreadName = name.value;
usedFragmentsMap[spreadName] ||= [];
usedFragmentsMap[spreadName].push(relativeFilePath);
},
});
}

return {
'FragmentDefinition > Name'(node: GraphQLESTreeNode<NameNode>) {
const fragmentName = node.value;
const fragmentUsage = usedFragmentsMap[fragmentName];

if (fragmentUsage.length === 1) {
context.report({
node,
messageId: RULE_ID,
data: { fragmentName, filePath: fragmentUsage[0] },
});
}
},
};
},
};
16 changes: 16 additions & 0 deletions packages/plugin/tests/__snapshots__/no-one-place-fragments.spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Vitest Snapshot v1

exports[`should error fragment used in one place 1`] = `
#### ⌨️ Code

1 | fragment UserFields on User {
2 | id
3 | firstName
4 | }

#### ❌ Error

> 1 | fragment UserFields on User {
| ^^^^^^^^^^ Fragment \`UserFields\` used only once. Inline him in "-877628611.graphql".
2 | id
`;
12 changes: 12 additions & 0 deletions packages/plugin/tests/mocks/no-one-place-fragments.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
fragment UserFields on User {
id
}

{
user {
...UserFields
friends {
...UserFields
}
}
}
35 changes: 35 additions & 0 deletions packages/plugin/tests/no-one-place-fragments.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { join } from 'path';
import { GraphQLRuleTester } from '../src';
import { rule } from '../src/rules/no-one-place-fragments';

const ruleTester = new GraphQLRuleTester();

ruleTester.runGraphQLTests('no-one-place-fragments', rule, {
valid: [
{
name: 'ok when spread 2 times',
code: ruleTester.fromMockFile('no-one-place-fragments.graphql'),
parserOptions: {
operations: join(__dirname, 'mocks/no-one-place-fragments.graphql'),
},
},
],
invalid: [
{
name: 'should error fragment used in one place',
code: ruleTester.fromMockFile('user-fields.graphql'),
errors: [
{ message: 'Fragment `UserFields` used only once. Inline him in "-877628611.graphql".' },
],
parserOptions: {
operations: /* GraphQL */ `
{
user {
...UserFields
}
}
`,
},
},
],
});

0 comments on commit abcfc14

Please sign in to comment.