@graphql-eslint/eslint-plugin
Version:
GraphQL plugin for ESLint
201 lines (197 loc) • 7.83 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.rule = void 0;
const graphql_1 = require("graphql");
const utils_1 = require("@graphql-tools/utils");
const utils_js_1 = require("../utils.js");
const index_js_1 = require("../estree-converter/index.js");
const RULE_ID = 'require-id-when-available';
const DEFAULT_ID_FIELD_NAME = 'id';
const schema = {
definitions: {
asString: {
type: 'string',
},
asArray: utils_js_1.ARRAY_DEFAULT_OPTIONS,
},
type: 'array',
maxItems: 1,
items: {
type: 'object',
additionalProperties: false,
properties: {
fieldName: {
oneOf: [{ $ref: '#/definitions/asString' }, { $ref: '#/definitions/asArray' }],
default: DEFAULT_ID_FIELD_NAME,
},
},
},
};
exports.rule = {
meta: {
type: 'suggestion',
hasSuggestions: true,
docs: {
category: 'Operations',
description: 'Enforce selecting specific fields when they are available on the GraphQL type.',
url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${RULE_ID}.md`,
requiresSchema: true,
requiresSiblings: true,
examples: [
{
title: 'Incorrect',
code: /* GraphQL */ `
# In your schema
type User {
id: ID!
name: String!
}
# Query
query {
user {
name
}
}
`,
},
{
title: 'Correct',
code: /* GraphQL */ `
# In your schema
type User {
id: ID!
name: String!
}
# Query
query {
user {
id
name
}
}
# Selecting \`id\` with an alias is also valid
query {
user {
id: name
}
}
`,
},
],
recommended: true,
},
messages: {
[RULE_ID]: "Field{{ pluralSuffix }} {{ fieldName }} must be selected when it's available on a type.\nInclude it in your selection set{{ addition }}.",
},
schema,
},
create(context) {
const schema = (0, utils_js_1.requireGraphQLSchemaFromContext)(RULE_ID, context);
const siblings = (0, utils_js_1.requireSiblingsOperations)(RULE_ID, context);
const { fieldName = DEFAULT_ID_FIELD_NAME } = context.options[0] || {};
const idNames = (0, utils_1.asArray)(fieldName);
// Check selections only in OperationDefinition,
// skip selections of OperationDefinition and InlineFragment
const selector = 'OperationDefinition SelectionSet[parent.kind!=/(^OperationDefinition|InlineFragment)$/]';
const typeInfo = new graphql_1.TypeInfo(schema);
function checkFragments(node) {
for (const selection of node.selections) {
if (selection.kind !== graphql_1.Kind.FRAGMENT_SPREAD) {
continue;
}
const [foundSpread] = siblings.getFragment(selection.name.value);
if (!foundSpread) {
continue;
}
const checkedFragmentSpreads = new Set();
const visitor = (0, graphql_1.visitWithTypeInfo)(typeInfo, {
SelectionSet(node, key, _parent) {
const parent = _parent;
if (parent.kind === graphql_1.Kind.FRAGMENT_DEFINITION) {
checkedFragmentSpreads.add(parent.name.value);
}
else if (parent.kind !== graphql_1.Kind.INLINE_FRAGMENT) {
checkSelections(node, typeInfo.getType(), selection.loc.start, parent, checkedFragmentSpreads);
}
},
});
(0, graphql_1.visit)(foundSpread.document, visitor);
}
}
function checkSelections(node, type,
// Fragment can be placed in separate file
// Provide actual fragment spread location instead of location in fragment
loc,
// Can't access to node.parent in GraphQL AST.Node, so pass as argument
parent, checkedFragmentSpreads = new Set()) {
const rawType = (0, index_js_1.getBaseType)(type);
const isObjectType = rawType instanceof graphql_1.GraphQLObjectType;
const isInterfaceType = rawType instanceof graphql_1.GraphQLInterfaceType;
if (!isObjectType && !isInterfaceType) {
return;
}
const fields = rawType.getFields();
const hasIdFieldInType = idNames.some(name => fields[name]);
if (!hasIdFieldInType) {
return;
}
function hasIdField({ selections }) {
return selections.some(selection => {
if (selection.kind === graphql_1.Kind.FIELD) {
if (selection.alias && idNames.includes(selection.alias.value)) {
return true;
}
return idNames.includes(selection.name.value);
}
if (selection.kind === graphql_1.Kind.INLINE_FRAGMENT) {
return hasIdField(selection.selectionSet);
}
if (selection.kind === graphql_1.Kind.FRAGMENT_SPREAD) {
const [foundSpread] = siblings.getFragment(selection.name.value);
if (foundSpread) {
const fragmentSpread = foundSpread.document;
checkedFragmentSpreads.add(fragmentSpread.name.value);
return hasIdField(fragmentSpread.selectionSet);
}
}
return false;
});
}
const hasId = hasIdField(node);
checkFragments(node);
if (hasId) {
return;
}
const pluralSuffix = idNames.length > 1 ? 's' : '';
const fieldName = (0, utils_js_1.englishJoinWords)(idNames.map(name => `\`${(parent.alias || parent.name).value}.${name}\``));
const addition = checkedFragmentSpreads.size === 0
? ''
: ` or add to used fragment${checkedFragmentSpreads.size > 1 ? 's' : ''} ${(0, utils_js_1.englishJoinWords)([...checkedFragmentSpreads].map(name => `\`${name}\``))}`;
const problem = {
loc,
messageId: RULE_ID,
data: {
pluralSuffix,
fieldName,
addition,
},
};
// Don't provide suggestions for selections in fragments as fragment can be in a separate file
if ('type' in node) {
problem.suggest = idNames.map(idName => ({
desc: `Add \`${idName}\` selection`,
fix: fixer => fixer.insertTextBefore(node.selections[0], `${idName} `),
}));
}
context.report(problem);
}
return {
[selector](node) {
const typeInfo = node.typeInfo();
if (typeInfo.gqlType) {
checkSelections(node, typeInfo.gqlType, node.loc.start, node.parent);
}
},
};
},
};