diff --git a/packages/compare-changes/src/index.ts b/packages/compare-changes/src/index.ts index 2d0451446b..6af4487e43 100644 --- a/packages/compare-changes/src/index.ts +++ b/packages/compare-changes/src/index.ts @@ -131,6 +131,21 @@ export const requiredMatchMetaMap: { FIELD_ARGUMENT_DESCRIPTION_CHANGED: ['argumentName', 'fieldName', 'newDescription', 'typeName'], FIELD_ARGUMENT_REMOVED: ['fieldName', 'removedFieldArgumentName', 'typeName'], FIELD_ARGUMENT_TYPE_CHANGED: ['argumentName', 'fieldName', 'newArgumentType', 'typeName'], + FIELD_ARGUMENT_DEPRECATION_ADDED: ['argumentName', 'fieldName', 'typeName'], + FIELD_ARGUMENT_DEPRECATION_REMOVED: ['argumentName', 'fieldName', 'typeName'], + FIELD_ARGUMENT_DEPRECATION_REASON_CHANGED: [ + 'argumentName', + 'fieldName', + 'newDeprecationReason', + 'typeName', + ], + FIELD_ARGUMENT_DEPRECATION_REASON_ADDED: [ + 'addedDeprecationReason', + 'argumentName', + 'fieldName', + 'typeName', + ], + FIELD_ARGUMENT_DEPRECATION_REASON_REMOVED: ['argumentName', 'fieldName', 'typeName'], FIELD_DEPRECATION_ADDED: ['fieldName', 'typeName'], FIELD_DEPRECATION_REASON_ADDED: ['addedDeprecationReason', 'fieldName', 'typeName'], FIELD_DEPRECATION_REASON_CHANGED: ['fieldName', 'newDeprecationReason', 'typeName'], @@ -148,6 +163,19 @@ export const requiredMatchMetaMap: { INPUT_FIELD_DESCRIPTION_REMOVED: ['inputFieldName', 'inputName'], INPUT_FIELD_REMOVED: ['inputName', 'removedFieldName'], INPUT_FIELD_TYPE_CHANGED: ['inputFieldName', 'inputName', 'newInputFieldType'], + INPUT_FIELD_DEPRECATION_ADDED: ['inputFieldName', 'inputName'], + INPUT_FIELD_DEPRECATION_REMOVED: ['inputFieldName', 'inputName'], + INPUT_FIELD_DEPRECATION_REASON_CHANGED: [ + 'inputFieldName', + 'inputName', + 'newDeprecationReason', + ], + INPUT_FIELD_DEPRECATION_REASON_ADDED: [ + 'addedDeprecationReason', + 'inputFieldName', + 'inputName', + ], + INPUT_FIELD_DEPRECATION_REASON_REMOVED: ['inputFieldName', 'inputName'], OBJECT_TYPE_INTERFACE_ADDED: ['addedInterfaceName', 'objectTypeName'], OBJECT_TYPE_INTERFACE_REMOVED: ['objectTypeName', 'removedInterfaceName'], SCHEMA_MUTATION_TYPE_CHANGED: ['newMutationTypeName'], diff --git a/packages/core/__tests__/diff/argument.test.ts b/packages/core/__tests__/diff/argument.test.ts index c19c274af6..cf9892414e 100644 --- a/packages/core/__tests__/diff/argument.test.ts +++ b/packages/core/__tests__/diff/argument.test.ts @@ -1,8 +1,93 @@ import { buildSchema } from 'graphql'; import { CriticalityLevel, diff } from '../../src/index.js'; -import { findFirstChangeByPath } from '../../utils/testing.js'; +import { findChangesByPath, findFirstChangeByPath } from '../../utils/testing.js'; describe('argument', () => { + test('argument deprecation reason changed / added / removed', async () => { + const a = buildSchema(/* GraphQL */ ` + type Query { + f( + a: String! @deprecated(reason: "OLD"), + b: String! @deprecated(reason: "BBB"), + c: String! + ): String + } + `); + const b = buildSchema(/* GraphQL */ ` + type Query { + f( + a: String! @deprecated(reason: "NEW"), + b: String!, + c: String! @deprecated(reason: "CCC") + ): String + } + `); + + const changes = await diff(a, b); + const change = { + a: findFirstChangeByPath(changes, 'Query.f.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Query.f.b.@deprecated'), + c: findFirstChangeByPath(changes, 'Query.f.c.@deprecated'), + }; + + expect(change.a.type).toEqual('FIELD_ARGUMENT_DEPRECATION_REASON_CHANGED'); + expect(change.a.message).toEqual( + "Deprecation reason on argument 'a' on field 'Query.f' has changed from 'OLD' to 'NEW'", + ); + expect(change.b.type).toEqual('FIELD_ARGUMENT_DEPRECATION_REMOVED'); + expect(change.b.message).toEqual("Argument 'b' on field 'Query.f' is no longer deprecated"); + expect(change.c.type).toEqual('FIELD_ARGUMENT_DEPRECATION_ADDED'); + expect(change.c.message).toEqual("Argument 'c' on field 'Query.f' is deprecated"); + }); + + test('argument deprecation added / removed', async () => { + const a = buildSchema(/* GraphQL */ ` + type Query { + f(a: String! @deprecated, b: String!): String + } + `); + const b = buildSchema(/* GraphQL */ ` + type Query { + f(a: String!, b: String! @deprecated): String + } + `); + + const changes = await diff(a, b); + const change = { + a: findFirstChangeByPath(changes, 'Query.f.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Query.f.b.@deprecated'), + }; + + expect(change.a.type).toEqual('FIELD_ARGUMENT_DEPRECATION_REMOVED'); + expect(change.a.message).toEqual("Argument 'a' on field 'Query.f' is no longer deprecated"); + expect(change.b.type).toEqual('FIELD_ARGUMENT_DEPRECATION_ADDED'); + expect(change.b.message).toEqual("Argument 'b' on field 'Query.f' is deprecated"); + }); + + test('argument deprecation added w/reason', async () => { + const a = buildSchema(/* GraphQL */ ` + type Query { + f(a: String!): String + } + `); + const b = buildSchema(/* GraphQL */ ` + type Query { + f(a: String! @deprecated(reason: "unused")): String + } + `); + + const changes = await diff(a, b); + expect(findChangesByPath(changes, 'Query.f.a.@deprecated')).toHaveLength(2); + const change = findFirstChangeByPath(changes, 'Query.f.a.@deprecated'); + expect(change.type).toEqual('FIELD_ARGUMENT_DEPRECATION_ADDED'); + expect(change.meta).toMatchObject({ + argumentName: 'a', + deprecationReason: 'unused', + fieldName: 'f', + typeName: 'Query', + }); + }); + test('added non-nullable with default value', async () => { const a = buildSchema(/* GraphQL */ ` type Query { diff --git a/packages/core/__tests__/diff/input.test.ts b/packages/core/__tests__/diff/input.test.ts index ef56302b74..bef76bfef3 100644 --- a/packages/core/__tests__/diff/input.test.ts +++ b/packages/core/__tests__/diff/input.test.ts @@ -4,6 +4,97 @@ import { findChangesByPath, findFirstChangeByPath } from '../../utils/testing.js describe('input', () => { describe('fields', () => { + test('deprecation reason changed / added / removed', async () => { + const a = buildSchema(/* GraphQL */ ` + input Foo { + a: String! @deprecated(reason: "OLD") + b: String! @deprecated(reason: "BBB") + c: String! + } + `); + const b = buildSchema(/* GraphQL */ ` + input Foo { + a: String! @deprecated(reason: "NEW") + b: String! + c: String! @deprecated(reason: "CCC") + } + `); + + const changes = await diff(a, b); + const change = { + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), + c: findFirstChangeByPath(changes, 'Foo.c.@deprecated'), + }; + + expect(change.a.type).toEqual('INPUT_FIELD_DEPRECATION_REASON_CHANGED'); + expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.a.message).toEqual( + "Deprecation reason on input field 'Foo.a' has changed from 'OLD' to 'NEW'", + ); + expect(change.b.type).toEqual('INPUT_FIELD_DEPRECATION_REMOVED'); + expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.b.message).toEqual("Input field 'Foo.b' is no longer deprecated"); + expect(change.c.type).toEqual('INPUT_FIELD_DEPRECATION_ADDED'); + expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.c.message).toEqual("Input field 'Foo.c' is deprecated"); + }); + + test('deprecation added / removed', async () => { + const a = buildSchema(/* GraphQL */ ` + input Foo { + a: String! @deprecated + b: String! + } + `); + const b = buildSchema(/* GraphQL */ ` + input Foo { + a: String! + b: String! @deprecated + } + `); + + const changes = await diff(a, b); + const change = { + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), + }; + + expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.a.type).toEqual('INPUT_FIELD_DEPRECATION_REMOVED'); + expect(change.a.message).toEqual("Input field 'Foo.a' is no longer deprecated"); + expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.b.type).toEqual('INPUT_FIELD_DEPRECATION_ADDED'); + expect(change.b.message).toEqual("Input field 'Foo.b' is deprecated"); + }); + + test('deprecation added w/reason', async () => { + const a = buildSchema(/* GraphQL */ ` + input Foo { + a: String! + } + `); + const b = buildSchema(/* GraphQL */ ` + input Foo { + a: String! @deprecated(reason: "Use b instead.") + } + `); + + const changes = await diff(a, b); + + expect(findChangesByPath(changes, 'Foo.a.@deprecated')).toHaveLength(2); + const change = findFirstChangeByPath(changes, 'Foo.a.@deprecated'); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('INPUT_FIELD_DEPRECATION_ADDED'); + expect(change.message).toEqual("Input field 'Foo.a' is deprecated"); + expect(change.meta).toMatchObject({ + deprecationReason: 'Use b instead.', + inputFieldName: 'a', + inputName: 'Foo', + }); + }); + test('added', async () => { const a = buildSchema(/* GraphQL */ ` input Foo { diff --git a/packages/core/src/diff/argument.ts b/packages/core/src/diff/argument.ts index e9e9d915fa..69e3c1c09c 100644 --- a/packages/core/src/diff/argument.ts +++ b/packages/core/src/diff/argument.ts @@ -5,9 +5,15 @@ import { GraphQLObjectType, Kind, } from 'graphql'; -import { compareDirectiveLists, diffArrays, isNotEqual } from '../utils/compare.js'; +import { compareDirectiveLists, diffArrays, isNotEqual, isVoid } from '../utils/compare.js'; +import { isDeprecated } from '../utils/is-deprecated.js'; import { fieldArgumentDefaultChanged, + fieldArgumentDeprecationAdded, + fieldArgumentDeprecationReasonAdded, + fieldArgumentDeprecationReasonChanged, + fieldArgumentDeprecationReasonRemoved, + fieldArgumentDeprecationRemoved, fieldArgumentDescriptionChanged, fieldArgumentTypeChanged, } from './changes/argument.js'; @@ -18,6 +24,8 @@ import { } from './changes/directive-usage.js'; import { AddChange } from './schema.js'; +const DEPRECATION_REASON_DEFAULT = 'No longer supported'; + export function changesInArgument( type: GraphQLObjectType | GraphQLInterfaceType, field: GraphQLField, @@ -29,6 +37,30 @@ export function changesInArgument( addChange(fieldArgumentDescriptionChanged(type, field, oldArg, newArg)); } + if (isVoid(oldArg) || !isDeprecated(oldArg)) { + if (isDeprecated(newArg)) { + addChange(fieldArgumentDeprecationAdded(type, field, newArg)); + } + } else if (!isDeprecated(newArg)) { + if (isDeprecated(oldArg)) { + addChange(fieldArgumentDeprecationRemoved(type, field, oldArg)); + } + } else if (isNotEqual(oldArg.deprecationReason, newArg.deprecationReason)) { + if ( + isVoid(oldArg.deprecationReason) || + oldArg.deprecationReason === DEPRECATION_REASON_DEFAULT + ) { + addChange(fieldArgumentDeprecationReasonAdded(type, field, newArg)); + } else if ( + isVoid(newArg.deprecationReason) || + newArg.deprecationReason === DEPRECATION_REASON_DEFAULT + ) { + addChange(fieldArgumentDeprecationReasonRemoved(type, field, oldArg)); + } else { + addChange(fieldArgumentDeprecationReasonChanged(type, field, oldArg, newArg)); + } + } + if (isNotEqual(oldArg?.defaultValue, newArg.defaultValue)) { if (Array.isArray(oldArg?.defaultValue) && Array.isArray(newArg.defaultValue)) { const diff = diffArrays(oldArg.defaultValue, newArg.defaultValue); @@ -44,40 +76,38 @@ export function changesInArgument( addChange(fieldArgumentTypeChanged(type, field, oldArg, newArg)); } - if (newArg.astNode?.directives) { - compareDirectiveLists(oldArg?.astNode?.directives || [], newArg.astNode.directives || [], { - onAdded(directive) { - addChange( - directiveUsageAdded( - Kind.ARGUMENT, - directive, - { - argument: newArg, - field, - type, - }, - oldArg === null, - ), - ); - directiveUsageChanged(null, directive, addChange, type, field, newArg); - }, + compareDirectiveLists(oldArg?.astNode?.directives || [], newArg.astNode?.directives || [], { + onAdded(directive) { + addChange( + directiveUsageAdded( + Kind.ARGUMENT, + directive, + { + argument: newArg, + field, + type, + }, + oldArg === null, + ), + ); + directiveUsageChanged(null, directive, addChange, type, field, newArg); + }, - onMutual(directive) { - directiveUsageChanged( - directive.oldVersion, - directive.newVersion, - addChange, - type, - field, - newArg, - ); - }, + onMutual(directive) { + directiveUsageChanged( + directive.oldVersion, + directive.newVersion, + addChange, + type, + field, + newArg, + ); + }, - onRemoved(directive) { - addChange( - directiveUsageRemoved(Kind.ARGUMENT, directive, { argument: oldArg!, field, type }), - ); - }, - }); - } + onRemoved(directive) { + addChange( + directiveUsageRemoved(Kind.ARGUMENT, directive, { argument: oldArg!, field, type }), + ); + }, + }); } diff --git a/packages/core/src/diff/changes/argument.ts b/packages/core/src/diff/changes/argument.ts index 271acbe06b..4130136435 100644 --- a/packages/core/src/diff/changes/argument.ts +++ b/packages/core/src/diff/changes/argument.ts @@ -1,4 +1,10 @@ -import { GraphQLArgument, GraphQLField, GraphQLInterfaceType, GraphQLObjectType } from 'graphql'; +import { + GraphQLArgument, + GraphQLDeprecatedDirective, + GraphQLField, + GraphQLInterfaceType, + GraphQLObjectType, +} from 'graphql'; import { safeChangeForInputValue } from '../../utils/graphql.js'; import { fmt, safeString } from '../../utils/string.js'; import { @@ -6,6 +12,11 @@ import { ChangeType, CriticalityLevel, FieldArgumentDefaultChangedChange, + FieldArgumentDeprecationAddedChange, + FieldArgumentDeprecationReasonAddedChange, + FieldArgumentDeprecationReasonChangedChange, + FieldArgumentDeprecationReasonRemovedChange, + FieldArgumentDeprecationRemovedChange, FieldArgumentDescriptionChangedChange, FieldArgumentTypeChangedChange, } from './change.js'; @@ -148,3 +159,178 @@ export function fieldArgumentTypeChanged( }, }); } + +function fieldArgumentDeprecationPath(meta: { + typeName: string; + fieldName: string; + argumentName: string; +}) { + return [meta.typeName, meta.fieldName, meta.argumentName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ); +} + +function buildFieldArgumentDeprecatedAddedMessage(args: FieldArgumentDeprecationAddedChange['meta']) { + return `Argument '${args.argumentName}' on field '${args.typeName}.${args.fieldName}' is deprecated`; +} + +export function fieldArgumentDeprecationAddedFromMeta(args: FieldArgumentDeprecationAddedChange) { + return { + type: ChangeType.FieldArgumentDeprecationAdded, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: buildFieldArgumentDeprecatedAddedMessage(args.meta), + meta: args.meta, + path: fieldArgumentDeprecationPath(args.meta), + } as const; +} + +export function fieldArgumentDeprecationAdded( + type: GraphQLObjectType | GraphQLInterfaceType, + field: GraphQLField, + arg: GraphQLArgument, +): Change { + return fieldArgumentDeprecationAddedFromMeta({ + type: ChangeType.FieldArgumentDeprecationAdded, + meta: { + typeName: type.name, + fieldName: field.name, + argumentName: arg.name, + deprecationReason: arg.deprecationReason ?? '', + }, + }); +} + +export function fieldArgumentDeprecationRemovedFromMeta(args: FieldArgumentDeprecationRemovedChange) { + return { + type: ChangeType.FieldArgumentDeprecationRemoved, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: `Argument '${args.meta.argumentName}' on field '${args.meta.typeName}.${args.meta.fieldName}' is no longer deprecated`, + meta: args.meta, + path: fieldArgumentDeprecationPath(args.meta), + } as const; +} + +export function fieldArgumentDeprecationRemoved( + type: GraphQLObjectType | GraphQLInterfaceType, + field: GraphQLField, + arg: GraphQLArgument, +): Change { + return fieldArgumentDeprecationRemovedFromMeta({ + type: ChangeType.FieldArgumentDeprecationRemoved, + meta: { + typeName: type.name, + fieldName: field.name, + argumentName: arg.name, + }, + }); +} + +function buildFieldArgumentDeprecationReasonChangedMessage( + args: FieldArgumentDeprecationReasonChangedChange['meta'], +) { + const oldReason = fmt(args.oldDeprecationReason); + const newReason = fmt(args.newDeprecationReason); + return `Deprecation reason on argument '${args.argumentName}' on field '${args.typeName}.${args.fieldName}' has changed from '${oldReason}' to '${newReason}'`; +} + +export function fieldArgumentDeprecationReasonChangedFromMeta( + args: FieldArgumentDeprecationReasonChangedChange, +) { + return { + type: ChangeType.FieldArgumentDeprecationReasonChanged, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: buildFieldArgumentDeprecationReasonChangedMessage(args.meta), + meta: args.meta, + path: fieldArgumentDeprecationPath(args.meta), + } as const; +} + +export function fieldArgumentDeprecationReasonChanged( + type: GraphQLObjectType | GraphQLInterfaceType, + field: GraphQLField, + oldArg: GraphQLArgument, + newArg: GraphQLArgument, +): Change { + return fieldArgumentDeprecationReasonChangedFromMeta({ + type: ChangeType.FieldArgumentDeprecationReasonChanged, + meta: { + argumentName: newArg.name, + fieldName: field.name, + typeName: type.name, + newDeprecationReason: newArg.deprecationReason ?? '', + oldDeprecationReason: oldArg.deprecationReason ?? '', + }, + }); +} + +function buildFieldArgumentDeprecationReasonAddedMessage( + args: FieldArgumentDeprecationReasonAddedChange['meta'], +) { + const reason = fmt(args.addedDeprecationReason); + return `Argument '${args.argumentName}' on field '${args.typeName}.${args.fieldName}' has deprecation reason '${reason}'`; +} + +export function fieldArgumentDeprecationReasonAddedFromMeta( + args: FieldArgumentDeprecationReasonAddedChange, +) { + return { + type: ChangeType.FieldArgumentDeprecationReasonAdded, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: buildFieldArgumentDeprecationReasonAddedMessage(args.meta), + meta: args.meta, + path: fieldArgumentDeprecationPath(args.meta), + } as const; +} + +export function fieldArgumentDeprecationReasonAdded( + type: GraphQLObjectType | GraphQLInterfaceType, + field: GraphQLField, + arg: GraphQLArgument, +): Change { + return fieldArgumentDeprecationReasonAddedFromMeta({ + type: ChangeType.FieldArgumentDeprecationReasonAdded, + meta: { + typeName: type.name, + fieldName: field.name, + argumentName: arg.name, + addedDeprecationReason: arg.deprecationReason ?? '', + }, + }); +} + +export function fieldArgumentDeprecationReasonRemovedFromMeta( + args: FieldArgumentDeprecationReasonRemovedChange, +) { + return { + type: ChangeType.FieldArgumentDeprecationReasonRemoved, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: `Deprecation reason was removed from argument '${args.meta.argumentName}' on field '${args.meta.typeName}.${args.meta.fieldName}'`, + meta: args.meta, + path: [args.meta.typeName, args.meta.fieldName, args.meta.argumentName].join('.'), + } as const; +} + +export function fieldArgumentDeprecationReasonRemoved( + type: GraphQLObjectType | GraphQLInterfaceType, + field: GraphQLField, + arg: GraphQLArgument, +): Change { + return fieldArgumentDeprecationReasonRemovedFromMeta({ + type: ChangeType.FieldArgumentDeprecationReasonRemoved, + meta: { + typeName: type.name, + fieldName: field.name, + argumentName: arg.name, + }, + }); +} diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index b645d59edf..5d3dc5d2e5 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -26,6 +26,11 @@ export const ChangeType = { FieldArgumentDescriptionChanged: 'FIELD_ARGUMENT_DESCRIPTION_CHANGED', FieldArgumentDefaultChanged: 'FIELD_ARGUMENT_DEFAULT_CHANGED', FieldArgumentTypeChanged: 'FIELD_ARGUMENT_TYPE_CHANGED', + FieldArgumentDeprecationAdded: 'FIELD_ARGUMENT_DEPRECATION_ADDED', + FieldArgumentDeprecationRemoved: 'FIELD_ARGUMENT_DEPRECATION_REMOVED', + FieldArgumentDeprecationReasonChanged: 'FIELD_ARGUMENT_DEPRECATION_REASON_CHANGED', + FieldArgumentDeprecationReasonAdded: 'FIELD_ARGUMENT_DEPRECATION_REASON_ADDED', + FieldArgumentDeprecationReasonRemoved: 'FIELD_ARGUMENT_DEPRECATION_REASON_REMOVED', // Directive DirectiveRemoved: 'DIRECTIVE_REMOVED', DirectiveAdded: 'DIRECTIVE_ADDED', @@ -68,6 +73,11 @@ export const ChangeType = { InputFieldDescriptionChanged: 'INPUT_FIELD_DESCRIPTION_CHANGED', InputFieldDefaultValueChanged: 'INPUT_FIELD_DEFAULT_VALUE_CHANGED', InputFieldTypeChanged: 'INPUT_FIELD_TYPE_CHANGED', + InputFieldDeprecationAdded: 'INPUT_FIELD_DEPRECATION_ADDED', + InputFieldDeprecationRemoved: 'INPUT_FIELD_DEPRECATION_REMOVED', + InputFieldDeprecationReasonChanged: 'INPUT_FIELD_DEPRECATION_REASON_CHANGED', + InputFieldDeprecationReasonAdded: 'INPUT_FIELD_DEPRECATION_REASON_ADDED', + InputFieldDeprecationReasonRemoved: 'INPUT_FIELD_DEPRECATION_REASON_REMOVED', // Type ObjectTypeInterfaceAdded: 'OBJECT_TYPE_INTERFACE_ADDED', ObjectTypeInterfaceRemoved: 'OBJECT_TYPE_INTERFACE_REMOVED', @@ -152,6 +162,55 @@ export type FieldArgumentTypeChangedChange = { }; }; +export type FieldArgumentDeprecationAddedChange = { + type: typeof ChangeType.FieldArgumentDeprecationAdded; + meta: { + typeName: string; + fieldName: string; + argumentName: string; + deprecationReason: string; + }; +}; + +export type FieldArgumentDeprecationRemovedChange = { + type: typeof ChangeType.FieldArgumentDeprecationRemoved; + meta: { + typeName: string; + fieldName: string; + argumentName: string; + }; +}; + +export type FieldArgumentDeprecationReasonChangedChange = { + type: typeof ChangeType.FieldArgumentDeprecationReasonChanged; + meta: { + typeName: string; + fieldName: string; + argumentName: string; + oldDeprecationReason: string; + newDeprecationReason: string; + }; +}; + +export type FieldArgumentDeprecationReasonAddedChange = { + type: typeof ChangeType.FieldArgumentDeprecationReasonAdded; + meta: { + typeName: string; + fieldName: string; + argumentName: string; + addedDeprecationReason: string; + }; +}; + +export type FieldArgumentDeprecationReasonRemovedChange = { + type: typeof ChangeType.FieldArgumentDeprecationReasonRemoved; + meta: { + typeName: string; + fieldName: string; + argumentName: string; + }; +}; + export type DirectiveRemovedChange = { type: typeof ChangeType.DirectiveRemoved; meta: { @@ -540,6 +599,50 @@ export type InputFieldTypeChangedChange = { }; }; +export type InputFieldDeprecationAddedChange = { + type: typeof ChangeType.InputFieldDeprecationAdded; + meta: { + inputName: string; + inputFieldName: string; + deprecationReason: string; + }; +}; + +export type InputFieldDeprecationRemovedChange = { + type: typeof ChangeType.InputFieldDeprecationRemoved; + meta: { + inputName: string; + inputFieldName: string; + }; +}; + +export type InputFieldDeprecationReasonChangedChange = { + type: typeof ChangeType.InputFieldDeprecationReasonChanged; + meta: { + inputName: string; + inputFieldName: string; + oldDeprecationReason: string; + newDeprecationReason: string; + }; +}; + +export type InputFieldDeprecationReasonAddedChange = { + type: typeof ChangeType.InputFieldDeprecationReasonAdded; + meta: { + inputName: string; + inputFieldName: string; + addedDeprecationReason: string; + }; +}; + +export type InputFieldDeprecationReasonRemovedChange = { + type: typeof ChangeType.InputFieldDeprecationReasonRemoved; + meta: { + inputName: string; + inputFieldName: string; + }; +}; + // Type export type ObjectTypeInterfaceAddedChange = { @@ -956,6 +1059,11 @@ type Changes = { [ChangeType.InputFieldDescriptionChanged]: InputFieldDescriptionChangedChange; [ChangeType.InputFieldDefaultValueChanged]: InputFieldDefaultValueChangedChange; [ChangeType.InputFieldTypeChanged]: InputFieldTypeChangedChange; + [ChangeType.InputFieldDeprecationAdded]: InputFieldDeprecationAddedChange; + [ChangeType.InputFieldDeprecationRemoved]: InputFieldDeprecationRemovedChange; + [ChangeType.InputFieldDeprecationReasonChanged]: InputFieldDeprecationReasonChangedChange; + [ChangeType.InputFieldDeprecationReasonAdded]: InputFieldDeprecationReasonAddedChange; + [ChangeType.InputFieldDeprecationReasonRemoved]: InputFieldDeprecationReasonRemovedChange; [ChangeType.FieldRemoved]: FieldRemovedChange; [ChangeType.FieldAdded]: FieldAddedChange; [ChangeType.FieldDescriptionAdded]: FieldDescriptionAddedChange; @@ -976,6 +1084,11 @@ type Changes = { [ChangeType.FieldArgumentDescriptionChanged]: FieldArgumentDescriptionChangedChange; [ChangeType.FieldArgumentDefaultChanged]: FieldArgumentDefaultChangedChange; [ChangeType.FieldArgumentTypeChanged]: FieldArgumentTypeChangedChange; + [ChangeType.FieldArgumentDeprecationAdded]: FieldArgumentDeprecationAddedChange; + [ChangeType.FieldArgumentDeprecationRemoved]: FieldArgumentDeprecationRemovedChange; + [ChangeType.FieldArgumentDeprecationReasonChanged]: FieldArgumentDeprecationReasonChangedChange; + [ChangeType.FieldArgumentDeprecationReasonAdded]: FieldArgumentDeprecationReasonAddedChange; + [ChangeType.FieldArgumentDeprecationReasonRemoved]: FieldArgumentDeprecationReasonRemovedChange; [ChangeType.DirectiveLocationAdded]: DirectiveLocationAddedChange; [ChangeType.DirectiveLocationRemoved]: DirectiveLocationRemovedChange; [ChangeType.EnumValueRemoved]: EnumValueRemovedChange; diff --git a/packages/core/src/diff/changes/input.ts b/packages/core/src/diff/changes/input.ts index e52d1386fe..e58df1150e 100644 --- a/packages/core/src/diff/changes/input.ts +++ b/packages/core/src/diff/changes/input.ts @@ -1,4 +1,9 @@ -import { GraphQLInputField, GraphQLInputObjectType, isNonNullType } from 'graphql'; +import { + GraphQLDeprecatedDirective, + GraphQLInputField, + GraphQLInputObjectType, + isNonNullType, +} from 'graphql'; import { safeChangeForInputValue } from '../../utils/graphql.js'; import { isDeprecated } from '../../utils/is-deprecated.js'; import { fmt, safeString } from '../../utils/string.js'; @@ -8,6 +13,11 @@ import { CriticalityLevel, InputFieldAddedChange, InputFieldDefaultValueChangedChange, + InputFieldDeprecationAddedChange, + InputFieldDeprecationReasonAddedChange, + InputFieldDeprecationReasonChangedChange, + InputFieldDeprecationReasonRemovedChange, + InputFieldDeprecationRemovedChange, InputFieldDescriptionAddedChange, InputFieldDescriptionChangedChange, InputFieldDescriptionRemovedChange, @@ -276,3 +286,164 @@ export function inputFieldTypeChanged( }, }); } + +function buildInputFieldDeprecatedAddedMessage(args: InputFieldDeprecationAddedChange['meta']) { + return `Input field '${args.inputName}.${args.inputFieldName}' is deprecated`; +} + +export function inputFieldDeprecationAddedFromMeta(args: InputFieldDeprecationAddedChange) { + return { + type: ChangeType.InputFieldDeprecationAdded, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: buildInputFieldDeprecatedAddedMessage(args.meta), + meta: args.meta, + path: [args.meta.inputName, args.meta.inputFieldName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), + } as const; +} + +export function inputFieldDeprecationAdded( + input: GraphQLInputObjectType, + field: GraphQLInputField, +): Change { + return inputFieldDeprecationAddedFromMeta({ + type: ChangeType.InputFieldDeprecationAdded, + meta: { + inputName: input.name, + inputFieldName: field.name, + deprecationReason: field.deprecationReason ?? '', + }, + }); +} + +export function inputFieldDeprecationRemovedFromMeta(args: InputFieldDeprecationRemovedChange) { + return { + type: ChangeType.InputFieldDeprecationRemoved, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: `Input field '${args.meta.inputName}.${args.meta.inputFieldName}' is no longer deprecated`, + meta: args.meta, + path: [args.meta.inputName, args.meta.inputFieldName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), + } as const; +} + +export function inputFieldDeprecationRemoved( + input: GraphQLInputObjectType, + field: GraphQLInputField, +): Change { + return inputFieldDeprecationRemovedFromMeta({ + type: ChangeType.InputFieldDeprecationRemoved, + meta: { + inputFieldName: field.name, + inputName: input.name, + }, + }); +} + +function buildInputFieldDeprecationReasonChangedMessage( + args: InputFieldDeprecationReasonChangedChange['meta'], +) { + const oldReason = fmt(args.oldDeprecationReason); + const newReason = fmt(args.newDeprecationReason); + return `Deprecation reason on input field '${args.inputName}.${args.inputFieldName}' has changed from '${oldReason}' to '${newReason}'`; +} + +export function inputFieldDeprecationReasonChangedFromMeta( + args: InputFieldDeprecationReasonChangedChange, +) { + return { + type: ChangeType.InputFieldDeprecationReasonChanged, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: buildInputFieldDeprecationReasonChangedMessage(args.meta), + meta: args.meta, + path: [args.meta.inputName, args.meta.inputFieldName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), + } as const; +} + +export function inputFieldDeprecationReasonChanged( + input: GraphQLInputObjectType, + oldField: GraphQLInputField, + newField: GraphQLInputField, +): Change { + return inputFieldDeprecationReasonChangedFromMeta({ + type: ChangeType.InputFieldDeprecationReasonChanged, + meta: { + inputFieldName: newField.name, + inputName: input.name, + newDeprecationReason: newField.deprecationReason ?? '', + oldDeprecationReason: oldField.deprecationReason ?? '', + }, + }); +} + +function buildInputFieldDeprecationReasonAddedMessage( + args: InputFieldDeprecationReasonAddedChange['meta'], +) { + const reason = fmt(args.addedDeprecationReason); + return `Input field '${args.inputName}.${args.inputFieldName}' has deprecation reason '${reason}'`; +} + +export function inputFieldDeprecationReasonAddedFromMeta(args: InputFieldDeprecationReasonAddedChange) { + return { + type: ChangeType.InputFieldDeprecationReasonAdded, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: buildInputFieldDeprecationReasonAddedMessage(args.meta), + meta: args.meta, + path: [args.meta.inputName, args.meta.inputFieldName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), + } as const; +} + +export function inputFieldDeprecationReasonAdded( + input: GraphQLInputObjectType, + field: GraphQLInputField, +): Change { + return inputFieldDeprecationReasonAddedFromMeta({ + type: ChangeType.InputFieldDeprecationReasonAdded, + meta: { + inputName: input.name, + inputFieldName: field.name, + addedDeprecationReason: field.deprecationReason ?? '', + }, + }); +} + +export function inputFieldDeprecationReasonRemovedFromMeta( + args: InputFieldDeprecationReasonRemovedChange, +) { + return { + type: ChangeType.InputFieldDeprecationReasonRemoved, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: `Deprecation reason was removed from input field '${args.meta.inputName}.${args.meta.inputFieldName}'`, + meta: args.meta, + path: [args.meta.inputName, args.meta.inputFieldName].join('.'), + } as const; +} + +export function inputFieldDeprecationReasonRemoved( + input: GraphQLInputObjectType, + field: GraphQLInputField, +): Change { + return inputFieldDeprecationReasonRemovedFromMeta({ + type: ChangeType.InputFieldDeprecationReasonRemoved, + meta: { + inputName: input.name, + inputFieldName: field.name, + }, + }); +} diff --git a/packages/core/src/diff/input.ts b/packages/core/src/diff/input.ts index 78cf3423b3..b5eae6959c 100644 --- a/packages/core/src/diff/input.ts +++ b/packages/core/src/diff/input.ts @@ -6,6 +6,7 @@ import { isNotEqual, isVoid, } from '../utils/compare.js'; +import { isDeprecated } from '../utils/is-deprecated.js'; import { directiveUsageAdded, directiveUsageChanged, @@ -14,6 +15,11 @@ import { import { inputFieldAdded, inputFieldDefaultValueChanged, + inputFieldDeprecationAdded, + inputFieldDeprecationReasonAdded, + inputFieldDeprecationReasonChanged, + inputFieldDeprecationReasonRemoved, + inputFieldDeprecationRemoved, inputFieldDescriptionAdded, inputFieldDescriptionChanged, inputFieldDescriptionRemoved, @@ -22,6 +28,8 @@ import { } from './changes/input.js'; import { AddChange } from './schema.js'; +const DEPRECATION_REASON_DEFAULT = 'No longer supported'; + export function changesInInputObject( oldInput: GraphQLInputObjectType | null, newInput: GraphQLInputObjectType, @@ -80,6 +88,30 @@ function changesInInputField( } } + if (isVoid(oldField) || !isDeprecated(oldField)) { + if (isDeprecated(newField)) { + addChange(inputFieldDeprecationAdded(input, newField)); + } + } else if (!isDeprecated(newField)) { + if (isDeprecated(oldField)) { + addChange(inputFieldDeprecationRemoved(input, oldField)); + } + } else if (isNotEqual(oldField.deprecationReason, newField.deprecationReason)) { + if ( + isVoid(oldField.deprecationReason) || + oldField.deprecationReason === DEPRECATION_REASON_DEFAULT + ) { + addChange(inputFieldDeprecationReasonAdded(input, newField)); + } else if ( + isVoid(newField.deprecationReason) || + newField.deprecationReason === DEPRECATION_REASON_DEFAULT + ) { + addChange(inputFieldDeprecationReasonRemoved(input, oldField)); + } else { + addChange(inputFieldDeprecationReasonChanged(input, oldField, newField)); + } + } + if (!isVoid(oldField)) { if (isNotEqual(oldField?.defaultValue, newField.defaultValue)) { if (Array.isArray(oldField?.defaultValue) && Array.isArray(newField.defaultValue)) { @@ -96,39 +128,37 @@ function changesInInputField( } } - if (newField.astNode?.directives) { - compareDirectiveLists(oldField?.astNode?.directives || [], newField.astNode.directives || [], { - onAdded(directive) { - addChange( - directiveUsageAdded( - Kind.INPUT_VALUE_DEFINITION, - directive, - { - type: input, - field: newField, - }, - oldField === null, - ), - ); - directiveUsageChanged(null, directive, addChange, input, newField); - }, - onMutual(directive) { - directiveUsageChanged( - directive.oldVersion, - directive.newVersion, - addChange, - input, - newField, - ); - }, - onRemoved(directive) { - addChange( - directiveUsageRemoved(Kind.INPUT_VALUE_DEFINITION, directive, { + compareDirectiveLists(oldField?.astNode?.directives || [], newField.astNode?.directives || [], { + onAdded(directive) { + addChange( + directiveUsageAdded( + Kind.INPUT_VALUE_DEFINITION, + directive, + { type: input, field: newField, - }), - ); - }, - }); - } + }, + oldField === null, + ), + ); + directiveUsageChanged(null, directive, addChange, input, newField); + }, + onMutual(directive) { + directiveUsageChanged( + directive.oldVersion, + directive.newVersion, + addChange, + input, + newField, + ); + }, + onRemoved(directive) { + addChange( + directiveUsageRemoved(Kind.INPUT_VALUE_DEFINITION, directive, { + type: input, + field: newField, + }), + ); + }, + }); } diff --git a/packages/core/src/diff/rules/simplify-changes.ts b/packages/core/src/diff/rules/simplify-changes.ts index 6ffe5a528e..bf2f2168c2 100644 --- a/packages/core/src/diff/rules/simplify-changes.ts +++ b/packages/core/src/diff/rules/simplify-changes.ts @@ -31,8 +31,16 @@ const simpleChangeTypes = new Set([ ChangeType.EnumValueDeprecationReasonChanged, ChangeType.FieldDeprecationReasonAdded, + ChangeType.FieldArgumentDeprecationAdded, + ChangeType.FieldArgumentDeprecationRemoved, + ChangeType.FieldArgumentDeprecationReasonChanged, + ChangeType.FieldArgumentDeprecationReasonAdded, ChangeType.FieldDescriptionAdded, ChangeType.InputFieldAdded, + ChangeType.InputFieldDeprecationAdded, + ChangeType.InputFieldDeprecationRemoved, + ChangeType.InputFieldDeprecationReasonChanged, + ChangeType.InputFieldDeprecationReasonAdded, ChangeType.InputFieldDescriptionAdded, ChangeType.ObjectTypeInterfaceAdded, ChangeType.TypeAdded, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b646713cfa..9c30b2dc60 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -30,6 +30,11 @@ export { fieldArgumentDescriptionChangedFromMeta, fieldArgumentDefaultChangedFromMeta, fieldArgumentTypeChangedFromMeta, + fieldArgumentDeprecationAddedFromMeta, + fieldArgumentDeprecationRemovedFromMeta, + fieldArgumentDeprecationReasonChangedFromMeta, + fieldArgumentDeprecationReasonAddedFromMeta, + fieldArgumentDeprecationReasonRemovedFromMeta, } from './diff/changes/argument.js'; export { directiveUsageArgumentDefinitionAddedFromMeta, @@ -106,6 +111,11 @@ export { inputFieldDescriptionChangedFromMeta, inputFieldDefaultValueChangedFromMeta, inputFieldTypeChangedFromMeta, + inputFieldDeprecationAddedFromMeta, + inputFieldDeprecationRemovedFromMeta, + inputFieldDeprecationReasonChangedFromMeta, + inputFieldDeprecationReasonAddedFromMeta, + inputFieldDeprecationReasonRemovedFromMeta, } from './diff/changes/input.js'; export { objectTypeInterfaceAddedFromMeta, @@ -163,6 +173,11 @@ export { FieldTypeChangedChange, FieldArgumentAddedChange, FieldArgumentRemovedChange, + FieldArgumentDeprecationAddedChange, + FieldArgumentDeprecationRemovedChange, + FieldArgumentDeprecationReasonChangedChange, + FieldArgumentDeprecationReasonAddedChange, + FieldArgumentDeprecationReasonRemovedChange, InputFieldRemovedChange, InputFieldAddedChange, InputFieldDescriptionAddedChange, @@ -170,6 +185,11 @@ export { InputFieldDescriptionChangedChange, InputFieldDefaultValueChangedChange, InputFieldTypeChangedChange, + InputFieldDeprecationAddedChange, + InputFieldDeprecationRemovedChange, + InputFieldDeprecationReasonChangedChange, + InputFieldDeprecationReasonAddedChange, + InputFieldDeprecationReasonRemovedChange, ObjectTypeInterfaceAddedChange, ObjectTypeInterfaceRemovedChange, SchemaQueryTypeChangedChange, diff --git a/packages/core/src/utils/is-deprecated.ts b/packages/core/src/utils/is-deprecated.ts index aa56a4ef7c..ea5892e4b1 100644 --- a/packages/core/src/utils/is-deprecated.ts +++ b/packages/core/src/utils/is-deprecated.ts @@ -1,7 +1,11 @@ -import { GraphQLEnumValue, GraphQLField, GraphQLInputField } from 'graphql'; +import { GraphQLArgument, GraphQLEnumValue, GraphQLField, GraphQLInputField } from 'graphql'; export function isDeprecated( - fieldOrEnumValue: GraphQLField | GraphQLEnumValue | GraphQLInputField, + fieldOrEnumValue: + | GraphQLField + | GraphQLEnumValue + | GraphQLInputField + | GraphQLArgument, ): boolean { if ('isDeprecated' in fieldOrEnumValue) { return !!fieldOrEnumValue['isDeprecated']; diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts index a8754b6cbf..c29b6e71ad 100644 --- a/packages/patch/src/types.ts +++ b/packages/patch/src/types.ts @@ -12,7 +12,11 @@ export type AdditionChangeType = | typeof ChangeType.FieldDeprecationAdded | typeof ChangeType.FieldDeprecationReasonAdded | typeof ChangeType.FieldDescriptionAdded + | typeof ChangeType.FieldArgumentDeprecationAdded + | typeof ChangeType.FieldArgumentDeprecationReasonAdded | typeof ChangeType.InputFieldAdded + | typeof ChangeType.InputFieldDeprecationAdded + | typeof ChangeType.InputFieldDeprecationReasonAdded | typeof ChangeType.InputFieldDescriptionAdded | typeof ChangeType.ObjectTypeInterfaceAdded | typeof ChangeType.TypeDescriptionAdded diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 77a652775c..84c03f0289 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -44,7 +44,11 @@ const isAdditionChange = (change: Change): change is Change