From 2e763b5de47e706062ec9e0e979c097d4ff01bbb Mon Sep 17 00:00:00 2001 From: dimitri Date: Fri, 20 Aug 2021 18:20:28 +0200 Subject: [PATCH] make works `match-document-filename` rule --- .changeset/nasty-pumpkins-taste.md | 5 + docs/README.md | 1 + docs/rules/match-document-filename.md | 152 +++++++++++ packages/plugin/package.json | 6 +- .../src/rules/avoid-operation-name-prefix.ts | 74 +++--- .../plugin/src/rules/avoid-typename-prefix.ts | 65 ++--- packages/plugin/src/rules/index.ts | 55 ++-- packages/plugin/src/rules/input-name.ts | 30 +-- .../src/rules/match-document-filename.ts | 250 ++++++++++++------ .../plugin/src/rules/naming-convention.ts | 29 +- .../src/rules/no-operation-name-suffix.ts | 44 ++- .../plugin/src/rules/require-description.ts | 7 +- .../plugin/src/rules/selection-set-depth.ts | 59 ++--- packages/plugin/src/utils.ts | 43 ++- .../tests/match-document-filename.spec.ts | 98 +++++++ yarn.lock | 17 ++ 16 files changed, 655 insertions(+), 280 deletions(-) create mode 100644 .changeset/nasty-pumpkins-taste.md create mode 100644 docs/rules/match-document-filename.md create mode 100644 packages/plugin/tests/match-document-filename.spec.ts diff --git a/.changeset/nasty-pumpkins-taste.md b/.changeset/nasty-pumpkins-taste.md new file mode 100644 index 00000000000..05b3d50a772 --- /dev/null +++ b/.changeset/nasty-pumpkins-taste.md @@ -0,0 +1,5 @@ +--- +'@graphql-eslint/eslint-plugin': minor +--- + +[New rule] Compare operation/fragment name to the file name diff --git a/docs/README.md b/docs/README.md index d56f2395c9d..62abe8b792c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,7 @@ Each rule has emojis denoting: | [known-type-names](rules/known-type-names.md) | A GraphQL document is only valid if referenced types (specifically variable definitions and fragment conditions) are defined by the type schema. |     đŸ”Ž | | | [lone-anonymous-operation](rules/lone-anonymous-operation.md) | A GraphQL document is only valid if when it contains an anonymous operation (the query short-hand) that it contains only that one operation definition. |     đŸ”Ž | | | [lone-schema-definition](rules/lone-schema-definition.md) | A GraphQL document is only valid if it contains only one schema definition. |     đŸ”Ž | | +| [match-document-filename](rules/match-document-filename.md) | This rule allows you to enforce that the file name should match the operation name |     đŸš€ | | | [naming-convention](rules/naming-convention.md) | Require names to follow specified conventions. |     đŸš€ | | | [no-anonymous-operations](rules/no-anonymous-operations.md) | Require name for your GraphQL operations. This is useful since most GraphQL client libraries are using the operation name for caching purposes. |     đŸš€ | | | [no-case-insensitive-enum-values-duplicates](rules/no-case-insensitive-enum-values-duplicates.md) | |     đŸš€ | 🔧 | diff --git a/docs/rules/match-document-filename.md b/docs/rules/match-document-filename.md new file mode 100644 index 00000000000..72eff1c144b --- /dev/null +++ b/docs/rules/match-document-filename.md @@ -0,0 +1,152 @@ +# `match-document-filename` + +- Category: `Best Practices` +- Rule name: `@graphql-eslint/match-document-filename` +- Requires GraphQL Schema: `false` [â„šī¸](../../README.md#extended-linting-rules-with-graphql-schema) +- Requires GraphQL Operations: `false` [â„šī¸](../../README.md#extended-linting-rules-with-siblings-operations) + +This rule allows you to enforce that the file name should match the operation name + +## Usage Examples + +### Correct + +```graphql +# eslint @graphql-eslint/match-document-filename: ['error', { fileExtension: '.gql' }] + +# user.gql +type User { + id: ID! +} +``` + +### Correct + +```graphql +# eslint @graphql-eslint/match-document-filename: ['error', { query: 'snake_case' }] + +# user_by_id.gql +query UserById { + userById(id: 5) { + id + name + fullName + } +} +``` + +### Correct + +```graphql +# eslint @graphql-eslint/match-document-filename: ['error', { fragment: { style: 'kebab-case', suffix: '.fragment' } }] + +# user-fields.fragment.gql +fragment user_fields on User { + id + email +} +``` + +### Correct + +```graphql +# eslint @graphql-eslint/match-document-filename: ['error', { mutation: { style: 'PascalCase', suffix: 'Mutation' } }] + +# DeleteUserMutation.gql +mutation DELETE_USER { + deleteUser(id: 5) +} +``` + +### Incorrect + +```graphql +# eslint @graphql-eslint/match-document-filename: ['error', { fileExtension: '.graphql' }] + +# post.gql +type Post { + id: ID! +} +``` + +### Incorrect + +```graphql +# eslint @graphql-eslint/match-document-filename: ['error', { query: 'PascalCase' }] + +# user-by-id.gql +query UserById { + userById(id: 5) { + id + name + fullName + } +} +``` + +## Config Schema + +### (array) + +The schema defines an array with all elements of the type `object`. + +The array object has the following properties: + +#### `fileExtension` (string, enum) + +This element must be one of the following enum values: + +* `.gql` +* `.graphql` + +#### `query` + +The object must be one of the following types: + +* `asString` +* `asObject` + +#### `mutation` + +The object must be one of the following types: + +* `asString` +* `asObject` + +#### `subscription` + +The object must be one of the following types: + +* `asString` +* `asObject` + +#### `fragment` + +The object must be one of the following types: + +* `asString` +* `asObject` + +--- + +# Sub Schemas + +The schema defines the following additional types: + +## `asString` (string) + +One of: `camelCase`, `PascalCase`, `snake_case`, `UPPER_CASE`, `kebab-case` + +## `asObject` (object) + +Properties of the `asObject` object: + +### `style` (string, enum) + +This element must be one of the following enum values: + +* `camelCase` +* `PascalCase` +* `snake_case` +* `UPPER_CASE` +* `kebab-case` \ No newline at end of file diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 1c7fde16bb2..2fb3148a080 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -31,14 +31,16 @@ "@graphql-tools/import": "^6.3.1", "@graphql-tools/utils": "^8.0.2", "graphql-config": "^4.0.1", - "graphql-depth-limit": "1.1.0" + "graphql-depth-limit": "1.1.0", + "lodash.lowercase": "^4.3.0" }, "devDependencies": { "@types/eslint": "7.28.0", "@types/graphql-depth-limit": "1.1.2", "bob-the-bundler": "1.5.1", "graphql": "15.5.0", - "typescript": "4.3.5" + "typescript": "4.3.5", + "@types/lodash.camelcase": "^4.3.6" }, "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0" diff --git a/packages/plugin/src/rules/avoid-operation-name-prefix.ts b/packages/plugin/src/rules/avoid-operation-name-prefix.ts index 2a986eb058c..978abfde5a3 100644 --- a/packages/plugin/src/rules/avoid-operation-name-prefix.ts +++ b/packages/plugin/src/rules/avoid-operation-name-prefix.ts @@ -1,4 +1,4 @@ -import { GraphQLESLintRule, GraphQLESLintRuleContext } from '../types'; +import { GraphQLESLintRule } from '../types'; import { GraphQLESTreeNode } from '../estree-parser/estree-ast'; import { OperationDefinitionNode, FragmentDefinitionNode } from 'graphql'; @@ -11,41 +11,6 @@ export type AvoidOperationNamePrefixConfig = [ const AVOID_OPERATION_NAME_PREFIX = 'AVOID_OPERATION_NAME_PREFIX'; -function verifyRule( - context: GraphQLESLintRuleContext, - node: GraphQLESTreeNode | GraphQLESTreeNode -) { - const config = context.options[0] || { keywords: [], caseSensitive: false }; - const caseSensitive = config.caseSensitive; - const keywords = config.keywords || []; - - if (node && node.name && node.name.value !== '') { - for (const keyword of keywords) { - const testKeyword = caseSensitive ? keyword : keyword.toLowerCase(); - const testName = caseSensitive ? node.name.value : node.name.value.toLowerCase(); - - if (testName.startsWith(testKeyword)) { - context.report({ - loc: { - start: { - line: node.name.loc.start.line, - column: node.name.loc.start.column - 1, - }, - end: { - line: node.name.loc.start.line, - column: node.name.loc.start.column + testKeyword.length - 1, - }, - }, - data: { - invalidPrefix: keyword, - }, - messageId: AVOID_OPERATION_NAME_PREFIX, - }); - } - } - } -} - const rule: GraphQLESLintRule = { meta: { type: 'suggestion', @@ -100,11 +65,38 @@ const rule: GraphQLESLintRule = { }, create(context) { return { - OperationDefinition(node) { - verifyRule(context, node); - }, - FragmentDefinition(node) { - verifyRule(context, node); + 'OperationDefinition, FragmentDefinition'( + node: GraphQLESTreeNode + ) { + const config = context.options[0] || { keywords: [], caseSensitive: false }; + const caseSensitive = config.caseSensitive; + const keywords = config.keywords || []; + + if (node && node.name && node.name.value !== '') { + for (const keyword of keywords) { + const testKeyword = caseSensitive ? keyword : keyword.toLowerCase(); + const testName = caseSensitive ? node.name.value : node.name.value.toLowerCase(); + + if (testName.startsWith(testKeyword)) { + context.report({ + loc: { + start: { + line: node.name.loc.start.line, + column: node.name.loc.start.column - 1, + }, + end: { + line: node.name.loc.start.line, + column: node.name.loc.start.column + testKeyword.length - 1, + }, + }, + data: { + invalidPrefix: keyword, + }, + messageId: AVOID_OPERATION_NAME_PREFIX, + }); + } + } + } }, }; }, diff --git a/packages/plugin/src/rules/avoid-typename-prefix.ts b/packages/plugin/src/rules/avoid-typename-prefix.ts index 05621f37e5b..b8dcf7f3647 100644 --- a/packages/plugin/src/rules/avoid-typename-prefix.ts +++ b/packages/plugin/src/rules/avoid-typename-prefix.ts @@ -1,32 +1,14 @@ -import { FieldDefinitionNode } from 'graphql'; +import { + InterfaceTypeDefinitionNode, + InterfaceTypeExtensionNode, + ObjectTypeDefinitionNode, + ObjectTypeExtensionNode, +} from 'graphql'; import { GraphQLESTreeNode } from '../estree-parser'; -import { GraphQLESLintRule, GraphQLESLintRuleContext } from '../types'; +import { GraphQLESLintRule } from '../types'; const AVOID_TYPENAME_PREFIX = 'AVOID_TYPENAME_PREFIX'; -function checkNode( - context: GraphQLESLintRuleContext, - typeName: string, - fields: GraphQLESTreeNode[] -) { - const lowerTypeName = (typeName || '').toLowerCase(); - - for (const field of fields) { - const fieldName = field.name.value || ''; - - if (fieldName && lowerTypeName && fieldName.toLowerCase().startsWith(lowerTypeName)) { - context.report({ - node: field.name, - data: { - fieldName, - typeName, - }, - messageId: AVOID_TYPENAME_PREFIX, - }); - } - } -} - const rule: GraphQLESLintRule = { meta: { type: 'suggestion', @@ -60,17 +42,28 @@ const rule: GraphQLESLintRule = { }, create(context) { return { - ObjectTypeDefinition(node) { - checkNode(context, node.name.value, node.fields); - }, - ObjectTypeExtension(node) { - checkNode(context, node.name.value, node.fields); - }, - InterfaceTypeDefinition(node) { - checkNode(context, node.name.value, node.fields); - }, - InterfaceTypeExtension(node) { - checkNode(context, node.name.value, node.fields); + 'ObjectTypeDefinition, ObjectTypeExtension, InterfaceTypeDefinition, InterfaceTypeExtension'( + node: GraphQLESTreeNode< + ObjectTypeDefinitionNode | ObjectTypeExtensionNode | InterfaceTypeDefinitionNode | InterfaceTypeExtensionNode + > + ) { + const typeName = node.name.value; + const lowerTypeName = (typeName || '').toLowerCase(); + + for (const field of node.fields) { + const fieldName = field.name.value || ''; + + if (fieldName && lowerTypeName && fieldName.toLowerCase().startsWith(lowerTypeName)) { + context.report({ + node: field.name, + data: { + fieldName, + typeName, + }, + messageId: AVOID_TYPENAME_PREFIX, + }); + } + } }, }; }, diff --git a/packages/plugin/src/rules/index.ts b/packages/plugin/src/rules/index.ts index f28be6b60df..f5ac489b112 100644 --- a/packages/plugin/src/rules/index.ts +++ b/packages/plugin/src/rules/index.ts @@ -1,45 +1,48 @@ -import noUnreachableTypes from './no-unreachable-types'; -import noUnusedFields from './no-unused-fields'; -import noAnonymousOperations from './no-anonymous-operations'; -import noOperationNameSuffix from './no-operation-name-suffix'; -import requireDeprecationReason from './require-deprecation-reason'; +import { GRAPHQL_JS_VALIDATIONS } from './graphql-js-validation'; +/* eslint sort-imports: 'error', sort-keys: 'error' */ +import avoidDuplicateFields from './avoid-duplicate-fields'; import avoidOperationNamePrefix from './avoid-operation-name-prefix'; -import noCaseInsensitiveEnumValuesDuplicates from './no-case-insensitive-enum-values-duplicates'; -import requireDescription from './require-description'; -import requireIdWhenAvailable from './require-id-when-available'; +import avoidTypenamePrefix from './avoid-typename-prefix'; import descriptionStyle from './description-style'; -import namingConvention from './naming-convention'; import inputName from './input-name'; -import uniqueFragmentName from './unique-fragment-name'; -import uniqueOperationName from './unique-operation-name'; +import matchDocumentFilename from './match-document-filename'; +import namingConvention from './naming-convention'; +import noAnonymousOperations from './no-anonymous-operations'; +import noCaseInsensitiveEnumValuesDuplicates from './no-case-insensitive-enum-values-duplicates'; import noDeprecated from './no-deprecated'; import noHashtagDescription from './no-hashtag-description'; +import noOperationNameSuffix from './no-operation-name-suffix'; +import noUnreachableTypes from './no-unreachable-types'; +import noUnusedFields from './no-unused-fields'; +import requireDeprecationReason from './require-deprecation-reason'; +import requireDescription from './require-description'; +import requireIdWhenAvailable from './require-id-when-available'; import selectionSetDepth from './selection-set-depth'; -import avoidDuplicateFields from './avoid-duplicate-fields'; import strictIdInTypes from './strict-id-in-types'; -import avoidTypenamePrefix from './avoid-typename-prefix'; -import { GRAPHQL_JS_VALIDATIONS } from './graphql-js-validation'; +import uniqueFragmentName from './unique-fragment-name'; +import uniqueOperationName from './unique-operation-name'; export const rules = { + 'avoid-duplicate-fields': avoidDuplicateFields, + 'avoid-operation-name-prefix': avoidOperationNamePrefix, 'avoid-typename-prefix': avoidTypenamePrefix, - 'no-unreachable-types': noUnreachableTypes, - 'no-unused-fields': noUnusedFields, + 'description-style': descriptionStyle, + 'input-name': inputName, + 'match-document-filename': matchDocumentFilename, + 'naming-convention': namingConvention, + 'no-anonymous-operations': noAnonymousOperations, + 'no-case-insensitive-enum-values-duplicates': noCaseInsensitiveEnumValuesDuplicates, 'no-deprecated': noDeprecated, - 'unique-fragment-name': uniqueFragmentName, - 'unique-operation-name': uniqueOperationName, 'no-hashtag-description': noHashtagDescription, - 'no-anonymous-operations': noAnonymousOperations, 'no-operation-name-suffix': noOperationNameSuffix, + 'no-unreachable-types': noUnreachableTypes, + 'no-unused-fields': noUnusedFields, 'require-deprecation-reason': requireDeprecationReason, - 'avoid-operation-name-prefix': avoidOperationNamePrefix, - 'selection-set-depth': selectionSetDepth, - 'no-case-insensitive-enum-values-duplicates': noCaseInsensitiveEnumValuesDuplicates, 'require-description': requireDescription, 'require-id-when-available': requireIdWhenAvailable, - 'description-style': descriptionStyle, - 'avoid-duplicate-fields': avoidDuplicateFields, - 'naming-convention': namingConvention, - 'input-name': inputName, + 'selection-set-depth': selectionSetDepth, 'strict-id-in-types': strictIdInTypes, + 'unique-fragment-name': uniqueFragmentName, + 'unique-operation-name': uniqueOperationName, ...GRAPHQL_JS_VALIDATIONS, }; diff --git a/packages/plugin/src/rules/input-name.ts b/packages/plugin/src/rules/input-name.ts index 9b30170ca1f..a3338feee02 100644 --- a/packages/plugin/src/rules/input-name.ts +++ b/packages/plugin/src/rules/input-name.ts @@ -1,4 +1,5 @@ import { GraphQLESLintRule } from '../types'; +import { isMutationType, isQueryType } from '../utils'; type InputNameRuleConfig = { checkInputType?: boolean; @@ -83,18 +84,6 @@ const rule: GraphQLESLintRule = { ...context?.options?.[0], }; - const isMutationType = node => { - return ( - (node.type === 'ObjectTypeDefinition' || node.type === 'ObjectTypeExtension') && node.name.value === 'Mutation' - ); - }; - - const isQueryType = node => { - return ( - (node.type === 'ObjectTypeDefinition' || node.type === 'ObjectTypeExtension') && node.name.value === 'Query' - ); - }; - const shouldCheckType = node => (options.checkMutations && isMutationType(node)) || (options.checkQueries && isQueryType(node)); @@ -123,19 +112,18 @@ const rule: GraphQLESLintRule = { if (shouldCheckType(inputValueNode.parent.parent)) { const mutationName = `${inputValueNode.parent.name.value}Input`; - if (options.caseSensitiveInputType) { - if (node.name.value !== mutationName) { - context.report({ node, message: `InputType "${node.name.value}" name should be "${mutationName}"` }); - } - } else { - if (node.name.value.toLowerCase() !== mutationName.toLowerCase()) { - context.report({ node, message: `InputType "${node.name.value}" name should be "${mutationName}"` }); - } + if ( + (options.caseSensitiveInputType && node.name.value !== mutationName) || + node.name.value.toLowerCase() !== mutationName.toLowerCase() + ) { + context.report({ + node, + message: `InputType "${node.name.value}" name should be "${mutationName}"`, + }); } } }; } - return listeners; }, }; diff --git a/packages/plugin/src/rules/match-document-filename.ts b/packages/plugin/src/rules/match-document-filename.ts index 690f77bb2a6..a6667fa6afb 100644 --- a/packages/plugin/src/rules/match-document-filename.ts +++ b/packages/plugin/src/rules/match-document-filename.ts @@ -1,42 +1,66 @@ +import { basename, extname } from 'path'; +import { existsSync } from 'fs'; +import { FragmentDefinitionNode, Kind, OperationDefinitionNode } from 'graphql'; +import { CaseStyle, convertCase } from '../utils'; import { GraphQLESLintRule } from '../types'; -import { basename } from 'path'; -import { OperationTypeNode } from 'graphql'; +import { GraphQLESTreeNode } from '../estree-parser'; -const MATCH_DOCUMENT_FILENAME = 'MATCH_DOCUMENT_FILENAME'; +const MATCH_EXTENSION = 'MATCH_EXTENSION'; +const MATCH_STYLE = 'MATCH_STYLE'; + +const ACCEPTED_EXTENSIONS: ['.gql', '.graphql'] = ['.gql', '.graphql']; +const CASE_STYLES: ['camelCase', 'PascalCase', 'snake_case', 'UPPER_CASE', 'kebab-case'] = [ + CaseStyle.camelCase, + CaseStyle.pascalCase, + CaseStyle.snakeCase, + CaseStyle.upperCase, + CaseStyle.kebabCase, +]; + +type PropertySchema = { + style: CaseStyle; + suffix: string; +}; type MatchDocumentFilenameRuleConfig = [ { - compareSections: boolean; - ignoreCase: boolean; + fileExtension?: typeof ACCEPTED_EXTENSIONS[number]; + query?: CaseStyle | PropertySchema; + mutation?: CaseStyle | PropertySchema; + subscription?: CaseStyle | PropertySchema; + fragment?: CaseStyle | PropertySchema; } ]; -function checkNameValidity( - docName: string, - docType: OperationTypeNode | 'fragment', - fileName: string, - options: MatchDocumentFilenameRuleConfig[number] -): boolean { - // TODO: Implement this - return true; -} - -const rule: GraphQLESLintRule = { +const schemaOption = { + oneOf: [{ $ref: '#/definitions/asString' }, { $ref: '#/definitions/asObject' }], +}; + +const rule: GraphQLESLintRule = { meta: { type: 'suggestion', docs: { category: 'Best Practices', - description: `This rule allow you to enforce that the file name should match the operation name.`, + description: 'This rule allows you to enforce that the file name should match the operation name', url: `https://github.com/dotansimha/graphql-eslint/blob/master/docs/rules/match-document-filename.md`, - requiresSchema: false, - requiresSiblings: false, examples: [ { title: 'Correct', + usage: [{ fileExtension: '.gql' }], code: /* GraphQL */ ` - # me.graphql - query me { - me { + # user.gql + type User { + id: ID! + } + `, + }, + { + title: 'Correct', + usage: [{ query: CaseStyle.snakeCase }], + code: /* GraphQL */ ` + # user_by_id.gql + query UserById { + userById(id: 5) { id name fullName @@ -44,12 +68,44 @@ const rule: GraphQLESLintRule = { } `, }, + { + title: 'Correct', + usage: [{ fragment: { style: CaseStyle.kebabCase, suffix: '.fragment' } }], + code: /* GraphQL */ ` + # user-fields.fragment.gql + fragment user_fields on User { + id + email + } + `, + }, + { + title: 'Correct', + usage: [{ mutation: { style: CaseStyle.pascalCase, suffix: 'Mutation' } }], + code: /* GraphQL */ ` + # DeleteUserMutation.gql + mutation DELETE_USER { + deleteUser(id: 5) + } + `, + }, + { + title: 'Incorrect', + usage: [{ fileExtension: '.graphql' }], + code: /* GraphQL */ ` + # post.gql + type Post { + id: ID! + } + `, + }, { title: 'Incorrect', + usage: [{ query: CaseStyle.pascalCase }], code: /* GraphQL */ ` - # user-by-id.graphql - query me { - me { + # user-by-id.gql + query UserById { + userById(id: 5) { id name fullName @@ -60,70 +116,112 @@ const rule: GraphQLESLintRule = { ], }, messages: { - [MATCH_DOCUMENT_FILENAME]: `The {{ type }} "{{ name }}" is named differnly than the filename ("{{ filename }}").`, + [MATCH_EXTENSION]: `File extension "{{ fileExtension }}" don't match extension "{{ expectedFileExtension }}"`, + [MATCH_STYLE]: `Unexpected filename "{{ filename }}". Rename it to "{{ expectedFilename }}"`, }, - schema: [ - { + schema: { + definitions: { + asString: { + type: 'string', + description: `One of: ${CASE_STYLES.map(t => `\`${t}\``).join(', ')}`, + enum: CASE_STYLES, + }, + asObject: { + type: 'object', + properties: { + style: { + type: 'string', + enum: CASE_STYLES, + }, + }, + }, + }, + $schema: 'http://json-schema.org/draft-04/schema#', + type: 'array', + items: { type: 'object', properties: { - compareSections: { - type: 'boolean', - default: true, - }, - ignoreCase: { - type: 'boolean', - default: true, + fileExtension: { + type: 'string', + enum: ACCEPTED_EXTENSIONS, }, + query: schemaOption, + mutation: schemaOption, + subscription: schemaOption, + fragment: schemaOption, }, - additionalProperties: false, }, - ], + }, }, create(context) { const options: MatchDocumentFilenameRuleConfig[number] = context.options[0] || { - compareSections: true, - ignoreCase: true, + fileExtension: null, }; + const filePath = context.getFilename(); + const isVirtualFile = !existsSync(filePath); + + if (process.env.NODE_ENV !== 'test' && isVirtualFile) { + // Skip validation for code files + return {}; + } + + const fileExtension = extname(filePath); + const filename = basename(filePath, fileExtension); return { - OperationDefinition: node => { - const operationName = node.name?.value; - const fileName = basename(context.getFilename()); - - if (operationName && fileName) { - const isValid = checkNameValidity(operationName, node.operation, fileName, options); - - if (!isValid) { - context.report({ - node, - messageId: MATCH_DOCUMENT_FILENAME, - data: { - type: node.operation, - name: operationName, - filename: fileName, - }, - }); - } + Document(documentNode) { + if (options.fileExtension && options.fileExtension !== fileExtension) { + context.report({ + node: documentNode, + messageId: MATCH_EXTENSION, + data: { + fileExtension, + expectedFileExtension: options.fileExtension, + }, + }); } - }, - FragmentDefinition: node => { - const fragmentName = node.name?.value; - const fileName = basename(context.getFilename()); - - if (fragmentName && fileName) { - const isValid = checkNameValidity(fragmentName, 'fragment', fileName, options); - - if (!isValid) { - context.report({ - node, - messageId: MATCH_DOCUMENT_FILENAME, - data: { - type: 'fragment', - name: fragmentName, - filename: fileName, - }, - }); - } + + const firstOperation = documentNode.definitions.find( + n => n.kind === Kind.OPERATION_DEFINITION + ) as GraphQLESTreeNode; + const firstFragment = documentNode.definitions.find( + n => n.kind === Kind.FRAGMENT_DEFINITION + ) as GraphQLESTreeNode; + + const node = firstOperation || firstFragment; + + if (!node) { + return; + } + const docName = node.name?.value; + + if (!docName) { + return; + } + const docType = 'operation' in node ? node.operation : 'fragment'; + + let option = options[docType]; + if (!option) { + // Config not provided + return; + } + + if (typeof option === 'string') { + option = { style: option } as PropertySchema; + } + const expectedExtension = options.fileExtension || fileExtension; + const expectedFilename = convertCase(option.style, docName) + (option.suffix || '') + expectedExtension; + const filenameWithExtension = filename + expectedExtension; + + if (expectedFilename !== filenameWithExtension) { + context.report({ + node: documentNode, + messageId: MATCH_STYLE, + data: { + expectedFilename, + filename: filenameWithExtension, + }, + }); } }, }; diff --git a/packages/plugin/src/rules/naming-convention.ts b/packages/plugin/src/rules/naming-convention.ts index 45a1d71833e..d7a5844b824 100644 --- a/packages/plugin/src/rules/naming-convention.ts +++ b/packages/plugin/src/rules/naming-convention.ts @@ -1,5 +1,6 @@ import { Kind } from 'graphql'; import { GraphQLESLintRule } from '../types'; +import { isQueryType } from '../utils'; const formats = { camelCase: /^[a-z][^_]*$/g, @@ -15,6 +16,7 @@ const acceptedStyles: ['camelCase', 'PascalCase', 'snake_case', 'UPPER_CASE'] = 'UPPER_CASE', ]; type ValidNaming = typeof acceptedStyles[number]; + interface CheckNameFormatParams { value: string; style?: ValidNaming; @@ -25,6 +27,7 @@ interface CheckNameFormatParams { forbiddenPrefixes: string[]; forbiddenSuffixes: string[]; } + function checkNameFormat(params: CheckNameFormatParams): { ok: false; errorMessage: string } | { ok: true } { const { value, @@ -44,10 +47,16 @@ function checkNameFormat(params: CheckNameFormatParams): { ok: false; errorMessa name = name.replace(/_*$/, ''); } if (prefix && !name.startsWith(prefix)) { - return { ok: false, errorMessage: '{{nodeType}} name "{{nodeName}}" should have "{{prefix}}" prefix' }; + return { + ok: false, + errorMessage: '{{nodeType}} name "{{nodeName}}" should have "{{prefix}}" prefix', + }; } if (suffix && !name.endsWith(suffix)) { - return { ok: false, errorMessage: '{{nodeType}} name "{{nodeName}}" should have "{{suffix}}" suffix' }; + return { + ok: false, + errorMessage: '{{nodeType}} name "{{nodeName}}" should have "{{suffix}}" suffix', + }; } if (style && !acceptedStyles.some(acceptedStyle => acceptedStyle === style)) { return { @@ -80,7 +89,10 @@ function checkNameFormat(params: CheckNameFormatParams): { ok: false; errorMessa if (ok) { return { ok: true }; } - return { ok: false, errorMessage: '{{nodeType}} name "{{nodeName}}" should be in {{format}} format' }; + return { + ok: false, + errorMessage: '{{nodeType}} name "{{nodeName}}" should be in {{format}} format', + }; } const schemaOption = { @@ -260,19 +272,10 @@ const rule: GraphQLESLintRule = { }; }; - const isQueryType = (node): boolean => { - return ( - (node.type === 'ObjectTypeDefinition' || node.type === 'ObjectTypeExtension') && node.name.value === 'Query' - ); - }; - return { Name: node => { if (node.value.startsWith('_') && options.leadingUnderscore === 'forbid') { - context.report({ - node, - message: 'Leading underscores are not allowed', - }); + context.report({ node, message: 'Leading underscores are not allowed' }); } if (node.value.endsWith('_') && options.trailingUnderscore === 'forbid') { context.report({ node, message: 'Trailing underscores are not allowed' }); diff --git a/packages/plugin/src/rules/no-operation-name-suffix.ts b/packages/plugin/src/rules/no-operation-name-suffix.ts index 9916d894ef1..80a80d43bb7 100644 --- a/packages/plugin/src/rules/no-operation-name-suffix.ts +++ b/packages/plugin/src/rules/no-operation-name-suffix.ts @@ -1,29 +1,9 @@ -import { GraphQLESLintRule, GraphQLESLintRuleContext } from '../types'; +import { GraphQLESLintRule } from '../types'; import { GraphQLESTreeNode } from '../estree-parser/estree-ast'; import { OperationDefinitionNode, FragmentDefinitionNode } from 'graphql'; const NO_OPERATION_NAME_SUFFIX = 'NO_OPERATION_NAME_SUFFIX'; -function verifyRule( - context: GraphQLESLintRuleContext, - node: GraphQLESTreeNode | GraphQLESTreeNode -) { - if (node && node.name && node.name.value !== '') { - const invalidSuffix = (node.type === 'OperationDefinition' ? node.operation : 'fragment').toLowerCase(); - - if (node.name.value.toLowerCase().endsWith(invalidSuffix)) { - context.report({ - node: node.name, - data: { - invalidSuffix, - }, - fix: fixer => fixer.removeRange([node.name.range[1] - invalidSuffix.length, node.name.range[1]]), - messageId: NO_OPERATION_NAME_SUFFIX, - }); - } - } -} - const rule: GraphQLESLintRule = { meta: { fixable: 'code', @@ -58,11 +38,23 @@ const rule: GraphQLESLintRule = { }, create(context) { return { - OperationDefinition(node) { - verifyRule(context, node); - }, - FragmentDefinition(node) { - verifyRule(context, node); + 'OperationDefinition, FragmentDefinition'( + node: GraphQLESTreeNode + ) { + if (node && node.name && node.name.value !== '') { + const invalidSuffix = (node.type === 'OperationDefinition' ? node.operation : 'fragment').toLowerCase(); + + if (node.name.value.toLowerCase().endsWith(invalidSuffix)) { + context.report({ + node: node.name, + data: { + invalidSuffix, + }, + fix: fixer => fixer.removeRange([node.name.range[1] - invalidSuffix.length, node.name.range[1]]), + messageId: NO_OPERATION_NAME_SUFFIX, + }); + } + } }, }; }, diff --git a/packages/plugin/src/rules/require-description.ts b/packages/plugin/src/rules/require-description.ts index adffc01cfc0..6fc5d7ffe8e 100644 --- a/packages/plugin/src/rules/require-description.ts +++ b/packages/plugin/src/rules/require-description.ts @@ -106,12 +106,7 @@ const rule: GraphQLESLintRule = { }, }, create(context) { - return context.options[0].on.reduce((prev, optionKey) => { - return { - ...prev, - [optionKey]: node => verifyRule(context, node), - }; - }, {}); + return Object.fromEntries(context.options[0].on.map(optionKey => [optionKey, node => verifyRule(context, node)])); }, }; diff --git a/packages/plugin/src/rules/selection-set-depth.ts b/packages/plugin/src/rules/selection-set-depth.ts index e32f35945f6..6b26bb765b7 100644 --- a/packages/plugin/src/rules/selection-set-depth.ts +++ b/packages/plugin/src/rules/selection-set-depth.ts @@ -95,37 +95,36 @@ const rule: GraphQLESLintRule = { const ignore = context.options[0].ignore || []; const checkFn = depthLimit(maxDepth, { ignore }); - return ['OperationDefinition', 'FragmentDefinition'].reduce((prev, nodeType) => { - return { - ...prev, - [nodeType]: (node: GraphQLESTreeNode | GraphQLESTreeNode) => { - try { - const rawNode = node.rawNode(); - const fragmentsInUse = siblings ? siblings.getFragmentsInUse(rawNode, true) : []; - const document: DocumentNode = { - kind: Kind.DOCUMENT, - definitions: [rawNode, ...fragmentsInUse], - }; + return { + 'OperationDefinition, FragmentDefinition'( + node: GraphQLESTreeNode + ) { + try { + const rawNode = node.rawNode(); + const fragmentsInUse = siblings ? siblings.getFragmentsInUse(rawNode, true) : []; + const document: DocumentNode = { + kind: Kind.DOCUMENT, + definitions: [rawNode, ...fragmentsInUse], + }; - checkFn({ - getDocument: () => document, - reportError: (error: GraphQLError) => { - context.report({ - loc: error.locations[0], - message: error.message, - }); - }, - }); - } catch (e) { - // eslint-disable-next-line no-console - console.warn( - `Rule "selection-set-depth" check failed due to a missing siblings operations. For more info: http://bit.ly/graphql-eslint-operations`, - e - ); - } - }, - }; - }, {}); + checkFn({ + getDocument: () => document, + reportError: (error: GraphQLError) => { + context.report({ + loc: error.locations[0], + message: error.message, + }); + }, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.warn( + `Rule "selection-set-depth" check failed due to a missing siblings operations. For more info: http://bit.ly/graphql-eslint-operations`, + e + ); + } + }, + }; }, }; diff --git a/packages/plugin/src/utils.ts b/packages/plugin/src/utils.ts index d496649fe4c..b27cb23b792 100644 --- a/packages/plugin/src/utils.ts +++ b/packages/plugin/src/utils.ts @@ -1,11 +1,12 @@ import { statSync } from 'fs'; import { dirname } from 'path'; import { Lexer, GraphQLSchema, Token, DocumentNode, Source } from 'graphql'; -import { GraphQLESLintRuleContext } from './types'; import { AST } from 'eslint'; +import { asArray, Source as LoaderSource } from '@graphql-tools/utils'; +import lowerCase from 'lodash.lowercase'; +import { GraphQLESLintRuleContext } from './types'; import { SiblingOperations } from './sibling-operations'; import { UsedFields, ReachableTypes } from './graphql-ast'; -import { asArray, Source as LoaderSource } from '@graphql-tools/utils'; export function requireSiblingsOperations( ruleName: string, @@ -151,5 +152,41 @@ export const loaderCache: Record = new Proxy(Object.crea cache[key] = asArray(value); } return true; + }, +}); + +const isObjectType = (node): boolean => ['ObjectTypeDefinition', 'ObjectTypeExtension'].includes(node.type); +export const isQueryType = (node): boolean => isObjectType(node) && node.name.value === 'Query'; +export const isMutationType = (node): boolean => isObjectType(node) && node.name.value === 'Mutation'; +export const isSubscriptionType = (node): boolean => isObjectType(node) && node.name.value === 'Subscription'; + +export enum CaseStyle { + camelCase = 'camelCase', + pascalCase = 'PascalCase', + snakeCase = 'snake_case', + upperCase = 'UPPER_CASE', + kebabCase = 'kebab-case', +} + +export const convertCase = (style: CaseStyle, str: string): string => { + const pascalCase = (str: string): string => + lowerCase(str) + .split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); + + switch (style) { + case CaseStyle.camelCase: { + const result = pascalCase(str); + return result.charAt(0).toLowerCase() + result.slice(1); + } + case CaseStyle.pascalCase: + return pascalCase(str); + case CaseStyle.snakeCase: + return lowerCase(str).replace(/ /g, '_'); + case CaseStyle.upperCase: + return lowerCase(str).replace(/ /g, '_').toUpperCase(); + case CaseStyle.kebabCase: + return lowerCase(str).replace(/ /g, '-'); } -}); \ No newline at end of file +}; diff --git a/packages/plugin/tests/match-document-filename.spec.ts b/packages/plugin/tests/match-document-filename.spec.ts new file mode 100644 index 00000000000..178d63f0229 --- /dev/null +++ b/packages/plugin/tests/match-document-filename.spec.ts @@ -0,0 +1,98 @@ +import { GraphQLRuleTester } from '../src'; +import rule from '../src/rules/match-document-filename'; + +const ruleTester = new GraphQLRuleTester(); + +ruleTester.runGraphQLTests('match-document-filename', rule, { + valid: [ + { + filename: 'src/me.gql', + code: '{ me }', + options: [{ fileExtension: '.gql' }], + }, + { + filename: 'src/user-by-id.query.gql', + code: `query USER_BY_ID { user { id } }`, + options: [{ query: { style: 'kebab-case', suffix: '.query' } }], + }, + { + filename: 'src/createUserQuery.gql', + code: `mutation CREATE_USER { user { id } }`, + options: [{ mutation: { style: 'camelCase', suffix: 'Query' } }], + }, + { + filename: 'src/NEW_USER.gql', + code: `subscription new_user { user { id } }`, + options: [{ subscription: { style: 'UPPER_CASE' } }], + }, + { + filename: 'src/user_fields.gql', + code: 'fragment UserFields on User { id }', + options: [{ fragment: { style: 'snake_case' } }], + }, + { + filename: 'src/UserById.gql', + code: `query USER_BY_ID { user { id } }`, + options: [{ query: { style: 'PascalCase' } }], + }, + ], + invalid: [ + { + filename: 'src/queries/me.graphql', + code: '{ me }', + options: [{ fileExtension: '.gql' }], + errors: [{ message: `File extension ".graphql" don't match extension ".gql"` }], + }, + { + filename: 'src/user-by-id.gql', + code: `query UserById { user { id } }`, + options: [{ query: { style: 'PascalCase' } }], + errors: [{ message: `Unexpected filename "user-by-id.gql". Rename it to "UserById.gql"` }], + }, + { + filename: 'src/userById.gql', + code: `query UserById { user { id } }`, + options: [{ query: { style: 'PascalCase', suffix: '.query' } }], + errors: [{ message: `Unexpected filename "userById.gql". Rename it to "UserById.query.gql"` }], + }, + { + filename: 'src/user-fields.gql', + code: `fragment UserFields on User { id }`, + options: [{ fragment: { style: 'PascalCase' } }], + errors: [{ message: 'Unexpected filename "user-fields.gql". Rename it to "UserFields.gql"' }], + }, + { + // Compare only first operation name + filename: 'src/getUsersQuery.gql', + code: `query getUsers { users } mutation createPost { createPost }`, + options: [ + { + query: { style: 'PascalCase', suffix: '.query' }, + mutation: { style: 'PascalCase', suffix: '.mutation' }, + }, + ], + errors: [{ message: 'Unexpected filename "getUsersQuery.gql". Rename it to "GetUsers.query.gql"' }], + }, + { + // Compare only first operation name if fragment is present + filename: 'src/getUsersQuery.gql', + code: /* GraphQL */ ` + fragment UserFields on User { + id + } + query getUsers { + users { + ...UserFields + } + } + `, + options: [ + { + query: { style: 'PascalCase', suffix: '.query' }, + fragment: { style: 'PascalCase', suffix: '.fragment' }, + }, + ], + errors: [{ message: 'Unexpected filename "getUsersQuery.gql". Rename it to "GetUsers.query.gql"' }], + }, + ], +}); diff --git a/yarn.lock b/yarn.lock index 6f39abb37da..a72cc209913 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1749,6 +1749,18 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d" integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ== +"@types/lodash.camelcase@^4.3.6": + version "4.3.6" + resolved "https://registry.yarnpkg.com/@types/lodash.camelcase/-/lodash.camelcase-4.3.6.tgz#393c748b70cd926fc629e6502a9d0929f217d5fd" + integrity sha512-hd/TEuPd76Jtf1xEq85CHbCqR+iqvs5IOKyrYbiaOg69BRQgPN9XkvLj8Jl8rBp/dfJ2wQ1AVcP8mZmybq7kIg== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*": + version "4.14.172" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.172.tgz#aad774c28e7bfd7a67de25408e03ee5a8c3d028a" + integrity sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw== + "@types/minimist@^1.2.0": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" @@ -4663,6 +4675,11 @@ lodash.get@4.4.2, lodash.get@^4: resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk= +lodash.lowercase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.lowercase/-/lodash.lowercase-4.3.0.tgz#46515aced4acb0b7093133333af068e4c3b14e9d" + integrity sha1-RlFaztSssLcJMTMzOvBo5MOxTp0= + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"