Skip to content

Commit

Permalink
馃帀 NEW RULE: Compare operation/fragment name to the file name (#458)
Browse files Browse the repository at this point in the history
  • Loading branch information
dotansimha committed Sep 6, 2021
1 parent 7b12bbf commit c6886ba
Show file tree
Hide file tree
Showing 18 changed files with 717 additions and 204 deletions.
5 changes: 5 additions & 0 deletions .changeset/nasty-pumpkins-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-eslint/eslint-plugin': minor
---

[New rule] Compare operation/fragment name to the file name
5 changes: 5 additions & 0 deletions .changeset/swift-cobras-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-eslint/eslint-plugin': patch
---

NEW PLUGIN: Compare operation/fragment name to the file name
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) | |     馃殌 | 馃敡 |
Expand Down
152 changes: 152 additions & 0 deletions docs/rules/match-document-filename.md
Original file line number Diff line number Diff line change
@@ -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`
6 changes: 4 additions & 2 deletions packages/plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.3",
"typescript": "4.4.2"
"typescript": "4.4.2",
"@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"
Expand Down
74 changes: 33 additions & 41 deletions packages/plugin/src/rules/avoid-operation-name-prefix.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -11,41 +11,6 @@ export type AvoidOperationNamePrefixConfig = [

const AVOID_OPERATION_NAME_PREFIX = 'AVOID_OPERATION_NAME_PREFIX';

function verifyRule(
context: GraphQLESLintRuleContext<AvoidOperationNamePrefixConfig>,
node: GraphQLESTreeNode<OperationDefinitionNode> | GraphQLESTreeNode<FragmentDefinitionNode>
) {
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<AvoidOperationNamePrefixConfig> = {
meta: {
type: 'suggestion',
Expand Down Expand Up @@ -100,11 +65,38 @@ const rule: GraphQLESLintRule<AvoidOperationNamePrefixConfig> = {
},
create(context) {
return {
OperationDefinition(node) {
verifyRule(context, node);
},
FragmentDefinition(node) {
verifyRule(context, node);
'OperationDefinition, FragmentDefinition'(
node: GraphQLESTreeNode<OperationDefinitionNode | FragmentDefinitionNode>
) {
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,
});
}
}
}
},
};
},
Expand Down
65 changes: 29 additions & 36 deletions packages/plugin/src/rules/avoid-typename-prefix.ts
Original file line number Diff line number Diff line change
@@ -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<any>,
typeName: string,
fields: GraphQLESTreeNode<FieldDefinitionNode>[]
) {
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',
Expand Down Expand Up @@ -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,
});
}
}
},
};
},
Expand Down

0 comments on commit c6886ba

Please sign in to comment.