Skip to content

Commit

Permalink
2️⃣ Fix unique-fragment-name and no-unused-fragments rules, skip …
Browse files Browse the repository at this point in the history
…graphql `#import` comments for siblings (#511)
  • Loading branch information
Dimitri POSTOLOV committed Jul 4, 2021
1 parent 4c14da7 commit 01ade2e
Show file tree
Hide file tree
Showing 13 changed files with 162 additions and 54 deletions.
5 changes: 5 additions & 0 deletions .changeset/witty-beans-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-eslint/eslint-plugin': patch
---

fix unique-fragment-name and no-unused-fragments rule
2 changes: 1 addition & 1 deletion docs/rules/no-unused-fragments.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
- Category: `Validation`
- Rule name: `@graphql-eslint/no-unused-fragments`
- Requires GraphQL Schema: `true` [ℹ️](../../README.md#extended-linting-rules-with-graphql-schema)
- Requires GraphQL Operations: `false` [ℹ️](../../README.md#extended-linting-rules-with-siblings-operations)
- Requires GraphQL Operations: `true` [ℹ️](../../README.md#extended-linting-rules-with-siblings-operations)

A GraphQL document is only valid if all fragment definitions are spread within operations, or spread within other fragments spread within operations.

Expand Down
13 changes: 7 additions & 6 deletions packages/plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,23 @@
"prepack": "bob prepack"
},
"dependencies": {
"@graphql-tools/utils": "~7.10.0",
"@graphql-tools/load": "~6.2.0",
"@graphql-tools/code-file-loader": "~6.3.0",
"@graphql-tools/graphql-file-loader": "~6.2.0",
"@graphql-tools/graphql-tag-pluck": "~6.5.0",
"@graphql-tools/import": "^6.3.1",
"@graphql-tools/json-file-loader": "~6.2.6",
"@graphql-tools/code-file-loader": "~6.3.0",
"@graphql-tools/load": "~6.2.0",
"@graphql-tools/url-loader": "~6.10.0",
"@graphql-tools/graphql-tag-pluck": "~6.5.0",
"@graphql-tools/utils": "~7.10.0",
"graphql-config": "^3.2.0",
"graphql-depth-limit": "1.1.0"
},
"devDependencies": {
"@types/graphql-depth-limit": "1.1.2",
"@types/eslint": "7.2.13",
"@types/graphql-depth-limit": "1.1.2",
"bob-the-bundler": "1.2.1",
"graphql-config": "3.3.0",
"graphql": "15.5.1",
"graphql-config": "3.3.0",
"typescript": "4.3.5"
},
"peerDependencies": {
Expand Down
66 changes: 50 additions & 16 deletions packages/plugin/src/rules/graphql-js-validation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { validate, GraphQLSchema, DocumentNode, ASTNode, ValidationRule } from 'graphql';
import { validateSDL } from 'graphql/validation/validate';
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';
import fs from 'fs';
import { parseImportLine, processImport } from '@graphql-tools/import';
import { existsSync } from 'fs';
import { join, dirname } from 'path';
import { GraphQLESLintRule, GraphQLESLintRuleContext } from '../types';
import { requireGraphQLSchemaFromContext } from '../utils';
import { requireGraphQLSchemaFromContext, requireSiblingsOperations } from '../utils';
import { GraphQLESTreeNode } from '../estree-parser';

function extractRuleName(stack: string | undefined): string | null {
Expand All @@ -24,7 +25,7 @@ export function validateDoc(
rules: ReadonlyArray<ValidationRule>,
ruleName: string | null = null
): void {
if (documentNode && documentNode.definitions && documentNode.definitions.length > 0) {
if (documentNode?.definitions?.length > 0) {
try {
const validationErrors = schema ? validate(schema, documentNode, rules) : validateSDL(documentNode, null, rules);

Expand Down Expand Up @@ -69,15 +70,15 @@ const validationToRule = (
}

const requiresSchema = docs.requiresSchema ?? true;

const requiresSiblings = docs.requiresSiblings ?? false;
return {
[name]: {
meta: {
docs: {
...docs,
category: 'Validation',
requiresSchema,
requiresSiblings: false,
requiresSiblings,
url: `https://github.com/dotansimha/graphql-eslint/blob/master/docs/rules/${name}.md`,
description: `${docs.description}\n\n> This rule is a wrapper around a \`graphql-js\` validation function. [You can find it's source code here](https://github.com/graphql/graphql-js/blob/main/src/validation/rules/${ruleName}Rule.ts).`,
},
Expand All @@ -96,9 +97,8 @@ const validationToRule = (
const schema = requiresSchema ? requireGraphQLSchemaFromContext(name, context) : null;

let documentNode: DocumentNode;
const filePath = context.getFilename();
const isVirtualFile = !fs.existsSync(filePath);
if (!isVirtualFile && getDocumentNode) {
const isRealFile = existsSync(context.getFilename());
if (isRealFile && getDocumentNode) {
documentNode = getDocumentNode(context);
}
validateDoc(node, context, schema, documentNode || node.rawNode(), [ruleFn], ruleName);
Expand Down Expand Up @@ -180,10 +180,8 @@ export const GRAPHQL_JS_VALIDATIONS = Object.assign(
if (!isGraphQLImportFile(code)) {
return null;
}
// Import documents if file contains '#import' comments
const fileLoader = new GraphQLFileLoader();
const graphqlAst = fileLoader.handleFileContent(code, context.getFilename(), { noLocation: true });
return graphqlAst.document;
// Import documents because file contains '#import' comments
return processImport(context.getFilename());
}
),
validationToRule('known-type-names', 'KnownTypeNames', {
Expand All @@ -202,9 +200,45 @@ export const GRAPHQL_JS_VALIDATIONS = Object.assign(
validationToRule('no-undefined-variables', 'NoUndefinedVariables', {
description: `A GraphQL operation is only valid if all variables encountered, both directly and via fragment spreads, are defined by that operation.`,
}),
validationToRule('no-unused-fragments', 'NoUnusedFragments', {
description: `A GraphQL document is only valid if all fragment definitions are spread within operations, or spread within other fragments spread within operations.`,
}),
validationToRule(
'no-unused-fragments',
'NoUnusedFragments',
{
description: `A GraphQL document is only valid if all fragment definitions are spread within operations, or spread within other fragments spread within operations.`,
requiresSiblings: true,
},
context => {
const siblings = requireSiblingsOperations('no-unused-fragments', context);
const documents = [...siblings.getOperations(), ...siblings.getFragments()]
.filter(({ document }) => isGraphQLImportFile(document.loc.source.body))
.map(({ filePath, document }) => ({
filePath,
code: document.loc.source.body,
}));

const getParentNode = (filePath: string): DocumentNode | null => {
for (const { filePath: docFilePath, code } of documents) {
const isFileImported = code
.split('\n')
.filter(isGraphQLImportFile)
.map(line => parseImportLine(line.replace('#', '')))
.some(o => filePath === join(dirname(docFilePath), o.from));

if (!isFileImported) {
continue;
}
// Import first file that import this file
const document = processImport(docFilePath);
// Import most top file that import this file
return getParentNode(docFilePath) || document;
}

return null;
};

return getParentNode(context.getFilename());
}
),
validationToRule('no-unused-variables', 'NoUnusedVariables', {
description: `A GraphQL operation is only valid if all variables defined by an operation are used, either directly or within a spread fragment.`,
}),
Expand Down
16 changes: 9 additions & 7 deletions packages/plugin/src/rules/unique-fragment-name.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { relative } from 'path';
import { GraphQLESLintRule } from '../types';
import { normalizePath, requireSiblingsOperations } from '../utils';

Expand Down Expand Up @@ -51,19 +52,20 @@ const rule: GraphQLESLintRule<[], false> = {
},
},
create(context) {
const filename = context.getFilename();
return {
FragmentDefinition: node => {
FragmentDefinition(node) {
const fragmentName = node.name?.value;
const siblings = requireSiblingsOperations('unique-fragment-name', context);

if (fragmentName) {
const siblingFragments = siblings.getFragment(fragmentName);
const conflictingFragments = siblingFragments.filter(f => {
const isSameName = f.document.name?.value === fragmentName;
const isSamePath = normalizePath(f.filePath) === normalizePath(filename);

const conflictingFragments = siblingFragments.filter(
f =>
f.document.name?.value === fragmentName &&
normalizePath(f.filePath) !== normalizePath(context.getFilename())
);
return isSameName && !isSamePath;
});

if (conflictingFragments.length > 0) {
context.report({
Expand All @@ -80,7 +82,7 @@ const rule: GraphQLESLintRule<[], false> = {
messageId: UNIQUE_FRAGMENT_NAME,
data: {
fragmentName,
fragmentsSummary: conflictingFragments.map(f => `\t${f.filePath}`).join('\n'),
fragmentsSummary: conflictingFragments.map(f => `\t${relative(process.cwd(), f.filePath)}`).join('\n'),
},
});
}
Expand Down
7 changes: 5 additions & 2 deletions packages/plugin/src/sibling-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function loadSiblings(baseDir: string, loadPaths: string[]): Source[] {
return loadDocumentsSync(loadPaths, {
cwd: baseDir,
loaders: operationsLoaders,
skipGraphQLImport: true,
});
}

Expand All @@ -69,8 +70,10 @@ export function getSiblingOperations(options: ParserOptions, gqlConfig: GraphQLC
} else {
const projectForFile = gqlConfig.getProjectForFile(options.filePath);

if (projectForFile) {
siblings = projectForFile.getDocumentsSync();
if (projectForFile && projectForFile.documents.length > 0) {
siblings = projectForFile.loadDocumentsSync(projectForFile.documents, {
skipGraphQLImport: true,
});
operationsCache.set(fileDir, siblings);
}
}
Expand Down
13 changes: 1 addition & 12 deletions packages/plugin/tests/known-fragment-names.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,6 @@ import { join } from 'path';
import { GraphQLRuleTester } from '../src';
import { GRAPHQL_JS_VALIDATIONS } from '../src/rules/graphql-js-validation';

const TEST_SCHEMA = /* GraphQL */ `
type User {
id: ID!
firstName: String!
}
type Query {
user: User
}
`;

const ruleTester = new GraphQLRuleTester();

ruleTester.runGraphQLTests('known-fragment-names', GRAPHQL_JS_VALIDATIONS['known-fragment-names'], {
Expand All @@ -21,7 +10,7 @@ ruleTester.runGraphQLTests('known-fragment-names', GRAPHQL_JS_VALIDATIONS['known
filename: join(__dirname, 'mocks/user.graphql'),
code: ruleTester.fromMockFile('user.graphql'),
parserOptions: {
schema: TEST_SCHEMA,
schema: join(__dirname, 'mocks/user-schema.graphql'),
},
},
],
Expand Down
7 changes: 7 additions & 0 deletions packages/plugin/tests/mocks/post-fields.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#import './user-fields.graphql'

fragment PostFields on Post {
user {
...UserFields
}
}
7 changes: 7 additions & 0 deletions packages/plugin/tests/mocks/post.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#import "./post-fields.graphql"

query Post {
post {
...PostFields
}
}
14 changes: 14 additions & 0 deletions packages/plugin/tests/mocks/user-schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
type User {
id: ID!
firstName: String!
}

type Post {
id: ID!
user: User!
}

type Query {
user: User
post: Post
}
31 changes: 31 additions & 0 deletions packages/plugin/tests/no-unused-fragments.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { join } from 'path';
import { GraphQLRuleTester } from '../src';
import { GRAPHQL_JS_VALIDATIONS } from '../src/rules/graphql-js-validation';

const ruleTester = new GraphQLRuleTester();

ruleTester.runGraphQLTests('no-unused-fragments', GRAPHQL_JS_VALIDATIONS['no-unused-fragments'], {
valid: [
{
filename: join(__dirname, 'mocks/user-fields.graphql'),
code: ruleTester.fromMockFile('user-fields.graphql'),
parserOptions: {
schema: join(__dirname, 'mocks/user-schema.graphql'),
operations: [join(__dirname, 'mocks/user-fields.graphql'), join(__dirname, 'mocks/user.graphql')],
},
},
{
filename: join(__dirname, 'mocks/user-fields.graphql'),
code: ruleTester.fromMockFile('user-fields.graphql'),
parserOptions: {
schema: join(__dirname, 'mocks/user-schema.graphql'),
operations: [
join(__dirname, 'mocks/user-fields.graphql'),
join(__dirname, 'mocks/post-fields.graphql'),
join(__dirname, 'mocks/post.graphql'),
],
},
},
],
invalid: [],
});
27 changes: 17 additions & 10 deletions packages/plugin/tests/unique-fragment-name.spec.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,40 @@
import { GraphQLRuleTester } from '../src/testkit';
import { join } from 'path';
import { GraphQLRuleTester, ParserOptions } from '../src';
import rule from '../src/rules/unique-fragment-name';
import { ParserOptions } from '../src/types';

const TEST_FRAGMENT = `fragment HasIdFields on HasId {
id
}`;
const TEST_FRAGMENT = /* GraphQL */ `
fragment HasIdFields on HasId {
id
}
`;

const SIBLING_FRAGMENTS = (fragments: string[] = [TEST_FRAGMENT]) => ({
const SIBLING_FRAGMENTS = (...operations: string[]) => ({
parserOptions: <ParserOptions>{
operations: fragments,
operations,
},
});
const ruleTester = new GraphQLRuleTester();

ruleTester.runGraphQLTests('unique-fragment-name', rule, {
valid: [
{
...SIBLING_FRAGMENTS(),
...SIBLING_FRAGMENTS(TEST_FRAGMENT),
code: `fragment Test on U { a b c }`,
},
{
...SIBLING_FRAGMENTS(join(__dirname, 'mocks/user-fields.graphql'), join(__dirname, 'mocks/user.graphql')),
filename: join(__dirname, 'mocks/user-fields.graphql'),
code: ruleTester.fromMockFile('user-fields.graphql'),
},
],
invalid: [
{
...SIBLING_FRAGMENTS(),
...SIBLING_FRAGMENTS(TEST_FRAGMENT),
code: `fragment HasIdFields on U { a b c }`,
errors: [{ messageId: 'UNIQUE_FRAGMENT_NAME' }],
},
{
...SIBLING_FRAGMENTS([TEST_FRAGMENT, `fragment HasIdFields on U { t }`]),
...SIBLING_FRAGMENTS(TEST_FRAGMENT, `fragment HasIdFields on U { t }`),
code: `fragment HasIdFields on U { a b c }`,
errors: [{ messageId: 'UNIQUE_FRAGMENT_NAME' }],
},
Expand Down
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1745,6 +1745,14 @@
resolve-from "5.0.0"
tslib "~2.1.0"

"@graphql-tools/import@^6.3.1":
version "6.3.1"
resolved "https://registry.yarnpkg.com/@graphql-tools/import/-/import-6.3.1.tgz#731c47ab6c6ac9f7994d75c76b6c2fa127d2d483"
integrity sha512-1szR19JI6WPibjYurMLdadHKZoG9C//8I/FZ0Dt4vJSbrMdVNp8WFxg4QnZrDeMG4MzZc90etsyF5ofKjcC+jw==
dependencies:
resolve-from "5.0.0"
tslib "~2.2.0"

"@graphql-tools/json-file-loader@^6.0.0", "@graphql-tools/json-file-loader@~6.2.6":
version "6.2.6"
resolved "https://registry.yarnpkg.com/@graphql-tools/json-file-loader/-/json-file-loader-6.2.6.tgz#830482cfd3721a0799cbf2fe5b09959d9332739a"
Expand Down

0 comments on commit 01ade2e

Please sign in to comment.