diff --git a/packages/plugin/__tests__/__snapshots__/nullable-records.spec.md b/packages/plugin/__tests__/__snapshots__/nullable-records.spec.md new file mode 100644 index 00000000000..4302bfed120 --- /dev/null +++ b/packages/plugin/__tests__/__snapshots__/nullable-records.spec.md @@ -0,0 +1,343 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`nullable-records > invalid > should disallow non-nullability on Node interface 1`] = ` +#### ⌨️ Code + + 1 | interface Node { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmap: Node! + 6 | } + +#### ❌ Error + + 4 | type Query { + > 5 | roadmap: Node! + | ^^^^^^^^^^^^^ Type \`Node\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 6 | } + +#### 🔧 Autofix output + + 1 | interface Node { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmap: Node + 6 | } +`; + +exports[`nullable-records > invalid > should disallow non-nullability on a union of types where at least one type has ID 1`] = ` +#### ⌨️ Code + + 1 | type Config { + 2 | name: String! + 3 | } + 4 | type Roadmap { + 5 | id: ID! + 6 | } + 7 | union ConfigOrRoadmap = Config | Roadmap + 8 | type Query { + 9 | config: ConfigOrRoadmap! + 10 | } + +#### ❌ Error + + 8 | type Query { + > 9 | config: ConfigOrRoadmap! + | ^^^^^^^^^^^^^^^^^^^^^^^ Union type \`ConfigOrRoadmap\` has to be nullable, because types \`Roadmap\` have \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 10 | } + +#### 🔧 Autofix output + + 1 | type Config { + 2 | name: String! + 3 | } + 4 | type Roadmap { + 5 | id: ID! + 6 | } + 7 | union ConfigOrRoadmap = Config | Roadmap + 8 | type Query { + 9 | config: ConfigOrRoadmap + 10 | } +`; + +exports[`nullable-records > invalid > should disallow non-nullability on list of unions of types where at least one type has ID 1`] = ` +#### ⌨️ Code + + 1 | type Config { + 2 | name: String! + 3 | } + 4 | type Roadmap { + 5 | id: ID! + 6 | } + 7 | union ConfigOrRoadmap = Config | Roadmap + 8 | type Query { + 9 | configs: [ConfigOrRoadmap!]! + 10 | } + +#### ❌ Error + + 8 | type Query { + > 9 | configs: [ConfigOrRoadmap!]! + | ^^^^^^^^^^^^^^^^^ Union type \`ConfigOrRoadmap\` has to be nullable, because types \`Roadmap\` have \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 10 | } + +#### 🔧 Autofix output + + 1 | type Config { + 2 | name: String! + 3 | } + 4 | type Roadmap { + 5 | id: ID! + 6 | } + 7 | union ConfigOrRoadmap = Config | Roadmap + 8 | type Query { + 9 | configs: [ConfigOrRoadmap]! + 10 | } +`; + +exports[`nullable-records > invalid > should disallow non-nullability on non-nullable list of Node interfaces 1`] = ` +#### ⌨️ Code + + 1 | interface Node { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmaps: [Node!]! + 6 | } + +#### ❌ Error + + 4 | type Query { + > 5 | roadmaps: [Node!]! + | ^^^^^^ Type \`Node\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 6 | } + +#### 🔧 Autofix output + + 1 | interface Node { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmaps: [Node]! + 6 | } +`; + +exports[`nullable-records > invalid > should disallow non-nullability on type that has ID 1`] = ` +#### ⌨️ Code + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmap: Roadmap! + 6 | } + +#### ❌ Error + + 4 | type Query { + > 5 | roadmap: Roadmap! + | ^^^^^^^^^^^^^^^^ Type \`Roadmap\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 6 | } + +#### 🔧 Autofix output + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmap: Roadmap + 6 | } +`; + +exports[`nullable-records > invalid > should disallow non-nullability on type that references type with ID 1`] = ` +#### ⌨️ Code + + 1 | type Feature { + 2 | id: ID! + 3 | } + 4 | type Roadmap { + 5 | id: ID! + 6 | feature: Feature! + 7 | } + 8 | type Query { + 9 | roadmap: Roadmap! + 10 | } + +#### ❌ Error 1/2 + + 5 | id: ID! + > 6 | feature: Feature! + | ^^^^^^^^^^^^^^^^ Type \`Feature\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 7 | } + +#### ❌ Error 2/2 + + 8 | type Query { + > 9 | roadmap: Roadmap! + | ^^^^^^^^^^^^^^^^ Type \`Roadmap\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 10 | } + +#### 🔧 Autofix output + + 1 | type Feature { + 2 | id: ID! + 3 | } + 4 | type Roadmap { + 5 | id: ID! + 6 | feature: Feature + 7 | } + 8 | type Query { + 9 | roadmap: Roadmap + 10 | } +`; + +exports[`nullable-records > invalid > should disallow non-nullability on type with ID 1`] = ` +#### ⌨️ Code + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmap: Roadmap! + 6 | } + +#### ❌ Error + + 4 | type Query { + > 5 | roadmap: Roadmap! + | ^^^^^^^^^^^^^^^^ Type \`Roadmap\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 6 | } + +#### 🔧 Autofix output + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmap: Roadmap + 6 | } +`; + +exports[`nullable-records > invalid > should disallow non-nullability on type with ID in a list and the list itself 1`] = ` +#### ⌨️ Code + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmaps: [Roadmap!]! + 6 | } + +#### ❌ Error + + 4 | type Query { + > 5 | roadmaps: [Roadmap!]! + | ^^^^^^^^^ Type \`Roadmap\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 6 | } + +#### 🔧 Autofix output + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmaps: [Roadmap]! + 6 | } +`; + +exports[`nullable-records > invalid > should disallow non-nullability on type with ID in a nullable list 1`] = ` +#### ⌨️ Code + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmaps: [Roadmap!] + 6 | } + +#### ❌ Error + + 4 | type Query { + > 5 | roadmaps: [Roadmap!] + | ^^^^^^^^^ Type \`Roadmap\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 6 | } + +#### 🔧 Autofix output + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmaps: [Roadmap] + 6 | } +`; + +exports[`nullable-records > invalid > should disallow non-nullability on type with ID in nested non-nullable lists 1`] = ` +#### ⌨️ Code + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmaps: [[Roadmap!]!]! + 6 | } + +#### ❌ Error + + 4 | type Query { + > 5 | roadmaps: [[Roadmap!]!]! + | ^^^^^^^^^ Type \`Roadmap\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 6 | } + +#### 🔧 Autofix output + + 1 | type Roadmap { + 2 | id: ID! + 3 | } + 4 | type Query { + 5 | roadmaps: [[Roadmap]!]! + 6 | } +`; + +exports[`nullable-records > invalid > should disallow nullability on Node interface field 1`] = ` +#### ⌨️ Code + + 1 | interface Node { + 2 | id: ID! + 3 | } + 4 | type Column implements Node { + 5 | id: ID! + 6 | } + 7 | type Roadmap implements Node { + 8 | id: ID! + 9 | columns: [Column!]! + 10 | } + 11 | type Query { + 12 | node(id: ID!): Node + 13 | } + +#### ❌ Error + + 8 | id: ID! + > 9 | columns: [Column!]! + | ^^^^^^^^ Type \`Column\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. https://the-guild.dev/graphql/eslint/rules/nullable-records + 10 | } + +#### 🔧 Autofix output + + 1 | interface Node { + 2 | id: ID! + 3 | } + 4 | type Column implements Node { + 5 | id: ID! + 6 | } + 7 | type Roadmap implements Node { + 8 | id: ID! + 9 | columns: [Column]! + 10 | } + 11 | type Query { + 12 | node(id: ID!): Node + 13 | } +`; diff --git a/packages/plugin/__tests__/nullable-records.spec.ts b/packages/plugin/__tests__/nullable-records.spec.ts new file mode 100644 index 00000000000..78e46de6663 --- /dev/null +++ b/packages/plugin/__tests__/nullable-records.spec.ts @@ -0,0 +1,418 @@ +import { + objectWithIdHasToBeNullable, + rule, + unionTypesWithIdHasToBeNullable, +} from '../src/rules/nullable-records'; +import { ParserOptionsForTests, ruleTester } from './test-utils'; + +function useSchema(code: string) { + return { + code, + parserOptions: { + graphQLConfig: { + schema: /* GraphQL */ ` + ${code} + `, + }, + } satisfies ParserOptionsForTests, + }; +} + +ruleTester.run('nullable-records', rule, { + valid: [ + { + name: 'should allow nullability on type with ID', + ...useSchema(/* GraphQL */ ` + type Roadmap { + id: ID! + } + type Query { + roadmap: Roadmap + } + `), + }, + { + name: 'should allow nullability on type with ID in a list', + ...useSchema(/* GraphQL */ ` + type Roadmap { + id: ID! + } + type Query { + roadmaps: [Roadmap]! + } + `), + }, + { + name: 'should allow non-nullability on type without ID which is in a list', + ...useSchema(/* GraphQL */ ` + type Config { + name: String! + } + type Query { + configs: [Config!]! + } + `), + }, + { + name: 'should allow non-nullability on union of types without ID, inside of a list', + ...useSchema(/* GraphQL */ ` + type Config { + name: String! + } + type Environment { + name: String! + } + union ConfigOrEnvironment = Config | Environment + type Query { + configs: [ConfigOrEnvironment!]! + } + `), + }, + { + name: 'should allow non-nullability on union of types without ID', + ...useSchema(/* GraphQL */ ` + type Config { + name: String! + } + type Environment { + name: String! + } + union ConfigOrEnvironment = Config | Environment + type Query { + config: ConfigOrEnvironment! + } + `), + }, + { + name: 'should enforce non-nullability on union inside of a list when one of types has ID', + ...useSchema(/* GraphQL */ ` + type Config { + name: String! + } + type Roadmap { + id: ID! + } + union ConfigOrRoadmap = Config | Roadmap + type Query { + configs: [ConfigOrRoadmap]! + } + `), + }, + { + name: 'should enforce non-nullability on union when one of types has ID', + ...useSchema(/* GraphQL */ ` + type Config { + name: String! + } + type Roadmap { + id: ID! + } + union ConfigOrRoadmap = Config | Roadmap + type Query { + config: ConfigOrRoadmap + } + `), + }, + { + name: 'should enforce non-nullability on nested type which has an ID', + ...useSchema(/* GraphQL */ ` + type Feature { + id: ID! + } + type Roadmap { + id: ID! + feature: Feature + } + type Query { + roadmap: Roadmap + } + `), + }, + { + name: "should allow non-nullability on nested type which doesn't have an ID", + ...useSchema(/* GraphQL */ ` + type Config { + name: String! + } + type Roadmap { + id: ID! + config: Config! + } + type Query { + roadmap: Roadmap + } + `), + }, + { + name: 'should allow non-nullability for input types that have ID', + ...useSchema(/* GraphQL */ ` + input Config { + name: String! + } + input Roadmap { + id: ID! + } + type Query { + config(roadmap: Roadmap!): Config! + } + `), + }, + { + name: 'should allow non-nullability for list input types that have ID', + ...useSchema(/* GraphQL */ ` + input Config { + name: String! + } + input Roadmap { + id: ID! + } + type Query { + config(roadmap: [Roadmap!]!): Config! + } + `), + }, + { + name: 'should allow non-nullability on interface without ID', + ...useSchema(/* GraphQL */ ` + interface User { + name: String! + } + type Query { + user: User! + } + `), + }, + { + name: 'should enforce non-nullability on Node interface', + ...useSchema(/* GraphQL */ ` + interface Node { + id: ID! + } + type Query { + user: Node + } + `), + }, + { + name: 'should allow non-nullability on types reachable only via mutation', + ...useSchema(` + interface Node { + id: ID! + } + type Query { + user: Node + } + type User { + id: ID! + } + type Mutation { + createUser: User! + } + `), + }, + { + options: [{ whitelistPatterns: ['CreateUserSuccess'] }], + name: 'should allow non-nullability with "success" suffix configuration', + ...useSchema(/* GraphQL */ ` + type User { + id: ID! + } + type CreateUserSuccess { + user: User! + users: [User!]! + } + union CreateUserPayload = CreateUserSuccess + type Query { + user: User + } + type Mutation { + createUser: CreateUserPayload! + } + `), + }, + ], + invalid: [ + { + name: 'should disallow non-nullability on type with ID', + errors: [{ message: objectWithIdHasToBeNullable('Roadmap') }], + ...useSchema(/* GraphQL */ ` + type Roadmap { + id: ID! + } + type Query { + roadmap: Roadmap! + } + `), + }, + { + name: 'should disallow non-nullability on type with ID in a list and the list itself', + errors: [{ message: objectWithIdHasToBeNullable('Roadmap') }], + ...useSchema(/* GraphQL */ ` + type Roadmap { + id: ID! + } + type Query { + roadmaps: [Roadmap!]! + } + `), + }, + { + name: 'should disallow non-nullability on type with ID in a nullable list', + errors: [{ message: objectWithIdHasToBeNullable('Roadmap') }], + ...useSchema(/* GraphQL */ ` + type Roadmap { + id: ID! + } + type Query { + roadmaps: [Roadmap!] + } + `), + }, + { + name: 'should disallow non-nullability on type with ID in nested non-nullable lists', + errors: [{ message: objectWithIdHasToBeNullable('Roadmap') }], + ...useSchema(` + type Roadmap { + id: ID! + } + type Query { + roadmaps: [[Roadmap!]!]! + } + `), + }, + { + name: 'should disallow non-nullability on list of unions of types where at least one type has ID', + errors: [ + { + message: unionTypesWithIdHasToBeNullable('ConfigOrRoadmap', ['Roadmap']), + }, + ], + + ...useSchema(/* GraphQL */ ` + type Config { + name: String! + } + type Roadmap { + id: ID! + } + union ConfigOrRoadmap = Config | Roadmap + type Query { + configs: [ConfigOrRoadmap!]! + } + `), + }, + { + name: 'should disallow non-nullability on a union of types where at least one type has ID', + errors: [ + { + message: unionTypesWithIdHasToBeNullable('ConfigOrRoadmap', ['Roadmap']), + }, + ], + + ...useSchema(/* GraphQL */ ` + type Config { + name: String! + } + type Roadmap { + id: ID! + } + union ConfigOrRoadmap = Config | Roadmap + type Query { + config: ConfigOrRoadmap! + } + `), + }, + { + name: 'should disallow non-nullability on type that has ID', + errors: [{ message: objectWithIdHasToBeNullable('Roadmap') }], + ...useSchema(/* GraphQL */ ` + type Roadmap { + id: ID! + } + type Query { + roadmap: Roadmap! + } + `), + }, + { + name: 'should disallow non-nullability on type that references type with ID', + errors: [ + { + message: objectWithIdHasToBeNullable('Feature'), + }, + { + message: objectWithIdHasToBeNullable('Roadmap'), + }, + ], + ...useSchema(` + type Feature { + id: ID! + } + type Roadmap { + id: ID! + feature: Feature! + } + type Query { + roadmap: Roadmap! + } + `), + }, + { + name: 'should disallow non-nullability on Node interface', + errors: [ + { + message: objectWithIdHasToBeNullable('Node'), + }, + ], + ...useSchema(/* GraphQL */ ` + interface Node { + id: ID! + } + type Query { + roadmap: Node! + } + `), + }, + { + name: 'should disallow non-nullability on non-nullable list of Node interfaces', + errors: [ + { + message: objectWithIdHasToBeNullable('Node'), + }, + ], + + ...useSchema(/* GraphQL */ ` + interface Node { + id: ID! + } + type Query { + roadmaps: [Node!]! + } + `), + }, + { + + name: 'should disallow nullability on Node interface field', + errors: [ + { + message: objectWithIdHasToBeNullable('Column'), + }, + ], + ...useSchema(/* GraphQL */ ` + interface Node { + id: ID! + } + type Column implements Node { + id: ID! + } + type Roadmap implements Node { + id: ID! + columns: [Column!]! + } + type Query { + node(id: ID!): Node + } + `), + }, + ], +}); diff --git a/packages/plugin/src/rules/nullable-records.ts b/packages/plugin/src/rules/nullable-records.ts new file mode 100644 index 00000000000..b092283c840 --- /dev/null +++ b/packages/plugin/src/rules/nullable-records.ts @@ -0,0 +1,359 @@ +import { + ASTNode, + FieldDefinitionNode, + GraphQLCompositeType, + GraphQLInterfaceType, + GraphQLList, + GraphQLNamedType, + GraphQLNonNull, + GraphQLObjectType, + GraphQLOutputType, + GraphQLSchema, + GraphQLUnionType, + ListTypeNode, +} from 'graphql'; +import { GraphQLESTreeNode } from '../estree-converter/types.js'; +import { GraphQLESLintRule, GraphQLESLintRuleContext } from '../types.js'; +import { requireGraphQLSchemaFromContext } from '../utils.js'; + +const RULE_ID = 'nullable-records'; + +type GraphQLObjectLikeType = Exclude; + +function getInnerType(type: GraphQLOutputType): GraphQLCompositeType | null { + if (type instanceof GraphQLList || type instanceof GraphQLNonNull) { + return getInnerType(type.ofType); + } + + if ( + type instanceof GraphQLObjectType || + type instanceof GraphQLInterfaceType || + type instanceof GraphQLUnionType + ) { + return type; + } + + return null; +} + +/** + * Recursively collects all used types in the schema that can be accessed from the given type + */ +function collectAllUsedTypes({ + type, + collection, + schema, +}: { + type: GraphQLNamedType; + collection: Map; + schema: GraphQLSchema; +}) { + if (type instanceof GraphQLObjectType) { + collection.set(type.name, type); + for (const fieldType of Object.values(type.getFields())) { + const innerType = getInnerType(fieldType.type); + + if (!innerType) { + continue; + } + + if (collection.has(innerType.name)) { + continue; + } + + collectAllUsedTypes({ type: innerType, collection, schema }); + } + } + + if (type instanceof GraphQLInterfaceType) { + collection.set(type.name, type); + for (const possibleType of schema.getPossibleTypes(type)) { + if (collection.has(possibleType.name)) { + continue; + } + + collectAllUsedTypes({ type: possibleType, collection, schema }); + } + } + + if (type instanceof GraphQLUnionType) { + for (const unionType of type.getTypes()) { + if (collection.has(unionType.name)) { + continue; + } + + collectAllUsedTypes({ type: unionType, collection, schema }); + } + } +} + +function getQueryRecordsTypes({ schema }: { schema: GraphQLSchema }) { + const typesUsedOnlyInQuery = new Map(); + + const queryType = schema.getType('Query'); + if (!queryType) { + return null; + } + + collectAllUsedTypes({ + type: queryType, + collection: typesUsedOnlyInQuery, + schema, + }); + + // filter out only types that have id field + return new Map([...typesUsedOnlyInQuery].filter(([_typeName, type]) => 'id' in type.getFields())); +} + +const docsLink = `https://the-guild.dev/graphql/eslint/rules/${RULE_ID}`; + +export const unionTypesWithIdHasToBeNullable = (unionName: string, typeNames: string[]) => + `Union type \`${unionName}\` has to be nullable, because types \`${typeNames + .sort() + .join('`, `')}\` have \`id\` field and can be deleted in the client runtime. ${docsLink}`; + +export const objectWithIdHasToBeNullable = (typeName: string) => + `Type \`${typeName}\` has to be nullable, because it has \`id\` field and can be deleted in the client runtime. ${docsLink}`; + +export const getErrorMessage = ({ + typeName, + allTypesWithId, + schema, +}: { + typeName: string; + allTypesWithId: Map; + schema: GraphQLSchema; +}) => { + let errorMessage: string | null = null; + if (allTypesWithId.has(typeName)) { + errorMessage = objectWithIdHasToBeNullable(typeName); + } + + const fieldInnerType = schema.getType(typeName); + if (fieldInnerType && fieldInnerType instanceof GraphQLUnionType) { + const unionTypesWithId = fieldInnerType + .getTypes() + .filter(({ name }) => allTypesWithId.has(name)) + .map(({ name }) => name); + + if (unionTypesWithId.length) { + errorMessage = unionTypesWithIdHasToBeNullable(typeName, unionTypesWithId); + } + } + + return errorMessage; +}; + +/** + * Recursively gets the inner type of a wrapping type + */ +function getOuterListType(typeNode: GraphQLESTreeNode) { + if (typeNode.kind === 'ListType') { + return getOuterListType(typeNode.parent); + } + + if (typeNode.kind === 'NonNullType') { + return getOuterListType(typeNode.parent); + } + + if (typeNode.kind === 'FieldDefinition') { + return typeNode.parent; + } + + return null; +} + +function isWhiteListedParentType(parentType: string, whitelistPatterns: string[]): boolean { + if (whitelistPatterns.length === 0) { + return false; + } + + for (const pattern of whitelistPatterns) { + if (new RegExp(pattern).test(parentType)) { + return true; + } + } + + return false +} + +function handleFieldDefinitionNode({ + context, + fieldNode, + allTypesWithId, + schema, +}: { + fieldNode: GraphQLESTreeNode; + allTypesWithId: NonNullable>; + schema: GraphQLSchema; + context: GraphQLESLintRuleContext; +}) { + const { options } = context; + const whitelistPatterns = options[0]?.whitelistPatterns || []; + if (isWhiteListedParentType(fieldNode.parent.name.value, whitelistPatterns)) { + return + } + + const rawFieldNode = fieldNode.rawNode(); + + // don't check fields that are nullable + if (rawFieldNode.type.kind !== 'NonNullType') { + return; + } + + const nonNullableFieldType = rawFieldNode.type; + // don't check types that are not type names + if (nonNullableFieldType.type.kind !== 'NamedType') { + return; + } + + const fieldTypeName = nonNullableFieldType.type.name.value; + + const errorMessage = getErrorMessage({ + typeName: fieldTypeName, + allTypesWithId, + schema, + }); + + if (errorMessage) { + context.report({ + node: fieldNode, + message: errorMessage, + fix(fixer) { + return fixer.replaceTextRange( + [nonNullableFieldType.loc?.start, nonNullableFieldType.loc?.end] as [number, number], + fieldTypeName, + ); + }, + }); + } +} + +function handleListTypeNode({ + context, + listTypeNode, + allTypesWithId, + schema, +}: { + listTypeNode: GraphQLESTreeNode; + allTypesWithId: NonNullable>; + schema: GraphQLSchema; + context: GraphQLESLintRuleContext; +}) { + // naive way to filter out fields that are in mutation Success type + if (getOuterListType(listTypeNode)?.name.value.endsWith('Success')) { + return; + } + + const listItemNode = listTypeNode.rawNode().type; + // don't check items in the list that are nullable + if (listItemNode.kind !== 'NonNullType') { + return; + } + + // don't check items in the list that are not type names + if (listItemNode.type.kind !== 'NamedType') { + return; + } + + const innerTypeName = listItemNode.type.name.value; + + const errorMessage = getErrorMessage({ + typeName: innerTypeName, + allTypesWithId, + schema, + }); + + if (errorMessage) { + context.report({ + node: listTypeNode, + message: errorMessage, + fix(fixer) { + return fixer.replaceTextRange(listTypeNode.range as [number, number], `[${innerTypeName}]`); + }, + }); + } +} + +export const rule: GraphQLESLintRule = { + meta: { + type: 'problem', + hasSuggestions: true, + fixable: 'code', + docs: { + category: 'Schema', + description: + 'Enforces users to return types that conform to Node interface as nullable. These types can usually be deleted from Relay cache and schema should reflect that.', + recommended: true, + // TODO + url: docsLink, + examples: [ + { + title: 'Incorrect', + code: /* GraphQL */ ` + type User { + id: ID! + name: String! + } + + type Query { + me: User! + } + `, + }, + { + title: 'Correct', + code: /* GraphQL */ ` + type User { + id: ID! + name: String + } + + type Query { + me: User + } + `, + }, + ], + schema: [ + { + type: 'object', + properties: { + whitelistPatterns: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + additionalProperties: false, + }, + ], + }, + }, + create(context) { + const schema = requireGraphQLSchemaFromContext(RULE_ID, context); + const allTypesWithId = getQueryRecordsTypes({ schema }); + + // if there are no types with id, there is nothing to check + if (!allTypesWithId) { + return {}; + } + + return { + // check that all fields that return object with id are nullable + FieldDefinition(fieldNode) { + handleFieldDefinitionNode({ + fieldNode, + allTypesWithId, + schema, + context, + }); + }, + // check that all items in lists that return object with id are nullable + ListType(listTypeNode) { + handleListTypeNode({ listTypeNode, allTypesWithId, schema, context }); + }, + }; + }, +}; diff --git a/website/src/pages/rules/_meta.ts b/website/src/pages/rules/_meta.ts index e61f8f57070..32bd27eaddd 100644 --- a/website/src/pages/rules/_meta.ts +++ b/website/src/pages/rules/_meta.ts @@ -68,5 +68,5 @@ export default { 'unique-variable-names': '', 'value-literals-of-correct-type': '', 'variables-are-input-types': '', - 'variables-in-allowed-position': '', -}; + 'variables-in-allowed-position': '' +}