From ffa06b825dd3ab9fd8754275d4b6e3994dad25b5 Mon Sep 17 00:00:00 2001 From: comatory Date: Wed, 17 Apr 2024 11:37:31 +0200 Subject: [PATCH] nullable-records rule This rule enforces that every field that returns type that contains `id` field, its return type must be nullable. Co-Authored-By: Tomas Balvin --- .../__snapshots__/nullable-records.spec.md | 343 +++++++++++++++ packages/plugin/src/rules/index.ts | 2 + .../src/rules/nullable-records/index.test.ts | 403 ++++++++++++++++++ .../src/rules/nullable-records/index.ts | 357 ++++++++++++++++ website/src/pages/rules/_meta.ts | 1 + website/src/pages/rules/index.md | 1 + website/src/pages/rules/nullable-records.mdx | 67 +++ 7 files changed, 1174 insertions(+) create mode 100644 packages/plugin/__tests__/__snapshots__/nullable-records.spec.md create mode 100644 packages/plugin/src/rules/nullable-records/index.test.ts create mode 100644 packages/plugin/src/rules/nullable-records/index.ts create mode 100644 website/src/pages/rules/nullable-records.mdx 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/src/rules/index.ts b/packages/plugin/src/rules/index.ts index ba65ab3701b..4ccb6417b8a 100644 --- a/packages/plugin/src/rules/index.ts +++ b/packages/plugin/src/rules/index.ts @@ -37,6 +37,7 @@ import { rule as strictIdInTypes } from './strict-id-in-types/index.js'; import { rule as uniqueEnumValueNames } from './unique-enum-value-names/index.js'; import { rule as uniqueFragmentName } from './unique-fragment-name/index.js'; import { rule as uniqueOperationName } from './unique-operation-name/index.js'; +import { rule as nullableRecords } from './nullable-records/index.js' export const rules = { ...GRAPHQL_JS_VALIDATIONS, @@ -56,6 +57,7 @@ export const rules = { 'no-typename-prefix': noTypenamePrefix, 'no-unreachable-types': noUnreachableTypes, 'no-unused-fields': noUnusedFields, + 'nullable-records': nullableRecords, 'relay-arguments': relayArguments, 'relay-connection-types': relayConnectionTypes, 'relay-edge-types': relayEdgeTypes, diff --git a/packages/plugin/src/rules/nullable-records/index.test.ts b/packages/plugin/src/rules/nullable-records/index.test.ts new file mode 100644 index 00000000000..26bb0d30fc6 --- /dev/null +++ b/packages/plugin/src/rules/nullable-records/index.test.ts @@ -0,0 +1,403 @@ +import { + objectWithIdHasToBeNullable, + rule, + unionTypesWithIdHasToBeNullable, +} from './index.js'; +import { ruleTester, withSchema } from '../../../__tests__/test-utils.js'; + +ruleTester.run('nullable-records', rule, { + valid: [ + withSchema({ + name: 'should allow nullability on type with ID', + code: /* GraphQL */ ` + type Config { + id: ID! + } + type Query { + config: Config + } + `, + }), + withSchema({ + name: 'should allow nullability on type with ID in a list', + code: /* GraphQL */ ` + type Config { + id: ID! + } + type Query { + configs: [Config]! + } + `, + }), + withSchema({ + name: 'should allow non-nullability on type without ID which is in a list', + code: /* GraphQL */ ` + type Config { + name: String! + } + type Query { + configs: [Config!]! + } + `, + }), + withSchema({ + name: 'should allow non-nullability on union of types without ID, inside of a list', + code: ` + type Config { + name: String! + } + type Environment { + name: String! + } + union ConfigOrEnvironment = Config | Environment + type Query { + configs: [ConfigOrEnvironment!]! + } + `, + }), + withSchema({ + name: 'should allow non-nullability on union of types without ID', + code: /* GraphQL */ ` + type Config { + name: String! + } + type Environment { + name: String! + } + union ConfigOrEnvironment = Config | Environment + type Query { + config: ConfigOrEnvironment! + } + `, + }), + withSchema({ + name: 'should enforce non-nullability on union inside of a list when one of types has ID', + code: /* GraphQL */ ` + type Config { + name: String! + } + type User { + id: ID! + } + union ConfigOrUser = Config | User + type Query { + configs: [ConfigOrUser]! + } + `, + }), + withSchema({ + name: 'should enforce non-nullability on union when one of types has ID', + code: /* GraphQL */ ` + type Config { + name: String! + } + type User { + id: ID! + } + union ConfigOrUser = Config | User + type Query { + config: ConfigOrUser + } + `, + }), + withSchema({ + name: 'should enforce non-nullability on nested type which has an ID', + code: /* GraphQL */ ` + type Value { + id: ID! + } + type Config { + id: ID! + value: Value + } + type Query { + config: Config + } + `, + }), + withSchema({ + name: "should allow non-nullability on nested type which doesn't have an ID", + code: /* GraphQL */ ` + type Config { + name: String! + } + type User { + id: ID! + config: Config! + } + type Query { + user: User + } + `, + }), + withSchema({ + name: 'should allow non-nullability for input types that have ID', + code: /* GraphQL */ ` + input Config { + name: String! + } + input ConfigFilter { + id: ID! + } + type Query { + config(filter: ConfigFilter!): Config! + } + `, + }), + withSchema({ + name: 'should allow non-nullability for list input types that have ID', + code: /* GraphQL */ ` + input Config { + name: String! + } + input ConfigFilter { + id: ID! + } + type Query { + config(filters: [ConfigFilter!]!): Config! + } + `, + }), + withSchema({ + name: 'should allow non-nullability on interface without ID', + code: /* GraphQL */ ` + interface User { + name: String! + } + type Query { + user: User! + } + `, + }), + withSchema({ + name: 'should enforce non-nullability on Node interface', + code: /* GraphQL */ ` + interface Node { + id: ID! + } + type Query { + user: Node + } + `, + }), + withSchema({ + name: 'should allow non-nullability on types reachable only via mutation', + code: /* GraphQL */ ` + interface Node { + id: ID! + } + type Query { + user: Node + } + type User { + id: ID! + } + type Mutation { + createUser: User! + } + `, + }), + withSchema({ + options: [{ whitelistPatterns: ['CreateUserSuccess'] }], + name: 'should allow non-nullability with "success" suffix configuration', + code: /* GraphQL */ ` + type User { + id: ID! + } + type CreateUserSuccess { + user: User! + users: [User!]! + } + union CreateUserPayload = CreateUserSuccess + type Query { + user: User + } + type Mutation { + createUser: CreateUserPayload! + } + `, + }), + ], + invalid: [ + withSchema({ + name: 'should disallow non-nullability on type with ID', + errors: [{ message: objectWithIdHasToBeNullable('User') }], + code: /* GraphQL */ ` + type User { + id: ID! + } + type Query { + user: User! + } + `, + }), + withSchema({ + name: 'should disallow non-nullability on type with ID in a list and the list itself', + errors: [{ message: objectWithIdHasToBeNullable('User') }], + code: /* GraphQL */ ` + type User { + id: ID! + } + type Query { + users: [User!]! + } + `, + }), + withSchema({ + name: 'should disallow non-nullability on type with ID in a nullable list', + errors: [{ message: objectWithIdHasToBeNullable('User') }], + code: /* GraphQL */ ` + type User { + id: ID! + } + type Query { + users: [User!] + } + `, + }), + withSchema({ + name: 'should disallow non-nullability on type with ID in nested non-nullable lists', + errors: [{ message: objectWithIdHasToBeNullable('User') }], + code: /* GraphQL */ ` + type User { + id: ID! + } + type Query { + users: [[User!]!]! + } + `, + }), + withSchema({ + name: 'should disallow non-nullability on list of unions of types where at least one type has ID', + errors: [ + { + message: unionTypesWithIdHasToBeNullable('ConfigOrUser', ['User']), + }, + ], + + code: /* GraphQL */ ` + type Config { + name: String! + } + type User { + id: ID! + } + union ConfigOrUser = Config | User + type Query { + configs: [ConfigOrUser!]! + } + `, + }), + withSchema({ + name: 'should disallow non-nullability on a union of types where at least one type has ID', + errors: [ + { + message: unionTypesWithIdHasToBeNullable('ConfigOrUser', ['User']), + }, + ], + code: /* GraphQL */ ` + type Config { + name: String! + } + type User { + id: ID! + } + union ConfigOrUser = Config | User + type Query { + config: ConfigOrUser! + } + `, + }), + withSchema({ + name: 'should disallow non-nullability on type that has ID', + errors: [{ message: objectWithIdHasToBeNullable('User') }], + code: /* GraphQL */ ` + type User { + id: ID! + } + type Query { + user: User! + } + `, + }), + withSchema({ + name: 'should disallow non-nullability on type that references type with ID', + errors: [ + { + message: objectWithIdHasToBeNullable('Config'), + }, + { + message: objectWithIdHasToBeNullable('User'), + }, + ], + code: /* GraphQL */ ` + type Config { + id: ID! + } + type User { + id: ID! + config: Config! + } + type Query { + user: User! + } + `, + }), + withSchema({ + name: 'should disallow non-nullability on Node interface', + errors: [ + { + message: objectWithIdHasToBeNullable('Node'), + }, + ], + code: /* GraphQL */ ` + interface Node { + id: ID! + } + type Query { + user: Node! + } + `, + }), + withSchema({ + name: 'should disallow non-nullability on non-nullable list of Node interfaces', + errors: [ + { + message: objectWithIdHasToBeNullable('Node'), + }, + ], + + code: /* GraphQL */ ` + interface Node { + id: ID! + } + type Query { + users: [Node!]! + } + `, + }), + withSchema({ + name: 'should disallow nullability on Node interface field', + errors: [ + { + message: objectWithIdHasToBeNullable('Config'), + }, + ], + code: /* GraphQL */ ` + interface Node { + id: ID! + } + type Config implements Node { + id: ID! + } + type User implements Node { + id: ID! + configs: [Config!]! + } + type Query { + node(id: ID!): Node + } + `, + }), + ], +}); diff --git a/packages/plugin/src/rules/nullable-records/index.ts b/packages/plugin/src/rules/nullable-records/index.ts new file mode 100644 index 00000000000..2a2cc510aec --- /dev/null +++ b/packages/plugin/src/rules/nullable-records/index.ts @@ -0,0 +1,357 @@ +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}]`); + }, + }); + } +} + +type Options = [{ whitelistPatterns: string[] }]; + +export const rule: GraphQLESLintRule = { + meta: { + schema: { + $id: 'https://the-guild.dev/graphql/eslint/rules/nullable-records', + properties: { + whitelistPatterns: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + }, + 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, + 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 + } + `, + }, + ], + }, + type: 'problem', + hasSuggestions: true, + fixable: 'code', + }, + 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..72d785ed63a 100644 --- a/website/src/pages/rules/_meta.ts +++ b/website/src/pages/rules/_meta.ts @@ -20,6 +20,7 @@ export default { 'no-typename-prefix': '', 'no-unreachable-types': '', 'no-unused-fields': '', + 'nullable-records': '', 'possible-type-extension': '', 'relay-arguments': '', 'relay-connection-types': '', diff --git a/website/src/pages/rules/index.md b/website/src/pages/rules/index.md index 46cebc9b1b5..57e4a7f0110 100644 --- a/website/src/pages/rules/index.md +++ b/website/src/pages/rules/index.md @@ -44,6 +44,7 @@ Name            &nbs [no-unused-fields](/rules/no-unused-fields)|Requires all fields to be used at some level by siblings operations.||📄|🚀|💡 [no-unused-fragments](/rules/no-unused-fragments)|A GraphQL document is only valid if all fragment definitions are spread within operations, or spread within other fragments spread within operations.|![recommended][]|đŸ“Ļ|🔮| [no-unused-variables](/rules/no-unused-variables)|A GraphQL operation is only valid if all variables defined by an operation are used, either directly or within a spread fragment.|![recommended][]|đŸ“Ļ|🔮| +[nullable-records](/rules/nullable-records)|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][]|📄|🚀|💡 [one-field-subscriptions](/rules/one-field-subscriptions)|A GraphQL subscription is valid only if it contains a single root field.|![recommended][]|đŸ“Ļ|🔮| [overlapping-fields-can-be-merged](/rules/overlapping-fields-can-be-merged)|A selection set is only valid if all fields (including spreading any fragments) either correspond to distinct response names or can be merged without ambiguity.|![recommended][]|đŸ“Ļ|🔮| [possible-fragment-spread](/rules/possible-fragment-spread)|A fragment spread is only valid if the type condition could ever possibly be true: if there is a non-empty intersection of the possible parent types, and possible types which pass the type condition.|![recommended][]|đŸ“Ļ|🔮| diff --git a/website/src/pages/rules/nullable-records.mdx b/website/src/pages/rules/nullable-records.mdx new file mode 100644 index 00000000000..f7e66ca2177 --- /dev/null +++ b/website/src/pages/rules/nullable-records.mdx @@ -0,0 +1,67 @@ +--- +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.' +--- + +# `nullable-records` + +✅ The `"extends": "plugin:@graphql-eslint/schema-recommended"` property in a configuration file +enables this rule. + +🔧 The `--fix` option on the +[command line](https://eslint.org/docs/user-guide/command-line-interface#--fix) can automatically +fix some of the problems reported by this rule. + +💡 This rule provides +[suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions) + +- Category: `Schema` +- Rule name: `@graphql-eslint/nullable-records` +- Requires GraphQL Schema: `false` + [â„šī¸](/docs/getting-started#extended-linting-rules-with-graphql-schema) +- Requires GraphQL Operations: `false` + [â„šī¸](/docs/getting-started#extended-linting-rules-with-siblings-operations) + +{frontMatter.description} + +## Usage Examples + +### Incorrect + +```graphql +# eslint @graphql-eslint/nullable-records: 'error' + +type User { + id: ID! + name: String! +} + +type Query { + me: User! +} +``` + +### Correct + +```graphql +# eslint @graphql-eslint/nullable-records: 'error' + +type User { + id: ID! + name: String +} + +type Query { + me: User +} +``` + +## Config Schema + +### + +## Resources + +- [Rule source](https://github.com/B2o5T/graphql-eslint/tree/master/packages/plugin/src/rules/nullable-records.ts) +- [Test source](https://github.com/B2o5T/graphql-eslint/tree/master/packages/plugin/__tests__/nullable-records.spec.ts)