Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add require-import-fragment rule #1443

Merged
merged 15 commits into from
Feb 9, 2023
2 changes: 1 addition & 1 deletion packages/plugin/src/rules/lone-executable-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const rule: GraphQLESLintRule<RuleOptions> = {
definitions.push({ type, node });
}
},
'Program:exit'() {
'Document:exit'() {
for (const { node, type } of definitions.slice(1) /* ignore first definition */) {
let name = pascalCase(type);
const definitionName = node.name?.value;
Expand Down
94 changes: 60 additions & 34 deletions packages/plugin/src/rules/require-import-fragment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import path from 'node:path';
import { NameNode } from 'graphql';
import { requireSiblingsOperations } from '@graphql-eslint/eslint-plugin';
import { GraphQLESTreeNode } from '../estree-converter/index.js';
import { GraphQLESLintRule } from '../types.js';

Expand All @@ -16,82 +18,106 @@ export const rule: GraphQLESLintRule = {
{
title: 'Incorrect',
code: /* GraphQL */ `
query MyQuery {
fooField {
...Foo
query {
foo {
...FooFields
}
}
`,
},
{
title: 'Incorrect',
code: /* GraphQL */ `
# import Bar from 'bar.graphql'
# import 'foo.graphql'

query MyQuery {
fooField {
...Foo
# import 'bar.graphql'
query {
foo {
...FooFields
}
}
`,
},
{
title: 'Incorrect',
code: /* GraphQL */ `
# import FooFields from 'bar.graphql'
query {
foo {
...FooFields
}
}
`,
},
{
title: 'Correct',
code: /* GraphQL */ `
# import Foo from 'foo.graphql'

query MyQuery {
fooField {
...Foo
# import FooFields from 'foo.graphql'
query {
foo {
...FooFields
}
}
`,
},
],
requiresSiblings: true,
},
hasSuggestions: true,
messages: {
[RULE_ID]: "Expected '{{name}}' fragment to be imported.",
[SUGGESTION_ID]: "Add import expression for '{{name}}'",
[RULE_ID]: 'Expected "{{fragmentName}}" fragment to be imported.',
[SUGGESTION_ID]: 'Add import expression for "{{fragmentName}}".',
},
schema: [],
},
create(context) {
const knownFragmentNames = new Set<string>();
const definedFragments = new Set<string>();
const fragmentSpreadNameNodes = new Set<GraphQLESTreeNode<NameNode>>();
const comments = context.getSourceCode().getAllComments();
const siblings = requireSiblingsOperations(RULE_ID, context);
const filePath = context.getFilename();

function checkFragmentSpreadNameNode(node: GraphQLESTreeNode<NameNode>) {
function checkFragmentSpreadNameNode(node: GraphQLESTreeNode<NameNode>): void {
const fragmentName = node.value;

if (knownFragmentNames.has(fragmentName)) {
if (definedFragments.has(fragmentName)) {
return;
}

const fragmentsFromSiblings = siblings.getFragment(fragmentName);

for (const comment of comments) {
if (
comment.type === 'Line' &&
comment.value.trimStart().startsWith(`import ${fragmentName} from `)
) {
return;
}
if (comment.type !== 'Line') continue;

// 1. could start with extra whitespace
// 2. match both named/default import
const isPossibleImported = new RegExp(
`^\\s*import\\s+(${fragmentName}\\s+from\\s+)?['"]`,
).test(comment.value);
if (!isPossibleImported) continue;

const extractedImportPath = comment.value.match(/(["'])((?:\1|.)*?)\1/)?.[2];
if (!extractedImportPath) continue;

const importPath = path.join(path.dirname(filePath), extractedImportPath);

const hasInSiblings = fragmentsFromSiblings.some(source => source.filePath === importPath);
if (hasInSiblings) return;
}

context.report({
node,
messageId: RULE_ID,
data: {
name: fragmentName,
},
data: { fragmentName },
suggest: [
{
messageId: SUGGESTION_ID,
data: { name: fragmentName },
data: { fragmentName },
fix(fixer) {
const suggestedPath = fragmentsFromSiblings.length
? path.relative(path.dirname(filePath), fragmentsFromSiblings[0]?.filePath)
: 'CHANGE_ME.graphql';
return fixer.insertTextBeforeRange(
[0, 0],
`# import ${fragmentName} from 'PLEASE_CHANGE.graphql'\n`,
`# import ${fragmentName} from '${suggestedPath}'\n`,
);
},
},
Expand All @@ -100,11 +126,11 @@ export const rule: GraphQLESLintRule = {
}

return {
FragmentSpread(fragmentSpreadNode) {
fragmentSpreadNameNodes.add(fragmentSpreadNode.name);
'FragmentSpread .name'(node: GraphQLESTreeNode<NameNode>) {
fragmentSpreadNameNodes.add(node);
},
FragmentDefinition(fragmentDefinitionNode) {
knownFragmentNames.add(fragmentDefinitionNode.name.value);
'FragmentDefinition .name'(node: GraphQLESTreeNode<NameNode>) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why .name and not just Name?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is because FragmentDefinition Name will match

// some abstract GraphQL AST Node
const node = {
  type: 'FragmentDefinition',
  name: { // ✅ matched 1
    type: 'Name'
    // ...
  },
  foo: { // ✅ matched 2
    type: 'Name',
    bar: { // ✅ matched 3
      type: 'Name' 
      // ...
    }
    // ...
  }
  // ...
}
  1. FragmentDefinition .name will match only node.name
  2. FragmentDefinition > Name will match only node.name and node.foo but not not.foo.bar

So for more safety, it should be FragmentDefinition > .name, I remember I got some issue due too wide selector, so it's better to be explicit while selecting nodes

definedFragments.add(node.value);
},
'Document:exit'() {
for (const fragmentSpreadNameNode of fragmentSpreadNameNodes) {
Expand Down
122 changes: 59 additions & 63 deletions packages/plugin/tests/__snapshots__/require-import-fragment.spec.md
Original file line number Diff line number Diff line change
@@ -1,87 +1,83 @@
// Vitest Snapshot v1

exports[`should report fragments when there are no appropriately named import expressions 1`] = `
exports[`should report fragments when there are no import expressions 1`] = `
#### ⌨️ Code

1 | # import Bar from 'foo.graphql'
2 |
3 | query MyQuery {
4 | fooField {
5 | ...Foo
6 | }
7 | }
1 | {
2 | foo {
3 | ...FooFields
4 | }
5 | }

#### ❌ Error

4 | fooField {
> 5 | ...Foo
| ^^^ Expected 'Foo' fragment to be imported.
6 | }

#### 💡 Suggestion: Add import expression for 'Foo'

1 | # import Foo from 'PLEASE_CHANGE.graphql'
2 | # import Bar from 'foo.graphql'
3 |
4 | query MyQuery {
5 | fooField {
6 | ...Foo
7 | }
8 | }
2 | foo {
> 3 | ...FooFields
| ^^^^^^^^^ Expected "FooFields" fragment to be imported.
4 | }

#### 💡 Suggestion: Add import expression for "FooFields".

1 | # import FooFields from 'CHANGE_ME.graphql'
2 | {
3 | foo {
4 | ...FooFields
5 | }
6 | }
`;

exports[`should report fragments when there are no import expressions 1`] = `
exports[`should report with default import 1`] = `
#### ⌨️ Code

1 | query MyQuery {
2 | fooField {
3 | ...Foo
4 | }
5 | }
1 | #import 'bar-fragment.gql'
2 | query {
3 | foo {
4 | ...FooFields
5 | }
6 | }

#### ❌ Error

2 | fooField {
> 3 | ...Foo
| ^^^ Expected 'Foo' fragment to be imported.
4 | }
3 | foo {
> 4 | ...FooFields
| ^^^^^^^^^ Expected "FooFields" fragment to be imported.
5 | }

#### 💡 Suggestion: Add import expression for 'Foo'
#### 💡 Suggestion: Add import expression for "FooFields".

1 | # import Foo from 'PLEASE_CHANGE.graphql'
2 | query MyQuery {
3 | fooField {
4 | ...Foo
5 | }
6 | }
1 | # import FooFields from 'foo-fragment.gql'
2 | #import 'bar-fragment.gql'
3 | query {
4 | foo {
5 | ...FooFields
6 | }
7 | }
`;

exports[`should report fragments when there are only invalid import expressions 1`] = `
exports[`should report with named import 1`] = `
#### ⌨️ Code

1 | # import 'foo.graphql'
2 |
3 | query MyQuery {
4 | fooField {
5 | ...Foo
6 | }
7 | }
1 | #import FooFields from "bar-fragment.gql"
2 | query {
3 | foo {
4 | ...FooFields
5 | }
6 | }

#### ❌ Error

4 | fooField {
> 5 | ...Foo
| ^^^ Expected 'Foo' fragment to be imported.
6 | }

#### 💡 Suggestion: Add import expression for 'Foo'

1 | # import Foo from 'PLEASE_CHANGE.graphql'
2 | # import 'foo.graphql'
3 |
4 | query MyQuery {
5 | fooField {
6 | ...Foo
7 | }
8 | }
3 | foo {
> 4 | ...FooFields
| ^^^^^^^^^ Expected "FooFields" fragment to be imported.
5 | }

#### 💡 Suggestion: Add import expression for "FooFields".

1 | # import FooFields from 'foo-fragment.gql'
2 | #import FooFields from "bar-fragment.gql"
3 | query {
4 | foo {
5 | ...FooFields
6 | }
7 | }
`;
3 changes: 3 additions & 0 deletions packages/plugin/tests/mocks/import-fragments/bar-fragment.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fragment BarFields on Bar {
id
}
3 changes: 3 additions & 0 deletions packages/plugin/tests/mocks/import-fragments/foo-fragment.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fragment FooFields on Foo {
id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#import 'bar-fragment.gql'
query {
foo {
...FooFields
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#import FooFields from "bar-fragment.gql"
query {
foo {
...FooFields
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Imports could have extra whitespace and double/single quotes

# import 'foo-fragment.gql'

query {
foo {
...FooFields
}
}
9 changes: 9 additions & 0 deletions packages/plugin/tests/mocks/import-fragments/valid-query.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Imports could have extra whitespace and double/single quotes

# import FooFields from "foo-fragment.gql"

query {
foo {
...FooFields
}
}
Loading