Skip to content

Commit 35b1ff3

Browse files
authored
Improve performance for parserServices, and allow access to sibling operations in rules (#142)
1 parent de76b13 commit 35b1ff3

14 files changed

+459
-158
lines changed

.changeset/short-beers-lick.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-eslint/eslint-plugin': minor
3+
---
4+
5+
Allow to load siblings operations

.changeset/two-bobcats-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@graphql-eslint/eslint-plugin': patch
3+
---
4+
5+
fix(parser): better performance, make sure schema is loaded and cached

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ This project integrates GraphQL and ESLint, for a better developer experience.
2626

2727
## Getting Started
2828

29-
* [Introducing GraphQL-ESLint!](https://the-guild.dev/blog/introducing-graphql-eslint) @ `the-guild.dev`
29+
- [Introducing GraphQL-ESLint!](https://the-guild.dev/blog/introducing-graphql-eslint) @ `the-guild.dev`
3030

3131
### Installation
3232

@@ -112,6 +112,28 @@ The parser allow you to specify a json file / graphql files(s) / url / raw strin
112112
113113
> Some rules requires type information to operate, it's marked in the docs of each plugin!
114114
115+
#### Extended linting rules with siblings operations
116+
117+
While implementing this tool, we had to find solutions for a better integration of the GraphQL ecosystem and ESLint core.
118+
119+
GraphQL operations can be distributed across many files, while ESLint operates on one file at a time. If you are using GraphQL fragments in separate files, some rules might yield incorrect results, due the the missing information.
120+
121+
To workaround that, we allow you to provide additional information on your GraphQL operations, making it available for rules while doing the actual linting.
122+
123+
To provide that, we are using `@graphql-tools` loaders to load your sibling operations and fragments, just specify a glob expression(s) that points to your code/.graphql files:
124+
125+
```json
126+
{
127+
"files": ["*.graphql"],
128+
"parser": "@graphql-eslint/eslint-plugin",
129+
"plugins": ["@graphql-eslint"],
130+
"parserOptions": {
131+
"operations": ["./src/**/*.graphql"],
132+
"schema": "./schema.graphql"
133+
}
134+
}
135+
```
136+
115137
### VSCode Integration
116138

117139
By default, [ESLint VSCode plugin](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) will not lint files with extensions other then js, jsx, ts, tsx.

docs/parser-options.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## Parser Options
22

3+
### `graphQLParserOptions`
4+
5+
With this configuration, you can specify custom configurations for GraphQL's `parse` method. By default, `graphql-eslint` parser just adds `noLocation: false` to make sure all parsed AST has `location` set, since we need this for tokening and for converting the GraphQL AST into ESTree.
6+
7+
You can find the [complete set of options for this object here](https://github.com/graphql/graphql-js/blob/master/src/language/parser.d.ts#L7)
8+
39
### `skipGraphQLConfig`
410

511
If you are using [`graphql-config`](https://graphql-config.com/) in your project, the parser will automatically use that to load your default GraphQL schema.

packages/plugin/package.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@
1717
},
1818
"dependencies": {
1919
"prettier-linter-helpers": "1.0.0",
20-
"@graphql-tools/utils": "^7.1.3",
21-
"@graphql-tools/load": "^6.2.5",
22-
"@graphql-tools/graphql-file-loader": "^6.2.6",
23-
"@graphql-tools/json-file-loader": "^6.2.6",
24-
"@graphql-tools/url-loader": "^6.4.0",
25-
"@graphql-tools/graphql-tag-pluck": "^6.3.0",
20+
"@graphql-tools/utils": "~7.1.3",
21+
"@graphql-tools/load": "~6.2.5",
22+
"@graphql-tools/graphql-file-loader": "~6.2.6",
23+
"@graphql-tools/json-file-loader": "~6.2.6",
24+
"@graphql-tools/code-file-loader": "~6.2.6",
25+
"@graphql-tools/url-loader": "~6.4.0",
26+
"@graphql-tools/graphql-tag-pluck": "~6.3.0",
2627
"graphql-config": "^3.2.0"
2728
},
2829
"devDependencies": {

packages/plugin/src/parser.ts

Lines changed: 23 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -1,128 +1,32 @@
11
import { convertToESTree } from './estree-parser/converter';
2-
import { GraphQLParseOptions, parseGraphQLSDL } from '@graphql-tools/utils';
3-
import { buildSchema, GraphQLError, GraphQLSchema, Source, Lexer, TypeInfo } from 'graphql';
4-
import { loadConfigSync, GraphQLProjectConfig } from 'graphql-config';
5-
import { loadSchemaSync } from '@graphql-tools/load';
6-
import { GraphQLFileLoader } from '@graphql-tools/graphql-file-loader';
7-
import { JsonFileLoader } from '@graphql-tools/json-file-loader';
8-
import { UrlLoader } from '@graphql-tools/url-loader';
9-
import { Linter, AST } from 'eslint';
2+
import { parseGraphQLSDL } from '@graphql-tools/utils';
3+
import { GraphQLError, TypeInfo } from 'graphql';
4+
import { Linter } from 'eslint';
105
import { GraphQLESLintParseResult, ParserOptions } from './types';
6+
import { extractTokens } from './utils';
7+
import { getSchema } from './schema';
8+
import { getSiblingOperations } from './sibling-operations';
119

12-
const DEFAULT_CONFIG: ParserOptions = {
13-
schema: null,
14-
skipGraphQLConfig: false,
15-
};
16-
17-
export function parse(code: string, options?: GraphQLParseOptions): Linter.ESLintParseResult['ast'] {
10+
export function parse(code: string, options?: ParserOptions): Linter.ESLintParseResult['ast'] {
1811
return parseForESLint(code, options).ast;
1912
}
2013

21-
function getLexer(source: Source): Lexer {
22-
// GraphQL v14
23-
const gqlLanguage = require('graphql/language');
24-
if (gqlLanguage && gqlLanguage.createLexer) {
25-
return gqlLanguage.createLexer(source, {});
26-
}
27-
28-
// GraphQL v15
29-
const { Lexer: LexerCls } = require('graphql');
30-
if (LexerCls && typeof LexerCls === 'function') {
31-
return new LexerCls(source);
32-
}
33-
34-
throw new Error(`Unsupported GraphQL version! Please make sure to use GraphQL v14 or newer!`);
35-
}
36-
37-
export function extractTokens(source: string): AST.Token[] {
38-
const lexer = getLexer(new Source(source));
39-
const tokens: AST.Token[] = [];
40-
let token = lexer.advance();
41-
42-
while (token && token.kind !== '<EOF>') {
43-
tokens.push({
44-
type: token.kind as any,
45-
loc: {
46-
start: {
47-
line: token.line,
48-
column: token.column,
49-
},
50-
end: {
51-
line: token.line,
52-
column: token.column,
53-
},
54-
},
55-
value: token.value,
56-
range: [token.start, token.end],
57-
});
58-
token = lexer.advance();
59-
}
60-
61-
return tokens;
62-
}
63-
6414
export function parseForESLint(code: string, options?: ParserOptions): GraphQLESLintParseResult {
65-
try {
66-
const config = {
67-
...DEFAULT_CONFIG,
68-
...(options || {}),
69-
...(options?.schemaOptions || {}),
70-
};
71-
72-
let schema: GraphQLSchema = null;
73-
let configProject: GraphQLProjectConfig = null;
74-
75-
if (!config.skipGraphQLConfig && options.filePath) {
76-
const gqlConfig = loadConfigSync({
77-
throwOnEmpty: false,
78-
throwOnMissing: false,
79-
});
80-
81-
if (gqlConfig) {
82-
const projectForFile = gqlConfig.getProjectForFile(options.filePath);
83-
84-
if (projectForFile) {
85-
configProject = projectForFile;
86-
schema = projectForFile.getSchemaSync();
87-
}
88-
}
89-
}
15+
const schema = getSchema(options);
16+
const operationsPaths = options.operations || [];
17+
const siblingOperations = getSiblingOperations(
18+
process.cwd(),
19+
Array.isArray(operationsPaths) ? operationsPaths : [operationsPaths]
20+
);
21+
const parserServices = {
22+
hasTypeInfo: schema !== null,
23+
schema,
24+
siblingOperations,
25+
};
9026

91-
if (!schema && config.schema) {
92-
try {
93-
schema = loadSchemaSync(config.schema, {
94-
...config,
95-
assumeValidSDL: true,
96-
loaders: [
97-
{
98-
loaderId: () => 'direct-string',
99-
canLoad: async () => false,
100-
load: async () => null,
101-
canLoadSync: pointer => typeof pointer === 'string' && pointer.includes('type '),
102-
loadSync: pointer => ({
103-
schema: buildSchema(pointer),
104-
}),
105-
},
106-
new GraphQLFileLoader(),
107-
new JsonFileLoader(),
108-
new UrlLoader(),
109-
],
110-
});
111-
} catch (e) {
112-
e.message = e.message + `\nRunning from directory: ${process.cwd()}`;
113-
114-
throw e;
115-
}
116-
}
117-
118-
const parserServices = {
119-
graphqlConfigProject: configProject,
120-
hasTypeInfo: schema !== null,
121-
schema,
122-
};
123-
124-
const graphqlAst = parseGraphQLSDL(config.filePath || '', code, {
125-
...config,
27+
try {
28+
const graphqlAst = parseGraphQLSDL(options.filePath || '', code, {
29+
...(options.graphQLParserOptions || {}),
12630
noLocation: false,
12731
});
12832

@@ -143,6 +47,8 @@ export function parseForESLint(code: string, options?: ParserOptions): GraphQLES
14347
},
14448
};
14549
} catch (e) {
50+
// In case of GraphQL parser error, we report it to ESLint as a parser error that matches the requirements
51+
// of ESLint. This will make sure to display it correctly in IDEs and lint results.
14652
if (e instanceof GraphQLError) {
14753
const eslintError = {
14854
index: e.positions[0],

packages/plugin/src/rules/require-id-when-available.ts

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { requireGraphQLSchemaFromContext } from '../utils';
1+
import { requireGraphQLSchemaFromContext, requireSiblingsOperations } from '../utils';
22
import { GraphQLESLintRule } from '../types';
3-
import { GraphQLInterfaceType, GraphQLObjectType } from 'graphql';
3+
import { print, GraphQLInterfaceType, GraphQLObjectType } from 'graphql';
44
import { getBaseType } from '../estree-parser';
55

66
const REQUIRE_ID_WHEN_AVAILABLE = 'REQUIRE_ID_WHEN_AVAILABLE';
@@ -17,7 +17,7 @@ const rule: GraphQLESLintRule<RequireIdWhenAvailableRuleConfig, true> = {
1717
url: `https://github.com/dotansimha/graphql-eslint/blob/master/docs/rules/require-id-when-available.md`,
1818
},
1919
messages: {
20-
[REQUIRE_ID_WHEN_AVAILABLE]: `Field "{{ fieldName }}" must be selected when it's available on a type. Please make sure to include it in your selection set!`,
20+
[REQUIRE_ID_WHEN_AVAILABLE]: `Field "{{ fieldName }}" must be selected when it's available on a type. Please make sure to include it in your selection set!\nIf you are using fragments, make sure that all used fragments sepcifies the field "{{ fieldName }}".`,
2121
},
2222
schema: {
2323
type: 'array',
@@ -38,7 +38,8 @@ const rule: GraphQLESLintRule<RequireIdWhenAvailableRuleConfig, true> = {
3838
create(context) {
3939
return {
4040
SelectionSet(node) {
41-
requireGraphQLSchemaFromContext(context);
41+
requireGraphQLSchemaFromContext('require-id-when-available', context);
42+
const siblings = requireSiblingsOperations('require-id-when-available', context);
4243

4344
const fieldName = (context.options[0] || {}).fieldName || DEFAULT_ID_FIELD_NAME;
4445

@@ -54,20 +55,31 @@ const rule: GraphQLESLintRule<RequireIdWhenAvailableRuleConfig, true> = {
5455
const hasIdFieldInType = !!fields[fieldName];
5556

5657
if (hasIdFieldInType) {
57-
const hasIdFieldInSelectionSet = !!node.selections.find(
58-
s => s.kind === 'Field' && s.name.value === fieldName
59-
);
58+
let found = false;
6059

61-
// check if the parent selection set has the ID field in there
62-
const { parent } = node as any;
63-
const hasIdFieldInInterfaceSelectionSet =
64-
parent &&
65-
parent.kind === 'InlineFragment' &&
66-
parent.parent &&
67-
parent.parent.kind === 'SelectionSet' &&
68-
!!parent.parent.selections.find(s => s.kind === 'Field' && s.name.value === fieldName);
60+
for (const selection of node.selections) {
61+
if (selection.kind === 'Field' && selection.name.value === fieldName) {
62+
found = true;
63+
} else if (selection.kind === 'InlineFragment') {
64+
found = !!(selection.selectionSet?.selections || []).find(
65+
s => s.kind === 'Field' && s.name.value === fieldName
66+
);
67+
} else if (selection.kind === 'FragmentSpread') {
68+
const foundSpread = siblings.getFragment(selection.name.value);
6969

70-
if (!hasIdFieldInSelectionSet && !hasIdFieldInInterfaceSelectionSet) {
70+
if (foundSpread[0]) {
71+
found = !!(foundSpread[0].selectionSet?.selections || []).find(
72+
s => s.kind === 'Field' && s.name.value === fieldName
73+
);
74+
}
75+
}
76+
77+
if (found) {
78+
break;
79+
}
80+
}
81+
82+
if (!found) {
7183
context.report({
7284
loc: {
7385
start: {

packages/plugin/src/rules/validate-against-schema.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ const rule: GraphQLESLintRule<ValidateAgainstSchemaRuleConfig> = {
100100

101101
return {
102102
OperationDefinition(node) {
103-
const schema = requireGraphQLSchemaFromContext(context);
103+
const schema = requireGraphQLSchemaFromContext('validate-against-schema', context);
104+
104105
validateDoc(
105106
node,
106107
context,
@@ -113,7 +114,8 @@ const rule: GraphQLESLintRule<ValidateAgainstSchemaRuleConfig> = {
113114
);
114115
},
115116
FragmentDefinition(node) {
116-
const schema = requireGraphQLSchemaFromContext(context);
117+
const schema = requireGraphQLSchemaFromContext('validate-against-schema', context);
118+
117119
validateDoc(
118120
node,
119121
context,

0 commit comments

Comments
 (0)