@graphql-eslint/eslint-plugin 3.14.0-alpha-20221222211539-5e993f5 → 3.14.0-alpha-20221223011223-bd3e820
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cjs/configs/index.js +10 -10
- package/cjs/documents.js +5 -5
- package/cjs/estree-converter/converter.js +2 -2
- package/cjs/estree-converter/index.js +3 -3
- package/cjs/estree-converter/utils.js +2 -2
- package/cjs/flat-configs.js +36 -0
- package/cjs/index.js +16 -14
- package/cjs/parser.js +13 -13
- package/cjs/processor.js +2 -2
- package/cjs/rules/alphabetize.js +7 -7
- package/cjs/rules/graphql-js-validation.js +9 -9
- package/cjs/rules/index.js +66 -66
- package/cjs/rules/lone-executable-definition.js +4 -4
- package/cjs/rules/match-document-filename.js +4 -4
- package/cjs/rules/naming-convention.js +6 -6
- package/cjs/rules/no-anonymous-operations.js +2 -2
- package/cjs/rules/no-deprecated.js +2 -2
- package/cjs/rules/no-one-place-fragments.js +3 -4
- package/cjs/rules/no-root-type.js +3 -3
- package/cjs/rules/no-scalar-result-type-on-mutation.js +2 -2
- package/cjs/rules/no-unreachable-types.js +3 -3
- package/cjs/rules/no-unused-fields.js +3 -3
- package/cjs/rules/relay-arguments.js +2 -2
- package/cjs/rules/relay-edge-types.js +6 -6
- package/cjs/rules/relay-page-info.js +5 -5
- package/cjs/rules/require-deprecation-date.js +2 -2
- package/cjs/rules/require-deprecation-reason.js +2 -2
- package/cjs/rules/require-description.js +8 -8
- package/cjs/rules/require-field-of-type-query-in-mutation-result.js +3 -3
- package/cjs/rules/require-id-when-available.js +8 -8
- package/cjs/rules/selection-set-depth.js +5 -5
- package/cjs/rules/strict-id-in-types.js +7 -7
- package/cjs/rules/unique-fragment-name.js +4 -4
- package/cjs/rules/unique-operation-name.js +2 -2
- package/cjs/schema.js +2 -2
- package/esm/cache.js +25 -0
- package/esm/configs/base.js +4 -0
- package/esm/configs/index.js +12 -0
- package/esm/configs/operations-all.js +29 -0
- package/esm/configs/operations-recommended.js +53 -0
- package/esm/configs/relay.js +9 -0
- package/esm/configs/schema-all.js +22 -0
- package/esm/configs/schema-recommended.js +49 -0
- package/esm/documents.js +144 -0
- package/esm/estree-converter/converter.js +58 -0
- package/esm/estree-converter/index.js +3 -0
- package/esm/estree-converter/types.js +1 -0
- package/esm/estree-converter/utils.js +102 -0
- package/esm/flat-configs.js +33 -0
- package/esm/graphql-config.js +49 -0
- package/esm/index.js +9 -0
- package/esm/package.json +1 -0
- package/esm/parser.js +56 -0
- package/esm/processor.js +75 -0
- package/esm/rules/alphabetize.js +344 -0
- package/esm/rules/description-style.js +75 -0
- package/esm/rules/graphql-js-validation.js +498 -0
- package/esm/rules/index.js +71 -0
- package/esm/rules/input-name.js +133 -0
- package/esm/rules/lone-executable-definition.js +85 -0
- package/esm/rules/match-document-filename.js +232 -0
- package/esm/rules/naming-convention.js +307 -0
- package/esm/rules/no-anonymous-operations.js +64 -0
- package/esm/rules/no-case-insensitive-enum-values-duplicates.js +58 -0
- package/esm/rules/no-deprecated.js +121 -0
- package/esm/rules/no-duplicate-fields.js +109 -0
- package/esm/rules/no-hashtag-description.js +86 -0
- package/esm/rules/no-one-place-fragments.js +80 -0
- package/esm/rules/no-root-type.js +83 -0
- package/esm/rules/no-scalar-result-type-on-mutation.js +63 -0
- package/esm/rules/no-typename-prefix.js +62 -0
- package/esm/rules/no-unreachable-types.js +154 -0
- package/esm/rules/no-unused-fields.js +127 -0
- package/esm/rules/relay-arguments.js +118 -0
- package/esm/rules/relay-connection-types.js +104 -0
- package/esm/rules/relay-edge-types.js +186 -0
- package/esm/rules/relay-page-info.js +97 -0
- package/esm/rules/require-deprecation-date.js +120 -0
- package/esm/rules/require-deprecation-reason.js +53 -0
- package/esm/rules/require-description.js +190 -0
- package/esm/rules/require-field-of-type-query-in-mutation-result.js +69 -0
- package/esm/rules/require-id-when-available.js +196 -0
- package/esm/rules/require-nullable-fields-with-oneof.js +58 -0
- package/esm/rules/require-type-pattern-with-oneof.js +57 -0
- package/esm/rules/selection-set-depth.js +131 -0
- package/esm/rules/strict-id-in-types.js +159 -0
- package/esm/rules/unique-fragment-name.js +86 -0
- package/esm/rules/unique-operation-name.js +62 -0
- package/esm/schema.js +37 -0
- package/esm/testkit.js +181 -0
- package/esm/types.js +1 -0
- package/esm/utils.js +83 -0
- package/package.json +10 -1
- package/typings/estree-converter/converter.d.cts +1 -1
- package/typings/estree-converter/converter.d.ts +1 -1
- package/typings/estree-converter/index.d.cts +3 -3
- package/typings/estree-converter/index.d.ts +3 -3
- package/typings/estree-converter/types.d.cts +3 -3
- package/typings/estree-converter/types.d.ts +3 -3
- package/typings/estree-converter/utils.d.cts +2 -2
- package/typings/estree-converter/utils.d.ts +2 -2
- package/typings/flat-configs.d.cts +248 -0
- package/typings/flat-configs.d.ts +248 -0
- package/typings/graphql-config.d.cts +1 -1
- package/typings/graphql-config.d.ts +1 -1
- package/typings/index.d.cts +8 -7
- package/typings/index.d.ts +8 -7
- package/typings/parser.d.cts +1 -1
- package/typings/parser.d.ts +1 -1
- package/typings/rules/alphabetize.d.cts +1 -1
- package/typings/rules/alphabetize.d.ts +1 -1
- package/typings/rules/description-style.d.cts +1 -1
- package/typings/rules/description-style.d.ts +1 -1
- package/typings/rules/graphql-js-validation.d.cts +1 -1
- package/typings/rules/graphql-js-validation.d.ts +1 -1
- package/typings/rules/index.d.cts +45 -45
- package/typings/rules/index.d.ts +45 -45
- package/typings/rules/input-name.d.cts +1 -1
- package/typings/rules/input-name.d.ts +1 -1
- package/typings/rules/lone-executable-definition.d.cts +1 -1
- package/typings/rules/lone-executable-definition.d.ts +1 -1
- package/typings/rules/match-document-filename.d.cts +2 -2
- package/typings/rules/match-document-filename.d.ts +2 -2
- package/typings/rules/naming-convention.d.cts +1 -1
- package/typings/rules/naming-convention.d.ts +1 -1
- package/typings/rules/no-anonymous-operations.d.cts +1 -1
- package/typings/rules/no-anonymous-operations.d.ts +1 -1
- package/typings/rules/no-case-insensitive-enum-values-duplicates.d.cts +1 -1
- package/typings/rules/no-case-insensitive-enum-values-duplicates.d.ts +1 -1
- package/typings/rules/no-deprecated.d.cts +1 -1
- package/typings/rules/no-deprecated.d.ts +1 -1
- package/typings/rules/no-duplicate-fields.d.cts +1 -1
- package/typings/rules/no-duplicate-fields.d.ts +1 -1
- package/typings/rules/no-hashtag-description.d.cts +1 -1
- package/typings/rules/no-hashtag-description.d.ts +1 -1
- package/typings/rules/no-one-place-fragments.d.cts +1 -1
- package/typings/rules/no-one-place-fragments.d.ts +1 -1
- package/typings/rules/no-root-type.d.cts +1 -1
- package/typings/rules/no-root-type.d.ts +1 -1
- package/typings/rules/no-scalar-result-type-on-mutation.d.cts +1 -1
- package/typings/rules/no-scalar-result-type-on-mutation.d.ts +1 -1
- package/typings/rules/no-typename-prefix.d.cts +1 -1
- package/typings/rules/no-typename-prefix.d.ts +1 -1
- package/typings/rules/no-unreachable-types.d.cts +1 -1
- package/typings/rules/no-unreachable-types.d.ts +1 -1
- package/typings/rules/no-unused-fields.d.cts +1 -1
- package/typings/rules/no-unused-fields.d.ts +1 -1
- package/typings/rules/relay-arguments.d.cts +1 -1
- package/typings/rules/relay-arguments.d.ts +1 -1
- package/typings/rules/relay-connection-types.d.cts +1 -1
- package/typings/rules/relay-connection-types.d.ts +1 -1
- package/typings/rules/relay-edge-types.d.cts +1 -1
- package/typings/rules/relay-edge-types.d.ts +1 -1
- package/typings/rules/relay-page-info.d.cts +1 -1
- package/typings/rules/relay-page-info.d.ts +1 -1
- package/typings/rules/require-deprecation-date.d.cts +1 -1
- package/typings/rules/require-deprecation-date.d.ts +1 -1
- package/typings/rules/require-deprecation-reason.d.cts +1 -1
- package/typings/rules/require-deprecation-reason.d.ts +1 -1
- package/typings/rules/require-description.d.cts +1 -1
- package/typings/rules/require-description.d.ts +1 -1
- package/typings/rules/require-field-of-type-query-in-mutation-result.d.cts +1 -1
- package/typings/rules/require-field-of-type-query-in-mutation-result.d.ts +1 -1
- package/typings/rules/require-id-when-available.d.cts +1 -1
- package/typings/rules/require-id-when-available.d.ts +1 -1
- package/typings/rules/require-nullable-fields-with-oneof.d.cts +1 -1
- package/typings/rules/require-nullable-fields-with-oneof.d.ts +1 -1
- package/typings/rules/require-type-pattern-with-oneof.d.cts +1 -1
- package/typings/rules/require-type-pattern-with-oneof.d.ts +1 -1
- package/typings/rules/selection-set-depth.d.cts +1 -1
- package/typings/rules/selection-set-depth.d.ts +1 -1
- package/typings/rules/strict-id-in-types.d.cts +1 -1
- package/typings/rules/strict-id-in-types.d.ts +1 -1
- package/typings/rules/unique-fragment-name.d.cts +2 -2
- package/typings/rules/unique-fragment-name.d.ts +2 -2
- package/typings/rules/unique-operation-name.d.cts +1 -1
- package/typings/rules/unique-operation-name.d.ts +1 -1
- package/typings/schema.d.cts +1 -1
- package/typings/schema.d.ts +1 -1
- package/typings/testkit.d.cts +3 -3
- package/typings/testkit.d.ts +3 -3
- package/typings/types.d.cts +2 -2
- package/typings/types.d.ts +2 -2
- package/typings/utils.d.cts +2 -2
- package/typings/utils.d.ts +2 -2
@@ -0,0 +1,118 @@
|
|
1
|
+
import { isScalarType, Kind } from 'graphql';
|
2
|
+
import { requireGraphQLSchemaFromContext } from '../utils.js';
|
3
|
+
const RULE_ID = 'relay-arguments';
|
4
|
+
const MISSING_ARGUMENTS = 'MISSING_ARGUMENTS';
|
5
|
+
const schema = {
|
6
|
+
type: 'array',
|
7
|
+
maxItems: 1,
|
8
|
+
items: {
|
9
|
+
type: 'object',
|
10
|
+
additionalProperties: false,
|
11
|
+
minProperties: 1,
|
12
|
+
properties: {
|
13
|
+
includeBoth: {
|
14
|
+
type: 'boolean',
|
15
|
+
default: true,
|
16
|
+
description: 'Enforce including both forward and backward pagination arguments',
|
17
|
+
},
|
18
|
+
},
|
19
|
+
},
|
20
|
+
};
|
21
|
+
export const rule = {
|
22
|
+
meta: {
|
23
|
+
type: 'problem',
|
24
|
+
docs: {
|
25
|
+
category: 'Schema',
|
26
|
+
description: [
|
27
|
+
'Set of rules to follow Relay specification for Arguments.',
|
28
|
+
'',
|
29
|
+
'- A field that returns a Connection type must include forward pagination arguments (`first` and `after`), backward pagination arguments (`last` and `before`), or both',
|
30
|
+
'',
|
31
|
+
'Forward pagination arguments',
|
32
|
+
'',
|
33
|
+
'- `first` takes a non-negative integer',
|
34
|
+
'- `after` takes the Cursor type',
|
35
|
+
'',
|
36
|
+
'Backward pagination arguments',
|
37
|
+
'',
|
38
|
+
'- `last` takes a non-negative integer',
|
39
|
+
'- `before` takes the Cursor type',
|
40
|
+
].join('\n'),
|
41
|
+
url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${RULE_ID}.md`,
|
42
|
+
examples: [
|
43
|
+
{
|
44
|
+
title: 'Incorrect',
|
45
|
+
code: /* GraphQL */ `
|
46
|
+
type User {
|
47
|
+
posts: PostConnection
|
48
|
+
}
|
49
|
+
`,
|
50
|
+
},
|
51
|
+
{
|
52
|
+
title: 'Correct',
|
53
|
+
code: /* GraphQL */ `
|
54
|
+
type User {
|
55
|
+
posts(after: String, first: Int, before: String, last: Int): PostConnection
|
56
|
+
}
|
57
|
+
`,
|
58
|
+
},
|
59
|
+
],
|
60
|
+
isDisabledForAllConfig: true,
|
61
|
+
},
|
62
|
+
messages: {
|
63
|
+
[MISSING_ARGUMENTS]: 'A field that returns a Connection type must include forward pagination arguments (`first` and `after`), backward pagination arguments (`last` and `before`), or both.',
|
64
|
+
},
|
65
|
+
schema,
|
66
|
+
},
|
67
|
+
create(context) {
|
68
|
+
const schema = requireGraphQLSchemaFromContext(RULE_ID, context);
|
69
|
+
const { includeBoth = true } = context.options[0] || {};
|
70
|
+
return {
|
71
|
+
'FieldDefinition > .gqlType Name[value=/Connection$/]'(node) {
|
72
|
+
let fieldNode = node.parent;
|
73
|
+
while (fieldNode.kind !== Kind.FIELD_DEFINITION) {
|
74
|
+
fieldNode = fieldNode.parent;
|
75
|
+
}
|
76
|
+
const args = Object.fromEntries(fieldNode.arguments.map(argument => [argument.name.value, argument]));
|
77
|
+
const hasForwardPagination = Boolean(args.first && args.after);
|
78
|
+
const hasBackwardPagination = Boolean(args.last && args.before);
|
79
|
+
if (!hasForwardPagination && !hasBackwardPagination) {
|
80
|
+
context.report({
|
81
|
+
node: fieldNode.name,
|
82
|
+
messageId: MISSING_ARGUMENTS,
|
83
|
+
});
|
84
|
+
return;
|
85
|
+
}
|
86
|
+
function checkField(typeName, argumentName) {
|
87
|
+
const argument = args[argumentName];
|
88
|
+
const hasArgument = Boolean(argument);
|
89
|
+
let type = argument;
|
90
|
+
if (hasArgument && type.gqlType.kind === Kind.NON_NULL_TYPE) {
|
91
|
+
type = type.gqlType;
|
92
|
+
}
|
93
|
+
const isAllowedNonNullType = hasArgument &&
|
94
|
+
type.gqlType.kind === Kind.NAMED_TYPE &&
|
95
|
+
(type.gqlType.name.value === typeName ||
|
96
|
+
(typeName === 'String' && isScalarType(schema.getType(type.gqlType.name.value))));
|
97
|
+
if (!isAllowedNonNullType) {
|
98
|
+
const returnType = typeName === 'String' ? 'String or Scalar' : typeName;
|
99
|
+
context.report({
|
100
|
+
node: (argument || fieldNode).name,
|
101
|
+
message: hasArgument
|
102
|
+
? `Argument \`${argumentName}\` must return ${returnType}.`
|
103
|
+
: `Field \`${fieldNode.name.value}\` must contain an argument \`${argumentName}\`, that return ${returnType}.`,
|
104
|
+
});
|
105
|
+
}
|
106
|
+
}
|
107
|
+
if (includeBoth || args.first || args.after) {
|
108
|
+
checkField('Int', 'first');
|
109
|
+
checkField('String', 'after');
|
110
|
+
}
|
111
|
+
if (includeBoth || args.last || args.before) {
|
112
|
+
checkField('Int', 'last');
|
113
|
+
checkField('String', 'before');
|
114
|
+
}
|
115
|
+
},
|
116
|
+
};
|
117
|
+
},
|
118
|
+
};
|
@@ -0,0 +1,104 @@
|
|
1
|
+
import { Kind } from 'graphql';
|
2
|
+
const MUST_BE_OBJECT_TYPE = 'MUST_BE_OBJECT_TYPE';
|
3
|
+
const MUST_CONTAIN_FIELD_EDGES = 'MUST_CONTAIN_FIELD_EDGES';
|
4
|
+
const MUST_CONTAIN_FIELD_PAGE_INFO = 'MUST_CONTAIN_FIELD_PAGE_INFO';
|
5
|
+
const MUST_HAVE_CONNECTION_SUFFIX = 'MUST_HAVE_CONNECTION_SUFFIX';
|
6
|
+
const EDGES_FIELD_MUST_RETURN_LIST_TYPE = 'EDGES_FIELD_MUST_RETURN_LIST_TYPE';
|
7
|
+
const PAGE_INFO_FIELD_MUST_RETURN_NON_NULL_TYPE = 'PAGE_INFO_FIELD_MUST_RETURN_NON_NULL_TYPE';
|
8
|
+
export const NON_OBJECT_TYPES = [
|
9
|
+
Kind.SCALAR_TYPE_DEFINITION,
|
10
|
+
Kind.UNION_TYPE_DEFINITION,
|
11
|
+
Kind.UNION_TYPE_EXTENSION,
|
12
|
+
Kind.INPUT_OBJECT_TYPE_DEFINITION,
|
13
|
+
Kind.INPUT_OBJECT_TYPE_EXTENSION,
|
14
|
+
Kind.ENUM_TYPE_DEFINITION,
|
15
|
+
Kind.ENUM_TYPE_EXTENSION,
|
16
|
+
Kind.INTERFACE_TYPE_DEFINITION,
|
17
|
+
Kind.INTERFACE_TYPE_EXTENSION,
|
18
|
+
];
|
19
|
+
const notConnectionTypesSelector = `:matches(${NON_OBJECT_TYPES})[name.value=/Connection$/] > .name`;
|
20
|
+
const hasEdgesField = (node) => node.fields.some(field => field.name.value === 'edges');
|
21
|
+
const hasPageInfoField = (node) => node.fields.some(field => field.name.value === 'pageInfo');
|
22
|
+
export const rule = {
|
23
|
+
meta: {
|
24
|
+
type: 'problem',
|
25
|
+
docs: {
|
26
|
+
category: 'Schema',
|
27
|
+
description: [
|
28
|
+
'Set of rules to follow Relay specification for Connection types.',
|
29
|
+
'',
|
30
|
+
'- Any type whose name ends in "Connection" is considered by spec to be a `Connection type`',
|
31
|
+
'- Connection type must be an Object type',
|
32
|
+
'- Connection type must contain a field `edges` that return a list type that wraps an edge type',
|
33
|
+
'- Connection type must contain a field `pageInfo` that return a non-null `PageInfo` Object type',
|
34
|
+
].join('\n'),
|
35
|
+
url: 'https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/relay-connection-types.md',
|
36
|
+
isDisabledForAllConfig: true,
|
37
|
+
examples: [
|
38
|
+
{
|
39
|
+
title: 'Incorrect',
|
40
|
+
code: /* GraphQL */ `
|
41
|
+
type UserPayload { # should be an Object type with \`Connection\` suffix
|
42
|
+
edges: UserEdge! # should return a list type
|
43
|
+
pageInfo: PageInfo # should return a non-null \`PageInfo\` Object type
|
44
|
+
}
|
45
|
+
`,
|
46
|
+
},
|
47
|
+
{
|
48
|
+
title: 'Correct',
|
49
|
+
code: /* GraphQL */ `
|
50
|
+
type UserConnection {
|
51
|
+
edges: [UserEdge]
|
52
|
+
pageInfo: PageInfo!
|
53
|
+
}
|
54
|
+
`,
|
55
|
+
},
|
56
|
+
],
|
57
|
+
},
|
58
|
+
messages: {
|
59
|
+
// Connection types
|
60
|
+
[MUST_BE_OBJECT_TYPE]: 'Connection type must be an Object type.',
|
61
|
+
[MUST_HAVE_CONNECTION_SUFFIX]: 'Connection type must have `Connection` suffix.',
|
62
|
+
[MUST_CONTAIN_FIELD_EDGES]: 'Connection type must contain a field `edges` that return a list type.',
|
63
|
+
[MUST_CONTAIN_FIELD_PAGE_INFO]: 'Connection type must contain a field `pageInfo` that return a non-null `PageInfo` Object type.',
|
64
|
+
[EDGES_FIELD_MUST_RETURN_LIST_TYPE]: '`edges` field must return a list type.',
|
65
|
+
[PAGE_INFO_FIELD_MUST_RETURN_NON_NULL_TYPE]: '`pageInfo` field must return a non-null `PageInfo` Object type.',
|
66
|
+
},
|
67
|
+
schema: [],
|
68
|
+
},
|
69
|
+
create(context) {
|
70
|
+
return {
|
71
|
+
[notConnectionTypesSelector](node) {
|
72
|
+
context.report({ node, messageId: MUST_BE_OBJECT_TYPE });
|
73
|
+
},
|
74
|
+
':matches(ObjectTypeDefinition, ObjectTypeExtension)[name.value!=/Connection$/]'(node) {
|
75
|
+
if (hasEdgesField(node) && hasPageInfoField(node)) {
|
76
|
+
context.report({ node: node.name, messageId: MUST_HAVE_CONNECTION_SUFFIX });
|
77
|
+
}
|
78
|
+
},
|
79
|
+
':matches(ObjectTypeDefinition, ObjectTypeExtension)[name.value=/Connection$/]'(node) {
|
80
|
+
if (!hasEdgesField(node)) {
|
81
|
+
context.report({ node: node.name, messageId: MUST_CONTAIN_FIELD_EDGES });
|
82
|
+
}
|
83
|
+
if (!hasPageInfoField(node)) {
|
84
|
+
context.report({ node: node.name, messageId: MUST_CONTAIN_FIELD_PAGE_INFO });
|
85
|
+
}
|
86
|
+
},
|
87
|
+
':matches(ObjectTypeDefinition, ObjectTypeExtension)[name.value=/Connection$/] > FieldDefinition[name.value=edges] > .gqlType'(node) {
|
88
|
+
const isListType = node.kind === Kind.LIST_TYPE ||
|
89
|
+
(node.kind === Kind.NON_NULL_TYPE && node.gqlType.kind === Kind.LIST_TYPE);
|
90
|
+
if (!isListType) {
|
91
|
+
context.report({ node, messageId: EDGES_FIELD_MUST_RETURN_LIST_TYPE });
|
92
|
+
}
|
93
|
+
},
|
94
|
+
':matches(ObjectTypeDefinition, ObjectTypeExtension)[name.value=/Connection$/] > FieldDefinition[name.value=pageInfo] > .gqlType'(node) {
|
95
|
+
const isNonNullPageInfoType = node.kind === Kind.NON_NULL_TYPE &&
|
96
|
+
node.gqlType.kind === Kind.NAMED_TYPE &&
|
97
|
+
node.gqlType.name.value === 'PageInfo';
|
98
|
+
if (!isNonNullPageInfoType) {
|
99
|
+
context.report({ node, messageId: PAGE_INFO_FIELD_MUST_RETURN_NON_NULL_TYPE });
|
100
|
+
}
|
101
|
+
},
|
102
|
+
};
|
103
|
+
},
|
104
|
+
};
|
@@ -0,0 +1,186 @@
|
|
1
|
+
import { visit, isObjectType, Kind, isScalarType, } from 'graphql';
|
2
|
+
import { getDocumentNodeFromSchema } from '@graphql-tools/utils';
|
3
|
+
import { getTypeName, requireGraphQLSchemaFromContext } from '../utils.js';
|
4
|
+
const RULE_ID = 'relay-edge-types';
|
5
|
+
const MESSAGE_MUST_BE_OBJECT_TYPE = 'MESSAGE_MUST_BE_OBJECT_TYPE';
|
6
|
+
const MESSAGE_MISSING_EDGE_SUFFIX = 'MESSAGE_MISSING_EDGE_SUFFIX';
|
7
|
+
const MESSAGE_LIST_TYPE_ONLY_EDGE_TYPE = 'MESSAGE_LIST_TYPE_ONLY_EDGE_TYPE';
|
8
|
+
const MESSAGE_SHOULD_IMPLEMENTS_NODE = 'MESSAGE_SHOULD_IMPLEMENTS_NODE';
|
9
|
+
let edgeTypesCache;
|
10
|
+
function getEdgeTypes(schema) {
|
11
|
+
// We don't want cache edgeTypes on test environment
|
12
|
+
// Otherwise edgeTypes will be same for all tests
|
13
|
+
if (process.env.NODE_ENV !== 'test' && edgeTypesCache) {
|
14
|
+
return edgeTypesCache;
|
15
|
+
}
|
16
|
+
const edgeTypes = new Set();
|
17
|
+
const visitor = {
|
18
|
+
ObjectTypeDefinition(node) {
|
19
|
+
const typeName = node.name.value;
|
20
|
+
const hasConnectionSuffix = typeName.endsWith('Connection');
|
21
|
+
if (!hasConnectionSuffix) {
|
22
|
+
return;
|
23
|
+
}
|
24
|
+
const edges = node.fields.find(field => field.name.value === 'edges');
|
25
|
+
if (edges) {
|
26
|
+
const edgesTypeName = getTypeName(edges);
|
27
|
+
const edgesType = schema.getType(edgesTypeName);
|
28
|
+
if (isObjectType(edgesType)) {
|
29
|
+
edgeTypes.add(edgesTypeName);
|
30
|
+
}
|
31
|
+
}
|
32
|
+
},
|
33
|
+
};
|
34
|
+
const astNode = getDocumentNodeFromSchema(schema); // Transforms the schema into ASTNode
|
35
|
+
visit(astNode, visitor);
|
36
|
+
edgeTypesCache = edgeTypes;
|
37
|
+
return edgeTypesCache;
|
38
|
+
}
|
39
|
+
const schema = {
|
40
|
+
type: 'array',
|
41
|
+
maxItems: 1,
|
42
|
+
items: {
|
43
|
+
type: 'object',
|
44
|
+
additionalProperties: false,
|
45
|
+
minProperties: 1,
|
46
|
+
properties: {
|
47
|
+
withEdgeSuffix: {
|
48
|
+
type: 'boolean',
|
49
|
+
default: true,
|
50
|
+
description: 'Edge type name must end in "Edge".',
|
51
|
+
},
|
52
|
+
shouldImplementNode: {
|
53
|
+
type: 'boolean',
|
54
|
+
default: true,
|
55
|
+
description: "Edge type's field `node` must implement `Node` interface.",
|
56
|
+
},
|
57
|
+
listTypeCanWrapOnlyEdgeType: {
|
58
|
+
type: 'boolean',
|
59
|
+
default: true,
|
60
|
+
description: 'A list type should only wrap an edge type.',
|
61
|
+
},
|
62
|
+
},
|
63
|
+
},
|
64
|
+
};
|
65
|
+
export const rule = {
|
66
|
+
meta: {
|
67
|
+
type: 'problem',
|
68
|
+
docs: {
|
69
|
+
category: 'Schema',
|
70
|
+
description: [
|
71
|
+
'Set of rules to follow Relay specification for Edge types.',
|
72
|
+
'',
|
73
|
+
"- A type that is returned in list form by a connection type's `edges` field is considered by this spec to be an Edge type",
|
74
|
+
'- Edge type must be an Object type',
|
75
|
+
'- Edge type must contain a field `node` that return either Scalar, Enum, Object, Interface, Union, or a non-null wrapper around one of those types. Notably, this field cannot return a list',
|
76
|
+
'- Edge type must contain a field `cursor` that return either String, Scalar, or a non-null wrapper around one of those types',
|
77
|
+
'- Edge type name must end in "Edge" _(optional)_',
|
78
|
+
"- Edge type's field `node` must implement `Node` interface _(optional)_",
|
79
|
+
'- A list type should only wrap an edge type _(optional)_',
|
80
|
+
].join('\n'),
|
81
|
+
url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${RULE_ID}.md`,
|
82
|
+
isDisabledForAllConfig: true,
|
83
|
+
requiresSchema: true,
|
84
|
+
examples: [
|
85
|
+
{
|
86
|
+
title: 'Correct',
|
87
|
+
code: /* GraphQL */ `
|
88
|
+
type UserConnection {
|
89
|
+
edges: [UserEdge]
|
90
|
+
pageInfo: PageInfo!
|
91
|
+
}
|
92
|
+
`,
|
93
|
+
},
|
94
|
+
],
|
95
|
+
},
|
96
|
+
messages: {
|
97
|
+
[MESSAGE_MUST_BE_OBJECT_TYPE]: 'Edge type must be an Object type.',
|
98
|
+
[MESSAGE_MISSING_EDGE_SUFFIX]: 'Edge type must have "Edge" suffix.',
|
99
|
+
[MESSAGE_LIST_TYPE_ONLY_EDGE_TYPE]: 'A list type should only wrap an edge type.',
|
100
|
+
[MESSAGE_SHOULD_IMPLEMENTS_NODE]: "Edge type's field `node` must implement `Node` interface.",
|
101
|
+
},
|
102
|
+
schema,
|
103
|
+
},
|
104
|
+
create(context) {
|
105
|
+
const schema = requireGraphQLSchemaFromContext(RULE_ID, context);
|
106
|
+
const edgeTypes = getEdgeTypes(schema);
|
107
|
+
const options = {
|
108
|
+
withEdgeSuffix: true,
|
109
|
+
shouldImplementNode: true,
|
110
|
+
listTypeCanWrapOnlyEdgeType: true,
|
111
|
+
...context.options[0],
|
112
|
+
};
|
113
|
+
const isNamedOrNonNullNamed = (node) => node.kind === Kind.NAMED_TYPE ||
|
114
|
+
(node.kind === Kind.NON_NULL_TYPE && node.gqlType.kind === Kind.NAMED_TYPE);
|
115
|
+
const checkNodeField = (node) => {
|
116
|
+
const nodeField = node.fields.find(field => field.name.value === 'node');
|
117
|
+
const message = 'return either a Scalar, Enum, Object, Interface, Union, or a non-null wrapper around one of those types.';
|
118
|
+
if (!nodeField) {
|
119
|
+
context.report({
|
120
|
+
node: node.name,
|
121
|
+
message: `Edge type must contain a field \`node\` that ${message}`,
|
122
|
+
});
|
123
|
+
}
|
124
|
+
else if (!isNamedOrNonNullNamed(nodeField.gqlType)) {
|
125
|
+
context.report({ node: nodeField.name, message: `Field \`node\` must ${message}` });
|
126
|
+
}
|
127
|
+
else if (options.shouldImplementNode) {
|
128
|
+
const nodeReturnTypeName = getTypeName(nodeField.gqlType.rawNode());
|
129
|
+
const type = schema.getType(nodeReturnTypeName);
|
130
|
+
if (!isObjectType(type)) {
|
131
|
+
return;
|
132
|
+
}
|
133
|
+
const implementsNode = type.astNode.interfaces.some(n => n.name.value === 'Node');
|
134
|
+
if (!implementsNode) {
|
135
|
+
context.report({ node: node.name, messageId: MESSAGE_SHOULD_IMPLEMENTS_NODE });
|
136
|
+
}
|
137
|
+
}
|
138
|
+
};
|
139
|
+
const checkCursorField = (node) => {
|
140
|
+
const cursorField = node.fields.find(field => field.name.value === 'cursor');
|
141
|
+
const message = 'return either a String, Scalar, or a non-null wrapper wrapper around one of those types.';
|
142
|
+
if (!cursorField) {
|
143
|
+
context.report({
|
144
|
+
node: node.name,
|
145
|
+
message: `Edge type must contain a field \`cursor\` that ${message}`,
|
146
|
+
});
|
147
|
+
return;
|
148
|
+
}
|
149
|
+
const typeName = getTypeName(cursorField.rawNode());
|
150
|
+
if (!isNamedOrNonNullNamed(cursorField.gqlType) ||
|
151
|
+
(typeName !== 'String' && !isScalarType(schema.getType(typeName)))) {
|
152
|
+
context.report({ node: cursorField.name, message: `Field \`cursor\` must ${message}` });
|
153
|
+
}
|
154
|
+
};
|
155
|
+
const listeners = {
|
156
|
+
':matches(ObjectTypeDefinition, ObjectTypeExtension)[name.value=/Connection$/] > FieldDefinition[name.value=edges] > .gqlType Name'(node) {
|
157
|
+
const type = schema.getType(node.value);
|
158
|
+
if (!isObjectType(type)) {
|
159
|
+
context.report({ node, messageId: MESSAGE_MUST_BE_OBJECT_TYPE });
|
160
|
+
}
|
161
|
+
},
|
162
|
+
':matches(ObjectTypeDefinition, ObjectTypeExtension)'(node) {
|
163
|
+
const typeName = node.name.value;
|
164
|
+
if (edgeTypes.has(typeName)) {
|
165
|
+
checkNodeField(node);
|
166
|
+
checkCursorField(node);
|
167
|
+
if (options.withEdgeSuffix && !typeName.endsWith('Edge')) {
|
168
|
+
context.report({ node: node.name, messageId: MESSAGE_MISSING_EDGE_SUFFIX });
|
169
|
+
}
|
170
|
+
}
|
171
|
+
},
|
172
|
+
};
|
173
|
+
if (options.listTypeCanWrapOnlyEdgeType) {
|
174
|
+
listeners['FieldDefinition > .gqlType'] = (node) => {
|
175
|
+
if (node.kind === Kind.LIST_TYPE ||
|
176
|
+
(node.kind === Kind.NON_NULL_TYPE && node.gqlType.kind === Kind.LIST_TYPE)) {
|
177
|
+
const typeName = getTypeName(node.rawNode());
|
178
|
+
if (!edgeTypes.has(typeName)) {
|
179
|
+
context.report({ node, messageId: MESSAGE_LIST_TYPE_ONLY_EDGE_TYPE });
|
180
|
+
}
|
181
|
+
}
|
182
|
+
};
|
183
|
+
}
|
184
|
+
return listeners;
|
185
|
+
},
|
186
|
+
};
|
@@ -0,0 +1,97 @@
|
|
1
|
+
import { isScalarType, Kind } from 'graphql';
|
2
|
+
import { NON_OBJECT_TYPES } from './relay-connection-types.js';
|
3
|
+
import { REPORT_ON_FIRST_CHARACTER, requireGraphQLSchemaFromContext } from '../utils.js';
|
4
|
+
const RULE_ID = 'relay-page-info';
|
5
|
+
const MESSAGE_MUST_EXIST = 'MESSAGE_MUST_EXIST';
|
6
|
+
const MESSAGE_MUST_BE_OBJECT_TYPE = 'MESSAGE_MUST_BE_OBJECT_TYPE';
|
7
|
+
const notPageInfoTypesSelector = `:matches(${NON_OBJECT_TYPES})[name.value=PageInfo] > .name`;
|
8
|
+
let hasPageInfoChecked = false;
|
9
|
+
export const rule = {
|
10
|
+
meta: {
|
11
|
+
type: 'problem',
|
12
|
+
docs: {
|
13
|
+
category: 'Schema',
|
14
|
+
description: [
|
15
|
+
'Set of rules to follow Relay specification for `PageInfo` object.',
|
16
|
+
'',
|
17
|
+
'- `PageInfo` must be an Object type',
|
18
|
+
'- `PageInfo` must contain fields `hasPreviousPage` and `hasNextPage`, that return non-null Boolean',
|
19
|
+
'- `PageInfo` must contain fields `startCursor` and `endCursor`, that return either String or Scalar, which can be null if there are no results',
|
20
|
+
].join('\n'),
|
21
|
+
url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${RULE_ID}.md`,
|
22
|
+
examples: [
|
23
|
+
{
|
24
|
+
title: 'Correct',
|
25
|
+
code: /* GraphQL */ `
|
26
|
+
type PageInfo {
|
27
|
+
hasPreviousPage: Boolean!
|
28
|
+
hasNextPage: Boolean!
|
29
|
+
startCursor: String
|
30
|
+
endCursor: String
|
31
|
+
}
|
32
|
+
`,
|
33
|
+
},
|
34
|
+
],
|
35
|
+
isDisabledForAllConfig: true,
|
36
|
+
requiresSchema: true,
|
37
|
+
},
|
38
|
+
messages: {
|
39
|
+
[MESSAGE_MUST_EXIST]: 'The server must provide a `PageInfo` object.',
|
40
|
+
[MESSAGE_MUST_BE_OBJECT_TYPE]: '`PageInfo` must be an Object type.',
|
41
|
+
},
|
42
|
+
schema: [],
|
43
|
+
},
|
44
|
+
create(context) {
|
45
|
+
const schema = requireGraphQLSchemaFromContext(RULE_ID, context);
|
46
|
+
if (process.env.NODE_ENV === 'test' || !hasPageInfoChecked) {
|
47
|
+
const pageInfoType = schema.getType('PageInfo');
|
48
|
+
if (!pageInfoType) {
|
49
|
+
context.report({
|
50
|
+
loc: REPORT_ON_FIRST_CHARACTER,
|
51
|
+
messageId: MESSAGE_MUST_EXIST,
|
52
|
+
});
|
53
|
+
}
|
54
|
+
hasPageInfoChecked = true;
|
55
|
+
}
|
56
|
+
return {
|
57
|
+
[notPageInfoTypesSelector](node) {
|
58
|
+
context.report({ node, messageId: MESSAGE_MUST_BE_OBJECT_TYPE });
|
59
|
+
},
|
60
|
+
'ObjectTypeDefinition[name.value=PageInfo]'(node) {
|
61
|
+
const fieldMap = Object.fromEntries(node.fields.map(field => [field.name.value, field]));
|
62
|
+
const checkField = (fieldName, typeName) => {
|
63
|
+
const field = fieldMap[fieldName];
|
64
|
+
let isAllowedType = false;
|
65
|
+
if (field) {
|
66
|
+
const type = field.gqlType;
|
67
|
+
if (typeName === 'Boolean') {
|
68
|
+
isAllowedType =
|
69
|
+
type.kind === Kind.NON_NULL_TYPE &&
|
70
|
+
type.gqlType.kind === Kind.NAMED_TYPE &&
|
71
|
+
type.gqlType.name.value === 'Boolean';
|
72
|
+
}
|
73
|
+
else if (type.kind === Kind.NAMED_TYPE) {
|
74
|
+
isAllowedType =
|
75
|
+
type.name.value === 'String' || isScalarType(schema.getType(type.name.value));
|
76
|
+
}
|
77
|
+
}
|
78
|
+
if (!isAllowedType) {
|
79
|
+
const returnType = typeName === 'Boolean'
|
80
|
+
? 'non-null Boolean'
|
81
|
+
: 'either String or Scalar, which can be null if there are no results';
|
82
|
+
context.report({
|
83
|
+
node: field ? field.name : node.name,
|
84
|
+
message: field
|
85
|
+
? `Field \`${fieldName}\` must return ${returnType}.`
|
86
|
+
: `\`PageInfo\` must contain a field \`${fieldName}\`, that return ${returnType}.`,
|
87
|
+
});
|
88
|
+
}
|
89
|
+
};
|
90
|
+
checkField('hasPreviousPage', 'Boolean');
|
91
|
+
checkField('hasNextPage', 'Boolean');
|
92
|
+
checkField('startCursor', 'String');
|
93
|
+
checkField('endCursor', 'String');
|
94
|
+
},
|
95
|
+
};
|
96
|
+
},
|
97
|
+
};
|
@@ -0,0 +1,120 @@
|
|
1
|
+
import { valueFromNode } from '../estree-converter/index.js';
|
2
|
+
// eslint-disable-next-line unicorn/better-regex
|
3
|
+
const DATE_REGEX = /^\d{2}\/\d{2}\/\d{4}$/;
|
4
|
+
const MESSAGE_REQUIRE_DATE = 'MESSAGE_REQUIRE_DATE';
|
5
|
+
const MESSAGE_INVALID_FORMAT = 'MESSAGE_INVALID_FORMAT';
|
6
|
+
const MESSAGE_INVALID_DATE = 'MESSAGE_INVALID_DATE';
|
7
|
+
const MESSAGE_CAN_BE_REMOVED = 'MESSAGE_CAN_BE_REMOVED';
|
8
|
+
const schema = {
|
9
|
+
type: 'array',
|
10
|
+
maxItems: 1,
|
11
|
+
items: {
|
12
|
+
type: 'object',
|
13
|
+
additionalProperties: false,
|
14
|
+
properties: {
|
15
|
+
argumentName: {
|
16
|
+
type: 'string',
|
17
|
+
},
|
18
|
+
},
|
19
|
+
},
|
20
|
+
};
|
21
|
+
export const rule = {
|
22
|
+
meta: {
|
23
|
+
type: 'suggestion',
|
24
|
+
hasSuggestions: true,
|
25
|
+
docs: {
|
26
|
+
category: 'Schema',
|
27
|
+
description: 'Require deletion date on `@deprecated` directive. Suggest removing deprecated things after deprecated date.',
|
28
|
+
url: 'https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/require-deprecation-date.md',
|
29
|
+
examples: [
|
30
|
+
{
|
31
|
+
title: 'Incorrect',
|
32
|
+
code: /* GraphQL */ `
|
33
|
+
type User {
|
34
|
+
firstname: String @deprecated
|
35
|
+
firstName: String
|
36
|
+
}
|
37
|
+
`,
|
38
|
+
},
|
39
|
+
{
|
40
|
+
title: 'Incorrect',
|
41
|
+
code: /* GraphQL */ `
|
42
|
+
type User {
|
43
|
+
firstname: String @deprecated(reason: "Use 'firstName' instead")
|
44
|
+
firstName: String
|
45
|
+
}
|
46
|
+
`,
|
47
|
+
},
|
48
|
+
{
|
49
|
+
title: 'Correct',
|
50
|
+
code: /* GraphQL */ `
|
51
|
+
type User {
|
52
|
+
firstname: String
|
53
|
+
@deprecated(reason: "Use 'firstName' instead", deletionDate: "25/12/2022")
|
54
|
+
firstName: String
|
55
|
+
}
|
56
|
+
`,
|
57
|
+
},
|
58
|
+
],
|
59
|
+
},
|
60
|
+
messages: {
|
61
|
+
[MESSAGE_REQUIRE_DATE]: 'Directive "@deprecated" must have a deletion date',
|
62
|
+
[MESSAGE_INVALID_FORMAT]: 'Deletion date must be in format "DD/MM/YYYY"',
|
63
|
+
[MESSAGE_INVALID_DATE]: 'Invalid "{{ deletionDate }}" deletion date',
|
64
|
+
[MESSAGE_CAN_BE_REMOVED]: '"{{ nodeName }}" сan be removed',
|
65
|
+
},
|
66
|
+
schema,
|
67
|
+
},
|
68
|
+
create(context) {
|
69
|
+
return {
|
70
|
+
'Directive[name.value=deprecated]'(node) {
|
71
|
+
var _a;
|
72
|
+
const argName = ((_a = context.options[0]) === null || _a === void 0 ? void 0 : _a.argumentName) || 'deletionDate';
|
73
|
+
const deletionDateNode = node.arguments.find(arg => arg.name.value === argName);
|
74
|
+
if (!deletionDateNode) {
|
75
|
+
context.report({
|
76
|
+
node: node.name,
|
77
|
+
messageId: MESSAGE_REQUIRE_DATE,
|
78
|
+
});
|
79
|
+
return;
|
80
|
+
}
|
81
|
+
const deletionDate = valueFromNode(deletionDateNode.value);
|
82
|
+
const isValidDate = DATE_REGEX.test(deletionDate);
|
83
|
+
if (!isValidDate) {
|
84
|
+
context.report({ node: deletionDateNode.value, messageId: MESSAGE_INVALID_FORMAT });
|
85
|
+
return;
|
86
|
+
}
|
87
|
+
let [day, month, year] = deletionDate.split('/');
|
88
|
+
day = day.padStart(2, '0');
|
89
|
+
month = month.padStart(2, '0');
|
90
|
+
const deletionDateInMS = Date.parse(`${year}-${month}-${day}`);
|
91
|
+
if (Number.isNaN(deletionDateInMS)) {
|
92
|
+
context.report({
|
93
|
+
node: deletionDateNode.value,
|
94
|
+
messageId: MESSAGE_INVALID_DATE,
|
95
|
+
data: {
|
96
|
+
deletionDate,
|
97
|
+
},
|
98
|
+
});
|
99
|
+
return;
|
100
|
+
}
|
101
|
+
const canRemove = Date.now() > deletionDateInMS;
|
102
|
+
if (canRemove) {
|
103
|
+
const { parent } = node;
|
104
|
+
const nodeName = parent.name.value;
|
105
|
+
context.report({
|
106
|
+
node: parent.name,
|
107
|
+
messageId: MESSAGE_CAN_BE_REMOVED,
|
108
|
+
data: { nodeName },
|
109
|
+
suggest: [
|
110
|
+
{
|
111
|
+
desc: `Remove \`${nodeName}\``,
|
112
|
+
fix: fixer => fixer.remove(parent),
|
113
|
+
},
|
114
|
+
],
|
115
|
+
});
|
116
|
+
}
|
117
|
+
},
|
118
|
+
};
|
119
|
+
},
|
120
|
+
};
|
@@ -0,0 +1,53 @@
|
|
1
|
+
import { valueFromNode } from '../estree-converter/index.js';
|
2
|
+
export const rule = {
|
3
|
+
meta: {
|
4
|
+
docs: {
|
5
|
+
description: 'Require all deprecation directives to specify a reason.',
|
6
|
+
category: 'Schema',
|
7
|
+
url: 'https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/require-deprecation-reason.md',
|
8
|
+
recommended: true,
|
9
|
+
examples: [
|
10
|
+
{
|
11
|
+
title: 'Incorrect',
|
12
|
+
code: /* GraphQL */ `
|
13
|
+
type MyType {
|
14
|
+
name: String @deprecated
|
15
|
+
}
|
16
|
+
`,
|
17
|
+
},
|
18
|
+
{
|
19
|
+
title: 'Incorrect',
|
20
|
+
code: /* GraphQL */ `
|
21
|
+
type MyType {
|
22
|
+
name: String @deprecated(reason: "")
|
23
|
+
}
|
24
|
+
`,
|
25
|
+
},
|
26
|
+
{
|
27
|
+
title: 'Correct',
|
28
|
+
code: /* GraphQL */ `
|
29
|
+
type MyType {
|
30
|
+
name: String @deprecated(reason: "no longer relevant, please use fullName field")
|
31
|
+
}
|
32
|
+
`,
|
33
|
+
},
|
34
|
+
],
|
35
|
+
},
|
36
|
+
type: 'suggestion',
|
37
|
+
schema: [],
|
38
|
+
},
|
39
|
+
create(context) {
|
40
|
+
return {
|
41
|
+
'Directive[name.value=deprecated]'(node) {
|
42
|
+
const reasonArgument = node.arguments.find(arg => arg.name.value === 'reason');
|
43
|
+
const value = reasonArgument && String(valueFromNode(reasonArgument.value)).trim();
|
44
|
+
if (!value) {
|
45
|
+
context.report({
|
46
|
+
node: node.name,
|
47
|
+
message: 'Directive "@deprecated" must have a reason!',
|
48
|
+
});
|
49
|
+
}
|
50
|
+
},
|
51
|
+
};
|
52
|
+
},
|
53
|
+
};
|