From cf7fcfce911dd299f8f6d925c7441990002f8f2e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 7 Jul 2025 14:44:24 -0700 Subject: [PATCH 01/23] major: diff includes all nested changes when a node is added --- .changeset/seven-jars-yell.md | 6 + .../__tests__/diff/directive-usage.test.ts | 23 ++++ packages/core/__tests__/diff/enum.test.ts | 48 ++++++++ packages/core/__tests__/diff/input.test.ts | 57 +++++++++- .../core/__tests__/diff/interface.test.ts | 44 ++++++-- packages/core/__tests__/diff/object.test.ts | 70 +++++++++--- packages/core/__tests__/diff/schema.test.ts | 5 +- packages/core/src/diff/argument.ts | 33 +++--- packages/core/src/diff/changes/argument.ts | 16 +-- packages/core/src/diff/changes/change.ts | 42 ++++++- .../core/src/diff/changes/directive-usage.ts | 56 ++++++++-- packages/core/src/diff/changes/directive.ts | 50 +++++---- packages/core/src/diff/changes/enum.ts | 22 ++-- packages/core/src/diff/changes/field.ts | 4 + packages/core/src/diff/changes/input.ts | 41 ++++--- packages/core/src/diff/changes/object.ts | 8 +- packages/core/src/diff/changes/type.ts | 54 +++++++-- packages/core/src/diff/changes/union.ts | 4 +- packages/core/src/diff/directive.ts | 27 ++--- packages/core/src/diff/enum.ts | 103 +++++++++++------- packages/core/src/diff/field.ts | 47 ++++---- packages/core/src/diff/input.ts | 71 +++++++----- packages/core/src/diff/interface.ts | 38 +++++-- packages/core/src/diff/object.ts | 21 ++-- packages/core/src/diff/scalar.ts | 10 +- packages/core/src/diff/schema.ts | 30 +++-- packages/core/src/diff/union.ts | 16 +-- packages/core/src/utils/compare.ts | 4 +- packages/core/src/utils/graphql.ts | 2 +- 29 files changed, 687 insertions(+), 265 deletions(-) create mode 100644 .changeset/seven-jars-yell.md diff --git a/.changeset/seven-jars-yell.md b/.changeset/seven-jars-yell.md new file mode 100644 index 0000000000..3750809802 --- /dev/null +++ b/.changeset/seven-jars-yell.md @@ -0,0 +1,6 @@ +--- +'@graphql-inspector/core': major +--- + +"diff" includes all nested changes when a node is added. Some change types have had additional meta fields added. +On deprecation add with a reason, a separate "fieldDeprecationReasonAdded" change is no longer included. diff --git a/packages/core/__tests__/diff/directive-usage.test.ts b/packages/core/__tests__/diff/directive-usage.test.ts index 7b2117046e..a8d54406aa 100644 --- a/packages/core/__tests__/diff/directive-usage.test.ts +++ b/packages/core/__tests__/diff/directive-usage.test.ts @@ -28,6 +28,29 @@ describe('directive-usage', () => { expect(change.message).toEqual("Directive 'external' was added to field 'Query.a'"); }); + test('added directive on added field', async () => { + const a = buildSchema(/* GraphQL */ ` + type Query { + _: String + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @external on FIELD_DEFINITION + + type Query { + _: String + a: String @external + } + `); + + const changes = await diff(a, b); + const change = findFirstChangeByPath(changes, 'Query.a.external'); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED'); + expect(change.message).toEqual("Directive 'external' was added to field 'Query.a'"); + }); + test('removed directive', async () => { const a = buildSchema(/* GraphQL */ ` directive @external on FIELD_DEFINITION diff --git a/packages/core/__tests__/diff/enum.test.ts b/packages/core/__tests__/diff/enum.test.ts index a049332db4..3b950766da 100644 --- a/packages/core/__tests__/diff/enum.test.ts +++ b/packages/core/__tests__/diff/enum.test.ts @@ -3,6 +3,54 @@ import { CriticalityLevel, diff, DiffRule } from '../../src/index.js'; import { findFirstChangeByPath } from '../../utils/testing.js'; describe('enum', () => { + test('added', async () => { + const a = buildSchema(/* GraphQL */ ` + type Query { + fieldA: String + } + `); + + const b = buildSchema(/* GraphQL */ ` + type Query { + fieldA: String + } + + enum enumA { + """ + A is the first letter in the alphabet + """ + A + B + } + `); + + const changes = await diff(a, b); + expect(changes.length).toEqual(4); + + { + const change = findFirstChangeByPath(changes, 'enumA'); + expect(change.meta).toMatchObject({ + addedTypeKind: 'EnumTypeDefinition', + addedTypeName: 'enumA', + }); + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.criticality.reason).not.toBeDefined(); + expect(change.message).toEqual(`Type 'enumA' was added`); + } + + { + const change = findFirstChangeByPath(changes, 'enumA.A'); + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.criticality.reason).not.toBeDefined(); + expect(change.message).toEqual(`Enum value 'A' was added to enum 'enumA'`); + expect(change.meta).toMatchObject({ + addedEnumValueName: 'A', + enumName: 'enumA', + addedToNewType: true, + }); + } + }); + test('value added', 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 8f6d2719cf..dd3946b8f9 100644 --- a/packages/core/__tests__/diff/input.test.ts +++ b/packages/core/__tests__/diff/input.test.ts @@ -1,6 +1,6 @@ import { buildSchema } from 'graphql'; import { CriticalityLevel, diff, DiffRule } from '../../src/index.js'; -import { findFirstChangeByPath } from '../../utils/testing.js'; +import { findChangesByPath, findFirstChangeByPath } from '../../utils/testing.js'; describe('input', () => { describe('fields', () => { @@ -38,6 +38,61 @@ describe('input', () => { "Input field 'd' of type 'String' was added to input object type 'Foo'", ); }); + + test('added with a default value', async () => { + const a = buildSchema(/* GraphQL */ ` + input Foo { + a: String! + } + `); + const b = buildSchema(/* GraphQL */ ` + input Foo { + a: String! + b: String! = "B" + } + `); + + const change = findFirstChangeByPath(await diff(a, b), 'Foo.b'); + expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.type).toEqual('INPUT_FIELD_ADDED'); + expect(change.meta).toMatchObject({ + addedFieldDefault: '"B"', + addedInputFieldName: 'b', + addedInputFieldType: 'String!', + addedToNewType: false, + inputName: 'Foo', + isAddedInputFieldTypeNullable: false, + }); + expect(change.message).toEqual( + `Input field 'b' of type 'String!' with default value '"B"' was added to input object type 'Foo'`, + ); + }); + + test('added to an added input', async () => { + const a = buildSchema(/* GraphQL */ ` + type Query { + _: String + } + `); + const b = buildSchema(/* GraphQL */ ` + type Query { + _: String + } + + input Foo { + a: String! + } + `); + + const change = findFirstChangeByPath(await diff(a, b), 'Foo.a'); + + expect(change.type).toEqual('INPUT_FIELD_ADDED'); + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.message).toEqual( + "Input field 'a' of type 'String!' was added to input object type 'Foo'", + ); + }); + test('removed', async () => { const a = buildSchema(/* GraphQL */ ` input Foo { diff --git a/packages/core/__tests__/diff/interface.test.ts b/packages/core/__tests__/diff/interface.test.ts index 153fb1bcea..bb39e72b08 100644 --- a/packages/core/__tests__/diff/interface.test.ts +++ b/packages/core/__tests__/diff/interface.test.ts @@ -169,24 +169,24 @@ describe('interface', () => { const changes = await diff(a, b); const change = { a: findFirstChangeByPath(changes, 'Foo.a'), - b: findChangesByPath(changes, 'Foo.b')[1], - c: findChangesByPath(changes, 'Foo.c')[1], + b: findFirstChangeByPath(changes, 'Foo.b'), + c: findFirstChangeByPath(changes, 'Foo.c'), }; // Changed - expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.a.type).toEqual('FIELD_DEPRECATION_REASON_CHANGED'); + expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.a.message).toEqual( "Deprecation reason on field 'Foo.a' has changed from 'OLD' to 'NEW'", ); // Removed - expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking); - expect(change.b.type).toEqual('FIELD_DEPRECATION_REASON_REMOVED'); - expect(change.b.message).toEqual("Deprecation reason was removed from field 'Foo.b'"); + expect(change.b.type).toEqual('FIELD_DEPRECATION_REMOVED'); + expect(change.b.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.b.message).toEqual("Field 'Foo.b' is no longer deprecated"); // Added + expect(change.c.type).toEqual('FIELD_DEPRECATION_ADDED'); expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking); - expect(change.c.type).toEqual('FIELD_DEPRECATION_REASON_ADDED'); - expect(change.c.message).toEqual("Field 'Foo.c' has deprecation reason 'CCC'"); + expect(change.c.message).toEqual("Field 'Foo.c' is deprecated"); }); test('deprecation added / removed', async () => { @@ -219,4 +219,32 @@ describe('interface', () => { expect(change.b.message).toEqual("Field 'Foo.b' is deprecated"); }); }); + + test('deprecation added w/reason', async () => { + const a = buildSchema(/* GraphQL */ ` + interface Foo { + a: String! + } + `); + const b = buildSchema(/* GraphQL */ ` + interface Foo { + a: String! @deprecated(reason: "A is the first letter.") + } + `); + + const changes = await diff(a, b); + + expect(findChangesByPath(changes, 'Foo.a')).toHaveLength(1); + const change = findFirstChangeByPath(changes, 'Foo.a'); + + // added + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('FIELD_DEPRECATION_ADDED'); + expect(change.message).toEqual("Field 'Foo.a' is deprecated"); + expect(change.meta).toMatchObject({ + deprecationReason: 'A is the first letter.', + fieldName: 'a', + typeName: 'Foo', + }); + }); }); diff --git a/packages/core/__tests__/diff/object.test.ts b/packages/core/__tests__/diff/object.test.ts index caecacf22d..6e15624e6b 100644 --- a/packages/core/__tests__/diff/object.test.ts +++ b/packages/core/__tests__/diff/object.test.ts @@ -31,11 +31,18 @@ describe('object', () => { } `); - const change = findFirstChangeByPath(await diff(a, b), 'B'); - const mutation = findFirstChangeByPath(await diff(a, b), 'Mutation'); + const changes = await diff(a, b); + expect(changes).toHaveLength(4); + + const change = findFirstChangeByPath(changes, 'B'); + const mutation = findFirstChangeByPath(changes, 'Mutation'); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(mutation.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.meta).toMatchObject({ + addedTypeKind: 'ObjectTypeDefinition', + addedTypeName: 'B', + }); }); describe('interfaces', () => { @@ -63,7 +70,8 @@ describe('object', () => { b: String! } - interface C { + interface C implements B { + b: String! c: String! } @@ -74,11 +82,43 @@ describe('object', () => { } `); - const change = findFirstChangeByPath(await diff(a, b), 'Foo'); + const changes = await diff(a, b); + + { + const change = findFirstChangeByPath(changes, 'Foo'); + expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.type).toEqual('OBJECT_TYPE_INTERFACE_ADDED'); + expect(change.message).toEqual("'Foo' object implements 'C' interface"); + expect(change.meta).toMatchObject({ + addedInterfaceName: 'C', + objectTypeName: 'Foo', + }); + } + + const cChanges = findChangesByPath(changes, 'C'); + expect(cChanges).toHaveLength(2); + { + const change = cChanges[0]; + expect(change.type).toEqual('TYPE_ADDED'); + expect(change.meta).toMatchObject({ + addedTypeKind: 'InterfaceTypeDefinition', + addedTypeName: 'C', + }); + } + + { + const change = cChanges[1]; + expect(change.type).toEqual('OBJECT_TYPE_INTERFACE_ADDED'); + expect(change.meta).toMatchObject({ + addedInterfaceName: 'B', + objectTypeName: 'C', + }); + } - expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); - expect(change.type).toEqual('OBJECT_TYPE_INTERFACE_ADDED'); - expect(change.message).toEqual("'Foo' object implements 'C' interface"); + { + const change = findFirstChangeByPath(changes, 'C.b'); + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + } }); test('removed', async () => { @@ -290,24 +330,24 @@ describe('object', () => { const changes = await diff(a, b); const change = { a: findFirstChangeByPath(changes, 'Foo.a'), - b: findChangesByPath(changes, 'Foo.b')[1], - c: findChangesByPath(changes, 'Foo.c')[1], + b: findFirstChangeByPath(changes, 'Foo.b'), + c: findFirstChangeByPath(changes, 'Foo.c'), }; // Changed - expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.a.type).toEqual('FIELD_DEPRECATION_REASON_CHANGED'); + expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.a.message).toEqual( "Deprecation reason on field 'Foo.a' has changed from 'OLD' to 'NEW'", ); // Removed - expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking); - expect(change.b.type).toEqual('FIELD_DEPRECATION_REASON_REMOVED'); - expect(change.b.message).toEqual("Deprecation reason was removed from field 'Foo.b'"); + expect(change.b.type).toEqual('FIELD_DEPRECATION_REMOVED'); + expect(change.b.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.b.message).toEqual("Field 'Foo.b' is no longer deprecated"); // Added + expect(change.c.type).toEqual('FIELD_DEPRECATION_ADDED'); expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking); - expect(change.c.type).toEqual('FIELD_DEPRECATION_REASON_ADDED'); - expect(change.c.message).toEqual("Field 'Foo.c' has deprecation reason 'CCC'"); + expect(change.c.message).toEqual("Field 'Foo.c' is deprecated"); }); test('deprecation added / removed', async () => { diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index 90392ece63..29f7356242 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -1,6 +1,7 @@ import { buildClientSchema, buildSchema, introspectionFromSchema } from 'graphql'; import { Change, CriticalityLevel, diff } from '../../src/index.js'; import { findBestMatch } from '../../src/utils/string.js'; +import { findChangesByPath, findFirstChangeByPath } from '../../utils/testing.js'; test('same schema', async () => { const schemaA = buildSchema(/* GraphQL */ ` @@ -820,9 +821,9 @@ test('adding root type should not be breaking', async () => { `); const changes = await diff(schemaA, schemaB); - const subscription = changes[0]; + expect(changes).toHaveLength(2); - expect(changes).toHaveLength(1); + const subscription = findFirstChangeByPath(changes, 'Subscription'); expect(subscription).toBeDefined(); expect(subscription!.criticality.level).toEqual(CriticalityLevel.NonBreaking); }); diff --git a/packages/core/src/diff/argument.ts b/packages/core/src/diff/argument.ts index a93652c860..0902790042 100644 --- a/packages/core/src/diff/argument.ts +++ b/packages/core/src/diff/argument.ts @@ -17,44 +17,49 @@ import { AddChange } from './schema.js'; export function changesInArgument( type: GraphQLObjectType | GraphQLInterfaceType, field: GraphQLField, - oldArg: GraphQLArgument, + oldArg: GraphQLArgument | null, newArg: GraphQLArgument, addChange: AddChange, ) { - if (isNotEqual(oldArg.description, newArg.description)) { + if (isNotEqual(oldArg?.description, newArg.description)) { addChange(fieldArgumentDescriptionChanged(type, field, oldArg, newArg)); } - if (isNotEqual(oldArg.defaultValue, newArg.defaultValue)) { - if (Array.isArray(oldArg.defaultValue) && Array.isArray(newArg.defaultValue)) { + if (isNotEqual(oldArg?.defaultValue, newArg.defaultValue)) { + if (Array.isArray(oldArg?.defaultValue) && Array.isArray(newArg.defaultValue)) { const diff = diffArrays(oldArg.defaultValue, newArg.defaultValue); if (diff.length > 0) { addChange(fieldArgumentDefaultChanged(type, field, oldArg, newArg)); } - } else if (JSON.stringify(oldArg.defaultValue) !== JSON.stringify(newArg.defaultValue)) { + } else if (JSON.stringify(oldArg?.defaultValue) !== JSON.stringify(newArg.defaultValue)) { addChange(fieldArgumentDefaultChanged(type, field, oldArg, newArg)); } } - if (isNotEqual(oldArg.type.toString(), newArg.type.toString())) { + if (isNotEqual(oldArg?.type.toString(), newArg.type.toString())) { addChange(fieldArgumentTypeChanged(type, field, oldArg, newArg)); } - if (oldArg.astNode?.directives && newArg.astNode?.directives) { - compareLists(oldArg.astNode.directives || [], newArg.astNode.directives || [], { + if (newArg.astNode?.directives) { + compareLists(oldArg?.astNode?.directives || [], newArg.astNode.directives || [], { onAdded(directive) { addChange( - directiveUsageAdded(Kind.ARGUMENT, directive, { - argument: newArg, - field, - type, - }), + directiveUsageAdded( + Kind.ARGUMENT, + directive, + { + argument: newArg, + field, + type, + }, + oldArg === null, + ), ); }, onRemoved(directive) { addChange( - directiveUsageRemoved(Kind.ARGUMENT, directive, { argument: oldArg, field, type }), + 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 91109c475f..049147b433 100644 --- a/packages/core/src/diff/changes/argument.ts +++ b/packages/core/src/diff/changes/argument.ts @@ -33,7 +33,7 @@ export function fieldArgumentDescriptionChangedFromMeta( export function fieldArgumentDescriptionChanged( type: GraphQLObjectType | GraphQLInterfaceType, field: GraphQLField, - oldArg: GraphQLArgument, + oldArg: GraphQLArgument | null, newArg: GraphQLArgument, ): Change { return fieldArgumentDescriptionChangedFromMeta({ @@ -41,8 +41,8 @@ export function fieldArgumentDescriptionChanged( meta: { typeName: type.name, fieldName: field.name, - argumentName: oldArg.name, - oldDescription: oldArg.description ?? null, + argumentName: newArg.name, + oldDescription: oldArg?.description ?? null, newDescription: newArg.description ?? null, }, }); @@ -75,7 +75,7 @@ export function fieldArgumentDefaultChangedFromMeta(args: FieldArgumentDefaultCh export function fieldArgumentDefaultChanged( type: GraphQLObjectType | GraphQLInterfaceType, field: GraphQLField, - oldArg: GraphQLArgument, + oldArg: GraphQLArgument | null, newArg: GraphQLArgument, ): Change { const meta: FieldArgumentDefaultChangedChange['meta'] = { @@ -84,7 +84,7 @@ export function fieldArgumentDefaultChanged( argumentName: newArg.name, }; - if (oldArg.defaultValue !== undefined) { + if (oldArg?.defaultValue !== undefined) { meta.oldDefaultValue = safeString(oldArg.defaultValue); } if (newArg.defaultValue !== undefined) { @@ -127,7 +127,7 @@ export function fieldArgumentTypeChangedFromMeta(args: FieldArgumentTypeChangedC export function fieldArgumentTypeChanged( type: GraphQLObjectType | GraphQLInterfaceType, field: GraphQLField, - oldArg: GraphQLArgument, + oldArg: GraphQLArgument | null, newArg: GraphQLArgument, ): Change { return fieldArgumentTypeChangedFromMeta({ @@ -136,9 +136,9 @@ export function fieldArgumentTypeChanged( typeName: type.name, fieldName: field.name, argumentName: newArg.name, - oldArgumentType: oldArg.type.toString(), + oldArgumentType: oldArg?.type.toString() ?? '', newArgumentType: newArg.type.toString(), - isSafeArgumentTypeChange: safeChangeForInputValue(oldArg.type, newArg.type), + isSafeArgumentTypeChange: !oldArg || safeChangeForInputValue(oldArg.type, newArg.type), }, }); } diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index 8ef6ea7181..c210da2bd6 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -1,3 +1,5 @@ +import { Kind } from 'graphql'; + export enum CriticalityLevel { Breaking = 'BREAKING', NonBreaking = 'NON_BREAKING', @@ -193,6 +195,7 @@ export type DirectiveArgumentAddedChange = { directiveName: string; addedDirectiveArgumentName: string; addedDirectiveArgumentTypeIsNonNull: boolean; + addedToNewDirective: boolean; }; }; @@ -252,6 +255,7 @@ export type EnumValueAddedChange = { meta: { enumName: string; addedEnumValueName: string; + addedToNewType: boolean; }; }; @@ -311,6 +315,7 @@ export type FieldAddedChange = { typeName: string; addedFieldName: string; typeType: string; + addedFieldReturnType: string; }; }; @@ -346,6 +351,7 @@ export type FieldDeprecationAddedChange = { meta: { typeName: string; fieldName: string; + deprecationReason: string; }; }; @@ -401,6 +407,7 @@ export type DirectiveUsageUnionMemberAddedChange = { unionName: string; addedUnionMemberTypeName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -453,6 +460,8 @@ export type InputFieldAddedChange = { addedInputFieldName: string; isAddedInputFieldTypeNullable: boolean; addedInputFieldType: string; + addedFieldDefault?: string; + addedToNewType: boolean; }; }; @@ -512,6 +521,7 @@ export type ObjectTypeInterfaceAddedChange = { meta: { objectTypeName: string; addedInterfaceName: string; + addedToNewType: boolean; }; }; @@ -558,11 +568,26 @@ export type TypeRemovedChange = { }; }; +type TypeAddedMeta = { + addedTypeName: string; + addedTypeKind: K; +}; + +type InputAddedMeta = TypeAddedMeta & { + addedTypeIsOneOf: boolean; +}; + export type TypeAddedChange = { type: typeof ChangeType.TypeAdded; - meta: { - addedTypeName: string; - }; + meta: + | InputAddedMeta + | TypeAddedMeta< + | Kind.ENUM_TYPE_DEFINITION + | Kind.OBJECT_TYPE_DEFINITION + | Kind.INTERFACE_TYPE_DEFINITION + | Kind.UNION_TYPE_DEFINITION + | Kind.SCALAR_TYPE_DEFINITION + >; }; export type TypeKindChangedChange = { @@ -614,6 +639,7 @@ export type UnionMemberAddedChange = { meta: { unionName: string; addedUnionMemberTypeName: string; + addedToNewType: boolean; }; }; @@ -624,6 +650,7 @@ export type DirectiveUsageEnumAddedChange = { meta: { enumName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -641,6 +668,7 @@ export type DirectiveUsageEnumValueAddedChange = { enumName: string; enumValueName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -672,6 +700,7 @@ export type DirectiveUsageInputObjectAddedChange = { isAddedInputFieldTypeNullable: boolean; addedInputFieldType: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -681,6 +710,7 @@ export type DirectiveUsageInputFieldDefinitionAddedChange = { inputObjectName: string; inputFieldName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -716,6 +746,7 @@ export type DirectiveUsageScalarAddedChange = { meta: { scalarName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -732,6 +763,7 @@ export type DirectiveUsageObjectAddedChange = { meta: { objectName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -748,6 +780,7 @@ export type DirectiveUsageInterfaceAddedChange = { meta: { interfaceName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -756,6 +789,7 @@ export type DirectiveUsageSchemaAddedChange = { meta: { addedDirectiveName: string; schemaTypeName: string; + addedToNewType: boolean; }; }; @@ -773,6 +807,7 @@ export type DirectiveUsageFieldDefinitionAddedChange = { typeName: string; fieldName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -792,6 +827,7 @@ export type DirectiveUsageArgumentDefinitionChange = { fieldName: string; argumentName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 00e0b165d4..0ca2ea863a 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -138,7 +138,9 @@ export function directiveUsageArgumentDefinitionAddedFromMeta( ) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to argument '${args.meta.argumentName}'`, }, type: ChangeType.DirectiveUsageArgumentDefinitionAdded, @@ -188,7 +190,9 @@ function buildDirectiveUsageInputObjectAddedMessage( export function directiveUsageInputObjectAddedFromMeta(args: DirectiveUsageInputObjectAddedChange) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to input object '${args.meta.inputObjectName}'`, }, type: ChangeType.DirectiveUsageInputObjectAdded, @@ -228,7 +232,9 @@ function buildDirectiveUsageInterfaceAddedMessage( export function directiveUsageInterfaceAddedFromMeta(args: DirectiveUsageInterfaceAddedChange) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to interface '${args.meta.interfaceName}'`, }, type: ChangeType.DirectiveUsageInterfaceAdded, @@ -268,7 +274,9 @@ export function directiveUsageInputFieldDefinitionAddedFromMeta( ) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to input field '${args.meta.inputFieldName}'`, }, type: ChangeType.DirectiveUsageInputFieldDefinitionAdded, @@ -314,7 +322,9 @@ function buildDirectiveUsageObjectAddedMessage( export function directiveUsageObjectAddedFromMeta(args: DirectiveUsageObjectAddedChange) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to object '${args.meta.objectName}'`, }, type: ChangeType.DirectiveUsageObjectAdded, @@ -350,7 +360,9 @@ function buildDirectiveUsageEnumAddedMessage(args: DirectiveUsageEnumAddedChange export function directiveUsageEnumAddedFromMeta(args: DirectiveUsageEnumAddedChange) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to enum '${args.meta.enumName}'`, }, type: ChangeType.DirectiveUsageEnumAdded, @@ -390,7 +402,9 @@ export function directiveUsageFieldDefinitionAddedFromMeta( ) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to field '${args.meta.fieldName}'`, }, type: ChangeType.DirectiveUsageFieldDefinitionAdded, @@ -430,7 +444,9 @@ function buildDirectiveUsageEnumValueAddedMessage( export function directiveUsageEnumValueAddedFromMeta(args: DirectiveUsageEnumValueAddedChange) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to enum value '${args.meta.enumName}.${args.meta.enumValueName}'`, }, type: ChangeType.DirectiveUsageEnumValueAdded, @@ -468,7 +484,9 @@ function buildDirectiveUsageSchemaAddedMessage( export function directiveUsageSchemaAddedFromMeta(args: DirectiveUsageSchemaAddedChange) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to schema '${args.meta.schemaTypeName}'`, }, type: ChangeType.DirectiveUsageSchemaAdded, @@ -506,7 +524,9 @@ function buildDirectiveUsageScalarAddedMessage( export function directiveUsageScalarAddedFromMeta(args: DirectiveUsageScalarAddedChange) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to scalar '${args.meta.scalarName}'`, }, type: ChangeType.DirectiveUsageScalarAdded, @@ -544,7 +564,9 @@ function buildDirectiveUsageUnionMemberAddedMessage( export function directiveUsageUnionMemberAddedFromMeta(args: DirectiveUsageUnionMemberAddedChange) { return { criticality: { - level: addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), + level: args.meta.addedToNewType + ? CriticalityLevel.NonBreaking + : addedSpecialDirective(args.meta.addedDirectiveName, CriticalityLevel.Dangerous), reason: `Directive '${args.meta.addedDirectiveName}' was added to union member '${args.meta.unionName}.${args.meta.addedUnionMemberTypeName}'`, }, type: ChangeType.DirectiveUsageUnionMemberAdded, @@ -579,6 +601,7 @@ export function directiveUsageAdded( kind: K, directive: ConstDirectiveNode, payload: KindToPayload[K]['input'], + addedToNewType: boolean, ): Change { if (isOfKind(kind, Kind.ARGUMENT, payload)) { return directiveUsageArgumentDefinitionAddedFromMeta({ @@ -588,6 +611,7 @@ export function directiveUsageAdded( argumentName: payload.argument.name, fieldName: payload.field.name, typeName: payload.type.name, + addedToNewType, }, }); } @@ -598,6 +622,7 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, inputFieldName: payload.field.name, inputObjectName: payload.type.name, + addedToNewType, }, }); } @@ -610,6 +635,7 @@ export function directiveUsageAdded( addedInputFieldType: payload.name, inputObjectName: payload.name, isAddedInputFieldTypeNullable: kind === Kind.INPUT_VALUE_DEFINITION, + addedToNewType, }, }); } @@ -619,6 +645,7 @@ export function directiveUsageAdded( meta: { addedDirectiveName: directive.name.value, interfaceName: payload.name, + addedToNewType, }, }); } @@ -628,6 +655,7 @@ export function directiveUsageAdded( meta: { objectName: payload.name, addedDirectiveName: directive.name.value, + addedToNewType, }, }); } @@ -637,6 +665,7 @@ export function directiveUsageAdded( meta: { enumName: payload.name, addedDirectiveName: directive.name.value, + addedToNewType, }, }); } @@ -647,6 +676,7 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, fieldName: payload.field.name, typeName: payload.parentType.name, + addedToNewType, }, }); } @@ -657,6 +687,7 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, addedUnionMemberTypeName: payload.name, unionName: payload.name, + addedToNewType, }, }); } @@ -667,6 +698,7 @@ export function directiveUsageAdded( enumName: payload.type.name, enumValueName: payload.value.name, addedDirectiveName: directive.name.value, + addedToNewType, }, }); } @@ -676,6 +708,7 @@ export function directiveUsageAdded( meta: { addedDirectiveName: directive.name.value, schemaTypeName: payload.getQueryType()?.name || '', + addedToNewType, }, }); } @@ -685,6 +718,7 @@ export function directiveUsageAdded( meta: { scalarName: payload.name, addedDirectiveName: directive.name.value, + addedToNewType, }, }); } diff --git a/packages/core/src/diff/changes/directive.ts b/packages/core/src/diff/changes/directive.ts index 06392d1bdd..2bbec3ca5b 100644 --- a/packages/core/src/diff/changes/directive.ts +++ b/packages/core/src/diff/changes/directive.ts @@ -95,14 +95,14 @@ export function directiveDescriptionChangedFromMeta(args: DirectiveDescriptionCh } export function directiveDescriptionChanged( - oldDirective: GraphQLDirective, + oldDirective: GraphQLDirective | null, newDirective: GraphQLDirective, ): Change { return directiveDescriptionChangedFromMeta({ type: ChangeType.DirectiveDescriptionChanged, meta: { - directiveName: oldDirective.name, - oldDirectiveDescription: oldDirective.description ?? null, + directiveName: newDirective.name, + oldDirectiveDescription: oldDirective?.description ?? null, newDirectiveDescription: newDirective.description ?? null, }, }); @@ -172,19 +172,25 @@ export function directiveLocationRemoved( } const directiveArgumentAddedBreakingReason = `A directive could be in use of a client application. Adding a non-nullable argument will break those clients.`; -const directiveArgumentNonBreakingReason = `A directive could be in use of a client application. Adding a non-nullable argument will break those clients.`; +const directiveArgumentNonBreakingReason = `A directive could be in use of a client application. Adding a nullable argument will not break those clients.`; +const directiveArgumentNewReason = `Refer to the directive usage for the breaking status. If the directive is new and therefore unused, then adding an argument does not risk breaking clients.`; export function directiveArgumentAddedFromMeta(args: DirectiveArgumentAddedChange) { return { - criticality: args.meta.addedDirectiveArgumentTypeIsNonNull + criticality: args.meta.addedToNewDirective ? { - level: CriticalityLevel.Breaking, - reason: directiveArgumentAddedBreakingReason, - } - : { level: CriticalityLevel.NonBreaking, - reason: directiveArgumentNonBreakingReason, - }, + reason: directiveArgumentNewReason, + } + : args.meta.addedDirectiveArgumentTypeIsNonNull + ? { + level: CriticalityLevel.Breaking, + reason: directiveArgumentAddedBreakingReason, + } + : { + level: CriticalityLevel.NonBreaking, + reason: directiveArgumentNonBreakingReason, + }, type: ChangeType.DirectiveArgumentAdded, message: `Argument '${args.meta.addedDirectiveArgumentName}' was added to directive '${args.meta.directiveName}'`, path: `@${args.meta.directiveName}`, @@ -195,6 +201,7 @@ export function directiveArgumentAddedFromMeta(args: DirectiveArgumentAddedChang export function directiveArgumentAdded( directive: GraphQLDirective, arg: GraphQLArgument, + addedToNewDirective: boolean, ): Change { return directiveArgumentAddedFromMeta({ type: ChangeType.DirectiveArgumentAdded, @@ -202,6 +209,7 @@ export function directiveArgumentAdded( directiveName: directive.name, addedDirectiveArgumentName: arg.name, addedDirectiveArgumentTypeIsNonNull: isNonNullType(arg.type), + addedToNewDirective, }, }); } @@ -262,15 +270,15 @@ export function directiveArgumentDescriptionChangedFromMeta( export function directiveArgumentDescriptionChanged( directive: GraphQLDirective, - oldArg: GraphQLArgument, + oldArg: GraphQLArgument | null, newArg: GraphQLArgument, ): Change { return directiveArgumentDescriptionChangedFromMeta({ type: ChangeType.DirectiveArgumentDescriptionChanged, meta: { directiveName: directive.name, - directiveArgumentName: oldArg.name, - oldDirectiveArgumentDescription: oldArg.description ?? null, + directiveArgumentName: newArg.name, + oldDirectiveArgumentDescription: oldArg?.description ?? null, newDirectiveArgumentDescription: newArg.description ?? null, }, }); @@ -304,14 +312,14 @@ export function directiveArgumentDefaultValueChangedFromMeta( export function directiveArgumentDefaultValueChanged( directive: GraphQLDirective, - oldArg: GraphQLArgument, + oldArg: GraphQLArgument | null, newArg: GraphQLArgument, ): Change { const meta: DirectiveArgumentDefaultValueChangedChange['meta'] = { directiveName: directive.name, - directiveArgumentName: oldArg.name, + directiveArgumentName: newArg.name, }; - if (oldArg.defaultValue !== undefined) { + if (oldArg?.defaultValue !== undefined) { meta.oldDirectiveArgumentDefaultValue = safeString(oldArg.defaultValue); } if (newArg.defaultValue !== undefined) { @@ -352,17 +360,17 @@ export function directiveArgumentTypeChangedFromMeta(args: DirectiveArgumentType export function directiveArgumentTypeChanged( directive: GraphQLDirective, - oldArg: GraphQLArgument, + oldArg: GraphQLArgument | null, newArg: GraphQLArgument, ): Change { return directiveArgumentTypeChangedFromMeta({ type: ChangeType.DirectiveArgumentTypeChanged, meta: { directiveName: directive.name, - directiveArgumentName: oldArg.name, - oldDirectiveArgumentType: oldArg.type.toString(), + directiveArgumentName: newArg.name, + oldDirectiveArgumentType: oldArg?.type.toString() ?? '', newDirectiveArgumentType: newArg.type.toString(), - isSafeDirectiveArgumentTypeChange: safeChangeForInputValue(oldArg.type, newArg.type), + isSafeDirectiveArgumentTypeChange: safeChangeForInputValue(oldArg?.type ?? null, newArg.type), }, }); } diff --git a/packages/core/src/diff/changes/enum.ts b/packages/core/src/diff/changes/enum.ts index cf6c74dd39..1a28608a5b 100644 --- a/packages/core/src/diff/changes/enum.ts +++ b/packages/core/src/diff/changes/enum.ts @@ -54,11 +54,13 @@ function buildEnumValueAddedMessage(args: EnumValueAddedChange) { const enumValueAddedCriticalityDangerousReason = `Adding an enum value may break existing clients that were not programming defensively against an added case when querying an enum.`; export function enumValueAddedFromMeta(args: EnumValueAddedChange) { + /** Dangerous is there was a previous enum value */ + const isSafe = args.meta.addedToNewType; return { type: ChangeType.EnumValueAdded, criticality: { - level: CriticalityLevel.Dangerous, - reason: enumValueAddedCriticalityDangerousReason, + level: isSafe ? CriticalityLevel.NonBreaking : CriticalityLevel.Dangerous, + reason: isSafe ? undefined : enumValueAddedCriticalityDangerousReason, }, message: buildEnumValueAddedMessage(args), meta: args.meta, @@ -67,14 +69,16 @@ export function enumValueAddedFromMeta(args: EnumValueAddedChange) { } export function enumValueAdded( - newEnum: GraphQLEnumType, + type: GraphQLEnumType, value: GraphQLEnumValue, + addedToNewType: boolean, ): Change { return enumValueAddedFromMeta({ type: ChangeType.EnumValueAdded, meta: { - enumName: newEnum.name, + enumName: type.name, addedEnumValueName: value.name, + addedToNewType, }, }); } @@ -105,15 +109,15 @@ export function enumValueDescriptionChangedFromMeta( export function enumValueDescriptionChanged( newEnum: GraphQLEnumType, - oldValue: GraphQLEnumValue, + oldValue: GraphQLEnumValue | null, newValue: GraphQLEnumValue, ): Change { return enumValueDescriptionChangedFromMeta({ type: ChangeType.EnumValueDescriptionChanged, meta: { enumName: newEnum.name, - enumValueName: oldValue.name, - oldEnumValueDescription: oldValue.description ?? null, + enumValueName: newValue.name, + oldEnumValueDescription: oldValue?.description ?? null, newEnumValueDescription: newValue.description ?? null, }, }); @@ -177,14 +181,14 @@ export function enumValueDeprecationReasonAddedFromMeta( export function enumValueDeprecationReasonAdded( newEnum: GraphQLEnumType, - oldValue: GraphQLEnumValue, + _oldValue: GraphQLEnumValue | null, newValue: GraphQLEnumValue, ): Change { return enumValueDeprecationReasonAddedFromMeta({ type: ChangeType.EnumValueDeprecationReasonAdded, meta: { enumName: newEnum.name, - enumValueName: oldValue.name, + enumValueName: newValue.name, addedValueDeprecationReason: newValue.deprecationReason ?? '', }, }); diff --git a/packages/core/src/diff/changes/field.ts b/packages/core/src/diff/changes/field.ts index 8e3fcbeaaa..83a91d408a 100644 --- a/packages/core/src/diff/changes/field.ts +++ b/packages/core/src/diff/changes/field.ts @@ -5,6 +5,7 @@ import { GraphQLObjectType, isInterfaceType, isNonNullType, + print, } from 'graphql'; import { safeChangeForField } from '../../utils/graphql.js'; import { @@ -90,6 +91,7 @@ export function fieldAdded( typeName: type.name, addedFieldName: field.name, typeType: entity, + addedFieldReturnType: field.astNode?.type ? print(field.astNode?.type) : '', }, }); } @@ -210,6 +212,7 @@ export function fieldDeprecationAdded( meta: { typeName: type.name, fieldName: field.name, + deprecationReason: field.deprecationReason ?? '', }, }); } @@ -218,6 +221,7 @@ export function fieldDeprecationRemovedFromMeta(args: FieldDeprecationRemovedCha return { type: ChangeType.FieldDeprecationRemoved, criticality: { + // @todo: Add a reason for why is this dangerous... Why is it?? level: CriticalityLevel.Dangerous, }, message: `Field '${args.meta.typeName}.${args.meta.fieldName}' is no longer deprecated`, diff --git a/packages/core/src/diff/changes/input.ts b/packages/core/src/diff/changes/input.ts index a4c0395800..6309c5939d 100644 --- a/packages/core/src/diff/changes/input.ts +++ b/packages/core/src/diff/changes/input.ts @@ -1,4 +1,5 @@ import { GraphQLInputField, GraphQLInputObjectType, isNonNullType } from 'graphql'; +import { isVoid } from '../../utils/compare.js'; import { safeChangeForInputValue } from '../../utils/graphql.js'; import { isDeprecated } from '../../utils/is-deprecated.js'; import { safeString } from '../../utils/string.js'; @@ -50,21 +51,25 @@ export function inputFieldRemoved( } export function buildInputFieldAddedMessage(args: InputFieldAddedChange['meta']) { - return `Input field '${args.addedInputFieldName}' of type '${args.addedInputFieldType}' was added to input object type '${args.inputName}'`; + return `Input field '${args.addedInputFieldName}' of type '${args.addedInputFieldType}'${args.addedFieldDefault ? ` with default value '${args.addedFieldDefault}'` : ''} was added to input object type '${args.inputName}'`; } export function inputFieldAddedFromMeta(args: InputFieldAddedChange) { return { type: ChangeType.InputFieldAdded, - criticality: args.meta.isAddedInputFieldTypeNullable + criticality: args.meta.addedToNewType ? { - level: CriticalityLevel.Dangerous, + level: CriticalityLevel.NonBreaking, } - : { - level: CriticalityLevel.Breaking, - reason: - 'Adding a required input field to an existing input object type is a breaking change because it will cause existing uses of this input object type to error.', - }, + : args.meta.isAddedInputFieldTypeNullable || args.meta.addedFieldDefault !== undefined + ? { + level: CriticalityLevel.Dangerous, + } + : { + level: CriticalityLevel.Breaking, + reason: + 'Adding a required input field to an existing input object type is a breaking change because it will cause existing uses of this input object type to error.', + }, message: buildInputFieldAddedMessage(args.meta), meta: args.meta, path: [args.meta.inputName, args.meta.addedInputFieldName].join('.'), @@ -74,6 +79,7 @@ export function inputFieldAddedFromMeta(args: InputFieldAddedChange) { export function inputFieldAdded( input: GraphQLInputObjectType, field: GraphQLInputField, + addedToNewType: boolean, ): Change { return inputFieldAddedFromMeta({ type: ChangeType.InputFieldAdded, @@ -82,6 +88,10 @@ export function inputFieldAdded( addedInputFieldName: field.name, isAddedInputFieldTypeNullable: !isNonNullType(field.type), addedInputFieldType: field.type.toString(), + ...(field.defaultValue === undefined + ? {} + : { addedFieldDefault: safeString(field.defaultValue) }), + addedToNewType, }, }); } @@ -189,13 +199,14 @@ function buildInputFieldDefaultValueChangedMessage( } export function inputFieldDefaultValueChangedFromMeta(args: InputFieldDefaultValueChangedChange) { + const criticality = { + level: CriticalityLevel.Dangerous, + reason: + 'Changing the default value for an argument may change the runtime behavior of a field if it was never provided.', + }; return { type: ChangeType.InputFieldDefaultValueChanged, - criticality: { - level: CriticalityLevel.Dangerous, - reason: - 'Changing the default value for an argument may change the runtime behavior of a field if it was never provided.', - }, + criticality, message: buildInputFieldDefaultValueChangedMessage(args.meta), meta: args.meta, path: [args.meta.inputName, args.meta.inputFieldName].join('.'), @@ -209,7 +220,7 @@ export function inputFieldDefaultValueChanged( ): Change { const meta: InputFieldDefaultValueChangedChange['meta'] = { inputName: input.name, - inputFieldName: oldField.name, + inputFieldName: newField.name, }; if (oldField.defaultValue !== undefined) { @@ -256,7 +267,7 @@ export function inputFieldTypeChanged( type: ChangeType.InputFieldTypeChanged, meta: { inputName: input.name, - inputFieldName: oldField.name, + inputFieldName: newField.name, oldInputFieldType: oldField.type.toString(), newInputFieldType: newField.type.toString(), isInputFieldTypeChangeSafe: safeChangeForInputValue(oldField.type, newField.type), diff --git a/packages/core/src/diff/changes/object.ts b/packages/core/src/diff/changes/object.ts index babbc79da2..36ab895464 100644 --- a/packages/core/src/diff/changes/object.ts +++ b/packages/core/src/diff/changes/object.ts @@ -15,7 +15,7 @@ export function objectTypeInterfaceAddedFromMeta(args: ObjectTypeInterfaceAddedC return { type: ChangeType.ObjectTypeInterfaceAdded, criticality: { - level: CriticalityLevel.Dangerous, + level: args.meta.addedToNewType ? CriticalityLevel.NonBreaking : CriticalityLevel.Dangerous, reason: 'Adding an interface to an object type may break existing clients that were not programming defensively against a new possible type.', }, @@ -27,13 +27,15 @@ export function objectTypeInterfaceAddedFromMeta(args: ObjectTypeInterfaceAddedC export function objectTypeInterfaceAdded( iface: GraphQLInterfaceType, - type: GraphQLObjectType, + type: GraphQLObjectType | GraphQLInterfaceType, + addedToNewType: boolean, ): Change { return objectTypeInterfaceAddedFromMeta({ type: ChangeType.ObjectTypeInterfaceAdded, meta: { objectTypeName: type.name, addedInterfaceName: iface.name, + addedToNewType, }, }); } @@ -58,7 +60,7 @@ export function objectTypeInterfaceRemovedFromMeta(args: ObjectTypeInterfaceRemo export function objectTypeInterfaceRemoved( iface: GraphQLInterfaceType, - type: GraphQLObjectType, + type: GraphQLObjectType | GraphQLInterfaceType, ): Change { return objectTypeInterfaceRemovedFromMeta({ type: ChangeType.ObjectTypeInterfaceRemoved, diff --git a/packages/core/src/diff/changes/type.ts b/packages/core/src/diff/changes/type.ts index 7149969da4..1610b85a38 100644 --- a/packages/core/src/diff/changes/type.ts +++ b/packages/core/src/diff/changes/type.ts @@ -1,4 +1,12 @@ -import { GraphQLNamedType } from 'graphql'; +import { + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isUnionType, + Kind, + type GraphQLNamedType, +} from 'graphql'; import { getKind } from '../../utils/graphql.js'; import { Change, @@ -53,12 +61,44 @@ export function typeAddedFromMeta(args: TypeAddedChange) { } as const; } +function addedTypeMeta(type: GraphQLNamedType): TypeAddedChange['meta'] { + if (isEnumType(type)) { + return { + addedTypeKind: Kind.ENUM_TYPE_DEFINITION, + addedTypeName: type.name, + }; + } + if (isObjectType(type) || isInterfaceType(type)) { + return { + addedTypeKind: getKind(type) as any as + | Kind.INTERFACE_TYPE_DEFINITION + | Kind.OBJECT_TYPE_DEFINITION, + addedTypeName: type.name, + }; + } + if (isUnionType(type)) { + return { + addedTypeKind: Kind.UNION_TYPE_DEFINITION, + addedTypeName: type.name, + }; + } + if (isInputObjectType(type)) { + return { + addedTypeKind: Kind.INPUT_OBJECT_TYPE_DEFINITION, + addedTypeIsOneOf: type.isOneOf, + addedTypeName: type.name, + }; + } + return { + addedTypeKind: getKind(type) as any as Kind.SCALAR_TYPE_DEFINITION, + addedTypeName: type.name, + }; +} + export function typeAdded(type: GraphQLNamedType): Change { return typeAddedFromMeta({ type: ChangeType.TypeAdded, - meta: { - addedTypeName: type.name, - }, + meta: addedTypeMeta(type), }); } @@ -80,15 +120,15 @@ export function typeKindChangedFromMeta(args: TypeKindChangedChange) { } export function typeKindChanged( - oldType: GraphQLNamedType, + oldType: GraphQLNamedType | null, newType: GraphQLNamedType, ): Change { return typeKindChangedFromMeta({ type: ChangeType.TypeKindChanged, meta: { - typeName: oldType.name, + typeName: newType.name, newTypeKind: String(getKind(newType)), - oldTypeKind: String(getKind(oldType)), + oldTypeKind: oldType ? String(getKind(oldType)) : '', }, }); } diff --git a/packages/core/src/diff/changes/union.ts b/packages/core/src/diff/changes/union.ts index afede6b89b..9357d05042 100644 --- a/packages/core/src/diff/changes/union.ts +++ b/packages/core/src/diff/changes/union.ts @@ -45,7 +45,7 @@ function buildUnionMemberAddedMessage(args: UnionMemberAddedChange['meta']) { export function buildUnionMemberAddedMessageFromMeta(args: UnionMemberAddedChange) { return { criticality: { - level: CriticalityLevel.Dangerous, + level: args.meta.addedToNewType ? CriticalityLevel.NonBreaking : CriticalityLevel.Dangerous, reason: 'Adding a possible type to Unions may break existing clients that were not programming defensively against a new possible type.', }, @@ -59,12 +59,14 @@ export function buildUnionMemberAddedMessageFromMeta(args: UnionMemberAddedChang export function unionMemberAdded( union: GraphQLUnionType, type: GraphQLObjectType, + addedToNewType: boolean, ): Change { return buildUnionMemberAddedMessageFromMeta({ type: ChangeType.UnionMemberAdded, meta: { unionName: union.name, addedUnionMemberTypeName: type.name, + addedToNewType, }, }); } diff --git a/packages/core/src/diff/directive.ts b/packages/core/src/diff/directive.ts index 035325da99..2d51e872d7 100644 --- a/packages/core/src/diff/directive.ts +++ b/packages/core/src/diff/directive.ts @@ -13,17 +13,17 @@ import { import { AddChange } from './schema.js'; export function changesInDirective( - oldDirective: GraphQLDirective, + oldDirective: GraphQLDirective | null, newDirective: GraphQLDirective, addChange: AddChange, ) { - if (isNotEqual(oldDirective.description, newDirective.description)) { + if (isNotEqual(oldDirective?.description, newDirective.description)) { addChange(directiveDescriptionChanged(oldDirective, newDirective)); } const locations = { - added: diffArrays(newDirective.locations, oldDirective.locations), - removed: diffArrays(oldDirective.locations, newDirective.locations), + added: diffArrays(newDirective.locations, oldDirective?.locations ?? []), + removed: diffArrays(oldDirective?.locations ?? [], newDirective.locations), }; // locations added @@ -32,36 +32,37 @@ export function changesInDirective( // locations removed for (const location of locations.removed) - addChange(directiveLocationRemoved(oldDirective, location as any)); + addChange(directiveLocationRemoved(newDirective, location as any)); - compareLists(oldDirective.args, newDirective.args, { + compareLists(oldDirective?.args ?? [], newDirective.args, { onAdded(arg) { - addChange(directiveArgumentAdded(newDirective, arg)); + addChange(directiveArgumentAdded(newDirective, arg, oldDirective === null)); + changesInDirectiveArgument(newDirective, null, arg, addChange); }, onRemoved(arg) { - addChange(directiveArgumentRemoved(oldDirective, arg)); + addChange(directiveArgumentRemoved(newDirective, arg)); }, onMutual(arg) { - changesInDirectiveArgument(oldDirective, arg.oldVersion, arg.newVersion, addChange); + changesInDirectiveArgument(newDirective, arg.oldVersion, arg.newVersion, addChange); }, }); } function changesInDirectiveArgument( directive: GraphQLDirective, - oldArg: GraphQLArgument, + oldArg: GraphQLArgument | null, newArg: GraphQLArgument, addChange: AddChange, ) { - if (isNotEqual(oldArg.description, newArg.description)) { + if (isNotEqual(oldArg?.description, newArg.description)) { addChange(directiveArgumentDescriptionChanged(directive, oldArg, newArg)); } - if (isNotEqual(oldArg.defaultValue, newArg.defaultValue)) { + if (isNotEqual(oldArg?.defaultValue, newArg.defaultValue)) { addChange(directiveArgumentDefaultValueChanged(directive, oldArg, newArg)); } - if (isNotEqual(oldArg.type.toString(), newArg.type.toString())) { + if (isNotEqual(oldArg?.type.toString(), newArg.type.toString())) { addChange(directiveArgumentTypeChanged(directive, oldArg, newArg)); } } diff --git a/packages/core/src/diff/enum.ts b/packages/core/src/diff/enum.ts index 1be7b0dacf..7fef009f4e 100644 --- a/packages/core/src/diff/enum.ts +++ b/packages/core/src/diff/enum.ts @@ -1,4 +1,4 @@ -import { GraphQLEnumType, Kind } from 'graphql'; +import { GraphQLEnumType, GraphQLEnumValue, Kind } from 'graphql'; import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; import { @@ -12,62 +12,81 @@ import { import { AddChange } from './schema.js'; export function changesInEnum( - oldEnum: GraphQLEnumType, + oldEnum: GraphQLEnumType | null, newEnum: GraphQLEnumType, addChange: AddChange, ) { - compareLists(oldEnum.getValues(), newEnum.getValues(), { + compareLists(oldEnum?.getValues() ?? [], newEnum.getValues(), { onAdded(value) { - addChange(enumValueAdded(newEnum, value)); + addChange(enumValueAdded(newEnum, value, oldEnum === null)); + changesInEnumValue({ newVersion: value, oldVersion: null }, newEnum, addChange); }, onRemoved(value) { - addChange(enumValueRemoved(oldEnum, value)); + addChange(enumValueRemoved(oldEnum!, value)); }, onMutual(value) { - const oldValue = value.oldVersion; - const newValue = value.newVersion; - - if (isNotEqual(oldValue.description, newValue.description)) { - addChange(enumValueDescriptionChanged(newEnum, oldValue, newValue)); - } - - if (isNotEqual(oldValue.deprecationReason, newValue.deprecationReason)) { - if (isVoid(oldValue.deprecationReason)) { - addChange(enumValueDeprecationReasonAdded(newEnum, oldValue, newValue)); - } else if (isVoid(newValue.deprecationReason)) { - addChange(enumValueDeprecationReasonRemoved(newEnum, oldValue, newValue)); - } else { - addChange(enumValueDeprecationReasonChanged(newEnum, oldValue, newValue)); - } - } - - compareLists(oldValue.astNode?.directives || [], newValue.astNode?.directives || [], { - onAdded(directive) { - addChange( - directiveUsageAdded(Kind.ENUM_VALUE_DEFINITION, directive, { - type: newEnum, - value: newValue, - }), - ); - }, - onRemoved(directive) { - addChange( - directiveUsageRemoved(Kind.ENUM_VALUE_DEFINITION, directive, { - type: oldEnum, - value: oldValue, - }), - ); - }, - }); + changesInEnumValue(value, newEnum, addChange); }, }); - compareLists(oldEnum.astNode?.directives || [], newEnum.astNode?.directives || [], { + compareLists(oldEnum?.astNode?.directives || [], newEnum.astNode?.directives || [], { onAdded(directive) { - addChange(directiveUsageAdded(Kind.ENUM_TYPE_DEFINITION, directive, newEnum)); + addChange( + directiveUsageAdded(Kind.ENUM_TYPE_DEFINITION, directive, newEnum, oldEnum === null), + ); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.ENUM_TYPE_DEFINITION, directive, newEnum)); }, }); } + +function changesInEnumValue( + value: { + newVersion: GraphQLEnumValue; + oldVersion: GraphQLEnumValue | null; + }, + newEnum: GraphQLEnumType, + addChange: AddChange, +) { + const oldValue = value.oldVersion; + const newValue = value.newVersion; + + if (isNotEqual(oldValue?.description, newValue.description)) { + addChange(enumValueDescriptionChanged(newEnum, oldValue, newValue)); + } + + if (isNotEqual(oldValue?.deprecationReason, newValue.deprecationReason)) { + if (isVoid(oldValue?.deprecationReason)) { + addChange(enumValueDeprecationReasonAdded(newEnum, oldValue, newValue)); + } else if (isVoid(newValue.deprecationReason)) { + addChange(enumValueDeprecationReasonRemoved(newEnum, oldValue, newValue)); + } else { + addChange(enumValueDeprecationReasonChanged(newEnum, oldValue, newValue)); + } + } + + compareLists(oldValue?.astNode?.directives || [], newValue.astNode?.directives || [], { + onAdded(directive) { + addChange( + directiveUsageAdded( + Kind.ENUM_VALUE_DEFINITION, + directive, + { + type: newEnum, + value: newValue, + }, + oldValue === null, + ), + ); + }, + onRemoved(directive) { + addChange( + directiveUsageRemoved(Kind.ENUM_VALUE_DEFINITION, directive, { + type: newEnum, + value: oldValue!, + }), + ); + }, + }); +} diff --git a/packages/core/src/diff/field.ts b/packages/core/src/diff/field.ts index ff9bf07c55..7377845bfa 100644 --- a/packages/core/src/diff/field.ts +++ b/packages/core/src/diff/field.ts @@ -20,30 +20,30 @@ import { AddChange } from './schema.js'; export function changesInField( type: GraphQLObjectType | GraphQLInterfaceType, - oldField: GraphQLField, + oldField: GraphQLField | null, newField: GraphQLField, addChange: AddChange, ) { - if (isNotEqual(oldField.description, newField.description)) { - if (isVoid(oldField.description)) { + if (isNotEqual(oldField?.description, newField.description)) { + if (isVoid(oldField?.description)) { addChange(fieldDescriptionAdded(type, newField)); } else if (isVoid(newField.description)) { - addChange(fieldDescriptionRemoved(type, oldField)); + addChange(fieldDescriptionRemoved(type, oldField!)); } else { - addChange(fieldDescriptionChanged(type, oldField, newField)); + addChange(fieldDescriptionChanged(type, oldField!, newField)); } } - if (isNotEqual(isDeprecated(oldField), isDeprecated(newField))) { + if (!isVoid(oldField) && isNotEqual(isDeprecated(oldField), isDeprecated(newField))) { if (isDeprecated(newField)) { addChange(fieldDeprecationAdded(type, newField)); } else { addChange(fieldDeprecationRemoved(type, oldField)); } - } - - if (isNotEqual(oldField.deprecationReason, newField.deprecationReason)) { - if (isVoid(oldField.deprecationReason)) { + } else if (isVoid(oldField) && isDeprecated(newField)) { + addChange(fieldDeprecationAdded(type, newField)); + } else if (isNotEqual(oldField?.deprecationReason, newField.deprecationReason)) { + if (isVoid(oldField?.deprecationReason)) { addChange(fieldDeprecationReasonAdded(type, newField)); } else if (isVoid(newField.deprecationReason)) { addChange(fieldDeprecationReasonRemoved(type, oldField)); @@ -52,36 +52,41 @@ export function changesInField( } } - if (isNotEqual(oldField.type.toString(), newField.type.toString())) { - addChange(fieldTypeChanged(type, oldField, newField)); + if (!isVoid(oldField) && isNotEqual(oldField!.type.toString(), newField.type.toString())) { + addChange(fieldTypeChanged(type, oldField!, newField)); } - compareLists(oldField.args, newField.args, { + compareLists(oldField?.args ?? [], newField.args, { onAdded(arg) { addChange(fieldArgumentAdded(type, newField, arg)); }, onRemoved(arg) { - addChange(fieldArgumentRemoved(type, oldField, arg)); + addChange(fieldArgumentRemoved(type, newField, arg)); }, onMutual(arg) { - changesInArgument(type, oldField, arg.oldVersion, arg.newVersion, addChange); + changesInArgument(type, newField, arg.oldVersion, arg.newVersion, addChange); }, }); - compareLists(oldField.astNode?.directives || [], newField.astNode?.directives || [], { + compareLists(oldField?.astNode?.directives || [], newField.astNode?.directives || [], { onAdded(directive) { addChange( - directiveUsageAdded(Kind.FIELD_DEFINITION, directive, { - parentType: type, - field: newField, - }), + directiveUsageAdded( + Kind.FIELD_DEFINITION, + directive, + { + parentType: type, + field: newField, + }, + oldField === null, + ), ); }, onRemoved(arg) { addChange( directiveUsageRemoved(Kind.FIELD_DEFINITION, arg, { parentType: type, - field: oldField, + field: oldField!, }), ); }, diff --git a/packages/core/src/diff/input.ts b/packages/core/src/diff/input.ts index 7b45de560d..30049ada87 100644 --- a/packages/core/src/diff/input.ts +++ b/packages/core/src/diff/input.ts @@ -13,80 +13,95 @@ import { import { AddChange } from './schema.js'; export function changesInInputObject( - oldInput: GraphQLInputObjectType, + oldInput: GraphQLInputObjectType | null, newInput: GraphQLInputObjectType, addChange: AddChange, ) { - const oldFields = oldInput.getFields(); + const oldFields = oldInput?.getFields() ?? {}; const newFields = newInput.getFields(); compareLists(Object.values(oldFields), Object.values(newFields), { onAdded(field) { - addChange(inputFieldAdded(newInput, field)); + addChange(inputFieldAdded(newInput, field, oldInput === null)); + changesInInputField(newInput, null, field, addChange); }, onRemoved(field) { - addChange(inputFieldRemoved(oldInput, field)); + addChange(inputFieldRemoved(oldInput!, field)); }, onMutual(field) { - changesInInputField(oldInput, field.oldVersion, field.newVersion, addChange); + changesInInputField(newInput, field.oldVersion, field.newVersion, addChange); }, }); - compareLists(oldInput.astNode?.directives || [], newInput.astNode?.directives || [], { + compareLists(oldInput?.astNode?.directives || [], newInput.astNode?.directives || [], { onAdded(directive) { - addChange(directiveUsageAdded(Kind.INPUT_OBJECT_TYPE_DEFINITION, directive, newInput)); + addChange( + directiveUsageAdded( + Kind.INPUT_OBJECT_TYPE_DEFINITION, + directive, + newInput, + oldInput === null, + ), + ); }, onRemoved(directive) { - addChange(directiveUsageRemoved(Kind.INPUT_OBJECT_TYPE_DEFINITION, directive, oldInput)); + addChange(directiveUsageRemoved(Kind.INPUT_OBJECT_TYPE_DEFINITION, directive, oldInput!)); }, }); } function changesInInputField( input: GraphQLInputObjectType, - oldField: GraphQLInputField, + oldField: GraphQLInputField | null, newField: GraphQLInputField, addChange: AddChange, ) { - if (isNotEqual(oldField.description, newField.description)) { - if (isVoid(oldField.description)) { + if (isNotEqual(oldField?.description, newField.description)) { + if (isVoid(oldField?.description)) { addChange(inputFieldDescriptionAdded(input, newField)); } else if (isVoid(newField.description)) { - addChange(inputFieldDescriptionRemoved(input, oldField)); + addChange(inputFieldDescriptionRemoved(input, oldField!)); } else { - addChange(inputFieldDescriptionChanged(input, oldField, newField)); + addChange(inputFieldDescriptionChanged(input, oldField!, newField)); } } - if (isNotEqual(oldField.defaultValue, newField.defaultValue)) { - if (Array.isArray(oldField.defaultValue) && Array.isArray(newField.defaultValue)) { - if (diffArrays(oldField.defaultValue, newField.defaultValue).length > 0) { + if (!isVoid(oldField)) { + if (isNotEqual(oldField?.defaultValue, newField.defaultValue)) { + if (Array.isArray(oldField?.defaultValue) && Array.isArray(newField.defaultValue)) { + if (diffArrays(oldField.defaultValue, newField.defaultValue).length > 0) { + addChange(inputFieldDefaultValueChanged(input, oldField, newField)); + } + } else if (JSON.stringify(oldField?.defaultValue) !== JSON.stringify(newField.defaultValue)) { addChange(inputFieldDefaultValueChanged(input, oldField, newField)); } - } else if (JSON.stringify(oldField.defaultValue) !== JSON.stringify(newField.defaultValue)) { - addChange(inputFieldDefaultValueChanged(input, oldField, newField)); } - } - if (isNotEqual(oldField.type.toString(), newField.type.toString())) { - addChange(inputFieldTypeChanged(input, oldField, newField)); + if (!isVoid(oldField) && isNotEqual(oldField.type.toString(), newField.type.toString())) { + addChange(inputFieldTypeChanged(input, oldField, newField)); + } } - if (oldField.astNode?.directives && newField.astNode?.directives) { - compareLists(oldField.astNode.directives || [], newField.astNode.directives || [], { + if (newField.astNode?.directives) { + compareLists(oldField?.astNode?.directives || [], newField.astNode.directives || [], { onAdded(directive) { addChange( - directiveUsageAdded(Kind.INPUT_VALUE_DEFINITION, directive, { - type: input, - field: newField, - }), + directiveUsageAdded( + Kind.INPUT_VALUE_DEFINITION, + directive, + { + type: input, + field: newField, + }, + oldField === null, + ), ); }, onRemoved(directive) { addChange( directiveUsageRemoved(Kind.INPUT_VALUE_DEFINITION, directive, { type: input, - field: oldField, + field: newField, }), ); }, diff --git a/packages/core/src/diff/interface.ts b/packages/core/src/diff/interface.ts index ac34f74b8a..0126222131 100644 --- a/packages/core/src/diff/interface.ts +++ b/packages/core/src/diff/interface.ts @@ -2,31 +2,55 @@ import { GraphQLInterfaceType, Kind } from 'graphql'; import { compareLists } from '../utils/compare.js'; import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; import { fieldAdded, fieldRemoved } from './changes/field.js'; +import { objectTypeInterfaceAdded, objectTypeInterfaceRemoved } from './changes/object.js'; import { changesInField } from './field.js'; import { AddChange } from './schema.js'; export function changesInInterface( - oldInterface: GraphQLInterfaceType, + oldInterface: GraphQLInterfaceType | null, newInterface: GraphQLInterfaceType, addChange: AddChange, ) { - compareLists(Object.values(oldInterface.getFields()), Object.values(newInterface.getFields()), { + const oldInterfaces = oldInterface?.getInterfaces() ?? []; + const newInterfaces = newInterface.getInterfaces(); + + compareLists(oldInterfaces, newInterfaces, { + onAdded(i) { + addChange(objectTypeInterfaceAdded(i, newInterface, oldInterface === null)); + }, + onRemoved(i) { + addChange(objectTypeInterfaceRemoved(i, oldInterface!)); + }, + }); + + const oldFields = oldInterface?.getFields() ?? {}; + const newFields = newInterface.getFields(); + + compareLists(Object.values(oldFields), Object.values(newFields), { onAdded(field) { addChange(fieldAdded(newInterface, field)); + changesInField(newInterface, null, field, addChange); }, onRemoved(field) { - addChange(fieldRemoved(oldInterface, field)); + addChange(fieldRemoved(oldInterface!, field)); }, onMutual(field) { - changesInField(oldInterface, field.oldVersion, field.newVersion, addChange); + changesInField(newInterface, field.oldVersion, field.newVersion, addChange); }, }); - compareLists(oldInterface.astNode?.directives || [], newInterface.astNode?.directives || [], { + compareLists(oldInterface?.astNode?.directives || [], newInterface.astNode?.directives || [], { onAdded(directive) { - addChange(directiveUsageAdded(Kind.INTERFACE_TYPE_DEFINITION, directive, newInterface)); + addChange( + directiveUsageAdded( + Kind.INTERFACE_TYPE_DEFINITION, + directive, + newInterface, + oldInterface === null, + ), + ); }, onRemoved(directive) { - addChange(directiveUsageRemoved(Kind.INTERFACE_TYPE_DEFINITION, directive, oldInterface)); + addChange(directiveUsageRemoved(Kind.INTERFACE_TYPE_DEFINITION, directive, oldInterface!)); }, }); } diff --git a/packages/core/src/diff/object.ts b/packages/core/src/diff/object.ts index 12817e1f0f..c716fb98a1 100644 --- a/packages/core/src/diff/object.ts +++ b/packages/core/src/diff/object.ts @@ -7,43 +7,44 @@ import { changesInField } from './field.js'; import { AddChange } from './schema.js'; export function changesInObject( - oldType: GraphQLObjectType, + oldType: GraphQLObjectType | null, newType: GraphQLObjectType, addChange: AddChange, ) { - const oldInterfaces = oldType.getInterfaces(); + const oldInterfaces = oldType?.getInterfaces() ?? []; const newInterfaces = newType.getInterfaces(); - const oldFields = oldType.getFields(); + const oldFields = oldType?.getFields() ?? {}; const newFields = newType.getFields(); compareLists(oldInterfaces, newInterfaces, { onAdded(i) { - addChange(objectTypeInterfaceAdded(i, newType)); + addChange(objectTypeInterfaceAdded(i, newType, oldType === null)); }, onRemoved(i) { - addChange(objectTypeInterfaceRemoved(i, oldType)); + addChange(objectTypeInterfaceRemoved(i, oldType!)); }, }); compareLists(Object.values(oldFields), Object.values(newFields), { onAdded(f) { addChange(fieldAdded(newType, f)); + changesInField(newType, null, f, addChange); }, onRemoved(f) { - addChange(fieldRemoved(oldType, f)); + addChange(fieldRemoved(oldType!, f)); }, onMutual(f) { - changesInField(oldType, f.oldVersion, f.newVersion, addChange); + changesInField(newType, f.oldVersion, f.newVersion, addChange); }, }); - compareLists(oldType.astNode?.directives || [], newType.astNode?.directives || [], { + compareLists(oldType?.astNode?.directives || [], newType.astNode?.directives || [], { onAdded(directive) { - addChange(directiveUsageAdded(Kind.OBJECT, directive, newType)); + addChange(directiveUsageAdded(Kind.OBJECT, directive, newType, oldType === null)); }, onRemoved(directive) { - addChange(directiveUsageRemoved(Kind.OBJECT, directive, oldType)); + addChange(directiveUsageRemoved(Kind.OBJECT, directive, oldType!)); }, }); } diff --git a/packages/core/src/diff/scalar.ts b/packages/core/src/diff/scalar.ts index 020752b322..fd3ba88586 100644 --- a/packages/core/src/diff/scalar.ts +++ b/packages/core/src/diff/scalar.ts @@ -4,16 +4,18 @@ import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive- import { AddChange } from './schema.js'; export function changesInScalar( - oldScalar: GraphQLScalarType, + oldScalar: GraphQLScalarType | null, newScalar: GraphQLScalarType, addChange: AddChange, ) { - compareLists(oldScalar.astNode?.directives || [], newScalar.astNode?.directives || [], { + compareLists(oldScalar?.astNode?.directives || [], newScalar.astNode?.directives || [], { onAdded(directive) { - addChange(directiveUsageAdded(Kind.SCALAR_TYPE_DEFINITION, directive, newScalar)); + addChange( + directiveUsageAdded(Kind.SCALAR_TYPE_DEFINITION, directive, newScalar, oldScalar === null), + ); }, onRemoved(directive) { - addChange(directiveUsageRemoved(Kind.SCALAR_TYPE_DEFINITION, directive, oldScalar)); + addChange(directiveUsageRemoved(Kind.SCALAR_TYPE_DEFINITION, directive, oldScalar!)); }, }); } diff --git a/packages/core/src/diff/schema.ts b/packages/core/src/diff/schema.ts index 0badd64085..a78cbe6c03 100644 --- a/packages/core/src/diff/schema.ts +++ b/packages/core/src/diff/schema.ts @@ -53,6 +53,7 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): { onAdded(type) { addChange(typeAdded(type)); + changesInType(null, type, addChange); }, onRemoved(type) { addChange(typeRemoved(type)); @@ -66,6 +67,7 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): compareLists(oldSchema.getDirectives(), newSchema.getDirectives(), { onAdded(directive) { addChange(directiveAdded(directive)); + changesInDirective(null, directive, addChange); }, onRemoved(directive) { addChange(directiveRemoved(directive)); @@ -77,7 +79,7 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): compareLists(oldSchema.astNode?.directives || [], newSchema.astNode?.directives || [], { onAdded(directive) { - addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema)); + addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema, false)); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.SCHEMA_DEFINITION, directive, oldSchema)); @@ -123,30 +125,34 @@ function changesInSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema, add } } -function changesInType(oldType: GraphQLNamedType, newType: GraphQLNamedType, addChange: AddChange) { - if (isEnumType(oldType) && isEnumType(newType)) { +function changesInType( + oldType: GraphQLNamedType | null, + newType: GraphQLNamedType, + addChange: AddChange, +) { + if ((oldType === null || isEnumType(oldType)) && isEnumType(newType)) { changesInEnum(oldType, newType, addChange); - } else if (isUnionType(oldType) && isUnionType(newType)) { + } else if ((oldType === null || isUnionType(oldType)) && isUnionType(newType)) { changesInUnion(oldType, newType, addChange); - } else if (isInputObjectType(oldType) && isInputObjectType(newType)) { + } else if ((oldType === null || isInputObjectType(oldType)) && isInputObjectType(newType)) { changesInInputObject(oldType, newType, addChange); - } else if (isObjectType(oldType) && isObjectType(newType)) { + } else if ((oldType === null || isObjectType(oldType)) && isObjectType(newType)) { changesInObject(oldType, newType, addChange); - } else if (isInterfaceType(oldType) && isInterfaceType(newType)) { + } else if ((oldType === null || isInterfaceType(oldType)) && isInterfaceType(newType)) { changesInInterface(oldType, newType, addChange); - } else if (isScalarType(oldType) && isScalarType(newType)) { + } else if ((oldType === null || isScalarType(oldType)) && isScalarType(newType)) { changesInScalar(oldType, newType, addChange); } else { addChange(typeKindChanged(oldType, newType)); } - if (isNotEqual(oldType.description, newType.description)) { - if (isVoid(oldType.description)) { + if (isNotEqual(oldType?.description, newType.description)) { + if (isVoid(oldType?.description)) { addChange(typeDescriptionAdded(newType)); - } else if (isVoid(newType.description)) { + } else if (oldType && isVoid(newType.description)) { addChange(typeDescriptionRemoved(oldType)); } else { - addChange(typeDescriptionChanged(oldType, newType)); + addChange(typeDescriptionChanged(oldType!, newType)); } } } diff --git a/packages/core/src/diff/union.ts b/packages/core/src/diff/union.ts index 030539b675..6c0ed2e6f2 100644 --- a/packages/core/src/diff/union.ts +++ b/packages/core/src/diff/union.ts @@ -5,28 +5,30 @@ import { unionMemberAdded, unionMemberRemoved } from './changes/union.js'; import { AddChange } from './schema.js'; export function changesInUnion( - oldUnion: GraphQLUnionType, + oldUnion: GraphQLUnionType | null, newUnion: GraphQLUnionType, addChange: AddChange, ) { - const oldTypes = oldUnion.getTypes(); + const oldTypes = oldUnion?.getTypes() ?? []; const newTypes = newUnion.getTypes(); compareLists(oldTypes, newTypes, { onAdded(t) { - addChange(unionMemberAdded(newUnion, t)); + addChange(unionMemberAdded(newUnion, t, oldUnion === null)); }, onRemoved(t) { - addChange(unionMemberRemoved(oldUnion, t)); + addChange(unionMemberRemoved(oldUnion!, t)); }, }); - compareLists(oldUnion.astNode?.directives || [], newUnion.astNode?.directives || [], { + compareLists(oldUnion?.astNode?.directives || [], newUnion.astNode?.directives || [], { onAdded(directive) { - addChange(directiveUsageAdded(Kind.UNION_TYPE_DEFINITION, directive, newUnion)); + addChange( + directiveUsageAdded(Kind.UNION_TYPE_DEFINITION, directive, newUnion, oldUnion === null), + ); }, onRemoved(directive) { - addChange(directiveUsageRemoved(Kind.UNION_TYPE_DEFINITION, directive, oldUnion)); + addChange(directiveUsageRemoved(Kind.UNION_TYPE_DEFINITION, directive, oldUnion!)); }, }); } diff --git a/packages/core/src/utils/compare.ts b/packages/core/src/utils/compare.ts index 170a31df02..b8d37c43d4 100644 --- a/packages/core/src/utils/compare.ts +++ b/packages/core/src/utils/compare.ts @@ -45,7 +45,7 @@ export function isNotEqual(a: T, b: T): boolean { return !isEqual(a, b); } -export function isVoid(a: T): boolean { +export function isVoid(a: T): a is T & (null | undefined) { return typeof a === 'undefined' || a === null; } @@ -67,7 +67,7 @@ export function compareLists( callbacks?: { onAdded?(t: T): void; onRemoved?(t: T): void; - onMutual?(t: { newVersion: T; oldVersion: T }): void; + onMutual?(t: { newVersion: T; oldVersion: T | null }): void; }, ) { const oldMap = keyMap(oldList, ({ name }) => extractName(name)); diff --git a/packages/core/src/utils/graphql.ts b/packages/core/src/utils/graphql.ts index 8c94a5d1f6..4c803c4259 100644 --- a/packages/core/src/utils/graphql.ts +++ b/packages/core/src/utils/graphql.ts @@ -58,7 +58,7 @@ export function safeChangeForInputValue( newType: GraphQLInputType, ): boolean { if (!isWrappingType(oldType) && !isWrappingType(newType)) { - return oldType.toString() === newType.toString(); + return oldType?.toString() === newType.toString(); } if (isListType(oldType) && isListType(newType)) { From 2adf87be1cb2be13de0170a91ca8bafe42735a7b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 8 Jul 2025 22:10:33 -0700 Subject: [PATCH 02/23] Fix directive argument changes to match others --- packages/core/src/diff/changes/change.ts | 3 +++ packages/core/src/diff/changes/directive.ts | 7 +++++-- packages/core/src/diff/changes/input.ts | 1 - packages/core/src/diff/directive.ts | 7 +++---- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index c210da2bd6..1830e0d66b 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -196,6 +196,9 @@ export type DirectiveArgumentAddedChange = { addedDirectiveArgumentName: string; addedDirectiveArgumentTypeIsNonNull: boolean; addedToNewDirective: boolean; + addedDirectiveArgumentDescription: string | null; + addedDirectiveArgumentType: string; + addedDirectiveDefaultValue?: string /* | null */; }; }; diff --git a/packages/core/src/diff/changes/directive.ts b/packages/core/src/diff/changes/directive.ts index 2bbec3ca5b..205fb630ff 100644 --- a/packages/core/src/diff/changes/directive.ts +++ b/packages/core/src/diff/changes/directive.ts @@ -208,7 +208,10 @@ export function directiveArgumentAdded( meta: { directiveName: directive.name, addedDirectiveArgumentName: arg.name, + addedDirectiveArgumentType: arg.type.toString(), + addedDirectiveDefaultValue: safeString(arg.defaultValue), addedDirectiveArgumentTypeIsNonNull: isNonNullType(arg.type), + addedDirectiveArgumentDescription: arg.description ?? null, addedToNewDirective, }, }); @@ -360,7 +363,7 @@ export function directiveArgumentTypeChangedFromMeta(args: DirectiveArgumentType export function directiveArgumentTypeChanged( directive: GraphQLDirective, - oldArg: GraphQLArgument | null, + oldArg: GraphQLArgument, newArg: GraphQLArgument, ): Change { return directiveArgumentTypeChangedFromMeta({ @@ -370,7 +373,7 @@ export function directiveArgumentTypeChanged( directiveArgumentName: newArg.name, oldDirectiveArgumentType: oldArg?.type.toString() ?? '', newDirectiveArgumentType: newArg.type.toString(), - isSafeDirectiveArgumentTypeChange: safeChangeForInputValue(oldArg?.type ?? null, newArg.type), + isSafeDirectiveArgumentTypeChange: safeChangeForInputValue(oldArg.type, newArg.type), }, }); } diff --git a/packages/core/src/diff/changes/input.ts b/packages/core/src/diff/changes/input.ts index 6309c5939d..bbb793581c 100644 --- a/packages/core/src/diff/changes/input.ts +++ b/packages/core/src/diff/changes/input.ts @@ -1,5 +1,4 @@ import { GraphQLInputField, GraphQLInputObjectType, isNonNullType } from 'graphql'; -import { isVoid } from '../../utils/compare.js'; import { safeChangeForInputValue } from '../../utils/graphql.js'; import { isDeprecated } from '../../utils/is-deprecated.js'; import { safeString } from '../../utils/string.js'; diff --git a/packages/core/src/diff/directive.ts b/packages/core/src/diff/directive.ts index 2d51e872d7..d1b918ad21 100644 --- a/packages/core/src/diff/directive.ts +++ b/packages/core/src/diff/directive.ts @@ -37,20 +37,19 @@ export function changesInDirective( compareLists(oldDirective?.args ?? [], newDirective.args, { onAdded(arg) { addChange(directiveArgumentAdded(newDirective, arg, oldDirective === null)); - changesInDirectiveArgument(newDirective, null, arg, addChange); }, onRemoved(arg) { addChange(directiveArgumentRemoved(newDirective, arg)); }, onMutual(arg) { - changesInDirectiveArgument(newDirective, arg.oldVersion, arg.newVersion, addChange); + changesInDirectiveArgument(newDirective, arg.oldVersion!, arg.newVersion, addChange); }, }); } function changesInDirectiveArgument( directive: GraphQLDirective, - oldArg: GraphQLArgument | null, + oldArg: GraphQLArgument, newArg: GraphQLArgument, addChange: AddChange, ) { @@ -62,7 +61,7 @@ function changesInDirectiveArgument( addChange(directiveArgumentDefaultValueChanged(directive, oldArg, newArg)); } - if (isNotEqual(oldArg?.type.toString(), newArg.type.toString())) { + if (isNotEqual(oldArg.type.toString(), newArg.type.toString())) { addChange(directiveArgumentTypeChanged(directive, oldArg, newArg)); } } From dfe87bfc9636146b92b9f225d564274352fcf304 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:59:06 -0700 Subject: [PATCH 03/23] Add rule to ignore nested additions --- .../rules/ignore-nested-additions.test.ts | 77 +++++++++++++++++++ packages/core/src/diff/changes/change.ts | 1 + packages/core/src/diff/changes/field.ts | 10 ++- packages/core/src/diff/field.ts | 2 +- .../src/diff/rules/ignore-nested-additions.ts | 55 +++++++++++++ packages/core/src/diff/rules/index.ts | 1 + 6 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts create mode 100644 packages/core/src/diff/rules/ignore-nested-additions.ts diff --git a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts new file mode 100644 index 0000000000..039f497bae --- /dev/null +++ b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts @@ -0,0 +1,77 @@ +import { buildSchema } from 'graphql'; +import { ignoreNestedAdditions } from '../../../src/diff/rules/index.js'; +import { diff } from '../../../src/index.js'; +import { findFirstChangeByPath } from '../../../utils/testing.js'; + +describe('ignoreNestedAdditions rule', () => { + test('added field on new object', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a(b: String): String! @deprecated(reason: "As a test") + } + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.a'); + expect(added).toBe(undefined); + }); + + test('added field on new interface', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + interface Foo { + a(b: String): String! @deprecated(reason: "As a test") + } + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.a'); + expect(added).toBe(undefined); + }); + + test('added value on new enum', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + """ + Here is a new enum named B + """ + enum B { + """ + It has newly added values + """ + C @deprecated(reason: "With deprecations") + D + } + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchInlineSnapshot({ + criticality: { + level: 'NON_BREAKING', + }, + message: "Type 'B' was added", + meta: { + addedTypeKind: 'EnumTypeDefinition', + addedTypeName: 'B', + }, + path: 'B', + type: 'TYPE_ADDED', + }); + }); +}); diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index 1830e0d66b..7410a03e5c 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -432,6 +432,7 @@ export type FieldArgumentAddedChange = { addedArgumentType: string; hasDefaultValue: boolean; isAddedFieldArgumentBreaking: boolean; + addedToNewField: boolean; }; }; diff --git a/packages/core/src/diff/changes/field.ts b/packages/core/src/diff/changes/field.ts index 83a91d408a..781b6685f6 100644 --- a/packages/core/src/diff/changes/field.ts +++ b/packages/core/src/diff/changes/field.ts @@ -377,9 +377,11 @@ export function fieldArgumentAddedFromMeta(args: FieldArgumentAddedChange) { return { type: ChangeType.FieldArgumentAdded, criticality: { - level: args.meta.isAddedFieldArgumentBreaking - ? CriticalityLevel.Breaking - : CriticalityLevel.Dangerous, + level: args.meta.addedToNewField + ? CriticalityLevel.NonBreaking + : args.meta.isAddedFieldArgumentBreaking + ? CriticalityLevel.Breaking + : CriticalityLevel.Dangerous, }, message: buildFieldArgumentAddedMessage(args.meta), meta: args.meta, @@ -391,6 +393,7 @@ export function fieldArgumentAdded( type: GraphQLObjectType | GraphQLInterfaceType, field: GraphQLField, arg: GraphQLArgument, + addedToNewField: boolean, ): Change { const isBreaking = isNonNullType(arg.type) && typeof arg.defaultValue === 'undefined'; @@ -402,6 +405,7 @@ export function fieldArgumentAdded( addedArgumentName: arg.name, addedArgumentType: arg.type.toString(), hasDefaultValue: arg.defaultValue != null, + addedToNewField, isAddedFieldArgumentBreaking: isBreaking, }, }); diff --git a/packages/core/src/diff/field.ts b/packages/core/src/diff/field.ts index 7377845bfa..c6bd2642de 100644 --- a/packages/core/src/diff/field.ts +++ b/packages/core/src/diff/field.ts @@ -58,7 +58,7 @@ export function changesInField( compareLists(oldField?.args ?? [], newField.args, { onAdded(arg) { - addChange(fieldArgumentAdded(type, newField, arg)); + addChange(fieldArgumentAdded(type, newField, arg, oldField === null)); }, onRemoved(arg) { addChange(fieldArgumentRemoved(type, newField, arg)); diff --git a/packages/core/src/diff/rules/ignore-nested-additions.ts b/packages/core/src/diff/rules/ignore-nested-additions.ts new file mode 100644 index 0000000000..182df04417 --- /dev/null +++ b/packages/core/src/diff/rules/ignore-nested-additions.ts @@ -0,0 +1,55 @@ +import { ChangeType } from '../changes/change.js'; +import { Rule } from './types.js'; + +const additionChangeTypes = new Set([ + ChangeType.DirectiveAdded, + ChangeType.DirectiveArgumentAdded, + ChangeType.DirectiveLocationAdded, + ChangeType.DirectiveUsageArgumentDefinitionAdded, + ChangeType.DirectiveUsageEnumAdded, + ChangeType.DirectiveUsageEnumValueAdded, + ChangeType.DirectiveUsageFieldAdded, + ChangeType.DirectiveUsageFieldDefinitionAdded, + ChangeType.DirectiveUsageInputFieldDefinitionAdded, + ChangeType.DirectiveUsageInputObjectAdded, + ChangeType.DirectiveUsageInterfaceAdded, + ChangeType.DirectiveUsageObjectAdded, + ChangeType.DirectiveUsageScalarAdded, + ChangeType.DirectiveUsageSchemaAdded, + ChangeType.DirectiveUsageUnionMemberAdded, + ChangeType.EnumValueAdded, + ChangeType.EnumValueDeprecationReasonAdded, + ChangeType.FieldAdded, + ChangeType.FieldArgumentAdded, + ChangeType.FieldDeprecationAdded, + ChangeType.FieldDeprecationReasonAdded, + ChangeType.FieldDescriptionAdded, + ChangeType.InputFieldAdded, + ChangeType.InputFieldDescriptionAdded, + ChangeType.ObjectTypeInterfaceAdded, + ChangeType.TypeAdded, + ChangeType.TypeDescriptionAdded, + ChangeType.UnionMemberAdded, +]); + +export const ignoreNestedAdditions: Rule = ({ changes }) => { + // Track which paths contained changes that represent additions to the schema + const additionPaths: string[] = []; + + const filteredChanges = changes.filter(({ path, type }) => { + if (path) { + const parentPath = path?.substring(0, path.lastIndexOf('.')) ?? ''; + const matches = additionPaths.filter(matchedPath => matchedPath.includes(parentPath)); + const hasAddedParent = matches.length > 0; + + if (additionChangeTypes.has(type)) { + additionPaths.push(path); + } + + return !hasAddedParent; + } + return true; + }); + + return filteredChanges; +}; diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index fb9f10a602..70db723148 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -4,3 +4,4 @@ export * from './ignore-description-changes.js'; export * from './safe-unreachable.js'; export * from './suppress-removal-of-deprecated-field.js'; export * from './ignore-usage-directives.js'; +export * from './ignore-nested-additions.js'; From c3dcc73dbf5b52c55da29de762c5e281905e2d0e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 9 Jul 2025 13:11:51 -0700 Subject: [PATCH 04/23] Add a field test --- .../rules/ignore-nested-additions.test.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts index 039f497bae..16896c8653 100644 --- a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts +++ b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts @@ -1,6 +1,6 @@ import { buildSchema } from 'graphql'; import { ignoreNestedAdditions } from '../../../src/diff/rules/index.js'; -import { diff } from '../../../src/index.js'; +import { ChangeType, CriticalityLevel, diff } from '../../../src/index.js'; import { findFirstChangeByPath } from '../../../utils/testing.js'; describe('ignoreNestedAdditions rule', () => { @@ -61,9 +61,9 @@ describe('ignoreNestedAdditions rule', () => { const changes = await diff(a, b, [ignoreNestedAdditions]); expect(changes).toHaveLength(1); - expect(changes[0]).toMatchInlineSnapshot({ + expect(changes[0]).toMatchObject({ criticality: { - level: 'NON_BREAKING', + level: CriticalityLevel.NonBreaking, }, message: "Type 'B' was added", meta: { @@ -71,7 +71,29 @@ describe('ignoreNestedAdditions rule', () => { addedTypeName: 'B', }, path: 'B', - type: 'TYPE_ADDED', + type: ChangeType.TypeAdded, }); }); + + test('added argument on new field', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a: String! + } + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a: String! + b(b: String): String! @deprecated(reason: "As a test") + } + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.b'); + expect(added.type).toBe(ChangeType.FieldAdded); + }); }); From 6fd13d2b894e42f0cb50fbca6b876fe43d69a385 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:22:47 -0700 Subject: [PATCH 05/23] Fix parent path; add more tests --- .../rules/ignore-nested-additions.test.ts | 48 ++++++++++++++++++- .../src/diff/rules/ignore-nested-additions.ts | 9 +++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts index 16896c8653..71373c4eda 100644 --- a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts +++ b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts @@ -75,7 +75,7 @@ describe('ignoreNestedAdditions rule', () => { }); }); - test('added argument on new field', async () => { + test('added argument / directive / deprecation / reason on new field', async () => { const a = buildSchema(/* GraphQL */ ` scalar A type Foo { @@ -96,4 +96,50 @@ describe('ignoreNestedAdditions rule', () => { const added = findFirstChangeByPath(changes, 'Foo.b'); expect(added.type).toBe(ChangeType.FieldAdded); }); + + test('added type / directive / directive argument on new union', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + directive @special(reason: String) on UNION + + type Foo { + a: String! + } + + union FooUnion @special(reason: "As a test") = + | Foo + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + expect(changes).toHaveLength(3); + + { + const added = findFirstChangeByPath(changes, 'FooUnion'); + expect(added.type).toBe(ChangeType.TypeAdded); + } + + { + const added = findFirstChangeByPath(changes, 'Foo'); + expect(added.type).toBe(ChangeType.TypeAdded); + } + }); + + test('added argument / location / description on new directive', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + directive @special(reason: String) on UNION | FIELD_DEFINITION + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, '@special'); + expect(added.type).toBe(ChangeType.DirectiveAdded); + }); }); diff --git a/packages/core/src/diff/rules/ignore-nested-additions.ts b/packages/core/src/diff/rules/ignore-nested-additions.ts index 182df04417..725f9be131 100644 --- a/packages/core/src/diff/rules/ignore-nested-additions.ts +++ b/packages/core/src/diff/rules/ignore-nested-additions.ts @@ -32,14 +32,19 @@ const additionChangeTypes = new Set([ ChangeType.UnionMemberAdded, ]); +const parentPath = (path: string) => { + const lastDividerIndex = path.lastIndexOf('.'); + return lastDividerIndex === -1 ? path : path.substring(0, lastDividerIndex); +} + export const ignoreNestedAdditions: Rule = ({ changes }) => { // Track which paths contained changes that represent additions to the schema const additionPaths: string[] = []; const filteredChanges = changes.filter(({ path, type }) => { if (path) { - const parentPath = path?.substring(0, path.lastIndexOf('.')) ?? ''; - const matches = additionPaths.filter(matchedPath => matchedPath.includes(parentPath)); + const parent = parentPath(path); + const matches = additionPaths.filter(matchedPath => matchedPath.includes(parent)); const hasAddedParent = matches.length > 0; if (additionChangeTypes.has(type)) { From 0cdcc17aa68271016b43d2e9a988c5ee24330428 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:27:53 -0700 Subject: [PATCH 06/23] TypeChanged changes --- packages/core/src/diff/changes/type.ts | 4 ++-- packages/core/src/diff/schema.ts | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/core/src/diff/changes/type.ts b/packages/core/src/diff/changes/type.ts index 1610b85a38..0d64b17ad6 100644 --- a/packages/core/src/diff/changes/type.ts +++ b/packages/core/src/diff/changes/type.ts @@ -120,7 +120,7 @@ export function typeKindChangedFromMeta(args: TypeKindChangedChange) { } export function typeKindChanged( - oldType: GraphQLNamedType | null, + oldType: GraphQLNamedType, newType: GraphQLNamedType, ): Change { return typeKindChangedFromMeta({ @@ -128,7 +128,7 @@ export function typeKindChanged( meta: { typeName: newType.name, newTypeKind: String(getKind(newType)), - oldTypeKind: oldType ? String(getKind(oldType)) : '', + oldTypeKind: String(getKind(oldType)), }, }); } diff --git a/packages/core/src/diff/schema.ts b/packages/core/src/diff/schema.ts index a78cbe6c03..92b7d37b70 100644 --- a/packages/core/src/diff/schema.ts +++ b/packages/core/src/diff/schema.ts @@ -130,19 +130,20 @@ function changesInType( newType: GraphQLNamedType, addChange: AddChange, ) { - if ((oldType === null || isEnumType(oldType)) && isEnumType(newType)) { + if ((isVoid(oldType) || isEnumType(oldType)) && isEnumType(newType)) { changesInEnum(oldType, newType, addChange); - } else if ((oldType === null || isUnionType(oldType)) && isUnionType(newType)) { + } else if ((isVoid(oldType) || isUnionType(oldType)) && isUnionType(newType)) { changesInUnion(oldType, newType, addChange); - } else if ((oldType === null || isInputObjectType(oldType)) && isInputObjectType(newType)) { + } else if ((isVoid(oldType) || isInputObjectType(oldType)) && isInputObjectType(newType)) { changesInInputObject(oldType, newType, addChange); - } else if ((oldType === null || isObjectType(oldType)) && isObjectType(newType)) { + } else if ((isVoid(oldType) || isObjectType(oldType)) && isObjectType(newType)) { changesInObject(oldType, newType, addChange); - } else if ((oldType === null || isInterfaceType(oldType)) && isInterfaceType(newType)) { + } else if ((isVoid(oldType) || isInterfaceType(oldType)) && isInterfaceType(newType)) { changesInInterface(oldType, newType, addChange); - } else if ((oldType === null || isScalarType(oldType)) && isScalarType(newType)) { + } else if ((isVoid(oldType) || isScalarType(oldType)) && isScalarType(newType)) { changesInScalar(oldType, newType, addChange); - } else { + } else if (!isVoid(oldType)) { + // no need to call if oldType is void since the type will be captured by the TypeAdded change. addChange(typeKindChanged(oldType, newType)); } From 985a146c78da2f8da477842bc5aee4b9bf143f7a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:28:11 -0700 Subject: [PATCH 07/23] prettier --- .../core/__tests__/diff/rules/ignore-nested-additions.test.ts | 3 +-- packages/core/src/diff/rules/ignore-nested-additions.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts index 71373c4eda..c7079705fa 100644 --- a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts +++ b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts @@ -109,8 +109,7 @@ describe('ignoreNestedAdditions rule', () => { a: String! } - union FooUnion @special(reason: "As a test") = - | Foo + union FooUnion @special(reason: "As a test") = Foo `); const changes = await diff(a, b, [ignoreNestedAdditions]); diff --git a/packages/core/src/diff/rules/ignore-nested-additions.ts b/packages/core/src/diff/rules/ignore-nested-additions.ts index 725f9be131..af61f0054b 100644 --- a/packages/core/src/diff/rules/ignore-nested-additions.ts +++ b/packages/core/src/diff/rules/ignore-nested-additions.ts @@ -35,7 +35,7 @@ const additionChangeTypes = new Set([ const parentPath = (path: string) => { const lastDividerIndex = path.lastIndexOf('.'); return lastDividerIndex === -1 ? path : path.substring(0, lastDividerIndex); -} +}; export const ignoreNestedAdditions: Rule = ({ changes }) => { // Track which paths contained changes that represent additions to the schema From 3f781fb80dc1925f365f2dd9453147f7669095ab Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 16 Jul 2025 19:02:45 -0700 Subject: [PATCH 08/23] Add more meta to changes --- packages/core/src/diff/changes/change.ts | 4 ++++ packages/core/src/diff/changes/directive.ts | 3 +++ packages/core/src/diff/changes/enum.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index 7410a03e5c..fc62e7a010 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -161,6 +161,9 @@ export type DirectiveAddedChange = { type: typeof ChangeType.DirectiveAdded; meta: { addedDirectiveName: string; + addedDirectiveRepeatable: boolean; + addedDirectiveLocations: string[]; + addedDirectiveDescription: string | null; }; }; @@ -259,6 +262,7 @@ export type EnumValueAddedChange = { enumName: string; addedEnumValueName: string; addedToNewType: boolean; + addedDirectiveDescription: string | null; }; }; diff --git a/packages/core/src/diff/changes/directive.ts b/packages/core/src/diff/changes/directive.ts index 205fb630ff..4c38187e84 100644 --- a/packages/core/src/diff/changes/directive.ts +++ b/packages/core/src/diff/changes/directive.ts @@ -70,6 +70,9 @@ export function directiveAdded( type: ChangeType.DirectiveAdded, meta: { addedDirectiveName: directive.name, + addedDirectiveDescription: directive.description ?? null, + addedDirectiveLocations: directive.locations.map(l => safeString(l)), + addedDirectiveRepeatable: directive.isRepeatable, }, }); } diff --git a/packages/core/src/diff/changes/enum.ts b/packages/core/src/diff/changes/enum.ts index 1a28608a5b..a34e6c6232 100644 --- a/packages/core/src/diff/changes/enum.ts +++ b/packages/core/src/diff/changes/enum.ts @@ -79,6 +79,7 @@ export function enumValueAdded( enumName: type.name, addedEnumValueName: value.name, addedToNewType, + addedDirectiveDescription: value.description ?? null, }, }); } From 441c198fd45fd12168a418b2d34e9a55098dba14 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:48:33 -0700 Subject: [PATCH 09/23] Add directive usage; add tests --- .../core/__tests__/diff/directive.test.ts | 12 +- packages/core/__tests__/diff/enum.test.ts | 2 +- packages/core/__tests__/diff/schema.test.ts | 2 +- packages/core/src/diff/changes/change.ts | 18 +- .../core/src/diff/changes/directive-usage.ts | 44 +- packages/core/src/diff/changes/directive.ts | 7 +- packages/core/src/diff/changes/enum.ts | 4 +- packages/core/src/diff/changes/field.ts | 3 +- packages/core/src/diff/enum.ts | 11 +- packages/core/src/diff/field.ts | 20 +- packages/core/src/index.ts | 1 - packages/patch/package.json | 71 ++ .../src/__tests__/directive-usage.test.ts | 1105 +++++++++++++++++ .../patch/src/__tests__/directives.test.ts | 77 ++ packages/patch/src/__tests__/enum.test.ts | 115 ++ packages/patch/src/__tests__/fields.test.ts | 171 +++ packages/patch/src/__tests__/inputs.test.ts | 67 + .../patch/src/__tests__/interfaces.test.ts | 136 ++ packages/patch/src/__tests__/types.test.ts | 87 ++ packages/patch/src/__tests__/unions.test.ts | 47 + packages/patch/src/__tests__/utils.ts | 17 + packages/patch/src/errors.ts | 157 +++ packages/patch/src/index.ts | 455 +++++++ packages/patch/src/node-templates.ts | 29 + .../patch/src/patches/directive-usages.ts | 275 ++++ packages/patch/src/patches/directives.ts | 263 ++++ packages/patch/src/patches/enum.ts | 195 +++ packages/patch/src/patches/fields.ts | 361 ++++++ packages/patch/src/patches/inputs.ts | 135 ++ packages/patch/src/patches/interfaces.ts | 83 ++ packages/patch/src/patches/schema.ts | 77 ++ packages/patch/src/patches/types.ts | 141 +++ packages/patch/src/patches/unions.ts | 60 + packages/patch/src/types.ts | 32 + packages/patch/src/utils.ts | 213 ++++ pnpm-lock.yaml | 11 + tsconfig.test.json | 3 +- vite.config.ts | 1 + 38 files changed, 4466 insertions(+), 42 deletions(-) create mode 100644 packages/patch/package.json create mode 100644 packages/patch/src/__tests__/directive-usage.test.ts create mode 100644 packages/patch/src/__tests__/directives.test.ts create mode 100644 packages/patch/src/__tests__/enum.test.ts create mode 100644 packages/patch/src/__tests__/fields.test.ts create mode 100644 packages/patch/src/__tests__/inputs.test.ts create mode 100644 packages/patch/src/__tests__/interfaces.test.ts create mode 100644 packages/patch/src/__tests__/types.test.ts create mode 100644 packages/patch/src/__tests__/unions.test.ts create mode 100644 packages/patch/src/__tests__/utils.ts create mode 100644 packages/patch/src/errors.ts create mode 100644 packages/patch/src/index.ts create mode 100644 packages/patch/src/node-templates.ts create mode 100644 packages/patch/src/patches/directive-usages.ts create mode 100644 packages/patch/src/patches/directives.ts create mode 100644 packages/patch/src/patches/enum.ts create mode 100644 packages/patch/src/patches/fields.ts create mode 100644 packages/patch/src/patches/inputs.ts create mode 100644 packages/patch/src/patches/interfaces.ts create mode 100644 packages/patch/src/patches/schema.ts create mode 100644 packages/patch/src/patches/types.ts create mode 100644 packages/patch/src/patches/unions.ts create mode 100644 packages/patch/src/types.ts create mode 100644 packages/patch/src/utils.ts diff --git a/packages/core/__tests__/diff/directive.test.ts b/packages/core/__tests__/diff/directive.test.ts index aa9c021b38..b90f434fc8 100644 --- a/packages/core/__tests__/diff/directive.test.ts +++ b/packages/core/__tests__/diff/directive.test.ts @@ -173,13 +173,13 @@ describe('directive', () => { }; // Nullable - expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); - expect(change.a.type).toEqual('DIRECTIVE_ARGUMENT_ADDED'); - expect(change.a.message).toEqual(`Argument 'name' was added to directive 'a'`); + expect(change.a?.type).toEqual('DIRECTIVE_ARGUMENT_ADDED'); + expect(change.a?.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.a?.message).toEqual(`Argument 'name' was added to directive 'a'`); // Non-nullable - expect(change.b.criticality.level).toEqual(CriticalityLevel.Breaking); - expect(change.b.type).toEqual('DIRECTIVE_ARGUMENT_ADDED'); - expect(change.b.message).toEqual(`Argument 'name' was added to directive 'b'`); + expect(change.b?.type).toEqual('DIRECTIVE_ARGUMENT_ADDED'); + expect(change.b?.criticality.level).toEqual(CriticalityLevel.Breaking); + expect(change.b?.message).toEqual(`Argument 'name' was added to directive 'b'`); }); test('removed', async () => { diff --git a/packages/core/__tests__/diff/enum.test.ts b/packages/core/__tests__/diff/enum.test.ts index 3b950766da..731a601e29 100644 --- a/packages/core/__tests__/diff/enum.test.ts +++ b/packages/core/__tests__/diff/enum.test.ts @@ -178,7 +178,7 @@ describe('enum', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A'); + const change = findFirstChangeByPath(changes, 'enumA.A.deprecated'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index 29f7356242..4a0fdf2fb9 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -372,7 +372,7 @@ test('huge test', async () => { 'Options.D', 'Options.A', 'Options.E', - 'Options.F', + 'Options.F.deprecated', '@willBeRemoved', '@yolo2', '@yolo', diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index fc62e7a010..2bffefcb95 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -40,7 +40,11 @@ export const ChangeType = { // Enum EnumValueRemoved: 'ENUM_VALUE_REMOVED', EnumValueAdded: 'ENUM_VALUE_ADDED', + // @todo This is missing from the code... + // EnumValueDescriptionAdded: 'ENUM_VALUE_DESCRIPTION_ADDED', EnumValueDescriptionChanged: 'ENUM_VALUE_DESCRIPTION_CHANGED', + // @todo this is not being emitted..... why? + // EnumValueDescriptionRemoved: 'ENUM_VALUE_DESCRIPTION_REMOVED', EnumValueDeprecationReasonChanged: 'ENUM_VALUE_DEPRECATION_REASON_CHANGED', EnumValueDeprecationReasonAdded: 'ENUM_VALUE_DEPRECATION_REASON_ADDED', EnumValueDeprecationReasonRemoved: 'ENUM_VALUE_DEPRECATION_REASON_REMOVED', @@ -104,6 +108,7 @@ export const ChangeType = { DirectiveUsageInterfaceRemoved: 'DIRECTIVE_USAGE_INTERFACE_REMOVED', DirectiveUsageArgumentDefinitionAdded: 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_ADDED', DirectiveUsageArgumentDefinitionRemoved: 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_REMOVED', + // DirectiveUsageArgumentDefinitionChanged: 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_CHANGED', DirectiveUsageSchemaAdded: 'DIRECTIVE_USAGE_SCHEMA_ADDED', DirectiveUsageSchemaRemoved: 'DIRECTIVE_USAGE_SCHEMA_REMOVED', DirectiveUsageFieldDefinitionAdded: 'DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED', @@ -717,6 +722,7 @@ export type DirectiveUsageInputFieldDefinitionAddedChange = { meta: { inputObjectName: string; inputFieldName: string; + inputFieldType: string; addedDirectiveName: string; addedToNewType: boolean; }; @@ -828,17 +834,6 @@ export type DirectiveUsageFieldDefinitionRemovedChange = { }; }; -export type DirectiveUsageArgumentDefinitionChange = { - type: typeof ChangeType.DirectiveUsageArgumentDefinitionAdded; - meta: { - typeName: string; - fieldName: string; - argumentName: string; - addedDirectiveName: string; - addedToNewType: boolean; - }; -}; - export type DirectiveUsageArgumentDefinitionRemovedChange = { type: typeof ChangeType.DirectiveUsageArgumentDefinitionRemoved; meta: { @@ -864,6 +859,7 @@ export type DirectiveUsageArgumentDefinitionAddedChange = { fieldName: string; argumentName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 0ca2ea863a..075e97e002 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -17,7 +17,7 @@ import { Change, ChangeType, CriticalityLevel, - DirectiveUsageArgumentDefinitionChange, + DirectiveUsageArgumentDefinitionAddedChange, DirectiveUsageArgumentDefinitionRemovedChange, DirectiveUsageEnumAddedChange, DirectiveUsageEnumRemovedChange, @@ -115,7 +115,9 @@ type KindToPayload = { field: GraphQLInputField; type: GraphQLInputObjectType; }; - change: DirectiveUsageArgumentDefinitionChange | DirectiveUsageArgumentDefinitionRemovedChange; + change: + | DirectiveUsageArgumentDefinitionAddedChange + | DirectiveUsageArgumentDefinitionRemovedChange; }; [Kind.ARGUMENT]: { input: { @@ -123,18 +125,20 @@ type KindToPayload = { type: GraphQLObjectType | GraphQLInterfaceType; argument: GraphQLArgument; }; - change: DirectiveUsageArgumentDefinitionChange | DirectiveUsageArgumentDefinitionRemovedChange; + change: + | DirectiveUsageArgumentDefinitionAddedChange + | DirectiveUsageArgumentDefinitionRemovedChange; }; }; function buildDirectiveUsageArgumentDefinitionAddedMessage( - args: DirectiveUsageArgumentDefinitionChange['meta'], + args: DirectiveUsageArgumentDefinitionAddedChange['meta'], ): string { return `Directive '${args.addedDirectiveName}' was added to argument '${args.argumentName}' of field '${args.fieldName}' in type '${args.typeName}'`; } export function directiveUsageArgumentDefinitionAddedFromMeta( - args: DirectiveUsageArgumentDefinitionChange, + args: DirectiveUsageArgumentDefinitionAddedChange, ) { return { criticality: { @@ -597,12 +601,39 @@ export function directiveUsageUnionMemberRemovedFromMeta( } as const; } +export type DirectiveUsageAddedChange = + | typeof ChangeType.DirectiveUsageArgumentDefinitionAdded + | typeof ChangeType.DirectiveUsageInputFieldDefinitionAdded + | typeof ChangeType.DirectiveUsageInputObjectAdded + | typeof ChangeType.DirectiveUsageInterfaceAdded + | typeof ChangeType.DirectiveUsageObjectAdded + | typeof ChangeType.DirectiveUsageEnumAdded + | typeof ChangeType.DirectiveUsageFieldDefinitionAdded + | typeof ChangeType.DirectiveUsageUnionMemberAdded + | typeof ChangeType.DirectiveUsageEnumValueAdded + | typeof ChangeType.DirectiveUsageSchemaAdded + | typeof ChangeType.DirectiveUsageScalarAdded + | typeof ChangeType.DirectiveUsageFieldAdded; + +export type DirectiveUsageRemovedChange = + | typeof ChangeType.DirectiveUsageArgumentDefinitionRemoved + | typeof ChangeType.DirectiveUsageInputFieldDefinitionRemoved + | typeof ChangeType.DirectiveUsageInputObjectRemoved + | typeof ChangeType.DirectiveUsageInterfaceRemoved + | typeof ChangeType.DirectiveUsageObjectRemoved + | typeof ChangeType.DirectiveUsageEnumRemoved + | typeof ChangeType.DirectiveUsageFieldDefinitionRemoved + | typeof ChangeType.DirectiveUsageUnionMemberRemoved + | typeof ChangeType.DirectiveUsageEnumValueRemoved + | typeof ChangeType.DirectiveUsageSchemaRemoved + | typeof ChangeType.DirectiveUsageScalarRemoved; + export function directiveUsageAdded( kind: K, directive: ConstDirectiveNode, payload: KindToPayload[K]['input'], addedToNewType: boolean, -): Change { +): Change { if (isOfKind(kind, Kind.ARGUMENT, payload)) { return directiveUsageArgumentDefinitionAddedFromMeta({ type: ChangeType.DirectiveUsageArgumentDefinitionAdded, @@ -621,6 +652,7 @@ export function directiveUsageAdded( meta: { addedDirectiveName: directive.name.value, inputFieldName: payload.field.name, + inputFieldType: payload.field.type.toString(), inputObjectName: payload.type.name, addedToNewType, }, diff --git a/packages/core/src/diff/changes/directive.ts b/packages/core/src/diff/changes/directive.ts index 4c38187e84..493e41a895 100644 --- a/packages/core/src/diff/changes/directive.ts +++ b/packages/core/src/diff/changes/directive.ts @@ -71,7 +71,7 @@ export function directiveAdded( meta: { addedDirectiveName: directive.name, addedDirectiveDescription: directive.description ?? null, - addedDirectiveLocations: directive.locations.map(l => safeString(l)), + addedDirectiveLocations: directive.locations.map(l => String(l)), addedDirectiveRepeatable: directive.isRepeatable, }, }); @@ -135,7 +135,7 @@ export function directiveLocationAdded( type: ChangeType.DirectiveLocationAdded, meta: { directiveName: directive.name, - addedDirectiveLocation: location.toString(), + addedDirectiveLocation: String(location), }, }); } @@ -212,7 +212,8 @@ export function directiveArgumentAdded( directiveName: directive.name, addedDirectiveArgumentName: arg.name, addedDirectiveArgumentType: arg.type.toString(), - addedDirectiveDefaultValue: safeString(arg.defaultValue), + addedDirectiveDefaultValue: + arg.defaultValue === undefined ? '' : safeString(arg.defaultValue), addedDirectiveArgumentTypeIsNonNull: isNonNullType(arg.type), addedDirectiveArgumentDescription: arg.description ?? null, addedToNewDirective, diff --git a/packages/core/src/diff/changes/enum.ts b/packages/core/src/diff/changes/enum.ts index a34e6c6232..2876b5dba2 100644 --- a/packages/core/src/diff/changes/enum.ts +++ b/packages/core/src/diff/changes/enum.ts @@ -1,4 +1,4 @@ -import { GraphQLEnumType, GraphQLEnumValue } from 'graphql'; +import { GraphQLDeprecatedDirective, GraphQLEnumType, GraphQLEnumValue } from 'graphql'; import { isDeprecated } from '../../utils/is-deprecated.js'; import { Change, @@ -139,7 +139,7 @@ export function enumValueDeprecationReasonChangedFromMeta( }, type: ChangeType.EnumValueDeprecationReasonChanged, message: buildEnumValueDeprecationChangedMessage(args.meta), - path: [args.meta.enumName, args.meta.enumValueName].join('.'), + path: [args.meta.enumName, args.meta.enumValueName, GraphQLDeprecatedDirective.name].join('.'), meta: args.meta, } as const; } diff --git a/packages/core/src/diff/changes/field.ts b/packages/core/src/diff/changes/field.ts index 781b6685f6..e06b649d07 100644 --- a/packages/core/src/diff/changes/field.ts +++ b/packages/core/src/diff/changes/field.ts @@ -1,5 +1,6 @@ import { GraphQLArgument, + GraphQLDeprecatedDirective, GraphQLField, GraphQLInterfaceType, GraphQLObjectType, @@ -289,7 +290,7 @@ export function fieldDeprecationReasonAddedFromMeta(args: FieldDeprecationReason }, message: buildFieldDeprecationReasonAddedMessage(args.meta), meta: args.meta, - path: [args.meta.typeName, args.meta.fieldName].join('.'), + path: [args.meta.typeName, args.meta.fieldName, GraphQLDeprecatedDirective.name].join('.'), } as const; } diff --git a/packages/core/src/diff/enum.ts b/packages/core/src/diff/enum.ts index 7fef009f4e..01fff956ae 100644 --- a/packages/core/src/diff/enum.ts +++ b/packages/core/src/diff/enum.ts @@ -57,9 +57,16 @@ function changesInEnumValue( } if (isNotEqual(oldValue?.deprecationReason, newValue.deprecationReason)) { - if (isVoid(oldValue?.deprecationReason)) { + // @note "No longer supported" is the default graphql reason + if ( + isVoid(oldValue?.deprecationReason) || + oldValue?.deprecationReason === 'No longer supported' + ) { addChange(enumValueDeprecationReasonAdded(newEnum, oldValue, newValue)); - } else if (isVoid(newValue.deprecationReason)) { + } else if ( + isVoid(newValue.deprecationReason) || + newValue?.deprecationReason === 'No longer supported' + ) { addChange(enumValueDeprecationReasonRemoved(newEnum, oldValue, newValue)); } else { addChange(enumValueDeprecationReasonChanged(newEnum, oldValue, newValue)); diff --git a/packages/core/src/diff/field.ts b/packages/core/src/diff/field.ts index c6bd2642de..1ae5e89f49 100644 --- a/packages/core/src/diff/field.ts +++ b/packages/core/src/diff/field.ts @@ -34,18 +34,24 @@ export function changesInField( } } - if (!isVoid(oldField) && isNotEqual(isDeprecated(oldField), isDeprecated(newField))) { + if (isVoid(oldField) || !isDeprecated(oldField)) { if (isDeprecated(newField)) { addChange(fieldDeprecationAdded(type, newField)); - } else { + } + } else if (!isDeprecated(newField)) { + if (isDeprecated(oldField)) { addChange(fieldDeprecationRemoved(type, oldField)); } - } else if (isVoid(oldField) && isDeprecated(newField)) { - addChange(fieldDeprecationAdded(type, newField)); - } else if (isNotEqual(oldField?.deprecationReason, newField.deprecationReason)) { - if (isVoid(oldField?.deprecationReason)) { + } else if (isNotEqual(oldField.deprecationReason, newField.deprecationReason)) { + if ( + isVoid(oldField.deprecationReason) || + oldField.deprecationReason === 'No longer supported' + ) { addChange(fieldDeprecationReasonAdded(type, newField)); - } else if (isVoid(newField.deprecationReason)) { + } else if ( + isVoid(newField.deprecationReason) || + newField.deprecationReason === 'No longer supported' + ) { addChange(fieldDeprecationReasonRemoved(type, oldField)); } else { addChange(fieldDeprecationReasonChanged(type, oldField, newField)); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e103130e1e..d0f7426768 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -178,7 +178,6 @@ export { SerializableChange, DirectiveUsageArgumentDefinitionAddedChange, DirectiveUsageArgumentDefinitionRemovedChange, - DirectiveUsageArgumentDefinitionChange, DirectiveUsageEnumAddedChange, DirectiveUsageEnumRemovedChange, DirectiveUsageEnumValueAddedChange, diff --git a/packages/patch/package.json b/packages/patch/package.json new file mode 100644 index 0000000000..bc9fd9c9d0 --- /dev/null +++ b/packages/patch/package.json @@ -0,0 +1,71 @@ +{ + "name": "@graphql-inspector/patch", + "version": "0.0.1", + "type": "module", + "description": "Applies changes output from @graphql-inspect/diff", + "repository": { + "type": "git", + "url": "graphql-hive/graphql-inspector", + "directory": "packages/patch" + }, + "author": { + "name": "Jeff Dolle", + "email": "jeff@the-guild.dev", + "url": "https://github.com/jdolle" + }, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "exports": { + ".": { + "require": { + "types": "./dist/typings/index.d.cts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + }, + "default": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./*": { + "require": { + "types": "./dist/typings/*.d.cts", + "default": "./dist/cjs/*.js" + }, + "import": { + "types": "./dist/typings/*.d.ts", + "default": "./dist/esm/*.js" + }, + "default": { + "types": "./dist/typings/*.d.ts", + "default": "./dist/esm/*.js" + } + }, + "./package.json": "./package.json" + }, + "typings": "dist/typings/index.d.ts", + "scripts": { + "prepack": "bob prepack" + }, + "dependencies": { + "tslib": "2.6.2" + }, + "devDependencies": { + "@graphql-inspector/core": "workspace:*" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "sideEffects": false, + "typescript": { + "definition": "dist/typings/index.d.ts" + } +} diff --git a/packages/patch/src/__tests__/directive-usage.test.ts b/packages/patch/src/__tests__/directive-usage.test.ts new file mode 100644 index 0000000000..44e40e29cc --- /dev/null +++ b/packages/patch/src/__tests__/directive-usage.test.ts @@ -0,0 +1,1105 @@ +import { expectPatchToMatch } from './utils.js'; + +const baseSchema = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } +`; + +describe('directiveUsages: added', () => { + test('directiveUsageArgumentDefinitionAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String! @meta(name: "owner", value: "kitchen")): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageInputFieldDefinitionAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! @meta(name: "owner", value: "kitchen") + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageInputObjectAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput @meta(name: "owner", value: "kitchen") { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageInterfaceAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food @meta(name: "owner", value: "kitchen") { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageObjectAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger @meta(name: "owner", value: "kitchen") { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageEnumAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor @meta(name: "owner", value: "kitchen") { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageFieldDefinitionAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! @meta(name: "owner", value: "kitchen") + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageUnionMemberAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack @meta(name: "owner", value: "kitchen") = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageEnumValueAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI @meta(name: "source", value: "mushrooms") + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageSchemaAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema @meta(name: "owner", value: "kitchen") { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageScalarAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories @meta(name: "owner", value: "kitchen") + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageFieldAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! @meta(name: "owner", value: "kitchen") + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); +}); + +describe('directiveUsages: removed', () => { + test('directiveUsageArgumentDefinitionRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageInputFieldDefinitionRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! @meta(name: "owner", value: "kitchen") + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageInputObjectRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput @meta(name: "owner", value: "kitchen") { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageInterfaceRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food @meta(name: "owner", value: "kitchen") { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageObjectRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger @meta(name: "owner", value: "kitchen") { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageEnumRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor @meta(name: "owner", value: "kitchen") { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageFieldDefinitionRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] @meta(name: "owner", value: "kitchen") + } + type Drink implements Food { + name: String! @meta(name: "owner", value: "kitchen") + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageUnionMemberRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack @meta(name: "owner", value: "kitchen") = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageEnumValueRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET @meta(name: "owner", value: "kitchen") + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageSchemaRemoved', async () => { + const before = /* GraphQL */ ` + schema @meta(name: "owner", value: "kitchen") { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageScalarRemoved', async () => { + const before = /* GraphQL */ ` + schema { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories @meta(name: "owner", value: "kitchen") + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/src/__tests__/directives.test.ts b/packages/patch/src/__tests__/directives.test.ts new file mode 100644 index 0000000000..de73dafaa2 --- /dev/null +++ b/packages/patch/src/__tests__/directives.test.ts @@ -0,0 +1,77 @@ +import { expectPatchToMatch } from './utils.js'; + +describe('directives', async () => { + test('directiveAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); + + test('directiveArgumentAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); + + test('directiveLocationAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION | OBJECT + `; + await expectPatchToMatch(before, after); + }); + + test('directiveArgumentDefaultValueChanged', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String = "It tastes good.") on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); + + test('directiveDescriptionChanged', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + """ + Signals that this thing is extra yummy + """ + directive @tasty(reason: String) on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); + + test('directiveArgumentTypeChanged', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/src/__tests__/enum.test.ts b/packages/patch/src/__tests__/enum.test.ts new file mode 100644 index 0000000000..8e9dcc96c3 --- /dev/null +++ b/packages/patch/src/__tests__/enum.test.ts @@ -0,0 +1,115 @@ +import { expectPatchToMatch } from './utils.js'; + +describe('enumValue', () => { + test('enumValueRemoved', async () => { + const before = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + SUPER_BROKE + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + await expectPatchToMatch(before, after); + }); + + test('enumValueAdded', async () => { + const before = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + SUPER_BROKE + } + `; + await expectPatchToMatch(before, after); + }); + + test('enumValueDeprecationReasonAdded', async () => { + const before = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + SUPER_BROKE @deprecated + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + SUPER_BROKE @deprecated(reason: "Error is enough") + } + `; + await expectPatchToMatch(before, after); + }); + + test('enumValueDescriptionChanged: Added', async () => { + const before = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + """ + The status of something. + """ + SUCCESS + ERROR + } + `; + await expectPatchToMatch(before, after); + }); + + test('enumValueDescriptionChanged: Changed', async () => { + const before = /* GraphQL */ ` + enum Status { + """ + Before + """ + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + """ + After + """ + SUCCESS + ERROR + } + `; + await expectPatchToMatch(before, after); + }); + + test('enumValueDescriptionChanged: Removed', async () => { + const before = /* GraphQL */ ` + enum Status { + """ + Before + """ + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + await expectPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/src/__tests__/fields.test.ts b/packages/patch/src/__tests__/fields.test.ts new file mode 100644 index 0000000000..546d9d54f0 --- /dev/null +++ b/packages/patch/src/__tests__/fields.test.ts @@ -0,0 +1,171 @@ +import { expectPatchToMatch } from './utils.js'; + +describe('fields', () => { + test('fieldTypeChanged', async () => { + const before = /* GraphQL */ ` + type Product { + id: ID! + } + `; + const after = /* GraphQL */ ` + type Product { + id: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldRemoved', async () => { + const before = /* GraphQL */ ` + type Product { + id: ID! + name: String + } + `; + const after = /* GraphQL */ ` + type Product { + id: ID! + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldAdded', async () => { + const before = /* GraphQL */ ` + type Product { + id: ID! + } + `; + const after = /* GraphQL */ ` + type Product { + id: ID! + name: String + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldArgumentAdded', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat(firstMessage: String): ChatSession + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldDeprecationReasonAdded', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession @deprecated + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession @deprecated(reason: "Use Query.initiateChat") + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldDeprecationAdded', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession @deprecated + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldDeprecationRemoved', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession @deprecated + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldDescriptionAdded', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + """ + Talk to a person + """ + chat: ChatSession + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldDescriptionChanged', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + """ + Talk to a person + """ + chat: ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + """ + Talk to a robot + """ + chat: ChatSession + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldDescriptionRemoved', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + """ + Talk to a person + """ + chat: ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession + } + `; + await expectPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/src/__tests__/inputs.test.ts b/packages/patch/src/__tests__/inputs.test.ts new file mode 100644 index 0000000000..b57c38284d --- /dev/null +++ b/packages/patch/src/__tests__/inputs.test.ts @@ -0,0 +1,67 @@ +import { expectPatchToMatch } from './utils.js'; + +describe('inputs', () => { + test('inputFieldAdded', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID! + other: String + } + `; + await expectPatchToMatch(before, after); + }); + + test('inputFieldRemoved', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + other: String + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + await expectPatchToMatch(before, after); + }); + + test('inputFieldDescriptionAdded', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + """ + After + """ + input FooInput { + id: ID! + } + `; + await expectPatchToMatch(before, after); + }); + + test('inputFieldDescriptionRemoved', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + await expectPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/src/__tests__/interfaces.test.ts b/packages/patch/src/__tests__/interfaces.test.ts new file mode 100644 index 0000000000..80cbe8c01d --- /dev/null +++ b/packages/patch/src/__tests__/interfaces.test.ts @@ -0,0 +1,136 @@ +import { expectPatchToMatch } from './utils.js'; + +describe('interfaces', () => { + test('objectTypeInterfaceAdded', async () => { + const before = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo { + id: ID! + } + `; + const after = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + await expectPatchToMatch(before, after); + }); + + test('objectTypeInterfaceRemoved', async () => { + const before = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + + const after = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo { + id: ID! + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldAdded', async () => { + const before = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + + const after = /* GraphQL */ ` + interface Node { + id: ID! + name: String + } + type Foo implements Node { + id: ID! + name: String + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldRemoved', async () => { + const before = /* GraphQL */ ` + interface Node { + id: ID! + name: String + } + type Foo implements Node { + id: ID! + name: String + } + `; + + const after = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageAdded', async () => { + const before = /* GraphQL */ ` + directive @meta on INTERFACE + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + + const after = /* GraphQL */ ` + directive @meta on INTERFACE + interface Node @meta { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + await expectPatchToMatch(before, after); + }); + + test('directiveUsageRemoved', async () => { + const before = /* GraphQL */ ` + directive @meta on INTERFACE + interface Node @meta { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + + const after = /* GraphQL */ ` + directive @meta on INTERFACE + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + await expectPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/src/__tests__/types.test.ts b/packages/patch/src/__tests__/types.test.ts new file mode 100644 index 0000000000..375cf55e08 --- /dev/null +++ b/packages/patch/src/__tests__/types.test.ts @@ -0,0 +1,87 @@ +import { expectPatchToMatch } from './utils.js'; + +describe('enum', () => { + test('typeRemoved', async () => { + const before = /* GraphQL */ ` + scalar Foo + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + scalar Foo + `; + await expectPatchToMatch(before, after); + }); + + test('typeAdded', async () => { + const before = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + SUPER_BROKE + } + `; + await expectPatchToMatch(before, after); + }); + + test('typeDescriptionChanged: Added', async () => { + const before = /* GraphQL */ ` + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + """ + The status of something. + """ + enum Status { + OK + } + `; + await expectPatchToMatch(before, after); + }); + + test('typeDescriptionChanged: Changed', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + """ + After + """ + enum Status { + OK + } + `; + await expectPatchToMatch(before, after); + }); + + test('typeDescriptionChanged: Removed', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + enum Status { + OK + } + `; + await expectPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/src/__tests__/unions.test.ts b/packages/patch/src/__tests__/unions.test.ts new file mode 100644 index 0000000000..61eb4df41e --- /dev/null +++ b/packages/patch/src/__tests__/unions.test.ts @@ -0,0 +1,47 @@ +import { expectPatchToMatch } from './utils.js'; + +describe('union', () => { + test('unionMemberAdded', async () => { + const before = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + union U = A + `; + const after = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + union U = A | B + `; + await expectPatchToMatch(before, after); + }); + + test('unionMemberRemoved', async () => { + const before = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + union U = A | B + `; + const after = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + union U = A + `; + await expectPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/src/__tests__/utils.ts b/packages/patch/src/__tests__/utils.ts new file mode 100644 index 0000000000..7c6bdfa4fb --- /dev/null +++ b/packages/patch/src/__tests__/utils.ts @@ -0,0 +1,17 @@ +import { buildSchema, GraphQLSchema, lexicographicSortSchema, printSchema } from 'graphql'; +import { Change, diff } from '@graphql-inspector/core'; +import { patchSchema } from '../index.js'; + +function printSortedSchema(schema: GraphQLSchema) { + return printSchema(lexicographicSortSchema(schema)); +} + +export async function expectPatchToMatch(before: string, after: string): Promise[]> { + const schemaA = buildSchema(before, { assumeValid: true, assumeValidSDL: true }); + const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); + + const changes = await diff(schemaA, schemaB); + const patched = patchSchema(schemaA, changes, { throwOnError: true }); + expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patched)); + return changes; +} diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts new file mode 100644 index 0000000000..5f61e68649 --- /dev/null +++ b/packages/patch/src/errors.ts @@ -0,0 +1,157 @@ +import { Kind } from 'graphql'; +import type { Change } from '@graphql-inspector/core'; +import type { PatchConfig } from './types.js'; + +export function handleError(change: Change, err: Error, config: PatchConfig) { + if (err instanceof NoopError) { + console.debug( + `Ignoring change ${change.type} at "${change.path}" because it does not modify the resulting schema.`, + ); + } else if (config.throwOnError === true) { + throw err; + } else { + console.warn(`Cannot apply ${change.type} at "${change.path}". ${err.message}`); + } +} + +/** + * When the change does not actually modify the resulting schema, then it is + * considered a "no-op". This error can safely be ignored. + */ +export class NoopError extends Error { + readonly noop = true; + constructor(message: string) { + super(`The change resulted in a no op. ${message}`); + } +} + +export class CoordinateNotFoundError extends Error { + constructor() { + super('Cannot find an element at the schema coordinate.'); + } +} + +export class DeletedCoordinateNotFoundError extends NoopError { + constructor() { + super('Cannot find an element at the schema coordinate.'); + } +} + +export class CoordinateAlreadyExistsError extends NoopError { + constructor(public readonly kind: Kind) { + super(`A "${kind}" already exists at the schema coordinate.`); + } +} + +export class DeprecationReasonAlreadyExists extends NoopError { + constructor(reason: string) { + super(`A deprecation reason already exists: "${reason}"`); + } +} + +export class DeprecatedDirectiveNotFound extends NoopError { + constructor() { + super('This coordinate is not deprecated.'); + } +} + +export class EnumValueNotFoundError extends Error { + constructor(typeName: string, value?: string | undefined) { + super(`The enum "${typeName}" does not contain "${value}".`); + } +} + +export class UnionMemberNotFoundError extends NoopError { + constructor() { + super(`The union does not contain the member.`); + } +} + +export class UnionMemberAlreadyExistsError extends NoopError { + constructor(typeName: string, type: string) { + super(`The union "${typeName}" already contains the member "${type}".`); + } +} + +export class DirectiveLocationAlreadyExistsError extends NoopError { + constructor(directiveName: string, location: string) { + super(`The directive "${directiveName}" already can be located on "${location}".`); + } +} + +export class DirectiveAlreadyExists extends NoopError { + constructor(directiveName: string) { + super(`The directive "${directiveName}" already exists.`); + } +} + +export class KindMismatchError extends Error { + constructor( + public readonly expectedKind: Kind, + public readonly receivedKind: Kind, + ) { + super(`Expected type to have be a "${expectedKind}", but found a "${receivedKind}".`); + } +} + +export class FieldTypeMismatchError extends Error { + constructor(expectedReturnType: string, receivedReturnType: string) { + super(`Expected the field to return ${expectedReturnType} but found ${receivedReturnType}.`); + } +} + +export class OldValueMismatchError extends Error { + constructor( + expectedValue: string | null | undefined, + receivedOldValue: string | null | undefined, + ) { + super(`Expected the value ${expectedValue} but found ${receivedOldValue}.`); + } +} + +export class OldTypeMismatchError extends Error { + constructor(expectedType: string | null | undefined, receivedOldType: string | null | undefined) { + super(`Expected the type ${expectedType} but found ${receivedOldType}.`); + } +} + +export class InterfaceAlreadyExistsOnTypeError extends NoopError { + constructor(interfaceName: string) { + super( + `Cannot add the interface "${interfaceName}" because it already is applied at that coordinate.`, + ); + } +} + +export class ArgumentDefaultValueMismatchError extends Error { + constructor( + expectedDefaultValue: string | undefined | null, + actualDefaultValue: string | undefined | null, + ) { + super( + `The argument's default value "${actualDefaultValue}" does not match the expected value "${expectedDefaultValue}".`, + ); + } +} + +export class ArgumentDescriptionMismatchError extends Error { + constructor( + expectedDefaultValue: string | undefined | null, + actualDefaultValue: string | undefined | null, + ) { + super( + `The argument's description "${actualDefaultValue}" does not match the expected "${expectedDefaultValue}".`, + ); + } +} + +export class DescriptionMismatchError extends NoopError { + constructor( + expectedDescription: string | undefined | null, + actualDescription: string | undefined | null, + ) { + super( + `The description, "${actualDescription}", does not the expected description, "${expectedDescription}".`, + ); + } +} diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts new file mode 100644 index 0000000000..8ac17630fd --- /dev/null +++ b/packages/patch/src/index.ts @@ -0,0 +1,455 @@ +import { + ASTNode, + buildASTSchema, + DocumentNode, + GraphQLSchema, + isDefinitionNode, + Kind, + parse, + printSchema, + visit, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + directiveUsageArgumentDefinitionAdded, + directiveUsageArgumentDefinitionRemoved, + directiveUsageEnumAdded, + directiveUsageEnumRemoved, + directiveUsageEnumValueAdded, + directiveUsageEnumValueRemoved, + directiveUsageFieldAdded, + directiveUsageFieldDefinitionAdded, + directiveUsageFieldDefinitionRemoved, + directiveUsageFieldRemoved, + directiveUsageInputFieldDefinitionAdded, + directiveUsageInputFieldDefinitionRemoved, + directiveUsageInputObjectAdded, + directiveUsageInputObjectRemoved, + directiveUsageInterfaceAdded, + directiveUsageInterfaceRemoved, + directiveUsageObjectAdded, + directiveUsageObjectRemoved, + directiveUsageScalarAdded, + directiveUsageScalarRemoved, + directiveUsageSchemaAdded, + directiveUsageSchemaRemoved, + directiveUsageUnionMemberAdded, + directiveUsageUnionMemberRemoved, +} from './patches/directive-usages.js'; +import { + directiveAdded, + directiveArgumentAdded, + directiveArgumentDefaultValueChanged, + directiveArgumentDescriptionChanged, + directiveArgumentTypeChanged, + directiveDescriptionChanged, + directiveLocationAdded, +} from './patches/directives.js'; +import { + enumValueAdded, + enumValueDeprecationReasonAdded, + enumValueDeprecationReasonChanged, + enumValueDescriptionChanged, + enumValueRemoved, +} from './patches/enum.js'; +import { + fieldAdded, + fieldArgumentAdded, + fieldDeprecationAdded, + fieldDeprecationReasonAdded, + fieldDeprecationRemoved, + fieldDescriptionAdded, + fieldDescriptionChanged, + fieldDescriptionRemoved, + fieldRemoved, + fieldTypeChanged, +} from './patches/fields.js'; +import { + inputFieldAdded, + inputFieldDescriptionAdded, + inputFieldRemoved, +} from './patches/inputs.js'; +import { objectTypeInterfaceAdded, objectTypeInterfaceRemoved } from './patches/interfaces.js'; +import { + schemaMutationTypeChanged, + schemaQueryTypeChanged, + schemaSubscriptionTypeChanged, +} from './patches/schema.js'; +import { + typeAdded, + typeDescriptionAdded, + typeDescriptionChanged, + typeDescriptionRemoved, + typeRemoved, +} from './patches/types.js'; +import { unionMemberAdded, unionMemberRemoved } from './patches/unions.js'; +import { PatchConfig, SchemaNode } from './types.js'; +import { debugPrintChange } from './utils.js'; + +export function patchSchema( + schema: GraphQLSchema, + changes: Change[], + config?: PatchConfig, +): GraphQLSchema { + const ast = parse(printSchema(schema)); + return buildASTSchema(patch(ast, changes, config), { assumeValid: true, assumeValidSDL: true }); +} + +function groupNodesByPath(ast: DocumentNode): [SchemaNode[], Map] { + const schemaNodes: SchemaNode[] = []; + const nodeByPath = new Map(); + const pathArray: string[] = []; + visit(ast, { + enter(node) { + switch (node.kind) { + case Kind.ARGUMENT: + case Kind.ENUM_TYPE_DEFINITION: + case Kind.ENUM_TYPE_EXTENSION: + case Kind.ENUM_VALUE_DEFINITION: + case Kind.FIELD_DEFINITION: + case Kind.INPUT_OBJECT_TYPE_DEFINITION: + case Kind.INPUT_OBJECT_TYPE_EXTENSION: + case Kind.INPUT_VALUE_DEFINITION: + case Kind.INTERFACE_TYPE_DEFINITION: + case Kind.INTERFACE_TYPE_EXTENSION: + case Kind.OBJECT_FIELD: + case Kind.OBJECT_TYPE_DEFINITION: + case Kind.OBJECT_TYPE_EXTENSION: + case Kind.SCALAR_TYPE_DEFINITION: + case Kind.SCALAR_TYPE_EXTENSION: + case Kind.UNION_TYPE_DEFINITION: + case Kind.UNION_TYPE_EXTENSION: + case Kind.DIRECTIVE: { + pathArray.push(node.name.value); + const path = pathArray.join('.'); + nodeByPath.set(path, node); + break; + } + case Kind.DIRECTIVE_DEFINITION: { + pathArray.push(`@${node.name.value}`); + const path = pathArray.join('.'); + nodeByPath.set(path, node); + break; + } + case Kind.DOCUMENT: { + break; + } + case Kind.SCHEMA_EXTENSION: + case Kind.SCHEMA_DEFINITION: { + schemaNodes.push(node); + break; + } + default: { + // by definition this things like return types, names, named nodes... + // it's nothing we want to collect. + return false; + } + } + }, + leave(node) { + switch (node.kind) { + case Kind.ARGUMENT: + case Kind.ENUM_TYPE_DEFINITION: + case Kind.ENUM_TYPE_EXTENSION: + case Kind.ENUM_VALUE_DEFINITION: + case Kind.FIELD_DEFINITION: + case Kind.INPUT_OBJECT_TYPE_DEFINITION: + case Kind.INPUT_OBJECT_TYPE_EXTENSION: + case Kind.INPUT_VALUE_DEFINITION: + case Kind.INTERFACE_TYPE_DEFINITION: + case Kind.INTERFACE_TYPE_EXTENSION: + case Kind.OBJECT_FIELD: + case Kind.OBJECT_TYPE_DEFINITION: + case Kind.OBJECT_TYPE_EXTENSION: + case Kind.SCALAR_TYPE_DEFINITION: + case Kind.SCALAR_TYPE_EXTENSION: + case Kind.UNION_TYPE_DEFINITION: + case Kind.UNION_TYPE_EXTENSION: + case Kind.DIRECTIVE: + case Kind.DIRECTIVE_DEFINITION: { + pathArray.pop(); + } + } + }, + }); + return [schemaNodes, nodeByPath]; +} + +export function patch( + ast: DocumentNode, + changes: Change[], + patchConfig?: PatchConfig, +): DocumentNode { + const config: PatchConfig = patchConfig ?? {}; + + const [schemaDefs, nodeByPath] = groupNodesByPath(ast); + + for (const change of changes) { + if (config.debug) { + debugPrintChange(change, nodeByPath); + } + + const changedPath = change.path; + if (changedPath === undefined) { + // a change without a path is useless... (@todo Only schema changes do this?) + continue; + } + + switch (change.type) { + case ChangeType.SchemaMutationTypeChanged: { + schemaMutationTypeChanged(change, schemaDefs, config); + break; + } + case ChangeType.SchemaQueryTypeChanged: { + schemaQueryTypeChanged(change, schemaDefs, config); + break; + } + case ChangeType.SchemaSubscriptionTypeChanged: { + schemaSubscriptionTypeChanged(change, schemaDefs, config); + break; + } + case ChangeType.DirectiveAdded: { + directiveAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveArgumentAdded: { + directiveArgumentAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveLocationAdded: { + directiveLocationAdded(change, nodeByPath, config); + break; + } + case ChangeType.EnumValueAdded: { + enumValueAdded(change, nodeByPath, config); + break; + } + case ChangeType.EnumValueDeprecationReasonAdded: { + enumValueDeprecationReasonAdded(change, nodeByPath, config); + break; + } + case ChangeType.EnumValueDeprecationReasonChanged: { + enumValueDeprecationReasonChanged(change, nodeByPath, config); + break; + } + case ChangeType.FieldAdded: { + fieldAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldRemoved: { + fieldRemoved(change, nodeByPath, config); + break; + } + case ChangeType.FieldTypeChanged: { + fieldTypeChanged(change, nodeByPath, config); + break; + } + case ChangeType.FieldArgumentAdded: { + fieldArgumentAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldDeprecationAdded: { + fieldDeprecationAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldDeprecationRemoved: { + fieldDeprecationRemoved(change, nodeByPath, config); + break; + } + case ChangeType.FieldDeprecationReasonAdded: { + fieldDeprecationReasonAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldDescriptionAdded: { + fieldDescriptionAdded(change, nodeByPath, config); + break; + } + case ChangeType.FieldDescriptionChanged: { + fieldDescriptionChanged(change, nodeByPath, config); + break; + } + case ChangeType.InputFieldAdded: { + inputFieldAdded(change, nodeByPath, config); + break; + } + case ChangeType.InputFieldRemoved: { + inputFieldRemoved(change, nodeByPath, config); + break; + } + case ChangeType.InputFieldDescriptionAdded: { + inputFieldDescriptionAdded(change, nodeByPath, config); + break; + } + case ChangeType.ObjectTypeInterfaceAdded: { + objectTypeInterfaceAdded(change, nodeByPath, config); + break; + } + case ChangeType.ObjectTypeInterfaceRemoved: { + objectTypeInterfaceRemoved(change, nodeByPath, config); + break; + } + case ChangeType.TypeDescriptionAdded: { + typeDescriptionAdded(change, nodeByPath, config); + break; + } + case ChangeType.TypeDescriptionChanged: { + typeDescriptionChanged(change, nodeByPath, config); + break; + } + case ChangeType.TypeDescriptionRemoved: { + typeDescriptionRemoved(change, nodeByPath, config); + break; + } + case ChangeType.TypeAdded: { + typeAdded(change, nodeByPath, config); + break; + } + case ChangeType.UnionMemberAdded: { + unionMemberAdded(change, nodeByPath, config); + break; + } + case ChangeType.UnionMemberRemoved: { + unionMemberRemoved(change, nodeByPath, config); + break; + } + case ChangeType.TypeRemoved: { + typeRemoved(change, nodeByPath, config); + break; + } + case ChangeType.EnumValueRemoved: { + enumValueRemoved(change, nodeByPath, config); + break; + } + case ChangeType.EnumValueDescriptionChanged: { + enumValueDescriptionChanged(change, nodeByPath, config); + break; + } + case ChangeType.FieldDescriptionRemoved: { + fieldDescriptionRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveArgumentDefaultValueChanged: { + directiveArgumentDefaultValueChanged(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveArgumentDescriptionChanged: { + directiveArgumentDescriptionChanged(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveArgumentTypeChanged: { + directiveArgumentTypeChanged(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveDescriptionChanged: { + directiveDescriptionChanged(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageArgumentDefinitionAdded: { + directiveUsageArgumentDefinitionAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageArgumentDefinitionRemoved: { + directiveUsageArgumentDefinitionRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageEnumAdded: { + directiveUsageEnumAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageEnumRemoved: { + directiveUsageEnumRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageEnumValueAdded: { + directiveUsageEnumValueAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageEnumValueRemoved: { + directiveUsageEnumValueRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageFieldAdded: { + directiveUsageFieldAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageFieldDefinitionAdded: { + directiveUsageFieldDefinitionAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageFieldDefinitionRemoved: { + directiveUsageFieldDefinitionRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageFieldRemoved: { + directiveUsageFieldRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageInputFieldDefinitionAdded: { + directiveUsageInputFieldDefinitionAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageInputFieldDefinitionRemoved: { + directiveUsageInputFieldDefinitionRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageInputObjectAdded: { + directiveUsageInputObjectAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageInputObjectRemoved: { + directiveUsageInputObjectRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageInterfaceAdded: { + directiveUsageInterfaceAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageInterfaceRemoved: { + directiveUsageInterfaceRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageObjectAdded: { + directiveUsageObjectAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageObjectRemoved: { + directiveUsageObjectRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageScalarAdded: { + directiveUsageScalarAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageScalarRemoved: { + directiveUsageScalarRemoved(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageSchemaAdded: { + directiveUsageSchemaAdded(change, schemaDefs, config); + break; + } + case ChangeType.DirectiveUsageSchemaRemoved: { + directiveUsageSchemaRemoved(change, schemaDefs, config); + break; + } + case ChangeType.DirectiveUsageUnionMemberAdded: { + directiveUsageUnionMemberAdded(change, nodeByPath, config); + break; + } + case ChangeType.DirectiveUsageUnionMemberRemoved: { + directiveUsageUnionMemberRemoved(change, nodeByPath, config); + break; + } + default: { + console.log(`${change.type} is not implemented yet.`); + } + } + } + + return { + kind: Kind.DOCUMENT, + + // filter out the non-definition nodes (e.g. field definitions) + definitions: Array.from(nodeByPath.values()).filter(isDefinitionNode), + }; +} diff --git a/packages/patch/src/node-templates.ts b/packages/patch/src/node-templates.ts new file mode 100644 index 0000000000..09845db469 --- /dev/null +++ b/packages/patch/src/node-templates.ts @@ -0,0 +1,29 @@ +import { Kind, NamedTypeNode, NameNode, StringValueNode, TypeNode } from 'graphql'; + +export function nameNode(name: string): NameNode { + return { + value: name, + kind: Kind.NAME, + }; +} + +export function stringNode(value: string): StringValueNode { + return { + kind: Kind.STRING, + value, + }; +} + +export function typeNode(name: string): TypeNode { + return { + kind: Kind.NAMED_TYPE, + name: nameNode(name), + }; +} + +export function namedTypeNode(name: string): NamedTypeNode { + return { + kind: Kind.NAMED_TYPE, + name: nameNode(name), + }; +} diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts new file mode 100644 index 0000000000..9a4ca7da85 --- /dev/null +++ b/packages/patch/src/patches/directive-usages.ts @@ -0,0 +1,275 @@ +import { ASTNode, DirectiveNode, Kind } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + DeletedCoordinateNotFoundError, + handleError, +} from '../errors.js'; +import { nameNode } from '../node-templates.js'; +import { PatchConfig, SchemaNode } from '../types.js'; +import { parentPath } from '../utils.js'; + +export type DirectiveUsageAddedChange = + | typeof ChangeType.DirectiveUsageArgumentDefinitionAdded + | typeof ChangeType.DirectiveUsageInputFieldDefinitionAdded + | typeof ChangeType.DirectiveUsageInputObjectAdded + | typeof ChangeType.DirectiveUsageInterfaceAdded + | typeof ChangeType.DirectiveUsageObjectAdded + | typeof ChangeType.DirectiveUsageEnumAdded + | typeof ChangeType.DirectiveUsageFieldDefinitionAdded + | typeof ChangeType.DirectiveUsageUnionMemberAdded + | typeof ChangeType.DirectiveUsageEnumValueAdded + | typeof ChangeType.DirectiveUsageSchemaAdded + | typeof ChangeType.DirectiveUsageScalarAdded + | typeof ChangeType.DirectiveUsageFieldAdded; + +export type DirectiveUsageRemovedChange = + | typeof ChangeType.DirectiveUsageArgumentDefinitionRemoved + | typeof ChangeType.DirectiveUsageInputFieldDefinitionRemoved + | typeof ChangeType.DirectiveUsageInputObjectRemoved + | typeof ChangeType.DirectiveUsageInterfaceRemoved + | typeof ChangeType.DirectiveUsageObjectRemoved + | typeof ChangeType.DirectiveUsageEnumRemoved + | typeof ChangeType.DirectiveUsageFieldDefinitionRemoved + | typeof ChangeType.DirectiveUsageFieldRemoved + | typeof ChangeType.DirectiveUsageUnionMemberRemoved + | typeof ChangeType.DirectiveUsageEnumValueRemoved + | typeof ChangeType.DirectiveUsageSchemaRemoved + | typeof ChangeType.DirectiveUsageScalarRemoved; + +function directiveUsageDefinitionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const directiveNode = nodeByPath.get(change.path!); + const parentNode = nodeByPath.get(parentPath(change.path!)) as + | { directives?: DirectiveNode[] } + | undefined; + if (directiveNode) { + handleError(change, new CoordinateAlreadyExistsError(directiveNode.kind), config); + } else if (parentNode) { + const newDirective: DirectiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(change.meta.addedDirectiveName), + }; + parentNode.directives = [...(parentNode.directives ?? []), newDirective]; + nodeByPath.set(change.path!, newDirective); + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +function directiveUsageDefinitionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const directiveNode = nodeByPath.get(change.path!); + const parentNode = nodeByPath.get(parentPath(change.path!)) as + | { directives?: DirectiveNode[] } + | undefined; + if (directiveNode && parentNode) { + parentNode.directives = parentNode.directives?.filter( + d => d.name.value !== change.meta.removedDirectiveName, + ); + nodeByPath.delete(change.path!); + } else { + handleError(change, new DeletedCoordinateNotFoundError(), config); + } +} + +export function directiveUsageArgumentDefinitionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageArgumentDefinitionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageEnumAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageEnumRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageEnumValueAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageEnumValueRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageFieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageFieldDefinitionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageFieldDefinitionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageFieldRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageInputFieldDefinitionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageInputFieldDefinitionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageInputObjectAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageInputObjectRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageInterfaceAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageInterfaceRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageObjectAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageObjectRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageScalarAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageScalarRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} + +export function directiveUsageSchemaAdded( + _change: Change, + _schemaDefs: SchemaNode[], + _config: PatchConfig, +) { + // @todo + // return directiveUsageDefinitionAdded(change, schemaDefs, config); +} + +export function directiveUsageSchemaRemoved( + _change: Change, + _schemaDefs: SchemaNode[], + _config: PatchConfig, +) { + // @todo + // return directiveUsageDefinitionRemoved(change, schemaDefs, config); +} + +export function directiveUsageUnionMemberAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config); +} + +export function directiveUsageUnionMemberRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config); +} diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts new file mode 100644 index 0000000000..52132a54b3 --- /dev/null +++ b/packages/patch/src/patches/directives.ts @@ -0,0 +1,263 @@ +import { + ASTNode, + DirectiveDefinitionNode, + InputValueDefinitionNode, + Kind, + NameNode, + parseConstValue, + parseType, + print, + StringValueNode, + TypeNode, + ValueNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + ArgumentDefaultValueMismatchError, + ArgumentDescriptionMismatchError, + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + DirectiveLocationAlreadyExistsError, + handleError, + KindMismatchError, + OldTypeMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import { PatchConfig } from '../types.js'; + +export function directiveAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const changedNode = nodeByPath.get(changedPath); + if (changedNode) { + handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); + } else { + const node: DirectiveDefinitionNode = { + kind: Kind.DIRECTIVE_DEFINITION, + name: nameNode(change.meta.addedDirectiveName), + repeatable: change.meta.addedDirectiveRepeatable, + locations: change.meta.addedDirectiveLocations.map(l => nameNode(l)), + description: change.meta.addedDirectiveDescription + ? stringNode(change.meta.addedDirectiveDescription) + : undefined, + }; + nodeByPath.set(changedPath, node); + } +} + +export function directiveArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const directiveNode = nodeByPath.get(changedPath); + if (!directiveNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { + const existingArg = directiveNode.arguments?.find( + d => d.name.value === change.meta.addedDirectiveArgumentName, + ); + if (existingArg) { + // @todo make sure to check that everything is equal to the change, else error + // because it conflicts. + // if (print(existingArg.type) === change.meta.addedDirectiveArgumentType) { + // // warn + // // handleError(change, new ArgumentAlreadyExistsError(), config); + // } else { + // // error + // } + } else { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedDirectiveArgumentName), + type: parseType(change.meta.addedDirectiveArgumentType), + }; + (directiveNode.arguments as InputValueDefinitionNode[] | undefined) = [ + ...(directiveNode.arguments ?? []), + node, + ]; + nodeByPath.set(`${changedPath}.${change.meta.addedDirectiveArgumentName}`, node); + } + } else { + handleError( + change, + new KindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + config, + ); + } +} + +export function directiveLocationAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const changedNode = nodeByPath.get(changedPath); + if (changedNode) { + if (changedNode.kind === Kind.DIRECTIVE_DEFINITION) { + if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { + handleError( + change, + new DirectiveLocationAlreadyExistsError( + change.meta.directiveName, + change.meta.addedDirectiveLocation, + ), + config, + ); + } else { + (changedNode.locations as NameNode[]) = [ + ...changedNode.locations, + nameNode(change.meta.addedDirectiveLocation), + ]; + } + } else { + handleError( + change, + new KindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function directiveDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const directiveNode = nodeByPath.get(changedPath); + if (!directiveNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { + // eslint-disable-next-line eqeqeq + if (directiveNode.description?.value == change.meta.oldDirectiveDescription) { + (directiveNode.description as StringValueNode | undefined) = change.meta + .newDirectiveDescription + ? stringNode(change.meta.newDirectiveDescription) + : undefined; + } else { + handleError( + change, + new ArgumentDescriptionMismatchError( + change.meta.oldDirectiveDescription, + directiveNode.description?.value, + ), + config, + ); + } + } else { + handleError( + change, + new KindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + config, + ); + } +} + +export function directiveArgumentDefaultValueChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const argumentNode = nodeByPath.get(changedPath); + if (!argumentNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if ( + (argumentNode.defaultValue && print(argumentNode.defaultValue)) === + change.meta.oldDirectiveArgumentDefaultValue + ) { + (argumentNode.defaultValue as ValueNode | undefined) = change.meta + .newDirectiveArgumentDefaultValue + ? parseConstValue(change.meta.newDirectiveArgumentDefaultValue) + : undefined; + } else { + handleError( + change, + new ArgumentDefaultValueMismatchError( + change.meta.oldDirectiveArgumentDefaultValue, + argumentNode.defaultValue && print(argumentNode.defaultValue), + ), + config, + ); + } + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + config, + ); + } +} + +export function directiveArgumentDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const argumentNode = nodeByPath.get(changedPath); + if (!argumentNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { + // eslint-disable-next-line eqeqeq + if (argumentNode.description?.value == change.meta.oldDirectiveArgumentDescription) { + (argumentNode.description as StringValueNode | undefined) = change.meta + .newDirectiveArgumentDescription + ? stringNode(change.meta.newDirectiveArgumentDescription) + : undefined; + } else { + handleError( + change, + new ArgumentDescriptionMismatchError( + change.meta.oldDirectiveArgumentDescription, + argumentNode.description?.value, + ), + config, + ); + } + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + config, + ); + } +} + +export function directiveArgumentTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const argumentNode = nodeByPath.get(changedPath); + if (!argumentNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if (print(argumentNode.type) === change.meta.oldDirectiveArgumentType) { + (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); + } else { + handleError( + change, + new OldTypeMismatchError(change.meta.oldDirectiveArgumentType, print(argumentNode.type)), + config, + ); + } + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + config, + ); + } +} diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts new file mode 100644 index 0000000000..c4ee966bd4 --- /dev/null +++ b/packages/patch/src/patches/enum.ts @@ -0,0 +1,195 @@ +import { ASTNode, EnumValueDefinitionNode, Kind, print, StringValueNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + EnumValueNotFoundError, + handleError, + KindMismatchError, + OldValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig } from '../types'; +import { getDeprecatedDirectiveNode, parentPath, upsertArgument } from '../utils.js'; + +export function enumValueRemoved( + removal: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = removal.path!; + const enumNode = nodeByPath.get(parentPath(changedPath)) as + | (ASTNode & { values?: EnumValueDefinitionNode[] }) + | undefined; + if (!enumNode) { + handleError(removal, new CoordinateNotFoundError(), config); + } else if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { + handleError(removal, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config); + } else if (enumNode.values === undefined || enumNode.values.length === 0) { + handleError( + removal, + new EnumValueNotFoundError(removal.meta.enumName, removal.meta.removedEnumValueName), + config, + ); + } else { + const beforeLength = enumNode.values.length; + enumNode.values = enumNode.values.filter( + f => f.name.value !== removal.meta.removedEnumValueName, + ); + if (beforeLength === enumNode.values.length) { + handleError( + removal, + new EnumValueNotFoundError(removal.meta.enumName, removal.meta.removedEnumValueName), + config, + ); + } else { + // delete the reference to the removed field. + nodeByPath.delete(changedPath); + } + } +} + +export function enumValueAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const enumValuePath = change.path!; + const enumNode = nodeByPath.get(parentPath(enumValuePath)) as + | (ASTNode & { values: EnumValueDefinitionNode[] }) + | undefined; + const changedNode = nodeByPath.get(enumValuePath); + if (!enumNode) { + handleError(change, new CoordinateNotFoundError(), config); + console.warn( + `Cannot apply change: ${change.type} to ${enumValuePath}. Parent type is missing.`, + ); + } else if (changedNode) { + handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); + } else if (enumNode.kind === Kind.ENUM_TYPE_DEFINITION) { + const c = change as Change; + const node: EnumValueDefinitionNode = { + kind: Kind.ENUM_VALUE_DEFINITION, + name: nameNode(c.meta.addedEnumValueName), + description: c.meta.addedDirectiveDescription + ? stringNode(c.meta.addedDirectiveDescription) + : undefined, + }; + (enumNode.values as EnumValueDefinitionNode[]) = [...(enumNode.values ?? []), node]; + nodeByPath.set(enumValuePath, node); + } else { + handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config); + } +} + +export function enumValueDeprecationReasonAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const enumValueNode = nodeByPath.get(changedPath); + if (enumValueNode) { + if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { + const deprecation = getDeprecatedDirectiveNode(enumValueNode); + if (deprecation) { + const argNode = upsertArgument( + deprecation, + 'reason', + stringNode(change.meta.addedValueDeprecationReason), + ); + nodeByPath.set(`${changedPath}.reason`, argNode); + } else { + handleError(change, new CoordinateNotFoundError(), config); + } + } else { + handleError( + change, + new KindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + config, + ); + } + } else { + handleError(change, new EnumValueNotFoundError(change.meta.enumName), config); + } +} + +export function enumValueDeprecationReasonChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const deprecatedNode = nodeByPath.get(changedPath); + if (deprecatedNode) { + if (deprecatedNode.kind === Kind.DIRECTIVE) { + const reasonArgNode = deprecatedNode.arguments?.find(n => n.name.value === 'reason'); + if (reasonArgNode) { + if (reasonArgNode.kind === Kind.ARGUMENT) { + if ( + reasonArgNode.value && + print(reasonArgNode.value) === change.meta.oldEnumValueDeprecationReason + ) { + (reasonArgNode.value as StringValueNode | undefined) = stringNode( + change.meta.newEnumValueDeprecationReason, + ); + } else { + handleError( + change, + new OldValueMismatchError( + change.meta.oldEnumValueDeprecationReason, + reasonArgNode.value && print(reasonArgNode.value), + ), + config, + ); + } + } else { + handleError(change, new KindMismatchError(Kind.ARGUMENT, reasonArgNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } + } else { + handleError(change, new KindMismatchError(Kind.DIRECTIVE, deprecatedNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function enumValueDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const enumValueNode = nodeByPath.get(changedPath); + if (enumValueNode) { + if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { + // eslint-disable-next-line eqeqeq + if (change.meta.oldEnumValueDescription == enumValueNode.description?.value) { + (enumValueNode.description as StringValueNode | undefined) = change.meta + .newEnumValueDescription + ? stringNode(change.meta.newEnumValueDescription) + : undefined; + } else { + handleError( + change, + new OldValueMismatchError( + change.meta.oldEnumValueDescription, + enumValueNode.description?.value, + ), + config, + ); + } + } else { + handleError( + change, + new KindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + config, + ); + } + } else { + handleError(change, new EnumValueNotFoundError(change.meta.enumName), config); + } +} diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts new file mode 100644 index 0000000000..eed0ad4e90 --- /dev/null +++ b/packages/patch/src/patches/fields.ts @@ -0,0 +1,361 @@ +import { + ArgumentNode, + ASTNode, + DirectiveNode, + FieldDefinitionNode, + GraphQLDeprecatedDirective, + InputValueDefinitionNode, + Kind, + parseType, + print, + StringValueNode, + TypeNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + DeprecatedDirectiveNotFound, + DeprecationReasonAlreadyExists, + DescriptionMismatchError, + DirectiveAlreadyExists, + FieldTypeMismatchError, + handleError, + KindMismatchError, + OldValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig } from '../types'; +import { getDeprecatedDirectiveNode, parentPath } from '../utils.js'; + +export function fieldTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const c = change as Change; + const node = nodeByPath.get(c.path!); + if (node) { + if (node.kind === Kind.FIELD_DEFINITION) { + const currentReturnType = print(node.type); + if (c.meta.oldFieldType === currentReturnType) { + (node.type as TypeNode) = parseType(c.meta.newFieldType); + } else { + handleError(c, new FieldTypeMismatchError(c.meta.oldFieldType, currentReturnType), config); + } + } else { + handleError(c, new KindMismatchError(Kind.FIELD_DEFINITION, node.kind), config); + } + } else { + handleError(c, new CoordinateNotFoundError(), config); + } +} + +export function fieldRemoved( + removal: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = removal.path!; + const typeNode = nodeByPath.get(parentPath(changedPath)) as + | (ASTNode & { fields?: FieldDefinitionNode[] }) + | undefined; + if (!typeNode || !typeNode.fields?.length) { + handleError(removal, new CoordinateNotFoundError(), config); + } else { + const beforeLength = typeNode.fields.length; + typeNode.fields = typeNode.fields.filter(f => f.name.value !== removal.meta.removedFieldName); + if (beforeLength === typeNode.fields.length) { + handleError(removal, new CoordinateNotFoundError(), config); + } else { + // delete the reference to the removed field. + nodeByPath.delete(changedPath); + } + } +} + +export function fieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const changedNode = nodeByPath.get(changedPath); + if (changedNode) { + handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); + } else { + const typeNode = nodeByPath.get(parentPath(changedPath)) as ASTNode & { + fields?: FieldDefinitionNode[]; + }; + if (!typeNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), config); + } else { + const node: FieldDefinitionNode = { + kind: Kind.FIELD_DEFINITION, + name: nameNode(change.meta.addedFieldName), + type: parseType(change.meta.addedFieldReturnType), + // description: change.meta.addedFieldDescription + // ? stringNode(change.meta.addedFieldDescription) + // : undefined, + }; + + typeNode.fields = [...(typeNode.fields ?? []), node]; + + // add new field to the node set + nodeByPath.set(changedPath, node); + } + } +} + +export function fieldArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const existing = nodeByPath.get(changedPath); + if (existing) { + handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); + } else { + const fieldNode = nodeByPath.get(parentPath(changedPath)) as ASTNode & { + arguments?: InputValueDefinitionNode[]; + }; + if (!fieldNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (fieldNode.kind === Kind.FIELD_DEFINITION) { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + // description: change.meta.addedArgumentDescription + // ? stringNode(change.meta.addedArgumentDescription) + // : undefined, + }; + + fieldNode.arguments = [...(fieldNode.arguments ?? []), node]; + + // add new field to the node set + nodeByPath.set(changedPath, node); + } else { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } + } +} + +export function fieldDeprecationReasonChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const deprecationNode = nodeByPath.get(changedPath); + if (deprecationNode) { + if (deprecationNode.kind === Kind.DIRECTIVE) { + const reasonArgument = deprecationNode.arguments?.find(a => a.name.value === 'reason'); + if (reasonArgument) { + if (print(reasonArgument.value) === change.meta.oldDeprecationReason) { + const node = { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.newDeprecationReason), + } as ArgumentNode; + (deprecationNode.arguments as ArgumentNode[] | undefined) = [ + ...(deprecationNode.arguments ?? []), + node, + ]; + } else { + handleError( + change, + new OldValueMismatchError( + print(reasonArgument.value), + change.meta.oldDeprecationReason, + ), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } + } else { + handleError(change, new KindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function fieldDeprecationReasonAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const deprecationNode = nodeByPath.get(changedPath); + if (deprecationNode) { + if (deprecationNode.kind === Kind.DIRECTIVE) { + const reasonArgument = deprecationNode.arguments?.find(a => a.name.value === 'reason'); + if (reasonArgument) { + handleError( + change, + new DeprecationReasonAlreadyExists((reasonArgument.value as StringValueNode)?.value), + config, + ); + } else { + const node = { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.addedDeprecationReason), + } as ArgumentNode; + (deprecationNode.arguments as ArgumentNode[] | undefined) = [ + ...(deprecationNode.arguments ?? []), + node, + ]; + nodeByPath.set(`${changedPath}.reason`, node); + } + } else { + handleError(change, new KindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function fieldDeprecationAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const fieldNode = nodeByPath.get(changedPath); + if (fieldNode) { + if (fieldNode.kind === Kind.FIELD_DEFINITION) { + const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); + if (hasExistingDeprecationDirective) { + handleError(change, new DirectiveAlreadyExists(GraphQLDeprecatedDirective.name), config); + } else { + const directiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(GraphQLDeprecatedDirective.name), + ...(change.meta.deprecationReason + ? { + arguments: [ + { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.deprecationReason), + }, + ], + } + : {}), + } as DirectiveNode; + + (fieldNode.directives as DirectiveNode[] | undefined) = [ + ...(fieldNode.directives ?? []), + directiveNode, + ]; + nodeByPath.set(`${changedPath}.${GraphQLDeprecatedDirective.name}`, directiveNode); + } + } else { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function fieldDeprecationRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const fieldNode = nodeByPath.get(changedPath); + if (fieldNode) { + if (fieldNode.kind === Kind.FIELD_DEFINITION) { + const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); + if (hasExistingDeprecationDirective) { + (fieldNode.directives as DirectiveNode[] | undefined) = fieldNode.directives?.filter( + d => d.name.value !== GraphQLDeprecatedDirective.name, + ); + nodeByPath.delete(`${changedPath}.${GraphQLDeprecatedDirective.name}`); + } else { + handleError(change, new DeprecatedDirectiveNotFound(), config); + } + } else { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function fieldDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const fieldNode = nodeByPath.get(changedPath); + if (fieldNode) { + if (fieldNode.kind === Kind.FIELD_DEFINITION) { + (fieldNode.description as StringValueNode | undefined) = change.meta.addedDescription + ? stringNode(change.meta.addedDescription) + : undefined; + } else { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function fieldDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const fieldNode = nodeByPath.get(changedPath); + if (fieldNode) { + if (fieldNode.kind === Kind.FIELD_DEFINITION) { + (fieldNode.description as StringValueNode | undefined) = undefined; + } else { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function fieldDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const fieldNode = nodeByPath.get(changedPath); + if (fieldNode) { + if (fieldNode.kind === Kind.FIELD_DEFINITION) { + if (fieldNode.description?.value === change.meta.oldDescription) { + (fieldNode.description as StringValueNode | undefined) = stringNode( + change.meta.newDescription, + ); + } else { + handleError( + change, + new DescriptionMismatchError(change.meta.oldDescription, fieldNode.description?.value), + config, + ); + } + } else { + handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts new file mode 100644 index 0000000000..3c0c87d40b --- /dev/null +++ b/packages/patch/src/patches/inputs.ts @@ -0,0 +1,135 @@ +import { ASTNode, InputValueDefinitionNode, Kind, parseType, StringValueNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + handleError, + KindMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig } from '../types.js'; +import { parentPath } from '../utils.js'; + +export function inputFieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const inputFieldPath = change.path!; + const existingNode = nodeByPath.get(inputFieldPath); + if (existingNode) { + handleError(change, new CoordinateAlreadyExistsError(existingNode.kind), config); + } else { + const typeNode = nodeByPath.get(parentPath(inputFieldPath)) as ASTNode & { + fields?: InputValueDefinitionNode[]; + }; + if (!typeNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (typeNode.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedInputFieldName), + type: parseType(change.meta.addedInputFieldType), + // description: change.meta.addedInputFieldDescription + // ? stringNode(change.meta.addedInputFieldDescription) + // : undefined, + }; + + typeNode.fields = [...(typeNode.fields ?? []), node]; + + // add new field to the node set + nodeByPath.set(inputFieldPath, node); + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } +} + +export function inputFieldRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const inputFieldPath = change.path!; + const existingNode = nodeByPath.get(inputFieldPath); + if (existingNode) { + const typeNode = nodeByPath.get(parentPath(inputFieldPath)) as ASTNode & { + fields?: InputValueDefinitionNode[]; + }; + if (!typeNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (typeNode.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { + typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); + + // add new field to the node set + nodeByPath.delete(inputFieldPath); + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function inputFieldDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const inputFieldPath = change.path!; + const existingNode = nodeByPath.get(inputFieldPath); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + (existingNode.description as StringValueNode | undefined) = stringNode( + change.meta.addedInputFieldDescription, + ); + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function inputFieldDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const inputFieldPath = change.path!; + const existingNode = nodeByPath.get(inputFieldPath); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if (existingNode.description === undefined) { + console.warn( + `Cannot remove a description at ${change.path} because no description is set.`, + ); + } else if (existingNode.description.value !== change.meta.removedDescription) { + console.warn( + `Description at ${change.path} does not match expected description, but proceeding with description removal anyways.`, + ); + } + (existingNode.description as StringValueNode | undefined) = undefined; + } else { + handleError( + change, + new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts new file mode 100644 index 0000000000..dae159120d --- /dev/null +++ b/packages/patch/src/patches/interfaces.ts @@ -0,0 +1,83 @@ +import { ASTNode, Kind, NamedTypeNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateNotFoundError, + handleError, + InterfaceAlreadyExistsOnTypeError, + KindMismatchError, +} from '../errors.js'; +import { namedTypeNode } from '../node-templates.js'; +import type { PatchConfig } from '../types'; + +export function objectTypeInterfaceAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const typeNode = nodeByPath.get(changedPath); + if (typeNode) { + if ( + typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || + typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION + ) { + const existing = typeNode.interfaces?.find( + i => i.name.value === change.meta.addedInterfaceName, + ); + if (existing) { + handleError( + change, + new InterfaceAlreadyExistsOnTypeError(change.meta.addedInterfaceName), + config, + ); + } else { + (typeNode.interfaces as NamedTypeNode[] | undefined) = [ + ...(typeNode.interfaces ?? []), + namedTypeNode(change.meta.addedInterfaceName), + ]; + } + } else { + handleError( + change, + new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function objectTypeInterfaceRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const typeNode = nodeByPath.get(changedPath); + if (typeNode) { + if ( + typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || + typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION + ) { + const existing = typeNode.interfaces?.find( + i => i.name.value === change.meta.removedInterfaceName, + ); + if (existing) { + (typeNode.interfaces as NamedTypeNode[] | undefined) = typeNode.interfaces?.filter( + i => i.name.value !== change.meta.removedInterfaceName, + ); + } else { + handleError(change, new CoordinateNotFoundError(), config); + } + } else { + handleError( + change, + new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} diff --git a/packages/patch/src/patches/schema.ts b/packages/patch/src/patches/schema.ts new file mode 100644 index 0000000000..505c66025b --- /dev/null +++ b/packages/patch/src/patches/schema.ts @@ -0,0 +1,77 @@ +import { NameNode, OperationTypeNode } from 'graphql'; +import type { Change, ChangeType } from '@graphql-inspector/core'; +import { CoordinateNotFoundError, handleError, OldTypeMismatchError } from '../errors.js'; +import { nameNode } from '../node-templates.js'; +import { PatchConfig, SchemaNode } from '../types.js'; + +export function schemaMutationTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + // @todo handle type extensions correctly + for (const schemaNode of schemaNodes) { + const mutation = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!mutation) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (mutation.type.name.value === change.meta.oldMutationTypeName) { + (mutation.type.name as NameNode) = nameNode(change.meta.newMutationTypeName); + } else { + handleError( + change, + new OldTypeMismatchError(change.meta.oldMutationTypeName, mutation?.type.name.value), + config, + ); + } + } +} + +export function schemaQueryTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + // @todo handle type extensions correctly + for (const schemaNode of schemaNodes) { + const query = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!query) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (query.type.name.value === change.meta.oldQueryTypeName) { + (query.type.name as NameNode) = nameNode(change.meta.newQueryTypeName); + } else { + handleError( + change, + new OldTypeMismatchError(change.meta.oldQueryTypeName, query?.type.name.value), + config, + ); + } + } +} + +export function schemaSubscriptionTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + // @todo handle type extensions correctly + for (const schemaNode of schemaNodes) { + const sub = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!sub) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (sub.type.name.value === change.meta.oldSubscriptionTypeName) { + (sub.type.name as NameNode) = nameNode(change.meta.newSubscriptionTypeName); + } else { + handleError( + change, + new OldTypeMismatchError(change.meta.oldSubscriptionTypeName, sub?.type.name.value), + config, + ); + } + } +} diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts new file mode 100644 index 0000000000..c57b72fca8 --- /dev/null +++ b/packages/patch/src/patches/types.ts @@ -0,0 +1,141 @@ +import { ASTNode, isTypeDefinitionNode, Kind, StringValueNode, TypeDefinitionNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateAlreadyExistsError, + CoordinateNotFoundError, + DescriptionMismatchError, + handleError, + KindMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig } from '../types'; + +export function typeAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const existing = nodeByPath.get(changedPath); + if (existing) { + handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); + } else { + const node: TypeDefinitionNode = { + name: nameNode(change.meta.addedTypeName), + kind: change.meta.addedTypeKind as TypeDefinitionNode['kind'], + }; + // @todo is this enough? + nodeByPath.set(changedPath, node); + } +} + +export function typeRemoved( + removal: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = removal.path!; + const removedNode = nodeByPath.get(changedPath); + if (removedNode) { + if (isTypeDefinitionNode(removedNode)) { + // delete the reference to the removed field. + for (const key of nodeByPath.keys()) { + if (key.startsWith(changedPath)) { + nodeByPath.delete(key); + } + } + } else { + handleError( + removal, + new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, removedNode.kind), + config, + ); + } + } else { + handleError(removal, new CoordinateNotFoundError(), config); + } +} + +export function typeDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const typeNode = nodeByPath.get(changedPath); + if (typeNode) { + if (isTypeDefinitionNode(typeNode)) { + (typeNode.description as StringValueNode | undefined) = change.meta.addedTypeDescription + ? stringNode(change.meta.addedTypeDescription) + : undefined; + } else { + handleError( + change, + new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function typeDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const typeNode = nodeByPath.get(changedPath); + if (typeNode) { + if (isTypeDefinitionNode(typeNode)) { + if (typeNode.description?.value !== change.meta.oldTypeDescription) { + handleError( + change, + new DescriptionMismatchError(change.meta.oldTypeDescription, typeNode.description?.value), + config, + ); + } + (typeNode.description as StringValueNode | undefined) = stringNode( + change.meta.newTypeDescription, + ); + } else { + handleError( + change, + new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function typeDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const typeNode = nodeByPath.get(changedPath); + if (typeNode) { + if (isTypeDefinitionNode(typeNode)) { + if (typeNode.description?.value !== change.meta.oldTypeDescription) { + handleError( + change, + new DescriptionMismatchError(change.meta.oldTypeDescription, typeNode.description?.value), + config, + ); + } + (typeNode.description as StringValueNode | undefined) = undefined; + } else { + handleError( + change, + new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts new file mode 100644 index 0000000000..84492b9038 --- /dev/null +++ b/packages/patch/src/patches/unions.ts @@ -0,0 +1,60 @@ +import { ASTNode, NamedTypeNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + CoordinateNotFoundError, + handleError, + UnionMemberAlreadyExistsError, + UnionMemberNotFoundError, +} from '../errors.js'; +import { namedTypeNode } from '../node-templates.js'; +import { PatchConfig } from '../types.js'; +import { parentPath } from '../utils.js'; + +export function unionMemberAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const union = nodeByPath.get(parentPath(changedPath)) as + | (ASTNode & { types?: NamedTypeNode[] }) + | undefined; + if (union) { + if (union.types?.some(n => n.name.value === change.meta.addedUnionMemberTypeName)) { + handleError( + change, + new UnionMemberAlreadyExistsError( + change.meta.unionName, + change.meta.addedUnionMemberTypeName, + ), + config, + ); + } else { + union.types = [...(union.types ?? []), namedTypeNode(change.meta.addedUnionMemberTypeName)]; + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} + +export function unionMemberRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const changedPath = change.path!; + const union = nodeByPath.get(parentPath(changedPath)) as + | (ASTNode & { types?: NamedTypeNode[] }) + | undefined; + if (union) { + if (union.types?.some(n => n.name.value === change.meta.removedUnionMemberTypeName)) { + union.types = union.types.filter( + t => t.name.value !== change.meta.removedUnionMemberTypeName, + ); + } else { + handleError(change, new UnionMemberNotFoundError(), config); + } + } else { + handleError(change, new CoordinateNotFoundError(), config); + } +} diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts new file mode 100644 index 0000000000..918c0b75d5 --- /dev/null +++ b/packages/patch/src/types.ts @@ -0,0 +1,32 @@ +import type { SchemaDefinitionNode, SchemaExtensionNode } from 'graphql'; +import type { Change, ChangeType } from '@graphql-inspector/core'; + +// @todo remove? +export type AdditionChangeType = + | typeof ChangeType.DirectiveAdded + | typeof ChangeType.DirectiveArgumentAdded + | typeof ChangeType.DirectiveLocationAdded + | typeof ChangeType.EnumValueAdded + | typeof ChangeType.EnumValueDeprecationReasonAdded + | typeof ChangeType.FieldAdded + | typeof ChangeType.FieldArgumentAdded + | typeof ChangeType.FieldDeprecationAdded + | typeof ChangeType.FieldDeprecationReasonAdded + | typeof ChangeType.FieldDescriptionAdded + | typeof ChangeType.InputFieldAdded + | typeof ChangeType.InputFieldDescriptionAdded + | typeof ChangeType.ObjectTypeInterfaceAdded + | typeof ChangeType.TypeDescriptionAdded + | typeof ChangeType.TypeAdded + | typeof ChangeType.UnionMemberAdded; + +export type SchemaNode = SchemaDefinitionNode | SchemaExtensionNode; + +export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; + +export type ChangesByType = { [key in TypeOfChangeType]?: Array> }; + +export type PatchConfig = { + throwOnError?: boolean; + debug?: boolean; +}; diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts new file mode 100644 index 0000000000..d965017d62 --- /dev/null +++ b/packages/patch/src/utils.ts @@ -0,0 +1,213 @@ +import { + ArgumentNode, + ASTNode, + ConstDirectiveNode, + ConstValueNode, + DirectiveNode, + GraphQLDeprecatedDirective, + InputValueDefinitionNode, + Kind, + NameNode, + StringValueNode, + TypeNode, + ValueNode, +} from 'graphql'; +import { Maybe } from 'graphql/jsutils/Maybe'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { nameNode } from './node-templates.js'; +import { AdditionChangeType } from './types.js'; + +export function getDeprecatedDirectiveNode( + definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, +): Maybe { + return definitionNode?.directives?.find( + node => node.name.value === GraphQLDeprecatedDirective.name, + ); +} + +export function addInputValueDefinitionArgument( + node: Maybe<{ + arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; + }>, + argumentName: string, + type: TypeNode, + defaultValue: ConstValueNode | undefined, + description: StringValueNode | undefined, + directives: ConstDirectiveNode[] | undefined, +): void { + if (node) { + let found = false; + for (const arg of node.arguments ?? []) { + if (arg.name.value === argumentName) { + found = true; + break; + } + } + if (found) { + console.error('Cannot patch definition that does not exist.'); + return; + } + + node.arguments = [ + ...(node.arguments ?? []), + { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(argumentName), + defaultValue, + type, + description, + directives, + }, + ]; + } +} + +export function removeInputValueDefinitionArgument( + node: Maybe<{ + arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; + }>, + argumentName: string, +): void { + if (node?.arguments) { + node.arguments = node.arguments.filter(({ name }) => name.value !== argumentName); + } else { + // @todo throw and standardize error messages + console.warn('Cannot apply input value argument removal.'); + } +} + +export function setInputValueDefinitionArgument( + node: Maybe<{ + arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; + }>, + argumentName: string, + values: { + type?: TypeNode; + defaultValue?: ConstValueNode | undefined; + description?: StringValueNode | undefined; + directives?: ConstDirectiveNode[] | undefined; + }, +): void { + if (node) { + let found = false; + for (const arg of node.arguments ?? []) { + if (arg.name.value === argumentName) { + if (Object.hasOwn(values, 'type') && values.type !== undefined) { + (arg.type as TypeNode) = values.type; + } + if (Object.hasOwn(values, 'defaultValue')) { + (arg.defaultValue as ConstValueNode | undefined) = values.defaultValue; + } + if (Object.hasOwn(values, 'description')) { + (arg.description as StringValueNode | undefined) = values.description; + } + if (Object.hasOwn(values, 'directives')) { + (arg.directives as ConstDirectiveNode[] | undefined) = values.directives; + } + found = true; + break; + } + } + if (!found) { + console.error('Cannot patch definition that does not exist.'); + // @todo throw error? + } + } +} + +export function upsertArgument( + node: { arguments?: ArgumentNode[] | readonly ArgumentNode[] }, + argumentName: string, + value: ValueNode, +): ArgumentNode { + for (const arg of node.arguments ?? []) { + if (arg.name.value === argumentName) { + (arg.value as ValueNode) = value; + return arg; + } + } + const arg: ArgumentNode = { + kind: Kind.ARGUMENT, + name: nameNode(argumentName), + value, + }; + node.arguments = [...(node.arguments ?? []), arg]; + return arg; +} + +export function findNamedNode( + nodes: Maybe>, + name: string, +): T | undefined { + return nodes?.find(value => value.name.value === name); +} + +/** + * @returns the removed node or undefined if no node matches the name. + */ +export function removeNamedNode( + nodes: Maybe>, + name: string, +): T | undefined { + if (nodes) { + const index = nodes?.findIndex(node => node.name.value === name); + if (index !== -1) { + const [deleted] = nodes.splice(index, 1); + return deleted; + } + } +} + +export function removeArgument( + node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined }>, + argumentName: string, +): void { + if (node?.arguments) { + node.arguments = node.arguments.filter(arg => arg.name.value !== argumentName); + } +} + +export function parentPath(path: string) { + const lastDividerIndex = path.lastIndexOf('.'); + return lastDividerIndex === -1 ? path : path.substring(0, lastDividerIndex); +} + +const isAdditionChange = (change: Change): change is Change => { + switch (change.type) { + case ChangeType.DirectiveAdded: + case ChangeType.DirectiveArgumentAdded: + case ChangeType.DirectiveLocationAdded: + case ChangeType.EnumValueAdded: + case ChangeType.EnumValueDeprecationReasonAdded: + case ChangeType.FieldAdded: + case ChangeType.FieldArgumentAdded: + case ChangeType.FieldDeprecationAdded: + case ChangeType.FieldDeprecationReasonAdded: + case ChangeType.FieldDescriptionAdded: + case ChangeType.InputFieldAdded: + case ChangeType.InputFieldDescriptionAdded: + case ChangeType.ObjectTypeInterfaceAdded: + case ChangeType.TypeDescriptionAdded: + case ChangeType.TypeAdded: + case ChangeType.UnionMemberAdded: + return true; + default: + return false; + } +}; + +export function debugPrintChange(change: Change, nodeByPath: Map) { + if (isAdditionChange(change)) { + console.log(`"${change.path}" is being added to the schema.`); + } else { + const changedNode = (change.path && nodeByPath.get(change.path)) || false; + + if (changedNode) { + console.log(`"${change.path}" has a change: [${change.type}] "${change.message}"`); + } else { + console.log( + `The change to "${change.path}" cannot be applied. That coordinate does not exist in the schema.`, + ); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c219b1908..0b0cc7819d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -663,6 +663,17 @@ importers: version: 2.6.2 publishDirectory: dist + packages/patch: + dependencies: + tslib: + specifier: 2.6.2 + version: 2.6.2 + devDependencies: + '@graphql-inspector/core': + specifier: workspace:* + version: link:../core/dist + publishDirectory: dist + website: dependencies: '@graphql-inspector/core': diff --git a/tsconfig.test.json b/tsconfig.test.json index 18d22fde45..4f393e22ed 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -44,7 +44,8 @@ "@graphql-inspector/validate-command": ["packages/commands/validate/src/index.ts"], "@graphql-inspector/introspect-command": ["packages/commands/introspect/src/index.ts"], "@graphql-inspector/similar-command": ["packages/commands/similar/src/index.ts"], - "@graphql-inspector/testing": ["packages/testing/src/index.ts"] + "@graphql-inspector/testing": ["packages/testing/src/index.ts"], + "@graphql-inspector/patch": ["packages/patch/src/index.ts"] } }, "include": ["packages"] diff --git a/vite.config.ts b/vite.config.ts index 9f79d95167..7b8c3d56bf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ '@graphql-inspector/url-loader': 'packages/loaders/url/src/index.ts', '@graphql-inspector/testing': 'packages/testing/src/index.ts', '@graphql-inspector/core': 'packages/core/src/index.ts', + '@graphql-inspector/patch': 'packages/patch/src/index.ts', 'graphql/language/parser.js': 'graphql/language/parser.js', graphql: 'graphql/index.js', }, From 288ae36c687f153cc7b9ad5501318cfbd420b3dd Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:49:18 -0700 Subject: [PATCH 10/23] Improve path handling --- packages/core/src/diff/changes/change.ts | 4 - packages/patch/src/index.ts | 6 - .../patch/src/patches/directive-usages.ts | 22 +++- packages/patch/src/patches/directives.ts | 60 +++++++--- packages/patch/src/patches/enum.ts | 52 ++++++--- packages/patch/src/patches/fields.ts | 104 ++++++++++++------ packages/patch/src/patches/inputs.ts | 39 +++++-- packages/patch/src/patches/interfaces.ts | 16 ++- packages/patch/src/patches/types.ts | 50 ++++++--- 9 files changed, 240 insertions(+), 113 deletions(-) diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index 2bffefcb95..a227f3118e 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -40,11 +40,7 @@ export const ChangeType = { // Enum EnumValueRemoved: 'ENUM_VALUE_REMOVED', EnumValueAdded: 'ENUM_VALUE_ADDED', - // @todo This is missing from the code... - // EnumValueDescriptionAdded: 'ENUM_VALUE_DESCRIPTION_ADDED', EnumValueDescriptionChanged: 'ENUM_VALUE_DESCRIPTION_CHANGED', - // @todo this is not being emitted..... why? - // EnumValueDescriptionRemoved: 'ENUM_VALUE_DESCRIPTION_REMOVED', EnumValueDeprecationReasonChanged: 'ENUM_VALUE_DEPRECATION_REASON_CHANGED', EnumValueDeprecationReasonAdded: 'ENUM_VALUE_DEPRECATION_REASON_ADDED', EnumValueDeprecationReasonRemoved: 'ENUM_VALUE_DEPRECATION_REASON_REMOVED', diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 8ac17630fd..27ae5cf58f 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -189,12 +189,6 @@ export function patch( debugPrintChange(change, nodeByPath); } - const changedPath = change.path; - if (changedPath === undefined) { - // a change without a path is useless... (@todo Only schema changes do this?) - continue; - } - switch (change.type) { case ChangeType.SchemaMutationTypeChanged: { schemaMutationTypeChanged(change, schemaDefs, config); diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index 9a4ca7da85..249d27d957 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -43,8 +43,13 @@ function directiveUsageDefinitionAdded( nodeByPath: Map, config: PatchConfig, ) { - const directiveNode = nodeByPath.get(change.path!); - const parentNode = nodeByPath.get(parentPath(change.path!)) as + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const directiveNode = nodeByPath.get(change.path); + const parentNode = nodeByPath.get(parentPath(change.path)) as | { directives?: DirectiveNode[] } | undefined; if (directiveNode) { @@ -55,7 +60,7 @@ function directiveUsageDefinitionAdded( name: nameNode(change.meta.addedDirectiveName), }; parentNode.directives = [...(parentNode.directives ?? []), newDirective]; - nodeByPath.set(change.path!, newDirective); + nodeByPath.set(change.path, newDirective); } else { handleError(change, new CoordinateNotFoundError(), config); } @@ -66,15 +71,20 @@ function directiveUsageDefinitionRemoved( nodeByPath: Map, config: PatchConfig, ) { - const directiveNode = nodeByPath.get(change.path!); - const parentNode = nodeByPath.get(parentPath(change.path!)) as + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const directiveNode = nodeByPath.get(change.path); + const parentNode = nodeByPath.get(parentPath(change.path)) as | { directives?: DirectiveNode[] } | undefined; if (directiveNode && parentNode) { parentNode.directives = parentNode.directives?.filter( d => d.name.value !== change.meta.removedDirectiveName, ); - nodeByPath.delete(change.path!); + nodeByPath.delete(change.path); } else { handleError(change, new DeletedCoordinateNotFoundError(), config); } diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 52132a54b3..7340b4c167 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -30,8 +30,12 @@ export function directiveAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const changedNode = nodeByPath.get(changedPath); + if (change.path === undefined) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const changedNode = nodeByPath.get(change.path); if (changedNode) { handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); } else { @@ -44,7 +48,7 @@ export function directiveAdded( ? stringNode(change.meta.addedDirectiveDescription) : undefined, }; - nodeByPath.set(changedPath, node); + nodeByPath.set(change.path, node); } } @@ -53,8 +57,12 @@ export function directiveArgumentAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const directiveNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { handleError(change, new CoordinateNotFoundError(), config); } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { @@ -80,7 +88,7 @@ export function directiveArgumentAdded( ...(directiveNode.arguments ?? []), node, ]; - nodeByPath.set(`${changedPath}.${change.meta.addedDirectiveArgumentName}`, node); + nodeByPath.set(`${change.path}.${change.meta.addedDirectiveArgumentName}`, node); } } else { handleError( @@ -96,8 +104,12 @@ export function directiveLocationAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const changedNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const changedNode = nodeByPath.get(change.path); if (changedNode) { if (changedNode.kind === Kind.DIRECTIVE_DEFINITION) { if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { @@ -132,8 +144,12 @@ export function directiveDescriptionChanged( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const directiveNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { handleError(change, new CoordinateNotFoundError(), config); } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { @@ -167,8 +183,12 @@ export function directiveArgumentDefaultValueChanged( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const argumentNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { handleError(change, new CoordinateNotFoundError(), config); } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { @@ -204,8 +224,12 @@ export function directiveArgumentDescriptionChanged( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const argumentNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { handleError(change, new CoordinateNotFoundError(), config); } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { @@ -239,8 +263,12 @@ export function directiveArgumentTypeChanged( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const argumentNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { handleError(change, new CoordinateNotFoundError(), config); } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index c4ee966bd4..fbbdaa7f5b 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -13,38 +13,42 @@ import type { PatchConfig } from '../types'; import { getDeprecatedDirectiveNode, parentPath, upsertArgument } from '../utils.js'; export function enumValueRemoved( - removal: Change, + change: Change, nodeByPath: Map, config: PatchConfig, ) { - const changedPath = removal.path!; - const enumNode = nodeByPath.get(parentPath(changedPath)) as + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const enumNode = nodeByPath.get(parentPath(change.path)) as | (ASTNode & { values?: EnumValueDefinitionNode[] }) | undefined; if (!enumNode) { - handleError(removal, new CoordinateNotFoundError(), config); + handleError(change, new CoordinateNotFoundError(), config); } else if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { - handleError(removal, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config); + handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config); } else if (enumNode.values === undefined || enumNode.values.length === 0) { handleError( - removal, - new EnumValueNotFoundError(removal.meta.enumName, removal.meta.removedEnumValueName), + change, + new EnumValueNotFoundError(change.meta.enumName, change.meta.removedEnumValueName), config, ); } else { const beforeLength = enumNode.values.length; enumNode.values = enumNode.values.filter( - f => f.name.value !== removal.meta.removedEnumValueName, + f => f.name.value !== change.meta.removedEnumValueName, ); if (beforeLength === enumNode.values.length) { handleError( - removal, - new EnumValueNotFoundError(removal.meta.enumName, removal.meta.removedEnumValueName), + change, + new EnumValueNotFoundError(change.meta.enumName, change.meta.removedEnumValueName), config, ); } else { // delete the reference to the removed field. - nodeByPath.delete(changedPath); + nodeByPath.delete(change.path); } } } @@ -87,8 +91,12 @@ export function enumValueDeprecationReasonAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const enumValueNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const enumValueNode = nodeByPath.get(change.path); if (enumValueNode) { if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { const deprecation = getDeprecatedDirectiveNode(enumValueNode); @@ -98,7 +106,7 @@ export function enumValueDeprecationReasonAdded( 'reason', stringNode(change.meta.addedValueDeprecationReason), ); - nodeByPath.set(`${changedPath}.reason`, argNode); + nodeByPath.set(`${change.path}.reason`, argNode); } else { handleError(change, new CoordinateNotFoundError(), config); } @@ -119,8 +127,12 @@ export function enumValueDeprecationReasonChanged( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const deprecatedNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const deprecatedNode = nodeByPath.get(change.path); if (deprecatedNode) { if (deprecatedNode.kind === Kind.DIRECTIVE) { const reasonArgNode = deprecatedNode.arguments?.find(n => n.name.value === 'reason'); @@ -162,8 +174,12 @@ export function enumValueDescriptionChanged( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const enumValueNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const enumValueNode = nodeByPath.get(change.path); if (enumValueNode) { if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { // eslint-disable-next-line eqeqeq diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index eed0ad4e90..caf7244d20 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -52,24 +52,28 @@ export function fieldTypeChanged( } export function fieldRemoved( - removal: Change, + change: Change, nodeByPath: Map, config: PatchConfig, ) { - const changedPath = removal.path!; - const typeNode = nodeByPath.get(parentPath(changedPath)) as + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const typeNode = nodeByPath.get(parentPath(change.path)) as | (ASTNode & { fields?: FieldDefinitionNode[] }) | undefined; if (!typeNode || !typeNode.fields?.length) { - handleError(removal, new CoordinateNotFoundError(), config); + handleError(change, new CoordinateNotFoundError(), config); } else { const beforeLength = typeNode.fields.length; - typeNode.fields = typeNode.fields.filter(f => f.name.value !== removal.meta.removedFieldName); + typeNode.fields = typeNode.fields.filter(f => f.name.value !== change.meta.removedFieldName); if (beforeLength === typeNode.fields.length) { - handleError(removal, new CoordinateNotFoundError(), config); + handleError(change, new CoordinateNotFoundError(), config); } else { // delete the reference to the removed field. - nodeByPath.delete(changedPath); + nodeByPath.delete(change.path); } } } @@ -79,12 +83,16 @@ export function fieldAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const changedNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const changedNode = nodeByPath.get(change.path); if (changedNode) { handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); } else { - const typeNode = nodeByPath.get(parentPath(changedPath)) as ASTNode & { + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { fields?: FieldDefinitionNode[]; }; if (!typeNode) { @@ -107,7 +115,7 @@ export function fieldAdded( typeNode.fields = [...(typeNode.fields ?? []), node]; // add new field to the node set - nodeByPath.set(changedPath, node); + nodeByPath.set(change.path, node); } } } @@ -117,12 +125,16 @@ export function fieldArgumentAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const existing = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const existing = nodeByPath.get(change.path); if (existing) { handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); } else { - const fieldNode = nodeByPath.get(parentPath(changedPath)) as ASTNode & { + const fieldNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { arguments?: InputValueDefinitionNode[]; }; if (!fieldNode) { @@ -140,7 +152,7 @@ export function fieldArgumentAdded( fieldNode.arguments = [...(fieldNode.arguments ?? []), node]; // add new field to the node set - nodeByPath.set(changedPath, node); + nodeByPath.set(change.path, node); } else { handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); } @@ -152,8 +164,12 @@ export function fieldDeprecationReasonChanged( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const deprecationNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const deprecationNode = nodeByPath.get(change.path); if (deprecationNode) { if (deprecationNode.kind === Kind.DIRECTIVE) { const reasonArgument = deprecationNode.arguments?.find(a => a.name.value === 'reason'); @@ -194,8 +210,12 @@ export function fieldDeprecationReasonAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const deprecationNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const deprecationNode = nodeByPath.get(change.path); if (deprecationNode) { if (deprecationNode.kind === Kind.DIRECTIVE) { const reasonArgument = deprecationNode.arguments?.find(a => a.name.value === 'reason'); @@ -215,7 +235,7 @@ export function fieldDeprecationReasonAdded( ...(deprecationNode.arguments ?? []), node, ]; - nodeByPath.set(`${changedPath}.reason`, node); + nodeByPath.set(`${change.path}.reason`, node); } } else { handleError(change, new KindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), config); @@ -230,8 +250,12 @@ export function fieldDeprecationAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const fieldNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const fieldNode = nodeByPath.get(change.path); if (fieldNode) { if (fieldNode.kind === Kind.FIELD_DEFINITION) { const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); @@ -258,7 +282,7 @@ export function fieldDeprecationAdded( ...(fieldNode.directives ?? []), directiveNode, ]; - nodeByPath.set(`${changedPath}.${GraphQLDeprecatedDirective.name}`, directiveNode); + nodeByPath.set(`${change.path}.${GraphQLDeprecatedDirective.name}`, directiveNode); } } else { handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); @@ -273,8 +297,12 @@ export function fieldDeprecationRemoved( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const fieldNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const fieldNode = nodeByPath.get(change.path); if (fieldNode) { if (fieldNode.kind === Kind.FIELD_DEFINITION) { const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); @@ -282,7 +310,7 @@ export function fieldDeprecationRemoved( (fieldNode.directives as DirectiveNode[] | undefined) = fieldNode.directives?.filter( d => d.name.value !== GraphQLDeprecatedDirective.name, ); - nodeByPath.delete(`${changedPath}.${GraphQLDeprecatedDirective.name}`); + nodeByPath.delete(`${change.path}.${GraphQLDeprecatedDirective.name}`); } else { handleError(change, new DeprecatedDirectiveNotFound(), config); } @@ -299,8 +327,12 @@ export function fieldDescriptionAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const fieldNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const fieldNode = nodeByPath.get(change.path); if (fieldNode) { if (fieldNode.kind === Kind.FIELD_DEFINITION) { (fieldNode.description as StringValueNode | undefined) = change.meta.addedDescription @@ -319,8 +351,12 @@ export function fieldDescriptionRemoved( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const fieldNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const fieldNode = nodeByPath.get(change.path); if (fieldNode) { if (fieldNode.kind === Kind.FIELD_DEFINITION) { (fieldNode.description as StringValueNode | undefined) = undefined; @@ -337,8 +373,12 @@ export function fieldDescriptionChanged( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const fieldNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const fieldNode = nodeByPath.get(change.path); if (fieldNode) { if (fieldNode.kind === Kind.FIELD_DEFINITION) { if (fieldNode.description?.value === change.meta.oldDescription) { diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index 3c0c87d40b..1060a5f375 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -15,12 +15,16 @@ export function inputFieldAdded( nodeByPath: Map, config: PatchConfig, ) { - const inputFieldPath = change.path!; - const existingNode = nodeByPath.get(inputFieldPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const existingNode = nodeByPath.get(change.path); if (existingNode) { handleError(change, new CoordinateAlreadyExistsError(existingNode.kind), config); } else { - const typeNode = nodeByPath.get(parentPath(inputFieldPath)) as ASTNode & { + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { fields?: InputValueDefinitionNode[]; }; if (!typeNode) { @@ -38,7 +42,7 @@ export function inputFieldAdded( typeNode.fields = [...(typeNode.fields ?? []), node]; // add new field to the node set - nodeByPath.set(inputFieldPath, node); + nodeByPath.set(change.path, node); } else { handleError( change, @@ -54,10 +58,14 @@ export function inputFieldRemoved( nodeByPath: Map, config: PatchConfig, ) { - const inputFieldPath = change.path!; - const existingNode = nodeByPath.get(inputFieldPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const existingNode = nodeByPath.get(change.path); if (existingNode) { - const typeNode = nodeByPath.get(parentPath(inputFieldPath)) as ASTNode & { + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { fields?: InputValueDefinitionNode[]; }; if (!typeNode) { @@ -66,7 +74,7 @@ export function inputFieldRemoved( typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); // add new field to the node set - nodeByPath.delete(inputFieldPath); + nodeByPath.delete(change.path); } else { handleError( change, @@ -84,8 +92,11 @@ export function inputFieldDescriptionAdded( nodeByPath: Map, config: PatchConfig, ) { - const inputFieldPath = change.path!; - const existingNode = nodeByPath.get(inputFieldPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + const existingNode = nodeByPath.get(change.path); if (existingNode) { if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { (existingNode.description as StringValueNode | undefined) = stringNode( @@ -108,8 +119,12 @@ export function inputFieldDescriptionRemoved( nodeByPath: Map, config: PatchConfig, ) { - const inputFieldPath = change.path!; - const existingNode = nodeByPath.get(inputFieldPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const existingNode = nodeByPath.get(change.path); if (existingNode) { if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { if (existingNode.description === undefined) { diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index dae159120d..8afd4f5a6e 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -14,8 +14,12 @@ export function objectTypeInterfaceAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const typeNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const typeNode = nodeByPath.get(change.path); if (typeNode) { if ( typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || @@ -53,8 +57,12 @@ export function objectTypeInterfaceRemoved( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const typeNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const typeNode = nodeByPath.get(change.path); if (typeNode) { if ( typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index c57b72fca8..7c190c6239 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -15,8 +15,12 @@ export function typeAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const existing = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const existing = nodeByPath.get(change.path); if (existing) { handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); } else { @@ -25,34 +29,38 @@ export function typeAdded( kind: change.meta.addedTypeKind as TypeDefinitionNode['kind'], }; // @todo is this enough? - nodeByPath.set(changedPath, node); + nodeByPath.set(change.path, node); } } export function typeRemoved( - removal: Change, + change: Change, nodeByPath: Map, config: PatchConfig, ) { - const changedPath = removal.path!; - const removedNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const removedNode = nodeByPath.get(change.path); if (removedNode) { if (isTypeDefinitionNode(removedNode)) { // delete the reference to the removed field. for (const key of nodeByPath.keys()) { - if (key.startsWith(changedPath)) { + if (key.startsWith(change.path)) { nodeByPath.delete(key); } } } else { handleError( - removal, + change, new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, removedNode.kind), config, ); } } else { - handleError(removal, new CoordinateNotFoundError(), config); + handleError(change, new CoordinateNotFoundError(), config); } } @@ -61,8 +69,12 @@ export function typeDescriptionAdded( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const typeNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const typeNode = nodeByPath.get(change.path); if (typeNode) { if (isTypeDefinitionNode(typeNode)) { (typeNode.description as StringValueNode | undefined) = change.meta.addedTypeDescription @@ -85,8 +97,12 @@ export function typeDescriptionChanged( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const typeNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const typeNode = nodeByPath.get(change.path); if (typeNode) { if (isTypeDefinitionNode(typeNode)) { if (typeNode.description?.value !== change.meta.oldTypeDescription) { @@ -116,8 +132,12 @@ export function typeDescriptionRemoved( nodeByPath: Map, config: PatchConfig, ) { - const changedPath = change.path!; - const typeNode = nodeByPath.get(changedPath); + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + + const typeNode = nodeByPath.get(change.path); if (typeNode) { if (isTypeDefinitionNode(typeNode)) { if (typeNode.description?.value !== change.meta.oldTypeDescription) { From f19e29950a245baa0a3b6731884ff3ccea19c422 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 23 Jul 2025 22:49:20 -0700 Subject: [PATCH 11/23] WIP: Print test schemas with directives. --- packages/core/src/diff/changes/change.ts | 1 - packages/patch/package.json | 6 +- .../src/__tests__/directive-usage.test.ts | 92 +++++++++++ packages/patch/src/__tests__/utils.ts | 3 +- .../patch/src/patches/directive-usages.ts | 51 ++++-- packages/patch/src/patches/directives.ts | 14 +- packages/patch/src/patches/enum.ts | 20 ++- packages/patch/src/patches/fields.ts | 6 +- packages/patch/src/patches/interfaces.ts | 9 +- packages/patch/src/patches/schema.ts | 3 - packages/patch/src/patches/types.ts | 1 - packages/patch/src/patches/unions.ts | 8 +- packages/patch/src/types.ts | 1 - packages/patch/src/utils.ts | 154 +----------------- pnpm-lock.yaml | 6 + 15 files changed, 174 insertions(+), 201 deletions(-) diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index a227f3118e..ff83ae53b9 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -104,7 +104,6 @@ export const ChangeType = { DirectiveUsageInterfaceRemoved: 'DIRECTIVE_USAGE_INTERFACE_REMOVED', DirectiveUsageArgumentDefinitionAdded: 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_ADDED', DirectiveUsageArgumentDefinitionRemoved: 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_REMOVED', - // DirectiveUsageArgumentDefinitionChanged: 'DIRECTIVE_USAGE_ARGUMENT_DEFINITION_CHANGED', DirectiveUsageSchemaAdded: 'DIRECTIVE_USAGE_SCHEMA_ADDED', DirectiveUsageSchemaRemoved: 'DIRECTIVE_USAGE_SCHEMA_REMOVED', DirectiveUsageFieldDefinitionAdded: 'DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED', diff --git a/packages/patch/package.json b/packages/patch/package.json index bc9fd9c9d0..57737880bd 100644 --- a/packages/patch/package.json +++ b/packages/patch/package.json @@ -57,8 +57,12 @@ "dependencies": { "tslib": "2.6.2" }, + "peerDependencies": { + "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" + }, "devDependencies": { - "@graphql-inspector/core": "workspace:*" + "@graphql-inspector/core": "workspace:*", + "@graphql-tools/utils": "^10.0.0" }, "publishConfig": { "directory": "dist", diff --git a/packages/patch/src/__tests__/directive-usage.test.ts b/packages/patch/src/__tests__/directive-usage.test.ts index 44e40e29cc..649a8ece59 100644 --- a/packages/patch/src/__tests__/directive-usage.test.ts +++ b/packages/patch/src/__tests__/directive-usage.test.ts @@ -1102,4 +1102,96 @@ describe('directiveUsages: removed', () => { const after = baseSchema; await expectPatchToMatch(before, after); }); + + test('schemaDirectiveUsageDefinitionAdded', async () => { + const before = baseSchema; + const after = /* GraphQL */ ` + schema @meta(name: "owner", value: "kitchen") { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories @meta(name: "owner", value: "kitchen") + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + await expectPatchToMatch(before, after); + }); + + test('schemaDirectiveUsageDefinitionRemoved', async () => { + const before = /* GraphQL */ ` + schema @meta(name: "owner", value: "kitchen") { + query: Query + mutation: Mutation + } + directive @meta( + name: String! + value: String! + ) on SCHEMA | SCALAR | OBJECT | FIELD_DEFINITION | ARGUMENT_DEFINITION | INTERFACE | UNION | ENUM | ENUM_VALUE | INPUT_OBJECT | INPUT_FIELD_DEFINITION + enum Flavor { + SWEET + SOUR + SAVORY + UMAMI + } + scalar Calories + interface Food { + name: String! + flavors: [Flavor!] + } + type Drink implements Food { + name: String! + flavors: [Flavor!] + volume: Int + } + type Burger { + name: String! + flavors: [Flavor!] + toppings: [Food] + } + union Snack = Drink | Burger + type Query { + food(name: String!): Food + } + type Mutation { + eat(input: EatInput): Calories + } + input EatInput { + foodName: String! + } + `; + const after = baseSchema; + await expectPatchToMatch(before, after); + }); }); diff --git a/packages/patch/src/__tests__/utils.ts b/packages/patch/src/__tests__/utils.ts index 7c6bdfa4fb..5f3bdc659a 100644 --- a/packages/patch/src/__tests__/utils.ts +++ b/packages/patch/src/__tests__/utils.ts @@ -1,9 +1,10 @@ import { buildSchema, GraphQLSchema, lexicographicSortSchema, printSchema } from 'graphql'; import { Change, diff } from '@graphql-inspector/core'; import { patchSchema } from '../index.js'; +import { printSchemaWithDirectives } from '@graphql-tools/utils' function printSortedSchema(schema: GraphQLSchema) { - return printSchema(lexicographicSortSchema(schema)); + return printSchemaWithDirectives(lexicographicSortSchema(schema)); } export async function expectPatchToMatch(before: string, after: string): Promise[]> { diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index 249d27d957..a40340aa18 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -8,7 +8,7 @@ import { } from '../errors.js'; import { nameNode } from '../node-templates.js'; import { PatchConfig, SchemaNode } from '../types.js'; -import { parentPath } from '../utils.js'; +import { findNamedNode, parentPath } from '../utils.js'; export type DirectiveUsageAddedChange = | typeof ChangeType.DirectiveUsageArgumentDefinitionAdded @@ -66,6 +66,37 @@ function directiveUsageDefinitionAdded( } } +function schemaDirectiveUsageDefinitionAdded( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + // @todo handle repeat directives + // findNamedNode(schemaNodes[0].directives, change.meta.addedDirectiveName) + throw new Error('DirectiveUsageAddedChange on schema node is not implemented yet.') +} + +function schemaDirectiveUsageDefinitionRemoved( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + for (const node of schemaNodes) { + // @todo handle repeated directives + const directiveNode = findNamedNode(node.directives, change.meta.removedDirectiveName); + if (directiveNode) { + (node.directives as DirectiveNode[] | undefined) = node.directives?.filter( + d => d.name.value !== change.meta.removedDirectiveName, + ); + } + } + handleError(change, new DeletedCoordinateNotFoundError(), config); +} + function directiveUsageDefinitionRemoved( change: Change, nodeByPath: Map, @@ -251,21 +282,19 @@ export function directiveUsageScalarRemoved( } export function directiveUsageSchemaAdded( - _change: Change, - _schemaDefs: SchemaNode[], - _config: PatchConfig, + change: Change, + schemaDefs: SchemaNode[], + config: PatchConfig, ) { - // @todo - // return directiveUsageDefinitionAdded(change, schemaDefs, config); + return schemaDirectiveUsageDefinitionAdded(change, schemaDefs, config); } export function directiveUsageSchemaRemoved( - _change: Change, - _schemaDefs: SchemaNode[], - _config: PatchConfig, + change: Change, + schemaDefs: SchemaNode[], + config: PatchConfig, ) { - // @todo - // return directiveUsageDefinitionRemoved(change, schemaDefs, config); + return schemaDirectiveUsageDefinitionRemoved(change, schemaDefs, config); } export function directiveUsageUnionMemberAdded( diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 7340b4c167..07987ed245 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -24,6 +24,7 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import { PatchConfig } from '../types.js'; +import { findNamedNode } from '../utils.js'; export function directiveAdded( change: Change, @@ -66,18 +67,9 @@ export function directiveArgumentAdded( if (!directiveNode) { handleError(change, new CoordinateNotFoundError(), config); } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { - const existingArg = directiveNode.arguments?.find( - d => d.name.value === change.meta.addedDirectiveArgumentName, - ); + const existingArg = findNamedNode(directiveNode.arguments, change.meta.addedDirectiveArgumentName); if (existingArg) { - // @todo make sure to check that everything is equal to the change, else error - // because it conflicts. - // if (print(existingArg.type) === change.meta.addedDirectiveArgumentType) { - // // warn - // // handleError(change, new ArgumentAlreadyExistsError(), config); - // } else { - // // error - // } + handleError(change, new CoordinateAlreadyExistsError(existingArg.kind), config) } else { const node: InputValueDefinitionNode = { kind: Kind.INPUT_VALUE_DEFINITION, diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index fbbdaa7f5b..b54ae51108 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -1,4 +1,4 @@ -import { ASTNode, EnumValueDefinitionNode, Kind, print, StringValueNode } from 'graphql'; +import { ArgumentNode, ASTNode, EnumValueDefinitionNode, Kind, print, StringValueNode, ValueNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { CoordinateAlreadyExistsError, @@ -10,7 +10,7 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; -import { getDeprecatedDirectiveNode, parentPath, upsertArgument } from '../utils.js'; +import { findNamedNode, getDeprecatedDirectiveNode, parentPath } from '../utils.js'; export function enumValueRemoved( change: Change, @@ -101,11 +101,15 @@ export function enumValueDeprecationReasonAdded( if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { const deprecation = getDeprecatedDirectiveNode(enumValueNode); if (deprecation) { - const argNode = upsertArgument( - deprecation, - 'reason', - stringNode(change.meta.addedValueDeprecationReason), - ); + if (findNamedNode(deprecation.arguments, 'reason')) { + handleError(change, new CoordinateAlreadyExistsError(Kind.ARGUMENT), config); + } + const argNode: ArgumentNode = { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.addedValueDeprecationReason), + }; + (deprecation.arguments as ArgumentNode[] | undefined) = [...(deprecation.arguments ?? []), argNode]; nodeByPath.set(`${change.path}.reason`, argNode); } else { handleError(change, new CoordinateNotFoundError(), config); @@ -135,7 +139,7 @@ export function enumValueDeprecationReasonChanged( const deprecatedNode = nodeByPath.get(change.path); if (deprecatedNode) { if (deprecatedNode.kind === Kind.DIRECTIVE) { - const reasonArgNode = deprecatedNode.arguments?.find(n => n.name.value === 'reason'); + const reasonArgNode = findNamedNode(deprecatedNode.arguments, 'reason'); if (reasonArgNode) { if (reasonArgNode.kind === Kind.ARGUMENT) { if ( diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index caf7244d20..1e3a38c075 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -26,7 +26,7 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; -import { getDeprecatedDirectiveNode, parentPath } from '../utils.js'; +import { findNamedNode, getDeprecatedDirectiveNode, parentPath } from '../utils.js'; export function fieldTypeChanged( change: Change, @@ -172,7 +172,7 @@ export function fieldDeprecationReasonChanged( const deprecationNode = nodeByPath.get(change.path); if (deprecationNode) { if (deprecationNode.kind === Kind.DIRECTIVE) { - const reasonArgument = deprecationNode.arguments?.find(a => a.name.value === 'reason'); + const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); if (reasonArgument) { if (print(reasonArgument.value) === change.meta.oldDeprecationReason) { const node = { @@ -218,7 +218,7 @@ export function fieldDeprecationReasonAdded( const deprecationNode = nodeByPath.get(change.path); if (deprecationNode) { if (deprecationNode.kind === Kind.DIRECTIVE) { - const reasonArgument = deprecationNode.arguments?.find(a => a.name.value === 'reason'); + const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); if (reasonArgument) { handleError( change, diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index 8afd4f5a6e..758b0f3311 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -8,6 +8,7 @@ import { } from '../errors.js'; import { namedTypeNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; +import { findNamedNode } from '../utils.js'; export function objectTypeInterfaceAdded( change: Change, @@ -25,9 +26,7 @@ export function objectTypeInterfaceAdded( typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION ) { - const existing = typeNode.interfaces?.find( - i => i.name.value === change.meta.addedInterfaceName, - ); + const existing = findNamedNode(typeNode.interfaces, change.meta.addedInterfaceName); if (existing) { handleError( change, @@ -68,9 +67,7 @@ export function objectTypeInterfaceRemoved( typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION ) { - const existing = typeNode.interfaces?.find( - i => i.name.value === change.meta.removedInterfaceName, - ); + const existing = findNamedNode(typeNode.interfaces, change.meta.removedInterfaceName); if (existing) { (typeNode.interfaces as NamedTypeNode[] | undefined) = typeNode.interfaces?.filter( i => i.name.value !== change.meta.removedInterfaceName, diff --git a/packages/patch/src/patches/schema.ts b/packages/patch/src/patches/schema.ts index 505c66025b..e4edb07e96 100644 --- a/packages/patch/src/patches/schema.ts +++ b/packages/patch/src/patches/schema.ts @@ -9,7 +9,6 @@ export function schemaMutationTypeChanged( schemaNodes: SchemaNode[], config: PatchConfig, ) { - // @todo handle type extensions correctly for (const schemaNode of schemaNodes) { const mutation = schemaNode.operationTypes?.find( ({ operation }) => operation === OperationTypeNode.MUTATION, @@ -33,7 +32,6 @@ export function schemaQueryTypeChanged( schemaNodes: SchemaNode[], config: PatchConfig, ) { - // @todo handle type extensions correctly for (const schemaNode of schemaNodes) { const query = schemaNode.operationTypes?.find( ({ operation }) => operation === OperationTypeNode.MUTATION, @@ -57,7 +55,6 @@ export function schemaSubscriptionTypeChanged( schemaNodes: SchemaNode[], config: PatchConfig, ) { - // @todo handle type extensions correctly for (const schemaNode of schemaNodes) { const sub = schemaNode.operationTypes?.find( ({ operation }) => operation === OperationTypeNode.MUTATION, diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index 7c190c6239..02526d1362 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -28,7 +28,6 @@ export function typeAdded( name: nameNode(change.meta.addedTypeName), kind: change.meta.addedTypeKind as TypeDefinitionNode['kind'], }; - // @todo is this enough? nodeByPath.set(change.path, node); } } diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts index 84492b9038..04fbe1b2e7 100644 --- a/packages/patch/src/patches/unions.ts +++ b/packages/patch/src/patches/unions.ts @@ -8,7 +8,7 @@ import { } from '../errors.js'; import { namedTypeNode } from '../node-templates.js'; import { PatchConfig } from '../types.js'; -import { parentPath } from '../utils.js'; +import { findNamedNode, parentPath } from '../utils.js'; export function unionMemberAdded( change: Change, @@ -20,7 +20,7 @@ export function unionMemberAdded( | (ASTNode & { types?: NamedTypeNode[] }) | undefined; if (union) { - if (union.types?.some(n => n.name.value === change.meta.addedUnionMemberTypeName)) { + if (findNamedNode(union.types, change.meta.addedUnionMemberTypeName)) { handleError( change, new UnionMemberAlreadyExistsError( @@ -47,8 +47,8 @@ export function unionMemberRemoved( | (ASTNode & { types?: NamedTypeNode[] }) | undefined; if (union) { - if (union.types?.some(n => n.name.value === change.meta.removedUnionMemberTypeName)) { - union.types = union.types.filter( + if (findNamedNode(union.types, change.meta.removedUnionMemberTypeName)) { + union.types = union.types!.filter( t => t.name.value !== change.meta.removedUnionMemberTypeName, ); } else { diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts index 918c0b75d5..7de3488b02 100644 --- a/packages/patch/src/types.ts +++ b/packages/patch/src/types.ts @@ -1,7 +1,6 @@ import type { SchemaDefinitionNode, SchemaExtensionNode } from 'graphql'; import type { Change, ChangeType } from '@graphql-inspector/core'; -// @todo remove? export type AdditionChangeType = | typeof ChangeType.DirectiveAdded | typeof ChangeType.DirectiveArgumentAdded diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index d965017d62..7c0ade6e62 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -1,138 +1,17 @@ import { - ArgumentNode, ASTNode, - ConstDirectiveNode, - ConstValueNode, DirectiveNode, GraphQLDeprecatedDirective, - InputValueDefinitionNode, - Kind, NameNode, - StringValueNode, - TypeNode, - ValueNode, } from 'graphql'; import { Maybe } from 'graphql/jsutils/Maybe'; import { Change, ChangeType } from '@graphql-inspector/core'; -import { nameNode } from './node-templates.js'; import { AdditionChangeType } from './types.js'; export function getDeprecatedDirectiveNode( definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, ): Maybe { - return definitionNode?.directives?.find( - node => node.name.value === GraphQLDeprecatedDirective.name, - ); -} - -export function addInputValueDefinitionArgument( - node: Maybe<{ - arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; - }>, - argumentName: string, - type: TypeNode, - defaultValue: ConstValueNode | undefined, - description: StringValueNode | undefined, - directives: ConstDirectiveNode[] | undefined, -): void { - if (node) { - let found = false; - for (const arg of node.arguments ?? []) { - if (arg.name.value === argumentName) { - found = true; - break; - } - } - if (found) { - console.error('Cannot patch definition that does not exist.'); - return; - } - - node.arguments = [ - ...(node.arguments ?? []), - { - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(argumentName), - defaultValue, - type, - description, - directives, - }, - ]; - } -} - -export function removeInputValueDefinitionArgument( - node: Maybe<{ - arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; - }>, - argumentName: string, -): void { - if (node?.arguments) { - node.arguments = node.arguments.filter(({ name }) => name.value !== argumentName); - } else { - // @todo throw and standardize error messages - console.warn('Cannot apply input value argument removal.'); - } -} - -export function setInputValueDefinitionArgument( - node: Maybe<{ - arguments?: InputValueDefinitionNode[] | readonly InputValueDefinitionNode[] | undefined; - }>, - argumentName: string, - values: { - type?: TypeNode; - defaultValue?: ConstValueNode | undefined; - description?: StringValueNode | undefined; - directives?: ConstDirectiveNode[] | undefined; - }, -): void { - if (node) { - let found = false; - for (const arg of node.arguments ?? []) { - if (arg.name.value === argumentName) { - if (Object.hasOwn(values, 'type') && values.type !== undefined) { - (arg.type as TypeNode) = values.type; - } - if (Object.hasOwn(values, 'defaultValue')) { - (arg.defaultValue as ConstValueNode | undefined) = values.defaultValue; - } - if (Object.hasOwn(values, 'description')) { - (arg.description as StringValueNode | undefined) = values.description; - } - if (Object.hasOwn(values, 'directives')) { - (arg.directives as ConstDirectiveNode[] | undefined) = values.directives; - } - found = true; - break; - } - } - if (!found) { - console.error('Cannot patch definition that does not exist.'); - // @todo throw error? - } - } -} - -export function upsertArgument( - node: { arguments?: ArgumentNode[] | readonly ArgumentNode[] }, - argumentName: string, - value: ValueNode, -): ArgumentNode { - for (const arg of node.arguments ?? []) { - if (arg.name.value === argumentName) { - (arg.value as ValueNode) = value; - return arg; - } - } - const arg: ArgumentNode = { - kind: Kind.ARGUMENT, - name: nameNode(argumentName), - value, - }; - node.arguments = [...(node.arguments ?? []), arg]; - return arg; + return findNamedNode(definitionNode?.directives, GraphQLDeprecatedDirective.name); } export function findNamedNode( @@ -142,31 +21,6 @@ export function findNamedNode( return nodes?.find(value => value.name.value === name); } -/** - * @returns the removed node or undefined if no node matches the name. - */ -export function removeNamedNode( - nodes: Maybe>, - name: string, -): T | undefined { - if (nodes) { - const index = nodes?.findIndex(node => node.name.value === name); - if (index !== -1) { - const [deleted] = nodes.splice(index, 1); - return deleted; - } - } -} - -export function removeArgument( - node: Maybe<{ arguments?: ArgumentNode[] | readonly ArgumentNode[] | undefined }>, - argumentName: string, -): void { - if (node?.arguments) { - node.arguments = node.arguments.filter(arg => arg.name.value !== argumentName); - } -} - export function parentPath(path: string) { const lastDividerIndex = path.lastIndexOf('.'); return lastDividerIndex === -1 ? path : path.substring(0, lastDividerIndex); @@ -198,14 +52,14 @@ const isAdditionChange = (change: Change): change is Change, nodeByPath: Map) { if (isAdditionChange(change)) { - console.log(`"${change.path}" is being added to the schema.`); + console.debug(`"${change.path}" is being added to the schema.`); } else { const changedNode = (change.path && nodeByPath.get(change.path)) || false; if (changedNode) { - console.log(`"${change.path}" has a change: [${change.type}] "${change.message}"`); + console.debug(`"${change.path}" has a change: [${change.type}] "${change.message}"`); } else { - console.log( + console.debug( `The change to "${change.path}" cannot be applied. That coordinate does not exist in the schema.`, ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b0cc7819d..1ceb3ab839 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -665,6 +665,9 @@ importers: packages/patch: dependencies: + graphql: + specifier: 16.10.0 + version: 16.10.0 tslib: specifier: 2.6.2 version: 2.6.2 @@ -672,6 +675,9 @@ importers: '@graphql-inspector/core': specifier: workspace:* version: link:../core/dist + '@graphql-tools/utils': + specifier: ^10.0.0 + version: 10.8.6(graphql@16.10.0) publishDirectory: dist website: From 6cd0ba3a75ca9c57432e26b7d601d0930d7c3434 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Sat, 26 Jul 2025 11:20:24 -0700 Subject: [PATCH 12/23] Support all directive usage cases --- .../__tests__/diff/directive-usage.test.ts | 54 +++--- packages/core/__tests__/diff/enum.test.ts | 29 ++- .../rules/ignore-nested-additions.test.ts | 12 +- packages/core/__tests__/diff/schema.test.ts | 38 ++-- packages/core/src/diff/argument.ts | 18 +- packages/core/src/diff/changes/change.ts | 41 +++++ .../core/src/diff/changes/directive-usage.ts | 165 +++++++++++++++--- packages/core/src/diff/changes/enum.ts | 8 +- packages/core/src/diff/changes/field.ts | 4 +- packages/core/src/diff/enum.ts | 28 ++- packages/core/src/diff/field.ts | 15 +- packages/core/src/diff/input.ts | 20 ++- packages/core/src/diff/interface.ts | 10 +- packages/core/src/diff/object.ts | 10 +- .../src/diff/rules/ignore-nested-additions.ts | 3 +- packages/core/src/diff/scalar.ts | 10 +- packages/core/src/diff/schema.ts | 10 +- packages/core/src/diff/union.ts | 10 +- packages/patch/package.json | 10 +- packages/patch/src/__tests__/fields.test.ts | 16 ++ packages/patch/src/__tests__/utils.ts | 9 +- packages/patch/src/index.ts | 58 ++++-- .../patch/src/patches/directive-usages.ts | 94 ++++++++-- packages/patch/src/patches/directives.ts | 7 +- packages/patch/src/patches/enum.ts | 21 ++- packages/patch/src/patches/fields.ts | 17 +- packages/patch/src/types.ts | 2 +- packages/patch/src/utils.ts | 26 ++- 28 files changed, 594 insertions(+), 151 deletions(-) diff --git a/packages/core/__tests__/diff/directive-usage.test.ts b/packages/core/__tests__/diff/directive-usage.test.ts index a8d54406aa..47016562cf 100644 --- a/packages/core/__tests__/diff/directive-usage.test.ts +++ b/packages/core/__tests__/diff/directive-usage.test.ts @@ -21,7 +21,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Query.a.external'); + const change = findFirstChangeByPath(changes, 'Query.a.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED'); @@ -44,7 +44,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Query.a.external'); + const change = findFirstChangeByPath(changes, 'Query.a.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.type).toEqual('DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED'); @@ -67,7 +67,7 @@ describe('directive-usage', () => { } `); - const change = findFirstChangeByPath(await diff(a, b), 'Query.a.external'); + const change = findFirstChangeByPath(await diff(a, b), 'Query.a.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED'); @@ -91,7 +91,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Query.a.oneOf'); + const change = findFirstChangeByPath(changes, 'Query.a.@oneOf'); expect(change.criticality.level).toEqual(CriticalityLevel.Breaking); expect(change.type).toEqual('DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED'); @@ -114,7 +114,7 @@ describe('directive-usage', () => { } `); - const change = findFirstChangeByPath(await diff(a, b), 'Query.a.oneOf'); + const change = findFirstChangeByPath(await diff(a, b), 'Query.a.@oneOf'); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.type).toEqual('DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED'); @@ -151,7 +151,7 @@ describe('directive-usage', () => { union Foo @external = A | B `); - const change = findFirstChangeByPath(await diff(a, b), 'Foo.external'); + const change = findFirstChangeByPath(await diff(a, b), 'Foo.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_UNION_MEMBER_ADDED'); @@ -187,7 +187,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, 'Foo.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_UNION_MEMBER_REMOVED'); @@ -222,7 +222,7 @@ describe('directive-usage', () => { union Foo @oneOf = A | B `); - const change = findFirstChangeByPath(await diff(a, b), 'Foo.oneOf'); + const change = findFirstChangeByPath(await diff(a, b), 'Foo.@oneOf'); expect(change.criticality.level).toEqual(CriticalityLevel.Breaking); expect(change.type).toEqual('DIRECTIVE_USAGE_UNION_MEMBER_ADDED'); @@ -258,7 +258,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.oneOf'); + const change = findFirstChangeByPath(changes, 'Foo.@oneOf'); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.type).toEqual('DIRECTIVE_USAGE_UNION_MEMBER_REMOVED'); @@ -293,7 +293,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.external'); + const change = findFirstChangeByPath(changes, 'enumA.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.criticality.reason).toBeDefined(); @@ -325,7 +325,7 @@ describe('directive-usage', () => { } `); - const change = findFirstChangeByPath(await diff(a, b), 'enumA.external'); + const change = findFirstChangeByPath(await diff(a, b), 'enumA.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_ENUM_REMOVED'); @@ -361,7 +361,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.B.external'); + const change = findFirstChangeByPath(changes, 'enumA.B.@external'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); @@ -396,7 +396,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A.external'); + const change = findFirstChangeByPath(changes, 'enumA.A.@external'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); @@ -423,7 +423,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, 'Foo.@external'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); @@ -447,7 +447,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, 'Foo.@external'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); @@ -474,7 +474,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.a.external'); + const change = findFirstChangeByPath(changes, 'Foo.a.@external'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); @@ -500,7 +500,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.a.external'); + const change = findFirstChangeByPath(changes, 'Foo.a.@external'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); @@ -523,7 +523,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, 'Foo.@external'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); @@ -541,7 +541,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, 'Foo.@external'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); @@ -566,7 +566,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, 'Foo.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_OBJECT_ADDED'); @@ -587,7 +587,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, 'Foo.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_OBJECT_REMOVED'); @@ -611,7 +611,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, 'Foo.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_INTERFACE_ADDED'); @@ -633,7 +633,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, 'Foo.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_INTERFACE_REMOVED'); @@ -657,7 +657,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.a.a.external'); + const change = findFirstChangeByPath(changes, 'Foo.a.a.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_ARGUMENT_DEFINITION_ADDED'); @@ -681,7 +681,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.a.a.external'); + const change = findFirstChangeByPath(changes, 'Foo.a.a.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_ARGUMENT_DEFINITION_REMOVED'); @@ -713,7 +713,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, '.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_SCHEMA_ADDED'); @@ -740,7 +740,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'Foo.external'); + const change = findFirstChangeByPath(changes, '.@external'); expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.type).toEqual('DIRECTIVE_USAGE_SCHEMA_REMOVED'); diff --git a/packages/core/__tests__/diff/enum.test.ts b/packages/core/__tests__/diff/enum.test.ts index 731a601e29..1e1a58e6e8 100644 --- a/packages/core/__tests__/diff/enum.test.ts +++ b/packages/core/__tests__/diff/enum.test.ts @@ -1,6 +1,6 @@ import { buildSchema } from 'graphql'; -import { CriticalityLevel, diff, DiffRule } from '../../src/index.js'; -import { findFirstChangeByPath } from '../../utils/testing.js'; +import { ChangeType, CriticalityLevel, diff, DiffRule } from '../../src/index.js'; +import { findChangesByPath, findFirstChangeByPath } from '../../utils/testing.js'; describe('enum', () => { test('added', async () => { @@ -178,7 +178,7 @@ describe('enum', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A.deprecated'); + const change = findFirstChangeByPath(changes, 'enumA.A.@deprecated'); expect(changes.length).toEqual(1); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); @@ -211,11 +211,26 @@ describe('enum', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A'); + expect(changes).toHaveLength(3); + const directiveChanges = findChangesByPath(changes, 'enumA.A.@deprecated'); + expect(directiveChanges).toHaveLength(2); - expect(changes.length).toEqual(2); - expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); - expect(change.message).toEqual(`Enum value 'enumA.A' was deprecated with reason 'New Reason'`); + for (const change of directiveChanges) { + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + if (change.type === ChangeType.EnumValueDeprecationReasonAdded) { + expect(change.message).toEqual( + `Enum value 'enumA.A' was deprecated with reason 'New Reason'`, + ); + } else if (change.type === ChangeType.DirectiveUsageEnumValueAdded) { + expect(change.message).toEqual(`Directive 'deprecated' was added to enum value 'enumA.A'`); + } + } + + { + const change = findFirstChangeByPath(changes, 'enumA.A.@deprecated.reason'); + expect(change.type).toEqual(ChangeType.DirectiveUsageArgumentAdded); + expect(change.message).toEqual(`Argument 'reason' was added to '@deprecated'`); + } }); test('deprecation reason removed', async () => { diff --git a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts index c7079705fa..dfb94e9cca 100644 --- a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts +++ b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts @@ -113,17 +113,23 @@ describe('ignoreNestedAdditions rule', () => { `); const changes = await diff(a, b, [ignoreNestedAdditions]); - expect(changes).toHaveLength(3); { const added = findFirstChangeByPath(changes, 'FooUnion'); - expect(added.type).toBe(ChangeType.TypeAdded); + expect(added?.type).toBe(ChangeType.TypeAdded); } { const added = findFirstChangeByPath(changes, 'Foo'); - expect(added.type).toBe(ChangeType.TypeAdded); + expect(added?.type).toBe(ChangeType.TypeAdded); + } + + { + const added = findFirstChangeByPath(changes, '@special'); + expect(added?.type).toBe(ChangeType.DirectiveAdded); } + + expect(changes).toHaveLength(3); }); test('added argument / location / description on new directive', async () => { diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index 4a0fdf2fb9..7f18916dc9 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -341,40 +341,45 @@ test('huge test', async () => { } } - for (const path of [ - 'WillBeRemoved', + const expectedPaths = [ 'DType', + 'DType.b', + 'WillBeRemoved', + 'AInput.c', + 'AInput.b', + 'AInput.a', + 'AInput.a', + 'AInput.a', + 'ListInput.a', 'Query.a', 'Query.a.anArg', 'Query.b', 'Query', 'BType', - 'AInput.b', - 'AInput.c', - 'AInput.a', - 'AInput.a', - 'AInput.a', 'CType', - 'CType.c', 'CType.b', + 'CType.c', 'CType.a', 'CType.a.arg', 'CType.d.arg', 'MyUnion', 'MyUnion', - 'AnotherInterface.anotherInterfaceField', 'AnotherInterface.b', + 'AnotherInterface.anotherInterfaceField', 'WithInterfaces', 'WithArguments.a.a', 'WithArguments.a.b', 'WithArguments.b.arg', - 'Options.C', 'Options.D', + 'Options.C', 'Options.A', - 'Options.E', - 'Options.F.deprecated', - '@willBeRemoved', + 'Options.E.@deprecated', + 'Options.E.@deprecated', + 'Options.F.@deprecated', + '@yolo2', + '@yolo2', '@yolo2', + '@willBeRemoved', '@yolo', '@yolo', '@yolo', @@ -383,14 +388,17 @@ test('huge test', async () => { '@yolo.someArg', '@yolo.someArg', '@yolo.anotherArg', - ]) { + ]; + for (const path of expectedPaths) { try { - expect(changes.some(c => c.path === path)).toEqual(true); + expect(changes.find(c => c.path === path)?.path).toEqual(path); } catch (e) { console.log(`Couldn't find: ${path}`); throw e; } } + // make sure all expected changes are accounted for. + expect(expectedPaths).toHaveLength(changes.length); }); test('array as default value in argument (same)', async () => { diff --git a/packages/core/src/diff/argument.ts b/packages/core/src/diff/argument.ts index 0902790042..c278351ff8 100644 --- a/packages/core/src/diff/argument.ts +++ b/packages/core/src/diff/argument.ts @@ -11,7 +11,11 @@ import { fieldArgumentDescriptionChanged, fieldArgumentTypeChanged, } from './changes/argument.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { AddChange } from './schema.js'; export function changesInArgument( @@ -55,6 +59,18 @@ export function changesInArgument( oldArg === null, ), ); + directiveUsageChanged(null, directive, addChange, type, field, newArg); + }, + + onMutual(directive) { + directiveUsageChanged( + directive.oldVersion, + directive.newVersion, + addChange, + type, + field, + newArg, + ); }, onRemoved(directive) { diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index ff83ae53b9..4fdacaf403 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -110,6 +110,8 @@ export const ChangeType = { DirectiveUsageFieldDefinitionRemoved: 'DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED', DirectiveUsageInputFieldDefinitionAdded: 'DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_ADDED', DirectiveUsageInputFieldDefinitionRemoved: 'DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_REMOVED', + DirectiveUsageArgumentAdded: 'DIRECTIVE_USAGE_ARGUMENT_ADDED', + DirectiveUsageArgumentRemoved: 'DIRECTIVE_USAGE_ARGUMENT_REMOVED', } as const; export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; @@ -858,6 +860,43 @@ export type DirectiveUsageArgumentDefinitionAddedChange = { }; }; +export type DirectiveUsageArgumentAddedChange = { + type: typeof ChangeType.DirectiveUsageArgumentAdded; + meta: { + /** Name of the directive that this argument is being added to */ + directiveName: string; + addedArgumentName: string; + addedArgumentValue: string; + /** If the argument had an existing value */ + oldArgumentValue: string | null; + /** The nearest ancestor that is a type, if any. If null, then this change is on the schema node */ + parentTypeName: string | null; + /** The nearest ancestor that is a field, if any. If null, then this change is on a type node. */ + parentFieldName: string | null; + /** The nearest ancestor that is a argument, if any. If null, then this change is on a field node. */ + parentArgumentName: string | null; + /** The nearest ancestor that is an enum value. If the directive is used on an enum value rather than a field, then populate this. */ + parentEnumValueName: string | null; + }; +}; + +export type DirectiveUsageArgumentRemovedChange = { + type: typeof ChangeType.DirectiveUsageArgumentRemoved; + meta: { + /** Name of the directive that this argument is being removed from */ + directiveName: string; + removedArgumentName: string; + /** The nearest ancestor that is a type, if any. If null, then this change is on the schema node */ + parentTypeName: string | null; + /** The nearest ancestor that is a field, if any. If null, then this change is on a type node. */ + parentFieldName: string | null; + /** The nearest ancestor that is a argument, if any. If null, then this change is on a field node. */ + parentArgumentName: string | null; + /** The nearest ancestor that is an enum value. If the directive is used on an enum value rather than a field, then populate this. */ + parentEnumValueName: string | null; + }; +}; + type Changes = { [ChangeType.TypeAdded]: TypeAddedChange; [ChangeType.TypeRemoved]: TypeRemovedChange; @@ -955,6 +994,8 @@ type Changes = { [ChangeType.DirectiveUsageFieldDefinitionRemoved]: DirectiveUsageFieldDefinitionRemovedChange; [ChangeType.DirectiveUsageInputFieldDefinitionAdded]: DirectiveUsageInputFieldDefinitionAddedChange; [ChangeType.DirectiveUsageInputFieldDefinitionRemoved]: DirectiveUsageInputFieldDefinitionRemovedChange; + [ChangeType.DirectiveUsageArgumentAdded]: DirectiveUsageArgumentAddedChange; + [ChangeType.DirectiveUsageArgumentRemoved]: DirectiveUsageArgumentRemovedChange; }; export type SerializableChange = Changes[keyof Changes]; diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 075e97e002..cdeb609660 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -7,18 +7,24 @@ import { GraphQLInputField, GraphQLInputObjectType, GraphQLInterfaceType, + GraphQLNamedType, GraphQLObjectType, GraphQLScalarType, GraphQLSchema, GraphQLUnionType, Kind, + print, } from 'graphql'; +import { compareLists } from '../../utils/compare.js'; +import { AddChange } from '../schema.js'; import { Change, ChangeType, CriticalityLevel, + DirectiveUsageArgumentAddedChange, DirectiveUsageArgumentDefinitionAddedChange, DirectiveUsageArgumentDefinitionRemovedChange, + DirectiveUsageArgumentRemovedChange, DirectiveUsageEnumAddedChange, DirectiveUsageEnumRemovedChange, DirectiveUsageEnumValueAddedChange, @@ -153,7 +159,7 @@ export function directiveUsageArgumentDefinitionAddedFromMeta( args.meta.typeName, args.meta.fieldName, args.meta.argumentName, - args.meta.addedDirectiveName, + `@${args.meta.addedDirectiveName}`, ].join('.'), meta: args.meta, } as const; @@ -179,7 +185,7 @@ export function directiveUsageArgumentDefinitionRemovedFromMeta( args.meta.typeName, args.meta.fieldName, args.meta.argumentName, - args.meta.removedDirectiveName, + `@${args.meta.removedDirectiveName}`, ].join('.'), meta: args.meta, } as const; @@ -201,7 +207,7 @@ export function directiveUsageInputObjectAddedFromMeta(args: DirectiveUsageInput }, type: ChangeType.DirectiveUsageInputObjectAdded, message: buildDirectiveUsageInputObjectAddedMessage(args.meta), - path: [args.meta.inputObjectName, args.meta.addedDirectiveName].join('.'), + path: [args.meta.inputObjectName, `@${args.meta.addedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -222,7 +228,7 @@ export function directiveUsageInputObjectRemovedFromMeta( }, type: ChangeType.DirectiveUsageInputObjectRemoved, message: buildDirectiveUsageInputObjectRemovedMessage(args.meta), - path: [args.meta.inputObjectName, args.meta.removedDirectiveName].join('.'), + path: [args.meta.inputObjectName, `@${args.meta.removedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -243,7 +249,7 @@ export function directiveUsageInterfaceAddedFromMeta(args: DirectiveUsageInterfa }, type: ChangeType.DirectiveUsageInterfaceAdded, message: buildDirectiveUsageInterfaceAddedMessage(args.meta), - path: [args.meta.interfaceName, args.meta.addedDirectiveName].join('.'), + path: [args.meta.interfaceName, `@${args.meta.addedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -262,7 +268,7 @@ export function directiveUsageInterfaceRemovedFromMeta(args: DirectiveUsageInter }, type: ChangeType.DirectiveUsageInterfaceRemoved, message: buildDirectiveUsageInterfaceRemovedMessage(args.meta), - path: [args.meta.interfaceName, args.meta.removedDirectiveName].join('.'), + path: [args.meta.interfaceName, `@${args.meta.removedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -285,9 +291,11 @@ export function directiveUsageInputFieldDefinitionAddedFromMeta( }, type: ChangeType.DirectiveUsageInputFieldDefinitionAdded, message: buildDirectiveUsageInputFieldDefinitionAddedMessage(args.meta), - path: [args.meta.inputObjectName, args.meta.inputFieldName, args.meta.addedDirectiveName].join( - '.', - ), + path: [ + args.meta.inputObjectName, + args.meta.inputFieldName, + `@${args.meta.addedDirectiveName}`, + ].join('.'), meta: args.meta, } as const; } @@ -311,7 +319,7 @@ export function directiveUsageInputFieldDefinitionRemovedFromMeta( path: [ args.meta.inputObjectName, args.meta.inputFieldName, - args.meta.removedDirectiveName, + `@${args.meta.removedDirectiveName}`, ].join('.'), meta: args.meta, } as const; @@ -333,7 +341,7 @@ export function directiveUsageObjectAddedFromMeta(args: DirectiveUsageObjectAdde }, type: ChangeType.DirectiveUsageObjectAdded, message: buildDirectiveUsageObjectAddedMessage(args.meta), - path: [args.meta.objectName, args.meta.addedDirectiveName].join('.'), + path: [args.meta.objectName, `@${args.meta.addedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -352,7 +360,7 @@ export function directiveUsageObjectRemovedFromMeta(args: DirectiveUsageObjectRe }, type: ChangeType.DirectiveUsageObjectRemoved, message: buildDirectiveUsageObjectRemovedMessage(args.meta), - path: [args.meta.objectName, args.meta.removedDirectiveName].join('.'), + path: [args.meta.objectName, `@${args.meta.removedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -371,7 +379,7 @@ export function directiveUsageEnumAddedFromMeta(args: DirectiveUsageEnumAddedCha }, type: ChangeType.DirectiveUsageEnumAdded, message: buildDirectiveUsageEnumAddedMessage(args.meta), - path: [args.meta.enumName, args.meta.addedDirectiveName].join('.'), + path: [args.meta.enumName, `@${args.meta.addedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -390,7 +398,7 @@ export function directiveUsageEnumRemovedFromMeta(args: DirectiveUsageEnumRemove }, type: ChangeType.DirectiveUsageEnumRemoved, message: buildDirectiveUsageEnumRemovedMessage(args.meta), - path: [args.meta.enumName, args.meta.removedDirectiveName].join('.'), + path: [args.meta.enumName, `@${args.meta.removedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -413,7 +421,7 @@ export function directiveUsageFieldDefinitionAddedFromMeta( }, type: ChangeType.DirectiveUsageFieldDefinitionAdded, message: buildDirectiveUsageFieldDefinitionAddedMessage(args.meta), - path: [args.meta.typeName, args.meta.fieldName, args.meta.addedDirectiveName].join('.'), + path: [args.meta.typeName, args.meta.fieldName, `@${args.meta.addedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -434,7 +442,7 @@ export function directiveUsageFieldDefinitionRemovedFromMeta( }, type: ChangeType.DirectiveUsageFieldDefinitionRemoved, message: buildDirectiveUsageFieldDefinitionRemovedMessage(args.meta), - path: [args.meta.typeName, args.meta.fieldName, args.meta.removedDirectiveName].join('.'), + path: [args.meta.typeName, args.meta.fieldName, `@${args.meta.removedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -455,7 +463,9 @@ export function directiveUsageEnumValueAddedFromMeta(args: DirectiveUsageEnumVal }, type: ChangeType.DirectiveUsageEnumValueAdded, message: buildDirectiveUsageEnumValueAddedMessage(args.meta), - path: [args.meta.enumName, args.meta.enumValueName, args.meta.addedDirectiveName].join('.'), + path: [args.meta.enumName, args.meta.enumValueName, `@${args.meta.addedDirectiveName}`].join( + '.', + ), meta: args.meta, } as const; } @@ -474,7 +484,9 @@ export function directiveUsageEnumValueRemovedFromMeta(args: DirectiveUsageEnumV }, type: ChangeType.DirectiveUsageEnumValueRemoved, message: buildDirectiveUsageEnumValueRemovedMessage(args.meta), - path: [args.meta.enumName, args.meta.enumValueName, args.meta.removedDirectiveName].join('.'), + path: [args.meta.enumName, args.meta.enumValueName, `@${args.meta.removedDirectiveName}`].join( + '.', + ), meta: args.meta, } as const; } @@ -495,7 +507,7 @@ export function directiveUsageSchemaAddedFromMeta(args: DirectiveUsageSchemaAdde }, type: ChangeType.DirectiveUsageSchemaAdded, message: buildDirectiveUsageSchemaAddedMessage(args.meta), - path: [args.meta.schemaTypeName, args.meta.addedDirectiveName].join('.'), + path: `.@${args.meta.addedDirectiveName}`, meta: args.meta, } as const; } @@ -514,7 +526,7 @@ export function directiveUsageSchemaRemovedFromMeta(args: DirectiveUsageSchemaRe }, type: ChangeType.DirectiveUsageSchemaRemoved, message: buildDirectiveUsageSchemaRemovedMessage(args.meta), - path: [args.meta.schemaTypeName, args.meta.removedDirectiveName].join('.'), + path: `.@${args.meta.removedDirectiveName}`, meta: args.meta, } as const; } @@ -535,7 +547,7 @@ export function directiveUsageScalarAddedFromMeta(args: DirectiveUsageScalarAdde }, type: ChangeType.DirectiveUsageScalarAdded, message: buildDirectiveUsageScalarAddedMessage(args.meta), - path: [args.meta.scalarName, args.meta.addedDirectiveName].join('.'), + path: [args.meta.scalarName, `@${args.meta.addedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -554,7 +566,7 @@ export function directiveUsageScalarRemovedFromMeta(args: DirectiveUsageScalarRe }, type: ChangeType.DirectiveUsageScalarRemoved, message: buildDirectiveUsageScalarRemovedMessage(args.meta), - path: [args.meta.scalarName, args.meta.removedDirectiveName].join('.'), + path: [args.meta.scalarName, `@${args.meta.removedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -575,7 +587,7 @@ export function directiveUsageUnionMemberAddedFromMeta(args: DirectiveUsageUnion }, type: ChangeType.DirectiveUsageUnionMemberAdded, message: buildDirectiveUsageUnionMemberAddedMessage(args.meta), - path: [args.meta.unionName, args.meta.addedDirectiveName].join('.'), + path: [args.meta.unionName, `@${args.meta.addedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -596,7 +608,7 @@ export function directiveUsageUnionMemberRemovedFromMeta( }, type: ChangeType.DirectiveUsageUnionMemberRemoved, message: buildDirectiveUsageUnionMemberRemovedMessage(args.meta), - path: [args.meta.unionName, args.meta.removedDirectiveName].join('.'), + path: [args.meta.unionName, `@${args.meta.removedDirectiveName}`].join('.'), meta: args.meta, } as const; } @@ -882,3 +894,108 @@ function isOfKind( ): _value is KindToPayload[K]['input'] { return kind === expectedKind; } + +export function directiveUsageArgumentAdded(args: DirectiveUsageArgumentAddedChange): Change { + return { + type: ChangeType.DirectiveUsageArgumentAdded, + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: `Argument '${args.meta.addedArgumentName}' was added to '@${args.meta.directiveName}'`, + path: [ + /** If the type is missing then this must be a directive on a schema */ + args.meta.parentTypeName ?? '', + args.meta.parentFieldName ?? args.meta.parentEnumValueName, + args.meta.parentArgumentName, + `@${args.meta.directiveName}`, + args.meta.addedArgumentName, + ] + .filter(p => p !== null) + .join('.'), + meta: args.meta, + }; +} + +export function directiveUsageArgumentRemoved(args: DirectiveUsageArgumentRemovedChange): Change { + return { + type: ChangeType.DirectiveUsageArgumentRemoved, + criticality: { + level: CriticalityLevel.Dangerous, + reason: `Changing an argument on a directive can change runtime behavior.`, + }, + message: `Argument '${args.meta.removedArgumentName}' was removed from '@${args.meta.directiveName}'`, + path: [ + /** If the type is missing then this must be a directive on a schema */ + args.meta.parentTypeName ?? '', + args.meta.parentFieldName ?? args.meta.parentEnumValueName, + args.meta.parentArgumentName, + `@${args.meta.directiveName}`, + args.meta.removedArgumentName, + ].join('.'), + meta: args.meta, + }; +} + +// @question should this be separate change events for every case for safety? +export function directiveUsageChanged( + oldDirective: ConstDirectiveNode | null, + newDirective: ConstDirectiveNode, + addChange: AddChange, + parentType?: GraphQLNamedType, + parentField?: GraphQLField | GraphQLInputField, + parentArgument?: GraphQLArgument, + parentEnumValue?: GraphQLEnumValue, +) { + compareLists(oldDirective?.arguments || [], newDirective.arguments || [], { + onAdded(argument) { + addChange( + directiveUsageArgumentAdded({ + type: ChangeType.DirectiveUsageArgumentAdded, + meta: { + addedArgumentName: argument.name.value, + addedArgumentValue: print(argument.value), + oldArgumentValue: null, + directiveName: newDirective.name.value, + parentTypeName: parentType?.name ?? null, + parentFieldName: parentField?.name ?? null, + parentArgumentName: parentArgument?.name ?? null, + parentEnumValueName: parentEnumValue?.name ?? null, + }, + }), + ); + }, + + onMutual(argument) { + directiveUsageArgumentAdded({ + type: ChangeType.DirectiveUsageArgumentAdded, + meta: { + addedArgumentName: argument.newVersion.name.value, + addedArgumentValue: print(argument.newVersion.value), + oldArgumentValue: + (argument.oldVersion?.value && print(argument.oldVersion.value)) ?? null, + directiveName: newDirective.name.value, + parentTypeName: parentType?.name ?? null, + parentFieldName: parentField?.name ?? null, + parentArgumentName: parentArgument?.name ?? null, + parentEnumValueName: parentEnumValue?.name ?? null, + }, + }); + }, + + onRemoved(argument) { + addChange( + directiveUsageArgumentRemoved({ + type: ChangeType.DirectiveUsageArgumentRemoved, + meta: { + removedArgumentName: argument.name.value, + directiveName: newDirective.name.value, + parentTypeName: parentType?.name ?? null, + parentFieldName: parentField?.name ?? null, + parentArgumentName: parentArgument?.name ?? null, + parentEnumValueName: parentEnumValue?.name ?? null, + }, + }), + ); + }, + }); +} diff --git a/packages/core/src/diff/changes/enum.ts b/packages/core/src/diff/changes/enum.ts index 2876b5dba2..f9acd24880 100644 --- a/packages/core/src/diff/changes/enum.ts +++ b/packages/core/src/diff/changes/enum.ts @@ -139,7 +139,9 @@ export function enumValueDeprecationReasonChangedFromMeta( }, type: ChangeType.EnumValueDeprecationReasonChanged, message: buildEnumValueDeprecationChangedMessage(args.meta), - path: [args.meta.enumName, args.meta.enumValueName, GraphQLDeprecatedDirective.name].join('.'), + path: [args.meta.enumName, args.meta.enumValueName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), meta: args.meta, } as const; } @@ -175,7 +177,9 @@ export function enumValueDeprecationReasonAddedFromMeta( }, type: ChangeType.EnumValueDeprecationReasonAdded, message: buildEnumValueDeprecationReasonAddedMessage(args.meta), - path: [args.meta.enumName, args.meta.enumValueName].join('.'), + path: [args.meta.enumName, args.meta.enumValueName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), meta: args.meta, } as const; } diff --git a/packages/core/src/diff/changes/field.ts b/packages/core/src/diff/changes/field.ts index e06b649d07..966462aef2 100644 --- a/packages/core/src/diff/changes/field.ts +++ b/packages/core/src/diff/changes/field.ts @@ -290,7 +290,9 @@ export function fieldDeprecationReasonAddedFromMeta(args: FieldDeprecationReason }, message: buildFieldDeprecationReasonAddedMessage(args.meta), meta: args.meta, - path: [args.meta.typeName, args.meta.fieldName, GraphQLDeprecatedDirective.name].join('.'), + path: [args.meta.typeName, args.meta.fieldName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), } as const; } diff --git a/packages/core/src/diff/enum.ts b/packages/core/src/diff/enum.ts index 01fff956ae..f436b3bd52 100644 --- a/packages/core/src/diff/enum.ts +++ b/packages/core/src/diff/enum.ts @@ -1,6 +1,11 @@ import { GraphQLEnumType, GraphQLEnumValue, Kind } from 'graphql'; +import { DEPRECATION_REASON_DEFAULT } from 'packages/patch/src/utils.js'; import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { enumValueAdded, enumValueDeprecationReasonAdded, @@ -34,6 +39,10 @@ export function changesInEnum( addChange( directiveUsageAdded(Kind.ENUM_TYPE_DEFINITION, directive, newEnum, oldEnum === null), ); + directiveUsageChanged(null, directive, addChange, newEnum); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newEnum); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.ENUM_TYPE_DEFINITION, directive, newEnum)); @@ -57,15 +66,14 @@ function changesInEnumValue( } if (isNotEqual(oldValue?.deprecationReason, newValue.deprecationReason)) { - // @note "No longer supported" is the default graphql reason if ( isVoid(oldValue?.deprecationReason) || - oldValue?.deprecationReason === 'No longer supported' + oldValue?.deprecationReason === DEPRECATION_REASON_DEFAULT ) { addChange(enumValueDeprecationReasonAdded(newEnum, oldValue, newValue)); } else if ( isVoid(newValue.deprecationReason) || - newValue?.deprecationReason === 'No longer supported' + newValue?.deprecationReason === DEPRECATION_REASON_DEFAULT ) { addChange(enumValueDeprecationReasonRemoved(newEnum, oldValue, newValue)); } else { @@ -86,6 +94,18 @@ function changesInEnumValue( oldValue === null, ), ); + directiveUsageChanged(null, directive, addChange, newEnum, undefined, undefined, newValue); + }, + onMutual(directive) { + directiveUsageChanged( + directive.oldVersion, + directive.newVersion, + addChange, + newEnum, + undefined, + undefined, + newValue, + ); }, onRemoved(directive) { addChange( diff --git a/packages/core/src/diff/field.ts b/packages/core/src/diff/field.ts index 1ae5e89f49..9b500dcb1d 100644 --- a/packages/core/src/diff/field.ts +++ b/packages/core/src/diff/field.ts @@ -1,8 +1,13 @@ import { GraphQLField, GraphQLInterfaceType, GraphQLObjectType, Kind } from 'graphql'; +import { DEPRECATION_REASON_DEFAULT } from 'packages/patch/src/utils.js'; import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { isDeprecated } from '../utils/is-deprecated.js'; import { changesInArgument } from './argument.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { fieldArgumentAdded, fieldArgumentRemoved, @@ -45,12 +50,12 @@ export function changesInField( } else if (isNotEqual(oldField.deprecationReason, newField.deprecationReason)) { if ( isVoid(oldField.deprecationReason) || - oldField.deprecationReason === 'No longer supported' + oldField.deprecationReason === DEPRECATION_REASON_DEFAULT ) { addChange(fieldDeprecationReasonAdded(type, newField)); } else if ( isVoid(newField.deprecationReason) || - newField.deprecationReason === 'No longer supported' + newField.deprecationReason === DEPRECATION_REASON_DEFAULT ) { addChange(fieldDeprecationReasonRemoved(type, oldField)); } else { @@ -87,6 +92,10 @@ export function changesInField( oldField === null, ), ); + directiveUsageChanged(null, directive, addChange, type, newField); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, type, newField); }, onRemoved(arg) { addChange( diff --git a/packages/core/src/diff/input.ts b/packages/core/src/diff/input.ts index 30049ada87..2ecc7a9766 100644 --- a/packages/core/src/diff/input.ts +++ b/packages/core/src/diff/input.ts @@ -1,6 +1,10 @@ import { GraphQLInputField, GraphQLInputObjectType, Kind } from 'graphql'; import { compareLists, diffArrays, isNotEqual, isVoid } from '../utils/compare.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { inputFieldAdded, inputFieldDefaultValueChanged, @@ -43,6 +47,10 @@ export function changesInInputObject( oldInput === null, ), ); + directiveUsageChanged(null, directive, addChange, newInput); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newInput); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.INPUT_OBJECT_TYPE_DEFINITION, directive, oldInput!)); @@ -96,6 +104,16 @@ function changesInInputField( oldField === null, ), ); + directiveUsageChanged(null, directive, addChange, input, newField); + }, + onMutual(directive) { + directiveUsageChanged( + directive.oldVersion, + directive.newVersion, + addChange, + input, + newField, + ); }, onRemoved(directive) { addChange( diff --git a/packages/core/src/diff/interface.ts b/packages/core/src/diff/interface.ts index 0126222131..bbdda50fd8 100644 --- a/packages/core/src/diff/interface.ts +++ b/packages/core/src/diff/interface.ts @@ -1,6 +1,10 @@ import { GraphQLInterfaceType, Kind } from 'graphql'; import { compareLists } from '../utils/compare.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { fieldAdded, fieldRemoved } from './changes/field.js'; import { objectTypeInterfaceAdded, objectTypeInterfaceRemoved } from './changes/object.js'; import { changesInField } from './field.js'; @@ -48,6 +52,10 @@ export function changesInInterface( oldInterface === null, ), ); + directiveUsageChanged(null, directive, addChange, newInterface); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newInterface); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.INTERFACE_TYPE_DEFINITION, directive, oldInterface!)); diff --git a/packages/core/src/diff/object.ts b/packages/core/src/diff/object.ts index c716fb98a1..3aef4e3d2f 100644 --- a/packages/core/src/diff/object.ts +++ b/packages/core/src/diff/object.ts @@ -1,6 +1,10 @@ import { GraphQLObjectType, Kind } from 'graphql'; import { compareLists } from '../utils/compare.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { fieldAdded, fieldRemoved } from './changes/field.js'; import { objectTypeInterfaceAdded, objectTypeInterfaceRemoved } from './changes/object.js'; import { changesInField } from './field.js'; @@ -42,6 +46,10 @@ export function changesInObject( compareLists(oldType?.astNode?.directives || [], newType.astNode?.directives || [], { onAdded(directive) { addChange(directiveUsageAdded(Kind.OBJECT, directive, newType, oldType === null)); + directiveUsageChanged(null, directive, addChange, newType); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newType); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.OBJECT, directive, oldType!)); diff --git a/packages/core/src/diff/rules/ignore-nested-additions.ts b/packages/core/src/diff/rules/ignore-nested-additions.ts index af61f0054b..9c9f7a0a2e 100644 --- a/packages/core/src/diff/rules/ignore-nested-additions.ts +++ b/packages/core/src/diff/rules/ignore-nested-additions.ts @@ -5,6 +5,7 @@ const additionChangeTypes = new Set([ ChangeType.DirectiveAdded, ChangeType.DirectiveArgumentAdded, ChangeType.DirectiveLocationAdded, + ChangeType.DirectiveUsageArgumentAdded, ChangeType.DirectiveUsageArgumentDefinitionAdded, ChangeType.DirectiveUsageEnumAdded, ChangeType.DirectiveUsageEnumValueAdded, @@ -44,7 +45,7 @@ export const ignoreNestedAdditions: Rule = ({ changes }) => { const filteredChanges = changes.filter(({ path, type }) => { if (path) { const parent = parentPath(path); - const matches = additionPaths.filter(matchedPath => matchedPath.includes(parent)); + const matches = additionPaths.filter(matchedPath => matchedPath.startsWith(parent)); const hasAddedParent = matches.length > 0; if (additionChangeTypes.has(type)) { diff --git a/packages/core/src/diff/scalar.ts b/packages/core/src/diff/scalar.ts index fd3ba88586..b59a157ce3 100644 --- a/packages/core/src/diff/scalar.ts +++ b/packages/core/src/diff/scalar.ts @@ -1,6 +1,10 @@ import { GraphQLScalarType, Kind } from 'graphql'; import { compareLists } from '../utils/compare.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { AddChange } from './schema.js'; export function changesInScalar( @@ -13,6 +17,10 @@ export function changesInScalar( addChange( directiveUsageAdded(Kind.SCALAR_TYPE_DEFINITION, directive, newScalar, oldScalar === null), ); + directiveUsageChanged(null, directive, addChange, newScalar); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newScalar); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.SCALAR_TYPE_DEFINITION, directive, oldScalar!)); diff --git a/packages/core/src/diff/schema.ts b/packages/core/src/diff/schema.ts index 92b7d37b70..bf6795ee0b 100644 --- a/packages/core/src/diff/schema.ts +++ b/packages/core/src/diff/schema.ts @@ -13,7 +13,11 @@ import { import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { isPrimitive } from '../utils/graphql.js'; import { Change } from './changes/change.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { directiveAdded, directiveRemoved } from './changes/directive.js'; import { schemaMutationTypeChanged, @@ -80,6 +84,10 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): compareLists(oldSchema.astNode?.directives || [], newSchema.astNode?.directives || [], { onAdded(directive) { addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema, false)); + directiveUsageChanged(null, directive, addChange); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.SCHEMA_DEFINITION, directive, oldSchema)); diff --git a/packages/core/src/diff/union.ts b/packages/core/src/diff/union.ts index 6c0ed2e6f2..b4c338076a 100644 --- a/packages/core/src/diff/union.ts +++ b/packages/core/src/diff/union.ts @@ -1,6 +1,10 @@ import { GraphQLUnionType, Kind } from 'graphql'; import { compareLists } from '../utils/compare.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { unionMemberAdded, unionMemberRemoved } from './changes/union.js'; import { AddChange } from './schema.js'; @@ -26,6 +30,10 @@ export function changesInUnion( addChange( directiveUsageAdded(Kind.UNION_TYPE_DEFINITION, directive, newUnion, oldUnion === null), ); + directiveUsageChanged(null, directive, addChange, newUnion); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newUnion); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.UNION_TYPE_DEFINITION, directive, oldUnion!)); diff --git a/packages/patch/package.json b/packages/patch/package.json index 57737880bd..7e826618c8 100644 --- a/packages/patch/package.json +++ b/packages/patch/package.json @@ -54,15 +54,15 @@ "scripts": { "prepack": "bob prepack" }, - "dependencies": { - "tslib": "2.6.2" - }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, + "dependencies": { + "@graphql-tools/utils": "^10.0.0", + "tslib": "2.6.2" + }, "devDependencies": { - "@graphql-inspector/core": "workspace:*", - "@graphql-tools/utils": "^10.0.0" + "@graphql-inspector/core": "workspace:*" }, "publishConfig": { "directory": "dist", diff --git a/packages/patch/src/__tests__/fields.test.ts b/packages/patch/src/__tests__/fields.test.ts index 546d9d54f0..885d84033c 100644 --- a/packages/patch/src/__tests__/fields.test.ts +++ b/packages/patch/src/__tests__/fields.test.ts @@ -93,6 +93,22 @@ describe('fields', () => { await expectPatchToMatch(before, after); }); + test('fieldDeprecationAdded: with reason', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession @deprecated(reason: "Because no one chats anymore") + } + `; + await expectPatchToMatch(before, after); + }); + test('fieldDeprecationRemoved', async () => { const before = /* GraphQL */ ` scalar ChatSession diff --git a/packages/patch/src/__tests__/utils.ts b/packages/patch/src/__tests__/utils.ts index 5f3bdc659a..40fb4db91f 100644 --- a/packages/patch/src/__tests__/utils.ts +++ b/packages/patch/src/__tests__/utils.ts @@ -1,7 +1,7 @@ -import { buildSchema, GraphQLSchema, lexicographicSortSchema, printSchema } from 'graphql'; +import { buildSchema, GraphQLSchema, lexicographicSortSchema, parse, print } from 'graphql'; import { Change, diff } from '@graphql-inspector/core'; +import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { patchSchema } from '../index.js'; -import { printSchemaWithDirectives } from '@graphql-tools/utils' function printSortedSchema(schema: GraphQLSchema) { return printSchemaWithDirectives(lexicographicSortSchema(schema)); @@ -12,7 +12,10 @@ export async function expectPatchToMatch(before: string, after: string): Promise const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); const changes = await diff(schemaA, schemaB); - const patched = patchSchema(schemaA, changes, { throwOnError: true }); + const patched = patchSchema(schemaA, changes, { + throwOnError: true, + debug: process.env.DEBUG === 'true', + }); expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patched)); return changes; } diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 27ae5cf58f..354bde62e6 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -6,13 +6,15 @@ import { isDefinitionNode, Kind, parse, - printSchema, visit, } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; +import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { + directiveUsageArgumentAdded, directiveUsageArgumentDefinitionAdded, directiveUsageArgumentDefinitionRemoved, + directiveUsageArgumentRemoved, directiveUsageEnumAdded, directiveUsageEnumRemoved, directiveUsageEnumValueAdded, @@ -91,8 +93,9 @@ export function patchSchema( changes: Change[], config?: PatchConfig, ): GraphQLSchema { - const ast = parse(printSchema(schema)); - return buildASTSchema(patch(ast, changes, config), { assumeValid: true, assumeValidSDL: true }); + const ast = parse(printSchemaWithDirectives(schema, { assumeValid: true })); + const patchedAst = patch(ast, changes, config); + return buildASTSchema(patchedAst, { assumeValid: true, assumeValidSDL: true }); } function groupNodesByPath(ast: DocumentNode): [SchemaNode[], Map] { @@ -118,8 +121,7 @@ function groupNodesByPath(ast: DocumentNode): [SchemaNode[], Map, + change: Change, schemaNodes: SchemaNode[], + nodeByPath: Map, config: PatchConfig, ) { // @todo handle repeat directives - // findNamedNode(schemaNodes[0].directives, change.meta.addedDirectiveName) - throw new Error('DirectiveUsageAddedChange on schema node is not implemented yet.') + const directiveAlreadyExists = schemaNodes.some(schemaNode => + findNamedNode(schemaNode.directives, change.meta.addedDirectiveName), + ); + if (directiveAlreadyExists) { + handleError(change, new DirectiveAlreadyExists(change.meta.addedDirectiveName), config); + } else { + const directiveNode: DirectiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(change.meta.addedDirectiveName), + }; + (schemaNodes[0].directives as DirectiveNode[] | undefined) = [ + ...(schemaNodes[0].directives ?? []), + directiveNode, + ]; + nodeByPath.set(`.@${change.meta.addedDirectiveName}`, directiveNode); + } } function schemaDirectiveUsageDefinitionRemoved( change: Change, schemaNodes: SchemaNode[], + nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); - return; - } + let deleted = false; + // @todo handle repeated directives for (const node of schemaNodes) { - // @todo handle repeated directives const directiveNode = findNamedNode(node.directives, change.meta.removedDirectiveName); if (directiveNode) { (node.directives as DirectiveNode[] | undefined) = node.directives?.filter( d => d.name.value !== change.meta.removedDirectiveName, ); + // nodeByPath.delete(change.path) + nodeByPath.delete(`.@${change.meta.removedDirectiveName}`); + deleted = true; + break; } } - handleError(change, new DeletedCoordinateNotFoundError(), config); + if (!deleted) { + handleError(change, new DeletedCoordinateNotFoundError(), config); + } } function directiveUsageDefinitionRemoved( @@ -284,17 +305,19 @@ export function directiveUsageScalarRemoved( export function directiveUsageSchemaAdded( change: Change, schemaDefs: SchemaNode[], + nodeByPath: Map, config: PatchConfig, ) { - return schemaDirectiveUsageDefinitionAdded(change, schemaDefs, config); + return schemaDirectiveUsageDefinitionAdded(change, schemaDefs, nodeByPath, config); } export function directiveUsageSchemaRemoved( change: Change, schemaDefs: SchemaNode[], + nodeByPath: Map, config: PatchConfig, ) { - return schemaDirectiveUsageDefinitionRemoved(change, schemaDefs, config); + return schemaDirectiveUsageDefinitionRemoved(change, schemaDefs, nodeByPath, config); } export function directiveUsageUnionMemberAdded( @@ -312,3 +335,48 @@ export function directiveUsageUnionMemberRemoved( ) { return directiveUsageDefinitionRemoved(change, nodeByPath, config); } + +export function directiveUsageArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + const directiveNode = nodeByPath.get(parentPath(change.path)); + if (!directiveNode) { + handleError(change, new CoordinateNotFoundError(), config); + } else if (directiveNode.kind === Kind.DIRECTIVE) { + const existing = findNamedNode(directiveNode.arguments, change.meta.addedArgumentName); + if (existing) { + handleError(change, new CoordinateAlreadyExistsError(directiveNode.kind), config); + } else { + const argNode: ArgumentNode = { + kind: Kind.ARGUMENT, + name: nameNode(change.meta.addedArgumentName), + value: parseValue(change.meta.addedArgumentValue), + }; + (directiveNode.arguments as ArgumentNode[] | undefined) = [ + ...(directiveNode.arguments ?? []), + argNode, + ]; + nodeByPath.set(change.path, argNode); + } + } else { + handleError(change, new KindMismatchError(Kind.DIRECTIVE, directiveNode.kind), config); + } +} + +export function directiveUsageArgumentRemoved( + change: Change, + _nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new CoordinateNotFoundError(), config); + return; + } + // @todo +} diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 07987ed245..67da04d9df 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -67,9 +67,12 @@ export function directiveArgumentAdded( if (!directiveNode) { handleError(change, new CoordinateNotFoundError(), config); } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { - const existingArg = findNamedNode(directiveNode.arguments, change.meta.addedDirectiveArgumentName); + const existingArg = findNamedNode( + directiveNode.arguments, + change.meta.addedDirectiveArgumentName, + ); if (existingArg) { - handleError(change, new CoordinateAlreadyExistsError(existingArg.kind), config) + handleError(change, new CoordinateAlreadyExistsError(existingArg.kind), config); } else { const node: InputValueDefinitionNode = { kind: Kind.INPUT_VALUE_DEFINITION, diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index b54ae51108..bffa80cb72 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -1,4 +1,12 @@ -import { ArgumentNode, ASTNode, EnumValueDefinitionNode, Kind, print, StringValueNode, ValueNode } from 'graphql'; +import { + ArgumentNode, + ASTNode, + DirectiveNode, + EnumValueDefinitionNode, + Kind, + print, + StringValueNode, +} from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { CoordinateAlreadyExistsError, @@ -10,7 +18,7 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; -import { findNamedNode, getDeprecatedDirectiveNode, parentPath } from '../utils.js'; +import { findNamedNode, parentPath } from '../utils.js'; export function enumValueRemoved( change: Change, @@ -96,10 +104,10 @@ export function enumValueDeprecationReasonAdded( return; } - const enumValueNode = nodeByPath.get(change.path); + const enumValueNode = nodeByPath.get(parentPath(change.path)); + const deprecation = nodeByPath.get(change.path) as DirectiveNode | undefined; if (enumValueNode) { if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { - const deprecation = getDeprecatedDirectiveNode(enumValueNode); if (deprecation) { if (findNamedNode(deprecation.arguments, 'reason')) { handleError(change, new CoordinateAlreadyExistsError(Kind.ARGUMENT), config); @@ -109,7 +117,10 @@ export function enumValueDeprecationReasonAdded( name: nameNode('reason'), value: stringNode(change.meta.addedValueDeprecationReason), }; - (deprecation.arguments as ArgumentNode[] | undefined) = [...(deprecation.arguments ?? []), argNode]; + (deprecation.arguments as ArgumentNode[] | undefined) = [ + ...(deprecation.arguments ?? []), + argNode, + ]; nodeByPath.set(`${change.path}.reason`, argNode); } else { handleError(change, new CoordinateNotFoundError(), config); diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 1e3a38c075..be870beedc 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -26,7 +26,12 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; -import { findNamedNode, getDeprecatedDirectiveNode, parentPath } from '../utils.js'; +import { + DEPRECATION_REASON_DEFAULT, + findNamedNode, + getDeprecatedDirectiveNode, + parentPath, +} from '../utils.js'; export function fieldTypeChanged( change: Change, @@ -265,7 +270,8 @@ export function fieldDeprecationAdded( const directiveNode = { kind: Kind.DIRECTIVE, name: nameNode(GraphQLDeprecatedDirective.name), - ...(change.meta.deprecationReason + ...(change.meta.deprecationReason && + change.meta.deprecationReason !== DEPRECATION_REASON_DEFAULT ? { arguments: [ { @@ -282,7 +288,10 @@ export function fieldDeprecationAdded( ...(fieldNode.directives ?? []), directiveNode, ]; - nodeByPath.set(`${change.path}.${GraphQLDeprecatedDirective.name}`, directiveNode); + nodeByPath.set( + [change.path, `@${GraphQLDeprecatedDirective.name}`].join(','), + directiveNode, + ); } } else { handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); @@ -310,7 +319,7 @@ export function fieldDeprecationRemoved( (fieldNode.directives as DirectiveNode[] | undefined) = fieldNode.directives?.filter( d => d.name.value !== GraphQLDeprecatedDirective.name, ); - nodeByPath.delete(`${change.path}.${GraphQLDeprecatedDirective.name}`); + nodeByPath.delete([change.path, `@${GraphQLDeprecatedDirective.name}`].join('.')); } else { handleError(change, new DeprecatedDirectiveNotFound(), config); } diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts index 7de3488b02..f1bcd850ad 100644 --- a/packages/patch/src/types.ts +++ b/packages/patch/src/types.ts @@ -1,5 +1,5 @@ import type { SchemaDefinitionNode, SchemaExtensionNode } from 'graphql'; -import type { Change, ChangeType } from '@graphql-inspector/core'; +import { Change, ChangeType } from '@graphql-inspector/core'; export type AdditionChangeType = | typeof ChangeType.DirectiveAdded diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 7c0ade6e62..9237dfaf7b 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -1,9 +1,4 @@ -import { - ASTNode, - DirectiveNode, - GraphQLDeprecatedDirective, - NameNode, -} from 'graphql'; +import { ASTNode, DirectiveNode, GraphQLDeprecatedDirective, NameNode } from 'graphql'; import { Maybe } from 'graphql/jsutils/Maybe'; import { Change, ChangeType } from '@graphql-inspector/core'; import { AdditionChangeType } from './types.js'; @@ -11,7 +6,7 @@ import { AdditionChangeType } from './types.js'; export function getDeprecatedDirectiveNode( definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, ): Maybe { - return findNamedNode(definitionNode?.directives, GraphQLDeprecatedDirective.name); + return findNamedNode(definitionNode?.directives, `@${GraphQLDeprecatedDirective.name}`); } export function findNamedNode( @@ -44,6 +39,19 @@ const isAdditionChange = (change: Change): change is Change, nodeByPath: Map Date: Mon, 28 Jul 2025 08:40:50 -0700 Subject: [PATCH 13/23] Remove unnecessary import --- packages/core/src/diff/enum.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/diff/enum.ts b/packages/core/src/diff/enum.ts index f436b3bd52..05528fb63f 100644 --- a/packages/core/src/diff/enum.ts +++ b/packages/core/src/diff/enum.ts @@ -1,5 +1,4 @@ import { GraphQLEnumType, GraphQLEnumValue, Kind } from 'graphql'; -import { DEPRECATION_REASON_DEFAULT } from 'packages/patch/src/utils.js'; import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { directiveUsageAdded, @@ -16,6 +15,8 @@ import { } from './changes/enum.js'; import { AddChange } from './schema.js'; +const DEPRECATION_REASON_DEFAULT = 'No longer supported'; + export function changesInEnum( oldEnum: GraphQLEnumType | null, newEnum: GraphQLEnumType, From 62c16bacdd93d491527267d93754edcbe7e8c8dd Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 28 Jul 2025 08:47:48 -0700 Subject: [PATCH 14/23] Same --- packages/core/src/diff/field.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/diff/field.ts b/packages/core/src/diff/field.ts index 9b500dcb1d..081090c620 100644 --- a/packages/core/src/diff/field.ts +++ b/packages/core/src/diff/field.ts @@ -1,5 +1,4 @@ import { GraphQLField, GraphQLInterfaceType, GraphQLObjectType, Kind } from 'graphql'; -import { DEPRECATION_REASON_DEFAULT } from 'packages/patch/src/utils.js'; import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { isDeprecated } from '../utils/is-deprecated.js'; import { changesInArgument } from './argument.js'; @@ -23,6 +22,8 @@ import { } from './changes/field.js'; import { AddChange } from './schema.js'; +const DEPRECATION_REASON_DEFAULT = 'No longer supported'; + export function changesInField( type: GraphQLObjectType | GraphQLInterfaceType, oldField: GraphQLField | null, From e5462279db0c8fcad09e997a244c3cb3792f5b67 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 20 Aug 2025 20:06:52 -0700 Subject: [PATCH 15/23] Simplify errors; add readme --- packages/patch/src/README.md | 37 ++++ packages/patch/src/errors.ts | 209 ++++++++++-------- packages/patch/src/index.ts | 10 + .../patch/src/patches/directive-usages.ts | 145 ++++++++++-- packages/patch/src/patches/directives.ts | 188 +++++++++++----- packages/patch/src/patches/enum.ts | 135 +++++++---- packages/patch/src/patches/fields.ts | 203 +++++++++++------ packages/patch/src/patches/inputs.ts | 110 +++++++-- packages/patch/src/patches/interfaces.ts | 34 ++- packages/patch/src/patches/schema.ts | 64 ++++-- packages/patch/src/patches/types.ts | 62 ++++-- packages/patch/src/patches/unions.ts | 40 +++- packages/patch/src/types.ts | 26 +++ pnpm-lock.yaml | 6 +- 14 files changed, 913 insertions(+), 356 deletions(-) create mode 100644 packages/patch/src/README.md diff --git a/packages/patch/src/README.md b/packages/patch/src/README.md new file mode 100644 index 0000000000..5a24cbf159 --- /dev/null +++ b/packages/patch/src/README.md @@ -0,0 +1,37 @@ +# GraphQL Change Patch + +This package applies a list of changes (output from `@graphql-inspector/core`'s `diff`) to a GraphQL Schema. + +## Usage + +```typescript +const schemaA = buildSchema(before, { assumeValid: true, assumeValidSDL: true }); +const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); + +const changes = await diff(schemaA, schemaB); +const patched = patchSchema(schemaA, changes); +expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patched)); +``` + +## Configuration + +> By default does not throw when hitting errors such as if a type that was modified no longer exists. + +`throwOnError?: boolean` + +> By default does not require the value at time of change to match what's currently in the schema. Enable this if you need to be extra cautious when detecting conflicts. + +`requireOldValueMatch?: boolean` + +> Allows handling errors more granularly if you only care about specific types of errors or want to capture the errors in a list somewhere etc. If 'true' is returned then this error is considered handled and the default error handling will not be ran. To halt patching, throw the error inside the handler. + +`onError?: (err: Error) => boolean | undefined | null` + +> Enables debug logging + +`debug?: boolean` + +## Remaining Work + +- [] Support repeat directives +- [] Support extensions diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index 5f61e68649..1b562743b8 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -3,10 +3,17 @@ import type { Change } from '@graphql-inspector/core'; import type { PatchConfig } from './types.js'; export function handleError(change: Change, err: Error, config: PatchConfig) { + if (config.onError?.(err) === true) { + // handled by onError + return; + } + if (err instanceof NoopError) { console.debug( `Ignoring change ${change.type} at "${change.path}" because it does not modify the resulting schema.`, ); + } else if (!config.requireOldValueMatch && err instanceof ValueMismatchError) { + console.debug(`Ignoreing old value mismatch at "${change.path}".`); } else if (config.throwOnError === true) { throw err; } else { @@ -25,133 +32,149 @@ export class NoopError extends Error { } } -export class CoordinateNotFoundError extends Error { - constructor() { - super('Cannot find an element at the schema coordinate.'); - } -} - -export class DeletedCoordinateNotFoundError extends NoopError { - constructor() { - super('Cannot find an element at the schema coordinate.'); - } -} - -export class CoordinateAlreadyExistsError extends NoopError { - constructor(public readonly kind: Kind) { - super(`A "${kind}" already exists at the schema coordinate.`); - } -} - -export class DeprecationReasonAlreadyExists extends NoopError { - constructor(reason: string) { - super(`A deprecation reason already exists: "${reason}"`); - } -} - -export class DeprecatedDirectiveNotFound extends NoopError { - constructor() { - super('This coordinate is not deprecated.'); - } -} - -export class EnumValueNotFoundError extends Error { - constructor(typeName: string, value?: string | undefined) { - super(`The enum "${typeName}" does not contain "${value}".`); - } -} - -export class UnionMemberNotFoundError extends NoopError { - constructor() { - super(`The union does not contain the member.`); - } -} - -export class UnionMemberAlreadyExistsError extends NoopError { - constructor(typeName: string, type: string) { - super(`The union "${typeName}" already contains the member "${type}".`); - } -} - -export class DirectiveLocationAlreadyExistsError extends NoopError { - constructor(directiveName: string, location: string) { - super(`The directive "${directiveName}" already can be located on "${location}".`); - } -} - -export class DirectiveAlreadyExists extends NoopError { - constructor(directiveName: string) { - super(`The directive "${directiveName}" already exists.`); +export class ValueMismatchError extends Error { + readonly mismatch = true; + constructor(kind: Kind, expected: string | undefined | null, actual: string | undefined | null) { + super( + `The existing value did not match what was expected. Expected the "${kind}" to be ${expected} but found ${actual}.`, + ); } } -export class KindMismatchError extends Error { +/** + * If the requested change would not modify the schema because that change is effectively + * already applied. + * + * If the added coordinate exists but the kind does not match what's expected, then use + * ChangedCoordinateKindMismatchError instead. + */ +export class AddedCoordinateAlreadyExistsError extends NoopError { constructor( - public readonly expectedKind: Kind, - public readonly receivedKind: Kind, + public readonly kind: Kind, + readonly expectedNameOrValue: string | undefined, ) { - super(`Expected type to have be a "${expectedKind}", but found a "${receivedKind}".`); - } -} - -export class FieldTypeMismatchError extends Error { - constructor(expectedReturnType: string, receivedReturnType: string) { - super(`Expected the field to return ${expectedReturnType} but found ${receivedReturnType}.`); - } -} + const expected = expectedNameOrValue ? `${expectedNameOrValue} ` : ''; + super(`A "${kind}" ${expected}already exists at the schema coordinate.`); + } +} + +export type AttributeName = + | 'description' + | 'defaultValue' + /** Enum values */ + | 'values' + /** Union types */ + | 'types' + /** Return type */ + | 'type' + | 'interfaces' + | 'directives' + | 'arguments' + | 'locations' + | 'fields'; -export class OldValueMismatchError extends Error { +/** + * If trying to add a node at a path, but that path no longer exists. E.g. add a description to + * a type, but that type was previously deleted. + * This differs from AddedCoordinateAlreadyExistsError because + */ +export class AddedAttributeCoordinateNotFoundError extends Error { constructor( - expectedValue: string | null | undefined, - receivedOldValue: string | null | undefined, + public readonly parentKind: Kind, + readonly attributeName: AttributeName, ) { - super(`Expected the value ${expectedValue} but found ${receivedOldValue}.`); + super(`Cannot set ${attributeName} on "${parentKind}" because it does not exist.`); } } -export class OldTypeMismatchError extends Error { - constructor(expectedType: string | null | undefined, receivedOldType: string | null | undefined) { - super(`Expected the type ${expectedType} but found ${receivedOldType}.`); +/** + * If trying to manipulate a node at a path, but that path no longer exists. E.g. change a description of + * a type, but that type was previously deleted. + */ +export class ChangedAncestorCoordinateNotFoundError extends Error { + constructor( + public readonly parentKind: Kind, + readonly attributeName: AttributeName, + ) { + super(`Cannot set "${attributeName}" on "${parentKind}" because it does not exist.`); } } -export class InterfaceAlreadyExistsOnTypeError extends NoopError { - constructor(interfaceName: string) { +/** + * If trying to remove a node but that node no longer exists. E.g. remove a directive from + * a type, but that type does not exist. + */ +export class DeletedAncestorCoordinateNotFoundError extends NoopError { + constructor( + public readonly parentKind: Kind, + readonly attributeName: AttributeName, + readonly expectedValue: string, + ) { super( - `Cannot add the interface "${interfaceName}" because it already is applied at that coordinate.`, + `Cannot delete "${expectedValue}" from "${attributeName}" on "${parentKind}" because the "${parentKind}" does not exist.`, ); } } -export class ArgumentDefaultValueMismatchError extends Error { +/** + * If adding an attribute to a node, but that attribute already exists. + * E.g. adding an interface but that interface is already applied to the type. + */ +export class AddedAttributeAlreadyExistsError extends NoopError { constructor( - expectedDefaultValue: string | undefined | null, - actualDefaultValue: string | undefined | null, + public readonly parentKind: Kind, + readonly attributeName: AttributeName, + readonly attributeValue: string, ) { super( - `The argument's default value "${actualDefaultValue}" does not match the expected value "${expectedDefaultValue}".`, + `Cannot add "${attributeValue}" to "${attributeName}" on "${parentKind}" because it already exists.`, ); } } -export class ArgumentDescriptionMismatchError extends Error { +/** + * If deleting an attribute from a node, but that attribute does not exist. + * E.g. deleting an interface but that interface is not applied to the type. + */ +export class DeletedAttributeNotFoundError extends NoopError { constructor( - expectedDefaultValue: string | undefined | null, - actualDefaultValue: string | undefined | null, + public readonly parentKind: Kind, + readonly attributeName: AttributeName, + public readonly value: string, ) { super( - `The argument's description "${actualDefaultValue}" does not match the expected "${expectedDefaultValue}".`, + `Cannot delete "${value}" from "${parentKind}"'s "${attributeName}" because "${value}" does not exist.`, ); } } -export class DescriptionMismatchError extends NoopError { +export class ChangedCoordinateNotFoundError extends Error { + constructor(expectedKind: Kind, expectedNameOrValue: string | undefined) { + super(`The "${expectedKind}" ${expectedNameOrValue} does not exist.`); + } +} + +export class DeletedCoordinateNotFound extends NoopError { + constructor(expectedKind: Kind, expectedNameOrValue: string | undefined) { + const expected = expectedNameOrValue ? `${expectedNameOrValue} ` : ''; + super(`The removed "${expectedKind}" ${expected}already does not exist.`); + } +} + +export class ChangedCoordinateKindMismatchError extends Error { constructor( - expectedDescription: string | undefined | null, - actualDescription: string | undefined | null, + public readonly expectedKind: Kind, + public readonly receivedKind: Kind, ) { - super( - `The description, "${actualDescription}", does not the expected description, "${expectedDescription}".`, - ); + super(`Expected type to have be a "${expectedKind}", but found a "${receivedKind}".`); + } +} + +/** + * This should not happen unless there's an issue with the diff creation. + */ +export class ChangePathMissingError extends Error { + constructor() { + super(`The change message is missing a "path". Cannot apply.`); } } diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 354bde62e6..cef0876cec 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -46,6 +46,7 @@ import { directiveArgumentTypeChanged, directiveDescriptionChanged, directiveLocationAdded, + directiveLocationRemoved, } from './patches/directives.js'; import { enumValueAdded, @@ -69,6 +70,7 @@ import { import { inputFieldAdded, inputFieldDescriptionAdded, + inputFieldDescriptionChanged, inputFieldRemoved, } from './patches/inputs.js'; import { objectTypeInterfaceAdded, objectTypeInterfaceRemoved } from './patches/interfaces.js'; @@ -233,6 +235,10 @@ export function patch( directiveLocationAdded(change, nodeByPath, config); break; } + case ChangeType.DirectiveLocationRemoved: { + directiveLocationRemoved(change, nodeByPath, config); + break; + } case ChangeType.EnumValueAdded: { enumValueAdded(change, nodeByPath, config); break; @@ -293,6 +299,10 @@ export function patch( inputFieldDescriptionAdded(change, nodeByPath, config); break; } + case ChangeType.InputFieldDescriptionChanged: { + inputFieldDescriptionChanged(change, nodeByPath, config); + break; + } case ChangeType.ObjectTypeInterfaceAdded: { objectTypeInterfaceAdded(change, nodeByPath, config); break; diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index db8e184ba9..5fed4dbaea 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -1,12 +1,17 @@ +/* eslint-disable unicorn/no-negated-condition */ import { ArgumentNode, ASTNode, DirectiveNode, Kind, parseValue } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - DeletedCoordinateNotFoundError, - DirectiveAlreadyExists, + AddedAttributeAlreadyExistsError, + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, handleError, - KindMismatchError, } from '../errors.js'; import { nameNode } from '../node-templates.js'; import { PatchConfig, SchemaNode } from '../types.js'; @@ -46,7 +51,11 @@ function directiveUsageDefinitionAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new ChangedCoordinateNotFoundError(Kind.DIRECTIVE, change.meta.addedDirectiveName), + config, + ); return; } @@ -55,7 +64,11 @@ function directiveUsageDefinitionAdded( | { directives?: DirectiveNode[] } | undefined; if (directiveNode) { - handleError(change, new DirectiveAlreadyExists(change.meta.addedDirectiveName), config); + handleError( + change, + new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, change.meta.addedDirectiveName), + config, + ); } else if (parentNode) { const newDirective: DirectiveNode = { kind: Kind.DIRECTIVE, @@ -64,7 +77,14 @@ function directiveUsageDefinitionAdded( parentNode.directives = [...(parentNode.directives ?? []), newDirective]; nodeByPath.set(change.path, newDirective); } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError( + Kind.OBJECT_TYPE_DEFINITION, // or interface... + 'directives', + ), + config, + ); } } @@ -79,7 +99,15 @@ function schemaDirectiveUsageDefinitionAdded( findNamedNode(schemaNode.directives, change.meta.addedDirectiveName), ); if (directiveAlreadyExists) { - handleError(change, new DirectiveAlreadyExists(change.meta.addedDirectiveName), config); + handleError( + change, + new AddedAttributeAlreadyExistsError( + Kind.SCHEMA_DEFINITION, + 'directives', + change.meta.addedDirectiveName, + ), + config, + ); } else { const directiveNode: DirectiveNode = { kind: Kind.DIRECTIVE, @@ -114,7 +142,15 @@ function schemaDirectiveUsageDefinitionRemoved( } } if (!deleted) { - handleError(change, new DeletedCoordinateNotFoundError(), config); + handleError( + change, + new DeletedAttributeNotFoundError( + Kind.SCHEMA_DEFINITION, + 'directives', + change.meta.removedDirectiveName, + ), + config, + ); } } @@ -124,21 +160,39 @@ function directiveUsageDefinitionRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const directiveNode = nodeByPath.get(change.path); const parentNode = nodeByPath.get(parentPath(change.path)) as - | { directives?: DirectiveNode[] } + | { kind: Kind; directives?: DirectiveNode[] } | undefined; - if (directiveNode && parentNode) { + if (!parentNode) { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.OBJECT_TYPE_DEFINITION, + 'directives', + change.meta.removedDirectiveName, + ), + config, + ); + } else if (!directiveNode) { + handleError( + change, + new DeletedAttributeNotFoundError( + parentNode.kind, + 'directives', + change.meta.removedDirectiveName, + ), + config, + ); + } else { parentNode.directives = parentNode.directives?.filter( d => d.name.value !== change.meta.removedDirectiveName, ); nodeByPath.delete(change.path); - } else { - handleError(change, new DeletedCoordinateNotFoundError(), config); } } @@ -342,16 +396,28 @@ export function directiveUsageArgumentAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const directiveNode = nodeByPath.get(parentPath(change.path)); if (!directiveNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new AddedAttributeCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + config, + ); } else if (directiveNode.kind === Kind.DIRECTIVE) { const existing = findNamedNode(directiveNode.arguments, change.meta.addedArgumentName); if (existing) { - handleError(change, new CoordinateAlreadyExistsError(directiveNode.kind), config); + handleError( + change, + new AddedAttributeAlreadyExistsError( + directiveNode.kind, + 'arguments', + change.meta.addedArgumentName, + ), + config, + ); } else { const argNode: ArgumentNode = { kind: Kind.ARGUMENT, @@ -365,18 +431,53 @@ export function directiveUsageArgumentAdded( nodeByPath.set(change.path, argNode); } } else { - handleError(change, new KindMismatchError(Kind.DIRECTIVE, directiveNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), + config, + ); } } export function directiveUsageArgumentRemoved( change: Change, - _nodeByPath: Map, + nodeByPath: Map, config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } - // @todo + const directiveNode = nodeByPath.get(parentPath(change.path)); + if (!directiveNode) { + handleError( + change, + new AddedAttributeCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + config, + ); + } else if (directiveNode.kind === Kind.DIRECTIVE) { + const existing = findNamedNode(directiveNode.arguments, change.meta.removedArgumentName); + if (existing) { + (directiveNode.arguments as ArgumentNode[] | undefined) = ( + directiveNode.arguments as ArgumentNode[] | undefined + )?.filter(a => a.name.value !== change.meta.removedArgumentName); + nodeByPath.delete(change.path); + } else { + handleError( + change, + new DeletedAttributeNotFoundError( + directiveNode.kind, + 'arguments', + change.meta.removedArgumentName, + ), + config, + ); + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), + config, + ); + } } diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 67da04d9df..6c87b972b4 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -13,14 +13,16 @@ import { } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - ArgumentDefaultValueMismatchError, - ArgumentDescriptionMismatchError, - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - DirectiveLocationAlreadyExistsError, + AddedAttributeAlreadyExistsError, + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, handleError, - KindMismatchError, - OldTypeMismatchError, + ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import { PatchConfig } from '../types.js'; @@ -32,13 +34,17 @@ export function directiveAdded( config: PatchConfig, ) { if (change.path === undefined) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const changedNode = nodeByPath.get(change.path); if (changedNode) { - handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); + handleError( + change, + new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedDirectiveName), + config, + ); } else { const node: DirectiveDefinitionNode = { kind: Kind.DIRECTIVE_DEFINITION, @@ -59,20 +65,32 @@ export function directiveArgumentAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new AddedAttributeCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + config, + ); } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { const existingArg = findNamedNode( directiveNode.arguments, change.meta.addedDirectiveArgumentName, ); if (existingArg) { - handleError(change, new CoordinateAlreadyExistsError(existingArg.kind), config); + handleError( + change, + new AddedAttributeAlreadyExistsError( + existingArg.kind, + 'arguments', + change.meta.addedDirectiveArgumentName, + ), + config, + ); } else { const node: InputValueDefinitionNode = { kind: Kind.INPUT_VALUE_DEFINITION, @@ -88,7 +106,7 @@ export function directiveArgumentAdded( } else { handleError( change, - new KindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), config, ); } @@ -100,7 +118,7 @@ export function directiveLocationAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -110,8 +128,9 @@ export function directiveLocationAdded( if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { handleError( change, - new DirectiveLocationAlreadyExistsError( - change.meta.directiveName, + new AddedAttributeAlreadyExistsError( + Kind.DIRECTIVE_DEFINITION, + 'locations', change.meta.addedDirectiveLocation, ), config, @@ -125,12 +144,65 @@ export function directiveLocationAdded( } else { handleError( change, - new KindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + config, + ); + } + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'locations'), + config, + ); + } +} + +export function directiveLocationRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(), config); + return; + } + + const changedNode = nodeByPath.get(change.path); + if (changedNode) { + if (changedNode.kind === Kind.DIRECTIVE_DEFINITION) { + const existing = changedNode.locations.findIndex( + l => l.value === change.meta.removedDirectiveLocation, + ); + if (existing >= 0) { + (changedNode.locations as NameNode[]) = changedNode.locations.toSpliced(existing, 1); + } else { + handleError( + change, + new DeletedAttributeNotFoundError( + changedNode.kind, + 'locations', + change.meta.removedDirectiveLocation, + ), + config, + ); + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.DIRECTIVE_DEFINITION, + 'locations', + change.meta.removedDirectiveLocation, + ), + config, + ); } } @@ -140,34 +212,38 @@ export function directiveDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), + config, + ); } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { // eslint-disable-next-line eqeqeq - if (directiveNode.description?.value == change.meta.oldDirectiveDescription) { - (directiveNode.description as StringValueNode | undefined) = change.meta - .newDirectiveDescription - ? stringNode(change.meta.newDirectiveDescription) - : undefined; - } else { + if (directiveNode.description?.value !== change.meta.oldDirectiveDescription) { handleError( change, - new ArgumentDescriptionMismatchError( + new ValueMismatchError( + Kind.STRING, change.meta.oldDirectiveDescription, directiveNode.description?.value, ), config, ); } + + (directiveNode.description as StringValueNode | undefined) = change.meta.newDirectiveDescription + ? stringNode(change.meta.newDirectiveDescription) + : undefined; } else { handleError( change, - new KindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), config, ); } @@ -179,13 +255,17 @@ export function directiveArgumentDefaultValueChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'defaultValue'), + config, + ); } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { if ( (argumentNode.defaultValue && print(argumentNode.defaultValue)) === @@ -198,7 +278,8 @@ export function directiveArgumentDefaultValueChanged( } else { handleError( change, - new ArgumentDefaultValueMismatchError( + new ValueMismatchError( + Kind.INPUT_VALUE_DEFINITION, change.meta.oldDirectiveArgumentDefaultValue, argumentNode.defaultValue && print(argumentNode.defaultValue), ), @@ -208,7 +289,7 @@ export function directiveArgumentDefaultValueChanged( } else { handleError( change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), config, ); } @@ -220,34 +301,38 @@ export function directiveArgumentDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new AddedAttributeCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'description'), + config, + ); } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { // eslint-disable-next-line eqeqeq - if (argumentNode.description?.value == change.meta.oldDirectiveArgumentDescription) { - (argumentNode.description as StringValueNode | undefined) = change.meta - .newDirectiveArgumentDescription - ? stringNode(change.meta.newDirectiveArgumentDescription) - : undefined; - } else { + if (argumentNode.description?.value != change.meta.oldDirectiveArgumentDescription) { handleError( change, - new ArgumentDescriptionMismatchError( - change.meta.oldDirectiveArgumentDescription, + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveArgumentDescription ?? undefined, argumentNode.description?.value, ), config, ); } + (argumentNode.description as StringValueNode | undefined) = change.meta + .newDirectiveArgumentDescription + ? stringNode(change.meta.newDirectiveArgumentDescription) + : undefined; } else { handleError( change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), config, ); } @@ -259,27 +344,30 @@ export function directiveArgumentTypeChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'type'), config); } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { - if (print(argumentNode.type) === change.meta.oldDirectiveArgumentType) { - (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); - } else { + if (print(argumentNode.type) !== change.meta.oldDirectiveArgumentType) { handleError( change, - new OldTypeMismatchError(change.meta.oldDirectiveArgumentType, print(argumentNode.type)), + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveArgumentType, + print(argumentNode.type), + ), config, ); } + (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); } else { handleError( change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), config, ); } diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index bffa80cb72..633c6fedbe 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -9,12 +9,15 @@ import { } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - EnumValueNotFoundError, + AddedAttributeAlreadyExistsError, + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAttributeNotFoundError, handleError, - KindMismatchError, - OldValueMismatchError, + ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; @@ -26,7 +29,7 @@ export function enumValueRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -34,13 +37,21 @@ export function enumValueRemoved( | (ASTNode & { values?: EnumValueDefinitionNode[] }) | undefined; if (!enumNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } else if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { - handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), + config, + ); } else if (enumNode.values === undefined || enumNode.values.length === 0) { handleError( change, - new EnumValueNotFoundError(change.meta.enumName, change.meta.removedEnumValueName), + new DeletedAttributeNotFoundError( + Kind.ENUM_TYPE_DEFINITION, + 'values', + change.meta.removedEnumValueName, + ), config, ); } else { @@ -51,7 +62,11 @@ export function enumValueRemoved( if (beforeLength === enumNode.values.length) { handleError( change, - new EnumValueNotFoundError(change.meta.enumName, change.meta.removedEnumValueName), + new DeletedAttributeNotFoundError( + Kind.ENUM_TYPE_DEFINITION, + 'values', + change.meta.removedEnumValueName, + ), config, ); } else { @@ -72,12 +87,17 @@ export function enumValueAdded( | undefined; const changedNode = nodeByPath.get(enumValuePath); if (!enumNode) { - handleError(change, new CoordinateNotFoundError(), config); - console.warn( - `Cannot apply change: ${change.type} to ${enumValuePath}. Parent type is missing.`, - ); + handleError(change, new ChangedAncestorCoordinateNotFoundError(Kind.ENUM, 'values'), config); } else if (changedNode) { - handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); + handleError( + change, + new AddedAttributeAlreadyExistsError( + changedNode.kind, + 'values', + change.meta.addedEnumValueName, + ), + config, + ); } else if (enumNode.kind === Kind.ENUM_TYPE_DEFINITION) { const c = change as Change; const node: EnumValueDefinitionNode = { @@ -90,7 +110,11 @@ export function enumValueAdded( (enumNode.values as EnumValueDefinitionNode[]) = [...(enumNode.values ?? []), node]; nodeByPath.set(enumValuePath, node); } else { - handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), + config, + ); } } @@ -100,7 +124,7 @@ export function enumValueDeprecationReasonAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -110,7 +134,11 @@ export function enumValueDeprecationReasonAdded( if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { if (deprecation) { if (findNamedNode(deprecation.arguments, 'reason')) { - handleError(change, new CoordinateAlreadyExistsError(Kind.ARGUMENT), config); + handleError( + change, + new AddedCoordinateAlreadyExistsError(Kind.ENUM_VALUE_DEFINITION, 'reason'), + config, + ); } const argNode: ArgumentNode = { kind: Kind.ARGUMENT, @@ -123,17 +151,21 @@ export function enumValueDeprecationReasonAdded( ]; nodeByPath.set(`${change.path}.reason`, argNode); } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } else { handleError( change, - new KindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + new ChangedCoordinateKindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), config, ); } } else { - handleError(change, new EnumValueNotFoundError(change.meta.enumName), config); + handleError( + change, + new AddedAttributeCoordinateNotFoundError(Kind.ENUM_VALUE_DEFINITION, 'directives'), + config, + ); } } @@ -143,7 +175,7 @@ export function enumValueDeprecationReasonChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -153,34 +185,43 @@ export function enumValueDeprecationReasonChanged( const reasonArgNode = findNamedNode(deprecatedNode.arguments, 'reason'); if (reasonArgNode) { if (reasonArgNode.kind === Kind.ARGUMENT) { - if ( + const oldValueMatches = reasonArgNode.value && - print(reasonArgNode.value) === change.meta.oldEnumValueDeprecationReason - ) { - (reasonArgNode.value as StringValueNode | undefined) = stringNode( - change.meta.newEnumValueDeprecationReason, - ); - } else { + print(reasonArgNode.value) === change.meta.oldEnumValueDeprecationReason; + + if (!oldValueMatches) { handleError( change, - new OldValueMismatchError( + new ValueMismatchError( + Kind.ARGUMENT, change.meta.oldEnumValueDeprecationReason, reasonArgNode.value && print(reasonArgNode.value), ), config, ); } + (reasonArgNode.value as StringValueNode | undefined) = stringNode( + change.meta.newEnumValueDeprecationReason, + ); } else { - handleError(change, new KindMismatchError(Kind.ARGUMENT, reasonArgNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ARGUMENT, reasonArgNode.kind), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } else { - handleError(change, new KindMismatchError(Kind.DIRECTIVE, deprecatedNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, deprecatedNode.kind), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -190,7 +231,7 @@ export function enumValueDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -198,29 +239,35 @@ export function enumValueDescriptionChanged( if (enumValueNode) { if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { // eslint-disable-next-line eqeqeq - if (change.meta.oldEnumValueDescription == enumValueNode.description?.value) { - (enumValueNode.description as StringValueNode | undefined) = change.meta - .newEnumValueDescription - ? stringNode(change.meta.newEnumValueDescription) - : undefined; - } else { + const oldValueMatches = + change.meta.oldEnumValueDescription == enumValueNode.description?.value; + if (!oldValueMatches) { handleError( change, - new OldValueMismatchError( + new ValueMismatchError( + Kind.ENUM_TYPE_DEFINITION, change.meta.oldEnumValueDescription, enumValueNode.description?.value, ), config, ); } + (enumValueNode.description as StringValueNode | undefined) = change.meta + .newEnumValueDescription + ? stringNode(change.meta.newEnumValueDescription) + : undefined; } else { handleError( change, - new KindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + new ChangedCoordinateKindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), config, ); } } else { - handleError(change, new EnumValueNotFoundError(change.meta.enumName), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.ENUM_VALUE_DEFINITION, 'values'), + config, + ); } } diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index be870beedc..2141cb2834 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -13,16 +13,13 @@ import { } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - DeprecatedDirectiveNotFound, - DeprecationReasonAlreadyExists, - DescriptionMismatchError, - DirectiveAlreadyExists, - FieldTypeMismatchError, + AddedAttributeAlreadyExistsError, + AddedCoordinateAlreadyExistsError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedCoordinateNotFound, handleError, - KindMismatchError, - OldValueMismatchError, + ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; @@ -43,16 +40,23 @@ export function fieldTypeChanged( if (node) { if (node.kind === Kind.FIELD_DEFINITION) { const currentReturnType = print(node.type); - if (c.meta.oldFieldType === currentReturnType) { - (node.type as TypeNode) = parseType(c.meta.newFieldType); - } else { - handleError(c, new FieldTypeMismatchError(c.meta.oldFieldType, currentReturnType), config); + if (c.meta.oldFieldType !== currentReturnType) { + handleError( + c, + new ValueMismatchError(Kind.FIELD_DEFINITION, c.meta.oldFieldType, currentReturnType), + config, + ); } + (node.type as TypeNode) = parseType(c.meta.newFieldType); } else { - handleError(c, new KindMismatchError(Kind.FIELD_DEFINITION, node.kind), config); + handleError( + c, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, node.kind), + config, + ); } } else { - handleError(c, new CoordinateNotFoundError(), config); + handleError(c, new ChangePathMissingError(), config); } } @@ -62,7 +66,7 @@ export function fieldRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -70,12 +74,12 @@ export function fieldRemoved( | (ASTNode & { fields?: FieldDefinitionNode[] }) | undefined; if (!typeNode || !typeNode.fields?.length) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } else { const beforeLength = typeNode.fields.length; typeNode.fields = typeNode.fields.filter(f => f.name.value !== change.meta.removedFieldName); if (beforeLength === typeNode.fields.length) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } else { // delete the reference to the removed field. nodeByPath.delete(change.path); @@ -89,24 +93,40 @@ export function fieldAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const changedNode = nodeByPath.get(change.path); if (changedNode) { - handleError(change, new CoordinateAlreadyExistsError(changedNode.kind), config); + if (changedNode.kind === Kind.OBJECT_FIELD) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedFieldName), + config, + ); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_FIELD, changedNode.kind), + config, + ); + } } else { const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { fields?: FieldDefinitionNode[]; }; if (!typeNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } else if ( typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION ) { - handleError(change, new KindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), + config, + ); } else { const node: FieldDefinitionNode = { kind: Kind.FIELD_DEFINITION, @@ -131,19 +151,23 @@ export function fieldArgumentAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const existing = nodeByPath.get(change.path); if (existing) { - handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); + handleError( + change, + new AddedCoordinateAlreadyExistsError(Kind.ARGUMENT, change.meta.addedArgumentName), + config, + ); } else { const fieldNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { arguments?: InputValueDefinitionNode[]; }; if (!fieldNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } else if (fieldNode.kind === Kind.FIELD_DEFINITION) { const node: InputValueDefinitionNode = { kind: Kind.INPUT_VALUE_DEFINITION, @@ -159,7 +183,11 @@ export function fieldArgumentAdded( // add new field to the node set nodeByPath.set(change.path, node); } else { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); } } } @@ -170,7 +198,7 @@ export function fieldDeprecationReasonChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -179,34 +207,39 @@ export function fieldDeprecationReasonChanged( if (deprecationNode.kind === Kind.DIRECTIVE) { const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); if (reasonArgument) { - if (print(reasonArgument.value) === change.meta.oldDeprecationReason) { - const node = { - kind: Kind.ARGUMENT, - name: nameNode('reason'), - value: stringNode(change.meta.newDeprecationReason), - } as ArgumentNode; - (deprecationNode.arguments as ArgumentNode[] | undefined) = [ - ...(deprecationNode.arguments ?? []), - node, - ]; - } else { + if (print(reasonArgument.value) !== change.meta.oldDeprecationReason) { handleError( change, - new OldValueMismatchError( + new ValueMismatchError( + Kind.ARGUMENT, print(reasonArgument.value), change.meta.oldDeprecationReason, ), config, ); } + + const node = { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.newDeprecationReason), + } as ArgumentNode; + (deprecationNode.arguments as ArgumentNode[] | undefined) = [ + ...(deprecationNode.arguments ?? []), + node, + ]; } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } else { - handleError(change, new KindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -216,7 +249,7 @@ export function fieldDeprecationReasonAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -227,7 +260,7 @@ export function fieldDeprecationReasonAdded( if (reasonArgument) { handleError( change, - new DeprecationReasonAlreadyExists((reasonArgument.value as StringValueNode)?.value), + new AddedAttributeAlreadyExistsError(Kind.DIRECTIVE, 'arguments', 'reason'), config, ); } else { @@ -243,10 +276,14 @@ export function fieldDeprecationReasonAdded( nodeByPath.set(`${change.path}.reason`, node); } } else { - handleError(change, new KindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -256,7 +293,7 @@ export function fieldDeprecationAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -265,7 +302,11 @@ export function fieldDeprecationAdded( if (fieldNode.kind === Kind.FIELD_DEFINITION) { const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); if (hasExistingDeprecationDirective) { - handleError(change, new DirectiveAlreadyExists(GraphQLDeprecatedDirective.name), config); + handleError( + change, + new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, '@deprecated'), + config, + ); } else { const directiveNode = { kind: Kind.DIRECTIVE, @@ -294,10 +335,14 @@ export function fieldDeprecationAdded( ); } } else { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -307,7 +352,7 @@ export function fieldDeprecationRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -321,13 +366,17 @@ export function fieldDeprecationRemoved( ); nodeByPath.delete([change.path, `@${GraphQLDeprecatedDirective.name}`].join('.')); } else { - handleError(change, new DeprecatedDirectiveNotFound(), config); + handleError(change, new DeletedCoordinateNotFound(Kind.DIRECTIVE, '@deprecated'), config); } } else { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -337,7 +386,7 @@ export function fieldDescriptionAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -348,10 +397,14 @@ export function fieldDescriptionAdded( ? stringNode(change.meta.addedDescription) : undefined; } else { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -361,7 +414,7 @@ export function fieldDescriptionRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -370,10 +423,14 @@ export function fieldDescriptionRemoved( if (fieldNode.kind === Kind.FIELD_DEFINITION) { (fieldNode.description as StringValueNode | undefined) = undefined; } else { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -383,28 +440,36 @@ export function fieldDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const fieldNode = nodeByPath.get(change.path); if (fieldNode) { if (fieldNode.kind === Kind.FIELD_DEFINITION) { - if (fieldNode.description?.value === change.meta.oldDescription) { - (fieldNode.description as StringValueNode | undefined) = stringNode( - change.meta.newDescription, - ); - } else { + if (fieldNode.description?.value !== change.meta.oldDescription) { handleError( change, - new DescriptionMismatchError(change.meta.oldDescription, fieldNode.description?.value), + new ValueMismatchError( + Kind.FIELD_DEFINITION, + change.meta.oldDescription, + fieldNode.description?.value, + ), config, ); } + + (fieldNode.description as StringValueNode | undefined) = stringNode( + change.meta.newDescription, + ); } else { - handleError(change, new KindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config); + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index 1060a5f375..4dcde58dd1 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -1,10 +1,13 @@ import { ASTNode, InputValueDefinitionNode, Kind, parseType, StringValueNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - CoordinateAlreadyExistsError, - CoordinateNotFoundError, + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, handleError, - KindMismatchError, + ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types.js'; @@ -16,19 +19,38 @@ export function inputFieldAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const existingNode = nodeByPath.get(change.path); if (existingNode) { - handleError(change, new CoordinateAlreadyExistsError(existingNode.kind), config); + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + handleError( + change, + new AddedCoordinateAlreadyExistsError( + Kind.INPUT_VALUE_DEFINITION, + change.meta.addedInputFieldName, + ), + config, + ); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } } else { const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { fields?: InputValueDefinitionNode[]; }; if (!typeNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new AddedAttributeCoordinateNotFoundError(Kind.INPUT_OBJECT_TYPE_DEFINITION, 'fields'), + config, + ); } else if (typeNode.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { const node: InputValueDefinitionNode = { kind: Kind.INPUT_VALUE_DEFINITION, @@ -46,7 +68,7 @@ export function inputFieldAdded( } else { handleError( change, - new KindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), config, ); } @@ -59,7 +81,7 @@ export function inputFieldRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -69,7 +91,15 @@ export function inputFieldRemoved( fields?: InputValueDefinitionNode[]; }; if (!typeNode) { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.INPUT_OBJECT_TYPE_DEFINITION, + 'fields', + change.meta.removedFieldName, + ), + config, + ); } else if (typeNode.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); @@ -78,12 +108,12 @@ export function inputFieldRemoved( } else { handleError( change, - new KindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -93,7 +123,7 @@ export function inputFieldDescriptionAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const existingNode = nodeByPath.get(change.path); @@ -105,12 +135,58 @@ export function inputFieldDescriptionAdded( } else { handleError( change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } + } else { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.INPUT_VALUE_DEFINITION, + 'description', + change.meta.addedInputFieldDescription, + ), + config, + ); + } +} + +export function inputFieldDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(), config); + return; + } + const existingNode = nodeByPath.get(change.path); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if (existingNode.description?.value !== change.meta.oldInputFieldDescription) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldInputFieldDescription, + existingNode.description?.value, + ), + config, + ); + } + (existingNode.description as StringValueNode | undefined) = stringNode( + change.meta.newInputFieldDescription, + ); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -120,7 +196,7 @@ export function inputFieldDescriptionRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -140,11 +216,11 @@ export function inputFieldDescriptionRemoved( } else { handleError( change, - new KindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index 758b0f3311..d3b36b5a51 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -1,10 +1,11 @@ import { ASTNode, Kind, NamedTypeNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - CoordinateNotFoundError, + AddedAttributeAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, handleError, - InterfaceAlreadyExistsOnTypeError, - KindMismatchError, } from '../errors.js'; import { namedTypeNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; @@ -16,7 +17,7 @@ export function objectTypeInterfaceAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -30,7 +31,11 @@ export function objectTypeInterfaceAdded( if (existing) { handleError( change, - new InterfaceAlreadyExistsOnTypeError(change.meta.addedInterfaceName), + new AddedAttributeAlreadyExistsError( + typeNode.kind, + 'interfaces', + change.meta.addedInterfaceName, + ), config, ); } else { @@ -42,12 +47,19 @@ export function objectTypeInterfaceAdded( } else { handleError( change, - new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + new ChangedCoordinateKindMismatchError( + Kind.OBJECT_TYPE_DEFINITION, // or Kind.INTERFACE_TYPE_DEFINITION + typeNode.kind, + ), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'interfaces'), + config, + ); } } @@ -57,7 +69,7 @@ export function objectTypeInterfaceRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -73,16 +85,16 @@ export function objectTypeInterfaceRemoved( i => i.name.value !== change.meta.removedInterfaceName, ); } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } else { handleError( change, - new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } diff --git a/packages/patch/src/patches/schema.ts b/packages/patch/src/patches/schema.ts index e4edb07e96..be7806626f 100644 --- a/packages/patch/src/patches/schema.ts +++ b/packages/patch/src/patches/schema.ts @@ -1,6 +1,7 @@ -import { NameNode, OperationTypeNode } from 'graphql'; +/* eslint-disable unicorn/no-negated-condition */ +import { Kind, NameNode, OperationTypeNode } from 'graphql'; import type { Change, ChangeType } from '@graphql-inspector/core'; -import { CoordinateNotFoundError, handleError, OldTypeMismatchError } from '../errors.js'; +import { ChangedCoordinateNotFoundError, handleError, ValueMismatchError } from '../errors.js'; import { nameNode } from '../node-templates.js'; import { PatchConfig, SchemaNode } from '../types.js'; @@ -14,15 +15,24 @@ export function schemaMutationTypeChanged( ({ operation }) => operation === OperationTypeNode.MUTATION, ); if (!mutation) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (mutation.type.name.value === change.meta.oldMutationTypeName) { - (mutation.type.name as NameNode) = nameNode(change.meta.newMutationTypeName); - } else { handleError( change, - new OldTypeMismatchError(change.meta.oldMutationTypeName, mutation?.type.name.value), + new ChangedCoordinateNotFoundError(Kind.SCHEMA_DEFINITION, 'mutation'), config, ); + } else { + if (mutation.type.name.value !== change.meta.oldMutationTypeName) { + handleError( + change, + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldMutationTypeName, + mutation?.type.name.value, + ), + config, + ); + } + (mutation.type.name as NameNode) = nameNode(change.meta.newMutationTypeName); } } } @@ -37,15 +47,24 @@ export function schemaQueryTypeChanged( ({ operation }) => operation === OperationTypeNode.MUTATION, ); if (!query) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (query.type.name.value === change.meta.oldQueryTypeName) { - (query.type.name as NameNode) = nameNode(change.meta.newQueryTypeName); - } else { handleError( change, - new OldTypeMismatchError(change.meta.oldQueryTypeName, query?.type.name.value), + new ChangedCoordinateNotFoundError(Kind.SCHEMA_DEFINITION, 'query'), config, ); + } else { + if (query.type.name.value !== change.meta.oldQueryTypeName) { + handleError( + change, + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldQueryTypeName, + query?.type.name.value, + ), + config, + ); + } + (query.type.name as NameNode) = nameNode(change.meta.newQueryTypeName); } } } @@ -57,18 +76,27 @@ export function schemaSubscriptionTypeChanged( ) { for (const schemaNode of schemaNodes) { const sub = schemaNode.operationTypes?.find( - ({ operation }) => operation === OperationTypeNode.MUTATION, + ({ operation }) => operation === OperationTypeNode.SUBSCRIPTION, ); if (!sub) { - handleError(change, new CoordinateNotFoundError(), config); - } else if (sub.type.name.value === change.meta.oldSubscriptionTypeName) { - (sub.type.name as NameNode) = nameNode(change.meta.newSubscriptionTypeName); - } else { handleError( change, - new OldTypeMismatchError(change.meta.oldSubscriptionTypeName, sub?.type.name.value), + new ChangedCoordinateNotFoundError(Kind.SCHEMA_DEFINITION, 'subscription'), config, ); + } else { + if (sub.type.name.value !== change.meta.oldSubscriptionTypeName) { + handleError( + change, + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldSubscriptionTypeName, + sub?.type.name.value, + ), + config, + ); + } + (sub.type.name as NameNode) = nameNode(change.meta.newSubscriptionTypeName); } } } diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index 02526d1362..2814a01fec 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -1,11 +1,13 @@ import { ASTNode, isTypeDefinitionNode, Kind, StringValueNode, TypeDefinitionNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - CoordinateAlreadyExistsError, - CoordinateNotFoundError, - DescriptionMismatchError, + AddedCoordinateAlreadyExistsError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, handleError, - KindMismatchError, + ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; @@ -16,13 +18,17 @@ export function typeAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } const existing = nodeByPath.get(change.path); if (existing) { - handleError(change, new CoordinateAlreadyExistsError(existing.kind), config); + handleError( + change, + new AddedCoordinateAlreadyExistsError(existing.kind, change.meta.addedTypeName), + config, + ); } else { const node: TypeDefinitionNode = { name: nameNode(change.meta.addedTypeName), @@ -38,7 +44,7 @@ export function typeRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -54,12 +60,12 @@ export function typeRemoved( } else { handleError( change, - new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, removedNode.kind), + new DeletedCoordinateNotFound(removedNode.kind, change.meta.removedTypeName), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -69,7 +75,7 @@ export function typeDescriptionAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -82,12 +88,12 @@ export function typeDescriptionAdded( } else { handleError( change, - new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -97,7 +103,7 @@ export function typeDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -107,7 +113,11 @@ export function typeDescriptionChanged( if (typeNode.description?.value !== change.meta.oldTypeDescription) { handleError( change, - new DescriptionMismatchError(change.meta.oldTypeDescription, typeNode.description?.value), + new ValueMismatchError( + Kind.STRING, + change.meta.oldTypeDescription, + typeNode.description?.value, + ), config, ); } @@ -117,12 +127,12 @@ export function typeDescriptionChanged( } else { handleError( change, - new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); } } @@ -132,7 +142,7 @@ export function typeDescriptionRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new CoordinateNotFoundError(), config); + handleError(change, new ChangePathMissingError(), config); return; } @@ -142,7 +152,11 @@ export function typeDescriptionRemoved( if (typeNode.description?.value !== change.meta.oldTypeDescription) { handleError( change, - new DescriptionMismatchError(change.meta.oldTypeDescription, typeNode.description?.value), + new ValueMismatchError( + Kind.STRING, + change.meta.oldTypeDescription, + typeNode.description?.value, + ), config, ); } @@ -150,11 +164,19 @@ export function typeDescriptionRemoved( } else { handleError( change, - new KindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), config, ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.OBJECT_TYPE_DEFINITION, + 'description', + change.meta.oldTypeDescription, + ), + config, + ); } } diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts index 04fbe1b2e7..4597455e17 100644 --- a/packages/patch/src/patches/unions.ts +++ b/packages/patch/src/patches/unions.ts @@ -1,10 +1,11 @@ -import { ASTNode, NamedTypeNode } from 'graphql'; +import { ASTNode, Kind, NamedTypeNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - CoordinateNotFoundError, + AddedAttributeAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, handleError, - UnionMemberAlreadyExistsError, - UnionMemberNotFoundError, } from '../errors.js'; import { namedTypeNode } from '../node-templates.js'; import { PatchConfig } from '../types.js'; @@ -23,8 +24,9 @@ export function unionMemberAdded( if (findNamedNode(union.types, change.meta.addedUnionMemberTypeName)) { handleError( change, - new UnionMemberAlreadyExistsError( - change.meta.unionName, + new AddedAttributeAlreadyExistsError( + Kind.UNION_TYPE_DEFINITION, + 'types', change.meta.addedUnionMemberTypeName, ), config, @@ -33,7 +35,11 @@ export function unionMemberAdded( union.types = [...(union.types ?? []), namedTypeNode(change.meta.addedUnionMemberTypeName)]; } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.UNION_TYPE_DEFINITION, 'types'), + config, + ); } } @@ -52,9 +58,25 @@ export function unionMemberRemoved( t => t.name.value !== change.meta.removedUnionMemberTypeName, ); } else { - handleError(change, new UnionMemberNotFoundError(), config); + handleError( + change, + new DeletedAttributeNotFoundError( + Kind.UNION_TYPE_DEFINITION, + 'types', + change.meta.removedUnionMemberTypeName, + ), + config, + ); } } else { - handleError(change, new CoordinateNotFoundError(), config); + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.UNION_TYPE_DEFINITION, + 'types', + change.meta.removedUnionMemberTypeName, + ), + config, + ); } } diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts index f1bcd850ad..cc77fd1000 100644 --- a/packages/patch/src/types.ts +++ b/packages/patch/src/types.ts @@ -26,6 +26,32 @@ export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; export type ChangesByType = { [key in TypeOfChangeType]?: Array> }; export type PatchConfig = { + /** + * By default does not throw when hitting errors such as if + * a type that was modified no longer exists. + */ throwOnError?: boolean; + + /** + * By default does not require the value at time of change to match + * what's currently in the schema. Enable this if you need to be extra + * cautious when detecting conflicts. + */ + requireOldValueMatch?: boolean; + + /** + * Allows handling errors more granularly if you only care about specific types of + * errors or want to capture the errors in a list somewhere etc. If 'true' is returned + * then this error is considered handled and the default error handling will not + * be ran. + * To halt patching, throw the error inside the handler. + * @param err The raised error + * @returns True if the error has been handled + */ + onError?: (err: Error) => boolean | undefined | null; + + /** + * Enables debug logging + */ debug?: boolean; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ceb3ab839..2b7a819cdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -665,6 +665,9 @@ importers: packages/patch: dependencies: + '@graphql-tools/utils': + specifier: ^10.0.0 + version: 10.8.6(graphql@16.10.0) graphql: specifier: 16.10.0 version: 16.10.0 @@ -675,9 +678,6 @@ importers: '@graphql-inspector/core': specifier: workspace:* version: link:../core/dist - '@graphql-tools/utils': - specifier: ^10.0.0 - version: 10.8.6(graphql@16.10.0) publishDirectory: dist website: From a430f804e3445974731661658d158ea334b58f17 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:38:47 -0700 Subject: [PATCH 16/23] Remove redundant types; patch more descriptions and defaults etc --- packages/core/src/diff/changes/change.ts | 22 ------- packages/patch/src/index.ts | 25 ++++++++ packages/patch/src/patches/fields.ts | 81 ++++++++++++++++++++++++ packages/patch/src/patches/inputs.ts | 49 +++++++++++++- packages/patch/src/utils.ts | 49 +++++++++++++- 5 files changed, 200 insertions(+), 26 deletions(-) diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index 4fdacaf403..1924bb9aff 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -78,9 +78,7 @@ export const ChangeType = { TypeAdded: 'TYPE_ADDED', TypeKindChanged: 'TYPE_KIND_CHANGED', TypeDescriptionChanged: 'TYPE_DESCRIPTION_CHANGED', - // TODO TypeDescriptionRemoved: 'TYPE_DESCRIPTION_REMOVED', - // TODO TypeDescriptionAdded: 'TYPE_DESCRIPTION_ADDED', // Union UnionMemberRemoved: 'UNION_MEMBER_REMOVED', @@ -925,26 +923,6 @@ type Changes = { [ChangeType.FieldDescriptionChanged]: FieldDescriptionChangedChange; [ChangeType.FieldArgumentAdded]: FieldArgumentAddedChange; [ChangeType.FieldArgumentRemoved]: FieldArgumentRemovedChange; - [ChangeType.InputFieldRemoved]: InputFieldRemovedChange; - [ChangeType.InputFieldAdded]: InputFieldAddedChange; - [ChangeType.InputFieldDescriptionAdded]: InputFieldDescriptionAddedChange; - [ChangeType.InputFieldDescriptionRemoved]: InputFieldDescriptionRemovedChange; - [ChangeType.InputFieldDescriptionChanged]: InputFieldDescriptionChangedChange; - [ChangeType.InputFieldDefaultValueChanged]: InputFieldDefaultValueChangedChange; - [ChangeType.InputFieldTypeChanged]: InputFieldTypeChangedChange; - [ChangeType.ObjectTypeInterfaceAdded]: ObjectTypeInterfaceAddedChange; - [ChangeType.ObjectTypeInterfaceRemoved]: ObjectTypeInterfaceRemovedChange; - [ChangeType.SchemaQueryTypeChanged]: SchemaQueryTypeChangedChange; - [ChangeType.SchemaMutationTypeChanged]: SchemaMutationTypeChangedChange; - [ChangeType.SchemaSubscriptionTypeChanged]: SchemaSubscriptionTypeChangedChange; - [ChangeType.TypeAdded]: TypeAddedChange; - [ChangeType.TypeRemoved]: TypeRemovedChange; - [ChangeType.TypeKindChanged]: TypeKindChangedChange; - [ChangeType.TypeDescriptionChanged]: TypeDescriptionChangedChange; - [ChangeType.TypeDescriptionRemoved]: TypeDescriptionRemovedChange; - [ChangeType.TypeDescriptionAdded]: TypeDescriptionAddedChange; - [ChangeType.UnionMemberAdded]: UnionMemberAddedChange; - [ChangeType.UnionMemberRemoved]: UnionMemberRemovedChange; [ChangeType.DirectiveRemoved]: DirectiveRemovedChange; [ChangeType.DirectiveAdded]: DirectiveAddedChange; [ChangeType.DirectiveArgumentAdded]: DirectiveArgumentAddedChange; diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index cef0876cec..cbb136782a 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -58,8 +58,11 @@ import { import { fieldAdded, fieldArgumentAdded, + fieldArgumentRemoved, + fieldArgumentDefaultChanged, fieldDeprecationAdded, fieldDeprecationReasonAdded, + fieldDeprecationReasonChanged, fieldDeprecationRemoved, fieldDescriptionAdded, fieldDescriptionChanged, @@ -69,8 +72,10 @@ import { } from './patches/fields.js'; import { inputFieldAdded, + inputFieldDefaultValueChanged, inputFieldDescriptionAdded, inputFieldDescriptionChanged, + inputFieldDescriptionRemoved, inputFieldRemoved, } from './patches/inputs.js'; import { objectTypeInterfaceAdded, objectTypeInterfaceRemoved } from './patches/interfaces.js'; @@ -267,6 +272,14 @@ export function patch( fieldArgumentAdded(change, nodeByPath, config); break; } + case ChangeType.FieldArgumentRemoved: { + fieldArgumentRemoved(change, nodeByPath, config); + break; + } + case ChangeType.FieldArgumentDefaultChanged: { + fieldArgumentDefaultChanged(change, nodeByPath, config); + break; + } case ChangeType.FieldDeprecationAdded: { fieldDeprecationAdded(change, nodeByPath, config); break; @@ -279,6 +292,10 @@ export function patch( fieldDeprecationReasonAdded(change, nodeByPath, config); break; } + case ChangeType.FieldDeprecationReasonChanged: { + fieldDeprecationReasonChanged(change, nodeByPath, config); + break; + } case ChangeType.FieldDescriptionAdded: { fieldDescriptionAdded(change, nodeByPath, config); break; @@ -303,6 +320,14 @@ export function patch( inputFieldDescriptionChanged(change, nodeByPath, config); break; } + case ChangeType.InputFieldDescriptionRemoved: { + inputFieldDescriptionRemoved(change, nodeByPath, config); + break; + } + case ChangeType.InputFieldDefaultValueChanged: { + inputFieldDefaultValueChanged(change, nodeByPath, config); + break; + } case ChangeType.ObjectTypeInterfaceAdded: { objectTypeInterfaceAdded(change, nodeByPath, config); break; diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 2141cb2834..34e3bf8633 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -1,11 +1,13 @@ import { ArgumentNode, ASTNode, + ConstValueNode, DirectiveNode, FieldDefinitionNode, GraphQLDeprecatedDirective, InputValueDefinitionNode, Kind, + parseConstValue, parseType, print, StringValueNode, @@ -16,7 +18,9 @@ import { AddedAttributeAlreadyExistsError, AddedCoordinateAlreadyExistsError, ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, DeletedCoordinateNotFound, handleError, ValueMismatchError, @@ -26,6 +30,7 @@ import type { PatchConfig } from '../types'; import { DEPRECATION_REASON_DEFAULT, findNamedNode, + getChangedNodeOfKind, getDeprecatedDirectiveNode, parentPath, } from '../utils.js'; @@ -192,6 +197,82 @@ export function fieldArgumentAdded( } } +export function fieldArgumentDefaultChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + if (existingArg) { + if ((existingArg.defaultValue && print(existingArg.defaultValue)) !== change.meta.oldDefaultValue) { + handleError( + change, + new ValueMismatchError( + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDefaultValue, + existingArg.defaultValue && print(existingArg.defaultValue), + ), + config, + ); + } + (existingArg.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue ? parseConstValue(change.meta.newDefaultValue) : undefined; + } +} + +export function fieldArgumentRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(), config); + return; + } + + const existing = nodeByPath.get(change.path); + if (existing) { + const fieldNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + arguments?: InputValueDefinitionNode[]; + }; + if (!fieldNode) { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.FIELD_DEFINITION, + 'arguments', + change.meta.removedFieldArgumentName, + ), + config, + ); + } else if (fieldNode.kind === Kind.FIELD_DEFINITION) { + fieldNode.arguments = fieldNode.arguments?.filter( + a => a.name.value === change.meta.removedFieldArgumentName, + ); + + // add new field to the node set + nodeByPath.delete(change.path); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError( + Kind.FIELD_DEFINITION, + fieldNode.kind, + ), + config, + ); + } + } else { + handleError( + change, + new DeletedCoordinateNotFound( + Kind.ARGUMENT, + change.meta.removedFieldArgumentName, + ), + config, + ); + } +} + export function fieldDeprecationReasonChanged( change: Change, nodeByPath: Map, diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index 4dcde58dd1..f09bd68498 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -1,4 +1,4 @@ -import { ASTNode, InputValueDefinitionNode, Kind, parseType, StringValueNode } from 'graphql'; +import { ASTNode, InputValueDefinitionNode, Kind, parseType, StringValueNode, print, ConstValueNode, parseConstValue } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeCoordinateNotFoundError, @@ -152,6 +152,43 @@ export function inputFieldDescriptionAdded( } } +export function inputFieldDefaultValueChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(), config); + return; + } + const existingNode = nodeByPath.get(change.path); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + const oldValueMatches = (existingNode.defaultValue && print(existingNode.defaultValue)) === change.meta.oldDefaultValue; + if (!oldValueMatches) { + handleError( + change, + new ValueMismatchError( + existingNode.defaultValue?.kind ?? Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDefaultValue, + existingNode.defaultValue && print(existingNode.defaultValue), + ), + config, + ); + } + (existingNode.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue ? parseConstValue(change.meta.newDefaultValue) : undefined; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } + } else { + handleError(change, new ChangePathMissingError(), config); + } +} + export function inputFieldDescriptionChanged( change: Change, nodeByPath: Map, @@ -221,6 +258,14 @@ export function inputFieldDescriptionRemoved( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.INPUT_VALUE_DEFINITION, + 'description', + change.meta.removedDescription, + ), + config, + ); } } diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 9237dfaf7b..755b542593 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -1,7 +1,8 @@ -import { ASTNode, DirectiveNode, GraphQLDeprecatedDirective, NameNode } from 'graphql'; +import { ASTKindToNode, ASTNode, DirectiveNode, GraphQLDeprecatedDirective, Kind, NameNode } from 'graphql'; import { Maybe } from 'graphql/jsutils/Maybe'; import { Change, ChangeType } from '@graphql-inspector/core'; -import { AdditionChangeType } from './types.js'; +import { AdditionChangeType, PatchConfig } from './types.js'; +import { ChangedCoordinateKindMismatchError, ChangedCoordinateNotFoundError, ChangePathMissingError, handleError } from './errors.js'; export function getDeprecatedDirectiveNode( definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, @@ -75,3 +76,47 @@ export function debugPrintChange(change: Change, nodeByPath: Map, config: PatchConfig): change is typeof change & { path: string } { + if (!change.path) { + handleError(change, new ChangePathMissingError(), config); + return false; + } + return true; +} + +/** + * Handles verifying the change object has a path, that the node exists in the + * nodeByPath Map, and that the found node is the expected Kind. + */ +export function getChangedNodeOfKind( + change: Change, + nodeByPath: Map, + kind: K, + config: PatchConfig +): ASTKindToNode[K] | void { + if (assertChangeHasPath(change, config)) { + const existing = nodeByPath.get(change.path); + if (!existing) { + handleError( + change, + new ChangedCoordinateNotFoundError( + Kind.INPUT_VALUE_DEFINITION, + change.meta.argumentName, + ), + config, + ); + } else if (existing.kind === kind) { + return existing as ASTKindToNode[K]; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError( + kind, + existing.kind, + ), + config, + ); + } + } +} \ No newline at end of file From 450a572e3c7b1e61fe6a897b1d71b39a7fffd7c6 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:39:13 -0700 Subject: [PATCH 17/23] Prettier --- packages/patch/src/index.ts | 2 +- packages/patch/src/patches/fields.ts | 18 ++++++------- packages/patch/src/patches/inputs.ts | 19 +++++++++++--- packages/patch/src/utils.ts | 39 ++++++++++++++++------------ 4 files changed, 47 insertions(+), 31 deletions(-) diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index cbb136782a..e5158e16c6 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -58,8 +58,8 @@ import { import { fieldAdded, fieldArgumentAdded, - fieldArgumentRemoved, fieldArgumentDefaultChanged, + fieldArgumentRemoved, fieldDeprecationAdded, fieldDeprecationReasonAdded, fieldDeprecationReasonChanged, diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 34e3bf8633..042ecb8b2f 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -204,7 +204,9 @@ export function fieldArgumentDefaultChanged( ) { const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); if (existingArg) { - if ((existingArg.defaultValue && print(existingArg.defaultValue)) !== change.meta.oldDefaultValue) { + if ( + (existingArg.defaultValue && print(existingArg.defaultValue)) !== change.meta.oldDefaultValue + ) { handleError( change, new ValueMismatchError( @@ -215,7 +217,9 @@ export function fieldArgumentDefaultChanged( config, ); } - (existingArg.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue ? parseConstValue(change.meta.newDefaultValue) : undefined; + (existingArg.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue + ? parseConstValue(change.meta.newDefaultValue) + : undefined; } } @@ -254,20 +258,14 @@ export function fieldArgumentRemoved( } else { handleError( change, - new ChangedCoordinateKindMismatchError( - Kind.FIELD_DEFINITION, - fieldNode.kind, - ), + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), config, ); } } else { handleError( change, - new DeletedCoordinateNotFound( - Kind.ARGUMENT, - change.meta.removedFieldArgumentName, - ), + new DeletedCoordinateNotFound(Kind.ARGUMENT, change.meta.removedFieldArgumentName), config, ); } diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index f09bd68498..686629933a 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -1,4 +1,13 @@ -import { ASTNode, InputValueDefinitionNode, Kind, parseType, StringValueNode, print, ConstValueNode, parseConstValue } from 'graphql'; +import { + ASTNode, + ConstValueNode, + InputValueDefinitionNode, + Kind, + parseConstValue, + parseType, + print, + StringValueNode, +} from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeCoordinateNotFoundError, @@ -164,7 +173,9 @@ export function inputFieldDefaultValueChanged( const existingNode = nodeByPath.get(change.path); if (existingNode) { if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { - const oldValueMatches = (existingNode.defaultValue && print(existingNode.defaultValue)) === change.meta.oldDefaultValue; + const oldValueMatches = + (existingNode.defaultValue && print(existingNode.defaultValue)) === + change.meta.oldDefaultValue; if (!oldValueMatches) { handleError( change, @@ -176,7 +187,9 @@ export function inputFieldDefaultValueChanged( config, ); } - (existingNode.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue ? parseConstValue(change.meta.newDefaultValue) : undefined; + (existingNode.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue + ? parseConstValue(change.meta.newDefaultValue) + : undefined; } else { handleError( change, diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 755b542593..b434640474 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -1,8 +1,20 @@ -import { ASTKindToNode, ASTNode, DirectiveNode, GraphQLDeprecatedDirective, Kind, NameNode } from 'graphql'; +import { + ASTKindToNode, + ASTNode, + DirectiveNode, + GraphQLDeprecatedDirective, + Kind, + NameNode, +} from 'graphql'; import { Maybe } from 'graphql/jsutils/Maybe'; import { Change, ChangeType } from '@graphql-inspector/core'; +import { + ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, + ChangePathMissingError, + handleError, +} from './errors.js'; import { AdditionChangeType, PatchConfig } from './types.js'; -import { ChangedCoordinateKindMismatchError, ChangedCoordinateNotFoundError, ChangePathMissingError, handleError } from './errors.js'; export function getDeprecatedDirectiveNode( definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, @@ -77,7 +89,10 @@ export function debugPrintChange(change: Change, nodeByPath: Map, config: PatchConfig): change is typeof change & { path: string } { +export function assertChangeHasPath( + change: Change, + config: PatchConfig, +): change is typeof change & { path: string } { if (!change.path) { handleError(change, new ChangePathMissingError(), config); return false; @@ -93,30 +108,20 @@ export function getChangedNodeOfKind( change: Change, nodeByPath: Map, kind: K, - config: PatchConfig + config: PatchConfig, ): ASTKindToNode[K] | void { if (assertChangeHasPath(change, config)) { const existing = nodeByPath.get(change.path); if (!existing) { handleError( change, - new ChangedCoordinateNotFoundError( - Kind.INPUT_VALUE_DEFINITION, - change.meta.argumentName, - ), + new ChangedCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, change.meta.argumentName), config, ); } else if (existing.kind === kind) { return existing as ASTKindToNode[K]; } else { - handleError( - change, - new ChangedCoordinateKindMismatchError( - kind, - existing.kind, - ), - config, - ); + handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); } } -} \ No newline at end of file +} From d23a3cb5ace55a95eb555446865f47cbd1f27fc4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:33:34 -0700 Subject: [PATCH 18/23] Support more changes --- .../patch/src/__tests__/directives.test.ts | 23 ++++++ packages/patch/src/__tests__/fields.test.ts | 40 ++++++++++ packages/patch/src/__tests__/inputs.test.ts | 14 ++++ packages/patch/src/errors.ts | 6 +- packages/patch/src/index.ts | 25 ++++++ packages/patch/src/patches/directives.ts | 40 +++++++++- packages/patch/src/patches/fields.ts | 60 +++++++++++---- packages/patch/src/patches/inputs.ts | 23 +++++- packages/patch/src/utils.ts | 76 ++++++++++++++++++- 9 files changed, 287 insertions(+), 20 deletions(-) diff --git a/packages/patch/src/__tests__/directives.test.ts b/packages/patch/src/__tests__/directives.test.ts index de73dafaa2..510e77de66 100644 --- a/packages/patch/src/__tests__/directives.test.ts +++ b/packages/patch/src/__tests__/directives.test.ts @@ -12,6 +12,17 @@ describe('directives', async () => { await expectPatchToMatch(before, after); }); + test('directiveRemoved', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + `; + await expectPatchToMatch(before, after); + }); + test('directiveArgumentAdded', async () => { const before = /* GraphQL */ ` scalar Food @@ -24,6 +35,18 @@ describe('directives', async () => { await expectPatchToMatch(before, after); }); + test('directiveArgumentRemoved', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); + test('directiveLocationAdded', async () => { const before = /* GraphQL */ ` scalar Food diff --git a/packages/patch/src/__tests__/fields.test.ts b/packages/patch/src/__tests__/fields.test.ts index 885d84033c..0fc370a801 100644 --- a/packages/patch/src/__tests__/fields.test.ts +++ b/packages/patch/src/__tests__/fields.test.ts @@ -61,6 +61,46 @@ describe('fields', () => { await expectPatchToMatch(before, after); }); + test('fieldArgumentTypeChanged', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + chat(id: String): ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat(id: ID!): ChatSession + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldArgumentDescriptionChanged', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + """ + The first is the worst + """ + chat(firstMessage: String): ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat( + """ + Second is best + """ + firstMessage: String + ): ChatSession + } + `; + await expectPatchToMatch(before, after); + }); + test('fieldDeprecationReasonAdded', async () => { const before = /* GraphQL */ ` scalar ChatSession diff --git a/packages/patch/src/__tests__/inputs.test.ts b/packages/patch/src/__tests__/inputs.test.ts index b57c38284d..d6c03a2922 100644 --- a/packages/patch/src/__tests__/inputs.test.ts +++ b/packages/patch/src/__tests__/inputs.test.ts @@ -48,6 +48,20 @@ describe('inputs', () => { await expectPatchToMatch(before, after); }); + test('inputFieldTypeChanged', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID + } + `; + await expectPatchToMatch(before, after); + }) + test('inputFieldDescriptionRemoved', async () => { const before = /* GraphQL */ ` """ diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index 1b562743b8..729c7f1ecf 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -13,7 +13,7 @@ export function handleError(change: Change, err: Error, config: PatchConfig `Ignoring change ${change.type} at "${change.path}" because it does not modify the resulting schema.`, ); } else if (!config.requireOldValueMatch && err instanceof ValueMismatchError) { - console.debug(`Ignoreing old value mismatch at "${change.path}".`); + console.debug(`Ignoring old value mismatch at "${change.path}".`); } else if (config.throwOnError === true) { throw err; } else { @@ -108,10 +108,10 @@ export class DeletedAncestorCoordinateNotFoundError extends NoopError { constructor( public readonly parentKind: Kind, readonly attributeName: AttributeName, - readonly expectedValue: string, + readonly expectedValue: string | undefined, ) { super( - `Cannot delete "${expectedValue}" from "${attributeName}" on "${parentKind}" because the "${parentKind}" does not exist.`, + `Cannot delete ${expectedValue ? `"${expectedValue}" ` : ''}from "${attributeName}" on "${parentKind}" because the "${parentKind}" does not exist.`, ); } } diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index e5158e16c6..ce5bf09f9a 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -43,10 +43,12 @@ import { directiveArgumentAdded, directiveArgumentDefaultValueChanged, directiveArgumentDescriptionChanged, + directiveArgumentRemoved, directiveArgumentTypeChanged, directiveDescriptionChanged, directiveLocationAdded, directiveLocationRemoved, + directiveRemoved, } from './patches/directives.js'; import { enumValueAdded, @@ -59,7 +61,9 @@ import { fieldAdded, fieldArgumentAdded, fieldArgumentDefaultChanged, + fieldArgumentDescriptionChanged, fieldArgumentRemoved, + fieldArgumentTypeChanged, fieldDeprecationAdded, fieldDeprecationReasonAdded, fieldDeprecationReasonChanged, @@ -72,6 +76,7 @@ import { } from './patches/fields.js'; import { inputFieldAdded, + inputFieldTypeChanged, inputFieldDefaultValueChanged, inputFieldDescriptionAdded, inputFieldDescriptionChanged, @@ -232,10 +237,18 @@ export function patch( directiveAdded(change, nodeByPath, config); break; } + case ChangeType.DirectiveRemoved: { + directiveRemoved(change, nodeByPath, config); + break; + } case ChangeType.DirectiveArgumentAdded: { directiveArgumentAdded(change, nodeByPath, config); break; } + case ChangeType.DirectiveArgumentRemoved: { + directiveArgumentRemoved(change, nodeByPath, config); + break; + } case ChangeType.DirectiveLocationAdded: { directiveLocationAdded(change, nodeByPath, config); break; @@ -272,10 +285,18 @@ export function patch( fieldArgumentAdded(change, nodeByPath, config); break; } + case ChangeType.FieldArgumentTypeChanged: { + fieldArgumentTypeChanged(change, nodeByPath, config); + break; + } case ChangeType.FieldArgumentRemoved: { fieldArgumentRemoved(change, nodeByPath, config); break; } + case ChangeType.FieldArgumentDescriptionChanged: { + fieldArgumentDescriptionChanged(change, nodeByPath, config); + break; + } case ChangeType.FieldArgumentDefaultChanged: { fieldArgumentDefaultChanged(change, nodeByPath, config); break; @@ -316,6 +337,10 @@ export function patch( inputFieldDescriptionAdded(change, nodeByPath, config); break; } + case ChangeType.InputFieldTypeChanged: { + inputFieldTypeChanged(change, nodeByPath, config); + break; + } case ChangeType.InputFieldDescriptionChanged: { inputFieldDescriptionChanged(change, nodeByPath, config); break; diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 6c87b972b4..2495c924b9 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -26,7 +26,12 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import { PatchConfig } from '../types.js'; -import { findNamedNode } from '../utils.js'; +import { + deleteNamedNode, + findNamedNode, + getDeletedNodeOfKind, + getDeletedParentNodeOfKind, +} from '../utils.js'; export function directiveAdded( change: Change, @@ -59,6 +64,17 @@ export function directiveAdded( } } +export function directiveRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE_DEFINITION, config); + if (existing) { + nodeByPath.delete(change.path!); + } +} + export function directiveArgumentAdded( change: Change, nodeByPath: Map, @@ -112,6 +128,28 @@ export function directiveArgumentAdded( } } +export function directiveArgumentRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const argNode = getDeletedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + if (argNode) { + const directiveNode = getDeletedParentNodeOfKind( + change, + nodeByPath, + Kind.DIRECTIVE_DEFINITION, + 'arguments', + config, + ); + + if (directiveNode) { + (directiveNode.arguments as ReadonlyArray | undefined) = + deleteNamedNode(directiveNode.arguments, change.meta.removedDirectiveArgumentName); + } + } +} + export function directiveLocationAdded( change: Change, nodeByPath: Map, diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 042ecb8b2f..a0aaa09afc 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -18,7 +18,6 @@ import { AddedAttributeAlreadyExistsError, AddedCoordinateAlreadyExistsError, ChangedCoordinateKindMismatchError, - ChangedCoordinateNotFoundError, ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedCoordinateNotFound, @@ -28,6 +27,7 @@ import { import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; import { + assertValueMatch, DEPRECATION_REASON_DEFAULT, findNamedNode, getChangedNodeOfKind, @@ -197,6 +197,44 @@ export function fieldArgumentAdded( } } +export function fieldArgumentTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + if (existingArg) { + assertValueMatch( + change, + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldArgumentType, + print(existingArg.type), + config, + ); + (existingArg.type as TypeNode) = parseType(change.meta.newArgumentType); + } +} + +export function fieldArgumentDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + if (existingArg) { + assertValueMatch( + change, + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDescription ?? undefined, + existingArg.description?.value, + config, + ); + (existingArg.description as StringValueNode | undefined) = change.meta.newDescription + ? stringNode(change.meta.newDescription) + : undefined; + } +} + export function fieldArgumentDefaultChanged( change: Change, nodeByPath: Map, @@ -204,19 +242,13 @@ export function fieldArgumentDefaultChanged( ) { const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); if (existingArg) { - if ( - (existingArg.defaultValue && print(existingArg.defaultValue)) !== change.meta.oldDefaultValue - ) { - handleError( - change, - new ValueMismatchError( - Kind.INPUT_VALUE_DEFINITION, - change.meta.oldDefaultValue, - existingArg.defaultValue && print(existingArg.defaultValue), - ), - config, - ); - } + assertValueMatch( + change, + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDefaultValue, + existingArg.defaultValue && print(existingArg.defaultValue), + config, + ); (existingArg.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue ? parseConstValue(change.meta.newDefaultValue) : undefined; diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index 686629933a..ba4a527d5b 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -7,6 +7,8 @@ import { parseType, print, StringValueNode, + printType, + TypeNode, } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { @@ -20,7 +22,7 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types.js'; -import { parentPath } from '../utils.js'; +import { assertValueMatch, getChangedNodeOfKind, parentPath } from '../utils.js'; export function inputFieldAdded( change: Change, @@ -161,6 +163,25 @@ export function inputFieldDescriptionAdded( } } +export function inputFieldTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const inputFieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + if (inputFieldNode) { + assertValueMatch( + change, + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldInputFieldType, + print(inputFieldNode.type), + config, + ); + + (inputFieldNode.type as TypeNode) = parseType(change.meta.newInputFieldType); + } +} + export function inputFieldDefaultValueChanged( change: Change, nodeByPath: Map, diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index b434640474..0b2c896bec 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -9,10 +9,14 @@ import { import { Maybe } from 'graphql/jsutils/Maybe'; import { Change, ChangeType } from '@graphql-inspector/core'; import { + AttributeName, ChangedCoordinateKindMismatchError, ChangedCoordinateNotFoundError, ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, handleError, + ValueMismatchError, } from './errors.js'; import { AdditionChangeType, PatchConfig } from './types.js'; @@ -29,6 +33,16 @@ export function findNamedNode( return nodes?.find(value => value.name.value === name); } +export function deleteNamedNode( + nodes: Maybe>, + name: string, +): ReadonlyArray | undefined { + if (nodes) { + const idx = nodes.findIndex(value => value.name.value === name); + return idx >= 0 ? nodes.toSpliced(idx, 1) : nodes; + } +} + export function parentPath(path: string) { const lastDividerIndex = path.lastIndexOf('.'); return lastDividerIndex === -1 ? path : path.substring(0, lastDividerIndex); @@ -100,6 +114,18 @@ export function assertChangeHasPath( return true; } +export function assertValueMatch( + change: Change, + expectedKind: Kind, + expected: string | undefined, + actual: string | undefined, + config: PatchConfig, +) { + if (expected !== actual) { + handleError(change, new ValueMismatchError(expectedKind, expected, actual), config); + } +} + /** * Handles verifying the change object has a path, that the node exists in the * nodeByPath Map, and that the found node is the expected Kind. @@ -115,7 +141,55 @@ export function getChangedNodeOfKind( if (!existing) { handleError( change, - new ChangedCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, change.meta.argumentName), + // @todo improve the error by providing the name or value somehow. + new ChangedCoordinateNotFoundError(kind, undefined), + config, + ); + } else if (existing.kind === kind) { + return existing as ASTKindToNode[K]; + } else { + handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + } + } +} + +export function getDeletedNodeOfKind( + change: Change, + nodeByPath: Map, + kind: K, + config: PatchConfig, +): ASTKindToNode[K] | void { + if (assertChangeHasPath(change, config)) { + const existing = nodeByPath.get(change.path); + if (!existing) { + handleError( + change, + // @todo improve the error by providing the name or value somehow. + new DeletedCoordinateNotFound(kind, undefined), + config, + ); + } else if (existing.kind === kind) { + return existing as ASTKindToNode[K]; + } else { + handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + } + } +} + +export function getDeletedParentNodeOfKind( + change: Change, + nodeByPath: Map, + kind: K, + attributeName: AttributeName, + config: PatchConfig, +): ASTKindToNode[K] | void { + if (assertChangeHasPath(change, config)) { + const existing = nodeByPath.get(parentPath(change.path)); + if (!existing) { + handleError( + change, + // @todo improve the error by providing the name or value somehow. + new DeletedAncestorCoordinateNotFoundError(kind, attributeName, undefined), config, ); } else if (existing.kind === kind) { From 7cc882bfe30e4ba51882f709d2244e9dd4c08cda Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 21 Aug 2025 18:36:29 -0700 Subject: [PATCH 19/23] fix prettier etc --- packages/patch/src/__tests__/inputs.test.ts | 2 +- packages/patch/src/index.ts | 2 +- packages/patch/src/patches/inputs.ts | 8 ++++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/patch/src/__tests__/inputs.test.ts b/packages/patch/src/__tests__/inputs.test.ts index d6c03a2922..c7448d8b02 100644 --- a/packages/patch/src/__tests__/inputs.test.ts +++ b/packages/patch/src/__tests__/inputs.test.ts @@ -60,7 +60,7 @@ describe('inputs', () => { } `; await expectPatchToMatch(before, after); - }) + }); test('inputFieldDescriptionRemoved', async () => { const before = /* GraphQL */ ` diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index ce5bf09f9a..5a4615d07a 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -76,12 +76,12 @@ import { } from './patches/fields.js'; import { inputFieldAdded, - inputFieldTypeChanged, inputFieldDefaultValueChanged, inputFieldDescriptionAdded, inputFieldDescriptionChanged, inputFieldDescriptionRemoved, inputFieldRemoved, + inputFieldTypeChanged, } from './patches/inputs.js'; import { objectTypeInterfaceAdded, objectTypeInterfaceRemoved } from './patches/interfaces.js'; import { diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index ba4a527d5b..67fb224a0e 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -7,7 +7,6 @@ import { parseType, print, StringValueNode, - printType, TypeNode, } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; @@ -168,7 +167,12 @@ export function inputFieldTypeChanged( nodeByPath: Map, config: PatchConfig, ) { - const inputFieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + const inputFieldNode = getChangedNodeOfKind( + change, + nodeByPath, + Kind.INPUT_VALUE_DEFINITION, + config, + ); if (inputFieldNode) { assertValueMatch( change, From 26343fa49c01eb9dfe9c5e032298f30073db89e1 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:04:36 -0700 Subject: [PATCH 20/23] Improve error handling and error types --- packages/patch/src/README.md | 2 +- packages/patch/src/errors.ts | 10 +- packages/patch/src/index.ts | 2 + .../patch/src/patches/directive-usages.ts | 6 +- packages/patch/src/patches/directives.ts | 16 +- packages/patch/src/patches/enum.ts | 26 +- packages/patch/src/patches/fields.ts | 504 +++++++----------- packages/patch/src/patches/inputs.ts | 18 +- packages/patch/src/patches/interfaces.ts | 8 +- packages/patch/src/patches/types.ts | 20 +- packages/patch/src/types.ts | 2 +- packages/patch/src/utils.ts | 2 +- 12 files changed, 266 insertions(+), 350 deletions(-) diff --git a/packages/patch/src/README.md b/packages/patch/src/README.md index 5a24cbf159..a19ea65079 100644 --- a/packages/patch/src/README.md +++ b/packages/patch/src/README.md @@ -25,7 +25,7 @@ expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patched)); > Allows handling errors more granularly if you only care about specific types of errors or want to capture the errors in a list somewhere etc. If 'true' is returned then this error is considered handled and the default error handling will not be ran. To halt patching, throw the error inside the handler. -`onError?: (err: Error) => boolean | undefined | null` +`onError?: (err: Error, change: Change) => boolean | undefined | null` > Enables debug logging diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index 729c7f1ecf..8af60cd185 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -3,7 +3,7 @@ import type { Change } from '@graphql-inspector/core'; import type { PatchConfig } from './types.js'; export function handleError(change: Change, err: Error, config: PatchConfig) { - if (config.onError?.(err) === true) { + if (config.onError?.(err, change) === true) { // handled by onError return; } @@ -150,7 +150,9 @@ export class DeletedAttributeNotFoundError extends NoopError { export class ChangedCoordinateNotFoundError extends Error { constructor(expectedKind: Kind, expectedNameOrValue: string | undefined) { - super(`The "${expectedKind}" ${expectedNameOrValue} does not exist.`); + super( + `The "${expectedKind}" ${expectedNameOrValue ? `"${expectedNameOrValue}"` : ''}does not exist.`, + ); } } @@ -174,7 +176,7 @@ export class ChangedCoordinateKindMismatchError extends Error { * This should not happen unless there's an issue with the diff creation. */ export class ChangePathMissingError extends Error { - constructor() { - super(`The change message is missing a "path". Cannot apply.`); + constructor(public readonly change: Change) { + super(`The change is missing a "path". Cannot apply.`); } } diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 5a4615d07a..93f1f03951 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -100,6 +100,8 @@ import { unionMemberAdded, unionMemberRemoved } from './patches/unions.js'; import { PatchConfig, SchemaNode } from './types.js'; import { debugPrintChange } from './utils.js'; +export * as errors from './errors.js'; + export function patchSchema( schema: GraphQLSchema, changes: Change[], diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index 5fed4dbaea..c3a00488c4 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -160,7 +160,7 @@ function directiveUsageDefinitionRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -396,7 +396,7 @@ export function directiveUsageArgumentAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } const directiveNode = nodeByPath.get(parentPath(change.path)); @@ -445,7 +445,7 @@ export function directiveUsageArgumentRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } const directiveNode = nodeByPath.get(parentPath(change.path)); diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 2495c924b9..9cf04c7ad0 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -39,7 +39,7 @@ export function directiveAdded( config: PatchConfig, ) { if (change.path === undefined) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -81,7 +81,7 @@ export function directiveArgumentAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -156,7 +156,7 @@ export function directiveLocationAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -201,7 +201,7 @@ export function directiveLocationRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -250,7 +250,7 @@ export function directiveDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -293,7 +293,7 @@ export function directiveArgumentDefaultValueChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -339,7 +339,7 @@ export function directiveArgumentDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -382,7 +382,7 @@ export function directiveArgumentTypeChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index 633c6fedbe..aa25095037 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -14,8 +14,10 @@ import { AddedCoordinateAlreadyExistsError, ChangedAncestorCoordinateNotFoundError, ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, ChangePathMissingError, DeletedAttributeNotFoundError, + DeletedCoordinateNotFound, handleError, ValueMismatchError, } from '../errors.js'; @@ -29,7 +31,7 @@ export function enumValueRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -37,7 +39,11 @@ export function enumValueRemoved( | (ASTNode & { values?: EnumValueDefinitionNode[] }) | undefined; if (!enumNode) { - handleError(change, new ChangePathMissingError(), config); + handleError( + change, + new DeletedCoordinateNotFound(Kind.ENUM_TYPE_DEFINITION, change.meta.removedEnumValueName), + config, + ); } else if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { handleError( change, @@ -124,7 +130,7 @@ export function enumValueDeprecationReasonAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -151,7 +157,7 @@ export function enumValueDeprecationReasonAdded( ]; nodeByPath.set(`${change.path}.reason`, argNode); } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); } } else { handleError( @@ -175,7 +181,7 @@ export function enumValueDeprecationReasonChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -211,7 +217,7 @@ export function enumValueDeprecationReasonChanged( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangedCoordinateNotFoundError(Kind.ARGUMENT, 'reason'), config); } } else { handleError( @@ -221,7 +227,11 @@ export function enumValueDeprecationReasonChanged( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + config, + ); } } @@ -231,7 +241,7 @@ export function enumValueDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index a0aaa09afc..023b2f380b 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -16,10 +16,13 @@ import { import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeAlreadyExistsError, + AddedAttributeCoordinateNotFoundError, AddedCoordinateAlreadyExistsError, ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, DeletedCoordinateNotFound, handleError, ValueMismatchError, @@ -27,10 +30,12 @@ import { import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; import { + assertChangeHasPath, assertValueMatch, DEPRECATION_REASON_DEFAULT, findNamedNode, getChangedNodeOfKind, + getDeletedNodeOfKind, getDeprecatedDirectiveNode, parentPath, } from '../utils.js'; @@ -40,28 +45,17 @@ export function fieldTypeChanged( nodeByPath: Map, config: PatchConfig, ) { - const c = change as Change; - const node = nodeByPath.get(c.path!); + const node = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); if (node) { - if (node.kind === Kind.FIELD_DEFINITION) { - const currentReturnType = print(node.type); - if (c.meta.oldFieldType !== currentReturnType) { - handleError( - c, - new ValueMismatchError(Kind.FIELD_DEFINITION, c.meta.oldFieldType, currentReturnType), - config, - ); - } - (node.type as TypeNode) = parseType(c.meta.newFieldType); - } else { + const currentReturnType = print(node.type); + if (change.meta.oldFieldType !== currentReturnType) { handleError( - c, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, node.kind), + change, + new ValueMismatchError(Kind.FIELD_DEFINITION, change.meta.oldFieldType, currentReturnType), config, ); } - } else { - handleError(c, new ChangePathMissingError(), config); + (node.type as TypeNode) = parseType(change.meta.newFieldType); } } @@ -71,20 +65,32 @@ export function fieldRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } const typeNode = nodeByPath.get(parentPath(change.path)) as | (ASTNode & { fields?: FieldDefinitionNode[] }) | undefined; - if (!typeNode || !typeNode.fields?.length) { - handleError(change, new ChangePathMissingError(), config); + if (!typeNode) { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.OBJECT_TYPE_DEFINITION, + 'fields', + change.meta.removedFieldName, + ), + config, + ); } else { - const beforeLength = typeNode.fields.length; - typeNode.fields = typeNode.fields.filter(f => f.name.value !== change.meta.removedFieldName); - if (beforeLength === typeNode.fields.length) { - handleError(change, new ChangePathMissingError(), config); + const beforeLength = typeNode.fields?.length ?? 0; + typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); + if (beforeLength === (typeNode.fields?.length ?? 0)) { + handleError( + change, + new DeletedAttributeNotFoundError(typeNode?.kind, 'fields', change.meta.removedFieldName), + config, + ); } else { // delete the reference to the removed field. nodeByPath.delete(change.path); @@ -97,55 +103,52 @@ export function fieldAdded( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(), config); - return; - } - - const changedNode = nodeByPath.get(change.path); - if (changedNode) { - if (changedNode.kind === Kind.OBJECT_FIELD) { - handleError( - change, - new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedFieldName), - config, - ); - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.OBJECT_FIELD, changedNode.kind), - config, - ); - } - } else { - const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { - fields?: FieldDefinitionNode[]; - }; - if (!typeNode) { - handleError(change, new ChangePathMissingError(), config); - } else if ( - typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && - typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION - ) { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), - config, - ); + if (assertChangeHasPath(change, config)) { + const changedNode = nodeByPath.get(change.path); + if (changedNode) { + if (changedNode.kind === Kind.OBJECT_FIELD) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedFieldName), + config, + ); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_FIELD, changedNode.kind), + config, + ); + } } else { - const node: FieldDefinitionNode = { - kind: Kind.FIELD_DEFINITION, - name: nameNode(change.meta.addedFieldName), - type: parseType(change.meta.addedFieldReturnType), - // description: change.meta.addedFieldDescription - // ? stringNode(change.meta.addedFieldDescription) - // : undefined, + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: FieldDefinitionNode[]; }; - - typeNode.fields = [...(typeNode.fields ?? []), node]; - - // add new field to the node set - nodeByPath.set(change.path, node); + if (!typeNode) { + handleError(change, new ChangePathMissingError(change), config); + } else if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), + config, + ); + } else { + const node: FieldDefinitionNode = { + kind: Kind.FIELD_DEFINITION, + name: nameNode(change.meta.addedFieldName), + type: parseType(change.meta.addedFieldReturnType), + // description: change.meta.addedFieldDescription + // ? stringNode(change.meta.addedFieldDescription) + // : undefined, + }; + + typeNode.fields = [...(typeNode.fields ?? []), node]; + + // add new field to the node set + nodeByPath.set(change.path, node); + } } } } @@ -155,44 +158,45 @@ export function fieldArgumentAdded( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(), config); - return; - } - - const existing = nodeByPath.get(change.path); - if (existing) { - handleError( - change, - new AddedCoordinateAlreadyExistsError(Kind.ARGUMENT, change.meta.addedArgumentName), - config, - ); - } else { - const fieldNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { - arguments?: InputValueDefinitionNode[]; - }; - if (!fieldNode) { - handleError(change, new ChangePathMissingError(), config); - } else if (fieldNode.kind === Kind.FIELD_DEFINITION) { - const node: InputValueDefinitionNode = { - kind: Kind.INPUT_VALUE_DEFINITION, - name: nameNode(change.meta.addedArgumentName), - type: parseType(change.meta.addedArgumentType), - // description: change.meta.addedArgumentDescription - // ? stringNode(change.meta.addedArgumentDescription) - // : undefined, - }; - - fieldNode.arguments = [...(fieldNode.arguments ?? []), node]; - - // add new field to the node set - nodeByPath.set(change.path, node); - } else { + if (assertChangeHasPath(change, config)) { + const existing = nodeByPath.get(change.path); + if (existing) { handleError( change, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + new AddedCoordinateAlreadyExistsError(Kind.ARGUMENT, change.meta.addedArgumentName), config, ); + } else { + const fieldNode = nodeByPath.get(parentPath(change.path!)) as ASTNode & { + arguments?: InputValueDefinitionNode[]; + }; + if (!fieldNode) { + handleError( + change, + new AddedAttributeCoordinateNotFoundError(Kind.FIELD_DEFINITION, 'arguments'), + config, + ); + } else if (fieldNode.kind === Kind.FIELD_DEFINITION) { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + // description: change.meta.addedArgumentDescription + // ? stringNode(change.meta.addedArgumentDescription) + // : undefined, + }; + + fieldNode.arguments = [...(fieldNode.arguments ?? []), node]; + + // add new field to the node set + nodeByPath.set(change.path!, node); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); + } } } } @@ -260,14 +264,9 @@ export function fieldArgumentRemoved( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(), config); - return; - } - - const existing = nodeByPath.get(change.path); + const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.ARGUMENT, config); if (existing) { - const fieldNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + const fieldNode = nodeByPath.get(parentPath(change.path!)) as ASTNode & { arguments?: InputValueDefinitionNode[]; }; if (!fieldNode) { @@ -286,7 +285,7 @@ export function fieldArgumentRemoved( ); // add new field to the node set - nodeByPath.delete(change.path); + nodeByPath.delete(change.path!); } else { handleError( change, @@ -294,12 +293,6 @@ export function fieldArgumentRemoved( config, ); } - } else { - handleError( - change, - new DeletedCoordinateNotFound(Kind.ARGUMENT, change.meta.removedFieldArgumentName), - config, - ); } } @@ -308,49 +301,34 @@ export function fieldDeprecationReasonChanged( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(), config); - return; - } - - const deprecationNode = nodeByPath.get(change.path); + const deprecationNode = getChangedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE, config); if (deprecationNode) { - if (deprecationNode.kind === Kind.DIRECTIVE) { - const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); - if (reasonArgument) { - if (print(reasonArgument.value) !== change.meta.oldDeprecationReason) { - handleError( - change, - new ValueMismatchError( - Kind.ARGUMENT, - print(reasonArgument.value), - change.meta.oldDeprecationReason, - ), - config, - ); - } - - const node = { - kind: Kind.ARGUMENT, - name: nameNode('reason'), - value: stringNode(change.meta.newDeprecationReason), - } as ArgumentNode; - (deprecationNode.arguments as ArgumentNode[] | undefined) = [ - ...(deprecationNode.arguments ?? []), - node, - ]; - } else { - handleError(change, new ChangePathMissingError(), config); + const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); + if (reasonArgument) { + if (print(reasonArgument.value) !== change.meta.oldDeprecationReason) { + handleError( + change, + new ValueMismatchError( + Kind.ARGUMENT, + print(reasonArgument.value), + change.meta.oldDeprecationReason, + ), + config, + ); } + + const node = { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.newDeprecationReason), + } as ArgumentNode; + (deprecationNode.arguments as ArgumentNode[] | undefined) = [ + ...(deprecationNode.arguments ?? []), + node, + ]; } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), - config, - ); + handleError(change, new ChangedCoordinateNotFoundError(Kind.ARGUMENT, 'reason'), config); } - } else { - handleError(change, new ChangePathMissingError(), config); } } @@ -359,42 +337,27 @@ export function fieldDeprecationReasonAdded( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(), config); - return; - } - - const deprecationNode = nodeByPath.get(change.path); + const deprecationNode = getChangedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE, config); if (deprecationNode) { - if (deprecationNode.kind === Kind.DIRECTIVE) { - const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); - if (reasonArgument) { - handleError( - change, - new AddedAttributeAlreadyExistsError(Kind.DIRECTIVE, 'arguments', 'reason'), - config, - ); - } else { - const node = { - kind: Kind.ARGUMENT, - name: nameNode('reason'), - value: stringNode(change.meta.addedDeprecationReason), - } as ArgumentNode; - (deprecationNode.arguments as ArgumentNode[] | undefined) = [ - ...(deprecationNode.arguments ?? []), - node, - ]; - nodeByPath.set(`${change.path}.reason`, node); - } - } else { + const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); + if (reasonArgument) { handleError( change, - new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, deprecationNode.kind), + new AddedAttributeAlreadyExistsError(Kind.DIRECTIVE, 'arguments', 'reason'), config, ); + } else { + const node = { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.addedDeprecationReason), + } as ArgumentNode; + (deprecationNode.arguments as ArgumentNode[] | undefined) = [ + ...(deprecationNode.arguments ?? []), + node, + ]; + nodeByPath.set(`${change.path}.reason`, node); } - } else { - handleError(change, new ChangePathMissingError(), config); } } @@ -403,57 +366,39 @@ export function fieldDeprecationAdded( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(), config); - return; - } - - const fieldNode = nodeByPath.get(change.path); + const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); if (fieldNode) { - if (fieldNode.kind === Kind.FIELD_DEFINITION) { - const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); - if (hasExistingDeprecationDirective) { - handleError( - change, - new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, '@deprecated'), - config, - ); - } else { - const directiveNode = { - kind: Kind.DIRECTIVE, - name: nameNode(GraphQLDeprecatedDirective.name), - ...(change.meta.deprecationReason && - change.meta.deprecationReason !== DEPRECATION_REASON_DEFAULT - ? { - arguments: [ - { - kind: Kind.ARGUMENT, - name: nameNode('reason'), - value: stringNode(change.meta.deprecationReason), - }, - ], - } - : {}), - } as DirectiveNode; - - (fieldNode.directives as DirectiveNode[] | undefined) = [ - ...(fieldNode.directives ?? []), - directiveNode, - ]; - nodeByPath.set( - [change.path, `@${GraphQLDeprecatedDirective.name}`].join(','), - directiveNode, - ); - } - } else { + const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); + if (hasExistingDeprecationDirective) { handleError( change, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, '@deprecated'), config, ); + } else { + const directiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(GraphQLDeprecatedDirective.name), + ...(change.meta.deprecationReason && + change.meta.deprecationReason !== DEPRECATION_REASON_DEFAULT + ? { + arguments: [ + { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.deprecationReason), + }, + ], + } + : {}), + } as DirectiveNode; + + (fieldNode.directives as DirectiveNode[] | undefined) = [ + ...(fieldNode.directives ?? []), + directiveNode, + ]; + nodeByPath.set([change.path, `@${GraphQLDeprecatedDirective.name}`].join(','), directiveNode); } - } else { - handleError(change, new ChangePathMissingError(), config); } } @@ -462,32 +407,17 @@ export function fieldDeprecationRemoved( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(), config); - return; - } - - const fieldNode = nodeByPath.get(change.path); + const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); if (fieldNode) { - if (fieldNode.kind === Kind.FIELD_DEFINITION) { - const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); - if (hasExistingDeprecationDirective) { - (fieldNode.directives as DirectiveNode[] | undefined) = fieldNode.directives?.filter( - d => d.name.value !== GraphQLDeprecatedDirective.name, - ); - nodeByPath.delete([change.path, `@${GraphQLDeprecatedDirective.name}`].join('.')); - } else { - handleError(change, new DeletedCoordinateNotFound(Kind.DIRECTIVE, '@deprecated'), config); - } - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), - config, + const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); + if (hasExistingDeprecationDirective) { + (fieldNode.directives as DirectiveNode[] | undefined) = fieldNode.directives?.filter( + d => d.name.value !== GraphQLDeprecatedDirective.name, ); + nodeByPath.delete([change.path, `@${GraphQLDeprecatedDirective.name}`].join('.')); + } else { + handleError(change, new DeletedCoordinateNotFound(Kind.DIRECTIVE, '@deprecated'), config); } - } else { - handleError(change, new ChangePathMissingError(), config); } } @@ -496,26 +426,11 @@ export function fieldDescriptionAdded( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(), config); - return; - } - - const fieldNode = nodeByPath.get(change.path); + const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); if (fieldNode) { - if (fieldNode.kind === Kind.FIELD_DEFINITION) { - (fieldNode.description as StringValueNode | undefined) = change.meta.addedDescription - ? stringNode(change.meta.addedDescription) - : undefined; - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), - config, - ); - } - } else { - handleError(change, new ChangePathMissingError(), config); + (fieldNode.description as StringValueNode | undefined) = change.meta.addedDescription + ? stringNode(change.meta.addedDescription) + : undefined; } } @@ -525,7 +440,7 @@ export function fieldDescriptionRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -541,7 +456,7 @@ export function fieldDescriptionRemoved( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); } } @@ -550,37 +465,20 @@ export function fieldDescriptionChanged( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(), config); - return; - } - - const fieldNode = nodeByPath.get(change.path); + const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); if (fieldNode) { - if (fieldNode.kind === Kind.FIELD_DEFINITION) { - if (fieldNode.description?.value !== change.meta.oldDescription) { - handleError( - change, - new ValueMismatchError( - Kind.FIELD_DEFINITION, - change.meta.oldDescription, - fieldNode.description?.value, - ), - config, - ); - } - - (fieldNode.description as StringValueNode | undefined) = stringNode( - change.meta.newDescription, - ); - } else { + if (fieldNode.description?.value !== change.meta.oldDescription) { handleError( change, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + new ValueMismatchError( + Kind.FIELD_DEFINITION, + change.meta.oldDescription, + fieldNode.description?.value, + ), config, ); } - } else { - handleError(change, new ChangePathMissingError(), config); + + (fieldNode.description as StringValueNode | undefined) = stringNode(change.meta.newDescription); } } diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index 67fb224a0e..3dae6bfe4e 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -29,7 +29,7 @@ export function inputFieldAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -91,7 +91,7 @@ export function inputFieldRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -123,7 +123,7 @@ export function inputFieldRemoved( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); } } @@ -133,7 +133,7 @@ export function inputFieldDescriptionAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } const existingNode = nodeByPath.get(change.path); @@ -192,7 +192,7 @@ export function inputFieldDefaultValueChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } const existingNode = nodeByPath.get(change.path); @@ -223,7 +223,7 @@ export function inputFieldDefaultValueChanged( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); } } @@ -233,7 +233,7 @@ export function inputFieldDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } const existingNode = nodeByPath.get(change.path); @@ -261,7 +261,7 @@ export function inputFieldDescriptionChanged( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); } } @@ -271,7 +271,7 @@ export function inputFieldDescriptionRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index d3b36b5a51..58e16c5b15 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -17,7 +17,7 @@ export function objectTypeInterfaceAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -69,7 +69,7 @@ export function objectTypeInterfaceRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -85,7 +85,7 @@ export function objectTypeInterfaceRemoved( i => i.name.value !== change.meta.removedInterfaceName, ); } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); } } else { handleError( @@ -95,6 +95,6 @@ export function objectTypeInterfaceRemoved( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); } } diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index 2814a01fec..7c8f922545 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -18,7 +18,7 @@ export function typeAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -44,7 +44,7 @@ export function typeRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -65,7 +65,11 @@ export function typeRemoved( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError( + change, + new DeletedCoordinateNotFound(Kind.OBJECT_TYPE_DEFINITION, change.meta.removedTypeName), + config, + ); } } @@ -75,7 +79,7 @@ export function typeDescriptionAdded( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -93,7 +97,7 @@ export function typeDescriptionAdded( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); } } @@ -103,7 +107,7 @@ export function typeDescriptionChanged( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } @@ -132,7 +136,7 @@ export function typeDescriptionChanged( ); } } else { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); } } @@ -142,7 +146,7 @@ export function typeDescriptionRemoved( config: PatchConfig, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return; } diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts index cc77fd1000..fa7fefa35a 100644 --- a/packages/patch/src/types.ts +++ b/packages/patch/src/types.ts @@ -48,7 +48,7 @@ export type PatchConfig = { * @param err The raised error * @returns True if the error has been handled */ - onError?: (err: Error) => boolean | undefined | null; + onError?: (err: Error, change: Change) => boolean | undefined | null; /** * Enables debug logging diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 0b2c896bec..2f4d97b43d 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -108,7 +108,7 @@ export function assertChangeHasPath( config: PatchConfig, ): change is typeof change & { path: string } { if (!change.path) { - handleError(change, new ChangePathMissingError(), config); + handleError(change, new ChangePathMissingError(change), config); return false; } return true; From 39c26164a2f74b70001363006e6a73b4efb64ea9 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:32:49 -0700 Subject: [PATCH 21/23] tweaking errors --- packages/patch/src/errors.ts | 27 ++++++++++--------- .../patch/src/patches/directive-usages.ts | 12 +++++++-- packages/patch/src/patches/directives.ts | 8 ++++-- packages/patch/src/patches/enum.ts | 6 ++++- packages/patch/src/patches/fields.ts | 6 ++++- packages/patch/src/patches/inputs.ts | 6 ++++- packages/patch/src/utils.ts | 10 +++---- 7 files changed, 51 insertions(+), 24 deletions(-) diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index 8af60cd185..94c613c54e 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -58,7 +58,7 @@ export class AddedCoordinateAlreadyExistsError extends NoopError { } } -export type AttributeName = +export type NodeAttribute = | 'description' | 'defaultValue' /** Enum values */ @@ -80,10 +80,13 @@ export type AttributeName = */ export class AddedAttributeCoordinateNotFoundError extends Error { constructor( - public readonly parentKind: Kind, - readonly attributeName: AttributeName, + public readonly parentName: string, + readonly attribute: NodeAttribute, + readonly attributeValue: string, ) { - super(`Cannot set ${attributeName} on "${parentKind}" because it does not exist.`); + super( + `Cannot add "${attributeValue}" to "${attribute}", because "${parentName}" does not exist.`, + ); } } @@ -94,9 +97,9 @@ export class AddedAttributeCoordinateNotFoundError extends Error { export class ChangedAncestorCoordinateNotFoundError extends Error { constructor( public readonly parentKind: Kind, - readonly attributeName: AttributeName, + readonly attribute: NodeAttribute, ) { - super(`Cannot set "${attributeName}" on "${parentKind}" because it does not exist.`); + super(`Cannot change the "${attribute}" because the "${parentKind}" does not exist.`); } } @@ -107,11 +110,11 @@ export class ChangedAncestorCoordinateNotFoundError extends Error { export class DeletedAncestorCoordinateNotFoundError extends NoopError { constructor( public readonly parentKind: Kind, - readonly attributeName: AttributeName, + readonly attribute: NodeAttribute, readonly expectedValue: string | undefined, ) { super( - `Cannot delete ${expectedValue ? `"${expectedValue}" ` : ''}from "${attributeName}" on "${parentKind}" because the "${parentKind}" does not exist.`, + `Cannot delete ${expectedValue ? `"${expectedValue}" ` : ''}from "${attribute}" on "${parentKind}" because the "${parentKind}" does not exist.`, ); } } @@ -123,11 +126,11 @@ export class DeletedAncestorCoordinateNotFoundError extends NoopError { export class AddedAttributeAlreadyExistsError extends NoopError { constructor( public readonly parentKind: Kind, - readonly attributeName: AttributeName, + readonly attribute: NodeAttribute, readonly attributeValue: string, ) { super( - `Cannot add "${attributeValue}" to "${attributeName}" on "${parentKind}" because it already exists.`, + `Cannot add "${attributeValue}" to "${attribute}" on "${parentKind}" because it already exists.`, ); } } @@ -139,11 +142,11 @@ export class AddedAttributeAlreadyExistsError extends NoopError { export class DeletedAttributeNotFoundError extends NoopError { constructor( public readonly parentKind: Kind, - readonly attributeName: AttributeName, + readonly attribute: NodeAttribute, public readonly value: string, ) { super( - `Cannot delete "${value}" from "${parentKind}"'s "${attributeName}" because "${value}" does not exist.`, + `Cannot delete "${value}" from "${parentKind}"'s "${attribute}" because "${value}" does not exist.`, ); } } diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index c3a00488c4..672b3157f9 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -403,7 +403,11 @@ export function directiveUsageArgumentAdded( if (!directiveNode) { handleError( change, - new AddedAttributeCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + new AddedAttributeCoordinateNotFoundError( + change.meta.directiveName, + 'arguments', + change.meta.addedArgumentName, + ), config, ); } else if (directiveNode.kind === Kind.DIRECTIVE) { @@ -452,7 +456,11 @@ export function directiveUsageArgumentRemoved( if (!directiveNode) { handleError( change, - new AddedAttributeCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + new DeletedAncestorCoordinateNotFoundError( + Kind.DIRECTIVE, + 'arguments', + change.meta.removedArgumentName, + ), config, ); } else if (directiveNode.kind === Kind.DIRECTIVE) { diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 9cf04c7ad0..e64d347c0a 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -89,7 +89,11 @@ export function directiveArgumentAdded( if (!directiveNode) { handleError( change, - new AddedAttributeCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + new AddedAttributeCoordinateNotFoundError( + change.meta.directiveName, + 'arguments', + change.meta.addedDirectiveArgumentName, + ), config, ); } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { @@ -347,7 +351,7 @@ export function directiveArgumentDescriptionChanged( if (!argumentNode) { handleError( change, - new AddedAttributeCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'description'), + new ChangedAncestorCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'description'), config, ); } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index aa25095037..ebb2f36e14 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -169,7 +169,11 @@ export function enumValueDeprecationReasonAdded( } else { handleError( change, - new AddedAttributeCoordinateNotFoundError(Kind.ENUM_VALUE_DEFINITION, 'directives'), + new AddedAttributeCoordinateNotFoundError( + change.meta.enumValueName, + 'directives', + '@deprecated', + ), config, ); } diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 023b2f380b..cbf44f752a 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -173,7 +173,11 @@ export function fieldArgumentAdded( if (!fieldNode) { handleError( change, - new AddedAttributeCoordinateNotFoundError(Kind.FIELD_DEFINITION, 'arguments'), + new AddedAttributeCoordinateNotFoundError( + change.meta.fieldName, + 'arguments', + change.meta.addedArgumentName, + ), config, ); } else if (fieldNode.kind === Kind.FIELD_DEFINITION) { diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index 3dae6bfe4e..209a565bbc 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -58,7 +58,11 @@ export function inputFieldAdded( if (!typeNode) { handleError( change, - new AddedAttributeCoordinateNotFoundError(Kind.INPUT_OBJECT_TYPE_DEFINITION, 'fields'), + new AddedAttributeCoordinateNotFoundError( + change.meta.inputName, + 'fields', + change.meta.addedInputFieldName, + ), config, ); } else if (typeNode.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 2f4d97b43d..4527348d78 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -9,13 +9,13 @@ import { import { Maybe } from 'graphql/jsutils/Maybe'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - AttributeName, ChangedCoordinateKindMismatchError, ChangedCoordinateNotFoundError, ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedCoordinateNotFound, handleError, + NodeAttribute, ValueMismatchError, } from './errors.js'; import { AdditionChangeType, PatchConfig } from './types.js'; @@ -103,8 +103,8 @@ export function debugPrintChange(change: Change, nodeByPath: Map, +export function assertChangeHasPath>( + change: C, config: PatchConfig, ): change is typeof change & { path: string } { if (!change.path) { @@ -180,7 +180,7 @@ export function getDeletedParentNodeOfKind( change: Change, nodeByPath: Map, kind: K, - attributeName: AttributeName, + attribute: NodeAttribute, config: PatchConfig, ): ASTKindToNode[K] | void { if (assertChangeHasPath(change, config)) { @@ -189,7 +189,7 @@ export function getDeletedParentNodeOfKind( handleError( change, // @todo improve the error by providing the name or value somehow. - new DeletedAncestorCoordinateNotFoundError(kind, attributeName, undefined), + new DeletedAncestorCoordinateNotFoundError(kind, attribute, undefined), config, ); } else if (existing.kind === kind) { From 0cf0b1144a6d029b155f99517911fd44cbbaa1d6 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:54:03 -0700 Subject: [PATCH 22/23] More error fixes --- packages/patch/src/patches/enum.ts | 6 +- packages/patch/src/patches/fields.ts | 6 +- packages/patch/src/patches/inputs.ts | 107 ++++++++++------------- packages/patch/src/patches/interfaces.ts | 22 ++++- packages/patch/src/patches/types.ts | 13 ++- 5 files changed, 89 insertions(+), 65 deletions(-) diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index ebb2f36e14..8284650395 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -157,7 +157,11 @@ export function enumValueDeprecationReasonAdded( ]; nodeByPath.set(`${change.path}.reason`, argNode); } else { - handleError(change, new ChangePathMissingError(change), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + config, + ); } } else { handleError( diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index cbf44f752a..c3a34da48f 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -460,7 +460,11 @@ export function fieldDescriptionRemoved( ); } } else { - handleError(change, new ChangePathMissingError(change), config); + handleError( + change, + new DeletedCoordinateNotFound(Kind.FIELD_DEFINITION, change.meta.fieldName), + config, + ); } } diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index 209a565bbc..3a309924df 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -13,15 +13,22 @@ import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeCoordinateNotFoundError, AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, ChangedCoordinateKindMismatchError, ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, handleError, ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types.js'; -import { assertValueMatch, getChangedNodeOfKind, parentPath } from '../utils.js'; +import { + assertValueMatch, + getChangedNodeOfKind, + getDeletedNodeOfKind, + parentPath, +} from '../utils.js'; export function inputFieldAdded( change: Change, @@ -127,7 +134,14 @@ export function inputFieldRemoved( ); } } else { - handleError(change, new ChangePathMissingError(change), config); + handleError( + change, + new DeletedCoordinateNotFound( + Kind.INPUT_OBJECT_TYPE_DEFINITION, + change.meta.removedFieldName, + ), + config, + ); } } @@ -227,7 +241,11 @@ export function inputFieldDefaultValueChanged( ); } } else { - handleError(change, new ChangePathMissingError(change), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'defaultValue'), + config, + ); } } @@ -236,36 +254,27 @@ export function inputFieldDescriptionChanged( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); - return; - } - const existingNode = nodeByPath.get(change.path); + const existingNode = getChangedNodeOfKind( + change, + nodeByPath, + Kind.INPUT_VALUE_DEFINITION, + config, + ); if (existingNode) { - if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { - if (existingNode.description?.value !== change.meta.oldInputFieldDescription) { - handleError( - change, - new ValueMismatchError( - Kind.STRING, - change.meta.oldInputFieldDescription, - existingNode.description?.value, - ), - config, - ); - } - (existingNode.description as StringValueNode | undefined) = stringNode( - change.meta.newInputFieldDescription, - ); - } else { + if (existingNode.description?.value !== change.meta.oldInputFieldDescription) { handleError( change, - new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + new ValueMismatchError( + Kind.STRING, + change.meta.oldInputFieldDescription, + existingNode.description?.value, + ), config, ); } - } else { - handleError(change, new ChangePathMissingError(change), config); + (existingNode.description as StringValueNode | undefined) = stringNode( + change.meta.newInputFieldDescription, + ); } } @@ -274,40 +283,20 @@ export function inputFieldDescriptionRemoved( nodeByPath: Map, config: PatchConfig, ) { - if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); - return; - } - - const existingNode = nodeByPath.get(change.path); + const existingNode = getDeletedNodeOfKind( + change, + nodeByPath, + Kind.INPUT_VALUE_DEFINITION, + config, + ); if (existingNode) { - if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { - if (existingNode.description === undefined) { - console.warn( - `Cannot remove a description at ${change.path} because no description is set.`, - ); - } else if (existingNode.description.value !== change.meta.removedDescription) { - console.warn( - `Description at ${change.path} does not match expected description, but proceeding with description removal anyways.`, - ); - } - (existingNode.description as StringValueNode | undefined) = undefined; - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), - config, + if (existingNode.description === undefined) { + console.warn(`Cannot remove a description at ${change.path} because no description is set.`); + } else if (existingNode.description.value !== change.meta.removedDescription) { + console.warn( + `Description at ${change.path} does not match expected description, but proceeding with description removal anyways.`, ); } - } else { - handleError( - change, - new DeletedAncestorCoordinateNotFoundError( - Kind.INPUT_VALUE_DEFINITION, - 'description', - change.meta.removedDescription, - ), - config, - ); + (existingNode.description as StringValueNode | undefined) = undefined; } } diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index 58e16c5b15..8c765595f7 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -5,6 +5,8 @@ import { ChangedAncestorCoordinateNotFoundError, ChangedCoordinateKindMismatchError, ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, handleError, } from '../errors.js'; import { namedTypeNode } from '../node-templates.js'; @@ -85,7 +87,15 @@ export function objectTypeInterfaceRemoved( i => i.name.value !== change.meta.removedInterfaceName, ); } else { - handleError(change, new ChangePathMissingError(change), config); + // @note this error isnt the best designed for this application + handleError( + change, + new DeletedCoordinateNotFound( + Kind.INTERFACE_TYPE_DEFINITION, + change.meta.removedInterfaceName, + ), + config, + ); } } else { handleError( @@ -95,6 +105,14 @@ export function objectTypeInterfaceRemoved( ); } } else { - handleError(change, new ChangePathMissingError(change), config); + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.INPUT_OBJECT_TYPE_DEFINITION, + 'interfaces', + change.meta.removedInterfaceName, + ), + config, + ); } } diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index 7c8f922545..e564d86b46 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -2,6 +2,7 @@ import { ASTNode, isTypeDefinitionNode, Kind, StringValueNode, TypeDefinitionNod import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, ChangedCoordinateKindMismatchError, ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, @@ -97,7 +98,11 @@ export function typeDescriptionAdded( ); } } else { - handleError(change, new ChangePathMissingError(change), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), + config, + ); } } @@ -136,7 +141,11 @@ export function typeDescriptionChanged( ); } } else { - handleError(change, new ChangePathMissingError(change), config); + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), + config, + ); } } From 4d9bd95288f2a904bbfa75b55350adf44c49005d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 26 Aug 2025 11:10:01 -0700 Subject: [PATCH 23/23] Export lower level methods --- packages/patch/src/index.ts | 193 ++++++++++++++++++++---------------- 1 file changed, 106 insertions(+), 87 deletions(-) diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 93f1f03951..965bbc4522 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -102,6 +102,9 @@ import { debugPrintChange } from './utils.js'; export * as errors from './errors.js'; +/** + * Wraps converting a schema to AST safely, patching, then rebuilding the schema from AST. + */ export function patchSchema( schema: GraphQLSchema, changes: Change[], @@ -112,9 +115,13 @@ export function patchSchema( return buildASTSchema(patchedAst, { assumeValid: true, assumeValidSDL: true }); } -function groupNodesByPath(ast: DocumentNode): [SchemaNode[], Map] { +/** + * Extracts all the root definitions from a DocumentNode and creates a mapping of their coordinate + * to the defined ASTNode. E.g. A field's coordinate is "Type.field". + */ +export function groupByCoordinateAST(ast: DocumentNode): [SchemaNode[], Map] { const schemaNodes: SchemaNode[] = []; - const nodeByPath = new Map(); + const nodesByCoordinate = new Map(); const pathArray: string[] = []; visit(ast, { enter(node) { @@ -138,13 +145,13 @@ function groupNodesByPath(ast: DocumentNode): [SchemaNode[], Map, changes: Change[], patchConfig?: PatchConfig, ): DocumentNode { const config: PatchConfig = patchConfig ?? {}; - const [schemaDefs, nodeByPath] = groupNodesByPath(ast); - for (const change of changes) { if (config.debug) { - debugPrintChange(change, nodeByPath); + debugPrintChange(change, nodesByCoordinate); } switch (change.type) { case ChangeType.SchemaMutationTypeChanged: { - schemaMutationTypeChanged(change, schemaDefs, config); + schemaMutationTypeChanged(change, schemaNodes, config); break; } case ChangeType.SchemaQueryTypeChanged: { - schemaQueryTypeChanged(change, schemaDefs, config); + schemaQueryTypeChanged(change, schemaNodes, config); break; } case ChangeType.SchemaSubscriptionTypeChanged: { - schemaSubscriptionTypeChanged(change, schemaDefs, config); + schemaSubscriptionTypeChanged(change, schemaNodes, config); break; } case ChangeType.DirectiveAdded: { - directiveAdded(change, nodeByPath, config); + directiveAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveRemoved: { - directiveRemoved(change, nodeByPath, config); + directiveRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveArgumentAdded: { - directiveArgumentAdded(change, nodeByPath, config); + directiveArgumentAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveArgumentRemoved: { - directiveArgumentRemoved(change, nodeByPath, config); + directiveArgumentRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveLocationAdded: { - directiveLocationAdded(change, nodeByPath, config); + directiveLocationAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveLocationRemoved: { - directiveLocationRemoved(change, nodeByPath, config); + directiveLocationRemoved(change, nodesByCoordinate, config); break; } case ChangeType.EnumValueAdded: { - enumValueAdded(change, nodeByPath, config); + enumValueAdded(change, nodesByCoordinate, config); break; } case ChangeType.EnumValueDeprecationReasonAdded: { - enumValueDeprecationReasonAdded(change, nodeByPath, config); + enumValueDeprecationReasonAdded(change, nodesByCoordinate, config); break; } case ChangeType.EnumValueDeprecationReasonChanged: { - enumValueDeprecationReasonChanged(change, nodeByPath, config); + enumValueDeprecationReasonChanged(change, nodesByCoordinate, config); break; } case ChangeType.FieldAdded: { - fieldAdded(change, nodeByPath, config); + fieldAdded(change, nodesByCoordinate, config); break; } case ChangeType.FieldRemoved: { - fieldRemoved(change, nodeByPath, config); + fieldRemoved(change, nodesByCoordinate, config); break; } case ChangeType.FieldTypeChanged: { - fieldTypeChanged(change, nodeByPath, config); + fieldTypeChanged(change, nodesByCoordinate, config); break; } case ChangeType.FieldArgumentAdded: { - fieldArgumentAdded(change, nodeByPath, config); + fieldArgumentAdded(change, nodesByCoordinate, config); break; } case ChangeType.FieldArgumentTypeChanged: { - fieldArgumentTypeChanged(change, nodeByPath, config); + fieldArgumentTypeChanged(change, nodesByCoordinate, config); break; } case ChangeType.FieldArgumentRemoved: { - fieldArgumentRemoved(change, nodeByPath, config); + fieldArgumentRemoved(change, nodesByCoordinate, config); break; } case ChangeType.FieldArgumentDescriptionChanged: { - fieldArgumentDescriptionChanged(change, nodeByPath, config); + fieldArgumentDescriptionChanged(change, nodesByCoordinate, config); break; } case ChangeType.FieldArgumentDefaultChanged: { - fieldArgumentDefaultChanged(change, nodeByPath, config); + fieldArgumentDefaultChanged(change, nodesByCoordinate, config); break; } case ChangeType.FieldDeprecationAdded: { - fieldDeprecationAdded(change, nodeByPath, config); + fieldDeprecationAdded(change, nodesByCoordinate, config); break; } case ChangeType.FieldDeprecationRemoved: { - fieldDeprecationRemoved(change, nodeByPath, config); + fieldDeprecationRemoved(change, nodesByCoordinate, config); break; } case ChangeType.FieldDeprecationReasonAdded: { - fieldDeprecationReasonAdded(change, nodeByPath, config); + fieldDeprecationReasonAdded(change, nodesByCoordinate, config); break; } case ChangeType.FieldDeprecationReasonChanged: { - fieldDeprecationReasonChanged(change, nodeByPath, config); + fieldDeprecationReasonChanged(change, nodesByCoordinate, config); break; } case ChangeType.FieldDescriptionAdded: { - fieldDescriptionAdded(change, nodeByPath, config); + fieldDescriptionAdded(change, nodesByCoordinate, config); break; } case ChangeType.FieldDescriptionChanged: { - fieldDescriptionChanged(change, nodeByPath, config); + fieldDescriptionChanged(change, nodesByCoordinate, config); break; } case ChangeType.InputFieldAdded: { - inputFieldAdded(change, nodeByPath, config); + inputFieldAdded(change, nodesByCoordinate, config); break; } case ChangeType.InputFieldRemoved: { - inputFieldRemoved(change, nodeByPath, config); + inputFieldRemoved(change, nodesByCoordinate, config); break; } case ChangeType.InputFieldDescriptionAdded: { - inputFieldDescriptionAdded(change, nodeByPath, config); + inputFieldDescriptionAdded(change, nodesByCoordinate, config); break; } case ChangeType.InputFieldTypeChanged: { - inputFieldTypeChanged(change, nodeByPath, config); + inputFieldTypeChanged(change, nodesByCoordinate, config); break; } case ChangeType.InputFieldDescriptionChanged: { - inputFieldDescriptionChanged(change, nodeByPath, config); + inputFieldDescriptionChanged(change, nodesByCoordinate, config); break; } case ChangeType.InputFieldDescriptionRemoved: { - inputFieldDescriptionRemoved(change, nodeByPath, config); + inputFieldDescriptionRemoved(change, nodesByCoordinate, config); break; } case ChangeType.InputFieldDefaultValueChanged: { - inputFieldDefaultValueChanged(change, nodeByPath, config); + inputFieldDefaultValueChanged(change, nodesByCoordinate, config); break; } case ChangeType.ObjectTypeInterfaceAdded: { - objectTypeInterfaceAdded(change, nodeByPath, config); + objectTypeInterfaceAdded(change, nodesByCoordinate, config); break; } case ChangeType.ObjectTypeInterfaceRemoved: { - objectTypeInterfaceRemoved(change, nodeByPath, config); + objectTypeInterfaceRemoved(change, nodesByCoordinate, config); break; } case ChangeType.TypeDescriptionAdded: { - typeDescriptionAdded(change, nodeByPath, config); + typeDescriptionAdded(change, nodesByCoordinate, config); break; } case ChangeType.TypeDescriptionChanged: { - typeDescriptionChanged(change, nodeByPath, config); + typeDescriptionChanged(change, nodesByCoordinate, config); break; } case ChangeType.TypeDescriptionRemoved: { - typeDescriptionRemoved(change, nodeByPath, config); + typeDescriptionRemoved(change, nodesByCoordinate, config); break; } case ChangeType.TypeAdded: { - typeAdded(change, nodeByPath, config); + typeAdded(change, nodesByCoordinate, config); break; } case ChangeType.UnionMemberAdded: { - unionMemberAdded(change, nodeByPath, config); + unionMemberAdded(change, nodesByCoordinate, config); break; } case ChangeType.UnionMemberRemoved: { - unionMemberRemoved(change, nodeByPath, config); + unionMemberRemoved(change, nodesByCoordinate, config); break; } case ChangeType.TypeRemoved: { - typeRemoved(change, nodeByPath, config); + typeRemoved(change, nodesByCoordinate, config); break; } case ChangeType.EnumValueRemoved: { - enumValueRemoved(change, nodeByPath, config); + enumValueRemoved(change, nodesByCoordinate, config); break; } case ChangeType.EnumValueDescriptionChanged: { - enumValueDescriptionChanged(change, nodeByPath, config); + enumValueDescriptionChanged(change, nodesByCoordinate, config); break; } case ChangeType.FieldDescriptionRemoved: { - fieldDescriptionRemoved(change, nodeByPath, config); + fieldDescriptionRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveArgumentDefaultValueChanged: { - directiveArgumentDefaultValueChanged(change, nodeByPath, config); + directiveArgumentDefaultValueChanged(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveArgumentDescriptionChanged: { - directiveArgumentDescriptionChanged(change, nodeByPath, config); + directiveArgumentDescriptionChanged(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveArgumentTypeChanged: { - directiveArgumentTypeChanged(change, nodeByPath, config); + directiveArgumentTypeChanged(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveDescriptionChanged: { - directiveDescriptionChanged(change, nodeByPath, config); + directiveDescriptionChanged(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageArgumentDefinitionAdded: { - directiveUsageArgumentDefinitionAdded(change, nodeByPath, config); + directiveUsageArgumentDefinitionAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageArgumentDefinitionRemoved: { - directiveUsageArgumentDefinitionRemoved(change, nodeByPath, config); + directiveUsageArgumentDefinitionRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageEnumAdded: { - directiveUsageEnumAdded(change, nodeByPath, config); + directiveUsageEnumAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageEnumRemoved: { - directiveUsageEnumRemoved(change, nodeByPath, config); + directiveUsageEnumRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageEnumValueAdded: { - directiveUsageEnumValueAdded(change, nodeByPath, config); + directiveUsageEnumValueAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageEnumValueRemoved: { - directiveUsageEnumValueRemoved(change, nodeByPath, config); + directiveUsageEnumValueRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageFieldAdded: { - directiveUsageFieldAdded(change, nodeByPath, config); + directiveUsageFieldAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageFieldDefinitionAdded: { - directiveUsageFieldDefinitionAdded(change, nodeByPath, config); + directiveUsageFieldDefinitionAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageFieldDefinitionRemoved: { - directiveUsageFieldDefinitionRemoved(change, nodeByPath, config); + directiveUsageFieldDefinitionRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageFieldRemoved: { - directiveUsageFieldRemoved(change, nodeByPath, config); + directiveUsageFieldRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageInputFieldDefinitionAdded: { - directiveUsageInputFieldDefinitionAdded(change, nodeByPath, config); + directiveUsageInputFieldDefinitionAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageInputFieldDefinitionRemoved: { - directiveUsageInputFieldDefinitionRemoved(change, nodeByPath, config); + directiveUsageInputFieldDefinitionRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageInputObjectAdded: { - directiveUsageInputObjectAdded(change, nodeByPath, config); + directiveUsageInputObjectAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageInputObjectRemoved: { - directiveUsageInputObjectRemoved(change, nodeByPath, config); + directiveUsageInputObjectRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageInterfaceAdded: { - directiveUsageInterfaceAdded(change, nodeByPath, config); + directiveUsageInterfaceAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageInterfaceRemoved: { - directiveUsageInterfaceRemoved(change, nodeByPath, config); + directiveUsageInterfaceRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageObjectAdded: { - directiveUsageObjectAdded(change, nodeByPath, config); + directiveUsageObjectAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageObjectRemoved: { - directiveUsageObjectRemoved(change, nodeByPath, config); + directiveUsageObjectRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageScalarAdded: { - directiveUsageScalarAdded(change, nodeByPath, config); + directiveUsageScalarAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageScalarRemoved: { - directiveUsageScalarRemoved(change, nodeByPath, config); + directiveUsageScalarRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageSchemaAdded: { - directiveUsageSchemaAdded(change, schemaDefs, nodeByPath, config); + directiveUsageSchemaAdded(change, schemaNodes, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageSchemaRemoved: { - directiveUsageSchemaRemoved(change, schemaDefs, nodeByPath, config); + directiveUsageSchemaRemoved(change, schemaNodes, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageUnionMemberAdded: { - directiveUsageUnionMemberAdded(change, nodeByPath, config); + directiveUsageUnionMemberAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageUnionMemberRemoved: { - directiveUsageUnionMemberRemoved(change, nodeByPath, config); + directiveUsageUnionMemberRemoved(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageArgumentAdded: { - directiveUsageArgumentAdded(change, nodeByPath, config); + directiveUsageArgumentAdded(change, nodesByCoordinate, config); break; } case ChangeType.DirectiveUsageArgumentRemoved: { - directiveUsageArgumentRemoved(change, nodeByPath, config); + directiveUsageArgumentRemoved(change, nodesByCoordinate, config); break; } default: { @@ -532,6 +538,19 @@ export function patch( return { kind: Kind.DOCUMENT, // filter out the non-definition nodes (e.g. field definitions) - definitions: [...schemaDefs, ...Array.from(nodeByPath.values()).filter(isDefinitionNode)], + definitions: [ + ...schemaNodes, + ...Array.from(nodesByCoordinate.values()).filter(isDefinitionNode), + ], }; } + +/** This method wraps groupByCoordinateAST and patchCoordinatesAST for convenience. */ +export function patch( + ast: DocumentNode, + changes: Change[], + patchConfig?: PatchConfig, +): DocumentNode { + const [schemaNodes, nodesByCoordinate] = groupByCoordinateAST(ast); + return patchCoordinatesAST(schemaNodes, nodesByCoordinate, changes, patchConfig); +}