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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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/73] 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); +} From 6b83adb538e41fd0eb36fd1b896c7ce01489552b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 1 Sep 2025 12:56:17 -0700 Subject: [PATCH 24/73] FieldAdded.addedFieldReturnType --- .../__tests__/diff/rules/ignore-nested-additions.test.ts | 6 ++++++ packages/core/src/diff/changes/field.ts | 3 +-- 2 files changed, 7 insertions(+), 2 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 dfb94e9cca..2e811883d8 100644 --- a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts +++ b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts @@ -95,6 +95,12 @@ describe('ignoreNestedAdditions rule', () => { const added = findFirstChangeByPath(changes, 'Foo.b'); expect(added.type).toBe(ChangeType.FieldAdded); + expect(added.meta).toEqual({ + addedFieldName: 'b', + addedFieldReturnType: 'String!', + typeName: 'Foo', + typeType: 'object type', + }); }); test('added type / directive / directive argument on new union', async () => { diff --git a/packages/core/src/diff/changes/field.ts b/packages/core/src/diff/changes/field.ts index 966462aef2..ce48db3360 100644 --- a/packages/core/src/diff/changes/field.ts +++ b/packages/core/src/diff/changes/field.ts @@ -6,7 +6,6 @@ import { GraphQLObjectType, isInterfaceType, isNonNullType, - print, } from 'graphql'; import { safeChangeForField } from '../../utils/graphql.js'; import { @@ -92,7 +91,7 @@ export function fieldAdded( typeName: type.name, addedFieldName: field.name, typeType: entity, - addedFieldReturnType: field.astNode?.type ? print(field.astNode?.type) : '', + addedFieldReturnType: field.type.toString(), }, }); } From 6f2756b8efe84f9db350cdaac974babbfe7fdadd Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 1 Sep 2025 14:45:15 -0700 Subject: [PATCH 25/73] Fix operation type changes --- packages/core/__tests__/diff/object.test.ts | 2 +- packages/core/__tests__/diff/schema.test.ts | 46 ++++++++++-- packages/core/src/diff/schema.ts | 23 ++---- packages/patch/src/__tests__/types.test.ts | 18 +++++ packages/patch/src/errors.ts | 2 +- packages/patch/src/patches/schema.ts | 81 ++++++++++++++++----- 6 files changed, 131 insertions(+), 41 deletions(-) diff --git a/packages/core/__tests__/diff/object.test.ts b/packages/core/__tests__/diff/object.test.ts index 6e15624e6b..a8c0c51498 100644 --- a/packages/core/__tests__/diff/object.test.ts +++ b/packages/core/__tests__/diff/object.test.ts @@ -32,7 +32,7 @@ describe('object', () => { `); const changes = await diff(a, b); - expect(changes).toHaveLength(4); + expect(changes).toHaveLength(5); const change = findFirstChangeByPath(changes, 'B'); const mutation = findFirstChangeByPath(changes, 'Mutation'); diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index 7f18916dc9..593bf63688 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -829,9 +829,45 @@ test('adding root type should not be breaking', async () => { `); const changes = await diff(schemaA, schemaB); - expect(changes).toHaveLength(2); - - const subscription = findFirstChangeByPath(changes, 'Subscription'); - expect(subscription).toBeDefined(); - expect(subscription!.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "BREAKING", + }, + "message": "Schema subscription root has changed from 'unknown' to 'Subscription'", + "meta": { + "newSubscriptionTypeName": "Subscription", + "oldSubscriptionTypeName": "unknown", + }, + "type": "SCHEMA_SUBSCRIPTION_TYPE_CHANGED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Type 'Subscription' was added", + "meta": { + "addedTypeKind": "ObjectTypeDefinition", + "addedTypeName": "Subscription", + }, + "path": "Subscription", + "type": "TYPE_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'onFoo' was added to object type 'Subscription'", + "meta": { + "addedFieldName": "onFoo", + "addedFieldReturnType": "String", + "typeName": "Subscription", + "typeType": "object type", + }, + "path": "Subscription.onFoo", + "type": "FIELD_ADDED", + }, + ] + `); }); diff --git a/packages/core/src/diff/schema.ts b/packages/core/src/diff/schema.ts index bf6795ee0b..39a1105f02 100644 --- a/packages/core/src/diff/schema.ts +++ b/packages/core/src/diff/schema.ts @@ -98,26 +98,15 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): } function changesInSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema, addChange: AddChange) { - const defaultNames = { - query: 'Query', - mutation: 'Mutation', - subscription: 'Subscription', - }; const oldRoot = { - query: (oldSchema.getQueryType() || ({} as GraphQLObjectType)).name ?? defaultNames.query, - mutation: - (oldSchema.getMutationType() || ({} as GraphQLObjectType)).name ?? defaultNames.mutation, - subscription: - (oldSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name ?? - defaultNames.subscription, + query: (oldSchema.getQueryType() || ({} as GraphQLObjectType)).name, + mutation: (oldSchema.getMutationType() || ({} as GraphQLObjectType)).name, + subscription: (oldSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name, }; const newRoot = { - query: (newSchema.getQueryType() || ({} as GraphQLObjectType)).name ?? defaultNames.query, - mutation: - (newSchema.getMutationType() || ({} as GraphQLObjectType)).name ?? defaultNames.mutation, - subscription: - (newSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name ?? - defaultNames.subscription, + query: (newSchema.getQueryType() || ({} as GraphQLObjectType)).name, + mutation: (newSchema.getMutationType() || ({} as GraphQLObjectType)).name, + subscription: (newSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name, }; if (isNotEqual(oldRoot.query, newRoot.query)) { diff --git a/packages/patch/src/__tests__/types.test.ts b/packages/patch/src/__tests__/types.test.ts index 375cf55e08..a65bfab7c4 100644 --- a/packages/patch/src/__tests__/types.test.ts +++ b/packages/patch/src/__tests__/types.test.ts @@ -31,6 +31,24 @@ describe('enum', () => { await expectPatchToMatch(before, after); }); + test('typeAdded Mutation', async () => { + const before = /* GraphQL */ ` + type Query { + foo: String + } + `; + const after = /* GraphQL */ ` + type Query { + foo: String + } + + type Mutation { + dooFoo: String + } + `; + await expectPatchToMatch(before, after); + }); + test('typeDescriptionChanged: Added', async () => { const before = /* GraphQL */ ` enum Status { diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index 94c613c54e..c97c8fa723 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -154,7 +154,7 @@ export class DeletedAttributeNotFoundError extends NoopError { export class ChangedCoordinateNotFoundError extends Error { constructor(expectedKind: Kind, expectedNameOrValue: string | undefined) { super( - `The "${expectedKind}" ${expectedNameOrValue ? `"${expectedNameOrValue}"` : ''}does not exist.`, + `The "${expectedKind}" ${expectedNameOrValue ? `"${expectedNameOrValue}" ` : ''}does not exist.`, ); } } diff --git a/packages/patch/src/patches/schema.ts b/packages/patch/src/patches/schema.ts index be7806626f..4a36f0014f 100644 --- a/packages/patch/src/patches/schema.ts +++ b/packages/patch/src/patches/schema.ts @@ -1,7 +1,7 @@ /* eslint-disable unicorn/no-negated-condition */ -import { Kind, NameNode, OperationTypeNode } from 'graphql'; +import { Kind, NameNode, OperationTypeDefinitionNode, OperationTypeNode } from 'graphql'; import type { Change, ChangeType } from '@graphql-inspector/core'; -import { ChangedCoordinateNotFoundError, handleError, ValueMismatchError } from '../errors.js'; +import { handleError, ValueMismatchError } from '../errors.js'; import { nameNode } from '../node-templates.js'; import { PatchConfig, SchemaNode } from '../types.js'; @@ -15,11 +15,28 @@ export function schemaMutationTypeChanged( ({ operation }) => operation === OperationTypeNode.MUTATION, ); if (!mutation) { - handleError( - change, - new ChangedCoordinateNotFoundError(Kind.SCHEMA_DEFINITION, 'mutation'), - config, - ); + if (change.meta.oldMutationTypeName !== 'unknown') { + handleError( + change, + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldMutationTypeName, + 'unknown', + ), + config, + ); + } + (schemaNode.operationTypes as OperationTypeDefinitionNode[]) = [ + ...(schemaNode.operationTypes ?? []), + { + kind: Kind.OPERATION_TYPE_DEFINITION, + operation: OperationTypeNode.MUTATION, + type: { + kind: Kind.NAMED_TYPE, + name: nameNode(change.meta.newMutationTypeName), + }, + }, + ]; } else { if (mutation.type.name.value !== change.meta.oldMutationTypeName) { handleError( @@ -47,11 +64,24 @@ export function schemaQueryTypeChanged( ({ operation }) => operation === OperationTypeNode.MUTATION, ); if (!query) { - handleError( - change, - new ChangedCoordinateNotFoundError(Kind.SCHEMA_DEFINITION, 'query'), - config, - ); + if (change.meta.oldQueryTypeName !== 'unknown') { + handleError( + change, + new ValueMismatchError(Kind.SCHEMA_DEFINITION, change.meta.oldQueryTypeName, 'unknown'), + config, + ); + } + (schemaNode.operationTypes as OperationTypeDefinitionNode[]) = [ + ...(schemaNode.operationTypes ?? []), + { + kind: Kind.OPERATION_TYPE_DEFINITION, + operation: OperationTypeNode.QUERY, + type: { + kind: Kind.NAMED_TYPE, + name: nameNode(change.meta.newQueryTypeName), + }, + }, + ]; } else { if (query.type.name.value !== change.meta.oldQueryTypeName) { handleError( @@ -79,11 +109,28 @@ export function schemaSubscriptionTypeChanged( ({ operation }) => operation === OperationTypeNode.SUBSCRIPTION, ); if (!sub) { - handleError( - change, - new ChangedCoordinateNotFoundError(Kind.SCHEMA_DEFINITION, 'subscription'), - config, - ); + if (change.meta.oldSubscriptionTypeName !== 'unknown') { + handleError( + change, + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldSubscriptionTypeName, + 'unknown', + ), + config, + ); + } + (schemaNode.operationTypes as OperationTypeDefinitionNode[]) = [ + ...(schemaNode.operationTypes ?? []), + { + kind: Kind.OPERATION_TYPE_DEFINITION, + operation: OperationTypeNode.QUERY, + type: { + kind: Kind.NAMED_TYPE, + name: nameNode(change.meta.newSubscriptionTypeName), + }, + }, + ]; } else { if (sub.type.name.value !== change.meta.oldSubscriptionTypeName) { handleError( From 83dfa4e791621376743b238f4ff098de3404fd19 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 1 Sep 2025 18:12:01 -0700 Subject: [PATCH 26/73] Consistency; adjust schema operation type logic --- packages/core/__tests__/diff/schema.test.ts | 2 +- .../core/src/diff/changes/directive-usage.ts | 10 +++++----- packages/core/src/diff/changes/schema.ts | 6 +++--- packages/core/src/diff/input.ts | 2 +- packages/core/src/index.ts | 2 ++ packages/core/src/utils/compare.ts | 6 +++--- packages/patch/src/__tests__/fields.test.ts | 14 ++++++++++++++ packages/patch/src/__tests__/inputs.test.ts | 17 +++++++++++++++++ 8 files changed, 46 insertions(+), 13 deletions(-) diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index 593bf63688..9a28fa4246 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -833,7 +833,7 @@ test('adding root type should not be breaking', async () => { [ { "criticality": { - "level": "BREAKING", + "level": "NON_BREAKING", }, "message": "Schema subscription root has changed from 'unknown' to 'Subscription'", "meta": { diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index cdeb609660..118b463a79 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -895,7 +895,7 @@ function isOfKind( return kind === expectedKind; } -export function directiveUsageArgumentAdded(args: DirectiveUsageArgumentAddedChange): Change { +export function directiveUsageArgumentAddedFromMeta(args: DirectiveUsageArgumentAddedChange): Change { return { type: ChangeType.DirectiveUsageArgumentAdded, criticality: { @@ -916,7 +916,7 @@ export function directiveUsageArgumentAdded(args: DirectiveUsageArgumentAddedCha }; } -export function directiveUsageArgumentRemoved(args: DirectiveUsageArgumentRemovedChange): Change { +export function directiveUsageArgumentRemovedFromMeta(args: DirectiveUsageArgumentRemovedChange): Change { return { type: ChangeType.DirectiveUsageArgumentRemoved, criticality: { @@ -949,7 +949,7 @@ export function directiveUsageChanged( compareLists(oldDirective?.arguments || [], newDirective.arguments || [], { onAdded(argument) { addChange( - directiveUsageArgumentAdded({ + directiveUsageArgumentAddedFromMeta({ type: ChangeType.DirectiveUsageArgumentAdded, meta: { addedArgumentName: argument.name.value, @@ -966,7 +966,7 @@ export function directiveUsageChanged( }, onMutual(argument) { - directiveUsageArgumentAdded({ + directiveUsageArgumentAddedFromMeta({ type: ChangeType.DirectiveUsageArgumentAdded, meta: { addedArgumentName: argument.newVersion.name.value, @@ -984,7 +984,7 @@ export function directiveUsageChanged( onRemoved(argument) { addChange( - directiveUsageArgumentRemoved({ + directiveUsageArgumentRemovedFromMeta({ type: ChangeType.DirectiveUsageArgumentRemoved, meta: { removedArgumentName: argument.name.value, diff --git a/packages/core/src/diff/changes/schema.ts b/packages/core/src/diff/changes/schema.ts index 1793e50e16..067ad21538 100644 --- a/packages/core/src/diff/changes/schema.ts +++ b/packages/core/src/diff/changes/schema.ts @@ -16,7 +16,7 @@ export function schemaQueryTypeChangedFromMeta(args: SchemaQueryTypeChangedChang return { type: ChangeType.SchemaQueryTypeChanged, criticality: { - level: CriticalityLevel.Breaking, + level: args.meta.oldQueryTypeName === 'unknown' ? CriticalityLevel.NonBreaking : CriticalityLevel.Breaking, }, message: buildSchemaQueryTypeChangedMessage(args.meta), meta: args.meta, @@ -49,7 +49,7 @@ export function schemaMutationTypeChangedFromMeta(args: SchemaMutationTypeChange return { type: ChangeType.SchemaMutationTypeChanged, criticality: { - level: CriticalityLevel.Breaking, + level: args.meta.oldMutationTypeName === 'unknown' ? CriticalityLevel.NonBreaking : CriticalityLevel.Breaking, }, message: buildSchemaMutationTypeChangedMessage(args.meta), meta: args.meta, @@ -82,7 +82,7 @@ export function schemaSubscriptionTypeChangedFromMeta(args: SchemaSubscriptionTy return { type: ChangeType.SchemaSubscriptionTypeChanged, criticality: { - level: CriticalityLevel.Breaking, + level: args.meta.oldSubscriptionTypeName === 'unknown' ? CriticalityLevel.NonBreaking : CriticalityLevel.Breaking, }, message: buildSchemaSubscriptionTypeChangedMessage(args.meta), meta: args.meta, diff --git a/packages/core/src/diff/input.ts b/packages/core/src/diff/input.ts index 2ecc7a9766..cc48d6d71b 100644 --- a/packages/core/src/diff/input.ts +++ b/packages/core/src/diff/input.ts @@ -26,7 +26,7 @@ export function changesInInputObject( compareLists(Object.values(oldFields), Object.values(newFields), { onAdded(field) { - addChange(inputFieldAdded(newInput, field, oldInput === null)); + addChange(inputFieldAdded(newInput, field, oldInput == null)); changesInInputField(newInput, null, field, addChange); }, onRemoved(field) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d0f7426768..ae757b99fa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,8 @@ export { directiveUsageSchemaRemovedFromMeta, directiveUsageUnionMemberAddedFromMeta, directiveUsageUnionMemberRemovedFromMeta, + directiveUsageArgumentRemovedFromMeta, + directiveUsageArgumentAddedFromMeta } from './diff/changes/directive-usage.js'; export { directiveRemovedFromMeta, diff --git a/packages/core/src/utils/compare.ts b/packages/core/src/utils/compare.ts index b8d37c43d4..9dde8c19b0 100644 --- a/packages/core/src/utils/compare.ts +++ b/packages/core/src/utils/compare.ts @@ -78,13 +78,13 @@ export function compareLists( const mutual: Array<{ newVersion: T; oldVersion: T }> = []; for (const oldItem of oldList) { - const newItem = newMap[extractName(oldItem.name)]; - if (newItem === undefined) { + const newItem = newMap[extractName(oldItem.name)] ?? null; + if (newItem === null) { removed.push(oldItem); } else { mutual.push({ newVersion: newItem, - oldVersion: oldItem, + oldVersion: oldItem ?? null, }); } } diff --git a/packages/patch/src/__tests__/fields.test.ts b/packages/patch/src/__tests__/fields.test.ts index 0fc370a801..9c4e63568d 100644 --- a/packages/patch/src/__tests__/fields.test.ts +++ b/packages/patch/src/__tests__/fields.test.ts @@ -45,6 +45,20 @@ describe('fields', () => { await expectPatchToMatch(before, after); }); + test('fieldAdded to new type', async () => { + const before = /* GraphQL */ ` + scalar Foo + `; + const after = /* GraphQL */ ` + scalar Foo + type Product { + id: ID! + name: String + } + `; + await expectPatchToMatch(before, after); + }); + test('fieldArgumentAdded', 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 c7448d8b02..23c1a74722 100644 --- a/packages/patch/src/__tests__/inputs.test.ts +++ b/packages/patch/src/__tests__/inputs.test.ts @@ -16,6 +16,23 @@ describe('inputs', () => { await expectPatchToMatch(before, after); }); + test('inputFieldAdded to new input', async () => { + const before = /* GraphQL */ ` + scalar Foo + `; + const after = /* GraphQL */ ` + scalar Foo + input FooInput { + id: ID! + other: String + } + type Query { + foo(foo: FooInput): Foo + } + `; + const changes = await expectPatchToMatch(before, after); + }); + test('inputFieldRemoved', async () => { const before = /* GraphQL */ ` input FooInput { From 563b054bf9f86197ccc0a1111b9f670100728e0e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 3 Sep 2025 11:17:35 -0700 Subject: [PATCH 27/73] Export missing change types --- packages/core/src/diff/changes/directive-usage.ts | 8 ++++++-- packages/core/src/diff/changes/schema.ts | 15 ++++++++++++--- packages/core/src/index.ts | 4 +++- packages/patch/src/README.md | 1 + 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 118b463a79..2cf2b3fc43 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -895,7 +895,9 @@ function isOfKind( return kind === expectedKind; } -export function directiveUsageArgumentAddedFromMeta(args: DirectiveUsageArgumentAddedChange): Change { +export function directiveUsageArgumentAddedFromMeta( + args: DirectiveUsageArgumentAddedChange, +): Change { return { type: ChangeType.DirectiveUsageArgumentAdded, criticality: { @@ -916,7 +918,9 @@ export function directiveUsageArgumentAddedFromMeta(args: DirectiveUsageArgument }; } -export function directiveUsageArgumentRemovedFromMeta(args: DirectiveUsageArgumentRemovedChange): Change { +export function directiveUsageArgumentRemovedFromMeta( + args: DirectiveUsageArgumentRemovedChange, +): Change { return { type: ChangeType.DirectiveUsageArgumentRemoved, criticality: { diff --git a/packages/core/src/diff/changes/schema.ts b/packages/core/src/diff/changes/schema.ts index 067ad21538..ea349d384a 100644 --- a/packages/core/src/diff/changes/schema.ts +++ b/packages/core/src/diff/changes/schema.ts @@ -16,7 +16,10 @@ export function schemaQueryTypeChangedFromMeta(args: SchemaQueryTypeChangedChang return { type: ChangeType.SchemaQueryTypeChanged, criticality: { - level: args.meta.oldQueryTypeName === 'unknown' ? CriticalityLevel.NonBreaking : CriticalityLevel.Breaking, + level: + args.meta.oldQueryTypeName === 'unknown' + ? CriticalityLevel.NonBreaking + : CriticalityLevel.Breaking, }, message: buildSchemaQueryTypeChangedMessage(args.meta), meta: args.meta, @@ -49,7 +52,10 @@ export function schemaMutationTypeChangedFromMeta(args: SchemaMutationTypeChange return { type: ChangeType.SchemaMutationTypeChanged, criticality: { - level: args.meta.oldMutationTypeName === 'unknown' ? CriticalityLevel.NonBreaking : CriticalityLevel.Breaking, + level: + args.meta.oldMutationTypeName === 'unknown' + ? CriticalityLevel.NonBreaking + : CriticalityLevel.Breaking, }, message: buildSchemaMutationTypeChangedMessage(args.meta), meta: args.meta, @@ -82,7 +88,10 @@ export function schemaSubscriptionTypeChangedFromMeta(args: SchemaSubscriptionTy return { type: ChangeType.SchemaSubscriptionTypeChanged, criticality: { - level: args.meta.oldSubscriptionTypeName === 'unknown' ? CriticalityLevel.NonBreaking : CriticalityLevel.Breaking, + level: + args.meta.oldSubscriptionTypeName === 'unknown' + ? CriticalityLevel.NonBreaking + : CriticalityLevel.Breaking, }, message: buildSchemaSubscriptionTypeChangedMessage(args.meta), meta: args.meta, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ae757b99fa..0ab3138e2e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -57,7 +57,7 @@ export { directiveUsageUnionMemberAddedFromMeta, directiveUsageUnionMemberRemovedFromMeta, directiveUsageArgumentRemovedFromMeta, - directiveUsageArgumentAddedFromMeta + directiveUsageArgumentAddedFromMeta, } from './diff/changes/directive-usage.js'; export { directiveRemovedFromMeta, @@ -202,4 +202,6 @@ export { DirectiveUsageSchemaRemovedChange, DirectiveUsageUnionMemberAddedChange, DirectiveUsageUnionMemberRemovedChange, + DirectiveUsageArgumentAddedChange, + DirectiveUsageArgumentRemovedChange, } from './diff/changes/change.js'; diff --git a/packages/patch/src/README.md b/packages/patch/src/README.md index a19ea65079..cd755be047 100644 --- a/packages/patch/src/README.md +++ b/packages/patch/src/README.md @@ -35,3 +35,4 @@ expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patched)); - [] Support repeat directives - [] Support extensions +- [] Fully support schema operation types From f61c7166fc70473a526fd5cb36fd3de555768b54 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:03:02 -0700 Subject: [PATCH 28/73] Change path for deprecated directives; fix duplicating deprecated directives on path --- .../core/__tests__/diff/interface.test.ts | 15 +-- packages/core/__tests__/diff/object.test.ts | 10 +- packages/core/__tests__/diff/schema.test.ts | 2 +- packages/core/src/diff/changes/field.ts | 12 ++- .../src/__tests__/directive-usage.test.ts | 16 +++ .../patch/src/patches/directive-usages.ts | 9 +- packages/patch/src/patches/fields.ts | 101 +++++++++++------- packages/patch/src/utils.ts | 2 +- 8 files changed, 108 insertions(+), 59 deletions(-) diff --git a/packages/core/__tests__/diff/interface.test.ts b/packages/core/__tests__/diff/interface.test.ts index bb39e72b08..6acb602aff 100644 --- a/packages/core/__tests__/diff/interface.test.ts +++ b/packages/core/__tests__/diff/interface.test.ts @@ -168,9 +168,9 @@ describe('interface', () => { const changes = await diff(a, b); const change = { - a: findFirstChangeByPath(changes, 'Foo.a'), - b: findFirstChangeByPath(changes, 'Foo.b'), - c: findFirstChangeByPath(changes, 'Foo.c'), + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), + c: findFirstChangeByPath(changes, 'Foo.c.@deprecated'), }; // Changed @@ -205,8 +205,8 @@ describe('interface', () => { const changes = await diff(a, b); const change = { - a: findFirstChangeByPath(changes, 'Foo.a'), - b: findFirstChangeByPath(changes, 'Foo.b'), + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), }; // Changed @@ -234,8 +234,9 @@ describe('interface', () => { const changes = await diff(a, b); - expect(findChangesByPath(changes, 'Foo.a')).toHaveLength(1); - const change = findFirstChangeByPath(changes, 'Foo.a'); + // one for deprecation added, and one for the reason added + expect(findChangesByPath(changes, 'Foo.a.@deprecated')).toHaveLength(2); + const change = findFirstChangeByPath(changes, 'Foo.a.@deprecated'); // added expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); diff --git a/packages/core/__tests__/diff/object.test.ts b/packages/core/__tests__/diff/object.test.ts index a8c0c51498..794d85a6a7 100644 --- a/packages/core/__tests__/diff/object.test.ts +++ b/packages/core/__tests__/diff/object.test.ts @@ -329,9 +329,9 @@ describe('object', () => { const changes = await diff(a, b); const change = { - a: findFirstChangeByPath(changes, 'Foo.a'), - b: findFirstChangeByPath(changes, 'Foo.b'), - c: findFirstChangeByPath(changes, 'Foo.c'), + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), + c: findFirstChangeByPath(changes, 'Foo.c.@deprecated'), }; // Changed @@ -366,8 +366,8 @@ describe('object', () => { const changes = await diff(a, b); const change = { - a: findFirstChangeByPath(changes, 'Foo.a'), - b: findFirstChangeByPath(changes, 'Foo.b'), + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), }; // Changed diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index 9a28fa4246..a10e1d6537 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -359,7 +359,7 @@ test('huge test', async () => { 'CType', 'CType.b', 'CType.c', - 'CType.a', + 'CType.a.@deprecated', 'CType.a.arg', 'CType.d.arg', 'MyUnion', diff --git a/packages/core/src/diff/changes/field.ts b/packages/core/src/diff/changes/field.ts index ce48db3360..a7bbade323 100644 --- a/packages/core/src/diff/changes/field.ts +++ b/packages/core/src/diff/changes/field.ts @@ -199,7 +199,9 @@ export function fieldDeprecationAddedFromMeta(args: FieldDeprecationAddedChange) }, message: buildFieldDeprecatedAddedMessage(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; } @@ -226,7 +228,9 @@ export function fieldDeprecationRemovedFromMeta(args: FieldDeprecationRemovedCha }, message: `Field '${args.meta.typeName}.${args.meta.fieldName}' is no longer deprecated`, meta: args.meta, - path: [args.meta.typeName, args.meta.fieldName].join('.'), + path: [args.meta.typeName, args.meta.fieldName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), } as const; } @@ -257,7 +261,9 @@ export function fieldDeprecationReasonChangedFromMeta(args: FieldDeprecationReas }, message: buildFieldDeprecationReasonChangedMessage(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/patch/src/__tests__/directive-usage.test.ts b/packages/patch/src/__tests__/directive-usage.test.ts index 649a8ece59..25d76ec567 100644 --- a/packages/patch/src/__tests__/directive-usage.test.ts +++ b/packages/patch/src/__tests__/directive-usage.test.ts @@ -43,6 +43,22 @@ const baseSchema = /* GraphQL */ ` `; describe('directiveUsages: added', () => { + test('directiveUsageFieldDefinitionAdded: @deprecated', async () => { + const before = ` + type Foo { + new: String + old: String + } + `; + const after = /* GraphQL */ ` + type Foo { + new: String + old: String @deprecated(reason: "No good") + } + `; + await expectPatchToMatch(before, after); + }); + test('directiveUsageArgumentDefinitionAdded', async () => { const before = baseSchema; const after = /* GraphQL */ ` diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index 672b3157f9..d22991b6e2 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -61,7 +61,7 @@ function directiveUsageDefinitionAdded( const directiveNode = nodeByPath.get(change.path); const parentNode = nodeByPath.get(parentPath(change.path)) as - | { directives?: DirectiveNode[] } + | { kind: Kind; directives?: DirectiveNode[] } | undefined; if (directiveNode) { handleError( @@ -70,6 +70,13 @@ function directiveUsageDefinitionAdded( config, ); } else if (parentNode) { + if ( + change.meta.addedDirectiveName === 'deprecated' && + (parentNode.kind === Kind.FIELD_DEFINITION || parentNode.kind === Kind.ENUM_VALUE_DEFINITION) + ) { + return; // ignore because deprecated is handled by its own change... consider adjusting this. + } + const newDirective: DirectiveNode = { kind: Kind.DIRECTIVE, name: nameNode(change.meta.addedDirectiveName), diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index c3a34da48f..a6eaa416ce 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -36,6 +36,7 @@ import { findNamedNode, getChangedNodeOfKind, getDeletedNodeOfKind, + getDeletedParentNodeOfKind, getDeprecatedDirectiveNode, parentPath, } from '../utils.js'; @@ -370,38 +371,48 @@ export function fieldDeprecationAdded( nodeByPath: Map, config: PatchConfig, ) { - const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); - if (fieldNode) { - 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; + if (assertChangeHasPath(change, config)) { + const fieldNode = nodeByPath.get(parentPath(change.path)); + if (fieldNode) { + if (fieldNode.kind !== Kind.FIELD_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); + return; + } + 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); + (fieldNode.directives as DirectiveNode[] | undefined) = [ + ...(fieldNode.directives ?? []), + directiveNode, + ]; + nodeByPath.set(change.path, directiveNode); + } } } } @@ -411,16 +422,24 @@ export function fieldDeprecationRemoved( nodeByPath: Map, config: PatchConfig, ) { - const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); - if (fieldNode) { - 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); + if (assertChangeHasPath(change, config)) { + const fieldNode = getDeletedParentNodeOfKind( + change, + nodeByPath, + Kind.FIELD_DEFINITION, + 'directives', + config, + ); + if (fieldNode) { + const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); + if (hasExistingDeprecationDirective) { + (fieldNode.directives as DirectiveNode[] | undefined) = fieldNode.directives?.filter( + d => d.name.value !== GraphQLDeprecatedDirective.name, + ); + nodeByPath.delete(change.path); + } else { + handleError(change, new DeletedCoordinateNotFound(Kind.DIRECTIVE, '@deprecated'), config); + } } } } diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 4527348d78..201b87ac8a 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -23,7 +23,7 @@ import { AdditionChangeType, PatchConfig } 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( From 2a429936b69ad880311bc750d35e4f6ab7db3630 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:54:17 -0700 Subject: [PATCH 29/73] Update seven-jars-yell.md --- .changeset/seven-jars-yell.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/seven-jars-yell.md b/.changeset/seven-jars-yell.md index 3750809802..285ad49b5a 100644 --- a/.changeset/seven-jars-yell.md +++ b/.changeset/seven-jars-yell.md @@ -2,5 +2,8 @@ '@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. +Add "@graphql-inspector/patch" package. +"diff" includes all nested changes when a node is added. +Additional meta fields added for more accurate severity levels. +Implement more change types for directives. +Adjust path on numerous change types to consistently map to the AST node being changed. From b415723a0da6b1b49e953d6a54dfdd03bed92b21 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 29 Oct 2025 19:25:42 -0700 Subject: [PATCH 30/73] Repeatable directives are a nightmare --- .../__tests__/diff/directive-usage.test.ts | 219 ++++++++++++++++++ .../core/__tests__/diff/directive.test.ts | 54 +++++ packages/core/src/diff/argument.ts | 4 +- packages/core/src/diff/changes/change.ts | 44 ++++ .../core/src/diff/changes/directive-usage.ts | 91 ++++++++ packages/core/src/diff/changes/directive.ts | 56 +++++ packages/core/src/diff/directive.ts | 12 + packages/core/src/diff/enum.ts | 6 +- packages/core/src/diff/field.ts | 4 +- packages/core/src/diff/input.ts | 12 +- packages/core/src/diff/interface.ts | 42 ++-- packages/core/src/diff/object.ts | 4 +- packages/core/src/diff/scalar.ts | 4 +- packages/core/src/diff/schema.ts | 4 +- packages/core/src/diff/union.ts | 4 +- packages/core/src/utils/compare.ts | 95 ++++++++ .../patch/src/__tests__/directives.test.ts | 22 ++ packages/patch/src/index.ts | 23 +- .../patch/src/patches/directive-usages.ts | 124 ++++++++-- packages/patch/src/patches/directives.ts | 74 ++++++ packages/patch/src/patches/enum.ts | 13 +- packages/patch/src/patches/fields.ts | 39 ++-- packages/patch/src/utils.ts | 3 + 23 files changed, 867 insertions(+), 86 deletions(-) diff --git a/packages/core/__tests__/diff/directive-usage.test.ts b/packages/core/__tests__/diff/directive-usage.test.ts index 47016562cf..effd6652a0 100644 --- a/packages/core/__tests__/diff/directive-usage.test.ts +++ b/packages/core/__tests__/diff/directive-usage.test.ts @@ -3,6 +3,225 @@ import { CriticalityLevel, diff } from '../../src/index.js'; import { findFirstChangeByPath } from '../../utils/testing.js'; describe('directive-usage', () => { + describe('repeatable directives', () => { + test.only('adding with no args', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag @tag + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(1); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 2, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + ] + `); + }); + + test('adding multiple times', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag @tag @tag + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(3); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 1, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 2, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 3, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + ] + `); + }); + + test('adding with different args', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") @tag(name: "bar") + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(1); + expect(changes).toMatchInlineSnapshot(); + }); + + test('changing arguments of the second usage', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") @tag(name: "foo2") + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") @tag(name: "bar") + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(1); + expect(changes).toMatchInlineSnapshot(); + }); + + test('removing with different args', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") @tag(name: "bar") + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(1); + expect(changes).toMatchInlineSnapshot(); + }); + + test('removing in from beginning and end', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "start") @tag(name: "mid") @tag(name: "end") + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "mid") + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(2); + expect(changes).toMatchInlineSnapshot(); + }); + + test('removing with no args', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag @tag + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(1); + expect(changes).toMatchInlineSnapshot(); + }); + }); + describe('field-level directives', () => { test('added directive', async () => { const a = buildSchema(/* GraphQL */ ` diff --git a/packages/core/__tests__/diff/directive.test.ts b/packages/core/__tests__/diff/directive.test.ts index b90f434fc8..52655698e0 100644 --- a/packages/core/__tests__/diff/directive.test.ts +++ b/packages/core/__tests__/diff/directive.test.ts @@ -328,4 +328,58 @@ describe('directive', () => { `Default value for argument 'name' on directive 'e' changed from '"Eee"' to 'undefined'`, ); }); + + describe('repeatable', async () => { + test('added', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @a on FIELD + + type Dummy { + field: String + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @a repeatable on FIELD + + type Dummy { + field: String + } + `); + + const changes = await diff(a, b); + const change = findFirstChangeByPath(changes, '@a'); + + expect(changes).toHaveLength(1); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('DIRECTIVE_REPEATABLE_ADDED'); + expect(change.message).toEqual("Directive 'a' added repeatable."); + }); + + test('removed', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @a repeatable on FIELD + + type Dummy { + field: String + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @a on FIELD + + type Dummy { + field: String + } + `); + + const changes = await diff(a, b); + const change = findFirstChangeByPath(changes, '@a'); + + expect(changes).toHaveLength(1); + + expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.type).toEqual('DIRECTIVE_REPEATABLE_REMOVED'); + expect(change.message).toEqual("Directive 'a' removed repeatable."); + }); + }); }); diff --git a/packages/core/src/diff/argument.ts b/packages/core/src/diff/argument.ts index c278351ff8..d8faa7f64c 100644 --- a/packages/core/src/diff/argument.ts +++ b/packages/core/src/diff/argument.ts @@ -5,7 +5,7 @@ import { GraphQLObjectType, Kind, } from 'graphql'; -import { compareLists, diffArrays, isNotEqual } from '../utils/compare.js'; +import { compareDirectiveLists, compareLists, diffArrays, isNotEqual } from '../utils/compare.js'; import { fieldArgumentDefaultChanged, fieldArgumentDescriptionChanged, @@ -45,7 +45,7 @@ export function changesInArgument( } if (newArg.astNode?.directives) { - compareLists(oldArg?.astNode?.directives || [], newArg.astNode.directives || [], { + compareDirectiveLists(oldArg?.astNode?.directives || [], newArg.astNode.directives || [], { onAdded(directive) { addChange( directiveUsageAdded( diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index 1924bb9aff..44a34660f1 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -37,6 +37,8 @@ export const ChangeType = { DirectiveArgumentDescriptionChanged: 'DIRECTIVE_ARGUMENT_DESCRIPTION_CHANGED', DirectiveArgumentDefaultValueChanged: 'DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED', DirectiveArgumentTypeChanged: 'DIRECTIVE_ARGUMENT_TYPE_CHANGED', + DirectiveRepeatableAdded: 'DIRECTIVE_REPEATABLE_ADDED', + DirectiveRepeatableRemoved: 'DIRECTIVE_REPEATABLE_REMOVED', // Enum EnumValueRemoved: 'ENUM_VALUE_REMOVED', EnumValueAdded: 'ENUM_VALUE_ADDED', @@ -176,6 +178,20 @@ export type DirectiveDescriptionChangedChange = { }; }; +export type DirectiveRepeatableAddedChange = { + type: typeof ChangeType.DirectiveRepeatableAdded; + meta: { + directiveName: string; + }; +}; + +export type DirectiveRepeatableRemovedChange = { + type: typeof ChangeType.DirectiveRepeatableRemoved; + meta: { + directiveName: string; + }; +}; + export type DirectiveLocationAddedChange = { type: typeof ChangeType.DirectiveLocationAdded; meta: { @@ -415,6 +431,7 @@ export type DirectiveUsageUnionMemberAddedChange = { addedUnionMemberTypeName: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -424,6 +441,7 @@ export type DirectiveUsageUnionMemberRemovedChange = { unionName: string; removedUnionMemberTypeName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -659,6 +677,7 @@ export type DirectiveUsageEnumAddedChange = { enumName: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -667,6 +686,7 @@ export type DirectiveUsageEnumRemovedChange = { meta: { enumName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -677,6 +697,7 @@ export type DirectiveUsageEnumValueAddedChange = { enumValueName: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -686,6 +707,7 @@ export type DirectiveUsageEnumValueRemovedChange = { enumName: string; enumValueName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -697,6 +719,7 @@ export type DirectiveUsageInputObjectRemovedChange = { isRemovedInputFieldTypeNullable: boolean; removedInputFieldType: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -709,6 +732,7 @@ export type DirectiveUsageInputObjectAddedChange = { addedInputFieldType: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -720,6 +744,7 @@ export type DirectiveUsageInputFieldDefinitionAddedChange = { inputFieldType: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -729,6 +754,7 @@ export type DirectiveUsageInputFieldDefinitionRemovedChange = { inputObjectName: string; inputFieldName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -738,6 +764,7 @@ export type DirectiveUsageFieldAddedChange = { typeName: string; fieldName: string; addedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -747,6 +774,7 @@ export type DirectiveUsageFieldRemovedChange = { typeName: string; fieldName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -756,6 +784,7 @@ export type DirectiveUsageScalarAddedChange = { scalarName: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -764,6 +793,7 @@ export type DirectiveUsageScalarRemovedChange = { meta: { scalarName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -773,6 +803,7 @@ export type DirectiveUsageObjectAddedChange = { objectName: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -781,6 +812,7 @@ export type DirectiveUsageObjectRemovedChange = { meta: { objectName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -790,6 +822,7 @@ export type DirectiveUsageInterfaceAddedChange = { interfaceName: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -799,6 +832,7 @@ export type DirectiveUsageSchemaAddedChange = { addedDirectiveName: string; schemaTypeName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -807,6 +841,7 @@ export type DirectiveUsageSchemaRemovedChange = { meta: { removedDirectiveName: string; schemaTypeName: string; + directiveRepeatedTimes: number; }; }; @@ -817,6 +852,7 @@ export type DirectiveUsageFieldDefinitionAddedChange = { fieldName: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -826,6 +862,7 @@ export type DirectiveUsageFieldDefinitionRemovedChange = { typeName: string; fieldName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -836,6 +873,7 @@ export type DirectiveUsageArgumentDefinitionRemovedChange = { fieldName: string; argumentName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -844,6 +882,7 @@ export type DirectiveUsageInterfaceRemovedChange = { meta: { interfaceName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -855,6 +894,7 @@ export type DirectiveUsageArgumentDefinitionAddedChange = { argumentName: string; addedDirectiveName: string; addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -875,6 +915,7 @@ export type DirectiveUsageArgumentAddedChange = { 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; + directiveRepeatedTimes: number; }; }; @@ -892,6 +933,7 @@ export type DirectiveUsageArgumentRemovedChange = { 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; + directiveRepeatedTimes: number; }; }; @@ -931,6 +973,8 @@ type Changes = { [ChangeType.DirectiveArgumentDefaultValueChanged]: DirectiveArgumentDefaultValueChangedChange; [ChangeType.DirectiveArgumentTypeChanged]: DirectiveArgumentTypeChangedChange; [ChangeType.DirectiveDescriptionChanged]: DirectiveDescriptionChangedChange; + [ChangeType.DirectiveRepeatableAdded]: DirectiveRepeatableAddedChange; + [ChangeType.DirectiveRepeatableRemoved]: DirectiveRepeatableRemovedChange; [ChangeType.FieldArgumentDescriptionChanged]: FieldArgumentDescriptionChangedChange; [ChangeType.FieldArgumentDefaultChanged]: FieldArgumentDefaultChangedChange; [ChangeType.FieldArgumentTypeChanged]: FieldArgumentTypeChangedChange; diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 2cf2b3fc43..b27d950b9e 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -655,6 +655,10 @@ export function directiveUsageAdded( fieldName: payload.field.name, typeName: payload.type.name, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes( + payload.argument.astNode?.directives ?? [], + directive, + ), }, }); } @@ -667,6 +671,10 @@ export function directiveUsageAdded( inputFieldType: payload.field.type.toString(), inputObjectName: payload.type.name, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -680,6 +688,7 @@ export function directiveUsageAdded( inputObjectName: payload.name, isAddedInputFieldTypeNullable: kind === Kind.INPUT_VALUE_DEFINITION, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -690,6 +699,7 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, interfaceName: payload.name, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -700,6 +710,7 @@ export function directiveUsageAdded( objectName: payload.name, addedDirectiveName: directive.name.value, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -710,6 +721,7 @@ export function directiveUsageAdded( enumName: payload.name, addedDirectiveName: directive.name.value, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -721,6 +733,10 @@ export function directiveUsageAdded( fieldName: payload.field.name, typeName: payload.parentType.name, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -732,6 +748,7 @@ export function directiveUsageAdded( addedUnionMemberTypeName: payload.name, unionName: payload.name, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -743,6 +760,10 @@ export function directiveUsageAdded( enumValueName: payload.value.name, addedDirectiveName: directive.name.value, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes( + payload.value.astNode?.directives ?? [], + directive, + ), }, }); } @@ -753,6 +774,7 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, schemaTypeName: payload.getQueryType()?.name || '', addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -763,6 +785,7 @@ export function directiveUsageAdded( scalarName: payload.name, addedDirectiveName: directive.name.value, addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -770,6 +793,30 @@ export function directiveUsageAdded( return {} as any; } +/** + * Counts the number of times a directive with the same name + * exists in the directives array before the passed directive. + * + * This is important for repeatable directives because it + * determines which instance of the directive usage the change applies to. + */ +function directiveRepeatTimes( + directives: readonly ConstDirectiveNode[], + directive: ConstDirectiveNode, +) { + const name = directive.name.value; + let repeats = 0; + for (const d of directives) { + if (d.name.value === name) { + repeats += 1; + } + if (d === directive) { + return repeats; + } + } + return 0; +} + export function directiveUsageRemoved( kind: K, directive: ConstDirectiveNode, @@ -783,6 +830,10 @@ export function directiveUsageRemoved( argumentName: payload.argument.name, fieldName: payload.field.name, typeName: payload.type.name, + directiveRepeatedTimes: directiveRepeatTimes( + payload.argument.astNode?.directives ?? [], + directive, + ), }, }); } @@ -793,6 +844,10 @@ export function directiveUsageRemoved( removedDirectiveName: directive.name.value, inputFieldName: payload.field.name, inputObjectName: payload.type.name, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -805,6 +860,7 @@ export function directiveUsageRemoved( removedInputFieldType: payload.name, inputObjectName: payload.name, isRemovedInputFieldTypeNullable: kind === Kind.INPUT_VALUE_DEFINITION, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -814,6 +870,7 @@ export function directiveUsageRemoved( meta: { removedDirectiveName: directive.name.value, interfaceName: payload.name, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -823,6 +880,7 @@ export function directiveUsageRemoved( meta: { objectName: payload.name, removedDirectiveName: directive.name.value, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -832,6 +890,7 @@ export function directiveUsageRemoved( meta: { enumName: payload.name, removedDirectiveName: directive.name.value, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -842,6 +901,10 @@ export function directiveUsageRemoved( removedDirectiveName: directive.name.value, fieldName: payload.field.name, typeName: payload.parentType.name, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -852,6 +915,7 @@ export function directiveUsageRemoved( removedDirectiveName: directive.name.value, removedUnionMemberTypeName: payload.name, unionName: payload.name, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -862,6 +926,10 @@ export function directiveUsageRemoved( enumName: payload.type.name, enumValueName: payload.value.name, removedDirectiveName: directive.name.value, + directiveRepeatedTimes: directiveRepeatTimes( + payload.value.astNode?.directives ?? [], + directive, + ), }, }); } @@ -871,6 +939,7 @@ export function directiveUsageRemoved( meta: { removedDirectiveName: directive.name.value, schemaTypeName: payload.getQueryType()?.name || '', + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -880,6 +949,7 @@ export function directiveUsageRemoved( meta: { scalarName: payload.name, removedDirectiveName: directive.name.value, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -964,6 +1034,13 @@ export function directiveUsageChanged( parentFieldName: parentField?.name ?? null, parentArgumentName: parentArgument?.name ?? null, parentEnumValueName: parentEnumValue?.name ?? null, + directiveRepeatedTimes: + // @todo should this lastly fall back to the GraphQLSchema? + directiveRepeatTimes( + (parentEnumValue || parentArgument || parentField || parentType)?.astNode + ?.directives ?? [], + newDirective, + ), }, }), ); @@ -982,6 +1059,13 @@ export function directiveUsageChanged( parentFieldName: parentField?.name ?? null, parentArgumentName: parentArgument?.name ?? null, parentEnumValueName: parentEnumValue?.name ?? null, + directiveRepeatedTimes: + // @todo should this lastly fall back to the GraphQLSchema? + directiveRepeatTimes( + (parentEnumValue || parentArgument || parentField || parentType)?.astNode + ?.directives ?? [], + newDirective, + ), }, }); }, @@ -997,6 +1081,13 @@ export function directiveUsageChanged( parentFieldName: parentField?.name ?? null, parentArgumentName: parentArgument?.name ?? null, parentEnumValueName: parentEnumValue?.name ?? null, + directiveRepeatedTimes: + // @todo should this lastly fall back to the GraphQLSchema? + directiveRepeatTimes( + (parentEnumValue || parentArgument || parentField || parentType)?.astNode + ?.directives ?? [], + newDirective, + ), }, }), ); diff --git a/packages/core/src/diff/changes/directive.ts b/packages/core/src/diff/changes/directive.ts index 493e41a895..c0dadec371 100644 --- a/packages/core/src/diff/changes/directive.ts +++ b/packages/core/src/diff/changes/directive.ts @@ -15,6 +15,8 @@ import { DirectiveLocationAddedChange, DirectiveLocationRemovedChange, DirectiveRemovedChange, + DirectiveRepeatableAddedChange, + DirectiveRepeatableRemovedChange, } from './change.js'; function buildDirectiveRemovedMessage(args: DirectiveRemovedChange['meta']): string { @@ -111,6 +113,60 @@ export function directiveDescriptionChanged( }); } +function buildDirectiveRepeatableAddedMessage(args: DirectiveRepeatableAddedChange['meta']) { + return `Directive '${args.directiveName}' added repeatable.`; +} + +function directiveRepeatableAddedFromMeta( + args: DirectiveRepeatableAddedChange, +): Change { + return { + criticality: { + level: CriticalityLevel.NonBreaking, + }, + type: ChangeType.DirectiveRepeatableAdded, + message: buildDirectiveRepeatableAddedMessage(args.meta), + path: `@${args.meta.directiveName}`, + meta: args.meta, + } as const; +} + +export function directiveRepeatableAdded(directive: GraphQLDirective) { + return directiveRepeatableAddedFromMeta({ + type: ChangeType.DirectiveRepeatableAdded, + meta: { + directiveName: directive.name, + }, + }); +} + +function buildDirectiveRepeatableRemovedMessage(args: DirectiveRepeatableAddedChange['meta']) { + return `Directive '${args.directiveName}' removed repeatable.`; +} + +function directiveRepeatableRemovedFromMeta( + args: DirectiveRepeatableRemovedChange, +): Change { + return { + criticality: { + level: CriticalityLevel.Dangerous, + }, + type: ChangeType.DirectiveRepeatableRemoved, + message: buildDirectiveRepeatableRemovedMessage(args.meta), + path: `@${args.meta.directiveName}`, + meta: args.meta, + } as const; +} + +export function directiveRepeatableRemoved(directive: GraphQLDirective) { + return directiveRepeatableRemovedFromMeta({ + type: ChangeType.DirectiveRepeatableRemoved, + meta: { + directiveName: directive.name, + }, + }); +} + function buildDirectiveLocationAddedMessage(args: DirectiveLocationAddedChange['meta']): string { return `Location '${args.addedDirectiveLocation}' was added to directive '${args.directiveName}'`; } diff --git a/packages/core/src/diff/directive.ts b/packages/core/src/diff/directive.ts index d1b918ad21..a88bcb3ced 100644 --- a/packages/core/src/diff/directive.ts +++ b/packages/core/src/diff/directive.ts @@ -9,6 +9,8 @@ import { directiveDescriptionChanged, directiveLocationAdded, directiveLocationRemoved, + directiveRepeatableAdded, + directiveRepeatableRemoved, } from './changes/directive.js'; import { AddChange } from './schema.js'; @@ -21,6 +23,16 @@ export function changesInDirective( addChange(directiveDescriptionChanged(oldDirective, newDirective)); } + // repeatable removed + if (!newDirective.isRepeatable && oldDirective?.isRepeatable) { + addChange(directiveRepeatableRemoved(newDirective)); + } + + // repeatable added + if (newDirective.isRepeatable && !oldDirective?.isRepeatable) { + addChange(directiveRepeatableAdded(newDirective)); + } + const locations = { added: diffArrays(newDirective.locations, oldDirective?.locations ?? []), removed: diffArrays(oldDirective?.locations ?? [], newDirective.locations), diff --git a/packages/core/src/diff/enum.ts b/packages/core/src/diff/enum.ts index 05528fb63f..0f6f8ea201 100644 --- a/packages/core/src/diff/enum.ts +++ b/packages/core/src/diff/enum.ts @@ -1,5 +1,5 @@ import { GraphQLEnumType, GraphQLEnumValue, Kind } from 'graphql'; -import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; +import { compareDirectiveLists, compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { directiveUsageAdded, directiveUsageChanged, @@ -35,7 +35,7 @@ export function changesInEnum( }, }); - compareLists(oldEnum?.astNode?.directives || [], newEnum.astNode?.directives || [], { + compareDirectiveLists(oldEnum?.astNode?.directives || [], newEnum.astNode?.directives || [], { onAdded(directive) { addChange( directiveUsageAdded(Kind.ENUM_TYPE_DEFINITION, directive, newEnum, oldEnum === null), @@ -82,7 +82,7 @@ function changesInEnumValue( } } - compareLists(oldValue?.astNode?.directives || [], newValue.astNode?.directives || [], { + compareDirectiveLists(oldValue?.astNode?.directives || [], newValue.astNode?.directives || [], { onAdded(directive) { addChange( directiveUsageAdded( diff --git a/packages/core/src/diff/field.ts b/packages/core/src/diff/field.ts index 081090c620..58b0660c4f 100644 --- a/packages/core/src/diff/field.ts +++ b/packages/core/src/diff/field.ts @@ -1,5 +1,5 @@ import { GraphQLField, GraphQLInterfaceType, GraphQLObjectType, Kind } from 'graphql'; -import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; +import { compareDirectiveLists, compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { isDeprecated } from '../utils/is-deprecated.js'; import { changesInArgument } from './argument.js'; import { @@ -80,7 +80,7 @@ export function changesInField( }, }); - compareLists(oldField?.astNode?.directives || [], newField.astNode?.directives || [], { + compareDirectiveLists(oldField?.astNode?.directives || [], newField.astNode?.directives || [], { onAdded(directive) { addChange( directiveUsageAdded( diff --git a/packages/core/src/diff/input.ts b/packages/core/src/diff/input.ts index cc48d6d71b..78cf3423b3 100644 --- a/packages/core/src/diff/input.ts +++ b/packages/core/src/diff/input.ts @@ -1,5 +1,11 @@ import { GraphQLInputField, GraphQLInputObjectType, Kind } from 'graphql'; -import { compareLists, diffArrays, isNotEqual, isVoid } from '../utils/compare.js'; +import { + compareDirectiveLists, + compareLists, + diffArrays, + isNotEqual, + isVoid, +} from '../utils/compare.js'; import { directiveUsageAdded, directiveUsageChanged, @@ -37,7 +43,7 @@ export function changesInInputObject( }, }); - compareLists(oldInput?.astNode?.directives || [], newInput.astNode?.directives || [], { + compareDirectiveLists(oldInput?.astNode?.directives || [], newInput.astNode?.directives || [], { onAdded(directive) { addChange( directiveUsageAdded( @@ -91,7 +97,7 @@ function changesInInputField( } if (newField.astNode?.directives) { - compareLists(oldField?.astNode?.directives || [], newField.astNode.directives || [], { + compareDirectiveLists(oldField?.astNode?.directives || [], newField.astNode.directives || [], { onAdded(directive) { addChange( directiveUsageAdded( diff --git a/packages/core/src/diff/interface.ts b/packages/core/src/diff/interface.ts index bbdda50fd8..f4beed7033 100644 --- a/packages/core/src/diff/interface.ts +++ b/packages/core/src/diff/interface.ts @@ -1,5 +1,5 @@ import { GraphQLInterfaceType, Kind } from 'graphql'; -import { compareLists } from '../utils/compare.js'; +import { compareDirectiveLists, compareLists } from '../utils/compare.js'; import { directiveUsageAdded, directiveUsageChanged, @@ -42,23 +42,27 @@ export function changesInInterface( changesInField(newInterface, field.oldVersion, field.newVersion, addChange); }, }); - compareLists(oldInterface?.astNode?.directives || [], newInterface.astNode?.directives || [], { - onAdded(directive) { - addChange( - directiveUsageAdded( - Kind.INTERFACE_TYPE_DEFINITION, - directive, - newInterface, - oldInterface === null, - ), - ); - directiveUsageChanged(null, directive, addChange, newInterface); + compareDirectiveLists( + oldInterface?.astNode?.directives || [], + newInterface.astNode?.directives || [], + { + onAdded(directive) { + addChange( + directiveUsageAdded( + Kind.INTERFACE_TYPE_DEFINITION, + directive, + newInterface, + 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!)); + }, }, - 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 3aef4e3d2f..3b8ad529fd 100644 --- a/packages/core/src/diff/object.ts +++ b/packages/core/src/diff/object.ts @@ -1,5 +1,5 @@ import { GraphQLObjectType, Kind } from 'graphql'; -import { compareLists } from '../utils/compare.js'; +import { compareDirectiveLists, compareLists } from '../utils/compare.js'; import { directiveUsageAdded, directiveUsageChanged, @@ -43,7 +43,7 @@ export function changesInObject( }, }); - compareLists(oldType?.astNode?.directives || [], newType.astNode?.directives || [], { + compareDirectiveLists(oldType?.astNode?.directives || [], newType.astNode?.directives || [], { onAdded(directive) { addChange(directiveUsageAdded(Kind.OBJECT, directive, newType, oldType === null)); directiveUsageChanged(null, directive, addChange, newType); diff --git a/packages/core/src/diff/scalar.ts b/packages/core/src/diff/scalar.ts index b59a157ce3..8273f59272 100644 --- a/packages/core/src/diff/scalar.ts +++ b/packages/core/src/diff/scalar.ts @@ -1,5 +1,5 @@ import { GraphQLScalarType, Kind } from 'graphql'; -import { compareLists } from '../utils/compare.js'; +import { compareDirectiveLists } from '../utils/compare.js'; import { directiveUsageAdded, directiveUsageChanged, @@ -12,7 +12,7 @@ export function changesInScalar( newScalar: GraphQLScalarType, addChange: AddChange, ) { - compareLists(oldScalar?.astNode?.directives || [], newScalar.astNode?.directives || [], { + compareDirectiveLists(oldScalar?.astNode?.directives || [], newScalar.astNode?.directives || [], { onAdded(directive) { addChange( directiveUsageAdded(Kind.SCALAR_TYPE_DEFINITION, directive, newScalar, oldScalar === null), diff --git a/packages/core/src/diff/schema.ts b/packages/core/src/diff/schema.ts index 39a1105f02..aa491e4f0e 100644 --- a/packages/core/src/diff/schema.ts +++ b/packages/core/src/diff/schema.ts @@ -10,7 +10,7 @@ import { isUnionType, Kind, } from 'graphql'; -import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; +import { compareDirectiveLists, compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { isPrimitive } from '../utils/graphql.js'; import { Change } from './changes/change.js'; import { @@ -81,7 +81,7 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): }, }); - compareLists(oldSchema.astNode?.directives || [], newSchema.astNode?.directives || [], { + compareDirectiveLists(oldSchema.astNode?.directives || [], newSchema.astNode?.directives || [], { onAdded(directive) { addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema, false)); directiveUsageChanged(null, directive, addChange); diff --git a/packages/core/src/diff/union.ts b/packages/core/src/diff/union.ts index b4c338076a..2c2da1927d 100644 --- a/packages/core/src/diff/union.ts +++ b/packages/core/src/diff/union.ts @@ -1,5 +1,5 @@ import { GraphQLUnionType, Kind } from 'graphql'; -import { compareLists } from '../utils/compare.js'; +import { compareDirectiveLists, compareLists } from '../utils/compare.js'; import { directiveUsageAdded, directiveUsageChanged, @@ -25,7 +25,7 @@ export function changesInUnion( }, }); - compareLists(oldUnion?.astNode?.directives || [], newUnion.astNode?.directives || [], { + compareDirectiveLists(oldUnion?.astNode?.directives || [], newUnion.astNode?.directives || [], { onAdded(directive) { addChange( directiveUsageAdded(Kind.UNION_TYPE_DEFINITION, directive, newUnion, oldUnion === null), diff --git a/packages/core/src/utils/compare.ts b/packages/core/src/utils/compare.ts index 9dde8c19b0..1c636ade77 100644 --- a/packages/core/src/utils/compare.ts +++ b/packages/core/src/utils/compare.ts @@ -119,3 +119,98 @@ export function compareLists( mutual, }; } + +/** + * This is special because directives can be repeated and a name alone is not enough + * to identify whether or not an instance was changed, added, or removed. + * The best option is to assume the order is the same, and treat the changes as they come. + * So `type T @foo` to `type T @foo(f: 'bar') @foo` would be adding an argument `f: 'bar'` and + * then adding a new directive `@foo`. Rather than adding `@foo(f: 'bar')` + */ +export function compareDirectiveLists( + oldList: readonly T[], + newList: readonly T[], + callbacks?: { + onAdded?(t: T): void; + onRemoved?(t: T): void; + onMutual?(t: { newVersion: T; oldVersion: T | null }): void; + }, +) { + const oldMap = keyMapList(oldList, ({ name }) => extractName(name)); + const newMap = keyMapList(newList, ({ name }) => extractName(name)); + + const added: T[] = []; + const removed: T[] = []; + const mutual: Array<{ newVersion: T; oldVersion: T }> = []; + + for (const oldItem of oldList) { + // check if the oldItem exists in the new schema + const newItems = newMap[extractName(oldItem.name)] ?? null; + // if not, then it's been removed + if (newItems === null) { + removed.push(oldItem); + } else { + // if so, then consider this a mutual change, and remove it from the list of newItems to avoid counting it in the future + const [newItem, ...rest] = newItems; + if (rest.length > 1) { + newMap[extractName(oldItem.name)] = rest as [T] & T[]; + } else { + delete newMap[extractName(oldItem.name)]; + } + + mutual.push({ + newVersion: newItem, + oldVersion: oldItem ?? null, + }); + } + } + + for (const newItem of newList) { + const existingItems = oldMap[extractName(newItem.name)] ?? null; + if (existingItems === null) { + added.push(newItem); + } else { + const [_, ...rest] = existingItems; + if (rest.length > 0) { + oldMap[extractName(newItem.name)] = rest as [T] & T[]; + } else { + delete oldMap[extractName(newItem.name)]; + } + } + } + + if (callbacks) { + if (callbacks.onAdded) { + for (const item of added) { + callbacks.onAdded(item); + } + } + if (callbacks.onRemoved) { + for (const item of removed) { + callbacks.onRemoved(item); + } + } + if (callbacks.onMutual) { + for (const item of mutual) { + callbacks.onMutual(item); + } + } + } + + return { + added, + removed, + mutual, + }; +} + +export function keyMapList( + list: readonly T[], + keyFn: (item: T) => string, +): Record { + return list.reduce((map, item) => { + const key = keyFn(item); + map[key] = [...(map[key] ?? []), item]; + return map; + }, Object.create(null)); +} diff --git a/packages/patch/src/__tests__/directives.test.ts b/packages/patch/src/__tests__/directives.test.ts index 510e77de66..245662eee5 100644 --- a/packages/patch/src/__tests__/directives.test.ts +++ b/packages/patch/src/__tests__/directives.test.ts @@ -97,4 +97,26 @@ describe('directives', async () => { `; await expectPatchToMatch(before, after); }); + + test('directiveRepeatableAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); + + test('directiveRepeatableRemoved', async () => { + const before = /* GraphQL */ ` + directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); }); diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 965bbc4522..0f2b92e18d 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -49,6 +49,8 @@ import { directiveLocationAdded, directiveLocationRemoved, directiveRemoved, + directiveRepeatableAdded, + directiveRepeatableRemoved, } from './patches/directives.js'; import { enumValueAdded, @@ -124,7 +126,7 @@ export function groupByCoordinateAST(ast: DocumentNode): [SchemaNode[], Map(); const pathArray: string[] = []; visit(ast, { - enter(node) { + enter(node, key) { switch (node.kind) { case Kind.ARGUMENT: case Kind.ENUM_TYPE_DEFINITION: @@ -161,12 +163,13 @@ export function groupByCoordinateAST(ast: DocumentNode): [SchemaNode[], Map, nodeByPath: Map, @@ -59,30 +78,41 @@ function directiveUsageDefinitionAdded( return; } - const directiveNode = nodeByPath.get(change.path); const parentNode = nodeByPath.get(parentPath(change.path)) as | { kind: Kind; directives?: DirectiveNode[] } | undefined; - if (directiveNode) { + if ( + change.meta.addedDirectiveName === 'deprecated' && + parentNode && + (parentNode.kind === Kind.FIELD_DEFINITION || parentNode.kind === Kind.ENUM_VALUE_DEFINITION) + ) { + return; // ignore because deprecated is handled by its own change... consider adjusting this. + } + const definition = nodeByPath.get(`@${change.meta.addedDirectiveName}`); + let repeatable = false; + if (!definition) { + console.warn(`Directive "@${change.meta.addedDirectiveName}" is missing a definition.`); + } + if (definition?.kind === Kind.DIRECTIVE_DEFINITION) { + repeatable = definition.repeatable; + } + const directiveNode = findNthDirective( + parentNode?.directives ?? [], + change.meta.addedDirectiveName, + change.meta.directiveRepeatedTimes, + ); + if (!repeatable && directiveNode) { handleError( change, new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, change.meta.addedDirectiveName), config, ); } else if (parentNode) { - if ( - change.meta.addedDirectiveName === 'deprecated' && - (parentNode.kind === Kind.FIELD_DEFINITION || parentNode.kind === Kind.ENUM_VALUE_DEFINITION) - ) { - return; // ignore because deprecated is handled by its own change... consider adjusting this. - } - const newDirective: DirectiveNode = { kind: Kind.DIRECTIVE, name: nameNode(change.meta.addedDirectiveName), }; parentNode.directives = [...(parentNode.directives ?? []), newDirective]; - nodeByPath.set(change.path, newDirective); } else { handleError( change, @@ -101,11 +131,34 @@ function schemaDirectiveUsageDefinitionAdded( nodeByPath: Map, config: PatchConfig, ) { - // @todo handle repeat directives + if (!change.path) { + handleError( + change, + new ChangedCoordinateNotFoundError(Kind.DIRECTIVE, change.meta.addedDirectiveName), + config, + ); + return; + } + if (change.meta.addedDirectiveName === 'deprecated') { + return; // ignore because deprecated is handled by its own change... consider adjusting this. + } + const definition = nodeByPath.get(`@${change.meta.addedDirectiveName}`); + let repeatable = false; + if (!definition) { + console.warn(`Directive "@${change.meta.addedDirectiveName}" is missing a definition.`); + } + if (definition?.kind === Kind.DIRECTIVE_DEFINITION) { + repeatable = definition.repeatable; + } + const directiveAlreadyExists = schemaNodes.some(schemaNode => - findNamedNode(schemaNode.directives, change.meta.addedDirectiveName), + findNthDirective( + schemaNode.directives ?? [], + change.meta.addedDirectiveName, + change.meta.directiveRepeatedTimes, + ), ); - if (directiveAlreadyExists) { + if (!repeatable && directiveAlreadyExists) { handleError( change, new AddedAttributeAlreadyExistsError( @@ -124,26 +177,28 @@ function schemaDirectiveUsageDefinitionAdded( ...(schemaNodes[0].directives ?? []), directiveNode, ]; - nodeByPath.set(`.@${change.meta.addedDirectiveName}`, directiveNode); } } function schemaDirectiveUsageDefinitionRemoved( change: Change, schemaNodes: SchemaNode[], - nodeByPath: Map, + _nodeByPath: Map, config: PatchConfig, ) { let deleted = false; - // @todo handle repeated directives for (const node of schemaNodes) { - const directiveNode = findNamedNode(node.directives, change.meta.removedDirectiveName); + const directiveNode = findNthDirective( + node?.directives ?? [], + change.meta.removedDirectiveName, + change.meta.directiveRepeatedTimes, + ); 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}`); + // nodeByPath.delete(`.@${change.meta.removedDirectiveName}`); deleted = true; break; } @@ -171,10 +226,14 @@ function directiveUsageDefinitionRemoved( return; } - const directiveNode = nodeByPath.get(change.path); const parentNode = nodeByPath.get(parentPath(change.path)) as | { kind: Kind; directives?: DirectiveNode[] } | undefined; + const directiveNode = findNthDirective( + parentNode?.directives ?? [], + change.meta.removedDirectiveName, + change.meta.directiveRepeatedTimes, + ); if (!parentNode) { handleError( change, @@ -199,7 +258,6 @@ function directiveUsageDefinitionRemoved( parentNode.directives = parentNode.directives?.filter( d => d.name.value !== change.meta.removedDirectiveName, ); - nodeByPath.delete(change.path); } } @@ -406,7 +464,15 @@ export function directiveUsageArgumentAdded( handleError(change, new ChangePathMissingError(change), config); return; } - const directiveNode = nodeByPath.get(parentPath(change.path)); + // Must use double parentPath b/c the path is referencing the argument + const parentNode = nodeByPath.get(parentPath(parentPath(change.path))) as + | { kind: Kind; directives?: DirectiveNode[] } + | undefined; + const directiveNode = findNthDirective( + parentNode?.directives ?? [], + change.meta.directiveName, + change.meta.directiveRepeatedTimes, + ); if (!directiveNode) { handleError( change, @@ -459,7 +525,21 @@ export function directiveUsageArgumentRemoved( handleError(change, new ChangePathMissingError(change), config); return; } - const directiveNode = nodeByPath.get(parentPath(change.path)); + const parentNode = nodeByPath.get(parentPath(change.path)) as + | { kind: Kind; directives?: DirectiveNode[] } + | undefined; + // if ( + // change.meta.directiveName === 'deprecated' && + // parentNode && (parentNode.kind === Kind.FIELD_DEFINITION || parentNode.kind === Kind.ENUM_VALUE_DEFINITION) + // ) { + // return; // ignore because deprecated is handled by its own change... consider adjusting this. + // } + + const directiveNode = findNthDirective( + parentNode?.directives ?? [], + change.meta.directiveName, + change.meta.directiveRepeatedTimes, + ); if (!directiveNode) { handleError( change, diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index e64d347c0a..e7d34d804e 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -414,3 +414,77 @@ export function directiveArgumentTypeChanged( ); } } + +export function directiveRepeatableAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const directiveNode = nodeByPath.get(change.path); + if (!directiveNode) { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), + config, + ); + } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { + // eslint-disable-next-line eqeqeq + if (directiveNode.repeatable !== false) { + handleError( + change, + new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'false'), + config, + ); + } + + (directiveNode.repeatable as boolean) = true; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + config, + ); + } +} + +export function directiveRepeatableRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const directiveNode = nodeByPath.get(change.path); + if (!directiveNode) { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), + config, + ); + } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { + // eslint-disable-next-line eqeqeq + if (directiveNode.repeatable !== true) { + handleError( + change, + new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'true'), + config, + ); + } + + (directiveNode.repeatable as boolean) = false; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + config, + ); + } +} diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index 8284650395..825705eb96 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -23,7 +23,7 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig } from '../types'; -import { findNamedNode, parentPath } from '../utils.js'; +import { findNamedNode, getDeprecatedDirectiveNode, parentPath } from '../utils.js'; export function enumValueRemoved( change: Change, @@ -135,9 +135,9 @@ export function enumValueDeprecationReasonAdded( } 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( @@ -192,8 +192,10 @@ export function enumValueDeprecationReasonChanged( handleError(change, new ChangePathMissingError(change), config); return; } - - const deprecatedNode = nodeByPath.get(change.path); + const enumValueNode = nodeByPath.get(parentPath(change.path)) as + | { readonly directives?: readonly DirectiveNode[] | undefined } + | undefined; + const deprecatedNode = getDeprecatedDirectiveNode(enumValueNode); if (deprecatedNode) { if (deprecatedNode.kind === Kind.DIRECTIVE) { const reasonArgNode = findNamedNode(deprecatedNode.arguments, 'reason'); @@ -256,9 +258,8 @@ export function enumValueDescriptionChanged( const enumValueNode = nodeByPath.get(change.path); if (enumValueNode) { if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { - // eslint-disable-next-line eqeqeq const oldValueMatches = - change.meta.oldEnumValueDescription == enumValueNode.description?.value; + change.meta.oldEnumValueDescription === (enumValueNode.description?.value ?? null); if (!oldValueMatches) { handleError( change, diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index a6eaa416ce..e7eb40265d 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -83,19 +83,20 @@ export function fieldRemoved( ), config, ); + return; + } + + 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 { - 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); - } + // delete the reference to the removed field. + nodeByPath.delete(change.path); } } @@ -306,7 +307,11 @@ export function fieldDeprecationReasonChanged( nodeByPath: Map, config: PatchConfig, ) { - const deprecationNode = getChangedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE, config); + assertChangeHasPath(change, config); + const parentNode = nodeByPath.get(parentPath(change.path!)) as + | { kind: Kind; directives?: DirectiveNode[] } + | undefined; + const deprecationNode = getDeprecatedDirectiveNode(parentNode); if (deprecationNode) { const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); if (reasonArgument) { @@ -342,7 +347,11 @@ export function fieldDeprecationReasonAdded( nodeByPath: Map, config: PatchConfig, ) { - const deprecationNode = getChangedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE, config); + assertChangeHasPath(change, config); + const parentNode = nodeByPath.get(parentPath(change.path!)) as + | { kind: Kind; directives?: DirectiveNode[] } + | undefined; + const deprecationNode = getDeprecatedDirectiveNode(parentNode); if (deprecationNode) { const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); if (reasonArgument) { @@ -361,7 +370,6 @@ export function fieldDeprecationReasonAdded( ...(deprecationNode.arguments ?? []), node, ]; - nodeByPath.set(`${change.path}.reason`, node); } } } @@ -411,7 +419,6 @@ export function fieldDeprecationAdded( ...(fieldNode.directives ?? []), directiveNode, ]; - nodeByPath.set(change.path, directiveNode); } } } diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 201b87ac8a..c3974d2d1f 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -136,6 +136,9 @@ export function getChangedNodeOfKind( kind: K, config: PatchConfig, ): ASTKindToNode[K] | void { + if (kind === Kind.DIRECTIVE) { + throw new Error('Directives cannot be found using this method.'); + } if (assertChangeHasPath(change, config)) { const existing = nodeByPath.get(change.path); if (!existing) { From a6d80a37e3d1400ab1140445aeeb245694c57bae Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 29 Oct 2025 19:36:13 -0700 Subject: [PATCH 31/73] Fix tests --- .../__tests__/diff/directive-usage.test.ts | 162 +++++++++++++++++- 1 file changed, 153 insertions(+), 9 deletions(-) diff --git a/packages/core/__tests__/diff/directive-usage.test.ts b/packages/core/__tests__/diff/directive-usage.test.ts index effd6652a0..196a7afbd8 100644 --- a/packages/core/__tests__/diff/directive-usage.test.ts +++ b/packages/core/__tests__/diff/directive-usage.test.ts @@ -4,7 +4,7 @@ import { findFirstChangeByPath } from '../../utils/testing.js'; describe('directive-usage', () => { describe('repeatable directives', () => { - test.only('adding with no args', async () => { + test('adding with no args', async () => { const a = buildSchema(/* GraphQL */ ` directive @tag(name: String) repeatable on FIELD_DEFINITION @@ -56,12 +56,12 @@ describe('directive-usage', () => { directive @tag(name: String) repeatable on FIELD_DEFINITION type Query { - a: String @tag @tag @tag + a: String @tag @tag(name: "second") @tag } `); const changes = await diff(a, b); - expect(changes).toHaveLength(3); + expect(changes).toHaveLength(4); expect(changes).toMatchInlineSnapshot(` [ { @@ -96,6 +96,25 @@ describe('directive-usage', () => { "path": "Query.a.@tag", "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Argument 'name' was added to '@tag'", + "meta": { + "addedArgumentName": "name", + "addedArgumentValue": ""second"", + "directiveName": "tag", + "directiveRepeatedTimes": 2, + "oldArgumentValue": null, + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_ADDED", + }, { "criticality": { "level": "DANGEROUS", @@ -133,8 +152,46 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - expect(changes).toHaveLength(1); - expect(changes).toMatchInlineSnapshot(); + expect(changes).toHaveLength(2); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 2, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Argument 'name' was added to '@tag'", + "meta": { + "addedArgumentName": "name", + "addedArgumentValue": ""bar"", + "directiveName": "tag", + "directiveRepeatedTimes": 2, + "oldArgumentValue": null, + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_ADDED", + }, + ] + `); }); test('changing arguments of the second usage', async () => { @@ -155,7 +212,25 @@ describe('directive-usage', () => { const changes = await diff(a, b); expect(changes).toHaveLength(1); - expect(changes).toMatchInlineSnapshot(); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was removed from field 'a'", + }, + "message": "Directive 'tag' was removed from field 'Query.a'", + "meta": { + "directiveRepeatedTimes": 2, + "fieldName": "a", + "removedDirectiveName": "tag", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + }, + ] + `); }); test('removing with different args', async () => { @@ -176,7 +251,25 @@ describe('directive-usage', () => { const changes = await diff(a, b); expect(changes).toHaveLength(1); - expect(changes).toMatchInlineSnapshot(); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was removed from field 'a'", + }, + "message": "Directive 'tag' was removed from field 'Query.a'", + "meta": { + "directiveRepeatedTimes": 2, + "fieldName": "a", + "removedDirectiveName": "tag", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + }, + ] + `); }); test('removing in from beginning and end', async () => { @@ -197,7 +290,40 @@ describe('directive-usage', () => { const changes = await diff(a, b); expect(changes).toHaveLength(2); - expect(changes).toMatchInlineSnapshot(); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was removed from field 'a'", + }, + "message": "Directive 'tag' was removed from field 'Query.a'", + "meta": { + "directiveRepeatedTimes": 2, + "fieldName": "a", + "removedDirectiveName": "tag", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + }, + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was removed from field 'a'", + }, + "message": "Directive 'tag' was removed from field 'Query.a'", + "meta": { + "directiveRepeatedTimes": 3, + "fieldName": "a", + "removedDirectiveName": "tag", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + }, + ] + `); }); test('removing with no args', async () => { @@ -218,7 +344,25 @@ describe('directive-usage', () => { const changes = await diff(a, b); expect(changes).toHaveLength(1); - expect(changes).toMatchInlineSnapshot(); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was removed from field 'a'", + }, + "message": "Directive 'tag' was removed from field 'Query.a'", + "meta": { + "directiveRepeatedTimes": 2, + "fieldName": "a", + "removedDirectiveName": "tag", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + }, + ] + `); }); }); From cd3db7e5588e2de0f353c8530e7202b88aee41d0 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 29 Oct 2025 19:47:01 -0700 Subject: [PATCH 32/73] add more complicated tests that are needed for completeness --- .../patch/src/__tests__/directives.test.ts | 69 ++++++++++++++++++- packages/patch/src/__tests__/utils.ts | 2 +- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/packages/patch/src/__tests__/directives.test.ts b/packages/patch/src/__tests__/directives.test.ts index 245662eee5..3065ce2d78 100644 --- a/packages/patch/src/__tests__/directives.test.ts +++ b/packages/patch/src/__tests__/directives.test.ts @@ -1,6 +1,6 @@ import { expectPatchToMatch } from './utils.js'; -describe('directives', async () => { +describe('directives', () => { test('directiveAdded', async () => { const before = /* GraphQL */ ` scalar Food @@ -120,3 +120,70 @@ describe('directives', async () => { await expectPatchToMatch(before, after); }); }); + +describe('repeat directives', () => { + test('Directives Added', async () => { + const before = /* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake @flavor(flavor: "bread") { + radius: Int! + } + `; + const after = /* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on FIELD_DEFINITION + type Pancake + @flavor(flavor: "sweet") + @flavor(flavor: "bread") + @flavor(flavor: "chocolate") + @flavor(flavor: "strawberry") + { + radius: Int! + } + `; + await expectPatchToMatch(before, after); + }); + + test('Directives Removed', async () => { + const before = /* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake @flavor(flavor: "sweet") + @flavor(flavor: "bread") + @flavor(flavor: "chocolate") + @flavor(flavor: "strawberry") + { + radius: Int! + } + `; + const after = /* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on FIELD_DEFINITION + type Pancake @flavor(flavor: "bread") { + radius: Int! + } + `; + await expectPatchToMatch(before, after); + }); + + test('Directive Arguments', async () => { + const before = /* GraphQL */ ` + directive @flavor(flavor: String) repeatable on OBJECT + type Pancake @flavor(flavor: "sweet") + @flavor(flavor: "bread") + @flavor(flavor: "chocolate") + @flavor(flavor: "strawberry") + { + radius: Int! + } + `; + const after = /* GraphQL */ ` + directive @flavor(flavor: String) repeatable on OBJECT + type Pancake @flavor + @flavor(flavor: "bread") + @flavor(flavor: "banana") + @flavor(flavor: "strawberry") + { + radius: Int! + } + `; + await expectPatchToMatch(before, after); + }); +}) diff --git a/packages/patch/src/__tests__/utils.ts b/packages/patch/src/__tests__/utils.ts index 40fb4db91f..2d1aa6defc 100644 --- a/packages/patch/src/__tests__/utils.ts +++ b/packages/patch/src/__tests__/utils.ts @@ -16,6 +16,6 @@ export async function expectPatchToMatch(before: string, after: string): Promise throwOnError: true, debug: process.env.DEBUG === 'true', }); - expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patched)); + expect(printSortedSchema(patched)).toBe(printSortedSchema(schemaB)); return changes; } From a8fc04dae7af36f58cdf003e1d2ddbdcd7d3cfde Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:31:49 -0700 Subject: [PATCH 33/73] support repeat directives. Fix argument mutual change --- .../__tests__/diff/directive-usage.test.ts | 77 +++++++- .../core/__tests__/diff/directive.test.ts | 33 ++++ packages/core/__tests__/diff/enum.test.ts | 3 +- packages/core/__tests__/diff/schema.test.ts | 178 ++++++------------ packages/core/src/diff/argument.ts | 2 +- .../core/src/diff/changes/directive-usage.ts | 79 +++++--- packages/core/src/utils/compare.ts | 25 +-- .../patch/src/__tests__/directives.test.ts | 27 ++- packages/patch/src/index.ts | 163 ++++++++-------- .../patch/src/patches/directive-usages.ts | 129 ++++++++----- packages/patch/src/patches/directives.ts | 14 +- packages/patch/src/patches/enum.ts | 7 +- packages/patch/src/patches/fields.ts | 17 +- packages/patch/src/patches/inputs.ts | 9 +- packages/patch/src/patches/interfaces.ts | 4 +- packages/patch/src/patches/schema.ts | 5 +- packages/patch/src/patches/types.ts | 7 +- packages/patch/src/patches/unions.ts | 4 +- packages/patch/src/types.ts | 10 +- 19 files changed, 482 insertions(+), 311 deletions(-) diff --git a/packages/core/__tests__/diff/directive-usage.test.ts b/packages/core/__tests__/diff/directive-usage.test.ts index 196a7afbd8..1f1446ddcc 100644 --- a/packages/core/__tests__/diff/directive-usage.test.ts +++ b/packages/core/__tests__/diff/directive-usage.test.ts @@ -211,23 +211,45 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - expect(changes).toHaveLength(1); + expect(changes).toHaveLength(2); expect(changes).toMatchInlineSnapshot(` [ { "criticality": { "level": "DANGEROUS", - "reason": "Directive 'tag' was removed from field 'a'", + "reason": "Changing an argument on a directive can change runtime behavior.", }, - "message": "Directive 'tag' was removed from field 'Query.a'", + "message": "Argument 'name' was removed from '@tag'", "meta": { + "directiveName": "tag", "directiveRepeatedTimes": 2, - "fieldName": "a", - "removedDirectiveName": "tag", - "typeName": "Query", + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + "removedArgumentName": "name", }, - "path": "Query.a.@tag", - "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_REMOVED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Argument 'name' was added to '@tag'", + "meta": { + "addedArgumentName": "name", + "addedArgumentValue": ""bar"", + "directiveName": "tag", + "directiveRepeatedTimes": 2, + "oldArgumentValue": ""foo2"", + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_ADDED", }, ] `); @@ -289,7 +311,7 @@ describe('directive-usage', () => { `); const changes = await diff(a, b); - expect(changes).toHaveLength(2); + expect(changes).toHaveLength(4); expect(changes).toMatchInlineSnapshot(` [ { @@ -322,6 +344,43 @@ describe('directive-usage', () => { "path": "Query.a.@tag", "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", }, + { + "criticality": { + "level": "DANGEROUS", + "reason": "Changing an argument on a directive can change runtime behavior.", + }, + "message": "Argument 'name' was removed from '@tag'", + "meta": { + "directiveName": "tag", + "directiveRepeatedTimes": 1, + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + "removedArgumentName": "name", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_REMOVED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Argument 'name' was added to '@tag'", + "meta": { + "addedArgumentName": "name", + "addedArgumentValue": ""mid"", + "directiveName": "tag", + "directiveRepeatedTimes": 1, + "oldArgumentValue": ""start"", + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_ADDED", + }, ] `); }); diff --git a/packages/core/__tests__/diff/directive.test.ts b/packages/core/__tests__/diff/directive.test.ts index 52655698e0..cda02a8741 100644 --- a/packages/core/__tests__/diff/directive.test.ts +++ b/packages/core/__tests__/diff/directive.test.ts @@ -381,5 +381,38 @@ describe('directive', () => { expect(change.type).toEqual('DIRECTIVE_REPEATABLE_REMOVED'); expect(change.message).toEqual("Directive 'a' removed repeatable."); }); + + test('complex remove', async () => { + const before = buildSchema(/* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake @flavor(flavor: "bread") { + radius: Int! + } + `); + const after = buildSchema(/* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake + @flavor(flavor: "sweet") + @flavor(flavor: "bread") + @flavor(flavor: "chocolate") + @flavor(flavor: "strawberry") { + radius: Int! + } + `); + const changes = await diff(before, after); + expect(changes.map(c => `[${c.criticality.level}] ${c.path}: ${c.message}`)) + .toMatchInlineSnapshot(` + [ + "[DANGEROUS] Pancake.@flavor: Directive 'flavor' was added to object 'Pancake'", + "[NON_BREAKING] Pancake.@flavor.flavor: Argument 'flavor' was added to '@flavor'", + "[DANGEROUS] Pancake.@flavor: Directive 'flavor' was added to object 'Pancake'", + "[NON_BREAKING] Pancake.@flavor.flavor: Argument 'flavor' was added to '@flavor'", + "[DANGEROUS] Pancake.@flavor: Directive 'flavor' was added to object 'Pancake'", + "[NON_BREAKING] Pancake.@flavor.flavor: Argument 'flavor' was added to '@flavor'", + "[DANGEROUS] Pancake.@flavor.flavor: Argument 'flavor' was removed from '@flavor'", + "[NON_BREAKING] Pancake.@flavor.flavor: Argument 'flavor' was added to '@flavor'", + ] + `); + }); }); }); diff --git a/packages/core/__tests__/diff/enum.test.ts b/packages/core/__tests__/diff/enum.test.ts index 1e1a58e6e8..c4cf1c53cb 100644 --- a/packages/core/__tests__/diff/enum.test.ts +++ b/packages/core/__tests__/diff/enum.test.ts @@ -180,7 +180,8 @@ describe('enum', () => { const changes = await diff(a, b); const change = findFirstChangeByPath(changes, 'enumA.A.@deprecated'); - expect(changes.length).toEqual(1); + // Changes include deprecated change, directive remove argument, and directive add argument. + expect(changes.length).toEqual(3); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.message).toEqual( `Enum value 'enumA.A' deprecation reason changed from 'Old Reason' to 'New Reason'`, diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index a10e1d6537..da75efb265 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -276,129 +276,61 @@ test('huge test', async () => { `); const changes = await diff(schemaA, schemaB); - - for (const msg of [ - `Type 'WillBeRemoved' was removed`, - `Type 'DType' was added`, - `Field 'Query.a' description changed from 'Just a simple string' to 'This description has been changed'`, - `Argument 'anArg: String' was removed from field 'Query.a'`, - `Field 'Query.b' changed type from 'BType' to 'Int!'`, - `Description 'The Query Root of this schema' on type 'Query' has changed to 'Query Root description changed'`, - `'BType' kind changed from 'ObjectTypeDefinition' to 'InputObjectTypeDefinition'`, - `Input field 'b' was removed from input object type 'AInput'`, - `Input field 'c' of type 'String!' was added to input object type 'AInput'`, - `Input field 'AInput.a' description changed from 'a' to 'changed'`, - `Input field 'AInput.a' default value changed from '"1"' to '1'`, - `Input field 'ListInput.a' default value changed from '[ 'foo' ]' to '[ 'bar' ]'`, - `Input field 'AInput.a' changed type from 'String' to 'Int'`, - `'CType' object implements 'AnInterface' interface`, - `Field 'c' was removed from object type 'CType'`, - `Field 'b' was added to object type 'CType'`, - `Deprecation reason on field 'CType.a' has changed from 'whynot' to 'cuz'`, - `Argument 'arg: Int' added to field 'CType.a'`, - `Default value '10' was added to argument 'arg' on field 'CType.d'`, - `Member 'BType' was removed from Union type 'MyUnion'`, - `Member 'DType' was added to Union type 'MyUnion'`, - `Field 'anotherInterfaceField' was removed from interface 'AnotherInterface'`, - `Field 'b' was added to interface 'AnotherInterface'`, - `'WithInterfaces' object type no longer implements 'AnotherInterface' interface`, - `Description for argument 'a' on field 'WithArguments.a' changed from 'Meh' to 'Description for a'`, - `Type for argument 'b' on field 'WithArguments.a' changed from 'String' to 'String!'`, - `Default value for argument 'arg' on field 'WithArguments.b' changed from '1' to '2'`, - `Enum value 'C' was removed from enum 'Options'`, - `Enum value 'D' was added to enum 'Options'`, - `Description 'Stuff' was added to enum value 'Options.A'`, - `Enum value 'Options.E' was deprecated with reason 'No longer supported'`, - `Enum value 'Options.F' deprecation reason changed from 'Old' to 'New'`, - `Directive 'willBeRemoved' was removed`, - `Directive 'yolo2' was added`, - `Directive 'yolo' description changed from 'Old' to 'New'`, - `Location 'FRAGMENT_SPREAD' was removed from directive 'yolo'`, - `Location 'INLINE_FRAGMENT' was removed from directive 'yolo'`, - `Location 'FIELD_DEFINITION' was added to directive 'yolo'`, - `Argument 'willBeRemoved' was removed from directive 'yolo'`, - `Description for argument 'someArg' on directive 'yolo' changed from 'Included when true.' to 'someArg does stuff'`, - `Type for argument 'someArg' on directive 'yolo' changed from 'Boolean!' to 'String!'`, - `Default value '"Test"' was added to argument 'anotherArg' on directive 'yolo'`, - ]) { - try { - expect(changes.some(c => c.message === msg)).toEqual(true); - } catch (e) { - console.log(`Couldn't find: ${msg}`); - const match = findBestMatch( - msg, - changes.map(c => ({ - typeId: c.path || '', - value: c.message, - })), - ); - - if (match.bestMatch) { - console.log(`We found a similar change: ${match.bestMatch.target.value}`); - } - - throw e; - } - } - - 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', - 'CType', - 'CType.b', - 'CType.c', - 'CType.a.@deprecated', - 'CType.a.arg', - 'CType.d.arg', - 'MyUnion', - 'MyUnion', - 'AnotherInterface.b', - 'AnotherInterface.anotherInterfaceField', - 'WithInterfaces', - 'WithArguments.a.a', - 'WithArguments.a.b', - 'WithArguments.b.arg', - 'Options.D', - 'Options.C', - 'Options.A', - 'Options.E.@deprecated', - 'Options.E.@deprecated', - 'Options.F.@deprecated', - '@yolo2', - '@yolo2', - '@yolo2', - '@willBeRemoved', - '@yolo', - '@yolo', - '@yolo', - '@yolo', - '@yolo.willBeRemoved', - '@yolo.someArg', - '@yolo.someArg', - '@yolo.anotherArg', - ]; - for (const path of expectedPaths) { - try { - 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); + expect(changes.map(c => `[${c.criticality.level}] ${c.path}: ${c.message}`)) + .toMatchInlineSnapshot(` + [ + "[BREAKING] WillBeRemoved: Type 'WillBeRemoved' was removed", + "[NON_BREAKING] DType: Type 'DType' was added", + "[NON_BREAKING] DType.b: Field 'b' was added to object type 'DType'", + "[BREAKING] AInput.b: Input field 'b' was removed from input object type 'AInput'", + "[BREAKING] AInput.c: Input field 'c' of type 'String!' was added to input object type 'AInput'", + "[NON_BREAKING] AInput.a: Input field 'AInput.a' description changed from 'a' to 'changed'", + "[DANGEROUS] AInput.a: Input field 'AInput.a' default value changed from '"1"' to '1'", + "[BREAKING] AInput.a: Input field 'AInput.a' changed type from 'String' to 'Int'", + "[DANGEROUS] ListInput.a: Input field 'ListInput.a' default value changed from '[ 'foo' ]' to '[ 'bar' ]'", + "[NON_BREAKING] Query.a: Field 'Query.a' description changed from 'Just a simple string' to 'This description has been changed'", + "[BREAKING] Query.a.anArg: Argument 'anArg: String' was removed from field 'Query.a'", + "[BREAKING] Query.b: Field 'Query.b' changed type from 'BType' to 'Int!'", + "[NON_BREAKING] Query: Description 'The Query Root of this schema' on type 'Query' has changed to 'Query Root description changed'", + "[BREAKING] BType: 'BType' kind changed from 'ObjectTypeDefinition' to 'InputObjectTypeDefinition'", + "[DANGEROUS] CType: 'CType' object implements 'AnInterface' interface", + "[BREAKING] CType.c: Field 'c' was removed from object type 'CType'", + "[NON_BREAKING] CType.b: Field 'b' was added to object type 'CType'", + "[NON_BREAKING] CType.a.@deprecated: Deprecation reason on field 'CType.a' has changed from 'whynot' to 'cuz'", + "[DANGEROUS] CType.a.arg: Argument 'arg: Int' added to field 'CType.a'", + "[DANGEROUS] CType.a.@deprecated.reason: Argument 'reason' was removed from '@deprecated'", + "[NON_BREAKING] CType.a.@deprecated.reason: Argument 'reason' was added to '@deprecated'", + "[DANGEROUS] CType.d.arg: Default value '10' was added to argument 'arg' on field 'CType.d'", + "[BREAKING] MyUnion: Member 'BType' was removed from Union type 'MyUnion'", + "[DANGEROUS] MyUnion: Member 'DType' was added to Union type 'MyUnion'", + "[BREAKING] AnotherInterface.anotherInterfaceField: Field 'anotherInterfaceField' was removed from interface 'AnotherInterface'", + "[NON_BREAKING] AnotherInterface.b: Field 'b' was added to interface 'AnotherInterface'", + "[BREAKING] WithInterfaces: 'WithInterfaces' object type no longer implements 'AnotherInterface' interface", + "[NON_BREAKING] WithArguments.a.a: Description for argument 'a' on field 'WithArguments.a' changed from 'Meh' to 'Description for a'", + "[BREAKING] WithArguments.a.b: Type for argument 'b' on field 'WithArguments.a' changed from 'String' to 'String!'", + "[DANGEROUS] WithArguments.b.arg: Default value for argument 'arg' on field 'WithArguments.b' changed from '1' to '2'", + "[BREAKING] Options.C: Enum value 'C' was removed from enum 'Options'", + "[DANGEROUS] Options.D: Enum value 'D' was added to enum 'Options'", + "[NON_BREAKING] Options.A: Description 'Stuff' was added to enum value 'Options.A'", + "[NON_BREAKING] Options.E.@deprecated: Enum value 'Options.E' was deprecated with reason 'No longer supported'", + "[NON_BREAKING] Options.E.@deprecated: Directive 'deprecated' was added to enum value 'Options.E'", + "[NON_BREAKING] Options.F.@deprecated: Enum value 'Options.F' deprecation reason changed from 'Old' to 'New'", + "[DANGEROUS] Options.F.@deprecated.reason: Argument 'reason' was removed from '@deprecated'", + "[NON_BREAKING] Options.F.@deprecated.reason: Argument 'reason' was added to '@deprecated'", + "[BREAKING] @willBeRemoved: Directive 'willBeRemoved' was removed", + "[NON_BREAKING] @yolo2: Directive 'yolo2' was added", + "[NON_BREAKING] @yolo2: Location 'FIELD' was added to directive 'yolo2'", + "[NON_BREAKING] @yolo2: Argument 'someArg' was added to directive 'yolo2'", + "[NON_BREAKING] @yolo: Directive 'yolo' description changed from 'Old' to 'New'", + "[NON_BREAKING] @yolo: Location 'FIELD_DEFINITION' was added to directive 'yolo'", + "[BREAKING] @yolo: Location 'FRAGMENT_SPREAD' was removed from directive 'yolo'", + "[BREAKING] @yolo: Location 'INLINE_FRAGMENT' was removed from directive 'yolo'", + "[BREAKING] @yolo.willBeRemoved: Argument 'willBeRemoved' was removed from directive 'yolo'", + "[NON_BREAKING] @yolo.someArg: Description for argument 'someArg' on directive 'yolo' changed from 'Included when true.' to 'someArg does stuff'", + "[BREAKING] @yolo.someArg: Type for argument 'someArg' on directive 'yolo' changed from 'Boolean!' to 'String!'", + "[DANGEROUS] @yolo.anotherArg: Default value '"Test"' was added to argument 'anotherArg' on directive 'yolo'", + ] + `); }); 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 d8faa7f64c..e9e9d915fa 100644 --- a/packages/core/src/diff/argument.ts +++ b/packages/core/src/diff/argument.ts @@ -5,7 +5,7 @@ import { GraphQLObjectType, Kind, } from 'graphql'; -import { compareDirectiveLists, compareLists, diffArrays, isNotEqual } from '../utils/compare.js'; +import { compareDirectiveLists, diffArrays, isNotEqual } from '../utils/compare.js'; import { fieldArgumentDefaultChanged, fieldArgumentDescriptionChanged, diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index b27d950b9e..0dfd9d72c1 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -1005,7 +1005,9 @@ export function directiveUsageArgumentRemovedFromMeta( args.meta.parentArgumentName, `@${args.meta.directiveName}`, args.meta.removedArgumentName, - ].join('.'), + ] + .filter(a => a !== null) + .join('.'), meta: args.meta, }; } @@ -1046,28 +1048,61 @@ export function directiveUsageChanged( ); }, + /** Treat a mutual change as a removal then addition. */ onMutual(argument) { - directiveUsageArgumentAddedFromMeta({ - 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, - directiveRepeatedTimes: - // @todo should this lastly fall back to the GraphQLSchema? - directiveRepeatTimes( - (parentEnumValue || parentArgument || parentField || parentType)?.astNode - ?.directives ?? [], - newDirective, - ), - }, - }); + if ( + argument.oldVersion && + print(argument.oldVersion.value) === print(argument.newVersion.value) + ) { + return; + } + + if (argument.oldVersion) { + addChange( + directiveUsageArgumentRemovedFromMeta({ + type: ChangeType.DirectiveUsageArgumentRemoved, + meta: { + removedArgumentName: argument.oldVersion.name.value, + directiveName: newDirective.name.value, + parentTypeName: parentType?.name ?? null, + parentFieldName: parentField?.name ?? null, + parentArgumentName: parentArgument?.name ?? null, + parentEnumValueName: parentEnumValue?.name ?? null, + directiveRepeatedTimes: + // @todo should this lastly fall back to the GraphQLSchema? + directiveRepeatTimes( + (parentEnumValue || parentArgument || parentField || parentType)?.astNode + ?.directives ?? [], + newDirective, + ), + }, + }), + ); + } + + addChange( + directiveUsageArgumentAddedFromMeta({ + 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, + directiveRepeatedTimes: + // @todo should this lastly fall back to the GraphQLSchema? + directiveRepeatTimes( + (parentEnumValue || parentArgument || parentField || parentType)?.astNode + ?.directives ?? [], + newDirective, + ), + }, + }), + ); }, onRemoved(argument) { diff --git a/packages/core/src/utils/compare.ts b/packages/core/src/utils/compare.ts index 1c636ade77..422133a1c8 100644 --- a/packages/core/src/utils/compare.ts +++ b/packages/core/src/utils/compare.ts @@ -1,4 +1,4 @@ -import { NameNode } from 'graphql'; +import { NameNode, print } from 'graphql'; export function keyMap(list: readonly T[], keyFn: (item: T) => string): Record { return list.reduce((map, item) => { @@ -96,16 +96,16 @@ export function compareLists( } if (callbacks) { - if (callbacks.onAdded) { - for (const item of added) { - callbacks.onAdded(item); - } - } if (callbacks.onRemoved) { for (const item of removed) { callbacks.onRemoved(item); } } + if (callbacks.onAdded) { + for (const item of added) { + callbacks.onAdded(item); + } + } if (callbacks.onMutual) { for (const item of mutual) { callbacks.onMutual(item); @@ -136,6 +136,7 @@ export function compareDirectiveLists( onMutual?(t: { newVersion: T; oldVersion: T | null }): void; }, ) { + // collect all the usages, in order, by name for the old and new version of the schema const oldMap = keyMapList(oldList, ({ name }) => extractName(name)); const newMap = keyMapList(newList, ({ name }) => extractName(name)); @@ -152,7 +153,7 @@ export function compareDirectiveLists( } else { // if so, then consider this a mutual change, and remove it from the list of newItems to avoid counting it in the future const [newItem, ...rest] = newItems; - if (rest.length > 1) { + if (rest.length > 0) { newMap[extractName(oldItem.name)] = rest as [T] & T[]; } else { delete newMap[extractName(oldItem.name)]; @@ -180,16 +181,16 @@ export function compareDirectiveLists( } if (callbacks) { - if (callbacks.onAdded) { - for (const item of added) { - callbacks.onAdded(item); - } - } if (callbacks.onRemoved) { for (const item of removed) { callbacks.onRemoved(item); } } + if (callbacks.onAdded) { + for (const item of added) { + callbacks.onAdded(item); + } + } if (callbacks.onMutual) { for (const item of mutual) { callbacks.onMutual(item); diff --git a/packages/patch/src/__tests__/directives.test.ts b/packages/patch/src/__tests__/directives.test.ts index 3065ce2d78..819cc2d423 100644 --- a/packages/patch/src/__tests__/directives.test.ts +++ b/packages/patch/src/__tests__/directives.test.ts @@ -130,13 +130,12 @@ describe('repeat directives', () => { } `; const after = /* GraphQL */ ` - directive @flavor(flavor: String!) repeatable on FIELD_DEFINITION + directive @flavor(flavor: String!) repeatable on OBJECT type Pancake @flavor(flavor: "sweet") @flavor(flavor: "bread") @flavor(flavor: "chocolate") - @flavor(flavor: "strawberry") - { + @flavor(flavor: "strawberry") { radius: Int! } `; @@ -146,16 +145,16 @@ describe('repeat directives', () => { test('Directives Removed', async () => { const before = /* GraphQL */ ` directive @flavor(flavor: String!) repeatable on OBJECT - type Pancake @flavor(flavor: "sweet") + type Pancake + @flavor(flavor: "sweet") @flavor(flavor: "bread") @flavor(flavor: "chocolate") - @flavor(flavor: "strawberry") - { + @flavor(flavor: "strawberry") { radius: Int! } `; const after = /* GraphQL */ ` - directive @flavor(flavor: String!) repeatable on FIELD_DEFINITION + directive @flavor(flavor: String!) repeatable on OBJECT type Pancake @flavor(flavor: "bread") { radius: Int! } @@ -166,24 +165,24 @@ describe('repeat directives', () => { test('Directive Arguments', async () => { const before = /* GraphQL */ ` directive @flavor(flavor: String) repeatable on OBJECT - type Pancake @flavor(flavor: "sweet") + type Pancake + @flavor(flavor: "sweet") @flavor(flavor: "bread") @flavor(flavor: "chocolate") - @flavor(flavor: "strawberry") - { + @flavor(flavor: "strawberry") { radius: Int! } `; const after = /* GraphQL */ ` directive @flavor(flavor: String) repeatable on OBJECT - type Pancake @flavor + type Pancake + @flavor @flavor(flavor: "bread") @flavor(flavor: "banana") - @flavor(flavor: "strawberry") - { + @flavor(flavor: "strawberry") { radius: Int! } `; await expectPatchToMatch(before, after); }); -}) +}); diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 0f2b92e18d..154950971a 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -99,7 +99,7 @@ import { typeRemoved, } from './patches/types.js'; import { unionMemberAdded, unionMemberRemoved } from './patches/unions.js'; -import { PatchConfig, SchemaNode } from './types.js'; +import { PatchConfig, PatchContext, SchemaNode } from './types.js'; import { debugPrintChange } from './utils.js'; export * as errors from './errors.js'; @@ -227,6 +227,9 @@ export function patchCoordinatesAST( patchConfig?: PatchConfig, ): DocumentNode { const config: PatchConfig = patchConfig ?? {}; + const context: PatchContext = { + removedDirectiveNodes: [], + }; for (const change of changes) { if (config.debug) { @@ -235,311 +238,311 @@ export function patchCoordinatesAST( switch (change.type) { case ChangeType.SchemaMutationTypeChanged: { - schemaMutationTypeChanged(change, schemaNodes, config); + schemaMutationTypeChanged(change, schemaNodes, config, context); break; } case ChangeType.SchemaQueryTypeChanged: { - schemaQueryTypeChanged(change, schemaNodes, config); + schemaQueryTypeChanged(change, schemaNodes, config, context); break; } case ChangeType.SchemaSubscriptionTypeChanged: { - schemaSubscriptionTypeChanged(change, schemaNodes, config); + schemaSubscriptionTypeChanged(change, schemaNodes, config, context); break; } case ChangeType.DirectiveAdded: { - directiveAdded(change, nodesByCoordinate, config); + directiveAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveRemoved: { - directiveRemoved(change, nodesByCoordinate, config); + directiveRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveArgumentAdded: { - directiveArgumentAdded(change, nodesByCoordinate, config); + directiveArgumentAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveArgumentRemoved: { - directiveArgumentRemoved(change, nodesByCoordinate, config); + directiveArgumentRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveLocationAdded: { - directiveLocationAdded(change, nodesByCoordinate, config); + directiveLocationAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveLocationRemoved: { - directiveLocationRemoved(change, nodesByCoordinate, config); + directiveLocationRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.EnumValueAdded: { - enumValueAdded(change, nodesByCoordinate, config); + enumValueAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.EnumValueDeprecationReasonAdded: { - enumValueDeprecationReasonAdded(change, nodesByCoordinate, config); + enumValueDeprecationReasonAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.EnumValueDeprecationReasonChanged: { - enumValueDeprecationReasonChanged(change, nodesByCoordinate, config); + enumValueDeprecationReasonChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldAdded: { - fieldAdded(change, nodesByCoordinate, config); + fieldAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldRemoved: { - fieldRemoved(change, nodesByCoordinate, config); + fieldRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldTypeChanged: { - fieldTypeChanged(change, nodesByCoordinate, config); + fieldTypeChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldArgumentAdded: { - fieldArgumentAdded(change, nodesByCoordinate, config); + fieldArgumentAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldArgumentTypeChanged: { - fieldArgumentTypeChanged(change, nodesByCoordinate, config); + fieldArgumentTypeChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldArgumentRemoved: { - fieldArgumentRemoved(change, nodesByCoordinate, config); + fieldArgumentRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldArgumentDescriptionChanged: { - fieldArgumentDescriptionChanged(change, nodesByCoordinate, config); + fieldArgumentDescriptionChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldArgumentDefaultChanged: { - fieldArgumentDefaultChanged(change, nodesByCoordinate, config); + fieldArgumentDefaultChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldDeprecationAdded: { - fieldDeprecationAdded(change, nodesByCoordinate, config); + fieldDeprecationAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldDeprecationRemoved: { - fieldDeprecationRemoved(change, nodesByCoordinate, config); + fieldDeprecationRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldDeprecationReasonAdded: { - fieldDeprecationReasonAdded(change, nodesByCoordinate, config); + fieldDeprecationReasonAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldDeprecationReasonChanged: { - fieldDeprecationReasonChanged(change, nodesByCoordinate, config); + fieldDeprecationReasonChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldDescriptionAdded: { - fieldDescriptionAdded(change, nodesByCoordinate, config); + fieldDescriptionAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldDescriptionChanged: { - fieldDescriptionChanged(change, nodesByCoordinate, config); + fieldDescriptionChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.InputFieldAdded: { - inputFieldAdded(change, nodesByCoordinate, config); + inputFieldAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.InputFieldRemoved: { - inputFieldRemoved(change, nodesByCoordinate, config); + inputFieldRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.InputFieldDescriptionAdded: { - inputFieldDescriptionAdded(change, nodesByCoordinate, config); + inputFieldDescriptionAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.InputFieldTypeChanged: { - inputFieldTypeChanged(change, nodesByCoordinate, config); + inputFieldTypeChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.InputFieldDescriptionChanged: { - inputFieldDescriptionChanged(change, nodesByCoordinate, config); + inputFieldDescriptionChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.InputFieldDescriptionRemoved: { - inputFieldDescriptionRemoved(change, nodesByCoordinate, config); + inputFieldDescriptionRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.InputFieldDefaultValueChanged: { - inputFieldDefaultValueChanged(change, nodesByCoordinate, config); + inputFieldDefaultValueChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.ObjectTypeInterfaceAdded: { - objectTypeInterfaceAdded(change, nodesByCoordinate, config); + objectTypeInterfaceAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.ObjectTypeInterfaceRemoved: { - objectTypeInterfaceRemoved(change, nodesByCoordinate, config); + objectTypeInterfaceRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.TypeDescriptionAdded: { - typeDescriptionAdded(change, nodesByCoordinate, config); + typeDescriptionAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.TypeDescriptionChanged: { - typeDescriptionChanged(change, nodesByCoordinate, config); + typeDescriptionChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.TypeDescriptionRemoved: { - typeDescriptionRemoved(change, nodesByCoordinate, config); + typeDescriptionRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.TypeAdded: { - typeAdded(change, nodesByCoordinate, config); + typeAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.UnionMemberAdded: { - unionMemberAdded(change, nodesByCoordinate, config); + unionMemberAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.UnionMemberRemoved: { - unionMemberRemoved(change, nodesByCoordinate, config); + unionMemberRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.TypeRemoved: { - typeRemoved(change, nodesByCoordinate, config); + typeRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.EnumValueRemoved: { - enumValueRemoved(change, nodesByCoordinate, config); + enumValueRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.EnumValueDescriptionChanged: { - enumValueDescriptionChanged(change, nodesByCoordinate, config); + enumValueDescriptionChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.FieldDescriptionRemoved: { - fieldDescriptionRemoved(change, nodesByCoordinate, config); + fieldDescriptionRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveArgumentDefaultValueChanged: { - directiveArgumentDefaultValueChanged(change, nodesByCoordinate, config); + directiveArgumentDefaultValueChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveArgumentDescriptionChanged: { - directiveArgumentDescriptionChanged(change, nodesByCoordinate, config); + directiveArgumentDescriptionChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveArgumentTypeChanged: { - directiveArgumentTypeChanged(change, nodesByCoordinate, config); + directiveArgumentTypeChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveDescriptionChanged: { - directiveDescriptionChanged(change, nodesByCoordinate, config); + directiveDescriptionChanged(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveRepeatableAdded: { - directiveRepeatableAdded(change, nodesByCoordinate, config); + directiveRepeatableAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveRepeatableRemoved: { - directiveRepeatableRemoved(change, nodesByCoordinate, config); + directiveRepeatableRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageArgumentDefinitionAdded: { - directiveUsageArgumentDefinitionAdded(change, nodesByCoordinate, config); + directiveUsageArgumentDefinitionAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageArgumentDefinitionRemoved: { - directiveUsageArgumentDefinitionRemoved(change, nodesByCoordinate, config); + directiveUsageArgumentDefinitionRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageEnumAdded: { - directiveUsageEnumAdded(change, nodesByCoordinate, config); + directiveUsageEnumAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageEnumRemoved: { - directiveUsageEnumRemoved(change, nodesByCoordinate, config); + directiveUsageEnumRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageEnumValueAdded: { - directiveUsageEnumValueAdded(change, nodesByCoordinate, config); + directiveUsageEnumValueAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageEnumValueRemoved: { - directiveUsageEnumValueRemoved(change, nodesByCoordinate, config); + directiveUsageEnumValueRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageFieldAdded: { - directiveUsageFieldAdded(change, nodesByCoordinate, config); + directiveUsageFieldAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageFieldDefinitionAdded: { - directiveUsageFieldDefinitionAdded(change, nodesByCoordinate, config); + directiveUsageFieldDefinitionAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageFieldDefinitionRemoved: { - directiveUsageFieldDefinitionRemoved(change, nodesByCoordinate, config); + directiveUsageFieldDefinitionRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageFieldRemoved: { - directiveUsageFieldRemoved(change, nodesByCoordinate, config); + directiveUsageFieldRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageInputFieldDefinitionAdded: { - directiveUsageInputFieldDefinitionAdded(change, nodesByCoordinate, config); + directiveUsageInputFieldDefinitionAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageInputFieldDefinitionRemoved: { - directiveUsageInputFieldDefinitionRemoved(change, nodesByCoordinate, config); + directiveUsageInputFieldDefinitionRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageInputObjectAdded: { - directiveUsageInputObjectAdded(change, nodesByCoordinate, config); + directiveUsageInputObjectAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageInputObjectRemoved: { - directiveUsageInputObjectRemoved(change, nodesByCoordinate, config); + directiveUsageInputObjectRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageInterfaceAdded: { - directiveUsageInterfaceAdded(change, nodesByCoordinate, config); + directiveUsageInterfaceAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageInterfaceRemoved: { - directiveUsageInterfaceRemoved(change, nodesByCoordinate, config); + directiveUsageInterfaceRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageObjectAdded: { - directiveUsageObjectAdded(change, nodesByCoordinate, config); + directiveUsageObjectAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageObjectRemoved: { - directiveUsageObjectRemoved(change, nodesByCoordinate, config); + directiveUsageObjectRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageScalarAdded: { - directiveUsageScalarAdded(change, nodesByCoordinate, config); + directiveUsageScalarAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageScalarRemoved: { - directiveUsageScalarRemoved(change, nodesByCoordinate, config); + directiveUsageScalarRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageSchemaAdded: { - directiveUsageSchemaAdded(change, schemaNodes, nodesByCoordinate, config); + directiveUsageSchemaAdded(change, schemaNodes, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageSchemaRemoved: { - directiveUsageSchemaRemoved(change, schemaNodes, nodesByCoordinate, config); + directiveUsageSchemaRemoved(change, schemaNodes, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageUnionMemberAdded: { - directiveUsageUnionMemberAdded(change, nodesByCoordinate, config); + directiveUsageUnionMemberAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageUnionMemberRemoved: { - directiveUsageUnionMemberRemoved(change, nodesByCoordinate, config); + directiveUsageUnionMemberRemoved(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageArgumentAdded: { - directiveUsageArgumentAdded(change, nodesByCoordinate, config); + directiveUsageArgumentAdded(change, nodesByCoordinate, config, context); break; } case ChangeType.DirectiveUsageArgumentRemoved: { - directiveUsageArgumentRemoved(change, nodesByCoordinate, config); + directiveUsageArgumentRemoved(change, nodesByCoordinate, config, context); break; } default: { @@ -548,6 +551,10 @@ export function patchCoordinatesAST( } } + for (const node of context.removedDirectiveNodes) { + node.directives = node.directives?.filter(d => d != null); + } + return { kind: Kind.DOCUMENT, // filter out the non-definition nodes (e.g. field definitions) diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index 522be98146..0d1cc9eb68 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -1,5 +1,5 @@ /* eslint-disable unicorn/no-negated-condition */ -import { ArgumentNode, ASTNode, DirectiveNode, Kind, parseValue } from 'graphql'; +import { ArgumentNode, ASTNode, DirectiveNode, Kind, parseValue, print, ValueNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeAlreadyExistsError, @@ -12,9 +12,10 @@ import { DeletedAncestorCoordinateNotFoundError, DeletedAttributeNotFoundError, handleError, + ValueMismatchError, } from '../errors.js'; import { nameNode } from '../node-templates.js'; -import { PatchConfig, SchemaNode } from '../types.js'; +import { PatchConfig, PatchContext, SchemaNode } from '../types.js'; import { findNamedNode, parentPath } from '../utils.js'; export type DirectiveUsageAddedChange = @@ -53,7 +54,8 @@ function findNthDirective(directives: readonly DirectiveNode[], name: string, n: let lastDirective: DirectiveNode | undefined; let count = 0; for (const d of directives) { - if (d.name.value === name) { + // @note this nullish check is critical even though the types dont recognize it. + if (d?.name.value === name) { lastDirective = d; count += 1; if (count === n) { @@ -68,6 +70,7 @@ function directiveUsageDefinitionAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError( @@ -130,6 +133,7 @@ function schemaDirectiveUsageDefinitionAdded( schemaNodes: SchemaNode[], nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError( @@ -185,6 +189,7 @@ function schemaDirectiveUsageDefinitionRemoved( schemaNodes: SchemaNode[], _nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { let deleted = false; for (const node of schemaNodes) { @@ -220,6 +225,7 @@ function directiveUsageDefinitionRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -255,9 +261,19 @@ function directiveUsageDefinitionRemoved( config, ); } else { - parentNode.directives = parentNode.directives?.filter( - d => d.name.value !== change.meta.removedDirectiveName, - ); + // null the value out for filtering later. The index is important so it can't be removed. + // @note the nullish check is critical here even though the types dont show it + const removedIndex = (parentNode.directives ?? []).findIndex(d => d === directiveNode); + const directiveList = [...(parentNode.directives ?? [])]; + if (removedIndex !== -1) { + (directiveList[removedIndex] as any) = undefined; + } + parentNode.directives = directiveList; + context.removedDirectiveNodes.push(parentNode); + // parentNode.directives = parentNode.direct + // ives?.filter( + // d => d.name.value !== change.meta.removedDirectiveName, + // ); } } @@ -265,160 +281,180 @@ export function directiveUsageArgumentDefinitionAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageArgumentDefinitionRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageEnumAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageEnumRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageEnumValueAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageEnumValueRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageFieldAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageFieldDefinitionAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageFieldDefinitionRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageFieldRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageInputFieldDefinitionAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageInputFieldDefinitionRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageInputObjectAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageInputObjectRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageInterfaceAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageInterfaceRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageObjectAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageObjectRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageScalarAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageScalarRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageSchemaAdded( @@ -426,8 +462,9 @@ export function directiveUsageSchemaAdded( schemaDefs: SchemaNode[], nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return schemaDirectiveUsageDefinitionAdded(change, schemaDefs, nodeByPath, config); + return schemaDirectiveUsageDefinitionAdded(change, schemaDefs, nodeByPath, config, context); } export function directiveUsageSchemaRemoved( @@ -435,31 +472,40 @@ export function directiveUsageSchemaRemoved( schemaDefs: SchemaNode[], nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return schemaDirectiveUsageDefinitionRemoved(change, schemaDefs, nodeByPath, config); + return schemaDirectiveUsageDefinitionRemoved(change, schemaDefs, nodeByPath, config, context); } export function directiveUsageUnionMemberAdded( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionAdded(change, nodeByPath, config); + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); } export function directiveUsageUnionMemberRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + context: PatchContext, ) { - return directiveUsageDefinitionRemoved(change, nodeByPath, config); + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); } export function directiveUsageArgumentAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { + // ignore because deprecated is handled by its own change... consider adjusting this. + if (change.meta.directiveName === 'deprecated') { + return; + } + if (!change.path) { handleError(change, new ChangePathMissingError(change), config); return; @@ -485,16 +531,14 @@ export function directiveUsageArgumentAdded( ); } else if (directiveNode.kind === Kind.DIRECTIVE) { const existing = findNamedNode(directiveNode.arguments, change.meta.addedArgumentName); + // "ArgumentAdded" but argument already exists. if (existing) { handleError( change, - new AddedAttributeAlreadyExistsError( - directiveNode.kind, - 'arguments', - change.meta.addedArgumentName, - ), + new ValueMismatchError(directiveNode.kind, null, print(existing.value)), config, ); + (existing.value as ValueNode) = parseValue(change.meta.addedArgumentValue); } else { const argNode: ArgumentNode = { kind: Kind.ARGUMENT, @@ -520,20 +564,21 @@ export function directiveUsageArgumentRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); return; } - const parentNode = nodeByPath.get(parentPath(change.path)) as + // Must use double parentPath b/c the path is referencing the argument + const parentNode = nodeByPath.get(parentPath(parentPath(change.path))) as | { kind: Kind; directives?: DirectiveNode[] } | undefined; - // if ( - // change.meta.directiveName === 'deprecated' && - // parentNode && (parentNode.kind === Kind.FIELD_DEFINITION || parentNode.kind === Kind.ENUM_VALUE_DEFINITION) - // ) { - // return; // ignore because deprecated is handled by its own change... consider adjusting this. - // } + + // ignore because deprecated is handled by its own change... consider adjusting this. + if (change.meta.directiveName === 'deprecated') { + return; + } const directiveNode = findNthDirective( parentNode?.directives ?? [], diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index e7d34d804e..df1040cd87 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -25,7 +25,7 @@ import { ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; -import { PatchConfig } from '../types.js'; +import { PatchConfig, PatchContext } from '../types.js'; import { deleteNamedNode, findNamedNode, @@ -37,6 +37,7 @@ export function directiveAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (change.path === undefined) { handleError(change, new ChangePathMissingError(change), config); @@ -68,6 +69,7 @@ export function directiveRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE_DEFINITION, config); if (existing) { @@ -79,6 +81,7 @@ export function directiveArgumentAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -136,6 +139,7 @@ export function directiveArgumentRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const argNode = getDeletedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); if (argNode) { @@ -158,6 +162,7 @@ export function directiveLocationAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -203,6 +208,7 @@ export function directiveLocationRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -252,6 +258,7 @@ export function directiveDescriptionChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -295,6 +302,7 @@ export function directiveArgumentDefaultValueChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -341,6 +349,7 @@ export function directiveArgumentDescriptionChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -384,6 +393,7 @@ export function directiveArgumentTypeChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -419,6 +429,7 @@ export function directiveRepeatableAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -456,6 +467,7 @@ export function directiveRepeatableRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index 825705eb96..b6afdcbc40 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -22,13 +22,14 @@ import { ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; -import type { PatchConfig } from '../types'; +import type { PatchConfig, PatchContext } from '../types'; import { findNamedNode, getDeprecatedDirectiveNode, parentPath } from '../utils.js'; export function enumValueRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -86,6 +87,7 @@ export function enumValueAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const enumValuePath = change.path!; const enumNode = nodeByPath.get(parentPath(enumValuePath)) as @@ -128,6 +130,7 @@ export function enumValueDeprecationReasonAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -187,6 +190,7 @@ export function enumValueDeprecationReasonChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -249,6 +253,7 @@ export function enumValueDescriptionChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index e7eb40265d..9be2c66c52 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -28,7 +28,7 @@ import { ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; -import type { PatchConfig } from '../types'; +import type { PatchConfig, PatchContext } from '../types'; import { assertChangeHasPath, assertValueMatch, @@ -45,6 +45,7 @@ export function fieldTypeChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const node = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); if (node) { @@ -64,6 +65,7 @@ export function fieldRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -104,6 +106,7 @@ export function fieldAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (assertChangeHasPath(change, config)) { const changedNode = nodeByPath.get(change.path); @@ -159,6 +162,7 @@ export function fieldArgumentAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (assertChangeHasPath(change, config)) { const existing = nodeByPath.get(change.path); @@ -211,6 +215,7 @@ export function fieldArgumentTypeChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); if (existingArg) { @@ -229,6 +234,7 @@ export function fieldArgumentDescriptionChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); if (existingArg) { @@ -249,6 +255,7 @@ export function fieldArgumentDefaultChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); if (existingArg) { @@ -269,6 +276,7 @@ export function fieldArgumentRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.ARGUMENT, config); if (existing) { @@ -306,6 +314,7 @@ export function fieldDeprecationReasonChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { assertChangeHasPath(change, config); const parentNode = nodeByPath.get(parentPath(change.path!)) as @@ -346,6 +355,7 @@ export function fieldDeprecationReasonAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { assertChangeHasPath(change, config); const parentNode = nodeByPath.get(parentPath(change.path!)) as @@ -378,6 +388,7 @@ export function fieldDeprecationAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (assertChangeHasPath(change, config)) { const fieldNode = nodeByPath.get(parentPath(change.path)); @@ -428,6 +439,7 @@ export function fieldDeprecationRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (assertChangeHasPath(change, config)) { const fieldNode = getDeletedParentNodeOfKind( @@ -455,6 +467,7 @@ export function fieldDescriptionAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); if (fieldNode) { @@ -468,6 +481,7 @@ export function fieldDescriptionRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -498,6 +512,7 @@ export function fieldDescriptionChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); if (fieldNode) { diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index 3a309924df..a0c7a98ba1 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -22,7 +22,7 @@ import { ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; -import type { PatchConfig } from '../types.js'; +import type { PatchConfig, PatchContext } from '../types.js'; import { assertValueMatch, getChangedNodeOfKind, @@ -34,6 +34,7 @@ export function inputFieldAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -100,6 +101,7 @@ export function inputFieldRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -149,6 +151,7 @@ export function inputFieldDescriptionAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -184,6 +187,7 @@ export function inputFieldTypeChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const inputFieldNode = getChangedNodeOfKind( change, @@ -208,6 +212,7 @@ export function inputFieldDefaultValueChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -253,6 +258,7 @@ export function inputFieldDescriptionChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const existingNode = getChangedNodeOfKind( change, @@ -282,6 +288,7 @@ export function inputFieldDescriptionRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const existingNode = getDeletedNodeOfKind( change, diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index 8c765595f7..1bfff525bd 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -10,13 +10,14 @@ import { handleError, } from '../errors.js'; import { namedTypeNode } from '../node-templates.js'; -import type { PatchConfig } from '../types'; +import type { PatchConfig, PatchContext } from '../types'; import { findNamedNode } from '../utils.js'; export function objectTypeInterfaceAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -69,6 +70,7 @@ export function objectTypeInterfaceRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); diff --git a/packages/patch/src/patches/schema.ts b/packages/patch/src/patches/schema.ts index 4a36f0014f..151870f46d 100644 --- a/packages/patch/src/patches/schema.ts +++ b/packages/patch/src/patches/schema.ts @@ -3,12 +3,13 @@ import { Kind, NameNode, OperationTypeDefinitionNode, OperationTypeNode } from ' import type { Change, ChangeType } from '@graphql-inspector/core'; import { handleError, ValueMismatchError } from '../errors.js'; import { nameNode } from '../node-templates.js'; -import { PatchConfig, SchemaNode } from '../types.js'; +import { PatchConfig, PatchContext, SchemaNode } from '../types.js'; export function schemaMutationTypeChanged( change: Change, schemaNodes: SchemaNode[], config: PatchConfig, + _context: PatchContext, ) { for (const schemaNode of schemaNodes) { const mutation = schemaNode.operationTypes?.find( @@ -58,6 +59,7 @@ export function schemaQueryTypeChanged( change: Change, schemaNodes: SchemaNode[], config: PatchConfig, + _context: PatchContext, ) { for (const schemaNode of schemaNodes) { const query = schemaNode.operationTypes?.find( @@ -103,6 +105,7 @@ export function schemaSubscriptionTypeChanged( change: Change, schemaNodes: SchemaNode[], config: PatchConfig, + _context: PatchContext, ) { for (const schemaNode of schemaNodes) { const sub = schemaNode.operationTypes?.find( diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index e564d86b46..aaafde22f2 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -11,12 +11,13 @@ import { ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; -import type { PatchConfig } from '../types'; +import type { PatchConfig, PatchContext } from '../types'; export function typeAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -43,6 +44,7 @@ export function typeRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -78,6 +80,7 @@ export function typeDescriptionAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -110,6 +113,7 @@ export function typeDescriptionChanged( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); @@ -153,6 +157,7 @@ export function typeDescriptionRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { if (!change.path) { handleError(change, new ChangePathMissingError(change), config); diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts index 4597455e17..831301d551 100644 --- a/packages/patch/src/patches/unions.ts +++ b/packages/patch/src/patches/unions.ts @@ -8,13 +8,14 @@ import { handleError, } from '../errors.js'; import { namedTypeNode } from '../node-templates.js'; -import { PatchConfig } from '../types.js'; +import { PatchConfig, PatchContext } from '../types.js'; import { findNamedNode, parentPath } from '../utils.js'; export function unionMemberAdded( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const changedPath = change.path!; const union = nodeByPath.get(parentPath(changedPath)) as @@ -47,6 +48,7 @@ export function unionMemberRemoved( change: Change, nodeByPath: Map, config: PatchConfig, + _context: PatchContext, ) { const changedPath = change.path!; const union = nodeByPath.get(parentPath(changedPath)) as diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts index fa7fefa35a..1f8c421269 100644 --- a/packages/patch/src/types.ts +++ b/packages/patch/src/types.ts @@ -1,4 +1,4 @@ -import type { SchemaDefinitionNode, SchemaExtensionNode } from 'graphql'; +import type { DirectiveNode, SchemaDefinitionNode, SchemaExtensionNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; export type AdditionChangeType = @@ -55,3 +55,11 @@ export type PatchConfig = { */ debug?: boolean; }; + +export type PatchContext = { + /** + * tracks which nodes have have their directives removed so that patch can + * go back and filter out the null records in the lists. + */ + removedDirectiveNodes: Array<{ directives?: DirectiveNode[] }>; +}; From e7911967b08cf0cfd311f331c661d245e624431a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 30 Oct 2025 13:35:52 -0700 Subject: [PATCH 34/73] fix import --- packages/core/src/utils/compare.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/utils/compare.ts b/packages/core/src/utils/compare.ts index 422133a1c8..badc2d92b8 100644 --- a/packages/core/src/utils/compare.ts +++ b/packages/core/src/utils/compare.ts @@ -1,4 +1,4 @@ -import { NameNode, print } from 'graphql'; +import { NameNode } from 'graphql'; export function keyMap(list: readonly T[], keyFn: (item: T) => string): Record { return list.reduce((map, item) => { From bcd9cef7c4f89b85c7540e71510465fa6c9a0351 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 13:48:37 -0700 Subject: [PATCH 35/73] Detailed changelog --- .changeset/seven-jars-yell.md | 151 ++++++++++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 5 deletions(-) diff --git a/.changeset/seven-jars-yell.md b/.changeset/seven-jars-yell.md index 285ad49b5a..9af2cec98e 100644 --- a/.changeset/seven-jars-yell.md +++ b/.changeset/seven-jars-yell.md @@ -2,8 +2,149 @@ '@graphql-inspector/core': major --- -Add "@graphql-inspector/patch" package. -"diff" includes all nested changes when a node is added. -Additional meta fields added for more accurate severity levels. -Implement more change types for directives. -Adjust path on numerous change types to consistently map to the AST node being changed. +This is a major change to `@graphql-inspector/core` and introduces a new `@graphql-inspector/patch` package, which applies changes output from `diff` on top of a schema -- essentially rebasing these changes onto any schema. + +These changes include: +- Numerous adjustments to Change types to create more accurate severity levels, such as a boolean indicating if the change applies to a new type or an existing type. +- Adjustmented the "path" on several change types in order to consistently map to the exact AST node being changed. For example, `EnumValueDeprecationReasonAdded`'s path previously referenced the enumValue (e.g. `EnumName.value`), not the deprecated directive (e.g. `EnumName.value.@deprecated`). +- Added new attributes in order to provide enough context for a new "@graphql-inspector/patch" function to apply changes accurately. +- Added support for repeatable directives +- Includes all nested changes in `diff` output when a new node is added. This can dramatically increase the number of changes listed which can be noisy, but it makes it possible for "@graphql-inspector/patch" to apply all changes from a schema. This can be optionally filtered using a newly exported `DiffRule.ignoreNestedAdditions` rule. + +For example, given an existing schema: + +```graphql +type User { + id: ID! + name: String! +} +``` + +And a diff schema: + +```graphql +type User { + id: ID! + name: String! + address: Address +} + +type Address { + line1: String! + line2: String! +} +``` + +Then previously the output would be: + +```json +[ + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Type 'Address' was added", + "meta": { + "addedTypeKind": "ObjectTypeDefinition", + "addedTypeName": "Address", + }, + "path": "Address", + "type": "TYPE_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'address' was added to object type 'User'", + "meta": { + "addedFieldName": "address", + "addedFieldReturnType": "Address", + "typeName": "User", + "typeType": "object type", + }, + "path": "User.address", + "type": "FIELD_ADDED", + }, +] +``` + +But now the output also includes the fields inside the new `Address` type: + +```json +[ + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Type 'Address' was added", + "meta": { + "addedTypeKind": "ObjectTypeDefinition", + "addedTypeName": "Address", + }, + "path": "Address", + "type": "TYPE_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'line1' was added to object type 'Address'", + "meta": { + "addedFieldName": "line1", + "addedFieldReturnType": "String!", + "typeName": "Address", + "typeType": "object type", + }, + "path": "Address.line1", + "type": "FIELD_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'line2' was added to object type 'Address'", + "meta": { + "addedFieldName": "line2", + "addedFieldReturnType": "String!", + "typeName": "Address", + "typeType": "object type", + }, + "path": "Address.line2", + "type": "FIELD_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'address' was added to object type 'User'", + "meta": { + "addedFieldName": "address", + "addedFieldReturnType": "Address", + "typeName": "User", + "typeType": "object type", + }, + "path": "User.address", + "type": "FIELD_ADDED", + }, +] +``` + +These additional changes can be filtered using a new rule: + +```js +import { DiffRule, diff } from "@graphql-inspector/core"; +const changes = await diff(a, b, [DiffRule.ignoreNestedAdditions]); +``` + +To apply the changes output to a schema using `patch`: + +```js +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)); +``` + +If working from an AST, you may alternatively use the exported `patch` function. But be careful to make sure directives are included in your AST or those changes will be missed. From 82e9bb7e71aad54c753d7712ae4f6f2493f47f38 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:06:11 -0700 Subject: [PATCH 36/73] Hide nested changes from CLI by default --- .changeset/long-rules-shop.md | 5 +++++ packages/commands/diff/src/index.ts | 31 ++++++++++++++++++----------- 2 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 .changeset/long-rules-shop.md diff --git a/.changeset/long-rules-shop.md b/.changeset/long-rules-shop.md new file mode 100644 index 0000000000..7a36062ef9 --- /dev/null +++ b/.changeset/long-rules-shop.md @@ -0,0 +1,5 @@ +--- +'@graphql-inspector/diff-command': minor +--- + +Added option to include nested changes. Use `--rule showNestedAdditions`. diff --git a/packages/commands/diff/src/index.ts b/packages/commands/diff/src/index.ts index 61e36af53d..3139090021 100644 --- a/packages/commands/diff/src/index.ts +++ b/packages/commands/diff/src/index.ts @@ -32,20 +32,27 @@ export async function handler(input: { ? resolveCompletionHandler(input.onComplete) : failOnBreakingChanges; - const rules = input.rules - ? input.rules - .filter(isString) - .map((name): Rule => { - const rule = resolveRule(name); + let showNestedAdditions = false; + const rules = [...input.rules ?? []] + .filter(isString) + .map((name): Rule | undefined => { + if (name === 'showNestedAdditions') { + showNestedAdditions = true; + return; + } - if (!rule) { - throw new Error(`Rule '${name}' does not exist!\n`); - } + const rule = resolveRule(name); - return rule; - }) - .filter(f => f) - : []; + if (!rule) { + throw new Error(`Rule '${name}' does not exist!\n`); + } + + return rule; + }) + .filter((f): f is NonNullable => !!f); + if (!showNestedAdditions) { + rules.push(DiffRule.ignoreNestedAdditions); + } const changes = await diffSchema(input.oldSchema, input.newSchema, rules, { checkUsage: input.onUsage ? resolveUsageHandler(input.onUsage) : undefined, From c8a03d13f4a641137caf201f6fe9895b34b0048d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:10:58 -0700 Subject: [PATCH 37/73] Move readme --- packages/patch/{src => }/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename packages/patch/{src => }/README.md (96%) diff --git a/packages/patch/src/README.md b/packages/patch/README.md similarity index 96% rename from packages/patch/src/README.md rename to packages/patch/README.md index cd755be047..30acf55a2c 100644 --- a/packages/patch/src/README.md +++ b/packages/patch/README.md @@ -33,6 +33,5 @@ expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patched)); ## Remaining Work -- [] Support repeat directives -- [] Support extensions +- [] Support type extensions - [] Fully support schema operation types From 367f54439127d7ae7a8ffe4f6cc23f7783dab596 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 14:17:01 -0700 Subject: [PATCH 38/73] Add imports on patch on example --- packages/patch/README.md | 5 ++++- packages/patch/src/__tests__/utils.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/patch/README.md b/packages/patch/README.md index 30acf55a2c..c8eedfab67 100644 --- a/packages/patch/README.md +++ b/packages/patch/README.md @@ -5,12 +5,15 @@ This package applies a list of changes (output from `@graphql-inspector/core`'s ## Usage ```typescript +import { buildSchema } from "graphql"; +import { diff } from "@graphql-inspector/core"; +import { patchSchema } from "@graphql-inspector/patch"; + 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 diff --git a/packages/patch/src/__tests__/utils.ts b/packages/patch/src/__tests__/utils.ts index 2d1aa6defc..7357656e18 100644 --- a/packages/patch/src/__tests__/utils.ts +++ b/packages/patch/src/__tests__/utils.ts @@ -1,4 +1,4 @@ -import { buildSchema, GraphQLSchema, lexicographicSortSchema, parse, print } from 'graphql'; +import { buildSchema, type GraphQLSchema, lexicographicSortSchema } from 'graphql'; import { Change, diff } from '@graphql-inspector/core'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { patchSchema } from '../index.js'; From e51bbd79a0c7c77a143aecfd93d0c973c9d9fdba Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:08:35 -0700 Subject: [PATCH 39/73] Explain requireOldValueMatch --- packages/patch/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/patch/README.md b/packages/patch/README.md index c8eedfab67..4693ab7982 100644 --- a/packages/patch/README.md +++ b/packages/patch/README.md @@ -22,7 +22,7 @@ const patched = patchSchema(schemaA, changes); `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. +> The changes output from `diff` include the values, such as default argument values of the old schema. E.g. changing `foo(arg: String = "bar")` to `foo(arg: String = "foo")` would track that the previous default value was `"bar"`. By enabling this option, `patch` can throw an error when patching a schema where the value doesn't match what is expected. E.g. where `foo.arg`'s default value is _NOT_ `"bar"`. This will avoid overwriting conflicting changes. This is recommended if using an automated process for patching schema. `requireOldValueMatch?: boolean` From 41389696ad59deea299ec420aef54bd64920fa9b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:12:44 -0700 Subject: [PATCH 40/73] Rename expectPatchToMatch --- .../src/__tests__/directive-usage.test.ts | 54 +++++++++---------- .../patch/src/__tests__/directives.test.ts | 28 +++++----- packages/patch/src/__tests__/enum.test.ts | 14 ++--- packages/patch/src/__tests__/fields.test.ts | 30 +++++------ packages/patch/src/__tests__/inputs.test.ts | 14 ++--- .../patch/src/__tests__/interfaces.test.ts | 14 ++--- packages/patch/src/__tests__/types.test.ts | 14 ++--- packages/patch/src/__tests__/unions.test.ts | 6 +-- packages/patch/src/__tests__/utils.ts | 2 +- 9 files changed, 88 insertions(+), 88 deletions(-) diff --git a/packages/patch/src/__tests__/directive-usage.test.ts b/packages/patch/src/__tests__/directive-usage.test.ts index 25d76ec567..5676d01ea9 100644 --- a/packages/patch/src/__tests__/directive-usage.test.ts +++ b/packages/patch/src/__tests__/directive-usage.test.ts @@ -1,4 +1,4 @@ -import { expectPatchToMatch } from './utils.js'; +import { expectDiffAndPatchToMatch } from './utils.js'; const baseSchema = /* GraphQL */ ` schema { @@ -56,7 +56,7 @@ describe('directiveUsages: added', () => { old: String @deprecated(reason: "No good") } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageArgumentDefinitionAdded', async () => { @@ -102,7 +102,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageInputFieldDefinitionAdded', async () => { @@ -148,7 +148,7 @@ describe('directiveUsages: added', () => { foodName: String! @meta(name: "owner", value: "kitchen") } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageInputObjectAdded', async () => { @@ -194,7 +194,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageInterfaceAdded', async () => { @@ -240,7 +240,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageObjectAdded', async () => { @@ -286,7 +286,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageEnumAdded', async () => { @@ -332,7 +332,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageFieldDefinitionAdded', async () => { @@ -378,7 +378,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageUnionMemberAdded', async () => { @@ -424,7 +424,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageEnumValueAdded', async () => { @@ -470,7 +470,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageSchemaAdded', async () => { @@ -516,7 +516,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageScalarAdded', async () => { @@ -562,7 +562,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageFieldAdded', async () => { @@ -608,7 +608,7 @@ describe('directiveUsages: added', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); @@ -656,7 +656,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageInputFieldDefinitionRemoved', async () => { @@ -702,7 +702,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageInputObjectRemoved', async () => { @@ -748,7 +748,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageInterfaceRemoved', async () => { @@ -794,7 +794,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageObjectRemoved', async () => { @@ -840,7 +840,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageEnumRemoved', async () => { @@ -886,7 +886,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageFieldDefinitionRemoved', async () => { @@ -932,7 +932,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageUnionMemberRemoved', async () => { @@ -978,7 +978,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageEnumValueRemoved', async () => { @@ -1024,7 +1024,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageSchemaRemoved', async () => { @@ -1070,7 +1070,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageScalarRemoved', async () => { @@ -1116,7 +1116,7 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('schemaDirectiveUsageDefinitionAdded', async () => { @@ -1162,7 +1162,7 @@ describe('directiveUsages: removed', () => { foodName: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('schemaDirectiveUsageDefinitionRemoved', async () => { @@ -1208,6 +1208,6 @@ describe('directiveUsages: removed', () => { } `; const after = baseSchema; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); diff --git a/packages/patch/src/__tests__/directives.test.ts b/packages/patch/src/__tests__/directives.test.ts index 819cc2d423..29b45c0042 100644 --- a/packages/patch/src/__tests__/directives.test.ts +++ b/packages/patch/src/__tests__/directives.test.ts @@ -1,4 +1,4 @@ -import { expectPatchToMatch } from './utils.js'; +import { expectDiffAndPatchToMatch } from './utils.js'; describe('directives', () => { test('directiveAdded', async () => { @@ -9,7 +9,7 @@ describe('directives', () => { scalar Food directive @tasty on FIELD_DEFINITION `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveRemoved', async () => { @@ -20,7 +20,7 @@ describe('directives', () => { const after = /* GraphQL */ ` scalar Food `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveArgumentAdded', async () => { @@ -32,7 +32,7 @@ describe('directives', () => { scalar Food directive @tasty(reason: String) on FIELD_DEFINITION `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveArgumentRemoved', async () => { @@ -44,7 +44,7 @@ describe('directives', () => { scalar Food directive @tasty on FIELD_DEFINITION `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveLocationAdded', async () => { @@ -56,7 +56,7 @@ describe('directives', () => { scalar Food directive @tasty(reason: String) on FIELD_DEFINITION | OBJECT `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveArgumentDefaultValueChanged', async () => { @@ -68,7 +68,7 @@ describe('directives', () => { scalar Food directive @tasty(reason: String = "It tastes good.") on FIELD_DEFINITION `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveDescriptionChanged', async () => { @@ -83,7 +83,7 @@ describe('directives', () => { """ directive @tasty(reason: String) on FIELD_DEFINITION `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveArgumentTypeChanged', async () => { @@ -95,7 +95,7 @@ describe('directives', () => { scalar Food directive @tasty(scale: Int!) on FIELD_DEFINITION `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveRepeatableAdded', async () => { @@ -107,7 +107,7 @@ describe('directives', () => { scalar Food directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveRepeatableRemoved', async () => { @@ -117,7 +117,7 @@ describe('directives', () => { const after = /* GraphQL */ ` directive @tasty(scale: Int!) on FIELD_DEFINITION `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); @@ -139,7 +139,7 @@ describe('repeat directives', () => { radius: Int! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('Directives Removed', async () => { @@ -159,7 +159,7 @@ describe('repeat directives', () => { radius: Int! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('Directive Arguments', async () => { @@ -183,6 +183,6 @@ describe('repeat directives', () => { radius: Int! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); diff --git a/packages/patch/src/__tests__/enum.test.ts b/packages/patch/src/__tests__/enum.test.ts index 8e9dcc96c3..6c9ba88c04 100644 --- a/packages/patch/src/__tests__/enum.test.ts +++ b/packages/patch/src/__tests__/enum.test.ts @@ -1,4 +1,4 @@ -import { expectPatchToMatch } from './utils.js'; +import { expectDiffAndPatchToMatch } from './utils.js'; describe('enumValue', () => { test('enumValueRemoved', async () => { @@ -15,7 +15,7 @@ describe('enumValue', () => { ERROR } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('enumValueAdded', async () => { @@ -32,7 +32,7 @@ describe('enumValue', () => { SUPER_BROKE } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('enumValueDeprecationReasonAdded', async () => { @@ -50,7 +50,7 @@ describe('enumValue', () => { SUPER_BROKE @deprecated(reason: "Error is enough") } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('enumValueDescriptionChanged: Added', async () => { @@ -69,7 +69,7 @@ describe('enumValue', () => { ERROR } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('enumValueDescriptionChanged: Changed', async () => { @@ -91,7 +91,7 @@ describe('enumValue', () => { ERROR } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('enumValueDescriptionChanged: Removed', async () => { @@ -110,6 +110,6 @@ describe('enumValue', () => { ERROR } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); diff --git a/packages/patch/src/__tests__/fields.test.ts b/packages/patch/src/__tests__/fields.test.ts index 9c4e63568d..9501c4e3ea 100644 --- a/packages/patch/src/__tests__/fields.test.ts +++ b/packages/patch/src/__tests__/fields.test.ts @@ -1,4 +1,4 @@ -import { expectPatchToMatch } from './utils.js'; +import { expectDiffAndPatchToMatch } from './utils.js'; describe('fields', () => { test('fieldTypeChanged', async () => { @@ -12,7 +12,7 @@ describe('fields', () => { id: String! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldRemoved', async () => { @@ -27,7 +27,7 @@ describe('fields', () => { id: ID! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldAdded', async () => { @@ -42,7 +42,7 @@ describe('fields', () => { name: String } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldAdded to new type', async () => { @@ -56,7 +56,7 @@ describe('fields', () => { name: String } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldArgumentAdded', async () => { @@ -72,7 +72,7 @@ describe('fields', () => { chat(firstMessage: String): ChatSession } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldArgumentTypeChanged', async () => { @@ -88,7 +88,7 @@ describe('fields', () => { chat(id: ID!): ChatSession } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldArgumentDescriptionChanged', async () => { @@ -112,7 +112,7 @@ describe('fields', () => { ): ChatSession } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldDeprecationReasonAdded', async () => { @@ -128,7 +128,7 @@ describe('fields', () => { chat: ChatSession @deprecated(reason: "Use Query.initiateChat") } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldDeprecationAdded', async () => { @@ -144,7 +144,7 @@ describe('fields', () => { chat: ChatSession @deprecated } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldDeprecationAdded: with reason', async () => { @@ -160,7 +160,7 @@ describe('fields', () => { chat: ChatSession @deprecated(reason: "Because no one chats anymore") } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldDeprecationRemoved', async () => { @@ -176,7 +176,7 @@ describe('fields', () => { chat: ChatSession } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldDescriptionAdded', async () => { @@ -195,7 +195,7 @@ describe('fields', () => { chat: ChatSession } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldDescriptionChanged', async () => { @@ -217,7 +217,7 @@ describe('fields', () => { chat: ChatSession } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldDescriptionRemoved', async () => { @@ -236,6 +236,6 @@ describe('fields', () => { chat: ChatSession } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); diff --git a/packages/patch/src/__tests__/inputs.test.ts b/packages/patch/src/__tests__/inputs.test.ts index 23c1a74722..5a87e06347 100644 --- a/packages/patch/src/__tests__/inputs.test.ts +++ b/packages/patch/src/__tests__/inputs.test.ts @@ -1,4 +1,4 @@ -import { expectPatchToMatch } from './utils.js'; +import { expectDiffAndPatchToMatch } from './utils.js'; describe('inputs', () => { test('inputFieldAdded', async () => { @@ -13,7 +13,7 @@ describe('inputs', () => { other: String } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('inputFieldAdded to new input', async () => { @@ -30,7 +30,7 @@ describe('inputs', () => { foo(foo: FooInput): Foo } `; - const changes = await expectPatchToMatch(before, after); + const changes = await expectDiffAndPatchToMatch(before, after); }); test('inputFieldRemoved', async () => { @@ -45,7 +45,7 @@ describe('inputs', () => { id: ID! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('inputFieldDescriptionAdded', async () => { @@ -62,7 +62,7 @@ describe('inputs', () => { id: ID! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('inputFieldTypeChanged', async () => { @@ -76,7 +76,7 @@ describe('inputs', () => { id: ID } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('inputFieldDescriptionRemoved', async () => { @@ -93,6 +93,6 @@ describe('inputs', () => { id: ID! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); diff --git a/packages/patch/src/__tests__/interfaces.test.ts b/packages/patch/src/__tests__/interfaces.test.ts index 80cbe8c01d..40cafa5cdc 100644 --- a/packages/patch/src/__tests__/interfaces.test.ts +++ b/packages/patch/src/__tests__/interfaces.test.ts @@ -1,4 +1,4 @@ -import { expectPatchToMatch } from './utils.js'; +import { expectDiffAndPatchToMatch } from './utils.js'; describe('interfaces', () => { test('objectTypeInterfaceAdded', async () => { @@ -18,7 +18,7 @@ describe('interfaces', () => { id: ID! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('objectTypeInterfaceRemoved', async () => { @@ -39,7 +39,7 @@ describe('interfaces', () => { id: ID! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldAdded', async () => { @@ -62,7 +62,7 @@ describe('interfaces', () => { name: String } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('fieldRemoved', async () => { @@ -85,7 +85,7 @@ describe('interfaces', () => { id: ID! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageAdded', async () => { @@ -108,7 +108,7 @@ describe('interfaces', () => { id: ID! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('directiveUsageRemoved', async () => { @@ -131,6 +131,6 @@ describe('interfaces', () => { id: ID! } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); diff --git a/packages/patch/src/__tests__/types.test.ts b/packages/patch/src/__tests__/types.test.ts index a65bfab7c4..9e2e00bc21 100644 --- a/packages/patch/src/__tests__/types.test.ts +++ b/packages/patch/src/__tests__/types.test.ts @@ -1,4 +1,4 @@ -import { expectPatchToMatch } from './utils.js'; +import { expectDiffAndPatchToMatch } from './utils.js'; describe('enum', () => { test('typeRemoved', async () => { @@ -11,7 +11,7 @@ describe('enum', () => { const after = /* GraphQL */ ` scalar Foo `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('typeAdded', async () => { @@ -28,7 +28,7 @@ describe('enum', () => { SUPER_BROKE } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('typeAdded Mutation', async () => { @@ -46,7 +46,7 @@ describe('enum', () => { dooFoo: String } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('typeDescriptionChanged: Added', async () => { @@ -63,7 +63,7 @@ describe('enum', () => { OK } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('typeDescriptionChanged: Changed', async () => { @@ -83,7 +83,7 @@ describe('enum', () => { OK } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('typeDescriptionChanged: Removed', async () => { @@ -100,6 +100,6 @@ describe('enum', () => { OK } `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); diff --git a/packages/patch/src/__tests__/unions.test.ts b/packages/patch/src/__tests__/unions.test.ts index 61eb4df41e..57e6d3520e 100644 --- a/packages/patch/src/__tests__/unions.test.ts +++ b/packages/patch/src/__tests__/unions.test.ts @@ -1,4 +1,4 @@ -import { expectPatchToMatch } from './utils.js'; +import { expectDiffAndPatchToMatch } from './utils.js'; describe('union', () => { test('unionMemberAdded', async () => { @@ -20,7 +20,7 @@ describe('union', () => { } union U = A | B `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); test('unionMemberRemoved', async () => { @@ -42,6 +42,6 @@ describe('union', () => { } union U = A `; - await expectPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); }); }); diff --git a/packages/patch/src/__tests__/utils.ts b/packages/patch/src/__tests__/utils.ts index 7357656e18..1da82ac6fd 100644 --- a/packages/patch/src/__tests__/utils.ts +++ b/packages/patch/src/__tests__/utils.ts @@ -7,7 +7,7 @@ function printSortedSchema(schema: GraphQLSchema) { return printSchemaWithDirectives(lexicographicSortSchema(schema)); } -export async function expectPatchToMatch(before: string, after: string): Promise[]> { +export async function expectDiffAndPatchToMatch(before: string, after: string): Promise[]> { const schemaA = buildSchema(before, { assumeValid: true, assumeValidSDL: true }); const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); From 9bffca44080c11208df1f205e7b9475db85de72f Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:13:00 -0700 Subject: [PATCH 41/73] prettier --- packages/commands/diff/src/index.ts | 2 +- packages/patch/src/__tests__/utils.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/commands/diff/src/index.ts b/packages/commands/diff/src/index.ts index 3139090021..f25cd4298f 100644 --- a/packages/commands/diff/src/index.ts +++ b/packages/commands/diff/src/index.ts @@ -33,7 +33,7 @@ export async function handler(input: { : failOnBreakingChanges; let showNestedAdditions = false; - const rules = [...input.rules ?? []] + const rules = [...(input.rules ?? [])] .filter(isString) .map((name): Rule | undefined => { if (name === 'showNestedAdditions') { diff --git a/packages/patch/src/__tests__/utils.ts b/packages/patch/src/__tests__/utils.ts index 1da82ac6fd..cc26907d51 100644 --- a/packages/patch/src/__tests__/utils.ts +++ b/packages/patch/src/__tests__/utils.ts @@ -1,4 +1,4 @@ -import { buildSchema, type GraphQLSchema, lexicographicSortSchema } from 'graphql'; +import { buildSchema, lexicographicSortSchema, type GraphQLSchema } from 'graphql'; import { Change, diff } from '@graphql-inspector/core'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { patchSchema } from '../index.js'; @@ -7,7 +7,10 @@ function printSortedSchema(schema: GraphQLSchema) { return printSchemaWithDirectives(lexicographicSortSchema(schema)); } -export async function expectDiffAndPatchToMatch(before: string, after: string): Promise[]> { +export async function expectDiffAndPatchToMatch( + before: string, + after: string, +): Promise[]> { const schemaA = buildSchema(before, { assumeValid: true, assumeValidSDL: true }); const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); From 9972ac7576db5cf070b89247dc3f927a44193be4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:14:54 -0700 Subject: [PATCH 42/73] clarify option in types --- packages/patch/src/types.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts index 1f8c421269..993b3800a7 100644 --- a/packages/patch/src/types.ts +++ b/packages/patch/src/types.ts @@ -33,9 +33,7 @@ export type PatchConfig = { 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. + * The changes output from `diff` include the values, such as default argument values of the old schema. E.g. changing `foo(arg: String = "bar")` to `foo(arg: String = "foo")` would track that the previous default value was `"bar"`. By enabling this option, `patch` can throw an error when patching a schema where the value doesn't match what is expected. E.g. where `foo.arg`'s default value is _NOT_ `"bar"`. This will avoid overwriting conflicting changes. This is recommended if using an automated process for patching schema. */ requireOldValueMatch?: boolean; From 3c870c708b29d754a7c6789055cfa6537e355903 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 15:59:27 -0700 Subject: [PATCH 43/73] Add meta check to enum value removed --- packages/core/__tests__/diff/enum.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/__tests__/diff/enum.test.ts b/packages/core/__tests__/diff/enum.test.ts index c4cf1c53cb..aec1b7e318 100644 --- a/packages/core/__tests__/diff/enum.test.ts +++ b/packages/core/__tests__/diff/enum.test.ts @@ -82,6 +82,11 @@ describe('enum', () => { expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.criticality.reason).toBeDefined(); expect(change.message).toEqual(`Enum value 'C' was added to enum 'enumA'`); + expect(change.meta).toMatchObject({ + addedEnumValueName: 'C', + enumName: 'enumA', + addedToNewType: false, + }); }); test('value removed', async () => { @@ -113,6 +118,10 @@ describe('enum', () => { expect(change.criticality.level).toEqual(CriticalityLevel.Breaking); expect(change.criticality.reason).toBeDefined(); expect(change.message).toEqual(`Enum value 'B' was removed from enum 'enumA'`); + expect(change.meta).toMatchObject({ + removedEnumValueName: 'B', + enumName: 'enumA', + }); }); test('description changed', async () => { From a89eec1b3dc05df11aa1b204142b8edf12ad13c2 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:17:45 -0700 Subject: [PATCH 44/73] inputFieldAddedFromMeta non-breaking cases --- packages/core/src/diff/changes/directive-usage.ts | 1 - packages/core/src/diff/changes/input.ts | 11 +++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 0dfd9d72c1..1f2bbe3148 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -1012,7 +1012,6 @@ export function directiveUsageArgumentRemovedFromMeta( }; } -// @question should this be separate change events for every case for safety? export function directiveUsageChanged( oldDirective: ConstDirectiveNode | null, newDirective: ConstDirectiveNode, diff --git a/packages/core/src/diff/changes/input.ts b/packages/core/src/diff/changes/input.ts index bbb793581c..7b8012470d 100644 --- a/packages/core/src/diff/changes/input.ts +++ b/packages/core/src/diff/changes/input.ts @@ -56,13 +56,12 @@ export function buildInputFieldAddedMessage(args: InputFieldAddedChange['meta']) export function inputFieldAddedFromMeta(args: InputFieldAddedChange) { return { type: ChangeType.InputFieldAdded, - criticality: args.meta.addedToNewType - ? { - level: CriticalityLevel.NonBreaking, - } - : args.meta.isAddedInputFieldTypeNullable || args.meta.addedFieldDefault !== undefined + criticality: + args.meta.addedToNewType || + args.meta.isAddedInputFieldTypeNullable || + args.meta.addedFieldDefault !== undefined ? { - level: CriticalityLevel.Dangerous, + level: CriticalityLevel.NonBreaking, } : { level: CriticalityLevel.Breaking, From ee61960f5bff3234f112feaf78ea807b2e7d0a63 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:19:22 -0700 Subject: [PATCH 45/73] Adjust objectTypeInterfaceAddedFromMeta reason --- packages/core/src/diff/changes/object.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/diff/changes/object.ts b/packages/core/src/diff/changes/object.ts index 36ab895464..cc7dc2e8aa 100644 --- a/packages/core/src/diff/changes/object.ts +++ b/packages/core/src/diff/changes/object.ts @@ -17,7 +17,7 @@ export function objectTypeInterfaceAddedFromMeta(args: ObjectTypeInterfaceAddedC criticality: { 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.', + 'Adding an interface to an object type may break existing clients that are consuming a field returning the interface type that are not programming defensively against this new possible type.', }, message: buildObjectTypeInterfaceAddedMessage(args.meta), meta: args.meta, From 083528d1ed78ed92dfbad2f6c20d7bf5204dfd93 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:26:57 -0700 Subject: [PATCH 46/73] add path check on union patches --- packages/patch/src/patches/unions.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts index 831301d551..d7bfd0b6eb 100644 --- a/packages/patch/src/patches/unions.ts +++ b/packages/patch/src/patches/unions.ts @@ -3,6 +3,7 @@ import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeAlreadyExistsError, ChangedAncestorCoordinateNotFoundError, + ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedAttributeNotFoundError, handleError, @@ -17,8 +18,11 @@ export function unionMemberAdded( config: PatchConfig, _context: PatchContext, ) { - const changedPath = change.path!; - const union = nodeByPath.get(parentPath(changedPath)) as + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + const union = nodeByPath.get(parentPath(change.path)) as | (ASTNode & { types?: NamedTypeNode[] }) | undefined; if (union) { @@ -50,8 +54,11 @@ export function unionMemberRemoved( config: PatchConfig, _context: PatchContext, ) { - const changedPath = change.path!; - const union = nodeByPath.get(parentPath(changedPath)) as + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + const union = nodeByPath.get(parentPath(change.path)) as | (ASTNode & { types?: NamedTypeNode[] }) | undefined; if (union) { From 15b957a16e2e1d4a83705fbbb83135c83543320d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:44:29 -0700 Subject: [PATCH 47/73] Fix tests; remove unique patch logic for deprecation --- packages/core/__tests__/diff/input.test.ts | 6 +- packages/patch/src/index.ts | 30 ---- .../patch/src/patches/directive-usages.ts | 20 --- packages/patch/src/patches/enum.ts | 131 +------------- packages/patch/src/patches/fields.ts | 162 ------------------ packages/patch/src/utils.ts | 8 - 6 files changed, 4 insertions(+), 353 deletions(-) diff --git a/packages/core/__tests__/diff/input.test.ts b/packages/core/__tests__/diff/input.test.ts index dd3946b8f9..ef56302b74 100644 --- a/packages/core/__tests__/diff/input.test.ts +++ b/packages/core/__tests__/diff/input.test.ts @@ -26,14 +26,14 @@ describe('input', () => { }; // Non-nullable - expect(change.c.criticality.level).toEqual(CriticalityLevel.Breaking); expect(change.c.type).toEqual('INPUT_FIELD_ADDED'); + expect(change.c.criticality.level).toEqual(CriticalityLevel.Breaking); expect(change.c.message).toEqual( "Input field 'c' of type 'String!' was added to input object type 'Foo'", ); // Nullable - expect(change.d.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.d.type).toEqual('INPUT_FIELD_ADDED'); + expect(change.d.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.d.message).toEqual( "Input field 'd' of type 'String' was added to input object type 'Foo'", ); @@ -53,7 +53,7 @@ describe('input', () => { `); const change = findFirstChangeByPath(await diff(a, b), 'Foo.b'); - expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.type).toEqual('INPUT_FIELD_ADDED'); expect(change.meta).toMatchObject({ addedFieldDefault: '"B"', diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 154950971a..56d0e43053 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -54,8 +54,6 @@ import { } from './patches/directives.js'; import { enumValueAdded, - enumValueDeprecationReasonAdded, - enumValueDeprecationReasonChanged, enumValueDescriptionChanged, enumValueRemoved, } from './patches/enum.js'; @@ -66,10 +64,6 @@ import { fieldArgumentDescriptionChanged, fieldArgumentRemoved, fieldArgumentTypeChanged, - fieldDeprecationAdded, - fieldDeprecationReasonAdded, - fieldDeprecationReasonChanged, - fieldDeprecationRemoved, fieldDescriptionAdded, fieldDescriptionChanged, fieldDescriptionRemoved, @@ -277,14 +271,6 @@ export function patchCoordinatesAST( enumValueAdded(change, nodesByCoordinate, config, context); break; } - case ChangeType.EnumValueDeprecationReasonAdded: { - enumValueDeprecationReasonAdded(change, nodesByCoordinate, config, context); - break; - } - case ChangeType.EnumValueDeprecationReasonChanged: { - enumValueDeprecationReasonChanged(change, nodesByCoordinate, config, context); - break; - } case ChangeType.FieldAdded: { fieldAdded(change, nodesByCoordinate, config, context); break; @@ -317,22 +303,6 @@ export function patchCoordinatesAST( fieldArgumentDefaultChanged(change, nodesByCoordinate, config, context); break; } - case ChangeType.FieldDeprecationAdded: { - fieldDeprecationAdded(change, nodesByCoordinate, config, context); - break; - } - case ChangeType.FieldDeprecationRemoved: { - fieldDeprecationRemoved(change, nodesByCoordinate, config, context); - break; - } - case ChangeType.FieldDeprecationReasonAdded: { - fieldDeprecationReasonAdded(change, nodesByCoordinate, config, context); - break; - } - case ChangeType.FieldDeprecationReasonChanged: { - fieldDeprecationReasonChanged(change, nodesByCoordinate, config, context); - break; - } case ChangeType.FieldDescriptionAdded: { fieldDescriptionAdded(change, nodesByCoordinate, config, context); break; diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index 0d1cc9eb68..570adb59c6 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -84,13 +84,6 @@ function directiveUsageDefinitionAdded( const parentNode = nodeByPath.get(parentPath(change.path)) as | { kind: Kind; directives?: DirectiveNode[] } | undefined; - if ( - change.meta.addedDirectiveName === 'deprecated' && - parentNode && - (parentNode.kind === Kind.FIELD_DEFINITION || parentNode.kind === Kind.ENUM_VALUE_DEFINITION) - ) { - return; // ignore because deprecated is handled by its own change... consider adjusting this. - } const definition = nodeByPath.get(`@${change.meta.addedDirectiveName}`); let repeatable = false; if (!definition) { @@ -143,9 +136,6 @@ function schemaDirectiveUsageDefinitionAdded( ); return; } - if (change.meta.addedDirectiveName === 'deprecated') { - return; // ignore because deprecated is handled by its own change... consider adjusting this. - } const definition = nodeByPath.get(`@${change.meta.addedDirectiveName}`); let repeatable = false; if (!definition) { @@ -501,11 +491,6 @@ export function directiveUsageArgumentAdded( config: PatchConfig, _context: PatchContext, ) { - // ignore because deprecated is handled by its own change... consider adjusting this. - if (change.meta.directiveName === 'deprecated') { - return; - } - if (!change.path) { handleError(change, new ChangePathMissingError(change), config); return; @@ -575,11 +560,6 @@ export function directiveUsageArgumentRemoved( | { kind: Kind; directives?: DirectiveNode[] } | undefined; - // ignore because deprecated is handled by its own change... consider adjusting this. - if (change.meta.directiveName === 'deprecated') { - return; - } - const directiveNode = findNthDirective( parentNode?.directives ?? [], change.meta.directiveName, diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index b6afdcbc40..d343628691 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -1,20 +1,14 @@ import { - ArgumentNode, ASTNode, - DirectiveNode, EnumValueDefinitionNode, Kind, - print, StringValueNode, } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeAlreadyExistsError, - AddedAttributeCoordinateNotFoundError, - AddedCoordinateAlreadyExistsError, ChangedAncestorCoordinateNotFoundError, ChangedCoordinateKindMismatchError, - ChangedCoordinateNotFoundError, ChangePathMissingError, DeletedAttributeNotFoundError, DeletedCoordinateNotFound, @@ -23,7 +17,7 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig, PatchContext } from '../types'; -import { findNamedNode, getDeprecatedDirectiveNode, parentPath } from '../utils.js'; +import { parentPath } from '../utils.js'; export function enumValueRemoved( change: Change, @@ -126,129 +120,6 @@ export function enumValueAdded( } } -export function enumValueDeprecationReasonAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, - _context: PatchContext, -) { - if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); - return; - } - - const enumValueNode = nodeByPath.get(parentPath(change.path)); - if (enumValueNode) { - if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { - const deprecation = getDeprecatedDirectiveNode(enumValueNode); - if (deprecation) { - if (findNamedNode(deprecation.arguments, 'reason')) { - handleError( - change, - new AddedCoordinateAlreadyExistsError(Kind.ENUM_VALUE_DEFINITION, 'reason'), - 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 ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), - config, - ); - } - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), - config, - ); - } - } else { - handleError( - change, - new AddedAttributeCoordinateNotFoundError( - change.meta.enumValueName, - 'directives', - '@deprecated', - ), - config, - ); - } -} - -export function enumValueDeprecationReasonChanged( - change: Change, - nodeByPath: Map, - config: PatchConfig, - _context: PatchContext, -) { - if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); - return; - } - const enumValueNode = nodeByPath.get(parentPath(change.path)) as - | { readonly directives?: readonly DirectiveNode[] | undefined } - | undefined; - const deprecatedNode = getDeprecatedDirectiveNode(enumValueNode); - if (deprecatedNode) { - if (deprecatedNode.kind === Kind.DIRECTIVE) { - const reasonArgNode = findNamedNode(deprecatedNode.arguments, 'reason'); - if (reasonArgNode) { - if (reasonArgNode.kind === Kind.ARGUMENT) { - const oldValueMatches = - reasonArgNode.value && - print(reasonArgNode.value) === change.meta.oldEnumValueDeprecationReason; - - if (!oldValueMatches) { - handleError( - change, - 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 ChangedCoordinateKindMismatchError(Kind.ARGUMENT, reasonArgNode.kind), - config, - ); - } - } else { - handleError(change, new ChangedCoordinateNotFoundError(Kind.ARGUMENT, 'reason'), config); - } - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, deprecatedNode.kind), - config, - ); - } - } else { - handleError( - change, - new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), - config, - ); - } -} - export function enumValueDescriptionChanged( change: Change, nodeByPath: Map, diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 9be2c66c52..3c7df543f2 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -1,10 +1,7 @@ import { - ArgumentNode, ASTNode, ConstValueNode, - DirectiveNode, FieldDefinitionNode, - GraphQLDeprecatedDirective, InputValueDefinitionNode, Kind, parseConstValue, @@ -15,11 +12,9 @@ import { } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { - AddedAttributeAlreadyExistsError, AddedAttributeCoordinateNotFoundError, AddedCoordinateAlreadyExistsError, ChangedCoordinateKindMismatchError, - ChangedCoordinateNotFoundError, ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedAttributeNotFoundError, @@ -32,12 +27,8 @@ import type { PatchConfig, PatchContext } from '../types'; import { assertChangeHasPath, assertValueMatch, - DEPRECATION_REASON_DEFAULT, - findNamedNode, getChangedNodeOfKind, getDeletedNodeOfKind, - getDeletedParentNodeOfKind, - getDeprecatedDirectiveNode, parentPath, } from '../utils.js'; @@ -310,159 +301,6 @@ export function fieldArgumentRemoved( } } -export function fieldDeprecationReasonChanged( - change: Change, - nodeByPath: Map, - config: PatchConfig, - _context: PatchContext, -) { - assertChangeHasPath(change, config); - const parentNode = nodeByPath.get(parentPath(change.path!)) as - | { kind: Kind; directives?: DirectiveNode[] } - | undefined; - const deprecationNode = getDeprecatedDirectiveNode(parentNode); - if (deprecationNode) { - 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 ChangedCoordinateNotFoundError(Kind.ARGUMENT, 'reason'), config); - } - } -} - -export function fieldDeprecationReasonAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, - _context: PatchContext, -) { - assertChangeHasPath(change, config); - const parentNode = nodeByPath.get(parentPath(change.path!)) as - | { kind: Kind; directives?: DirectiveNode[] } - | undefined; - const deprecationNode = getDeprecatedDirectiveNode(parentNode); - if (deprecationNode) { - 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, - ]; - } - } -} - -export function fieldDeprecationAdded( - change: Change, - nodeByPath: Map, - config: PatchConfig, - _context: PatchContext, -) { - if (assertChangeHasPath(change, config)) { - const fieldNode = nodeByPath.get(parentPath(change.path)); - if (fieldNode) { - if (fieldNode.kind !== Kind.FIELD_DEFINITION) { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), - config, - ); - return; - } - 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, - ]; - } - } - } -} - -export function fieldDeprecationRemoved( - change: Change, - nodeByPath: Map, - config: PatchConfig, - _context: PatchContext, -) { - if (assertChangeHasPath(change, config)) { - const fieldNode = getDeletedParentNodeOfKind( - change, - nodeByPath, - Kind.FIELD_DEFINITION, - 'directives', - config, - ); - if (fieldNode) { - const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); - if (hasExistingDeprecationDirective) { - (fieldNode.directives as DirectiveNode[] | undefined) = fieldNode.directives?.filter( - d => d.name.value !== GraphQLDeprecatedDirective.name, - ); - nodeByPath.delete(change.path); - } else { - handleError(change, new DeletedCoordinateNotFound(Kind.DIRECTIVE, '@deprecated'), config); - } - } - } -} - export function fieldDescriptionAdded( change: Change, nodeByPath: Map, diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index c3974d2d1f..b87c39f8fa 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -1,8 +1,6 @@ import { ASTKindToNode, ASTNode, - DirectiveNode, - GraphQLDeprecatedDirective, Kind, NameNode, } from 'graphql'; @@ -20,12 +18,6 @@ import { } from './errors.js'; import { AdditionChangeType, PatchConfig } from './types.js'; -export function getDeprecatedDirectiveNode( - definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, -): Maybe { - return findNamedNode(definitionNode?.directives, GraphQLDeprecatedDirective.name); -} - export function findNamedNode( nodes: Maybe>, name: string, From 94165766cfd7febbb356fccdd144ded4a1a3fc4c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 16:55:34 -0700 Subject: [PATCH 48/73] Simplify nested conditions --- packages/patch/src/index.ts | 6 +- packages/patch/src/patches/enum.ts | 7 +- packages/patch/src/patches/interfaces.ts | 129 ++++++++++++----------- packages/patch/src/utils.ts | 7 +- 4 files changed, 70 insertions(+), 79 deletions(-) diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 56d0e43053..a8569f18ca 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -52,11 +52,7 @@ import { directiveRepeatableAdded, directiveRepeatableRemoved, } from './patches/directives.js'; -import { - enumValueAdded, - enumValueDescriptionChanged, - enumValueRemoved, -} from './patches/enum.js'; +import { enumValueAdded, enumValueDescriptionChanged, enumValueRemoved } from './patches/enum.js'; import { fieldAdded, fieldArgumentAdded, diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index d343628691..a5b26eb8dd 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -1,9 +1,4 @@ -import { - ASTNode, - EnumValueDefinitionNode, - Kind, - StringValueNode, -} from 'graphql'; +import { ASTNode, EnumValueDefinitionNode, Kind, StringValueNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeAlreadyExistsError, diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index 1bfff525bd..956874ca06 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -25,45 +25,48 @@ export function objectTypeInterfaceAdded( } const typeNode = nodeByPath.get(change.path); - if (typeNode) { - if ( - typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || - typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION - ) { - const existing = findNamedNode(typeNode.interfaces, change.meta.addedInterfaceName); - if (existing) { - handleError( - change, - new AddedAttributeAlreadyExistsError( - typeNode.kind, - 'interfaces', - change.meta.addedInterfaceName, - ), - config, - ); - } else { - (typeNode.interfaces as NamedTypeNode[] | undefined) = [ - ...(typeNode.interfaces ?? []), - namedTypeNode(change.meta.addedInterfaceName), - ]; - } - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError( - Kind.OBJECT_TYPE_DEFINITION, // or Kind.INTERFACE_TYPE_DEFINITION - typeNode.kind, - ), - config, - ); - } - } else { + if (!typeNode) { handleError( change, new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'interfaces'), config, ); + return; + } + + if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + handleError( + change, + new ChangedCoordinateKindMismatchError( + Kind.OBJECT_TYPE_DEFINITION, // or Kind.INTERFACE_TYPE_DEFINITION + typeNode.kind, + ), + config, + ); + return; } + + const existing = findNamedNode(typeNode.interfaces, change.meta.addedInterfaceName); + if (existing) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + typeNode.kind, + 'interfaces', + change.meta.addedInterfaceName, + ), + config, + ); + return; + } + + (typeNode.interfaces as NamedTypeNode[] | undefined) = [ + ...(typeNode.interfaces ?? []), + namedTypeNode(change.meta.addedInterfaceName), + ]; } export function objectTypeInterfaceRemoved( @@ -78,35 +81,7 @@ export function objectTypeInterfaceRemoved( } const typeNode = nodeByPath.get(change.path); - if (typeNode) { - if ( - typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || - typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION - ) { - 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, - ); - } else { - // @note this error isnt the best designed for this application - handleError( - change, - new DeletedCoordinateNotFound( - Kind.INTERFACE_TYPE_DEFINITION, - change.meta.removedInterfaceName, - ), - config, - ); - } - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, - ); - } - } else { + if (!typeNode) { handleError( change, new DeletedAncestorCoordinateNotFoundError( @@ -116,5 +91,35 @@ export function objectTypeInterfaceRemoved( ), config, ); + return; + } + + if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + return; + } + + const existing = findNamedNode(typeNode.interfaces, change.meta.removedInterfaceName); + if (!existing) { + handleError( + change, + new DeletedCoordinateNotFound( + Kind.INTERFACE_TYPE_DEFINITION, + change.meta.removedInterfaceName, + ), + config, + ); + return; } + + (typeNode.interfaces as NamedTypeNode[] | undefined) = typeNode.interfaces?.filter( + i => i.name.value !== change.meta.removedInterfaceName, + ); } diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index b87c39f8fa..56a9378149 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -1,9 +1,4 @@ -import { - ASTKindToNode, - ASTNode, - Kind, - NameNode, -} from 'graphql'; +import { ASTKindToNode, ASTNode, Kind, NameNode } from 'graphql'; import { Maybe } from 'graphql/jsutils/Maybe'; import { Change, ChangeType } from '@graphql-inspector/core'; import { From 801f0e63e0577f8cc1075932c43639f8d75e241a Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 17:05:19 -0700 Subject: [PATCH 49/73] Mention not validating --- packages/patch/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index a8569f18ca..23e666ea11 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -96,6 +96,7 @@ export * as errors from './errors.js'; /** * Wraps converting a schema to AST safely, patching, then rebuilding the schema from AST. + * The schema is not validated in this function. That it is the responsibility of the caller. */ export function patchSchema( schema: GraphQLSchema, From 3ce91ba2665a1e216af5779366c3e1d400b09dbf Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 17:58:48 -0700 Subject: [PATCH 50/73] Simplifying nested conditions --- .../patch/src/patches/directive-usages.ts | 186 ++++---- packages/patch/src/patches/directives.ts | 396 ++++++++++-------- packages/patch/src/patches/enum.ts | 134 +++--- 3 files changed, 382 insertions(+), 334 deletions(-) diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index 570adb59c6..627ca8df85 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -1,4 +1,3 @@ -/* eslint-disable unicorn/no-negated-condition */ import { ArgumentNode, ASTNode, DirectiveNode, Kind, parseValue, print, ValueNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { @@ -84,6 +83,18 @@ function directiveUsageDefinitionAdded( const parentNode = nodeByPath.get(parentPath(change.path)) as | { kind: Kind; directives?: DirectiveNode[] } | undefined; + if (!parentNode) { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError( + Kind.OBJECT_TYPE_DEFINITION, // or interface... + 'directives', + ), + config, + ); + return; + } + const definition = nodeByPath.get(`@${change.meta.addedDirectiveName}`); let repeatable = false; if (!definition) { @@ -103,22 +114,14 @@ function directiveUsageDefinitionAdded( new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, change.meta.addedDirectiveName), config, ); - } else if (parentNode) { - const newDirective: DirectiveNode = { - kind: Kind.DIRECTIVE, - name: nameNode(change.meta.addedDirectiveName), - }; - parentNode.directives = [...(parentNode.directives ?? []), newDirective]; - } else { - handleError( - change, - new ChangedAncestorCoordinateNotFoundError( - Kind.OBJECT_TYPE_DEFINITION, // or interface... - 'directives', - ), - config, - ); + return; } + + const newDirective: DirectiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(change.meta.addedDirectiveName), + }; + parentNode.directives = [...(parentNode.directives ?? []), newDirective]; } function schemaDirectiveUsageDefinitionAdded( @@ -162,16 +165,17 @@ function schemaDirectiveUsageDefinitionAdded( ), config, ); - } else { - const directiveNode: DirectiveNode = { - kind: Kind.DIRECTIVE, - name: nameNode(change.meta.addedDirectiveName), - }; - (schemaNodes[0].directives as DirectiveNode[] | undefined) = [ - ...(schemaNodes[0].directives ?? []), - directiveNode, - ]; + return; } + + const directiveNode: DirectiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(change.meta.addedDirectiveName), + }; + (schemaNodes[0].directives as DirectiveNode[] | undefined) = [ + ...(schemaNodes[0].directives ?? []), + directiveNode, + ]; } function schemaDirectiveUsageDefinitionRemoved( @@ -192,8 +196,6 @@ function schemaDirectiveUsageDefinitionRemoved( (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; } @@ -225,11 +227,6 @@ function directiveUsageDefinitionRemoved( const parentNode = nodeByPath.get(parentPath(change.path)) as | { kind: Kind; directives?: DirectiveNode[] } | undefined; - const directiveNode = findNthDirective( - parentNode?.directives ?? [], - change.meta.removedDirectiveName, - change.meta.directiveRepeatedTimes, - ); if (!parentNode) { handleError( change, @@ -240,7 +237,15 @@ function directiveUsageDefinitionRemoved( ), config, ); - } else if (!directiveNode) { + return; + } + + const directiveNode = findNthDirective( + parentNode?.directives ?? [], + change.meta.removedDirectiveName, + change.meta.directiveRepeatedTimes, + ); + if (!directiveNode) { handleError( change, new DeletedAttributeNotFoundError( @@ -250,21 +255,18 @@ function directiveUsageDefinitionRemoved( ), config, ); - } else { - // null the value out for filtering later. The index is important so it can't be removed. - // @note the nullish check is critical here even though the types dont show it - const removedIndex = (parentNode.directives ?? []).findIndex(d => d === directiveNode); - const directiveList = [...(parentNode.directives ?? [])]; - if (removedIndex !== -1) { - (directiveList[removedIndex] as any) = undefined; - } - parentNode.directives = directiveList; - context.removedDirectiveNodes.push(parentNode); - // parentNode.directives = parentNode.direct - // ives?.filter( - // d => d.name.value !== change.meta.removedDirectiveName, - // ); + return; } + // null the value out for filtering later. The index is important so that changes reference + // the correct DirectiveNode. + // @note the nullish check is critical here even though the types dont show it + const removedIndex = (parentNode.directives ?? []).findIndex(d => d === directiveNode); + const directiveList = [...(parentNode.directives ?? [])]; + if (removedIndex !== -1) { + (directiveList[removedIndex] as any) = undefined; + } + parentNode.directives = directiveList; + context.removedDirectiveNodes.push(parentNode); } export function directiveUsageArgumentDefinitionAdded( @@ -514,35 +516,39 @@ export function directiveUsageArgumentAdded( ), config, ); - } else if (directiveNode.kind === Kind.DIRECTIVE) { - const existing = findNamedNode(directiveNode.arguments, change.meta.addedArgumentName); - // "ArgumentAdded" but argument already exists. - if (existing) { - handleError( - change, - new ValueMismatchError(directiveNode.kind, null, print(existing.value)), - config, - ); - (existing.value as ValueNode) = parseValue(change.meta.addedArgumentValue); - } 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 { + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), config, ); + return; + } + + const existing = findNamedNode(directiveNode.arguments, change.meta.addedArgumentName); + // "ArgumentAdded" but argument already exists. + if (existing) { + handleError( + change, + new ValueMismatchError(directiveNode.kind, null, print(existing.value)), + config, + ); + (existing.value as ValueNode) = parseValue(change.meta.addedArgumentValue); + return; } + + 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); } export function directiveUsageArgumentRemoved( @@ -575,29 +581,31 @@ export function directiveUsageArgumentRemoved( ), 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 { + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), config, ); + return; } + + const existing = findNamedNode(directiveNode.arguments, change.meta.removedArgumentName); + if (!existing) { + handleError( + change, + new DeletedAttributeNotFoundError( + directiveNode.kind, + 'arguments', + change.meta.removedArgumentName, + ), + config, + ); + } + + (directiveNode.arguments as ArgumentNode[] | undefined) = ( + directiveNode.arguments as ArgumentNode[] | undefined + )?.filter(a => a.name.value !== change.meta.removedArgumentName); } diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index df1040cd87..9bae3071cf 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -51,18 +51,18 @@ export function directiveAdded( new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedDirectiveName), 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(change.path, node); + return; } + 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(change.path, node); } export function directiveRemoved( @@ -99,40 +99,44 @@ export function directiveArgumentAdded( ), config, ); - } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { - const existingArg = findNamedNode( - directiveNode.arguments, - change.meta.addedDirectiveArgumentName, - ); - if (existingArg) { - handleError( - change, - new AddedAttributeAlreadyExistsError( - existingArg.kind, - 'arguments', - change.meta.addedDirectiveArgumentName, - ), - config, - ); - } 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(`${change.path}.${change.meta.addedDirectiveArgumentName}`, node); - } - } else { + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), config, ); + return; + } + + const existingArg = findNamedNode( + directiveNode.arguments, + change.meta.addedDirectiveArgumentName, + ); + if (existingArg) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + existingArg.kind, + 'arguments', + change.meta.addedDirectiveArgumentName, + ), + config, + ); + return; } + + 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(`${change.path}.${change.meta.addedDirectiveArgumentName}`, node); } export function directiveArgumentRemoved( @@ -170,38 +174,41 @@ export function directiveLocationAdded( } const changedNode = nodeByPath.get(change.path); - if (changedNode) { - if (changedNode.kind === Kind.DIRECTIVE_DEFINITION) { - if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { - handleError( - change, - new AddedAttributeAlreadyExistsError( - Kind.DIRECTIVE_DEFINITION, - 'locations', - change.meta.addedDirectiveLocation, - ), - config, - ); - } else { - (changedNode.locations as NameNode[]) = [ - ...changedNode.locations, - nameNode(change.meta.addedDirectiveLocation), - ]; - } - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), - config, - ); - } - } else { + if (!changedNode) { handleError( change, new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'locations'), config, ); + return; + } + + if (changedNode.kind !== Kind.DIRECTIVE_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + config, + ); + return; + } + + if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + Kind.DIRECTIVE_DEFINITION, + 'locations', + change.meta.addedDirectiveLocation, + ), + config, + ); + return; } + + (changedNode.locations as NameNode[]) = [ + ...changedNode.locations, + nameNode(change.meta.addedDirectiveLocation), + ]; } export function directiveLocationRemoved( @@ -216,32 +223,7 @@ export function directiveLocationRemoved( } 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 { + if (!changedNode) { handleError( change, new DeletedAncestorCoordinateNotFoundError( @@ -251,6 +233,33 @@ export function directiveLocationRemoved( ), config, ); + return; + } + + if (changedNode.kind !== Kind.DIRECTIVE_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + config, + ); + return; + } + + 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, + ); } } @@ -272,30 +281,32 @@ export function directiveDescriptionChanged( 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) { - handleError( - change, - 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 { + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), config, ); + return; } + + if (directiveNode.description?.value !== change.meta.oldDirectiveDescription) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveDescription, + directiveNode.description?.value, + ), + config, + ); + } + + (directiveNode.description as StringValueNode | undefined) = change.meta.newDirectiveDescription + ? stringNode(change.meta.newDirectiveDescription) + : undefined; } export function directiveArgumentDefaultValueChanged( @@ -316,32 +327,36 @@ export function directiveArgumentDefaultValueChanged( new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'defaultValue'), 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 ValueMismatchError( - Kind.INPUT_VALUE_DEFINITION, - change.meta.oldDirectiveArgumentDefaultValue, - argumentNode.defaultValue && print(argumentNode.defaultValue), - ), - config, - ); - } - } else { + return; + } + + if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), config, ); + return; + } + + 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 ValueMismatchError( + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDirectiveArgumentDefaultValue, + argumentNode.defaultValue && print(argumentNode.defaultValue), + ), + config, + ); } } @@ -363,30 +378,33 @@ export function directiveArgumentDescriptionChanged( new ChangedAncestorCoordinateNotFoundError(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) { - handleError( - change, - 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 { + return; + } + + if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), config, ); + return; + } + + if ((argumentNode.description?.value ?? null) !== change.meta.oldDirectiveArgumentDescription) { + handleError( + change, + 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; } export function directiveArgumentTypeChanged( @@ -403,26 +421,29 @@ export function directiveArgumentTypeChanged( const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { handleError(change, new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'type'), config); - } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { - if (print(argumentNode.type) !== change.meta.oldDirectiveArgumentType) { - handleError( - change, - new ValueMismatchError( - Kind.STRING, - change.meta.oldDirectiveArgumentType, - print(argumentNode.type), - ), - config, - ); - } - (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); - } else { + return; + } + if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), config, ); + return; + } + + if (print(argumentNode.type) !== change.meta.oldDirectiveArgumentType) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveArgumentType, + print(argumentNode.type), + ), + config, + ); } + (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); } export function directiveRepeatableAdded( @@ -443,24 +464,26 @@ export function directiveRepeatableAdded( new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), config, ); - } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { - // eslint-disable-next-line eqeqeq - if (directiveNode.repeatable !== false) { - handleError( - change, - new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'false'), - config, - ); - } - - (directiveNode.repeatable as boolean) = true; - } else { + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), config, ); + return; } + + if (directiveNode.repeatable !== false) { + handleError( + change, + new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'false'), + config, + ); + } + + (directiveNode.repeatable as boolean) = true; } export function directiveRepeatableRemoved( @@ -481,22 +504,25 @@ export function directiveRepeatableRemoved( new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), config, ); - } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { - // eslint-disable-next-line eqeqeq - if (directiveNode.repeatable !== true) { - handleError( - change, - new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'true'), - config, - ); - } + return; + } - (directiveNode.repeatable as boolean) = false; - } else { + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), config, ); + return; + } + + if (directiveNode.repeatable !== true) { + handleError( + change, + new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'true'), + config, + ); } + + (directiveNode.repeatable as boolean) = false; } diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index a5b26eb8dd..55b9e105a4 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -34,13 +34,19 @@ export function enumValueRemoved( new DeletedCoordinateNotFound(Kind.ENUM_TYPE_DEFINITION, change.meta.removedEnumValueName), config, ); - } else if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { + return; + } + + if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config, ); - } else if (enumNode.values === undefined || enumNode.values.length === 0) { + return; + } + + if (enumNode.values === undefined || enumNode.values.length === 0) { handleError( change, new DeletedAttributeNotFoundError( @@ -50,26 +56,26 @@ export function enumValueRemoved( ), config, ); - } else { - const beforeLength = enumNode.values.length; - enumNode.values = enumNode.values.filter( - f => f.name.value !== change.meta.removedEnumValueName, + return; + } + + const beforeLength = enumNode.values.length; + enumNode.values = enumNode.values.filter(f => f.name.value !== change.meta.removedEnumValueName); + if (beforeLength === enumNode.values.length) { + handleError( + change, + new DeletedAttributeNotFoundError( + Kind.ENUM_TYPE_DEFINITION, + 'values', + change.meta.removedEnumValueName, + ), + config, ); - if (beforeLength === enumNode.values.length) { - handleError( - change, - new DeletedAttributeNotFoundError( - Kind.ENUM_TYPE_DEFINITION, - 'values', - change.meta.removedEnumValueName, - ), - config, - ); - } else { - // delete the reference to the removed field. - nodeByPath.delete(change.path); - } + return; } + + // delete the reference to the removed field. + nodeByPath.delete(change.path); } export function enumValueAdded( @@ -85,7 +91,10 @@ export function enumValueAdded( const changedNode = nodeByPath.get(enumValuePath); if (!enumNode) { handleError(change, new ChangedAncestorCoordinateNotFoundError(Kind.ENUM, 'values'), config); - } else if (changedNode) { + return; + } + + if (changedNode) { handleError( change, new AddedAttributeAlreadyExistsError( @@ -95,24 +104,28 @@ export function enumValueAdded( ), 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 { + return; + } + + if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { handleError( change, new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), config, ); + return; } + + 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); } export function enumValueDescriptionChanged( @@ -127,37 +140,38 @@ export function enumValueDescriptionChanged( } const enumValueNode = nodeByPath.get(change.path); - if (enumValueNode) { - if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { - const oldValueMatches = - change.meta.oldEnumValueDescription === (enumValueNode.description?.value ?? null); - if (!oldValueMatches) { - handleError( - change, - 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 ChangedCoordinateKindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), - config, - ); - } - } else { + if (!enumValueNode) { handleError( change, new ChangedAncestorCoordinateNotFoundError(Kind.ENUM_VALUE_DEFINITION, 'values'), config, ); + return; + } + + if (enumValueNode.kind !== Kind.ENUM_VALUE_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + config, + ); + return; + } + + const oldValueMatches = + change.meta.oldEnumValueDescription === (enumValueNode.description?.value ?? null); + if (!oldValueMatches) { + handleError( + change, + 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; } From aa4ba828f7725851f4d028571b6d6cd6e0a1326b Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 18:33:06 -0700 Subject: [PATCH 51/73] Simplifying nested conditions --- packages/patch/src/patches/fields.ts | 291 ++++++++++++++------------- packages/patch/src/utils.ts | 109 +++++----- 2 files changed, 208 insertions(+), 192 deletions(-) diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 3c7df543f2..5f9b4748f1 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -25,7 +25,6 @@ import { import { nameNode, stringNode } from '../node-templates.js'; import type { PatchConfig, PatchContext } from '../types'; import { - assertChangeHasPath, assertValueMatch, getChangedNodeOfKind, getDeletedNodeOfKind, @@ -99,52 +98,54 @@ export function fieldAdded( config: PatchConfig, _context: PatchContext, ) { - 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, - ); - } + if (!change.path) { + handleError(change, new ChangePathMissingError(change), 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(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 typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { - fields?: FieldDefinitionNode[]; + 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, }; - 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]; + typeNode.fields = [...(typeNode.fields ?? []), node]; - // add new field to the node set - nodeByPath.set(change.path, node); - } + // add new field to the node set + nodeByPath.set(change.path, node); } } } @@ -155,51 +156,57 @@ export function fieldArgumentAdded( config: PatchConfig, _context: PatchContext, ) { - if (assertChangeHasPath(change, config)) { - 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 AddedAttributeCoordinateNotFoundError( - change.meta.fieldName, - 'arguments', - change.meta.addedArgumentName, - ), - 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, - }; + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } - fieldNode.arguments = [...(fieldNode.arguments ?? []), node]; + const existing = nodeByPath.get(change.path); + if (existing) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(Kind.ARGUMENT, change.meta.addedArgumentName), + config, + ); + return; + } - // add new field to the node set - nodeByPath.set(change.path!, node); - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), - config, - ); - } - } + const fieldNode = nodeByPath.get(parentPath(change.path!)) as ASTNode & { + arguments?: InputValueDefinitionNode[]; + }; + if (!fieldNode) { + handleError( + change, + new AddedAttributeCoordinateNotFoundError( + change.meta.fieldName, + 'arguments', + change.meta.addedArgumentName, + ), + config, + ); + return; } + if (fieldNode.kind !== Kind.FIELD_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); + return; + } + 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); } export function fieldArgumentTypeChanged( @@ -270,35 +277,43 @@ export function fieldArgumentRemoved( _context: PatchContext, ) { const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.ARGUMENT, config); - 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, - ); + if (!existing) { + handleError( + change, + new DeletedCoordinateNotFound(Kind.ARGUMENT, change.meta.removedFieldArgumentName), + config, + ); + return; + } - // add new field to the node set - nodeByPath.delete(change.path!); - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), - config, - ); - } + 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, + ); + return; + } + if (fieldNode.kind !== Kind.FIELD_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); } + fieldNode.arguments = fieldNode.arguments?.filter( + a => a.name.value === change.meta.removedFieldArgumentName, + ); + + // add new field to the node set + nodeByPath.delete(change.path!); } export function fieldDescriptionAdded( @@ -327,23 +342,24 @@ export function fieldDescriptionRemoved( } const fieldNode = nodeByPath.get(change.path); - if (fieldNode) { - if (fieldNode.kind === Kind.FIELD_DEFINITION) { - (fieldNode.description as StringValueNode | undefined) = undefined; - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), - config, - ); - } - } else { + if (!fieldNode) { handleError( change, new DeletedCoordinateNotFound(Kind.FIELD_DEFINITION, change.meta.fieldName), config, ); + return; } + if (fieldNode.kind !== Kind.FIELD_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); + return; + } + + (fieldNode.description as StringValueNode | undefined) = undefined; } export function fieldDescriptionChanged( @@ -353,19 +369,20 @@ export function fieldDescriptionChanged( _context: PatchContext, ) { const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); - if (fieldNode) { - 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); + if (!fieldNode) { + return; } + 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); } diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 56a9378149..fc8985cc60 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -88,19 +88,6 @@ export function debugPrintChange(change: Change, nodeByPath: Map>( - change: C, - config: PatchConfig, -): change is typeof change & { path: string } { - if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); - return false; - } - return true; -} - export function assertValueMatch( change: Change, expectedKind: Kind, @@ -126,21 +113,25 @@ export function getChangedNodeOfKind( if (kind === Kind.DIRECTIVE) { throw new Error('Directives cannot be found using this method.'); } - 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 ChangedCoordinateNotFoundError(kind, undefined), - config, - ); - } else if (existing.kind === kind) { - return existing as ASTKindToNode[K]; - } else { - handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); - } + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const existing = nodeByPath.get(change.path); + if (!existing) { + handleError( + change, + // @todo improve the error by providing the name or value somehow. + new ChangedCoordinateNotFoundError(kind, undefined), + config, + ); + return; + } + if (existing.kind !== kind) { + handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); } + return existing as ASTKindToNode[K]; } export function getDeletedNodeOfKind( @@ -149,21 +140,25 @@ export function getDeletedNodeOfKind( 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); - } + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + 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, + ); + return; + } + if (existing.kind !== kind) { + handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + return; } + return existing as ASTKindToNode[K]; } export function getDeletedParentNodeOfKind( @@ -173,19 +168,23 @@ export function getDeletedParentNodeOfKind( attribute: NodeAttribute, 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, attribute, undefined), - config, - ); - } else if (existing.kind === kind) { - return existing as ASTKindToNode[K]; - } else { - handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); - } + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + 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, attribute, undefined), + config, + ); + return; + } + if (existing.kind !== kind) { + handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + return; } + return existing as ASTKindToNode[K]; } From d1b076a0693101d3d2dded9048e434570e2a02ce Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 18:47:08 -0700 Subject: [PATCH 52/73] Simplifying nested conditions --- packages/patch/src/patches/inputs.ts | 262 ++++++++++++++------------- 1 file changed, 138 insertions(+), 124 deletions(-) diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index a0c7a98ba1..adc3c982b4 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -59,42 +59,45 @@ export function inputFieldAdded( config, ); } - } else { - const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { - fields?: InputValueDefinitionNode[]; - }; - if (!typeNode) { - handleError( - change, - new AddedAttributeCoordinateNotFoundError( - change.meta.inputName, - 'fields', - change.meta.addedInputFieldName, - ), - 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, - }; + return; + } + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: InputValueDefinitionNode[]; + }; + if (!typeNode) { + handleError( + change, + new AddedAttributeCoordinateNotFoundError( + change.meta.inputName, + 'fields', + change.meta.addedInputFieldName, + ), + config, + ); + return; + } + if (typeNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + return; + } - typeNode.fields = [...(typeNode.fields ?? []), node]; + 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, + }; - // add new field to the node set - nodeByPath.set(change.path, node); - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), - config, - ); - } - } + typeNode.fields = [...(typeNode.fields ?? []), node]; + + // add new field to the node set + nodeByPath.set(change.path, node); } export function inputFieldRemoved( @@ -109,33 +112,7 @@ export function inputFieldRemoved( } const existingNode = nodeByPath.get(change.path); - if (existingNode) { - const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { - fields?: InputValueDefinitionNode[]; - }; - if (!typeNode) { - 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); - - // add new field to the node set - nodeByPath.delete(change.path); - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), - config, - ); - } - } else { + if (!existingNode) { handleError( change, new DeletedCoordinateNotFound( @@ -144,7 +121,38 @@ export function inputFieldRemoved( ), config, ); + return; + } + + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: InputValueDefinitionNode[]; + }; + if (!typeNode) { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.INPUT_OBJECT_TYPE_DEFINITION, + 'fields', + change.meta.removedFieldName, + ), + config, + ); + return; + } + + if (typeNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + return; } + + typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); + + // add new field to the node set + nodeByPath.delete(change.path); } export function inputFieldDescriptionAdded( @@ -158,19 +166,7 @@ export function inputFieldDescriptionAdded( return; } const existingNode = nodeByPath.get(change.path); - if (existingNode) { - if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { - (existingNode.description as StringValueNode | undefined) = stringNode( - change.meta.addedInputFieldDescription, - ); - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), - config, - ); - } - } else { + if (!existingNode) { handleError( change, new DeletedAncestorCoordinateNotFoundError( @@ -180,7 +176,20 @@ export function inputFieldDescriptionAdded( ), config, ); + return; + } + if (existingNode.kind !== Kind.INPUT_VALUE_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + return; } + + (existingNode.description as StringValueNode | undefined) = stringNode( + change.meta.addedInputFieldDescription, + ); } export function inputFieldTypeChanged( @@ -219,39 +228,41 @@ export function inputFieldDefaultValueChanged( 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 { + if (!existingNode) { handleError( change, new ChangedAncestorCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'defaultValue'), config, ); + return; } + + if (existingNode.kind !== Kind.INPUT_VALUE_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + return; + } + + 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; } export function inputFieldDescriptionChanged( @@ -266,22 +277,23 @@ export function inputFieldDescriptionChanged( Kind.INPUT_VALUE_DEFINITION, config, ); - if (existingNode) { - 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, + if (!existingNode) { + return; + } + 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, + ); } export function inputFieldDescriptionRemoved( @@ -296,14 +308,16 @@ export function inputFieldDescriptionRemoved( Kind.INPUT_VALUE_DEFINITION, config, ); - if (existingNode) { - 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; + if (!existingNode) { + return; + } + + 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; } From 87b7f49a2cb0968a2b848ce8bd11e94cdbbc3859 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Fri, 31 Oct 2025 18:56:39 -0700 Subject: [PATCH 53/73] Simplifying nested conditions --- packages/patch/src/patches/inputs.ts | 5 +- packages/patch/src/patches/types.ts | 168 ++++++++++++++------------- packages/patch/src/patches/unions.ts | 66 ++++++----- 3 files changed, 123 insertions(+), 116 deletions(-) diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index adc3c982b4..c7a07cd3f5 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -139,7 +139,7 @@ export function inputFieldRemoved( ); return; } - + if (typeNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) { handleError( change, @@ -247,8 +247,7 @@ export function inputFieldDefaultValueChanged( } const oldValueMatches = - (existingNode.defaultValue && print(existingNode.defaultValue)) === - change.meta.oldDefaultValue; + (existingNode.defaultValue && print(existingNode.defaultValue)) === change.meta.oldDefaultValue; if (!oldValueMatches) { handleError( change, diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index aaafde22f2..bc554976c4 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -31,13 +31,13 @@ export function typeAdded( new AddedCoordinateAlreadyExistsError(existing.kind, change.meta.addedTypeName), config, ); - } else { - const node: TypeDefinitionNode = { - name: nameNode(change.meta.addedTypeName), - kind: change.meta.addedTypeKind as TypeDefinitionNode['kind'], - }; - nodeByPath.set(change.path, node); + return; } + const node: TypeDefinitionNode = { + name: nameNode(change.meta.addedTypeName), + kind: change.meta.addedTypeKind as TypeDefinitionNode['kind'], + }; + nodeByPath.set(change.path, node); } export function typeRemoved( @@ -52,27 +52,29 @@ export function typeRemoved( } 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(change.path)) { - nodeByPath.delete(key); - } - } - } else { - handleError( - change, - new DeletedCoordinateNotFound(removedNode.kind, change.meta.removedTypeName), - config, - ); - } - } else { + if (!removedNode) { handleError( change, new DeletedCoordinateNotFound(Kind.OBJECT_TYPE_DEFINITION, change.meta.removedTypeName), config, ); + return; + } + + if (!isTypeDefinitionNode(removedNode)) { + handleError( + change, + new DeletedCoordinateNotFound(removedNode.kind, change.meta.removedTypeName), + config, + ); + return; + } + + // delete the reference to the removed field. + for (const key of nodeByPath.keys()) { + if (key.startsWith(change.path)) { + nodeByPath.delete(key); + } } } @@ -88,25 +90,26 @@ export function typeDescriptionAdded( } const typeNode = nodeByPath.get(change.path); - if (typeNode) { - if (isTypeDefinitionNode(typeNode)) { - (typeNode.description as StringValueNode | undefined) = change.meta.addedTypeDescription - ? stringNode(change.meta.addedTypeDescription) - : undefined; - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, - ); - } - } else { + if (!typeNode) { handleError( change, new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), config, ); + return; + } + if (!isTypeDefinitionNode(typeNode)) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + return; } + + (typeNode.description as StringValueNode | undefined) = change.meta.addedTypeDescription + ? stringNode(change.meta.addedTypeDescription) + : undefined; } export function typeDescriptionChanged( @@ -121,36 +124,37 @@ export function typeDescriptionChanged( } const typeNode = nodeByPath.get(change.path); - if (typeNode) { - if (isTypeDefinitionNode(typeNode)) { - if (typeNode.description?.value !== change.meta.oldTypeDescription) { - handleError( - change, - new ValueMismatchError( - Kind.STRING, - change.meta.oldTypeDescription, - typeNode.description?.value, - ), - config, - ); - } - (typeNode.description as StringValueNode | undefined) = stringNode( - change.meta.newTypeDescription, - ); - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, - ); - } - } else { + if (!typeNode) { handleError( change, new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), config, ); + return; } + + if (!isTypeDefinitionNode(typeNode)) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + return; + } + if (typeNode.description?.value !== change.meta.oldTypeDescription) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldTypeDescription, + typeNode.description?.value, + ), + config, + ); + } + (typeNode.description as StringValueNode | undefined) = stringNode( + change.meta.newTypeDescription, + ); } export function typeDescriptionRemoved( @@ -165,28 +169,7 @@ export function typeDescriptionRemoved( } const typeNode = nodeByPath.get(change.path); - if (typeNode) { - if (isTypeDefinitionNode(typeNode)) { - if (typeNode.description?.value !== change.meta.oldTypeDescription) { - handleError( - change, - new ValueMismatchError( - Kind.STRING, - change.meta.oldTypeDescription, - typeNode.description?.value, - ), - config, - ); - } - (typeNode.description as StringValueNode | undefined) = undefined; - } else { - handleError( - change, - new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, - ); - } - } else { + if (!typeNode) { handleError( change, new DeletedAncestorCoordinateNotFoundError( @@ -196,5 +179,28 @@ export function typeDescriptionRemoved( ), config, ); + return; + } + + if (!isTypeDefinitionNode(typeNode)) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + return; + } + + if (typeNode.description?.value !== change.meta.oldTypeDescription) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldTypeDescription, + typeNode.description?.value, + ), + config, + ); } + (typeNode.description as StringValueNode | undefined) = undefined; } diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts index d7bfd0b6eb..0983976c39 100644 --- a/packages/patch/src/patches/unions.ts +++ b/packages/patch/src/patches/unions.ts @@ -25,27 +25,29 @@ export function unionMemberAdded( const union = nodeByPath.get(parentPath(change.path)) as | (ASTNode & { types?: NamedTypeNode[] }) | undefined; - if (union) { - if (findNamedNode(union.types, change.meta.addedUnionMemberTypeName)) { - handleError( - change, - new AddedAttributeAlreadyExistsError( - Kind.UNION_TYPE_DEFINITION, - 'types', - change.meta.addedUnionMemberTypeName, - ), - config, - ); - } else { - union.types = [...(union.types ?? []), namedTypeNode(change.meta.addedUnionMemberTypeName)]; - } - } else { + if (!union) { handleError( change, new ChangedAncestorCoordinateNotFoundError(Kind.UNION_TYPE_DEFINITION, 'types'), config, ); + return; } + + if (findNamedNode(union.types, change.meta.addedUnionMemberTypeName)) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + Kind.UNION_TYPE_DEFINITION, + 'types', + change.meta.addedUnionMemberTypeName, + ), + config, + ); + return; + } + + union.types = [...(union.types ?? []), namedTypeNode(change.meta.addedUnionMemberTypeName)]; } export function unionMemberRemoved( @@ -61,23 +63,7 @@ export function unionMemberRemoved( const union = nodeByPath.get(parentPath(change.path)) as | (ASTNode & { types?: NamedTypeNode[] }) | undefined; - if (union) { - if (findNamedNode(union.types, change.meta.removedUnionMemberTypeName)) { - union.types = union.types!.filter( - t => t.name.value !== change.meta.removedUnionMemberTypeName, - ); - } else { - handleError( - change, - new DeletedAttributeNotFoundError( - Kind.UNION_TYPE_DEFINITION, - 'types', - change.meta.removedUnionMemberTypeName, - ), - config, - ); - } - } else { + if (!union) { handleError( change, new DeletedAncestorCoordinateNotFoundError( @@ -87,5 +73,21 @@ export function unionMemberRemoved( ), config, ); + return; } + + if (!findNamedNode(union.types, change.meta.removedUnionMemberTypeName)) { + handleError( + change, + new DeletedAttributeNotFoundError( + Kind.UNION_TYPE_DEFINITION, + 'types', + change.meta.removedUnionMemberTypeName, + ), + config, + ); + return; + } + + union.types = union.types!.filter(t => t.name.value !== change.meta.removedUnionMemberTypeName); } From 32159914c81d5608b738b80ae5059bd0042051a9 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:47:35 -0800 Subject: [PATCH 54/73] Make removing deprecation safe --- packages/core/src/diff/changes/field.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/diff/changes/field.ts b/packages/core/src/diff/changes/field.ts index a7bbade323..8eda935af0 100644 --- a/packages/core/src/diff/changes/field.ts +++ b/packages/core/src/diff/changes/field.ts @@ -223,8 +223,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, + level: CriticalityLevel.NonBreaking, }, message: `Field '${args.meta.typeName}.${args.meta.fieldName}' is no longer deprecated`, meta: args.meta, From e23fe3b321fc2c93d6d08a959b76a829172068d1 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:57:36 -0800 Subject: [PATCH 55/73] Use version 0.0.0 and add changeset to patch --- .changeset/empty-cougars-grab.md | 20 ++++++++++++++++++++ packages/patch/package.json | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 .changeset/empty-cougars-grab.md diff --git a/.changeset/empty-cougars-grab.md b/.changeset/empty-cougars-grab.md new file mode 100644 index 0000000000..656806177a --- /dev/null +++ b/.changeset/empty-cougars-grab.md @@ -0,0 +1,20 @@ +--- +'@graphql-inspector/patch': patch +--- + +Initial release. Patch applies a list of changes (output from `@graphql-inspector/core`'s `diff`) to +a GraphQL Schema. + +Example usage: + +```typescript +import { buildSchema } from "graphql"; +import { diff } from "@graphql-inspector/core"; +import { patchSchema } from "@graphql-inspector/patch"; + +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); +``` diff --git a/packages/patch/package.json b/packages/patch/package.json index 7e826618c8..359df6495f 100644 --- a/packages/patch/package.json +++ b/packages/patch/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-inspector/patch", - "version": "0.0.1", + "version": "0.0.0", "type": "module", "description": "Applies changes output from @graphql-inspect/diff", "repository": { From c71a0e8dba7c05ff586973c38e0af175c3e35cb0 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 4 Nov 2025 18:33:36 -0800 Subject: [PATCH 56/73] Adjust error handling --- packages/patch/README.md | 36 +++- packages/patch/src/__tests__/utils.ts | 3 +- packages/patch/src/errors.ts | 53 +++++- packages/patch/src/index.ts | 13 +- .../patch/src/patches/directive-usages.ts | 78 ++++----- packages/patch/src/patches/directives.ts | 158 +++++++----------- packages/patch/src/patches/enum.ts | 52 +++--- packages/patch/src/patches/fields.ts | 86 ++++------ packages/patch/src/patches/inputs.ts | 74 ++++---- packages/patch/src/patches/interfaces.ts | 35 ++-- packages/patch/src/patches/schema.ts | 32 ++-- packages/patch/src/patches/types.ts | 66 +++----- packages/patch/src/patches/unions.ts | 25 ++- packages/patch/src/types.ts | 24 +-- packages/patch/src/utils.ts | 36 ++-- 15 files changed, 346 insertions(+), 425 deletions(-) diff --git a/packages/patch/README.md b/packages/patch/README.md index 4693ab7982..36b1535a56 100644 --- a/packages/patch/README.md +++ b/packages/patch/README.md @@ -18,21 +18,41 @@ const patched = patchSchema(schemaA, changes); ## Configuration -> By default does not throw when hitting errors such as if a type that was modified no longer exists. +### `debug?: boolean` -`throwOnError?: boolean` +> Enables debug logging -> The changes output from `diff` include the values, such as default argument values of the old schema. E.g. changing `foo(arg: String = "bar")` to `foo(arg: String = "foo")` would track that the previous default value was `"bar"`. By enabling this option, `patch` can throw an error when patching a schema where the value doesn't match what is expected. E.g. where `foo.arg`'s default value is _NOT_ `"bar"`. This will avoid overwriting conflicting changes. This is recommended if using an automated process for patching schema. +### `onError?: (err: Error, change: Change) => void` -`requireOldValueMatch?: boolean` +> Define how you want errors to be handled. This package exports three predefined error handlers: `errors.strictErrorHandler`, `errors.defaultErrorHandler`, and `errors.looseErrorHandler`. Strict is recommended if you want to manually resolve value conflicts. -> 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. +> [!CAUTION] +> Error classes are still being actively improved. It's recommended to use one of the exported error functions rather than build your own at this time. -`onError?: (err: Error, change: Change) => boolean | undefined | null` +#### `defaultErrorHandler` -> Enables debug logging +A convenient, semi-strict error handler. This ignores "no-op" errors -- if +the change wouldn't impact the patched schema at all. And it ignores +value mismatches, which are when the change notices that the value captured in +the change doesn't match the value in the patched schema. + +For example, if the change indicates the default value WAS "foo" before being +changed, but the patch is applied to a schema where the default value is "bar". +This is useful to avoid overwriting changes unknowingly that may have occurred +from other sources. + +#### `strictErrorHandler` + +The strictest of the standard error handlers. This checks if the error is a "No-op", +meaning if the change wouldn't impact the schema at all, and ignores the error +only in this one case. Otherwise, the error is raised. + +#### `looseErrorHandler` -`debug?: boolean` +The least strict error handler. This will only log errors and will never +raise an error. This is potentially useful for getting a patched schema +rendered, and then handling the conflict/error in a separate step. E.g. +if creating a merge conflict resolution UI. ## Remaining Work diff --git a/packages/patch/src/__tests__/utils.ts b/packages/patch/src/__tests__/utils.ts index cc26907d51..19f9581cc4 100644 --- a/packages/patch/src/__tests__/utils.ts +++ b/packages/patch/src/__tests__/utils.ts @@ -1,6 +1,7 @@ import { buildSchema, lexicographicSortSchema, type GraphQLSchema } from 'graphql'; import { Change, diff } from '@graphql-inspector/core'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; +import { strictErrorHandler } from '../errors.js'; import { patchSchema } from '../index.js'; function printSortedSchema(schema: GraphQLSchema) { @@ -16,8 +17,8 @@ export async function expectDiffAndPatchToMatch( const changes = await diff(schemaA, schemaB); const patched = patchSchema(schemaA, changes, { - throwOnError: true, debug: process.env.DEBUG === 'true', + onError: strictErrorHandler, }); expect(printSortedSchema(patched)).toBe(printSortedSchema(schemaB)); return changes; diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index c97c8fa723..5864578054 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -1,25 +1,62 @@ import { Kind } from 'graphql'; import type { Change } from '@graphql-inspector/core'; -import type { PatchConfig } from './types.js'; +import type { ErrorHandler } from './types.js'; -export function handleError(change: Change, err: Error, config: PatchConfig) { - if (config.onError?.(err, change) === true) { - // handled by onError - return; +/** + * The strictest of the standard error handlers. This checks if the error is a "No-op", + * meaning if the change wouldn't impact the schema at all, and ignores the error + * only in this one case. Otherwise, the error is raised. + */ +export const strictErrorHandler: ErrorHandler = (err, change) => { + if (err instanceof NoopError) { + console.debug( + `Ignoring change ${change.type} at "${change.path}" because it does not modify the resulting schema.`, + ); + } else { + throw err; } +}; +/** + * A convenient, semi-strict error handler. This ignores "no-op" errors -- if + * the change wouldn't impact the patched schema at all. And it ignores + * value mismatches, which are when the change notices that the value captured in + * the change doesn't match the value in the patched schema. + * + * For example, if the change indicates the default value WAS "foo" before being + * changed, but the patch is applied to a schema where the default value is "bar". + * This is useful to avoid overwriting changes unknowingly that may have occurred + * from other sources. + */ +export const defaultErrorHandler: ErrorHandler = (err, change) => { 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) { + } else if (err instanceof ValueMismatchError) { console.debug(`Ignoring old value mismatch at "${change.path}".`); - } else if (config.throwOnError === true) { + } else { throw err; + } +}; + +/** + * The least strict error handler. This will only log errors and will never + * raise an error. This is potentially useful for getting a patched schema + * rendered, and then handling the conflict/error in a separate step. E.g. + * if creating a merge conflict resolution UI. + */ +export const looseErrorHandler: ErrorHandler = (err, change) => { + if (err instanceof NoopError) { + console.debug( + `Ignoring change ${change.type} at "${change.path}" because it does not modify the resulting schema.`, + ); + } else if (err instanceof ValueMismatchError) { + console.debug(`Ignoring old value mismatch at "${change.path}".`); } 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 diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts index 23e666ea11..e14d8167dc 100644 --- a/packages/patch/src/index.ts +++ b/packages/patch/src/index.ts @@ -10,6 +10,7 @@ import { } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; +import { defaultErrorHandler } from './errors.js'; import { directiveUsageArgumentAdded, directiveUsageArgumentDefinitionAdded, @@ -101,7 +102,7 @@ export * as errors from './errors.js'; export function patchSchema( schema: GraphQLSchema, changes: Change[], - config?: PatchConfig, + config?: Partial, ): GraphQLSchema { const ast = parse(printSchemaWithDirectives(schema, { assumeValid: true })); const patchedAst = patch(ast, changes, config); @@ -215,9 +216,13 @@ export function patchCoordinatesAST( schemaNodes: SchemaNode[], nodesByCoordinate: Map, changes: Change[], - patchConfig?: PatchConfig, + patchConfig: Partial = {}, ): DocumentNode { - const config: PatchConfig = patchConfig ?? {}; + const config: PatchConfig = { + onError: defaultErrorHandler, + debug: false, + ...patchConfig, + }; const context: PatchContext = { removedDirectiveNodes: [], }; @@ -536,7 +541,7 @@ export function patchCoordinatesAST( export function patch( ast: DocumentNode, changes: Change[], - patchConfig?: PatchConfig, + patchConfig?: Partial, ): DocumentNode { const [schemaNodes, nodesByCoordinate] = groupByCoordinateAST(ast); return patchCoordinatesAST(schemaNodes, nodesByCoordinate, changes, patchConfig); diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index 627ca8df85..a42901ab81 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -10,7 +10,6 @@ import { ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedAttributeNotFoundError, - handleError, ValueMismatchError, } from '../errors.js'; import { nameNode } from '../node-templates.js'; @@ -72,10 +71,9 @@ function directiveUsageDefinitionAdded( _context: PatchContext, ) { if (!change.path) { - handleError( - change, + config.onError( new ChangedCoordinateNotFoundError(Kind.DIRECTIVE, change.meta.addedDirectiveName), - config, + change, ); return; } @@ -84,13 +82,12 @@ function directiveUsageDefinitionAdded( | { kind: Kind; directives?: DirectiveNode[] } | undefined; if (!parentNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError( Kind.OBJECT_TYPE_DEFINITION, // or interface... 'directives', ), - config, + change, ); return; } @@ -109,10 +106,9 @@ function directiveUsageDefinitionAdded( change.meta.directiveRepeatedTimes, ); if (!repeatable && directiveNode) { - handleError( - change, + config.onError( new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, change.meta.addedDirectiveName), - config, + change, ); return; } @@ -132,10 +128,9 @@ function schemaDirectiveUsageDefinitionAdded( _context: PatchContext, ) { if (!change.path) { - handleError( - change, + config.onError( new ChangedCoordinateNotFoundError(Kind.DIRECTIVE, change.meta.addedDirectiveName), - config, + change, ); return; } @@ -156,14 +151,13 @@ function schemaDirectiveUsageDefinitionAdded( ), ); if (!repeatable && directiveAlreadyExists) { - handleError( - change, + config.onError( new AddedAttributeAlreadyExistsError( Kind.SCHEMA_DEFINITION, 'directives', change.meta.addedDirectiveName, ), - config, + change, ); return; } @@ -201,14 +195,13 @@ function schemaDirectiveUsageDefinitionRemoved( } } if (!deleted) { - handleError( - change, + config.onError( new DeletedAttributeNotFoundError( Kind.SCHEMA_DEFINITION, 'directives', change.meta.removedDirectiveName, ), - config, + change, ); } } @@ -220,7 +213,7 @@ function directiveUsageDefinitionRemoved( context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } @@ -228,14 +221,13 @@ function directiveUsageDefinitionRemoved( | { kind: Kind; directives?: DirectiveNode[] } | undefined; if (!parentNode) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.OBJECT_TYPE_DEFINITION, 'directives', change.meta.removedDirectiveName, ), - config, + change, ); return; } @@ -246,14 +238,13 @@ function directiveUsageDefinitionRemoved( change.meta.directiveRepeatedTimes, ); if (!directiveNode) { - handleError( - change, + config.onError( new DeletedAttributeNotFoundError( parentNode.kind, 'directives', change.meta.removedDirectiveName, ), - config, + change, ); return; } @@ -494,7 +485,7 @@ export function directiveUsageArgumentAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } // Must use double parentPath b/c the path is referencing the argument @@ -507,22 +498,20 @@ export function directiveUsageArgumentAdded( change.meta.directiveRepeatedTimes, ); if (!directiveNode) { - handleError( - change, + config.onError( new AddedAttributeCoordinateNotFoundError( change.meta.directiveName, 'arguments', change.meta.addedArgumentName, ), - config, + change, ); return; } if (directiveNode.kind !== Kind.DIRECTIVE) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), - config, + change, ); return; } @@ -530,11 +519,7 @@ export function directiveUsageArgumentAdded( const existing = findNamedNode(directiveNode.arguments, change.meta.addedArgumentName); // "ArgumentAdded" but argument already exists. if (existing) { - handleError( - change, - new ValueMismatchError(directiveNode.kind, null, print(existing.value)), - config, - ); + config.onError(new ValueMismatchError(directiveNode.kind, null, print(existing.value)), change); (existing.value as ValueNode) = parseValue(change.meta.addedArgumentValue); return; } @@ -558,7 +543,7 @@ export function directiveUsageArgumentRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } // Must use double parentPath b/c the path is referencing the argument @@ -572,36 +557,33 @@ export function directiveUsageArgumentRemoved( change.meta.directiveRepeatedTimes, ); if (!directiveNode) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.DIRECTIVE, 'arguments', change.meta.removedArgumentName, ), - config, + change, ); return; } if (directiveNode.kind !== Kind.DIRECTIVE) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), - config, + change, ); return; } const existing = findNamedNode(directiveNode.arguments, change.meta.removedArgumentName); if (!existing) { - handleError( - change, + config.onError( new DeletedAttributeNotFoundError( directiveNode.kind, 'arguments', change.meta.removedArgumentName, ), - config, + change, ); } diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 9bae3071cf..028a6db9b0 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -21,7 +21,6 @@ import { ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedAttributeNotFoundError, - handleError, ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; @@ -40,16 +39,15 @@ export function directiveAdded( _context: PatchContext, ) { if (change.path === undefined) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const changedNode = nodeByPath.get(change.path); if (changedNode) { - handleError( - change, + config.onError( new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedDirectiveName), - config, + change, ); return; } @@ -84,28 +82,26 @@ export function directiveArgumentAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { - handleError( - change, + config.onError( new AddedAttributeCoordinateNotFoundError( change.meta.directiveName, 'arguments', change.meta.addedDirectiveArgumentName, ), - config, + change, ); return; } if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), - config, + change, ); return; } @@ -115,14 +111,13 @@ export function directiveArgumentAdded( change.meta.addedDirectiveArgumentName, ); if (existingArg) { - handleError( - change, + config.onError( new AddedAttributeAlreadyExistsError( existingArg.kind, 'arguments', change.meta.addedDirectiveArgumentName, ), - config, + change, ); return; } @@ -169,38 +164,35 @@ export function directiveLocationAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const changedNode = nodeByPath.get(change.path); if (!changedNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'locations'), - config, + change, ); return; } if (changedNode.kind !== Kind.DIRECTIVE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), - config, + change, ); return; } if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { - handleError( - change, + config.onError( new AddedAttributeAlreadyExistsError( Kind.DIRECTIVE_DEFINITION, 'locations', change.meta.addedDirectiveLocation, ), - config, + change, ); return; } @@ -218,29 +210,27 @@ export function directiveLocationRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const changedNode = nodeByPath.get(change.path); if (!changedNode) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.DIRECTIVE_DEFINITION, 'locations', change.meta.removedDirectiveLocation, ), - config, + change, ); return; } if (changedNode.kind !== Kind.DIRECTIVE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), - config, + change, ); return; } @@ -251,14 +241,13 @@ export function directiveLocationRemoved( if (existing >= 0) { (changedNode.locations as NameNode[]) = changedNode.locations.toSpliced(existing, 1); } else { - handleError( - change, + config.onError( new DeletedAttributeNotFoundError( changedNode.kind, 'locations', change.meta.removedDirectiveLocation, ), - config, + change, ); } } @@ -270,37 +259,34 @@ export function directiveDescriptionChanged( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), - config, + change, ); return; } if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), - config, + change, ); return; } if (directiveNode.description?.value !== change.meta.oldDirectiveDescription) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.STRING, change.meta.oldDirectiveDescription, directiveNode.description?.value, ), - config, + change, ); } @@ -316,25 +302,23 @@ export function directiveArgumentDefaultValueChanged( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'defaultValue'), - config, + change, ); return; } if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), - config, + change, ); return; } @@ -348,14 +332,13 @@ export function directiveArgumentDefaultValueChanged( ? parseConstValue(change.meta.newDirectiveArgumentDefaultValue) : undefined; } else { - handleError( - change, + config.onError( new ValueMismatchError( Kind.INPUT_VALUE_DEFINITION, change.meta.oldDirectiveArgumentDefaultValue, argumentNode.defaultValue && print(argumentNode.defaultValue), ), - config, + change, ); } } @@ -367,38 +350,35 @@ export function directiveArgumentDescriptionChanged( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'description'), - config, + change, ); return; } if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), - config, + change, ); return; } if ((argumentNode.description?.value ?? null) !== change.meta.oldDirectiveArgumentDescription) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.STRING, change.meta.oldDirectiveArgumentDescription ?? undefined, argumentNode.description?.value, ), - config, + change, ); } (argumentNode.description as StringValueNode | undefined) = change.meta @@ -414,33 +394,31 @@ export function directiveArgumentTypeChanged( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { - handleError(change, new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'type'), config); + config.onError(new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'type'), change); return; } if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), - config, + change, ); return; } if (print(argumentNode.type) !== change.meta.oldDirectiveArgumentType) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.STRING, change.meta.oldDirectiveArgumentType, print(argumentNode.type), ), - config, + change, ); } (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); @@ -453,33 +431,30 @@ export function directiveRepeatableAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), - config, + change, ); return; } if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), - config, + change, ); return; } if (directiveNode.repeatable !== false) { - handleError( - change, + config.onError( new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'false'), - config, + change, ); } @@ -493,34 +468,31 @@ export function directiveRepeatableRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), - config, + change, ); return; } if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), - config, + change, ); return; } if (directiveNode.repeatable !== true) { - handleError( - change, + config.onError( new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'true'), - config, + change, ); } diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index 55b9e105a4..5e7fdc9ec9 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -7,7 +7,6 @@ import { ChangePathMissingError, DeletedAttributeNotFoundError, DeletedCoordinateNotFound, - handleError, ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; @@ -21,7 +20,7 @@ export function enumValueRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } @@ -29,32 +28,29 @@ export function enumValueRemoved( | (ASTNode & { values?: EnumValueDefinitionNode[] }) | undefined; if (!enumNode) { - handleError( - change, + config.onError( new DeletedCoordinateNotFound(Kind.ENUM_TYPE_DEFINITION, change.meta.removedEnumValueName), - config, + change, ); return; } if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), - config, + change, ); return; } if (enumNode.values === undefined || enumNode.values.length === 0) { - handleError( - change, + config.onError( new DeletedAttributeNotFoundError( Kind.ENUM_TYPE_DEFINITION, 'values', change.meta.removedEnumValueName, ), - config, + change, ); return; } @@ -62,14 +58,13 @@ export function enumValueRemoved( const beforeLength = enumNode.values.length; enumNode.values = enumNode.values.filter(f => f.name.value !== change.meta.removedEnumValueName); if (beforeLength === enumNode.values.length) { - handleError( - change, + config.onError( new DeletedAttributeNotFoundError( Kind.ENUM_TYPE_DEFINITION, 'values', change.meta.removedEnumValueName, ), - config, + change, ); return; } @@ -90,28 +85,26 @@ export function enumValueAdded( | undefined; const changedNode = nodeByPath.get(enumValuePath); if (!enumNode) { - handleError(change, new ChangedAncestorCoordinateNotFoundError(Kind.ENUM, 'values'), config); + config.onError(new ChangedAncestorCoordinateNotFoundError(Kind.ENUM, 'values'), change); return; } if (changedNode) { - handleError( - change, + config.onError( new AddedAttributeAlreadyExistsError( changedNode.kind, 'values', change.meta.addedEnumValueName, ), - config, + change, ); return; } if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), - config, + change, ); return; } @@ -135,25 +128,23 @@ export function enumValueDescriptionChanged( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const enumValueNode = nodeByPath.get(change.path); if (!enumValueNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.ENUM_VALUE_DEFINITION, 'values'), - config, + change, ); return; } if (enumValueNode.kind !== Kind.ENUM_VALUE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), - config, + change, ); return; } @@ -161,14 +152,13 @@ export function enumValueDescriptionChanged( const oldValueMatches = change.meta.oldEnumValueDescription === (enumValueNode.description?.value ?? null); if (!oldValueMatches) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.ENUM_TYPE_DEFINITION, change.meta.oldEnumValueDescription, enumValueNode.description?.value, ), - config, + change, ); } (enumValueNode.description as StringValueNode | undefined) = change.meta.newEnumValueDescription diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 5f9b4748f1..0f196f9b98 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -19,7 +19,6 @@ import { DeletedAncestorCoordinateNotFoundError, DeletedAttributeNotFoundError, DeletedCoordinateNotFound, - handleError, ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; @@ -41,10 +40,9 @@ export function fieldTypeChanged( if (node) { const currentReturnType = print(node.type); if (change.meta.oldFieldType !== currentReturnType) { - handleError( - change, + config.onError( new ValueMismatchError(Kind.FIELD_DEFINITION, change.meta.oldFieldType, currentReturnType), - config, + change, ); } (node.type as TypeNode) = parseType(change.meta.newFieldType); @@ -58,7 +56,7 @@ export function fieldRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } @@ -66,14 +64,13 @@ export function fieldRemoved( | (ASTNode & { fields?: FieldDefinitionNode[] }) | undefined; if (!typeNode) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.OBJECT_TYPE_DEFINITION, 'fields', change.meta.removedFieldName, ), - config, + change, ); return; } @@ -81,10 +78,9 @@ export function fieldRemoved( 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, + config.onError( new DeletedAttributeNotFoundError(typeNode?.kind, 'fields', change.meta.removedFieldName), - config, + change, ); } else { // delete the reference to the removed field. @@ -99,22 +95,20 @@ export function fieldAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const changedNode = nodeByPath.get(change.path); if (changedNode) { if (changedNode.kind === Kind.OBJECT_FIELD) { - handleError( - change, + config.onError( new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedFieldName), - config, + change, ); } else { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.OBJECT_FIELD, changedNode.kind), - config, + change, ); } } else { @@ -122,15 +116,14 @@ export function fieldAdded( fields?: FieldDefinitionNode[]; }; if (!typeNode) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); } else if ( typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION ) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), - config, + change, ); } else { const node: FieldDefinitionNode = { @@ -157,16 +150,15 @@ export function fieldArgumentAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const existing = nodeByPath.get(change.path); if (existing) { - handleError( - change, + config.onError( new AddedCoordinateAlreadyExistsError(Kind.ARGUMENT, change.meta.addedArgumentName), - config, + change, ); return; } @@ -175,22 +167,20 @@ export function fieldArgumentAdded( arguments?: InputValueDefinitionNode[]; }; if (!fieldNode) { - handleError( - change, + config.onError( new AddedAttributeCoordinateNotFoundError( change.meta.fieldName, 'arguments', change.meta.addedArgumentName, ), - config, + change, ); return; } if (fieldNode.kind !== Kind.FIELD_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), - config, + change, ); return; } @@ -278,10 +268,9 @@ export function fieldArgumentRemoved( ) { const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.ARGUMENT, config); if (!existing) { - handleError( - change, + config.onError( new DeletedCoordinateNotFound(Kind.ARGUMENT, change.meta.removedFieldArgumentName), - config, + change, ); return; } @@ -290,22 +279,20 @@ export function fieldArgumentRemoved( arguments?: InputValueDefinitionNode[]; }; if (!fieldNode) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.FIELD_DEFINITION, 'arguments', change.meta.removedFieldArgumentName, ), - config, + change, ); return; } if (fieldNode.kind !== Kind.FIELD_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), - config, + change, ); } fieldNode.arguments = fieldNode.arguments?.filter( @@ -337,24 +324,22 @@ export function fieldDescriptionRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const fieldNode = nodeByPath.get(change.path); if (!fieldNode) { - handleError( - change, + config.onError( new DeletedCoordinateNotFound(Kind.FIELD_DEFINITION, change.meta.fieldName), - config, + change, ); return; } if (fieldNode.kind !== Kind.FIELD_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), - config, + change, ); return; } @@ -373,14 +358,13 @@ export function fieldDescriptionChanged( return; } if (fieldNode.description?.value !== change.meta.oldDescription) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.FIELD_DEFINITION, change.meta.oldDescription, fieldNode.description?.value, ), - config, + change, ); } diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index c7a07cd3f5..c7a91db44a 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -18,7 +18,6 @@ import { ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedCoordinateNotFound, - handleError, ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; @@ -37,26 +36,24 @@ export function inputFieldAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const existingNode = nodeByPath.get(change.path); if (existingNode) { if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { - handleError( - change, + config.onError( new AddedCoordinateAlreadyExistsError( Kind.INPUT_VALUE_DEFINITION, change.meta.addedInputFieldName, ), - config, + change, ); } else { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), - config, + change, ); } return; @@ -65,22 +62,20 @@ export function inputFieldAdded( fields?: InputValueDefinitionNode[]; }; if (!typeNode) { - handleError( - change, + config.onError( new AddedAttributeCoordinateNotFoundError( change.meta.inputName, 'fields', change.meta.addedInputFieldName, ), - config, + change, ); return; } if (typeNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), - config, + change, ); return; } @@ -107,19 +102,18 @@ export function inputFieldRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const existingNode = nodeByPath.get(change.path); if (!existingNode) { - handleError( - change, + config.onError( new DeletedCoordinateNotFound( Kind.INPUT_OBJECT_TYPE_DEFINITION, change.meta.removedFieldName, ), - config, + change, ); return; } @@ -128,23 +122,21 @@ export function inputFieldRemoved( fields?: InputValueDefinitionNode[]; }; if (!typeNode) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.INPUT_OBJECT_TYPE_DEFINITION, 'fields', change.meta.removedFieldName, ), - config, + change, ); return; } if (typeNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), - config, + change, ); return; } @@ -162,27 +154,25 @@ export function inputFieldDescriptionAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const existingNode = nodeByPath.get(change.path); if (!existingNode) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.INPUT_VALUE_DEFINITION, 'description', change.meta.addedInputFieldDescription, ), - config, + change, ); return; } if (existingNode.kind !== Kind.INPUT_VALUE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), - config, + change, ); return; } @@ -224,24 +214,22 @@ export function inputFieldDefaultValueChanged( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const existingNode = nodeByPath.get(change.path); if (!existingNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'defaultValue'), - config, + change, ); return; } if (existingNode.kind !== Kind.INPUT_VALUE_DEFINITION) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), - config, + change, ); return; } @@ -249,14 +237,13 @@ export function inputFieldDefaultValueChanged( const oldValueMatches = (existingNode.defaultValue && print(existingNode.defaultValue)) === change.meta.oldDefaultValue; if (!oldValueMatches) { - handleError( - change, + config.onError( new ValueMismatchError( existingNode.defaultValue?.kind ?? Kind.INPUT_VALUE_DEFINITION, change.meta.oldDefaultValue, existingNode.defaultValue && print(existingNode.defaultValue), ), - config, + change, ); } (existingNode.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue @@ -280,14 +267,13 @@ export function inputFieldDescriptionChanged( return; } if (existingNode.description?.value !== change.meta.oldInputFieldDescription) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.STRING, change.meta.oldInputFieldDescription, existingNode.description?.value, ), - config, + change, ); } (existingNode.description as StringValueNode | undefined) = stringNode( diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index 956874ca06..20a8dd38bb 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -7,7 +7,6 @@ import { ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedCoordinateNotFound, - handleError, } from '../errors.js'; import { namedTypeNode } from '../node-templates.js'; import type { PatchConfig, PatchContext } from '../types'; @@ -20,16 +19,15 @@ export function objectTypeInterfaceAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const typeNode = nodeByPath.get(change.path); if (!typeNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'interfaces'), - config, + change, ); return; } @@ -38,27 +36,25 @@ export function objectTypeInterfaceAdded( typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION ) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError( Kind.OBJECT_TYPE_DEFINITION, // or Kind.INTERFACE_TYPE_DEFINITION typeNode.kind, ), - config, + change, ); return; } const existing = findNamedNode(typeNode.interfaces, change.meta.addedInterfaceName); if (existing) { - handleError( - change, + config.onError( new AddedAttributeAlreadyExistsError( typeNode.kind, 'interfaces', change.meta.addedInterfaceName, ), - config, + change, ); return; } @@ -76,20 +72,19 @@ export function objectTypeInterfaceRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const typeNode = nodeByPath.get(change.path); if (!typeNode) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.INPUT_OBJECT_TYPE_DEFINITION, 'interfaces', change.meta.removedInterfaceName, ), - config, + change, ); return; } @@ -98,23 +93,21 @@ export function objectTypeInterfaceRemoved( typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION ) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, + change, ); return; } const existing = findNamedNode(typeNode.interfaces, change.meta.removedInterfaceName); if (!existing) { - handleError( - change, + config.onError( new DeletedCoordinateNotFound( Kind.INTERFACE_TYPE_DEFINITION, change.meta.removedInterfaceName, ), - config, + change, ); return; } diff --git a/packages/patch/src/patches/schema.ts b/packages/patch/src/patches/schema.ts index 151870f46d..c24572c249 100644 --- a/packages/patch/src/patches/schema.ts +++ b/packages/patch/src/patches/schema.ts @@ -1,7 +1,7 @@ /* eslint-disable unicorn/no-negated-condition */ import { Kind, NameNode, OperationTypeDefinitionNode, OperationTypeNode } from 'graphql'; import type { Change, ChangeType } from '@graphql-inspector/core'; -import { handleError, ValueMismatchError } from '../errors.js'; +import { ValueMismatchError } from '../errors.js'; import { nameNode } from '../node-templates.js'; import { PatchConfig, PatchContext, SchemaNode } from '../types.js'; @@ -17,14 +17,13 @@ export function schemaMutationTypeChanged( ); if (!mutation) { if (change.meta.oldMutationTypeName !== 'unknown') { - handleError( - change, + config.onError( new ValueMismatchError( Kind.SCHEMA_DEFINITION, change.meta.oldMutationTypeName, 'unknown', ), - config, + change, ); } (schemaNode.operationTypes as OperationTypeDefinitionNode[]) = [ @@ -40,14 +39,13 @@ export function schemaMutationTypeChanged( ]; } else { if (mutation.type.name.value !== change.meta.oldMutationTypeName) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.SCHEMA_DEFINITION, change.meta.oldMutationTypeName, mutation?.type.name.value, ), - config, + change, ); } (mutation.type.name as NameNode) = nameNode(change.meta.newMutationTypeName); @@ -67,10 +65,9 @@ export function schemaQueryTypeChanged( ); if (!query) { if (change.meta.oldQueryTypeName !== 'unknown') { - handleError( - change, + config.onError( new ValueMismatchError(Kind.SCHEMA_DEFINITION, change.meta.oldQueryTypeName, 'unknown'), - config, + change, ); } (schemaNode.operationTypes as OperationTypeDefinitionNode[]) = [ @@ -86,14 +83,13 @@ export function schemaQueryTypeChanged( ]; } else { if (query.type.name.value !== change.meta.oldQueryTypeName) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.SCHEMA_DEFINITION, change.meta.oldQueryTypeName, query?.type.name.value, ), - config, + change, ); } (query.type.name as NameNode) = nameNode(change.meta.newQueryTypeName); @@ -113,14 +109,13 @@ export function schemaSubscriptionTypeChanged( ); if (!sub) { if (change.meta.oldSubscriptionTypeName !== 'unknown') { - handleError( - change, + config.onError( new ValueMismatchError( Kind.SCHEMA_DEFINITION, change.meta.oldSubscriptionTypeName, 'unknown', ), - config, + change, ); } (schemaNode.operationTypes as OperationTypeDefinitionNode[]) = [ @@ -136,14 +131,13 @@ export function schemaSubscriptionTypeChanged( ]; } else { if (sub.type.name.value !== change.meta.oldSubscriptionTypeName) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.SCHEMA_DEFINITION, change.meta.oldSubscriptionTypeName, sub?.type.name.value, ), - config, + change, ); } (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 bc554976c4..2594286ed7 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -7,7 +7,6 @@ import { ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedCoordinateNotFound, - handleError, ValueMismatchError, } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; @@ -20,16 +19,15 @@ export function typeAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const existing = nodeByPath.get(change.path); if (existing) { - handleError( - change, + config.onError( new AddedCoordinateAlreadyExistsError(existing.kind, change.meta.addedTypeName), - config, + change, ); return; } @@ -47,25 +45,23 @@ export function typeRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const removedNode = nodeByPath.get(change.path); if (!removedNode) { - handleError( - change, + config.onError( new DeletedCoordinateNotFound(Kind.OBJECT_TYPE_DEFINITION, change.meta.removedTypeName), - config, + change, ); return; } if (!isTypeDefinitionNode(removedNode)) { - handleError( - change, + config.onError( new DeletedCoordinateNotFound(removedNode.kind, change.meta.removedTypeName), - config, + change, ); return; } @@ -85,24 +81,22 @@ export function typeDescriptionAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const typeNode = nodeByPath.get(change.path); if (!typeNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), - config, + change, ); return; } if (!isTypeDefinitionNode(typeNode)) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, + change, ); return; } @@ -119,37 +113,34 @@ export function typeDescriptionChanged( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const typeNode = nodeByPath.get(change.path); if (!typeNode) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), - config, + change, ); return; } if (!isTypeDefinitionNode(typeNode)) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, + change, ); return; } if (typeNode.description?.value !== change.meta.oldTypeDescription) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.STRING, change.meta.oldTypeDescription, typeNode.description?.value, ), - config, + change, ); } (typeNode.description as StringValueNode | undefined) = stringNode( @@ -164,42 +155,39 @@ export function typeDescriptionRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const typeNode = nodeByPath.get(change.path); if (!typeNode) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.OBJECT_TYPE_DEFINITION, 'description', change.meta.oldTypeDescription, ), - config, + change, ); return; } if (!isTypeDefinitionNode(typeNode)) { - handleError( - change, + config.onError( new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), - config, + change, ); return; } if (typeNode.description?.value !== change.meta.oldTypeDescription) { - handleError( - change, + config.onError( new ValueMismatchError( Kind.STRING, change.meta.oldTypeDescription, typeNode.description?.value, ), - config, + change, ); } (typeNode.description as StringValueNode | undefined) = undefined; diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts index 0983976c39..3e4839de2c 100644 --- a/packages/patch/src/patches/unions.ts +++ b/packages/patch/src/patches/unions.ts @@ -6,7 +6,6 @@ import { ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedAttributeNotFoundError, - handleError, } from '../errors.js'; import { namedTypeNode } from '../node-templates.js'; import { PatchConfig, PatchContext } from '../types.js'; @@ -19,30 +18,28 @@ export function unionMemberAdded( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const union = nodeByPath.get(parentPath(change.path)) as | (ASTNode & { types?: NamedTypeNode[] }) | undefined; if (!union) { - handleError( - change, + config.onError( new ChangedAncestorCoordinateNotFoundError(Kind.UNION_TYPE_DEFINITION, 'types'), - config, + change, ); return; } if (findNamedNode(union.types, change.meta.addedUnionMemberTypeName)) { - handleError( - change, + config.onError( new AddedAttributeAlreadyExistsError( Kind.UNION_TYPE_DEFINITION, 'types', change.meta.addedUnionMemberTypeName, ), - config, + change, ); return; } @@ -57,34 +54,32 @@ export function unionMemberRemoved( _context: PatchContext, ) { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const union = nodeByPath.get(parentPath(change.path)) as | (ASTNode & { types?: NamedTypeNode[] }) | undefined; if (!union) { - handleError( - change, + config.onError( new DeletedAncestorCoordinateNotFoundError( Kind.UNION_TYPE_DEFINITION, 'types', change.meta.removedUnionMemberTypeName, ), - config, + change, ); return; } if (!findNamedNode(union.types, change.meta.removedUnionMemberTypeName)) { - handleError( - change, + config.onError( new DeletedAttributeNotFoundError( Kind.UNION_TYPE_DEFINITION, 'types', change.meta.removedUnionMemberTypeName, ), - config, + change, ); return; } diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts index 993b3800a7..a8754b6cbf 100644 --- a/packages/patch/src/types.ts +++ b/packages/patch/src/types.ts @@ -26,32 +26,20 @@ 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; - - /** - * The changes output from `diff` include the values, such as default argument values of the old schema. E.g. changing `foo(arg: String = "bar")` to `foo(arg: String = "foo")` would track that the previous default value was `"bar"`. By enabling this option, `patch` can throw an error when patching a schema where the value doesn't match what is expected. E.g. where `foo.arg`'s default value is _NOT_ `"bar"`. This will avoid overwriting conflicting changes. This is recommended if using an automated process for patching schema. - */ - 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. + * errors or want to capture the errors in a list somewhere etc. + * * To halt patching, throw the error inside the handler. * @param err The raised error - * @returns True if the error has been handled + * @returns void */ - onError?: (err: Error, change: Change) => boolean | undefined | null; + onError: (err: Error, change: Change) => void; /** * Enables debug logging */ - debug?: boolean; + debug: boolean; }; export type PatchContext = { @@ -61,3 +49,5 @@ export type PatchContext = { */ removedDirectiveNodes: Array<{ directives?: DirectiveNode[] }>; }; + +export type ErrorHandler = (err: Error, change: Change) => void; diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index fc8985cc60..4ec980382c 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -7,7 +7,6 @@ import { ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, DeletedCoordinateNotFound, - handleError, NodeAttribute, ValueMismatchError, } from './errors.js'; @@ -96,7 +95,7 @@ export function assertValueMatch( config: PatchConfig, ) { if (expected !== actual) { - handleError(change, new ValueMismatchError(expectedKind, expected, actual), config); + config.onError(new ValueMismatchError(expectedKind, expected, actual), change); } } @@ -114,22 +113,17 @@ export function getChangedNodeOfKind( throw new Error('Directives cannot be found using this method.'); } if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } const existing = nodeByPath.get(change.path); if (!existing) { - handleError( - change, - // @todo improve the error by providing the name or value somehow. - new ChangedCoordinateNotFoundError(kind, undefined), - config, - ); + config.onError(new ChangedCoordinateNotFoundError(kind, undefined), change); return; } if (existing.kind !== kind) { - handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + config.onError(new ChangedCoordinateKindMismatchError(kind, existing.kind), change); } return existing as ASTKindToNode[K]; } @@ -141,21 +135,16 @@ export function getDeletedNodeOfKind( config: PatchConfig, ): ASTKindToNode[K] | void { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } 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, - ); + config.onError(new DeletedCoordinateNotFound(kind, undefined), change); return; } if (existing.kind !== kind) { - handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + config.onError(new ChangedCoordinateKindMismatchError(kind, existing.kind), change); return; } return existing as ASTKindToNode[K]; @@ -169,21 +158,16 @@ export function getDeletedParentNodeOfKind( config: PatchConfig, ): ASTKindToNode[K] | void { if (!change.path) { - handleError(change, new ChangePathMissingError(change), config); + config.onError(new ChangePathMissingError(change), change); return; } 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, attribute, undefined), - config, - ); + config.onError(new DeletedAncestorCoordinateNotFoundError(kind, attribute, undefined), change); return; } if (existing.kind !== kind) { - handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + config.onError(new ChangedCoordinateKindMismatchError(kind, existing.kind), change); return; } return existing as ASTKindToNode[K]; From 5f7658178e82212e058a40112df7254b69a4cf18 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 5 Nov 2025 10:36:07 -0800 Subject: [PATCH 57/73] Fix description removed; move tests outside of src --- packages/core/__tests__/diff/interface.test.ts | 4 ++-- packages/core/__tests__/diff/object.test.ts | 4 ++-- packages/core/src/diff/schema.ts | 4 ++-- packages/patch/{src => }/__tests__/directive-usage.test.ts | 0 packages/patch/{src => }/__tests__/directives.test.ts | 0 packages/patch/{src => }/__tests__/enum.test.ts | 0 packages/patch/{src => }/__tests__/fields.test.ts | 0 packages/patch/{src => }/__tests__/inputs.test.ts | 0 packages/patch/{src => }/__tests__/interfaces.test.ts | 0 packages/patch/{src => }/__tests__/types.test.ts | 0 packages/patch/{src => }/__tests__/unions.test.ts | 0 packages/patch/{src => }/__tests__/utils.ts | 5 ++--- packages/patch/src/errors.ts | 2 +- packages/patch/src/patches/directives.ts | 2 +- packages/patch/src/patches/types.ts | 6 +++--- 15 files changed, 13 insertions(+), 14 deletions(-) rename packages/patch/{src => }/__tests__/directive-usage.test.ts (100%) rename packages/patch/{src => }/__tests__/directives.test.ts (100%) rename packages/patch/{src => }/__tests__/enum.test.ts (100%) rename packages/patch/{src => }/__tests__/fields.test.ts (100%) rename packages/patch/{src => }/__tests__/inputs.test.ts (100%) rename packages/patch/{src => }/__tests__/interfaces.test.ts (100%) rename packages/patch/{src => }/__tests__/types.test.ts (100%) rename packages/patch/{src => }/__tests__/unions.test.ts (100%) rename packages/patch/{src => }/__tests__/utils.ts (86%) diff --git a/packages/core/__tests__/diff/interface.test.ts b/packages/core/__tests__/diff/interface.test.ts index 6acb602aff..a7d725a11f 100644 --- a/packages/core/__tests__/diff/interface.test.ts +++ b/packages/core/__tests__/diff/interface.test.ts @@ -181,7 +181,7 @@ describe('interface', () => { ); // Removed expect(change.b.type).toEqual('FIELD_DEPRECATION_REMOVED'); - expect(change.b.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.b.message).toEqual("Field 'Foo.b' is no longer deprecated"); // Added expect(change.c.type).toEqual('FIELD_DEPRECATION_ADDED'); @@ -210,7 +210,7 @@ describe('interface', () => { }; // Changed - expect(change.a.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.a.type).toEqual('FIELD_DEPRECATION_REMOVED'); expect(change.a.message).toEqual("Field 'Foo.a' is no longer deprecated"); // Removed diff --git a/packages/core/__tests__/diff/object.test.ts b/packages/core/__tests__/diff/object.test.ts index 794d85a6a7..9a52224944 100644 --- a/packages/core/__tests__/diff/object.test.ts +++ b/packages/core/__tests__/diff/object.test.ts @@ -342,7 +342,7 @@ describe('object', () => { ); // Removed expect(change.b.type).toEqual('FIELD_DEPRECATION_REMOVED'); - expect(change.b.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.b.message).toEqual("Field 'Foo.b' is no longer deprecated"); // Added expect(change.c.type).toEqual('FIELD_DEPRECATION_ADDED'); @@ -371,7 +371,7 @@ describe('object', () => { }; // Changed - expect(change.a.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.a.type).toEqual('FIELD_DEPRECATION_REMOVED'); expect(change.a.message).toEqual("Field 'Foo.a' is no longer deprecated"); // Removed diff --git a/packages/core/src/diff/schema.ts b/packages/core/src/diff/schema.ts index aa491e4f0e..d29b49d03b 100644 --- a/packages/core/src/diff/schema.ts +++ b/packages/core/src/diff/schema.ts @@ -147,10 +147,10 @@ function changesInType( if (isNotEqual(oldType?.description, newType.description)) { if (isVoid(oldType?.description)) { addChange(typeDescriptionAdded(newType)); - } else if (oldType && isVoid(newType.description)) { + } else if (oldType.description && isVoid(newType.description)) { addChange(typeDescriptionRemoved(oldType)); } else { - addChange(typeDescriptionChanged(oldType!, newType)); + addChange(typeDescriptionChanged(oldType, newType)); } } } diff --git a/packages/patch/src/__tests__/directive-usage.test.ts b/packages/patch/__tests__/directive-usage.test.ts similarity index 100% rename from packages/patch/src/__tests__/directive-usage.test.ts rename to packages/patch/__tests__/directive-usage.test.ts diff --git a/packages/patch/src/__tests__/directives.test.ts b/packages/patch/__tests__/directives.test.ts similarity index 100% rename from packages/patch/src/__tests__/directives.test.ts rename to packages/patch/__tests__/directives.test.ts diff --git a/packages/patch/src/__tests__/enum.test.ts b/packages/patch/__tests__/enum.test.ts similarity index 100% rename from packages/patch/src/__tests__/enum.test.ts rename to packages/patch/__tests__/enum.test.ts diff --git a/packages/patch/src/__tests__/fields.test.ts b/packages/patch/__tests__/fields.test.ts similarity index 100% rename from packages/patch/src/__tests__/fields.test.ts rename to packages/patch/__tests__/fields.test.ts diff --git a/packages/patch/src/__tests__/inputs.test.ts b/packages/patch/__tests__/inputs.test.ts similarity index 100% rename from packages/patch/src/__tests__/inputs.test.ts rename to packages/patch/__tests__/inputs.test.ts diff --git a/packages/patch/src/__tests__/interfaces.test.ts b/packages/patch/__tests__/interfaces.test.ts similarity index 100% rename from packages/patch/src/__tests__/interfaces.test.ts rename to packages/patch/__tests__/interfaces.test.ts diff --git a/packages/patch/src/__tests__/types.test.ts b/packages/patch/__tests__/types.test.ts similarity index 100% rename from packages/patch/src/__tests__/types.test.ts rename to packages/patch/__tests__/types.test.ts diff --git a/packages/patch/src/__tests__/unions.test.ts b/packages/patch/__tests__/unions.test.ts similarity index 100% rename from packages/patch/src/__tests__/unions.test.ts rename to packages/patch/__tests__/unions.test.ts diff --git a/packages/patch/src/__tests__/utils.ts b/packages/patch/__tests__/utils.ts similarity index 86% rename from packages/patch/src/__tests__/utils.ts rename to packages/patch/__tests__/utils.ts index 19f9581cc4..2aec201c58 100644 --- a/packages/patch/src/__tests__/utils.ts +++ b/packages/patch/__tests__/utils.ts @@ -1,8 +1,7 @@ import { buildSchema, lexicographicSortSchema, type GraphQLSchema } from 'graphql'; import { Change, diff } from '@graphql-inspector/core'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; -import { strictErrorHandler } from '../errors.js'; -import { patchSchema } from '../index.js'; +import { errors, patchSchema } from '../src/index.js'; function printSortedSchema(schema: GraphQLSchema) { return printSchemaWithDirectives(lexicographicSortSchema(schema)); @@ -18,7 +17,7 @@ export async function expectDiffAndPatchToMatch( const changes = await diff(schemaA, schemaB); const patched = patchSchema(schemaA, changes, { debug: process.env.DEBUG === 'true', - onError: strictErrorHandler, + onError: errors.strictErrorHandler, }); expect(printSortedSchema(patched)).toBe(printSortedSchema(schemaB)); return changes; diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index 5864578054..1756972ecd 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -73,7 +73,7 @@ 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}.`, + `The existing value did not match what was expected. Expected the "${kind}" to be "${String(expected)}" but found "${String(actual)}".`, ); } } diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 028a6db9b0..684b2de59c 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -279,7 +279,7 @@ export function directiveDescriptionChanged( return; } - if (directiveNode.description?.value !== change.meta.oldDirectiveDescription) { + if ((directiveNode.description?.value ?? null) !== change.meta.oldDirectiveDescription) { config.onError( new ValueMismatchError( Kind.STRING, diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index 2594286ed7..1fe61d54f9 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -149,7 +149,7 @@ export function typeDescriptionChanged( } export function typeDescriptionRemoved( - change: Change, + change: Change, nodeByPath: Map, config: PatchConfig, _context: PatchContext, @@ -180,11 +180,11 @@ export function typeDescriptionRemoved( return; } - if (typeNode.description?.value !== change.meta.oldTypeDescription) { + if (typeNode.description?.value !== change.meta.removedTypeDescription) { config.onError( new ValueMismatchError( Kind.STRING, - change.meta.oldTypeDescription, + change.meta.removedTypeDescription, typeNode.description?.value, ), change, From 66a21cda8a98af7d7e064641cc6ecffdfd97c93d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:44:35 -0800 Subject: [PATCH 58/73] Add failure cases; fix field add error --- packages/patch/__tests__/fields.test.ts | 26 ++++- packages/patch/__tests__/inputs.test.ts | 109 +++++++++++++++++++- packages/patch/__tests__/interfaces.test.ts | 100 +++++++++++++++++- packages/patch/__tests__/types.test.ts | 84 ++++++++++++++- packages/patch/__tests__/unions.test.ts | 66 +++++++++++- packages/patch/__tests__/utils.ts | 53 ++++++++-- packages/patch/src/patches/fields.ts | 82 +++++++++------ 7 files changed, 468 insertions(+), 52 deletions(-) diff --git a/packages/patch/__tests__/fields.test.ts b/packages/patch/__tests__/fields.test.ts index 9501c4e3ea..dfd6e491ff 100644 --- a/packages/patch/__tests__/fields.test.ts +++ b/packages/patch/__tests__/fields.test.ts @@ -1,4 +1,4 @@ -import { expectDiffAndPatchToMatch } from './utils.js'; +import { expectDiffAndPatchToMatch, expectDiffAndPatchToThrow } from './utils.js'; describe('fields', () => { test('fieldTypeChanged', async () => { @@ -45,7 +45,7 @@ describe('fields', () => { await expectDiffAndPatchToMatch(before, after); }); - test('fieldAdded to new type', async () => { + test('fieldAdded: adding field with a new type', async () => { const before = /* GraphQL */ ` scalar Foo `; @@ -59,6 +59,28 @@ describe('fields', () => { await expectDiffAndPatchToMatch(before, after); }); + test('fieldAdded: throws if adding a field and the field already exists with a different returnType', async () => { + const before = /* GraphQL */ ` + type Product { + id: ID! + } + `; + const after = /* GraphQL */ ` + type Product { + id: ID! + name: String + } + `; + + const patchTarget = /* GraphQL */ ` + type Product { + id: ID! + name: String! + } + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('fieldArgumentAdded', async () => { const before = /* GraphQL */ ` scalar ChatSession diff --git a/packages/patch/__tests__/inputs.test.ts b/packages/patch/__tests__/inputs.test.ts index 5a87e06347..b470700c2a 100644 --- a/packages/patch/__tests__/inputs.test.ts +++ b/packages/patch/__tests__/inputs.test.ts @@ -1,4 +1,8 @@ -import { expectDiffAndPatchToMatch } from './utils.js'; +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} from './utils.js'; describe('inputs', () => { test('inputFieldAdded', async () => { @@ -16,7 +20,7 @@ describe('inputs', () => { await expectDiffAndPatchToMatch(before, after); }); - test('inputFieldAdded to new input', async () => { + test('inputFieldAdded: field added to new input', async () => { const before = /* GraphQL */ ` scalar Foo `; @@ -30,7 +34,31 @@ describe('inputs', () => { foo(foo: FooInput): Foo } `; - const changes = await expectDiffAndPatchToMatch(before, after); + await expectDiffAndPatchToMatch(before, after); + }); + + test('inputFieldAdded: passes if input already exists', async () => { + const before = /* GraphQL */ ` + scalar Foo + `; + const after = /* GraphQL */ ` + scalar Foo + input FooInput { + id: ID! + other: String + } + type Query { + foo(foo: FooInput): Foo + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + input FooInput { + id: ID! + other: String + } + `; + await expectDiffAndPatchToPass(before, after, patchTarget); }); test('inputFieldRemoved', async () => { @@ -48,6 +76,24 @@ describe('inputs', () => { await expectDiffAndPatchToMatch(before, after); }); + test('inputFieldRemoved: passes for non-existent field', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + other: String + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); + test('inputFieldDescriptionAdded', async () => { const before = /* GraphQL */ ` input FooInput { @@ -65,6 +111,26 @@ describe('inputs', () => { await expectDiffAndPatchToMatch(before, after); }); + test('inputFieldDescriptionAdded: errors for non-existent field', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + """ + After + """ + input FooInput { + id: ID! + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('inputFieldTypeChanged', async () => { const before = /* GraphQL */ ` input FooInput { @@ -79,6 +145,23 @@ describe('inputs', () => { await expectDiffAndPatchToMatch(before, after); }); + test('inputFieldTypeChanged: errors for non-existent input', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('inputFieldDescriptionRemoved', async () => { const before = /* GraphQL */ ` """ @@ -95,4 +178,24 @@ describe('inputs', () => { `; await expectDiffAndPatchToMatch(before, after); }); + + test('inputFieldDescriptionRemoved: passes for non-existent input', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); }); diff --git a/packages/patch/__tests__/interfaces.test.ts b/packages/patch/__tests__/interfaces.test.ts index 40cafa5cdc..8c63b26188 100644 --- a/packages/patch/__tests__/interfaces.test.ts +++ b/packages/patch/__tests__/interfaces.test.ts @@ -1,4 +1,8 @@ -import { expectDiffAndPatchToMatch } from './utils.js'; +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} from './utils.js'; describe('interfaces', () => { test('objectTypeInterfaceAdded', async () => { @@ -21,6 +25,26 @@ describe('interfaces', () => { await expectDiffAndPatchToMatch(before, after); }); + test('objectTypeInterfaceAdded: passes if already exists', 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 expectDiffAndPatchToPass(before, after, after); + }); + test('objectTypeInterfaceRemoved', async () => { const before = /* GraphQL */ ` interface Node { @@ -42,6 +66,27 @@ describe('interfaces', () => { await expectDiffAndPatchToMatch(before, after); }); + test('objectTypeInterfaceRemoved: passes if interface is not applied to type', 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 expectDiffAndPatchToPass(before, after, after); + }); + test('fieldAdded', async () => { const before = /* GraphQL */ ` interface Node { @@ -65,6 +110,59 @@ describe('interfaces', () => { await expectDiffAndPatchToMatch(before, after); }); + test('fieldAdded: passes if field already added', 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 expectDiffAndPatchToPass(before, after, after); + }); + + test('fieldAdded: throws if type is non-existent', 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 + } + `; + + const patchTarget = /* GraphQL */ ` + interface Node { + id: ID! + name: String + } + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('fieldRemoved', async () => { const before = /* GraphQL */ ` interface Node { diff --git a/packages/patch/__tests__/types.test.ts b/packages/patch/__tests__/types.test.ts index 9e2e00bc21..8c200c72cd 100644 --- a/packages/patch/__tests__/types.test.ts +++ b/packages/patch/__tests__/types.test.ts @@ -1,4 +1,8 @@ -import { expectDiffAndPatchToMatch } from './utils.js'; +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} from './utils.js'; describe('enum', () => { test('typeRemoved', async () => { @@ -14,6 +18,22 @@ describe('enum', () => { await expectDiffAndPatchToMatch(before, after); }); + test('typeRemoved: passes if type is non-existent', async () => { + const before = /* GraphQL */ ` + scalar Foo + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + scalar Foo + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); + test('typeAdded', async () => { const before = /* GraphQL */ ` enum Status { @@ -31,7 +51,24 @@ describe('enum', () => { await expectDiffAndPatchToMatch(before, after); }); - test('typeAdded Mutation', async () => { + test('typeAdded: ignores if already exists', async () => { + const before = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + SUPER_BROKE + } + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + test('typeAdded: patches Mutation', async () => { const before = /* GraphQL */ ` type Query { foo: String @@ -86,6 +123,29 @@ describe('enum', () => { await expectDiffAndPatchToMatch(before, after); }); + test('typeDescriptionChanged: errors for non-existent types', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + """ + After + """ + enum Status { + OK + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('typeDescriptionChanged: Removed', async () => { const before = /* GraphQL */ ` """ @@ -102,4 +162,24 @@ describe('enum', () => { `; await expectDiffAndPatchToMatch(before, after); }); + + test('typeDescriptionChanged: remove ignored for non-existent type', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + enum Status { + OK + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); }); diff --git a/packages/patch/__tests__/unions.test.ts b/packages/patch/__tests__/unions.test.ts index 57e6d3520e..f964c3387e 100644 --- a/packages/patch/__tests__/unions.test.ts +++ b/packages/patch/__tests__/unions.test.ts @@ -1,4 +1,8 @@ -import { expectDiffAndPatchToMatch } from './utils.js'; +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} from './utils.js'; describe('union', () => { test('unionMemberAdded', async () => { @@ -23,6 +27,36 @@ describe('union', () => { await expectDiffAndPatchToMatch(before, after); }); + test('unionMemberAdded: errors if union does not exist', 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 + `; + const patchTarget = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('unionMemberRemoved', async () => { const before = /* GraphQL */ ` type A { @@ -44,4 +78,34 @@ describe('union', () => { `; await expectDiffAndPatchToMatch(before, after); }); + + test('unionMemberRemoved: ignores already removed union', 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 + `; + const patchTarget = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); }); diff --git a/packages/patch/__tests__/utils.ts b/packages/patch/__tests__/utils.ts index 2aec201c58..552fa1ea52 100644 --- a/packages/patch/__tests__/utils.ts +++ b/packages/patch/__tests__/utils.ts @@ -1,24 +1,59 @@ -import { buildSchema, lexicographicSortSchema, type GraphQLSchema } from 'graphql'; +import { + buildASTSchema, + buildSchema, + GraphQLSchema, + lexicographicSortSchema, + parse, +} from 'graphql'; import { Change, diff } from '@graphql-inspector/core'; import { printSchemaWithDirectives } from '@graphql-tools/utils'; -import { errors, patchSchema } from '../src/index.js'; +import { errors, patch } from '../src/index.js'; function printSortedSchema(schema: GraphQLSchema) { return printSchemaWithDirectives(lexicographicSortSchema(schema)); } -export async function expectDiffAndPatchToMatch( - before: string, - after: string, -): Promise[]> { +async function buildDiffPatch(before: string, after: string, patchTarget: string = before) { 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, { + const patched = patch(parse(patchTarget), changes, { debug: process.env.DEBUG === 'true', onError: errors.strictErrorHandler, }); - expect(printSortedSchema(patched)).toBe(printSortedSchema(schemaB)); - return changes; + return buildASTSchema(patched, { assumeValid: true, assumeValidSDL: true }); +} + +export async function expectDiffAndPatchToMatch( + before: string, + after: string, +): Promise { + const patched = await buildDiffPatch(before, after); + expect(printSortedSchema(patched)).toBe(printSortedSchema(buildSchema(after))); + return patched; +} + +export async function expectDiffAndPatchToThrow( + before: string, + after: string, + /** The schema that gets patched using the diff */ + patchSchema: string, +): Promise { + await expect(async () => await buildDiffPatch(before, after, patchSchema)).rejects.toThrow(); +} + +/** + * Differs from "expectDiffAndPatchToMatch" because the end result doesn't need to match the "after" + * argument. Instead, it just needs to not result in an error when patching another schema. + */ +export async function expectDiffAndPatchToPass( + before: string, + after: string, + /** The schema that gets patched using the diff */ + patchSchema: string, +): Promise { + const result = buildDiffPatch(before, after, patchSchema); + await expect(result).resolves.toBeInstanceOf(GraphQLSchema); + return result; } diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 0f196f9b98..d3868faff3 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -100,47 +100,61 @@ export function fieldAdded( } const changedNode = nodeByPath.get(change.path); if (changedNode) { - if (changedNode.kind === Kind.OBJECT_FIELD) { - config.onError( - new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedFieldName), - change, - ); + if (changedNode.kind === Kind.FIELD_DEFINITION) { + if (print(changedNode.type) === change.meta.addedFieldReturnType) { + config.onError( + new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedFieldName), + change, + ); + } else { + config.onError( + new ValueMismatchError( + Kind.FIELD_DEFINITION, + undefined, + change.meta.addedFieldReturnType, + ), + change, + ); + } } else { config.onError( - new ChangedCoordinateKindMismatchError(Kind.OBJECT_FIELD, changedNode.kind), + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, changedNode.kind), change, ); } - } else { - const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { - fields?: FieldDefinitionNode[]; - }; - if (!typeNode) { - config.onError(new ChangePathMissingError(change), change); - } else if ( - typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && - typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION - ) { - config.onError( - new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), - change, - ); - } 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]; + return; + } + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: FieldDefinitionNode[]; + }; + if (!typeNode) { + config.onError(new ChangePathMissingError(change), change); + return; + } - // add new field to the node set - nodeByPath.set(change.path, node); - } + if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), + change, + ); + return; } + 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); } export function fieldArgumentAdded( From bee17d0a02d258aaa0398455902a5d0bf57c0a40 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 5 Nov 2025 19:17:28 -0800 Subject: [PATCH 59/73] fix removed reference --- packages/patch/src/patches/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index 1fe61d54f9..3882d1f9b8 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -165,7 +165,7 @@ export function typeDescriptionRemoved( new DeletedAncestorCoordinateNotFoundError( Kind.OBJECT_TYPE_DEFINITION, 'description', - change.meta.oldTypeDescription, + change.meta.removedTypeDescription, ), change, ); From 4c697c0191eec220be5e72bc4ff282526214423d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 6 Nov 2025 11:41:20 -0800 Subject: [PATCH 60/73] Add edge case tests for directives --- packages/core/src/diff/changes/directive.ts | 2 +- packages/patch/__tests__/directives.test.ts | 235 +++++++++++++++++++- packages/patch/__tests__/utils.ts | 10 +- packages/patch/src/errors.ts | 3 +- packages/patch/src/patches/directives.ts | 120 +++++++--- 5 files changed, 338 insertions(+), 32 deletions(-) diff --git a/packages/core/src/diff/changes/directive.ts b/packages/core/src/diff/changes/directive.ts index c0dadec371..368714a0cd 100644 --- a/packages/core/src/diff/changes/directive.ts +++ b/packages/core/src/diff/changes/directive.ts @@ -269,7 +269,7 @@ export function directiveArgumentAdded( addedDirectiveArgumentName: arg.name, addedDirectiveArgumentType: arg.type.toString(), addedDirectiveDefaultValue: - arg.defaultValue === undefined ? '' : safeString(arg.defaultValue), + arg.defaultValue === undefined ? undefined : safeString(arg.defaultValue), addedDirectiveArgumentTypeIsNonNull: isNonNullType(arg.type), addedDirectiveArgumentDescription: arg.description ?? null, addedToNewDirective, diff --git a/packages/patch/__tests__/directives.test.ts b/packages/patch/__tests__/directives.test.ts index 29b45c0042..40c96bea3a 100644 --- a/packages/patch/__tests__/directives.test.ts +++ b/packages/patch/__tests__/directives.test.ts @@ -1,4 +1,8 @@ -import { expectDiffAndPatchToMatch } from './utils.js'; +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} from './utils.js'; describe('directives', () => { test('directiveAdded', async () => { @@ -12,6 +16,54 @@ describe('directives', () => { await expectDiffAndPatchToMatch(before, after); }); + test('directiveAdded: ignores if directive already exists', async () => { + const before = /* GraphQL */ ` + scalar Food + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + /** + * @note this is somewhat counter intuitive, but if the directive already exists + * and has all the same properties of the change -- but with more, then it's + * assumed that this addition was intentional and there should be no conflict. + * This change can result in an invalid schema though. If the change adds a + * directive usage that is lacking these arguments. + */ + test('directiveAdded: ignores if directive exists but only arguments do not match', async () => { + const before = /* GraphQL */ ` + scalar Food + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(flavor: String) on FIELD_DEFINITION + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); + + test('directiveAdded: errors if directive exists but locations do not match', async () => { + const before = /* GraphQL */ ` + scalar Food + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(flavor: String) on INTERFACE + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('directiveRemoved', async () => { const before = /* GraphQL */ ` scalar Food @@ -23,6 +75,17 @@ describe('directives', () => { await expectDiffAndPatchToMatch(before, after); }); + test('directiveRemoved: ignores if patching schema does not have directive', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + `; + await expectDiffAndPatchToPass(before, after, after); + }); + test('directiveArgumentAdded', async () => { const before = /* GraphQL */ ` scalar Food @@ -35,6 +98,50 @@ describe('directives', () => { await expectDiffAndPatchToMatch(before, after); }); + test('directiveArgumentAdded: ignores if directive argument is already added', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + test('directiveArgumentAdded: errors if directive argument is already added but type differs', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String!) on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('directiveArgumentAdded: errors if directive argument is already added but defaultValue differs', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String = "ok") on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String! = "not ok") on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('directiveArgumentRemoved', async () => { const before = /* GraphQL */ ` scalar Food @@ -47,6 +154,18 @@ describe('directives', () => { await expectDiffAndPatchToMatch(before, after); }); + test('directiveArgumentRemoved: ignores if non-existent', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + await expectDiffAndPatchToPass(before, after, after); + }); + test('directiveLocationAdded', async () => { const before = /* GraphQL */ ` scalar Food @@ -59,6 +178,38 @@ describe('directives', () => { await expectDiffAndPatchToMatch(before, after); }); + test('directiveLocationAdded: ignores if already exists', 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 expectDiffAndPatchToPass(before, after, after); + }); + + /** + * This is okay because the change is to add another location. It says nothing about whether or not + * the existing locations are sufficient otherwise. + */ + test('directiveLocationAdded: passes if already exists with a different location', 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 + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION | INTERFACE + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); + test('directiveArgumentDefaultValueChanged', async () => { const before = /* GraphQL */ ` scalar Food @@ -71,6 +222,22 @@ describe('directives', () => { await expectDiffAndPatchToMatch(before, after); }); + test('directiveArgumentDefaultValueChanged: throws if old default value does not match schema', 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 + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String = "Flavertown") on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('directiveDescriptionChanged', async () => { const before = /* GraphQL */ ` scalar Food @@ -86,6 +253,28 @@ describe('directives', () => { await expectDiffAndPatchToMatch(before, after); }); + test('directiveDescriptionChanged: throws if old description does not match schema', 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 + `; + const patchTarget = /* GraphQL */ ` + scalar Food + """ + I change this + """ + directive @tasty(reason: String) on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('directiveArgumentTypeChanged', async () => { const before = /* GraphQL */ ` scalar Food @@ -98,6 +287,22 @@ describe('directives', () => { await expectDiffAndPatchToMatch(before, after); }); + test('directiveArgumentTypeChanged: throws if old argument type does not match schema', 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 + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(scale: String!) on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + test('directiveRepeatableAdded', async () => { const before = /* GraphQL */ ` scalar Food @@ -110,6 +315,21 @@ describe('directives', () => { await expectDiffAndPatchToMatch(before, after); }); + test('directiveRepeatableAdded: throws if directive does not exist in patched schema', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION + `; + const patchSchema = /* GraphQL */ ` + scalar Food + `; + await expectDiffAndPatchToThrow(before, after, patchSchema); + }); + test('directiveRepeatableRemoved', async () => { const before = /* GraphQL */ ` directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION @@ -119,6 +339,19 @@ describe('directives', () => { `; await expectDiffAndPatchToMatch(before, after); }); + + test('directiveRepeatableRemoved: ignores if directive does not exist in patched schema', async () => { + const before = /* GraphQL */ ` + directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); }); describe('repeat directives', () => { diff --git a/packages/patch/__tests__/utils.ts b/packages/patch/__tests__/utils.ts index 552fa1ea52..66d45dd33a 100644 --- a/packages/patch/__tests__/utils.ts +++ b/packages/patch/__tests__/utils.ts @@ -40,7 +40,15 @@ export async function expectDiffAndPatchToThrow( /** The schema that gets patched using the diff */ patchSchema: string, ): Promise { - await expect(async () => await buildDiffPatch(before, after, patchSchema)).rejects.toThrow(); + await expect(async () => { + try { + return await buildDiffPatch(before, after, patchSchema); + } catch (e) { + const err = e as Error; + console.error(`Patch threw as expected with error: ${err.message}`); + throw e; + } + }).rejects.toThrow(); } /** diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index 1756972ecd..3f0389fefe 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -108,7 +108,8 @@ export type NodeAttribute = | 'directives' | 'arguments' | 'locations' - | 'fields'; + | 'fields' + | 'repeatable'; /** * If trying to add a node at a path, but that path no longer exists. E.g. add a description to diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 684b2de59c..2cf63361e3 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -44,23 +44,61 @@ export function directiveAdded( } const changedNode = nodeByPath.get(change.path); - if (changedNode) { + + if (!changedNode) { + 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(change.path, node); + return; + } + + if (changedNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + change, + ); + return; + } + + // eslint-disable-next-line eqeqeq + if (change.meta.addedDirectiveRepeatable != changedNode.repeatable) { config.onError( - new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedDirectiveName), + new ValueMismatchError( + changedNode.kind, + `repeatable=${change.meta.addedDirectiveRepeatable}`, + `repeatable=${changedNode.repeatable}`, + ), + change, + ); + return; + } + + if ( + change.meta.addedDirectiveLocations.join('|') !== + changedNode.locations.map(l => l.value).join('|') + ) { + config.onError( + new ValueMismatchError( + changedNode.kind, + `locations=${change.meta.addedDirectiveLocations.join('|')}`, + `locations=${changedNode.locations.map(l => l.value).join('|')}`, + ), change, ); return; } - 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(change.path, node); + + config.onError( + new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedDirectiveName), + change, + ); } export function directiveRemoved( @@ -110,28 +148,54 @@ export function directiveArgumentAdded( directiveNode.arguments, change.meta.addedDirectiveArgumentName, ); - if (existingArg) { + if (!existingArg) { + 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(`${change.path}.${change.meta.addedDirectiveArgumentName}`, node); + return; + } + + const existingType = print(existingArg.type); + if (existingType !== change.meta.addedDirectiveArgumentType) { config.onError( - new AddedAttributeAlreadyExistsError( + new ValueMismatchError( existingArg.kind, - 'arguments', - change.meta.addedDirectiveArgumentName, + `type=${change.meta.addedDirectiveArgumentType}`, + `type=${existingType}`, ), change, ); - return; } - 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(`${change.path}.${change.meta.addedDirectiveArgumentName}`, node); + const existingDefaultValue = existingArg.defaultValue + ? print(existingArg.defaultValue) + : undefined; + if (change.meta.addedDirectiveDefaultValue !== existingDefaultValue) { + config.onError( + new ValueMismatchError( + existingArg.kind, + `defaultValue=${change.meta.addedDirectiveDefaultValue}`, + `defaultValue=${existingDefaultValue}`, + ), + change, + ); + } + + config.onError( + new AddedAttributeAlreadyExistsError( + existingArg.kind, + 'arguments', + change.meta.addedDirectiveArgumentName, + ), + change, + ); } export function directiveArgumentRemoved( @@ -475,7 +539,7 @@ export function directiveRepeatableRemoved( const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), + new DeletedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'repeatable', 'true'), change, ); return; From 91e35cd2e8c8b576c978600b94564c62da10093e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:36:57 -0800 Subject: [PATCH 61/73] Improve numerous error messages --- packages/patch/src/errors.ts | 118 +++++++++--------- .../patch/src/patches/directive-usages.ts | 34 ++--- packages/patch/src/patches/directives.ts | 103 +++++++++------ packages/patch/src/patches/enum.ts | 27 +++- packages/patch/src/patches/fields.ts | 39 +++--- packages/patch/src/patches/inputs.ts | 26 ++-- packages/patch/src/patches/interfaces.ts | 13 +- packages/patch/src/patches/types.ts | 21 ++-- packages/patch/src/patches/unions.ts | 18 ++- packages/patch/src/utils.ts | 25 ---- 10 files changed, 238 insertions(+), 186 deletions(-) diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index 3f0389fefe..77bdafc454 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -1,17 +1,16 @@ import { Kind } from 'graphql'; import type { Change } from '@graphql-inspector/core'; -import type { ErrorHandler } from './types.js'; +import type { ChangesByType, ErrorHandler } from './types.js'; +import { parentPath } from './utils.js'; /** * The strictest of the standard error handlers. This checks if the error is a "No-op", * meaning if the change wouldn't impact the schema at all, and ignores the error * only in this one case. Otherwise, the error is raised. */ -export const strictErrorHandler: ErrorHandler = (err, change) => { +export const strictErrorHandler: ErrorHandler = (err, _change) => { if (err instanceof NoopError) { - console.debug( - `Ignoring change ${change.type} at "${change.path}" because it does not modify the resulting schema.`, - ); + console.debug(`[IGNORED] ${err.message}`); } else { throw err; } @@ -30,9 +29,7 @@ export const strictErrorHandler: ErrorHandler = (err, change) => { */ export const defaultErrorHandler: ErrorHandler = (err, change) => { if (err instanceof NoopError) { - console.debug( - `Ignoring change ${change.type} at "${change.path}" because it does not modify the resulting schema.`, - ); + console.debug(`[IGNORED] ${err.message}`); } else if (err instanceof ValueMismatchError) { console.debug(`Ignoring old value mismatch at "${change.path}".`); } else { @@ -48,13 +45,11 @@ export const defaultErrorHandler: ErrorHandler = (err, change) => { */ export const looseErrorHandler: ErrorHandler = (err, change) => { if (err instanceof NoopError) { - console.debug( - `Ignoring change ${change.type} at "${change.path}" because it does not modify the resulting schema.`, - ); + console.debug(`[IGNORED] ${err.message}`); } else if (err instanceof ValueMismatchError) { console.debug(`Ignoring old value mismatch at "${change.path}".`); } else { - console.warn(`Cannot apply ${change.type} at "${change.path}". ${err.message}`); + console.warn(err.message); } }; @@ -87,43 +82,31 @@ export class ValueMismatchError extends Error { */ export class AddedCoordinateAlreadyExistsError extends NoopError { constructor( - public readonly kind: Kind, - readonly expectedNameOrValue: string | undefined, + public readonly path: string, + public readonly changeType: keyof ChangesByType, ) { - const expected = expectedNameOrValue ? `${expectedNameOrValue} ` : ''; - super(`A "${kind}" ${expected}already exists at the schema coordinate.`); + const subpath = path.substring(path.lastIndexOf('.') + 1); + const parent = parentPath(path); + const printedParent = parent === subpath ? 'schema' : `"${parent}"`; + super( + `Cannot apply "${changeType}" to add "${subpath}" to ${printedParent} because that schema coordinate already exists.`, + ); } } -export type NodeAttribute = - | 'description' - | 'defaultValue' - /** Enum values */ - | 'values' - /** Union types */ - | 'types' - /** Return type */ - | 'type' - | 'interfaces' - | 'directives' - | 'arguments' - | 'locations' - | 'fields' - | 'repeatable'; - -/** - * 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( - public readonly parentName: string, - readonly attribute: NodeAttribute, - readonly attributeValue: string, + public readonly path: string, + public readonly changeType: keyof ChangesByType, + /** + * The value of what is being changed at the path. E.g. if the description is being changed, then this should + * be the description string. + */ + public readonly changeValue: string | number | null, ) { + const subpath = path.substring(path.lastIndexOf('.')); super( - `Cannot add "${attributeValue}" to "${attribute}", because "${parentName}" does not exist.`, + `Cannot apply addition "${changeType}" (${changeValue}) to "${subpath}", because "${path}" does not exist.`, ); } } @@ -134,10 +117,18 @@ export class AddedAttributeCoordinateNotFoundError extends Error { */ export class ChangedAncestorCoordinateNotFoundError extends Error { constructor( - public readonly parentKind: Kind, - readonly attribute: NodeAttribute, + public readonly path: string, + public readonly changeType: keyof ChangesByType, + /** + * The value of what is being changed at the path. E.g. if the description is being changed, then this should + * be the description string. + */ + public readonly changeValue: string | number | boolean | null, ) { - super(`Cannot change the "${attribute}" because the "${parentKind}" does not exist.`); + const subpath = path.substring(path.lastIndexOf('.')); + super( + `Cannot apply change "${changeType}" (${typeof changeValue === 'string' ? `"${changeValue}"` : changeValue}) to "${subpath}", because the "${parentPath(path)}" does not exist.`, + ); } } @@ -147,12 +138,17 @@ export class ChangedAncestorCoordinateNotFoundError extends Error { */ export class DeletedAncestorCoordinateNotFoundError extends NoopError { constructor( - public readonly parentKind: Kind, - readonly attribute: NodeAttribute, - readonly expectedValue: string | undefined, + public readonly path: string, + public readonly changeType: keyof ChangesByType, + /** + * The value of what is being changed at the path. E.g. if the description is being changed, then this should + * be the description string. + */ + public readonly expectedValue: string | number | boolean | null, ) { + const subpath = path.substring(path.lastIndexOf('.')); super( - `Cannot delete ${expectedValue ? `"${expectedValue}" ` : ''}from "${attribute}" on "${parentKind}" because the "${parentKind}" does not exist.`, + `Cannot apply "${changeType}" to remove ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}} from "${subpath}", because "${parentPath(path)}" does not exist.`, ); } } @@ -163,12 +159,15 @@ export class DeletedAncestorCoordinateNotFoundError extends NoopError { */ export class AddedAttributeAlreadyExistsError extends NoopError { constructor( - public readonly parentKind: Kind, - readonly attribute: NodeAttribute, - readonly attributeValue: string, + public readonly path: string, + public readonly changeType: string, + /** The property's path on the node. E.g. defaultValue */ + public readonly attribute: string, + public readonly expectedValue?: string, ) { + const subpath = path.substring(path.lastIndexOf('.')); super( - `Cannot add "${attributeValue}" to "${attribute}" on "${parentKind}" because it already exists.`, + `Cannot apply "${changeType}" to add ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue} to "${subpath}.${attribute}", because it already exists`, ); } } @@ -179,12 +178,15 @@ export class AddedAttributeAlreadyExistsError extends NoopError { */ export class DeletedAttributeNotFoundError extends NoopError { constructor( - public readonly parentKind: Kind, - readonly attribute: NodeAttribute, - public readonly value: string, + public readonly path: string, + public readonly changeType: string, + /** The property's path on the node. E.g. defaultValue */ + public readonly attribute: string, + public readonly expectedValue?: string, ) { + const subpath = path.substring(path.lastIndexOf('.')); super( - `Cannot delete "${value}" from "${parentKind}"'s "${attribute}" because "${value}" does not exist.`, + `Cannot apply "${changeType}" to remove ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}} from ${subpath}'s "${attribute}", because "${attribute}" does not exist at "${path}".`, ); } } @@ -218,6 +220,8 @@ export class ChangedCoordinateKindMismatchError extends Error { */ export class ChangePathMissingError extends Error { constructor(public readonly change: Change) { - super(`The change is missing a "path". Cannot apply.`); + super( + `The change "${change.type}" at "${change.path}" is missing a "path" value. Cannot apply.`, + ); } } diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index a42901ab81..c556260638 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -84,8 +84,9 @@ function directiveUsageDefinitionAdded( if (!parentNode) { config.onError( new ChangedAncestorCoordinateNotFoundError( - Kind.OBJECT_TYPE_DEFINITION, // or interface... - 'directives', + change.path, + change.type, + change.meta.addedDirectiveName, ), change, ); @@ -106,10 +107,7 @@ function directiveUsageDefinitionAdded( change.meta.directiveRepeatedTimes, ); if (!repeatable && directiveNode) { - config.onError( - new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, change.meta.addedDirectiveName), - change, - ); + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); return; } @@ -153,7 +151,8 @@ function schemaDirectiveUsageDefinitionAdded( if (!repeatable && directiveAlreadyExists) { config.onError( new AddedAttributeAlreadyExistsError( - Kind.SCHEMA_DEFINITION, + change.path, + change.type, 'directives', change.meta.addedDirectiveName, ), @@ -197,7 +196,8 @@ function schemaDirectiveUsageDefinitionRemoved( if (!deleted) { config.onError( new DeletedAttributeNotFoundError( - Kind.SCHEMA_DEFINITION, + change.path ?? '', + change.type, 'directives', change.meta.removedDirectiveName, ), @@ -223,8 +223,8 @@ function directiveUsageDefinitionRemoved( if (!parentNode) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.OBJECT_TYPE_DEFINITION, - 'directives', + change.path, + change.type, change.meta.removedDirectiveName, ), change, @@ -240,7 +240,8 @@ function directiveUsageDefinitionRemoved( if (!directiveNode) { config.onError( new DeletedAttributeNotFoundError( - parentNode.kind, + change.path, + change.type, 'directives', change.meta.removedDirectiveName, ), @@ -500,8 +501,8 @@ export function directiveUsageArgumentAdded( if (!directiveNode) { config.onError( new AddedAttributeCoordinateNotFoundError( - change.meta.directiveName, - 'arguments', + change.path, + change.type, change.meta.addedArgumentName, ), change, @@ -559,8 +560,8 @@ export function directiveUsageArgumentRemoved( if (!directiveNode) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.DIRECTIVE, - 'arguments', + change.path, + change.type, change.meta.removedArgumentName, ), change, @@ -579,7 +580,8 @@ export function directiveUsageArgumentRemoved( if (!existing) { config.onError( new DeletedAttributeNotFoundError( - directiveNode.kind, + change.path, + change.type, 'arguments', change.meta.removedArgumentName, ), diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts index 2cf63361e3..b52619e5a5 100644 --- a/packages/patch/src/patches/directives.ts +++ b/packages/patch/src/patches/directives.ts @@ -25,12 +25,7 @@ import { } from '../errors.js'; import { nameNode, stringNode } from '../node-templates.js'; import { PatchConfig, PatchContext } from '../types.js'; -import { - deleteNamedNode, - findNamedNode, - getDeletedNodeOfKind, - getDeletedParentNodeOfKind, -} from '../utils.js'; +import { deleteNamedNode, findNamedNode, getDeletedNodeOfKind, parentPath } from '../utils.js'; export function directiveAdded( change: Change, @@ -95,10 +90,7 @@ export function directiveAdded( return; } - config.onError( - new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedDirectiveName), - change, - ); + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); } export function directiveRemoved( @@ -128,8 +120,8 @@ export function directiveArgumentAdded( if (!directiveNode) { config.onError( new AddedAttributeCoordinateNotFoundError( - change.meta.directiveName, - 'arguments', + change.path, + change.type, change.meta.addedDirectiveArgumentName, ), change, @@ -190,7 +182,8 @@ export function directiveArgumentAdded( config.onError( new AddedAttributeAlreadyExistsError( - existingArg.kind, + change.path, + change.type, 'arguments', change.meta.addedDirectiveArgumentName, ), @@ -204,20 +197,35 @@ export function directiveArgumentRemoved( config: PatchConfig, _context: PatchContext, ) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } 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); + if (argNode) { + const directiveNode = nodeByPath.get(parentPath(change.path)); + if (!directiveNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedDirectiveArgumentName, + ), + change, + ); + return; } + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + change, + ); + return; + } + + (directiveNode.arguments as ReadonlyArray | undefined) = + deleteNamedNode(directiveNode.arguments, change.meta.removedDirectiveArgumentName); } } @@ -235,7 +243,11 @@ export function directiveLocationAdded( const changedNode = nodeByPath.get(change.path); if (!changedNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'locations'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedDirectiveLocation, + ), change, ); return; @@ -252,7 +264,8 @@ export function directiveLocationAdded( if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { config.onError( new AddedAttributeAlreadyExistsError( - Kind.DIRECTIVE_DEFINITION, + change.path, + change.type, 'locations', change.meta.addedDirectiveLocation, ), @@ -282,8 +295,8 @@ export function directiveLocationRemoved( if (!changedNode) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.DIRECTIVE_DEFINITION, - 'locations', + change.path, + change.type, change.meta.removedDirectiveLocation, ), change, @@ -307,7 +320,8 @@ export function directiveLocationRemoved( } else { config.onError( new DeletedAttributeNotFoundError( - changedNode.kind, + change.path, + change.type, 'locations', change.meta.removedDirectiveLocation, ), @@ -330,7 +344,11 @@ export function directiveDescriptionChanged( const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDirectiveDescription, + ), change, ); return; @@ -373,7 +391,11 @@ export function directiveArgumentDefaultValueChanged( const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'defaultValue'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDirectiveArgumentDefaultValue ?? null, + ), change, ); return; @@ -421,7 +443,11 @@ export function directiveArgumentDescriptionChanged( const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'description'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDirectiveArgumentDescription, + ), change, ); return; @@ -464,7 +490,14 @@ export function directiveArgumentTypeChanged( const argumentNode = nodeByPath.get(change.path); if (!argumentNode) { - config.onError(new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'type'), change); + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDirectiveArgumentType, + ), + change, + ); return; } if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { @@ -502,7 +535,7 @@ export function directiveRepeatableAdded( const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), + new ChangedAncestorCoordinateNotFoundError(change.path, change.type, true), change, ); return; @@ -539,7 +572,7 @@ export function directiveRepeatableRemoved( const directiveNode = nodeByPath.get(change.path); if (!directiveNode) { config.onError( - new DeletedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'repeatable', 'true'), + new DeletedAncestorCoordinateNotFoundError(change.path, change.type, true), change, ); return; diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts index 5e7fdc9ec9..2e2208c9d0 100644 --- a/packages/patch/src/patches/enum.ts +++ b/packages/patch/src/patches/enum.ts @@ -60,7 +60,8 @@ export function enumValueRemoved( if (beforeLength === enumNode.values.length) { config.onError( new DeletedAttributeNotFoundError( - Kind.ENUM_TYPE_DEFINITION, + change.path, + change.type, 'values', change.meta.removedEnumValueName, ), @@ -79,20 +80,32 @@ export function enumValueAdded( config: PatchConfig, _context: PatchContext, ) { - const enumValuePath = change.path!; + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + const enumValuePath = change.path; const enumNode = nodeByPath.get(parentPath(enumValuePath)) as | (ASTNode & { values: EnumValueDefinitionNode[] }) | undefined; const changedNode = nodeByPath.get(enumValuePath); if (!enumNode) { - config.onError(new ChangedAncestorCoordinateNotFoundError(Kind.ENUM, 'values'), change); + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedEnumValueName, + ), + change, + ); return; } if (changedNode) { config.onError( new AddedAttributeAlreadyExistsError( - changedNode.kind, + change.path, + change.type, 'values', change.meta.addedEnumValueName, ), @@ -135,7 +148,11 @@ export function enumValueDescriptionChanged( const enumValueNode = nodeByPath.get(change.path); if (!enumValueNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.ENUM_VALUE_DEFINITION, 'values'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newEnumValueDescription, + ), change, ); return; diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index d3868faff3..68f3fcfbe4 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -14,6 +14,7 @@ import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeCoordinateNotFoundError, AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, ChangedCoordinateKindMismatchError, ChangePathMissingError, DeletedAncestorCoordinateNotFoundError, @@ -66,8 +67,8 @@ export function fieldRemoved( if (!typeNode) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.OBJECT_TYPE_DEFINITION, - 'fields', + change.path, + change.type, change.meta.removedFieldName, ), change, @@ -79,7 +80,12 @@ export function fieldRemoved( typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); if (beforeLength === (typeNode.fields?.length ?? 0)) { config.onError( - new DeletedAttributeNotFoundError(typeNode?.kind, 'fields', change.meta.removedFieldName), + new DeletedAttributeNotFoundError( + change.path, + change.type, + 'fields', + change.meta.removedFieldName, + ), change, ); } else { @@ -102,10 +108,7 @@ export function fieldAdded( if (changedNode) { if (changedNode.kind === Kind.FIELD_DEFINITION) { if (print(changedNode.type) === change.meta.addedFieldReturnType) { - config.onError( - new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedFieldName), - change, - ); + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); } else { config.onError( new ValueMismatchError( @@ -128,7 +131,14 @@ export function fieldAdded( fields?: FieldDefinitionNode[]; }; if (!typeNode) { - config.onError(new ChangePathMissingError(change), change); + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedFieldName, + ), + change, + ); return; } @@ -170,10 +180,7 @@ export function fieldArgumentAdded( const existing = nodeByPath.get(change.path); if (existing) { - config.onError( - new AddedCoordinateAlreadyExistsError(Kind.ARGUMENT, change.meta.addedArgumentName), - change, - ); + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); return; } @@ -183,8 +190,8 @@ export function fieldArgumentAdded( if (!fieldNode) { config.onError( new AddedAttributeCoordinateNotFoundError( - change.meta.fieldName, - 'arguments', + change.path, + change.type, change.meta.addedArgumentName, ), change, @@ -295,8 +302,8 @@ export function fieldArgumentRemoved( if (!fieldNode) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.FIELD_DEFINITION, - 'arguments', + change.path!, // asserted by "getDeletedNodeOfKind" + change.type, change.meta.removedFieldArgumentName, ), change, diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index c7a91db44a..7148c34cbc 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -43,13 +43,7 @@ export function inputFieldAdded( const existingNode = nodeByPath.get(change.path); if (existingNode) { if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { - config.onError( - new AddedCoordinateAlreadyExistsError( - Kind.INPUT_VALUE_DEFINITION, - change.meta.addedInputFieldName, - ), - change, - ); + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); } else { config.onError( new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), @@ -64,8 +58,8 @@ export function inputFieldAdded( if (!typeNode) { config.onError( new AddedAttributeCoordinateNotFoundError( - change.meta.inputName, - 'fields', + change.path, + change.type, change.meta.addedInputFieldName, ), change, @@ -124,8 +118,8 @@ export function inputFieldRemoved( if (!typeNode) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.INPUT_OBJECT_TYPE_DEFINITION, - 'fields', + change.path, + change.type, change.meta.removedFieldName, ), change, @@ -161,8 +155,8 @@ export function inputFieldDescriptionAdded( if (!existingNode) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.INPUT_VALUE_DEFINITION, - 'description', + change.path, + change.type, change.meta.addedInputFieldDescription, ), change, @@ -220,7 +214,11 @@ export function inputFieldDefaultValueChanged( const existingNode = nodeByPath.get(change.path); if (!existingNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'defaultValue'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDefaultValue ?? null, + ), change, ); return; diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index 20a8dd38bb..b68cefd5ea 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -26,7 +26,11 @@ export function objectTypeInterfaceAdded( const typeNode = nodeByPath.get(change.path); if (!typeNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'interfaces'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedInterfaceName, + ), change, ); return; @@ -50,7 +54,8 @@ export function objectTypeInterfaceAdded( if (existing) { config.onError( new AddedAttributeAlreadyExistsError( - typeNode.kind, + change.path, + change.type, 'interfaces', change.meta.addedInterfaceName, ), @@ -80,8 +85,8 @@ export function objectTypeInterfaceRemoved( if (!typeNode) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.INPUT_OBJECT_TYPE_DEFINITION, - 'interfaces', + change.path, + change.type, change.meta.removedInterfaceName, ), change, diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index 3882d1f9b8..0a64c24df8 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -25,10 +25,7 @@ export function typeAdded( const existing = nodeByPath.get(change.path); if (existing) { - config.onError( - new AddedCoordinateAlreadyExistsError(existing.kind, change.meta.addedTypeName), - change, - ); + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); return; } const node: TypeDefinitionNode = { @@ -88,7 +85,11 @@ export function typeDescriptionAdded( const typeNode = nodeByPath.get(change.path); if (!typeNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedTypeDescription, + ), change, ); return; @@ -120,7 +121,11 @@ export function typeDescriptionChanged( const typeNode = nodeByPath.get(change.path); if (!typeNode) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newTypeDescription, + ), change, ); return; @@ -163,8 +168,8 @@ export function typeDescriptionRemoved( if (!typeNode) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.OBJECT_TYPE_DEFINITION, - 'description', + change.path, + change.type, change.meta.removedTypeDescription, ), change, diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts index 3e4839de2c..d02392f404 100644 --- a/packages/patch/src/patches/unions.ts +++ b/packages/patch/src/patches/unions.ts @@ -1,4 +1,4 @@ -import { ASTNode, Kind, NamedTypeNode } from 'graphql'; +import { ASTNode, NamedTypeNode } from 'graphql'; import { Change, ChangeType } from '@graphql-inspector/core'; import { AddedAttributeAlreadyExistsError, @@ -26,7 +26,11 @@ export function unionMemberAdded( | undefined; if (!union) { config.onError( - new ChangedAncestorCoordinateNotFoundError(Kind.UNION_TYPE_DEFINITION, 'types'), + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedUnionMemberTypeName, + ), change, ); return; @@ -35,7 +39,8 @@ export function unionMemberAdded( if (findNamedNode(union.types, change.meta.addedUnionMemberTypeName)) { config.onError( new AddedAttributeAlreadyExistsError( - Kind.UNION_TYPE_DEFINITION, + change.path, + change.type, 'types', change.meta.addedUnionMemberTypeName, ), @@ -63,8 +68,8 @@ export function unionMemberRemoved( if (!union) { config.onError( new DeletedAncestorCoordinateNotFoundError( - Kind.UNION_TYPE_DEFINITION, - 'types', + change.path, + change.type, change.meta.removedUnionMemberTypeName, ), change, @@ -75,7 +80,8 @@ export function unionMemberRemoved( if (!findNamedNode(union.types, change.meta.removedUnionMemberTypeName)) { config.onError( new DeletedAttributeNotFoundError( - Kind.UNION_TYPE_DEFINITION, + change.path, + change.type, 'types', change.meta.removedUnionMemberTypeName, ), diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index 4ec980382c..f8649b264e 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -5,9 +5,7 @@ import { ChangedCoordinateKindMismatchError, ChangedCoordinateNotFoundError, ChangePathMissingError, - DeletedAncestorCoordinateNotFoundError, DeletedCoordinateNotFound, - NodeAttribute, ValueMismatchError, } from './errors.js'; import { AdditionChangeType, PatchConfig } from './types.js'; @@ -149,26 +147,3 @@ export function getDeletedNodeOfKind( } return existing as ASTKindToNode[K]; } - -export function getDeletedParentNodeOfKind( - change: Change, - nodeByPath: Map, - kind: K, - attribute: NodeAttribute, - config: PatchConfig, -): ASTKindToNode[K] | void { - if (!change.path) { - config.onError(new ChangePathMissingError(change), change); - return; - } - const existing = nodeByPath.get(parentPath(change.path)); - if (!existing) { - config.onError(new DeletedAncestorCoordinateNotFoundError(kind, attribute, undefined), change); - return; - } - if (existing.kind !== kind) { - config.onError(new ChangedCoordinateKindMismatchError(kind, existing.kind), change); - return; - } - return existing as ASTKindToNode[K]; -} From 7450087b01a9494b2a51f9e97a9cb9e25cab8b5d Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:12:44 -0800 Subject: [PATCH 62/73] More error format improvements --- packages/patch/src/errors.ts | 17 ++++++++++++----- packages/patch/src/patches/directive-usages.ts | 4 +++- packages/patch/src/patches/fields.ts | 10 ++-------- packages/patch/src/patches/inputs.ts | 8 +------- packages/patch/src/patches/interfaces.ts | 8 +------- packages/patch/src/patches/types.ts | 10 ++-------- packages/patch/src/utils.ts | 2 +- 7 files changed, 22 insertions(+), 37 deletions(-) diff --git a/packages/patch/src/errors.ts b/packages/patch/src/errors.ts index 77bdafc454..041d6c0081 100644 --- a/packages/patch/src/errors.ts +++ b/packages/patch/src/errors.ts @@ -148,7 +148,7 @@ export class DeletedAncestorCoordinateNotFoundError extends NoopError { ) { const subpath = path.substring(path.lastIndexOf('.')); super( - `Cannot apply "${changeType}" to remove ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}} from "${subpath}", because "${parentPath(path)}" does not exist.`, + `Cannot apply "${changeType}" to remove ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue} from "${subpath}", because "${parentPath(path)}" does not exist.`, ); } } @@ -186,7 +186,7 @@ export class DeletedAttributeNotFoundError extends NoopError { ) { const subpath = path.substring(path.lastIndexOf('.')); super( - `Cannot apply "${changeType}" to remove ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue}} from ${subpath}'s "${attribute}", because "${attribute}" does not exist at "${path}".`, + `Cannot apply "${changeType}" to remove ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue} from ${subpath}'s "${attribute}", because "${attribute}" does not exist at "${path}".`, ); } } @@ -200,9 +200,16 @@ export class ChangedCoordinateNotFoundError extends Error { } export class DeletedCoordinateNotFound extends NoopError { - constructor(expectedKind: Kind, expectedNameOrValue: string | undefined) { - const expected = expectedNameOrValue ? `${expectedNameOrValue} ` : ''; - super(`The removed "${expectedKind}" ${expected}already does not exist.`); + constructor( + public readonly path: string, + public readonly changeType: string, + ) { + const subpath = path.substring(path.lastIndexOf('.')); + const parent = parentPath(path); + const printedParent = parent === subpath ? 'schema' : `"${parent}"`; + super( + `Cannot apply "${changeType}" on "${printedParent}", because "${subpath}" does not exist.`, + ); } } diff --git a/packages/patch/src/patches/directive-usages.ts b/packages/patch/src/patches/directive-usages.ts index c556260638..12b03d8168 100644 --- a/packages/patch/src/patches/directive-usages.ts +++ b/packages/patch/src/patches/directive-usages.ts @@ -96,7 +96,9 @@ function directiveUsageDefinitionAdded( const definition = nodeByPath.get(`@${change.meta.addedDirectiveName}`); let repeatable = false; if (!definition) { - console.warn(`Directive "@${change.meta.addedDirectiveName}" is missing a definition.`); + console.warn( + `Patch cannot determine the repeatability of directive "@${change.meta.addedDirectiveName}" because it's missing a definition.`, + ); } if (definition?.kind === Kind.DIRECTIVE_DEFINITION) { repeatable = definition.repeatable; diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts index 68f3fcfbe4..fb7129b1e6 100644 --- a/packages/patch/src/patches/fields.ts +++ b/packages/patch/src/patches/fields.ts @@ -289,10 +289,7 @@ export function fieldArgumentRemoved( ) { const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.ARGUMENT, config); if (!existing) { - config.onError( - new DeletedCoordinateNotFound(Kind.ARGUMENT, change.meta.removedFieldArgumentName), - change, - ); + config.onError(new DeletedCoordinateNotFound(change.path ?? '', change.type), change); return; } @@ -351,10 +348,7 @@ export function fieldDescriptionRemoved( const fieldNode = nodeByPath.get(change.path); if (!fieldNode) { - config.onError( - new DeletedCoordinateNotFound(Kind.FIELD_DEFINITION, change.meta.fieldName), - change, - ); + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); return; } if (fieldNode.kind !== Kind.FIELD_DEFINITION) { diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts index 7148c34cbc..98d671a985 100644 --- a/packages/patch/src/patches/inputs.ts +++ b/packages/patch/src/patches/inputs.ts @@ -102,13 +102,7 @@ export function inputFieldRemoved( const existingNode = nodeByPath.get(change.path); if (!existingNode) { - config.onError( - new DeletedCoordinateNotFound( - Kind.INPUT_OBJECT_TYPE_DEFINITION, - change.meta.removedFieldName, - ), - change, - ); + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); return; } diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts index b68cefd5ea..2980992be1 100644 --- a/packages/patch/src/patches/interfaces.ts +++ b/packages/patch/src/patches/interfaces.ts @@ -107,13 +107,7 @@ export function objectTypeInterfaceRemoved( const existing = findNamedNode(typeNode.interfaces, change.meta.removedInterfaceName); if (!existing) { - config.onError( - new DeletedCoordinateNotFound( - Kind.INTERFACE_TYPE_DEFINITION, - change.meta.removedInterfaceName, - ), - change, - ); + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); return; } diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts index 0a64c24df8..7fb2044f41 100644 --- a/packages/patch/src/patches/types.ts +++ b/packages/patch/src/patches/types.ts @@ -48,18 +48,12 @@ export function typeRemoved( const removedNode = nodeByPath.get(change.path); if (!removedNode) { - config.onError( - new DeletedCoordinateNotFound(Kind.OBJECT_TYPE_DEFINITION, change.meta.removedTypeName), - change, - ); + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); return; } if (!isTypeDefinitionNode(removedNode)) { - config.onError( - new DeletedCoordinateNotFound(removedNode.kind, change.meta.removedTypeName), - change, - ); + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); return; } diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts index f8649b264e..77a652775c 100644 --- a/packages/patch/src/utils.ts +++ b/packages/patch/src/utils.ts @@ -138,7 +138,7 @@ export function getDeletedNodeOfKind( } const existing = nodeByPath.get(change.path); if (!existing) { - config.onError(new DeletedCoordinateNotFound(kind, undefined), change); + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); return; } if (existing.kind !== kind) { From af2cfad76f3fc1103938f54b740340dfae17e6f9 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:17:22 -0800 Subject: [PATCH 63/73] Fix changelog --- .changeset/empty-cougars-grab.md | 2 ++ .changeset/seven-jars-yell.md | 13 ------------- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/.changeset/empty-cougars-grab.md b/.changeset/empty-cougars-grab.md index 656806177a..7500abb6e6 100644 --- a/.changeset/empty-cougars-grab.md +++ b/.changeset/empty-cougars-grab.md @@ -18,3 +18,5 @@ const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); const changes = await diff(schemaA, schemaB); const patched = patchSchema(schemaA, changes); ``` + +If working from an AST, you may alternatively use the exported `patch` function. But be careful to make sure directives are included in your AST or those changes will be missed. diff --git a/.changeset/seven-jars-yell.md b/.changeset/seven-jars-yell.md index 9af2cec98e..cfd0da18a9 100644 --- a/.changeset/seven-jars-yell.md +++ b/.changeset/seven-jars-yell.md @@ -135,16 +135,3 @@ These additional changes can be filtered using a new rule: import { DiffRule, diff } from "@graphql-inspector/core"; const changes = await diff(a, b, [DiffRule.ignoreNestedAdditions]); ``` - -To apply the changes output to a schema using `patch`: - -```js -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)); -``` - -If working from an AST, you may alternatively use the exported `patch` function. But be careful to make sure directives are included in your AST or those changes will be missed. From 408d59a87fad5dfc8ec0a871405050a060c46848 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 6 Nov 2025 18:49:55 -0800 Subject: [PATCH 64/73] add fieldremoval to removal list --- packages/core/src/diff/changes/directive-usage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 1f2bbe3148..14a7759755 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -638,7 +638,8 @@ export type DirectiveUsageRemovedChange = | typeof ChangeType.DirectiveUsageUnionMemberRemoved | typeof ChangeType.DirectiveUsageEnumValueRemoved | typeof ChangeType.DirectiveUsageSchemaRemoved - | typeof ChangeType.DirectiveUsageScalarRemoved; + | typeof ChangeType.DirectiveUsageScalarRemoved + | typeof ChangeType.DirectiveUsageFieldRemoved; export function directiveUsageAdded( kind: K, From 78dc291f6da7f7157ee24db4e966043f9469b49c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:02:52 -0800 Subject: [PATCH 65/73] consistent missing values --- packages/core/src/diff/changes/change.ts | 2 +- packages/core/src/diff/changes/directive.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index 44a34660f1..ba7815bfa3 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -215,7 +215,7 @@ export type DirectiveArgumentAddedChange = { addedDirectiveArgumentName: string; addedDirectiveArgumentTypeIsNonNull: boolean; addedToNewDirective: boolean; - addedDirectiveArgumentDescription: string | null; + 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 368714a0cd..9ac9b9db92 100644 --- a/packages/core/src/diff/changes/directive.ts +++ b/packages/core/src/diff/changes/directive.ts @@ -271,7 +271,7 @@ export function directiveArgumentAdded( addedDirectiveDefaultValue: arg.defaultValue === undefined ? undefined : safeString(arg.defaultValue), addedDirectiveArgumentTypeIsNonNull: isNonNullType(arg.type), - addedDirectiveArgumentDescription: arg.description ?? null, + addedDirectiveArgumentDescription: arg.description ?? undefined, addedToNewDirective, }, }); From 16a3374fa56d271e52d5370ae290a129fa1953e4 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:40:41 -0800 Subject: [PATCH 66/73] Fix merge conflict --- packages/core/src/diff/changes/directive-usage.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 0cd3176cbf..8065470a38 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -780,6 +780,7 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, fieldName: payload.field.name, typeName: payload.parentType.name, + directiveRepeatedTimes: directiveRepeatTimes(payload.field.astNode?.directives ?? [], directive), }, }); } @@ -959,6 +960,7 @@ export function directiveUsageRemoved( removedDirectiveName: directive.name.value, fieldName: payload.field.name, typeName: payload.parentType.name, + directiveRepeatedTimes: directiveRepeatTimes(payload.field.astNode?.directives ?? [], directive), }, }); } From e123bc4e44111d0db2ccb61c9fd87af5ac9ca02e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 6 Nov 2025 19:48:35 -0800 Subject: [PATCH 67/73] prettier --- packages/core/src/diff/changes/directive-usage.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 8065470a38..9a45f3a22f 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -780,7 +780,10 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, fieldName: payload.field.name, typeName: payload.parentType.name, - directiveRepeatedTimes: directiveRepeatTimes(payload.field.astNode?.directives ?? [], directive), + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -960,7 +963,10 @@ export function directiveUsageRemoved( removedDirectiveName: directive.name.value, fieldName: payload.field.name, typeName: payload.parentType.name, - directiveRepeatedTimes: directiveRepeatTimes(payload.field.astNode?.directives ?? [], directive), + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } From c0dfb489f2383e400aebfeaa658e0c1608f270b9 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:02:33 -0800 Subject: [PATCH 68/73] Improve diff command changelog; Make diff command a major release --- .changeset/long-rules-shop.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.changeset/long-rules-shop.md b/.changeset/long-rules-shop.md index 7a36062ef9..056adf86f0 100644 --- a/.changeset/long-rules-shop.md +++ b/.changeset/long-rules-shop.md @@ -1,5 +1,11 @@ --- -'@graphql-inspector/diff-command': minor +'@graphql-inspector/diff-command': major --- Added option to include nested changes. Use `--rule showNestedAdditions`. +Added better directive support. +Adjusted severity level for conditionally safe changes: +- Adding or removing deprecated directive is considered non-breaking +- Adding an interface to a new type is non-breaking +- Adding an argument to a new field is non-breaking +- Adding a directive to a new object (type, interface, etc..) is non-breaking From f8e6c9a177b0a94d82c3e510f956f2f7e2587ce3 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:10:58 -0800 Subject: [PATCH 69/73] add Field definition to KindToPayload --- packages/core/src/diff/changes/directive-usage.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index 6ca7b94548..b6d8da22d8 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -21,6 +21,8 @@ import { Change, ChangeType, CriticalityLevel, + DirectiveAddedChange, + DirectiveRemovedChange, DirectiveUsageArgumentAddedChange, DirectiveUsageArgumentDefinitionAddedChange, DirectiveUsageArgumentDefinitionRemovedChange, @@ -76,6 +78,13 @@ function removedSpecialDirective( } type KindToPayload = { + [Kind.FIELD]: { + input: { + field: GraphQLField; + parentType: GraphQLInterfaceType | GraphQLObjectType; + }; + change: DirectiveAddedChange | DirectiveRemovedChange; + }; [Kind.ENUM_TYPE_DEFINITION]: { input: GraphQLEnumType; change: DirectiveUsageEnumAddedChange | DirectiveUsageEnumRemovedChange; From fbbba07261493ba684b0ff7bb11ae2bc40ef581c Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:16:35 -0800 Subject: [PATCH 70/73] Update paths on new tests --- packages/core/__tests__/diff/enum.test.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/core/__tests__/diff/enum.test.ts b/packages/core/__tests__/diff/enum.test.ts index 197abfaa06..63f9f48910 100644 --- a/packages/core/__tests__/diff/enum.test.ts +++ b/packages/core/__tests__/diff/enum.test.ts @@ -373,9 +373,8 @@ describe('enum', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A'); - - expect(changes.length).toEqual(1); + expect(changes.length).toEqual(3); + const change = findFirstChangeByPath(changes, 'enumA.A.@deprecated'); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.message).toEqual( `Enum value 'enumA.A' deprecation reason changed from 'It\\'s old' to 'It\\'s new'`, @@ -406,9 +405,9 @@ 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(2); + expect(changes.length).toEqual(3); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.message).toEqual( `Enum value 'enumA.A' was deprecated with reason 'Don\\'t use this'`, @@ -439,9 +438,9 @@ 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(changes.length).toEqual(3); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.message).toEqual( `Enum value 'enumA.A' deprecation reason changed from 'Old Reason' to 'New Reason'`, From 8944728b18f8459bdf00ef671bca0f8b933970be Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:54:32 -0800 Subject: [PATCH 71/73] Rename ignoreNestedAdditions to simplifyChanges --- .changeset/long-rules-shop.md | 2 +- .changeset/seven-jars-yell.md | 4 +- packages/commands/diff/src/index.ts | 10 +- .../__tests__/diff/directive-usage.test.ts | 1 + .../rules/ignore-nested-additions.test.ts | 156 ---------- .../diff/rules/simplify-changes.test.ts | 272 ++++++++++++++++++ packages/core/src/diff/rules/index.ts | 2 +- ...ested-additions.ts => simplify-changes.ts} | 26 +- 8 files changed, 299 insertions(+), 174 deletions(-) delete mode 100644 packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts create mode 100644 packages/core/__tests__/diff/rules/simplify-changes.test.ts rename packages/core/src/diff/rules/{ignore-nested-additions.ts => simplify-changes.ts} (65%) diff --git a/.changeset/long-rules-shop.md b/.changeset/long-rules-shop.md index 056adf86f0..570cba58ca 100644 --- a/.changeset/long-rules-shop.md +++ b/.changeset/long-rules-shop.md @@ -2,7 +2,7 @@ '@graphql-inspector/diff-command': major --- -Added option to include nested changes. Use `--rule showNestedAdditions`. +Added option to include nested changes. Use `--rule verboseChanges`. Added better directive support. Adjusted severity level for conditionally safe changes: - Adding or removing deprecated directive is considered non-breaking diff --git a/.changeset/seven-jars-yell.md b/.changeset/seven-jars-yell.md index cfd0da18a9..6ed95b4dc2 100644 --- a/.changeset/seven-jars-yell.md +++ b/.changeset/seven-jars-yell.md @@ -9,7 +9,7 @@ These changes include: - Adjustmented the "path" on several change types in order to consistently map to the exact AST node being changed. For example, `EnumValueDeprecationReasonAdded`'s path previously referenced the enumValue (e.g. `EnumName.value`), not the deprecated directive (e.g. `EnumName.value.@deprecated`). - Added new attributes in order to provide enough context for a new "@graphql-inspector/patch" function to apply changes accurately. - Added support for repeatable directives -- Includes all nested changes in `diff` output when a new node is added. This can dramatically increase the number of changes listed which can be noisy, but it makes it possible for "@graphql-inspector/patch" to apply all changes from a schema. This can be optionally filtered using a newly exported `DiffRule.ignoreNestedAdditions` rule. +- Includes all nested changes in `diff` output when a new node is added. This can dramatically increase the number of changes listed which can be noisy, but it makes it possible for "@graphql-inspector/patch" to apply all changes from a schema. This can be optionally filtered using a newly exported `DiffRule.simplifyChanges` rule. For example, given an existing schema: @@ -133,5 +133,5 @@ These additional changes can be filtered using a new rule: ```js import { DiffRule, diff } from "@graphql-inspector/core"; -const changes = await diff(a, b, [DiffRule.ignoreNestedAdditions]); +const changes = await diff(a, b, [DiffRule.simplifyChanges]); ``` diff --git a/packages/commands/diff/src/index.ts b/packages/commands/diff/src/index.ts index f25cd4298f..25f6bced9f 100644 --- a/packages/commands/diff/src/index.ts +++ b/packages/commands/diff/src/index.ts @@ -32,12 +32,12 @@ export async function handler(input: { ? resolveCompletionHandler(input.onComplete) : failOnBreakingChanges; - let showNestedAdditions = false; + let verboseChanges = false; const rules = [...(input.rules ?? [])] .filter(isString) .map((name): Rule | undefined => { - if (name === 'showNestedAdditions') { - showNestedAdditions = true; + if (name === 'verboseChanges') { + verboseChanges = true; return; } @@ -50,8 +50,8 @@ export async function handler(input: { return rule; }) .filter((f): f is NonNullable => !!f); - if (!showNestedAdditions) { - rules.push(DiffRule.ignoreNestedAdditions); + if (!verboseChanges) { + rules.push(DiffRule.simplifyChanges); } const changes = await diffSchema(input.oldSchema, input.newSchema, rules, { diff --git a/packages/core/__tests__/diff/directive-usage.test.ts b/packages/core/__tests__/diff/directive-usage.test.ts index e2849c6582..f0fabe2470 100644 --- a/packages/core/__tests__/diff/directive-usage.test.ts +++ b/packages/core/__tests__/diff/directive-usage.test.ts @@ -2,6 +2,7 @@ import { buildSchema } from 'graphql'; import { CriticalityLevel, diff, + DiffRule, directiveUsageFieldAddedFromMeta, directiveUsageFieldRemovedFromMeta, } from '../../src/index.js'; diff --git a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts deleted file mode 100644 index 2e811883d8..0000000000 --- a/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { buildSchema } from 'graphql'; -import { ignoreNestedAdditions } from '../../../src/diff/rules/index.js'; -import { ChangeType, CriticalityLevel, 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]).toMatchObject({ - criticality: { - level: CriticalityLevel.NonBreaking, - }, - message: "Type 'B' was added", - meta: { - addedTypeKind: 'EnumTypeDefinition', - addedTypeName: 'B', - }, - path: 'B', - type: ChangeType.TypeAdded, - }); - }); - - test('added argument / directive / deprecation / reason 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); - expect(added.meta).toEqual({ - addedFieldName: 'b', - addedFieldReturnType: 'String!', - typeName: 'Foo', - typeType: 'object type', - }); - }); - - 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]); - - { - const added = findFirstChangeByPath(changes, 'FooUnion'); - expect(added?.type).toBe(ChangeType.TypeAdded); - } - - { - const added = findFirstChangeByPath(changes, 'Foo'); - 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 () => { - 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/__tests__/diff/rules/simplify-changes.test.ts b/packages/core/__tests__/diff/rules/simplify-changes.test.ts new file mode 100644 index 0000000000..ecd7302432 --- /dev/null +++ b/packages/core/__tests__/diff/rules/simplify-changes.test.ts @@ -0,0 +1,272 @@ +import { buildSchema } from 'graphql'; +import { simplifyChanges } from '../../../src/diff/rules/index.js'; +import { ChangeType, CriticalityLevel, diff } from '../../../src/index.js'; +import { findFirstChangeByPath } from '../../../utils/testing.js'; + +describe('simplifyChanges 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, [simplifyChanges]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.a'); + expect(added).toBe(undefined); + }); + + test('removed field on a removed object', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a(b: String): String! @deprecated(reason: "As a test") + } + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + + const removed = findFirstChangeByPath(changes, 'Foo.a'); + expect(removed).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, [simplifyChanges]); + 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, [simplifyChanges]); + + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: "Type 'B' was added", + meta: { + addedTypeKind: 'EnumTypeDefinition', + addedTypeName: 'B', + }, + path: 'B', + type: ChangeType.TypeAdded, + }); + }); + + test('removed value on removed enum', async () => { + const a = 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 b = buildSchema(/* GraphQL */ ` + scalar A + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + criticality: { + level: CriticalityLevel.Breaking, + }, + message: "Type 'B' was removed", + meta: { + removedTypeName: 'B', + }, + path: 'B', + type: ChangeType.TypeRemoved, + }); + }); + + test('added argument / directive / deprecation / reason 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, [simplifyChanges]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.b'); + expect(added.type).toBe(ChangeType.FieldAdded); + expect(added.meta).toEqual({ + addedFieldName: 'b', + addedFieldReturnType: 'String!', + typeName: 'Foo', + typeType: 'object type', + }); + }); + + 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, [simplifyChanges]); + + { + const added = findFirstChangeByPath(changes, 'FooUnion'); + expect(added?.type).toBe(ChangeType.TypeAdded); + } + + { + const added = findFirstChangeByPath(changes, 'Foo'); + 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 () => { + 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, [simplifyChanges]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, '@special'); + expect(added.type).toBe(ChangeType.DirectiveAdded); + }); + + test('deprecation added', async () => { + const a = buildSchema(/* GraphQL */ ` + type Foo { + bar: String + } + `); + const b = buildSchema(/* GraphQL */ ` + type Foo { + bar: String @deprecated(reason: "Because") + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + const change = findFirstChangeByPath(changes, 'Foo.bar.@deprecated'); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('FIELD_DEPRECATION_ADDED'); + expect(change.message).toEqual("Field 'Foo.bar' is deprecated"); + }); + + test('deprecation changed', async () => { + const a = buildSchema(/* GraphQL */ ` + type Foo { + bar: String @deprecated(reason: "Before") + } + `); + const b = buildSchema(/* GraphQL */ ` + type Foo { + bar: String @deprecated(reason: "After") + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + const change = findFirstChangeByPath(changes, 'Foo.bar.@deprecated'); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('FIELD_DEPRECATION_REASON_CHANGED'); + expect(change.message).toEqual( + "Deprecation reason on field 'Foo.bar' has changed from 'Before' to 'After'", + ); + }); + + test('deprecation removed', async () => { + const a = buildSchema(/* GraphQL */ ` + type Foo { + bar: String @deprecated(reason: "Because") + } + `); + const b = buildSchema(/* GraphQL */ ` + type Foo { + bar: String + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + const change = findFirstChangeByPath(changes, 'Foo.bar.@deprecated'); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('FIELD_DEPRECATION_REMOVED'); + expect(change.message).toEqual("Field 'Foo.bar' is no longer deprecated"); + }); +}); diff --git a/packages/core/src/diff/rules/index.ts b/packages/core/src/diff/rules/index.ts index 70db723148..23de5be55a 100644 --- a/packages/core/src/diff/rules/index.ts +++ b/packages/core/src/diff/rules/index.ts @@ -4,4 +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'; +export * from './simplify-changes.js'; diff --git a/packages/core/src/diff/rules/ignore-nested-additions.ts b/packages/core/src/diff/rules/simplify-changes.ts similarity index 65% rename from packages/core/src/diff/rules/ignore-nested-additions.ts rename to packages/core/src/diff/rules/simplify-changes.ts index 9c9f7a0a2e..92d7089a01 100644 --- a/packages/core/src/diff/rules/ignore-nested-additions.ts +++ b/packages/core/src/diff/rules/simplify-changes.ts @@ -1,7 +1,7 @@ import { ChangeType } from '../changes/change.js'; import { Rule } from './types.js'; -const additionChangeTypes = new Set([ +const simpleChangeTypes = new Set([ ChangeType.DirectiveAdded, ChangeType.DirectiveArgumentAdded, ChangeType.DirectiveLocationAdded, @@ -23,6 +23,13 @@ const additionChangeTypes = new Set([ ChangeType.FieldAdded, ChangeType.FieldArgumentAdded, ChangeType.FieldDeprecationAdded, + + // These are not additions -- but this is necessary to eliminate nested removals for directives + // because the deprecationReasons are redundant with directives + ChangeType.FieldDeprecationRemoved, + ChangeType.FieldDeprecationReasonChanged, + ChangeType.EnumValueDeprecationReasonChanged, + ChangeType.FieldDeprecationReasonAdded, ChangeType.FieldDescriptionAdded, ChangeType.InputFieldAdded, @@ -38,21 +45,22 @@ const parentPath = (path: string) => { 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[] = []; +export const simplifyChanges: Rule = ({ changes }) => { + // Track which paths contained changes that represent a group of changes to the schema + // e.g. the addition of a type implicity contains the addition of that type's fields. + const changePaths: string[] = []; const filteredChanges = changes.filter(({ path, type }) => { if (path) { const parent = parentPath(path); - const matches = additionPaths.filter(matchedPath => matchedPath.startsWith(parent)); - const hasAddedParent = matches.length > 0; + const matches = changePaths.filter(matchedPath => matchedPath.startsWith(parent)); + const hasChangedParent = matches.length > 0; - if (additionChangeTypes.has(type)) { - additionPaths.push(path); + if (simpleChangeTypes.has(type)) { + changePaths.push(path); } - return !hasAddedParent; + return !hasChangedParent; } return true; }); From 9ae7a68a87c730ba9aa9f17a16753944ef909752 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:00:18 -0800 Subject: [PATCH 72/73] diff-command changelog clarification --- .changeset/long-rules-shop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/long-rules-shop.md b/.changeset/long-rules-shop.md index 570cba58ca..d6a549fae6 100644 --- a/.changeset/long-rules-shop.md +++ b/.changeset/long-rules-shop.md @@ -2,7 +2,7 @@ '@graphql-inspector/diff-command': major --- -Added option to include nested changes. Use `--rule verboseChanges`. +Added option to include nested changes. Use `--rule verboseChanges`. Enabling this will output nested changes. I.e. if adding a new type, then `verboseChanges` will also include the addition of the fields on that type. By default, these changes are excluded from the output because they don't impact the outcome and create a lot of noise. Added better directive support. Adjusted severity level for conditionally safe changes: - Adding or removing deprecated directive is considered non-breaking From 46dff19eb10af638e23a31e2faec214b82eabf71 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Mon, 10 Nov 2025 13:00:50 -0800 Subject: [PATCH 73/73] Diff command changelog spacing --- .changeset/long-rules-shop.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/long-rules-shop.md b/.changeset/long-rules-shop.md index d6a549fae6..d4bd7aaa3d 100644 --- a/.changeset/long-rules-shop.md +++ b/.changeset/long-rules-shop.md @@ -3,7 +3,9 @@ --- Added option to include nested changes. Use `--rule verboseChanges`. Enabling this will output nested changes. I.e. if adding a new type, then `verboseChanges` will also include the addition of the fields on that type. By default, these changes are excluded from the output because they don't impact the outcome and create a lot of noise. + Added better directive support. + Adjusted severity level for conditionally safe changes: - Adding or removing deprecated directive is considered non-breaking - Adding an interface to a new type is non-breaking