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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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,