Skip to content

Commit 11b3af6

Browse files
authored
feat: support multiple id field names in require-id-when-available rule (#819)
1 parent 503dd9f commit 11b3af6

File tree

9 files changed

+141
-85
lines changed

9 files changed

+141
-85
lines changed

.changeset/tender-adults-shave.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+
feat: support multiple id field names in `require-id-when-available` rule

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ module.exports = {
2727
'unicorn/prefer-includes': 'error',
2828
'unicorn/no-useless-fallback-in-spread': 'error',
2929
'unicorn/better-regex': 'error',
30+
'prefer-destructuring': ['error', { object: true }],
3031
},
3132
overrides: [
3233
{

docs/rules/require-id-when-available.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,25 @@ query user {
5454

5555
The schema defines the following properties:
5656

57-
### `fieldName` (string)
57+
### `fieldName`
58+
59+
The object must be one of the following types:
60+
61+
* `asString`
62+
* `asArray`
5863

5964
Default: `"id"`
6065

66+
---
67+
68+
# Sub Schemas
69+
70+
The schema defines the following additional types:
71+
72+
## `asString` (string)
73+
74+
## `asArray` (array)
75+
6176
## Resources
6277

6378
- [Rule source](../../packages/plugin/src/rules/require-id-when-available.ts)

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

Lines changed: 75 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { requireGraphQLSchemaFromContext, requireSiblingsOperations } from '../utils';
1+
import { getLocation, requireGraphQLSchemaFromContext, requireSiblingsOperations } from '../utils';
22
import { GraphQLESLintRule } from '../types';
3-
import { GraphQLInterfaceType, GraphQLObjectType } from 'graphql';
4-
import { getBaseType } from '../estree-parser';
3+
import { GraphQLInterfaceType, GraphQLObjectType, Kind, SelectionNode } from 'graphql';
4+
import { getBaseType, GraphQLESTreeNode } from '../estree-parser';
55

66
const REQUIRE_ID_WHEN_AVAILABLE = 'REQUIRE_ID_WHEN_AVAILABLE';
77
const DEFAULT_ID_FIELD_NAME = 'id';
@@ -57,100 +57,104 @@ const rule: GraphQLESLintRule<[RequireIdWhenAvailableRuleConfig], true> = {
5757
recommended: true,
5858
},
5959
messages: {
60-
[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 {{ checkedFragments }} specifies the field "{{ fieldName }}".`,
60+
[REQUIRE_ID_WHEN_AVAILABLE]: [
61+
`Field {{ fieldName }} must be selected when it's available on a type. Please make sure to include it in your selection set!`,
62+
`If you are using fragments, make sure that all used fragments {{ checkedFragments }}specifies the field {{ fieldName }}.`,
63+
].join('\n'),
6164
},
6265
schema: {
66+
definitions: {
67+
asString: {
68+
type: 'string',
69+
},
70+
asArray: {
71+
type: 'array',
72+
minItems: 1,
73+
uniqueItems: true,
74+
},
75+
},
6376
type: 'array',
6477
maxItems: 1,
6578
items: {
6679
type: 'object',
6780
additionalProperties: false,
6881
properties: {
6982
fieldName: {
70-
type: 'string',
83+
oneOf: [{ $ref: '#/definitions/asString' }, { $ref: '#/definitions/asArray' }],
7184
default: DEFAULT_ID_FIELD_NAME,
7285
},
7386
},
7487
},
7588
},
7689
},
7790
create(context) {
91+
requireGraphQLSchemaFromContext('require-id-when-available', context);
92+
const siblings = requireSiblingsOperations('require-id-when-available', context);
93+
const { fieldName = DEFAULT_ID_FIELD_NAME } = context.options[0] || {};
94+
const idNames = Array.isArray(fieldName) ? fieldName : [fieldName];
95+
96+
const isFound = (s: GraphQLESTreeNode<SelectionNode> | SelectionNode) =>
97+
s.kind === Kind.FIELD && idNames.includes(s.name.value);
98+
7899
return {
79100
SelectionSet(node) {
80-
requireGraphQLSchemaFromContext('require-id-when-available', context);
81-
const siblings = requireSiblingsOperations('require-id-when-available', context);
101+
const typeInfo = node.typeInfo();
102+
if (!typeInfo.gqlType) {
103+
return;
104+
}
82105

83-
const fieldName = (context.options[0] || {}).fieldName || DEFAULT_ID_FIELD_NAME;
106+
const rawType = getBaseType(typeInfo.gqlType);
107+
const isObjectType = rawType instanceof GraphQLObjectType;
108+
const isInterfaceType = rawType instanceof GraphQLInterfaceType;
109+
if (!isObjectType && !isInterfaceType) {
110+
return;
111+
}
84112

85-
if (!node.selections || node.selections.length === 0) {
113+
const fields = rawType.getFields();
114+
const hasIdFieldInType = idNames.some(name => fields[name]);
115+
if (!hasIdFieldInType) {
86116
return;
87117
}
88118

89-
const typeInfo = node.typeInfo();
90-
if (typeInfo && typeInfo.gqlType) {
91-
const rawType = getBaseType(typeInfo.gqlType);
92-
if (rawType instanceof GraphQLObjectType || rawType instanceof GraphQLInterfaceType) {
93-
const fields = rawType.getFields();
94-
const hasIdFieldInType = !!fields[fieldName];
95-
const checkedFragmentSpreads: Set<string> = new Set();
96-
97-
if (hasIdFieldInType) {
98-
let found = false;
99-
100-
for (const selection of node.selections) {
101-
if (selection.kind === 'Field' && selection.name.value === fieldName) {
102-
found = true;
103-
} else if (selection.kind === 'InlineFragment') {
104-
found = (selection.selectionSet?.selections || []).some(
105-
s => s.kind === 'Field' && s.name.value === fieldName
106-
);
107-
} else if (selection.kind === 'FragmentSpread') {
108-
const foundSpread = siblings.getFragment(selection.name.value);
109-
110-
if (foundSpread[0]) {
111-
checkedFragmentSpreads.add(foundSpread[0].document.name.value);
112-
113-
found = (foundSpread[0].document.selectionSet?.selections || []).some(
114-
s => s.kind === 'Field' && s.name.value === fieldName
115-
);
116-
}
117-
}
118-
119-
if (found) {
120-
break;
121-
}
122-
}
119+
const checkedFragmentSpreads = new Set<string>();
120+
let found = false;
123121

124-
const { parent } = node as any;
125-
const hasIdFieldInInterfaceSelectionSet =
126-
parent &&
127-
parent.kind === 'InlineFragment' &&
128-
parent.parent &&
129-
parent.parent.kind === 'SelectionSet' &&
130-
parent.parent.selections.some(s => s.kind === 'Field' && s.name.value === fieldName);
131-
132-
if (!found && !hasIdFieldInInterfaceSelectionSet) {
133-
context.report({
134-
loc: {
135-
start: {
136-
line: node.loc.start.line,
137-
column: node.loc.start.column - 1,
138-
},
139-
end: {
140-
line: node.loc.end.line,
141-
column: node.loc.end.column - 1,
142-
},
143-
},
144-
messageId: REQUIRE_ID_WHEN_AVAILABLE,
145-
data: {
146-
checkedFragments:
147-
checkedFragmentSpreads.size === 0 ? '' : `(${Array.from(checkedFragmentSpreads).join(', ')})`,
148-
fieldName,
149-
},
150-
});
151-
}
122+
for (const selection of node.selections) {
123+
if (isFound(selection)) {
124+
found = true;
125+
} else if (selection.kind === Kind.INLINE_FRAGMENT) {
126+
found = selection.selectionSet?.selections.some(s => isFound(s));
127+
} else if (selection.kind === Kind.FRAGMENT_SPREAD) {
128+
const [foundSpread] = siblings.getFragment(selection.name.value);
129+
130+
if (foundSpread) {
131+
checkedFragmentSpreads.add(foundSpread.document.name.value);
132+
found = foundSpread.document.selectionSet?.selections.some(s => isFound(s));
152133
}
153134
}
135+
136+
if (found) {
137+
break;
138+
}
139+
}
140+
141+
const { parent } = node as any;
142+
const hasIdFieldInInterfaceSelectionSet =
143+
parent &&
144+
parent.kind === Kind.INLINE_FRAGMENT &&
145+
parent.parent &&
146+
parent.parent.kind === Kind.SELECTION_SET &&
147+
parent.parent.selections.some(s => isFound(s));
148+
149+
if (!found && !hasIdFieldInInterfaceSelectionSet) {
150+
context.report({
151+
loc: getLocation(node.loc),
152+
messageId: REQUIRE_ID_WHEN_AVAILABLE,
153+
data: {
154+
checkedFragments: checkedFragmentSpreads.size === 0 ? '' : `(${[...checkedFragmentSpreads].join(', ')}) `,
155+
fieldName: idNames.map(name => `"${name}"`).join(' or '),
156+
},
157+
});
154158
}
155159
},
156160
};

packages/plugin/src/rules/selection-set-depth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const rule: GraphQLESLintRule<[SelectionSetDepthRuleConfig]> = {
9595
);
9696
}
9797

98-
const maxDepth = context.options[0].maxDepth;
98+
const { maxDepth } = context.options[0];
9999
const ignore = context.options[0].ignore || [];
100100
const checkFn = depthLimit(maxDepth, { ignore });
101101

packages/plugin/tests/__snapshots__/examples.spec.ts.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ Array [
210210
},
211211
Object {
212212
message: Field "id" must be selected when it's available on a type. Please make sure to include it in your selection set!
213-
If you are using fragments, make sure that all used fragments specifies the field "id".,
213+
If you are using fragments, make sure that all used fragments specifies the field "id".,
214214
ruleId: @graphql-eslint/require-id-when-available,
215215
},
216216
],

packages/plugin/tests/__snapshots__/require-id-when-available.spec.ts.snap

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,24 @@
22

33
exports[` 1`] = `
44
> 1 | query { hasId { name } }
5-
| ^^^^^^^ Field "id" must be selected when it's available on a type. Please make sure to include it in your selection set!
6-
If you are using fragments, make sure that all used fragments specifies the field "id".
5+
| ^ Field "id" must be selected when it's available on a type. Please make sure to include it in your selection set!
6+
If you are using fragments, make sure that all used fragments specifies the field "id".
77
`;
88

99
exports[` 2`] = `
1010
> 1 | query { hasId { id } }
11-
| ^^^^^ Field "name" must be selected when it's available on a type. Please make sure to include it in your selection set!
12-
If you are using fragments, make sure that all used fragments specifies the field "name".
11+
| ^ Field "name" must be selected when it's available on a type. Please make sure to include it in your selection set!
12+
If you are using fragments, make sure that all used fragments specifies the field "name".
13+
`;
14+
15+
exports[` 3`] = `
16+
1 |
17+
2 | query {
18+
> 3 | hasId {
19+
| ^ Field "id" or "_id" must be selected when it's available on a type. Please make sure to include it in your selection set!
20+
If you are using fragments, make sure that all used fragments specifies the field "id" or "_id".
21+
4 | name
22+
5 | }
23+
6 | }
24+
7 |
1325
`;

packages/plugin/tests/parser.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('Parser', () => {
5757
`;
5858

5959
const result = parseForESLint(code, { filePath: 'test.graphql', schema, skipGraphQLConfig: true });
60-
const selectionSet = (result.ast.body[0] as any).definitions[0].selectionSet;
60+
const { selectionSet } = (result.ast.body[0] as any).definitions[0];
6161
const typeInfo = selectionSet.typeInfo();
6262
expect(typeInfo).toBeDefined();
6363
expect(typeInfo.gqlType.name).toEqual('Query');

packages/plugin/tests/require-id-when-available.spec.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { GraphQLRuleTester } from '../src/testkit';
1+
import { GraphQLRuleTester, ParserOptions } from '../src';
22
import rule from '../src/rules/require-id-when-available';
3-
import { ParserOptions } from '../src/types';
43

54
const TEST_SCHEMA = /* GraphQL */ `
65
type Query {
@@ -34,18 +33,19 @@ const TEST_SCHEMA = /* GraphQL */ `
3433
3534
type HasId {
3635
id: ID!
36+
_id: ID!
3737
name: String!
3838
}
3939
`;
4040

4141
const WITH_SCHEMA = {
4242
parserOptions: <ParserOptions>{
4343
schema: TEST_SCHEMA,
44-
operations: [
45-
`fragment HasIdFields on HasId {
44+
operations: /* GraphQL */ `
45+
fragment HasIdFields on HasId {
4646
id
47-
}`,
48-
],
47+
}
48+
`,
4949
},
5050
};
5151
const ruleTester = new GraphQLRuleTester();
@@ -67,6 +67,12 @@ ruleTester.runGraphQLTests('require-id-when-available', rule, {
6767
...WITH_SCHEMA,
6868
code: `query { vehicles { id ...on Car { mileage } } }`,
6969
},
70+
{
71+
...WITH_SCHEMA,
72+
name: 'support multiple id field names',
73+
code: `query { hasId { _id } }`,
74+
options: [{ fieldName: ['id', '_id'] }],
75+
},
7076
],
7177
invalid: [
7278
{
@@ -80,5 +86,18 @@ ruleTester.runGraphQLTests('require-id-when-available', rule, {
8086
options: [{ fieldName: 'name' }],
8187
errors: [{ messageId: 'REQUIRE_ID_WHEN_AVAILABLE' }],
8288
},
89+
{
90+
...WITH_SCHEMA,
91+
name: 'support multiple id field names',
92+
code: /* GraphQL */ `
93+
query {
94+
hasId {
95+
name
96+
}
97+
}
98+
`,
99+
options: [{ fieldName: ['id', '_id'] }],
100+
errors: [{ messageId: 'REQUIRE_ID_WHEN_AVAILABLE' }],
101+
},
83102
],
84103
});

0 commit comments

Comments
 (0)