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..47016562cf 100644 --- a/packages/core/__tests__/diff/directive-usage.test.ts +++ b/packages/core/__tests__/diff/directive-usage.test.ts @@ -21,13 +21,36 @@ 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'); 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 @@ -44,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'); @@ -68,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'); @@ -91,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'); @@ -128,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'); @@ -164,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'); @@ -199,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'); @@ -235,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'); @@ -270,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(); @@ -302,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'); @@ -338,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); @@ -373,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); @@ -400,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); @@ -424,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); @@ -451,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); @@ -477,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); @@ -500,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); @@ -518,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); @@ -543,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'); @@ -564,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'); @@ -588,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'); @@ -610,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'); @@ -634,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'); @@ -658,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'); @@ -690,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'); @@ -717,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/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 a049332db4..1e1a58e6e8 100644 --- a/packages/core/__tests__/diff/enum.test.ts +++ b/packages/core/__tests__/diff/enum.test.ts @@ -1,8 +1,56 @@ 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 () => { + 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 { @@ -130,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); @@ -163,11 +211,26 @@ describe('enum', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A'); - - 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'`); + expect(changes).toHaveLength(3); + const directiveChanges = findChangesByPath(changes, 'enumA.A.@deprecated'); + expect(directiveChanges).toHaveLength(2); + + 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/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/rules/ignore-nested-additions.test.ts b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts new file mode 100644 index 0000000000..dfb94e9cca --- /dev/null +++ b/packages/core/__tests__/diff/rules/ignore-nested-additions.test.ts @@ -0,0 +1,150 @@ +import { buildSchema } from 'graphql'; +import { ignoreNestedAdditions } from '../../../src/diff/rules/index.js'; +import { ChangeType, CriticalityLevel, diff } from '../../../src/index.js'; +import { findFirstChangeByPath } from '../../../utils/testing.js'; + +describe('ignoreNestedAdditions rule', () => { + test('added field on new object', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a(b: String): String! @deprecated(reason: "As a test") + } + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.a'); + expect(added).toBe(undefined); + }); + + test('added field on new interface', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + interface Foo { + a(b: String): String! @deprecated(reason: "As a test") + } + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.a'); + expect(added).toBe(undefined); + }); + + test('added value on new enum', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + """ + Here is a new enum named B + """ + enum B { + """ + It has newly added values + """ + C @deprecated(reason: "With deprecations") + D + } + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: "Type 'B' was added", + meta: { + addedTypeKind: 'EnumTypeDefinition', + addedTypeName: 'B', + }, + path: 'B', + type: ChangeType.TypeAdded, + }); + }); + + test('added argument / directive / deprecation / reason on new field', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a: String! + } + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a: String! + b(b: String): String! @deprecated(reason: "As a test") + } + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.b'); + expect(added.type).toBe(ChangeType.FieldAdded); + }); + + test('added type / directive / directive argument on new union', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + directive @special(reason: String) on UNION + + type Foo { + a: String! + } + + union FooUnion @special(reason: "As a test") = Foo + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + + { + const added = findFirstChangeByPath(changes, 'FooUnion'); + expect(added?.type).toBe(ChangeType.TypeAdded); + } + + { + const added = findFirstChangeByPath(changes, 'Foo'); + expect(added?.type).toBe(ChangeType.TypeAdded); + } + + { + const added = findFirstChangeByPath(changes, '@special'); + expect(added?.type).toBe(ChangeType.DirectiveAdded); + } + + expect(changes).toHaveLength(3); + }); + + test('added argument / location / description on new directive', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + directive @special(reason: String) on UNION | FIELD_DEFINITION + `); + + const changes = await diff(a, b, [ignoreNestedAdditions]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, '@special'); + expect(added.type).toBe(ChangeType.DirectiveAdded); + }); +}); diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index 90392ece63..7f18916dc9 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 */ ` @@ -340,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', - '@willBeRemoved', + 'Options.E.@deprecated', + 'Options.E.@deprecated', + 'Options.F.@deprecated', + '@yolo2', + '@yolo2', '@yolo2', + '@willBeRemoved', '@yolo', '@yolo', '@yolo', @@ -382,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 () => { @@ -820,9 +829,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..c278351ff8 100644 --- a/packages/core/src/diff/argument.ts +++ b/packages/core/src/diff/argument.ts @@ -11,50 +11,71 @@ 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( 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, + ), + ); + directiveUsageChanged(null, directive, addChange, type, field, newArg); + }, + + onMutual(directive) { + directiveUsageChanged( + directive.oldVersion, + directive.newVersion, + addChange, + type, + field, + newArg, ); }, onRemoved(directive) { addChange( - directiveUsageRemoved(Kind.ARGUMENT, directive, { argument: oldArg, field, type }), + 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..1924bb9aff 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', @@ -76,9 +78,7 @@ export const ChangeType = { TypeAdded: 'TYPE_ADDED', TypeKindChanged: 'TYPE_KIND_CHANGED', TypeDescriptionChanged: 'TYPE_DESCRIPTION_CHANGED', - // TODO TypeDescriptionRemoved: 'TYPE_DESCRIPTION_REMOVED', - // TODO TypeDescriptionAdded: 'TYPE_DESCRIPTION_ADDED', // Union UnionMemberRemoved: 'UNION_MEMBER_REMOVED', @@ -108,6 +108,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]; @@ -159,6 +161,9 @@ export type DirectiveAddedChange = { type: typeof ChangeType.DirectiveAdded; meta: { addedDirectiveName: string; + addedDirectiveRepeatable: boolean; + addedDirectiveLocations: string[]; + addedDirectiveDescription: string | null; }; }; @@ -193,6 +198,10 @@ export type DirectiveArgumentAddedChange = { directiveName: string; addedDirectiveArgumentName: string; addedDirectiveArgumentTypeIsNonNull: boolean; + addedToNewDirective: boolean; + addedDirectiveArgumentDescription: string | null; + addedDirectiveArgumentType: string; + addedDirectiveDefaultValue?: string /* | null */; }; }; @@ -252,6 +261,8 @@ export type EnumValueAddedChange = { meta: { enumName: string; addedEnumValueName: string; + addedToNewType: boolean; + addedDirectiveDescription: string | null; }; }; @@ -311,6 +322,7 @@ export type FieldAddedChange = { typeName: string; addedFieldName: string; typeType: string; + addedFieldReturnType: string; }; }; @@ -346,6 +358,7 @@ export type FieldDeprecationAddedChange = { meta: { typeName: string; fieldName: string; + deprecationReason: string; }; }; @@ -401,6 +414,7 @@ export type DirectiveUsageUnionMemberAddedChange = { unionName: string; addedUnionMemberTypeName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -422,6 +436,7 @@ export type FieldArgumentAddedChange = { addedArgumentType: string; hasDefaultValue: boolean; isAddedFieldArgumentBreaking: boolean; + addedToNewField: boolean; }; }; @@ -453,6 +468,8 @@ export type InputFieldAddedChange = { addedInputFieldName: string; isAddedInputFieldTypeNullable: boolean; addedInputFieldType: string; + addedFieldDefault?: string; + addedToNewType: boolean; }; }; @@ -512,6 +529,7 @@ export type ObjectTypeInterfaceAddedChange = { meta: { objectTypeName: string; addedInterfaceName: string; + addedToNewType: boolean; }; }; @@ -558,11 +576,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 +647,7 @@ export type UnionMemberAddedChange = { meta: { unionName: string; addedUnionMemberTypeName: string; + addedToNewType: boolean; }; }; @@ -624,6 +658,7 @@ export type DirectiveUsageEnumAddedChange = { meta: { enumName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -641,6 +676,7 @@ export type DirectiveUsageEnumValueAddedChange = { enumName: string; enumValueName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -672,6 +708,7 @@ export type DirectiveUsageInputObjectAddedChange = { isAddedInputFieldTypeNullable: boolean; addedInputFieldType: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -680,7 +717,9 @@ export type DirectiveUsageInputFieldDefinitionAddedChange = { meta: { inputObjectName: string; inputFieldName: string; + inputFieldType: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -716,6 +755,7 @@ export type DirectiveUsageScalarAddedChange = { meta: { scalarName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -732,6 +772,7 @@ export type DirectiveUsageObjectAddedChange = { meta: { objectName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -748,6 +789,7 @@ export type DirectiveUsageInterfaceAddedChange = { meta: { interfaceName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -756,6 +798,7 @@ export type DirectiveUsageSchemaAddedChange = { meta: { addedDirectiveName: string; schemaTypeName: string; + addedToNewType: boolean; }; }; @@ -773,6 +816,7 @@ export type DirectiveUsageFieldDefinitionAddedChange = { typeName: string; fieldName: string; addedDirectiveName: string; + addedToNewType: boolean; }; }; @@ -785,16 +829,6 @@ export type DirectiveUsageFieldDefinitionRemovedChange = { }; }; -export type DirectiveUsageArgumentDefinitionChange = { - type: typeof ChangeType.DirectiveUsageArgumentDefinitionAdded; - meta: { - typeName: string; - fieldName: string; - argumentName: string; - addedDirectiveName: string; - }; -}; - export type DirectiveUsageArgumentDefinitionRemovedChange = { type: typeof ChangeType.DirectiveUsageArgumentDefinitionRemoved; meta: { @@ -820,6 +854,44 @@ export type DirectiveUsageArgumentDefinitionAddedChange = { fieldName: string; argumentName: string; addedDirectiveName: string; + addedToNewType: boolean; + }; +}; + +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; }; }; @@ -851,26 +923,6 @@ type Changes = { [ChangeType.FieldDescriptionChanged]: FieldDescriptionChangedChange; [ChangeType.FieldArgumentAdded]: FieldArgumentAddedChange; [ChangeType.FieldArgumentRemoved]: FieldArgumentRemovedChange; - [ChangeType.InputFieldRemoved]: InputFieldRemovedChange; - [ChangeType.InputFieldAdded]: InputFieldAddedChange; - [ChangeType.InputFieldDescriptionAdded]: InputFieldDescriptionAddedChange; - [ChangeType.InputFieldDescriptionRemoved]: InputFieldDescriptionRemovedChange; - [ChangeType.InputFieldDescriptionChanged]: InputFieldDescriptionChangedChange; - [ChangeType.InputFieldDefaultValueChanged]: InputFieldDefaultValueChangedChange; - [ChangeType.InputFieldTypeChanged]: InputFieldTypeChangedChange; - [ChangeType.ObjectTypeInterfaceAdded]: ObjectTypeInterfaceAddedChange; - [ChangeType.ObjectTypeInterfaceRemoved]: ObjectTypeInterfaceRemovedChange; - [ChangeType.SchemaQueryTypeChanged]: SchemaQueryTypeChangedChange; - [ChangeType.SchemaMutationTypeChanged]: SchemaMutationTypeChangedChange; - [ChangeType.SchemaSubscriptionTypeChanged]: SchemaSubscriptionTypeChangedChange; - [ChangeType.TypeAdded]: TypeAddedChange; - [ChangeType.TypeRemoved]: TypeRemovedChange; - [ChangeType.TypeKindChanged]: TypeKindChangedChange; - [ChangeType.TypeDescriptionChanged]: TypeDescriptionChangedChange; - [ChangeType.TypeDescriptionRemoved]: TypeDescriptionRemovedChange; - [ChangeType.TypeDescriptionAdded]: TypeDescriptionAddedChange; - [ChangeType.UnionMemberAdded]: UnionMemberAddedChange; - [ChangeType.UnionMemberRemoved]: UnionMemberRemovedChange; [ChangeType.DirectiveRemoved]: DirectiveRemovedChange; [ChangeType.DirectiveAdded]: DirectiveAddedChange; [ChangeType.DirectiveArgumentAdded]: DirectiveArgumentAddedChange; @@ -920,6 +972,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 00e0b165d4..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, - DirectiveUsageArgumentDefinitionChange, + DirectiveUsageArgumentAddedChange, + DirectiveUsageArgumentDefinitionAddedChange, DirectiveUsageArgumentDefinitionRemovedChange, + DirectiveUsageArgumentRemovedChange, DirectiveUsageEnumAddedChange, DirectiveUsageEnumRemovedChange, DirectiveUsageEnumValueAddedChange, @@ -115,7 +121,9 @@ type KindToPayload = { field: GraphQLInputField; type: GraphQLInputObjectType; }; - change: DirectiveUsageArgumentDefinitionChange | DirectiveUsageArgumentDefinitionRemovedChange; + change: + | DirectiveUsageArgumentDefinitionAddedChange + | DirectiveUsageArgumentDefinitionRemovedChange; }; [Kind.ARGUMENT]: { input: { @@ -123,22 +131,26 @@ 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: { - 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, @@ -147,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; @@ -173,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; @@ -188,12 +200,14 @@ 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, 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; } @@ -214,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; } @@ -228,12 +242,14 @@ 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, 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; } @@ -252,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; } @@ -268,14 +284,18 @@ 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, 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; } @@ -299,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; @@ -314,12 +334,14 @@ 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, 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; } @@ -338,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; } @@ -350,12 +372,14 @@ 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, 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; } @@ -374,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; } @@ -390,12 +414,14 @@ 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, 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; } @@ -416,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; } @@ -430,12 +456,16 @@ 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, 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; } @@ -454,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; } @@ -468,12 +500,14 @@ 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, message: buildDirectiveUsageSchemaAddedMessage(args.meta), - path: [args.meta.schemaTypeName, args.meta.addedDirectiveName].join('.'), + path: `.@${args.meta.addedDirectiveName}`, meta: args.meta, } as const; } @@ -492,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; } @@ -506,12 +540,14 @@ 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, 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; } @@ -530,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; } @@ -544,12 +580,14 @@ 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, 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; } @@ -570,16 +608,44 @@ 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; } +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'], -): Change { + addedToNewType: boolean, +): Change { if (isOfKind(kind, Kind.ARGUMENT, payload)) { return directiveUsageArgumentDefinitionAddedFromMeta({ type: ChangeType.DirectiveUsageArgumentDefinitionAdded, @@ -588,6 +654,7 @@ export function directiveUsageAdded( argumentName: payload.argument.name, fieldName: payload.field.name, typeName: payload.type.name, + addedToNewType, }, }); } @@ -597,7 +664,9 @@ export function directiveUsageAdded( meta: { addedDirectiveName: directive.name.value, inputFieldName: payload.field.name, + inputFieldType: payload.field.type.toString(), inputObjectName: payload.type.name, + addedToNewType, }, }); } @@ -610,6 +679,7 @@ export function directiveUsageAdded( addedInputFieldType: payload.name, inputObjectName: payload.name, isAddedInputFieldTypeNullable: kind === Kind.INPUT_VALUE_DEFINITION, + addedToNewType, }, }); } @@ -619,6 +689,7 @@ export function directiveUsageAdded( meta: { addedDirectiveName: directive.name.value, interfaceName: payload.name, + addedToNewType, }, }); } @@ -628,6 +699,7 @@ export function directiveUsageAdded( meta: { objectName: payload.name, addedDirectiveName: directive.name.value, + addedToNewType, }, }); } @@ -637,6 +709,7 @@ export function directiveUsageAdded( meta: { enumName: payload.name, addedDirectiveName: directive.name.value, + addedToNewType, }, }); } @@ -647,6 +720,7 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, fieldName: payload.field.name, typeName: payload.parentType.name, + addedToNewType, }, }); } @@ -657,6 +731,7 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, addedUnionMemberTypeName: payload.name, unionName: payload.name, + addedToNewType, }, }); } @@ -667,6 +742,7 @@ export function directiveUsageAdded( enumName: payload.type.name, enumValueName: payload.value.name, addedDirectiveName: directive.name.value, + addedToNewType, }, }); } @@ -676,6 +752,7 @@ export function directiveUsageAdded( meta: { addedDirectiveName: directive.name.value, schemaTypeName: payload.getQueryType()?.name || '', + addedToNewType, }, }); } @@ -685,6 +762,7 @@ export function directiveUsageAdded( meta: { scalarName: payload.name, addedDirectiveName: directive.name.value, + addedToNewType, }, }); } @@ -816,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/directive.ts b/packages/core/src/diff/changes/directive.ts index 06392d1bdd..493e41a895 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 => String(l)), + addedDirectiveRepeatable: directive.isRepeatable, }, }); } @@ -95,14 +98,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, }, }); @@ -132,7 +135,7 @@ export function directiveLocationAdded( type: ChangeType.DirectiveLocationAdded, meta: { directiveName: directive.name, - addedDirectiveLocation: location.toString(), + addedDirectiveLocation: String(location), }, }); } @@ -172,19 +175,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,13 +204,19 @@ export function directiveArgumentAddedFromMeta(args: DirectiveArgumentAddedChang export function directiveArgumentAdded( directive: GraphQLDirective, arg: GraphQLArgument, + addedToNewDirective: boolean, ): Change { return directiveArgumentAddedFromMeta({ type: ChangeType.DirectiveArgumentAdded, meta: { directiveName: directive.name, addedDirectiveArgumentName: arg.name, + addedDirectiveArgumentType: arg.type.toString(), + addedDirectiveDefaultValue: + arg.defaultValue === undefined ? '' : safeString(arg.defaultValue), addedDirectiveArgumentTypeIsNonNull: isNonNullType(arg.type), + addedDirectiveArgumentDescription: arg.description ?? null, + addedToNewDirective, }, }); } @@ -262,15 +277,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 +319,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) { @@ -359,8 +374,8 @@ export function directiveArgumentTypeChanged( 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), }, diff --git a/packages/core/src/diff/changes/enum.ts b/packages/core/src/diff/changes/enum.ts index cf6c74dd39..f9acd24880 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, @@ -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,17 @@ 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, + addedDirectiveDescription: value.description ?? null, }, }); } @@ -105,15 +110,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, }, }); @@ -134,7 +139,9 @@ 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; } @@ -170,21 +177,23 @@ 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; } 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..966462aef2 100644 --- a/packages/core/src/diff/changes/field.ts +++ b/packages/core/src/diff/changes/field.ts @@ -1,10 +1,12 @@ import { GraphQLArgument, + GraphQLDeprecatedDirective, GraphQLField, GraphQLInterfaceType, GraphQLObjectType, isInterfaceType, isNonNullType, + print, } from 'graphql'; import { safeChangeForField } from '../../utils/graphql.js'; import { @@ -90,6 +92,7 @@ export function fieldAdded( typeName: type.name, addedFieldName: field.name, typeType: entity, + addedFieldReturnType: field.astNode?.type ? print(field.astNode?.type) : '', }, }); } @@ -210,6 +213,7 @@ export function fieldDeprecationAdded( meta: { typeName: type.name, fieldName: field.name, + deprecationReason: field.deprecationReason ?? '', }, }); } @@ -218,6 +222,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`, @@ -285,7 +290,9 @@ 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; } @@ -373,9 +380,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, @@ -387,6 +396,7 @@ export function fieldArgumentAdded( type: GraphQLObjectType | GraphQLInterfaceType, field: GraphQLField, arg: GraphQLArgument, + addedToNewField: boolean, ): Change { const isBreaking = isNonNullType(arg.type) && typeof arg.defaultValue === 'undefined'; @@ -398,6 +408,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/changes/input.ts b/packages/core/src/diff/changes/input.ts index a4c0395800..bbb793581c 100644 --- a/packages/core/src/diff/changes/input.ts +++ b/packages/core/src/diff/changes/input.ts @@ -50,21 +50,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 +78,7 @@ export function inputFieldAddedFromMeta(args: InputFieldAddedChange) { export function inputFieldAdded( input: GraphQLInputObjectType, field: GraphQLInputField, + addedToNewType: boolean, ): Change { return inputFieldAddedFromMeta({ type: ChangeType.InputFieldAdded, @@ -82,6 +87,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 +198,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 +219,7 @@ export function inputFieldDefaultValueChanged( ): Change { const meta: InputFieldDefaultValueChangedChange['meta'] = { inputName: input.name, - inputFieldName: oldField.name, + inputFieldName: newField.name, }; if (oldField.defaultValue !== undefined) { @@ -256,7 +266,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..0d64b17ad6 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), }); } @@ -86,7 +126,7 @@ export function typeKindChanged( return typeKindChangedFromMeta({ type: ChangeType.TypeKindChanged, meta: { - typeName: oldType.name, + typeName: newType.name, newTypeKind: String(getKind(newType)), oldTypeKind: 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..d1b918ad21 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,17 +32,17 @@ 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)); }, 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); }, }); } @@ -53,11 +53,11 @@ function changesInDirectiveArgument( 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)); } diff --git a/packages/core/src/diff/enum.ts b/packages/core/src/diff/enum.ts index 1be7b0dacf..05528fb63f 100644 --- a/packages/core/src/diff/enum.ts +++ b/packages/core/src/diff/enum.ts @@ -1,6 +1,10 @@ -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 { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { enumValueAdded, enumValueDeprecationReasonAdded, @@ -11,63 +15,106 @@ import { } from './changes/enum.js'; import { AddChange } from './schema.js'; +const DEPRECATION_REASON_DEFAULT = 'No longer supported'; + 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), + ); + directiveUsageChanged(null, directive, addChange, newEnum); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newEnum); }, 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) || + oldValue?.deprecationReason === DEPRECATION_REASON_DEFAULT + ) { + addChange(enumValueDeprecationReasonAdded(newEnum, oldValue, newValue)); + } else if ( + isVoid(newValue.deprecationReason) || + newValue?.deprecationReason === DEPRECATION_REASON_DEFAULT + ) { + 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, + ), + ); + directiveUsageChanged(null, directive, addChange, newEnum, undefined, undefined, newValue); + }, + onMutual(directive) { + directiveUsageChanged( + directive.oldVersion, + directive.newVersion, + addChange, + newEnum, + undefined, + undefined, + newValue, + ); + }, + 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..081090c620 100644 --- a/packages/core/src/diff/field.ts +++ b/packages/core/src/diff/field.ts @@ -2,7 +2,11 @@ import { GraphQLField, GraphQLInterfaceType, GraphQLObjectType, Kind } from 'gra 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, @@ -18,70 +22,87 @@ 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, + 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) || !isDeprecated(oldField)) { if (isDeprecated(newField)) { addChange(fieldDeprecationAdded(type, newField)); - } else { + } + } else if (!isDeprecated(newField)) { + if (isDeprecated(oldField)) { addChange(fieldDeprecationRemoved(type, oldField)); } - } - - if (isNotEqual(oldField.deprecationReason, newField.deprecationReason)) { - if (isVoid(oldField.deprecationReason)) { + } else if (isNotEqual(oldField.deprecationReason, newField.deprecationReason)) { + if ( + isVoid(oldField.deprecationReason) || + oldField.deprecationReason === DEPRECATION_REASON_DEFAULT + ) { addChange(fieldDeprecationReasonAdded(type, newField)); - } else if (isVoid(newField.deprecationReason)) { + } else if ( + isVoid(newField.deprecationReason) || + newField.deprecationReason === DEPRECATION_REASON_DEFAULT + ) { addChange(fieldDeprecationReasonRemoved(type, oldField)); } else { addChange(fieldDeprecationReasonChanged(type, oldField, newField)); } } - 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)); + addChange(fieldArgumentAdded(type, newField, arg, oldField === null)); }, 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, + ), ); + directiveUsageChanged(null, directive, addChange, type, newField); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, type, newField); }, 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..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, @@ -13,80 +17,109 @@ 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, + ), + ); + 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)); + 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, + ), + ); + directiveUsageChanged(null, directive, addChange, input, newField); + }, + onMutual(directive) { + directiveUsageChanged( + directive.oldVersion, + directive.newVersion, + addChange, + input, + newField, ); }, onRemoved(directive) { addChange( directiveUsageRemoved(Kind.INPUT_VALUE_DEFINITION, directive, { type: input, - field: oldField, + field: newField, }), ); }, diff --git a/packages/core/src/diff/interface.ts b/packages/core/src/diff/interface.ts index ac34f74b8a..bbdda50fd8 100644 --- a/packages/core/src/diff/interface.ts +++ b/packages/core/src/diff/interface.ts @@ -1,32 +1,64 @@ 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'; 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, + ), + ); + directiveUsageChanged(null, directive, addChange, newInterface); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newInterface); }, 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..3aef4e3d2f 100644 --- a/packages/core/src/diff/object.ts +++ b/packages/core/src/diff/object.ts @@ -1,49 +1,58 @@ 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'; 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)); + directiveUsageChanged(null, directive, addChange, newType); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newType); }, onRemoved(directive) { - addChange(directiveUsageRemoved(Kind.OBJECT, directive, oldType)); + 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 new file mode 100644 index 0000000000..9c9f7a0a2e --- /dev/null +++ b/packages/core/src/diff/rules/ignore-nested-additions.ts @@ -0,0 +1,61 @@ +import { ChangeType } from '../changes/change.js'; +import { Rule } from './types.js'; + +const additionChangeTypes = new Set([ + ChangeType.DirectiveAdded, + ChangeType.DirectiveArgumentAdded, + ChangeType.DirectiveLocationAdded, + ChangeType.DirectiveUsageArgumentAdded, + 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, +]); + +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 parent = parentPath(path); + const matches = additionPaths.filter(matchedPath => matchedPath.startsWith(parent)); + 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'; diff --git a/packages/core/src/diff/scalar.ts b/packages/core/src/diff/scalar.ts index 020752b322..b59a157ce3 100644 --- a/packages/core/src/diff/scalar.ts +++ b/packages/core/src/diff/scalar.ts @@ -1,19 +1,29 @@ 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( - 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), + ); + directiveUsageChanged(null, directive, addChange, newScalar); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newScalar); }, 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..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, @@ -53,6 +57,7 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): { onAdded(type) { addChange(typeAdded(type)); + changesInType(null, type, addChange); }, onRemoved(type) { addChange(typeRemoved(type)); @@ -66,6 +71,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 +83,11 @@ 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)); + directiveUsageChanged(null, directive, addChange); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange); }, onRemoved(directive) { addChange(directiveUsageRemoved(Kind.SCHEMA_DEFINITION, directive, oldSchema)); @@ -123,30 +133,35 @@ 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 ((isVoid(oldType) || isEnumType(oldType)) && isEnumType(newType)) { changesInEnum(oldType, newType, addChange); - } else if (isUnionType(oldType) && isUnionType(newType)) { + } else if ((isVoid(oldType) || isUnionType(oldType)) && isUnionType(newType)) { changesInUnion(oldType, newType, addChange); - } else if (isInputObjectType(oldType) && isInputObjectType(newType)) { + } else if ((isVoid(oldType) || isInputObjectType(oldType)) && isInputObjectType(newType)) { changesInInputObject(oldType, newType, addChange); - } else if (isObjectType(oldType) && isObjectType(newType)) { + } else if ((isVoid(oldType) || isObjectType(oldType)) && isObjectType(newType)) { changesInObject(oldType, newType, addChange); - } else if (isInterfaceType(oldType) && isInterfaceType(newType)) { + } else if ((isVoid(oldType) || isInterfaceType(oldType)) && isInterfaceType(newType)) { changesInInterface(oldType, newType, addChange); - } else if (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)); } - 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..b4c338076a 100644 --- a/packages/core/src/diff/union.ts +++ b/packages/core/src/diff/union.ts @@ -1,32 +1,42 @@ 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'; 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), + ); + directiveUsageChanged(null, directive, addChange, newUnion); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newUnion); }, onRemoved(directive) { - addChange(directiveUsageRemoved(Kind.UNION_TYPE_DEFINITION, directive, oldUnion)); + addChange(directiveUsageRemoved(Kind.UNION_TYPE_DEFINITION, directive, oldUnion!)); }, }); } 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/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)) { diff --git a/packages/patch/package.json b/packages/patch/package.json new file mode 100644 index 0000000000..7e826618c8 --- /dev/null +++ b/packages/patch/package.json @@ -0,0 +1,75 @@ +{ + "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" + }, + "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:*" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "sideEffects": false, + "typescript": { + "definition": "dist/typings/index.d.ts" + } +} diff --git a/packages/patch/src/README.md b/packages/patch/src/README.md new file mode 100644 index 0000000000..a19ea65079 --- /dev/null +++ b/packages/patch/src/README.md @@ -0,0 +1,37 @@ +# GraphQL Change Patch + +This package applies a list of changes (output from `@graphql-inspector/core`'s `diff`) to a GraphQL Schema. + +## Usage + +```typescript +const schemaA = buildSchema(before, { assumeValid: true, assumeValidSDL: true }); +const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); + +const changes = await diff(schemaA, schemaB); +const patched = patchSchema(schemaA, changes); +expect(printSortedSchema(schemaB)).toBe(printSortedSchema(patched)); +``` + +## Configuration + +> By default does not throw when hitting errors such as if a type that was modified no longer exists. + +`throwOnError?: boolean` + +> By default does not require the value at time of change to match what's currently in the schema. Enable this if you need to be extra cautious when detecting conflicts. + +`requireOldValueMatch?: boolean` + +> Allows handling errors more granularly if you only care about specific types of errors or want to capture the errors in a list somewhere etc. If 'true' is returned then this error is considered handled and the default error handling will not be ran. To halt patching, throw the error inside the handler. + +`onError?: (err: Error, change: Change) => boolean | undefined | null` + +> Enables debug logging + +`debug?: boolean` + +## Remaining Work + +- [] Support repeat directives +- [] Support extensions 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..649a8ece59 --- /dev/null +++ b/packages/patch/src/__tests__/directive-usage.test.ts @@ -0,0 +1,1197 @@ +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); + }); + + 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__/directives.test.ts b/packages/patch/src/__tests__/directives.test.ts new file mode 100644 index 0000000000..510e77de66 --- /dev/null +++ b/packages/patch/src/__tests__/directives.test.ts @@ -0,0 +1,100 @@ +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('directiveRemoved', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + `; + await expectPatchToMatch(before, after); + }); + + test('directiveArgumentAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); + + test('directiveArgumentRemoved', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + await expectPatchToMatch(before, after); + }); + + test('directiveLocationAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + 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..0fc370a801 --- /dev/null +++ b/packages/patch/src/__tests__/fields.test.ts @@ -0,0 +1,227 @@ +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('fieldArgumentTypeChanged', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + chat(id: String): ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat(id: ID!): ChatSession + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldArgumentDescriptionChanged', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + """ + The first is the worst + """ + chat(firstMessage: String): ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat( + """ + Second is best + """ + firstMessage: String + ): ChatSession + } + `; + await expectPatchToMatch(before, after); + }); + + test('fieldDeprecationReasonAdded', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + 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('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 + 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..c7448d8b02 --- /dev/null +++ b/packages/patch/src/__tests__/inputs.test.ts @@ -0,0 +1,81 @@ +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('inputFieldTypeChanged', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID + } + `; + await expectPatchToMatch(before, after); + }); + + test('inputFieldDescriptionRemoved', async () => { + const before = /* GraphQL */ ` + """ + 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..40fb4db91f --- /dev/null +++ b/packages/patch/src/__tests__/utils.ts @@ -0,0 +1,21 @@ +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'; + +function printSortedSchema(schema: GraphQLSchema) { + return printSchemaWithDirectives(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, + debug: process.env.DEBUG === '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..94c613c54e --- /dev/null +++ b/packages/patch/src/errors.ts @@ -0,0 +1,185 @@ +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 (config.onError?.(err, change) === true) { + // handled by onError + return; + } + + if (err instanceof NoopError) { + console.debug( + `Ignoring change ${change.type} at "${change.path}" because it does not modify the resulting schema.`, + ); + } else if (!config.requireOldValueMatch && err instanceof ValueMismatchError) { + console.debug(`Ignoring old value mismatch at "${change.path}".`); + } 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 ValueMismatchError extends Error { + readonly mismatch = true; + constructor(kind: Kind, expected: string | undefined | null, actual: string | undefined | null) { + super( + `The existing value did not match what was expected. Expected the "${kind}" to be ${expected} but found ${actual}.`, + ); + } +} + +/** + * If the requested change would not modify the schema because that change is effectively + * already applied. + * + * If the added coordinate exists but the kind does not match what's expected, then use + * ChangedCoordinateKindMismatchError instead. + */ +export class AddedCoordinateAlreadyExistsError extends NoopError { + constructor( + public readonly kind: Kind, + readonly expectedNameOrValue: string | undefined, + ) { + const expected = expectedNameOrValue ? `${expectedNameOrValue} ` : ''; + super(`A "${kind}" ${expected}already exists at the schema coordinate.`); + } +} + +export type NodeAttribute = + | 'description' + | 'defaultValue' + /** Enum values */ + | 'values' + /** Union types */ + | 'types' + /** Return type */ + | 'type' + | 'interfaces' + | 'directives' + | 'arguments' + | 'locations' + | 'fields'; + +/** + * If trying to add a node at a path, but that path no longer exists. E.g. add a description to + * a type, but that type was previously deleted. + * This differs from AddedCoordinateAlreadyExistsError because + */ +export class AddedAttributeCoordinateNotFoundError extends Error { + constructor( + public readonly parentName: string, + readonly attribute: NodeAttribute, + readonly attributeValue: string, + ) { + super( + `Cannot add "${attributeValue}" to "${attribute}", because "${parentName}" does not exist.`, + ); + } +} + +/** + * If trying to manipulate a node at a path, but that path no longer exists. E.g. change a description of + * a type, but that type was previously deleted. + */ +export class ChangedAncestorCoordinateNotFoundError extends Error { + constructor( + public readonly parentKind: Kind, + readonly attribute: NodeAttribute, + ) { + super(`Cannot change the "${attribute}" because the "${parentKind}" does not exist.`); + } +} + +/** + * If trying to remove a node but that node no longer exists. E.g. remove a directive from + * a type, but that type does not exist. + */ +export class DeletedAncestorCoordinateNotFoundError extends NoopError { + constructor( + public readonly parentKind: Kind, + readonly attribute: NodeAttribute, + readonly expectedValue: string | undefined, + ) { + super( + `Cannot delete ${expectedValue ? `"${expectedValue}" ` : ''}from "${attribute}" on "${parentKind}" because the "${parentKind}" does not exist.`, + ); + } +} + +/** + * If adding an attribute to a node, but that attribute already exists. + * E.g. adding an interface but that interface is already applied to the type. + */ +export class AddedAttributeAlreadyExistsError extends NoopError { + constructor( + public readonly parentKind: Kind, + readonly attribute: NodeAttribute, + readonly attributeValue: string, + ) { + super( + `Cannot add "${attributeValue}" to "${attribute}" on "${parentKind}" because it already exists.`, + ); + } +} + +/** + * If deleting an attribute from a node, but that attribute does not exist. + * E.g. deleting an interface but that interface is not applied to the type. + */ +export class DeletedAttributeNotFoundError extends NoopError { + constructor( + public readonly parentKind: Kind, + readonly attribute: NodeAttribute, + public readonly value: string, + ) { + super( + `Cannot delete "${value}" from "${parentKind}"'s "${attribute}" because "${value}" does not exist.`, + ); + } +} + +export class ChangedCoordinateNotFoundError extends Error { + constructor(expectedKind: Kind, expectedNameOrValue: string | undefined) { + super( + `The "${expectedKind}" ${expectedNameOrValue ? `"${expectedNameOrValue}"` : ''}does not exist.`, + ); + } +} + +export class DeletedCoordinateNotFound extends NoopError { + constructor(expectedKind: Kind, expectedNameOrValue: string | undefined) { + const expected = expectedNameOrValue ? `${expectedNameOrValue} ` : ''; + super(`The removed "${expectedKind}" ${expected}already does not exist.`); + } +} + +export class ChangedCoordinateKindMismatchError extends Error { + constructor( + public readonly expectedKind: Kind, + public readonly receivedKind: Kind, + ) { + super(`Expected type to have be a "${expectedKind}", but found a "${receivedKind}".`); + } +} + +/** + * This should not happen unless there's an issue with the diff creation. + */ +export class ChangePathMissingError extends Error { + constructor(public readonly change: Change) { + super(`The change is missing a "path". Cannot apply.`); + } +} diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts new file mode 100644 index 0000000000..965bbc4522 --- /dev/null +++ b/packages/patch/src/index.ts @@ -0,0 +1,556 @@ +import { + ASTNode, + buildASTSchema, + DocumentNode, + GraphQLSchema, + isDefinitionNode, + Kind, + parse, + visit, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { printSchemaWithDirectives } from '@graphql-tools/utils'; +import { + directiveUsageArgumentAdded, + directiveUsageArgumentDefinitionAdded, + directiveUsageArgumentDefinitionRemoved, + directiveUsageArgumentRemoved, + 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, + directiveArgumentRemoved, + directiveArgumentTypeChanged, + directiveDescriptionChanged, + directiveLocationAdded, + directiveLocationRemoved, + directiveRemoved, +} from './patches/directives.js'; +import { + enumValueAdded, + enumValueDeprecationReasonAdded, + enumValueDeprecationReasonChanged, + enumValueDescriptionChanged, + enumValueRemoved, +} from './patches/enum.js'; +import { + fieldAdded, + fieldArgumentAdded, + fieldArgumentDefaultChanged, + fieldArgumentDescriptionChanged, + fieldArgumentRemoved, + fieldArgumentTypeChanged, + fieldDeprecationAdded, + fieldDeprecationReasonAdded, + fieldDeprecationReasonChanged, + fieldDeprecationRemoved, + fieldDescriptionAdded, + fieldDescriptionChanged, + fieldDescriptionRemoved, + fieldRemoved, + fieldTypeChanged, +} from './patches/fields.js'; +import { + inputFieldAdded, + inputFieldDefaultValueChanged, + inputFieldDescriptionAdded, + inputFieldDescriptionChanged, + inputFieldDescriptionRemoved, + inputFieldRemoved, + inputFieldTypeChanged, +} 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 * as errors from './errors.js'; + +/** + * Wraps converting a schema to AST safely, patching, then rebuilding the schema from AST. + */ +export function patchSchema( + schema: GraphQLSchema, + changes: Change[], + config?: PatchConfig, +): GraphQLSchema { + const ast = parse(printSchemaWithDirectives(schema, { assumeValid: true })); + const patchedAst = patch(ast, changes, config); + return buildASTSchema(patchedAst, { assumeValid: true, assumeValidSDL: true }); +} + +/** + * Extracts all the root definitions from a DocumentNode and creates a mapping of their coordinate + * to the defined ASTNode. E.g. A field's coordinate is "Type.field". + */ +export function groupByCoordinateAST(ast: DocumentNode): [SchemaNode[], Map] { + const schemaNodes: SchemaNode[] = []; + const nodesByCoordinate = 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: { + pathArray.push(node.name.value); + const path = pathArray.join('.'); + nodesByCoordinate.set(path, node); + break; + } + case Kind.DIRECTIVE_DEFINITION: { + pathArray.push(`@${node.name.value}`); + const path = pathArray.join('.'); + nodesByCoordinate.set(path, node); + break; + } + case Kind.DIRECTIVE: { + /** + * Check if this directive is on the schema node. If so, then push an empty path + * to distinguish it from the definitions + */ + const isRoot = pathArray.length === 0; + if (isRoot) { + pathArray.push(`.@${node.name.value}`); + } else { + pathArray.push(`@${node.name.value}`); + } + const path = pathArray.join('.'); + nodesByCoordinate.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_DEFINITION: + case Kind.DIRECTIVE: { + pathArray.pop(); + break; + } + } + }, + }); + return [schemaNodes, nodesByCoordinate]; +} + +export function patchCoordinatesAST( + schemaNodes: SchemaNode[], + nodesByCoordinate: Map, + changes: Change[], + patchConfig?: PatchConfig, +): DocumentNode { + const config: PatchConfig = patchConfig ?? {}; + + for (const change of changes) { + if (config.debug) { + debugPrintChange(change, nodesByCoordinate); + } + + switch (change.type) { + case ChangeType.SchemaMutationTypeChanged: { + schemaMutationTypeChanged(change, schemaNodes, config); + break; + } + case ChangeType.SchemaQueryTypeChanged: { + schemaQueryTypeChanged(change, schemaNodes, config); + break; + } + case ChangeType.SchemaSubscriptionTypeChanged: { + schemaSubscriptionTypeChanged(change, schemaNodes, config); + break; + } + case ChangeType.DirectiveAdded: { + directiveAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveRemoved: { + directiveRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveArgumentAdded: { + directiveArgumentAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveArgumentRemoved: { + directiveArgumentRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveLocationAdded: { + directiveLocationAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveLocationRemoved: { + directiveLocationRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.EnumValueAdded: { + enumValueAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.EnumValueDeprecationReasonAdded: { + enumValueDeprecationReasonAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.EnumValueDeprecationReasonChanged: { + enumValueDeprecationReasonChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldAdded: { + fieldAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldRemoved: { + fieldRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldTypeChanged: { + fieldTypeChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldArgumentAdded: { + fieldArgumentAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldArgumentTypeChanged: { + fieldArgumentTypeChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldArgumentRemoved: { + fieldArgumentRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldArgumentDescriptionChanged: { + fieldArgumentDescriptionChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldArgumentDefaultChanged: { + fieldArgumentDefaultChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldDeprecationAdded: { + fieldDeprecationAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldDeprecationRemoved: { + fieldDeprecationRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldDeprecationReasonAdded: { + fieldDeprecationReasonAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldDeprecationReasonChanged: { + fieldDeprecationReasonChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldDescriptionAdded: { + fieldDescriptionAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldDescriptionChanged: { + fieldDescriptionChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.InputFieldAdded: { + inputFieldAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.InputFieldRemoved: { + inputFieldRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.InputFieldDescriptionAdded: { + inputFieldDescriptionAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.InputFieldTypeChanged: { + inputFieldTypeChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.InputFieldDescriptionChanged: { + inputFieldDescriptionChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.InputFieldDescriptionRemoved: { + inputFieldDescriptionRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.InputFieldDefaultValueChanged: { + inputFieldDefaultValueChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.ObjectTypeInterfaceAdded: { + objectTypeInterfaceAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.ObjectTypeInterfaceRemoved: { + objectTypeInterfaceRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.TypeDescriptionAdded: { + typeDescriptionAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.TypeDescriptionChanged: { + typeDescriptionChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.TypeDescriptionRemoved: { + typeDescriptionRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.TypeAdded: { + typeAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.UnionMemberAdded: { + unionMemberAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.UnionMemberRemoved: { + unionMemberRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.TypeRemoved: { + typeRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.EnumValueRemoved: { + enumValueRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.EnumValueDescriptionChanged: { + enumValueDescriptionChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.FieldDescriptionRemoved: { + fieldDescriptionRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveArgumentDefaultValueChanged: { + directiveArgumentDefaultValueChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveArgumentDescriptionChanged: { + directiveArgumentDescriptionChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveArgumentTypeChanged: { + directiveArgumentTypeChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveDescriptionChanged: { + directiveDescriptionChanged(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageArgumentDefinitionAdded: { + directiveUsageArgumentDefinitionAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageArgumentDefinitionRemoved: { + directiveUsageArgumentDefinitionRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageEnumAdded: { + directiveUsageEnumAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageEnumRemoved: { + directiveUsageEnumRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageEnumValueAdded: { + directiveUsageEnumValueAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageEnumValueRemoved: { + directiveUsageEnumValueRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageFieldAdded: { + directiveUsageFieldAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageFieldDefinitionAdded: { + directiveUsageFieldDefinitionAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageFieldDefinitionRemoved: { + directiveUsageFieldDefinitionRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageFieldRemoved: { + directiveUsageFieldRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageInputFieldDefinitionAdded: { + directiveUsageInputFieldDefinitionAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageInputFieldDefinitionRemoved: { + directiveUsageInputFieldDefinitionRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageInputObjectAdded: { + directiveUsageInputObjectAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageInputObjectRemoved: { + directiveUsageInputObjectRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageInterfaceAdded: { + directiveUsageInterfaceAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageInterfaceRemoved: { + directiveUsageInterfaceRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageObjectAdded: { + directiveUsageObjectAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageObjectRemoved: { + directiveUsageObjectRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageScalarAdded: { + directiveUsageScalarAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageScalarRemoved: { + directiveUsageScalarRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageSchemaAdded: { + directiveUsageSchemaAdded(change, schemaNodes, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageSchemaRemoved: { + directiveUsageSchemaRemoved(change, schemaNodes, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageUnionMemberAdded: { + directiveUsageUnionMemberAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageUnionMemberRemoved: { + directiveUsageUnionMemberRemoved(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageArgumentAdded: { + directiveUsageArgumentAdded(change, nodesByCoordinate, config); + break; + } + case ChangeType.DirectiveUsageArgumentRemoved: { + directiveUsageArgumentRemoved(change, nodesByCoordinate, 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: [ + ...schemaNodes, + ...Array.from(nodesByCoordinate.values()).filter(isDefinitionNode), + ], + }; +} + +/** This method wraps groupByCoordinateAST and patchCoordinatesAST for convenience. */ +export function patch( + ast: DocumentNode, + changes: Change[], + patchConfig?: PatchConfig, +): DocumentNode { + const [schemaNodes, nodesByCoordinate] = groupByCoordinateAST(ast); + return patchCoordinatesAST(schemaNodes, nodesByCoordinate, changes, patchConfig); +} 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..672b3157f9 --- /dev/null +++ b/packages/patch/src/patches/directive-usages.ts @@ -0,0 +1,491 @@ +/* eslint-disable unicorn/no-negated-condition */ +import { ArgumentNode, ASTNode, DirectiveNode, Kind, parseValue } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, + handleError, +} from '../errors.js'; +import { nameNode } from '../node-templates.js'; +import { PatchConfig, SchemaNode } from '../types.js'; +import { findNamedNode, 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, +) { + if (!change.path) { + handleError( + change, + new ChangedCoordinateNotFoundError(Kind.DIRECTIVE, change.meta.addedDirectiveName), + config, + ); + return; + } + + const directiveNode = nodeByPath.get(change.path); + const parentNode = nodeByPath.get(parentPath(change.path)) as + | { directives?: DirectiveNode[] } + | undefined; + if (directiveNode) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, change.meta.addedDirectiveName), + config, + ); + } else if (parentNode) { + const newDirective: DirectiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(change.meta.addedDirectiveName), + }; + parentNode.directives = [...(parentNode.directives ?? []), newDirective]; + nodeByPath.set(change.path, newDirective); + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError( + Kind.OBJECT_TYPE_DEFINITION, // or interface... + 'directives', + ), + config, + ); + } +} + +function schemaDirectiveUsageDefinitionAdded( + change: Change, + schemaNodes: SchemaNode[], + nodeByPath: Map, + config: PatchConfig, +) { + // @todo handle repeat directives + const directiveAlreadyExists = schemaNodes.some(schemaNode => + findNamedNode(schemaNode.directives, change.meta.addedDirectiveName), + ); + if (directiveAlreadyExists) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + Kind.SCHEMA_DEFINITION, + 'directives', + 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, +) { + let deleted = false; + // @todo handle repeated directives + for (const node of schemaNodes) { + 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; + } + } + if (!deleted) { + handleError( + change, + new DeletedAttributeNotFoundError( + Kind.SCHEMA_DEFINITION, + 'directives', + change.meta.removedDirectiveName, + ), + config, + ); + } +} + +function directiveUsageDefinitionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const directiveNode = nodeByPath.get(change.path); + const parentNode = nodeByPath.get(parentPath(change.path)) as + | { kind: Kind; directives?: DirectiveNode[] } + | undefined; + if (!parentNode) { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.OBJECT_TYPE_DEFINITION, + 'directives', + change.meta.removedDirectiveName, + ), + config, + ); + } else if (!directiveNode) { + handleError( + change, + new DeletedAttributeNotFoundError( + parentNode.kind, + 'directives', + change.meta.removedDirectiveName, + ), + config, + ); + } else { + parentNode.directives = parentNode.directives?.filter( + d => d.name.value !== change.meta.removedDirectiveName, + ); + nodeByPath.delete(change.path); + } +} + +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[], + nodeByPath: Map, + config: PatchConfig, +) { + return schemaDirectiveUsageDefinitionAdded(change, schemaDefs, nodeByPath, config); +} + +export function directiveUsageSchemaRemoved( + change: Change, + schemaDefs: SchemaNode[], + nodeByPath: Map, + config: PatchConfig, +) { + return schemaDirectiveUsageDefinitionRemoved(change, schemaDefs, nodeByPath, 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); +} + +export function directiveUsageArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + const directiveNode = nodeByPath.get(parentPath(change.path)); + if (!directiveNode) { + handleError( + change, + new AddedAttributeCoordinateNotFoundError( + change.meta.directiveName, + 'arguments', + change.meta.addedArgumentName, + ), + config, + ); + } else if (directiveNode.kind === Kind.DIRECTIVE) { + const existing = findNamedNode(directiveNode.arguments, change.meta.addedArgumentName); + if (existing) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + directiveNode.kind, + 'arguments', + change.meta.addedArgumentName, + ), + 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 ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), + config, + ); + } +} + +export function directiveUsageArgumentRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + const directiveNode = nodeByPath.get(parentPath(change.path)); + if (!directiveNode) { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.DIRECTIVE, + 'arguments', + change.meta.removedArgumentName, + ), + config, + ); + } else if (directiveNode.kind === Kind.DIRECTIVE) { + const existing = findNamedNode(directiveNode.arguments, change.meta.removedArgumentName); + if (existing) { + (directiveNode.arguments as ArgumentNode[] | undefined) = ( + directiveNode.arguments as ArgumentNode[] | undefined + )?.filter(a => a.name.value !== change.meta.removedArgumentName); + nodeByPath.delete(change.path); + } else { + handleError( + change, + new DeletedAttributeNotFoundError( + directiveNode.kind, + 'arguments', + change.meta.removedArgumentName, + ), + config, + ); + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), + config, + ); + } +} diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts new file mode 100644 index 0000000000..e64d347c0a --- /dev/null +++ b/packages/patch/src/patches/directives.ts @@ -0,0 +1,416 @@ +import { + ASTNode, + DirectiveDefinitionNode, + InputValueDefinitionNode, + Kind, + NameNode, + parseConstValue, + parseType, + print, + StringValueNode, + TypeNode, + ValueNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, + handleError, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import { PatchConfig } from '../types.js'; +import { + deleteNamedNode, + findNamedNode, + getDeletedNodeOfKind, + getDeletedParentNodeOfKind, +} from '../utils.js'; + +export function directiveAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (change.path === undefined) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const changedNode = nodeByPath.get(change.path); + if (changedNode) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedDirectiveName), + config, + ); + } else { + const node: DirectiveDefinitionNode = { + kind: Kind.DIRECTIVE_DEFINITION, + name: nameNode(change.meta.addedDirectiveName), + repeatable: change.meta.addedDirectiveRepeatable, + locations: change.meta.addedDirectiveLocations.map(l => nameNode(l)), + description: change.meta.addedDirectiveDescription + ? stringNode(change.meta.addedDirectiveDescription) + : undefined, + }; + nodeByPath.set(change.path, node); + } +} + +export function directiveRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE_DEFINITION, config); + if (existing) { + nodeByPath.delete(change.path!); + } +} + +export function directiveArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const directiveNode = nodeByPath.get(change.path); + if (!directiveNode) { + handleError( + change, + new AddedAttributeCoordinateNotFoundError( + change.meta.directiveName, + 'arguments', + change.meta.addedDirectiveArgumentName, + ), + config, + ); + } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { + const existingArg = findNamedNode( + directiveNode.arguments, + change.meta.addedDirectiveArgumentName, + ); + if (existingArg) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + existingArg.kind, + 'arguments', + change.meta.addedDirectiveArgumentName, + ), + config, + ); + } else { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedDirectiveArgumentName), + type: parseType(change.meta.addedDirectiveArgumentType), + }; + (directiveNode.arguments as InputValueDefinitionNode[] | undefined) = [ + ...(directiveNode.arguments ?? []), + node, + ]; + nodeByPath.set(`${change.path}.${change.meta.addedDirectiveArgumentName}`, node); + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + config, + ); + } +} + +export function directiveArgumentRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const argNode = getDeletedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + if (argNode) { + const directiveNode = getDeletedParentNodeOfKind( + change, + nodeByPath, + Kind.DIRECTIVE_DEFINITION, + 'arguments', + config, + ); + + if (directiveNode) { + (directiveNode.arguments as ReadonlyArray | undefined) = + deleteNamedNode(directiveNode.arguments, change.meta.removedDirectiveArgumentName); + } + } +} + +export function directiveLocationAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), 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)) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + Kind.DIRECTIVE_DEFINITION, + 'locations', + change.meta.addedDirectiveLocation, + ), + config, + ); + } else { + (changedNode.locations as NameNode[]) = [ + ...changedNode.locations, + nameNode(change.meta.addedDirectiveLocation), + ]; + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + config, + ); + } + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'locations'), + config, + ); + } +} + +export function directiveLocationRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const changedNode = nodeByPath.get(change.path); + if (changedNode) { + if (changedNode.kind === Kind.DIRECTIVE_DEFINITION) { + const existing = changedNode.locations.findIndex( + l => l.value === change.meta.removedDirectiveLocation, + ); + if (existing >= 0) { + (changedNode.locations as NameNode[]) = changedNode.locations.toSpliced(existing, 1); + } else { + handleError( + change, + new DeletedAttributeNotFoundError( + changedNode.kind, + 'locations', + change.meta.removedDirectiveLocation, + ), + config, + ); + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + config, + ); + } + } else { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.DIRECTIVE_DEFINITION, + 'locations', + change.meta.removedDirectiveLocation, + ), + config, + ); + } +} + +export function directiveDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const directiveNode = nodeByPath.get(change.path); + if (!directiveNode) { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE_DEFINITION, 'description'), + config, + ); + } else if (directiveNode.kind === Kind.DIRECTIVE_DEFINITION) { + // eslint-disable-next-line eqeqeq + if (directiveNode.description?.value !== change.meta.oldDirectiveDescription) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveDescription, + directiveNode.description?.value, + ), + config, + ); + } + + (directiveNode.description as StringValueNode | undefined) = change.meta.newDirectiveDescription + ? stringNode(change.meta.newDirectiveDescription) + : undefined; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + config, + ); + } +} + +export function directiveArgumentDefaultValueChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const argumentNode = nodeByPath.get(change.path); + if (!argumentNode) { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'defaultValue'), + config, + ); + } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if ( + (argumentNode.defaultValue && print(argumentNode.defaultValue)) === + change.meta.oldDirectiveArgumentDefaultValue + ) { + (argumentNode.defaultValue as ValueNode | undefined) = change.meta + .newDirectiveArgumentDefaultValue + ? parseConstValue(change.meta.newDirectiveArgumentDefaultValue) + : undefined; + } else { + handleError( + change, + new ValueMismatchError( + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDirectiveArgumentDefaultValue, + argumentNode.defaultValue && print(argumentNode.defaultValue), + ), + config, + ); + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + config, + ); + } +} + +export function directiveArgumentDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const argumentNode = nodeByPath.get(change.path); + if (!argumentNode) { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'description'), + config, + ); + } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { + // eslint-disable-next-line eqeqeq + if (argumentNode.description?.value != change.meta.oldDirectiveArgumentDescription) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveArgumentDescription ?? undefined, + argumentNode.description?.value, + ), + config, + ); + } + (argumentNode.description as StringValueNode | undefined) = change.meta + .newDirectiveArgumentDescription + ? stringNode(change.meta.newDirectiveArgumentDescription) + : undefined; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + config, + ); + } +} + +export function directiveArgumentTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const argumentNode = nodeByPath.get(change.path); + if (!argumentNode) { + handleError(change, new ChangedAncestorCoordinateNotFoundError(Kind.ARGUMENT, 'type'), config); + } else if (argumentNode.kind === Kind.INPUT_VALUE_DEFINITION) { + if (print(argumentNode.type) !== change.meta.oldDirectiveArgumentType) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveArgumentType, + print(argumentNode.type), + ), + config, + ); + } + (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + config, + ); + } +} diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts new file mode 100644 index 0000000000..8284650395 --- /dev/null +++ b/packages/patch/src/patches/enum.ts @@ -0,0 +1,291 @@ +import { + ArgumentNode, + ASTNode, + DirectiveNode, + EnumValueDefinitionNode, + Kind, + print, + StringValueNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, + ChangePathMissingError, + DeletedAttributeNotFoundError, + DeletedCoordinateNotFound, + handleError, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig } from '../types'; +import { findNamedNode, parentPath } from '../utils.js'; + +export function enumValueRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const enumNode = nodeByPath.get(parentPath(change.path)) as + | (ASTNode & { values?: EnumValueDefinitionNode[] }) + | undefined; + if (!enumNode) { + handleError( + change, + new DeletedCoordinateNotFound(Kind.ENUM_TYPE_DEFINITION, change.meta.removedEnumValueName), + config, + ); + } else if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), + config, + ); + } else if (enumNode.values === undefined || enumNode.values.length === 0) { + handleError( + change, + new DeletedAttributeNotFoundError( + Kind.ENUM_TYPE_DEFINITION, + 'values', + change.meta.removedEnumValueName, + ), + config, + ); + } else { + const beforeLength = enumNode.values.length; + enumNode.values = enumNode.values.filter( + f => f.name.value !== change.meta.removedEnumValueName, + ); + if (beforeLength === enumNode.values.length) { + handleError( + change, + new DeletedAttributeNotFoundError( + Kind.ENUM_TYPE_DEFINITION, + 'values', + change.meta.removedEnumValueName, + ), + config, + ); + } else { + // delete the reference to the removed field. + nodeByPath.delete(change.path); + } + } +} + +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 ChangedAncestorCoordinateNotFoundError(Kind.ENUM, 'values'), config); + } else if (changedNode) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + changedNode.kind, + 'values', + change.meta.addedEnumValueName, + ), + config, + ); + } else if (enumNode.kind === Kind.ENUM_TYPE_DEFINITION) { + const c = change as Change; + const node: EnumValueDefinitionNode = { + 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 ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), + config, + ); + } +} + +export function enumValueDeprecationReasonAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + 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) { + if (deprecation) { + if (findNamedNode(deprecation.arguments, 'reason')) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(Kind.ENUM_VALUE_DEFINITION, 'reason'), + config, + ); + } + const argNode: ArgumentNode = { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.addedValueDeprecationReason), + }; + (deprecation.arguments as ArgumentNode[] | undefined) = [ + ...(deprecation.arguments ?? []), + argNode, + ]; + nodeByPath.set(`${change.path}.reason`, argNode); + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + config, + ); + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + config, + ); + } + } else { + handleError( + change, + new AddedAttributeCoordinateNotFoundError( + change.meta.enumValueName, + 'directives', + '@deprecated', + ), + config, + ); + } +} + +export function enumValueDeprecationReasonChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const deprecatedNode = nodeByPath.get(change.path); + if (deprecatedNode) { + if (deprecatedNode.kind === Kind.DIRECTIVE) { + const reasonArgNode = findNamedNode(deprecatedNode.arguments, 'reason'); + if (reasonArgNode) { + if (reasonArgNode.kind === Kind.ARGUMENT) { + const oldValueMatches = + reasonArgNode.value && + print(reasonArgNode.value) === change.meta.oldEnumValueDeprecationReason; + + if (!oldValueMatches) { + handleError( + change, + new ValueMismatchError( + Kind.ARGUMENT, + change.meta.oldEnumValueDeprecationReason, + reasonArgNode.value && print(reasonArgNode.value), + ), + config, + ); + } + (reasonArgNode.value as StringValueNode | undefined) = stringNode( + change.meta.newEnumValueDeprecationReason, + ); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ARGUMENT, reasonArgNode.kind), + config, + ); + } + } else { + handleError(change, new ChangedCoordinateNotFoundError(Kind.ARGUMENT, 'reason'), config); + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, deprecatedNode.kind), + config, + ); + } + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.DIRECTIVE, 'arguments'), + config, + ); + } +} + +export function enumValueDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const enumValueNode = nodeByPath.get(change.path); + if (enumValueNode) { + if (enumValueNode.kind === Kind.ENUM_VALUE_DEFINITION) { + // eslint-disable-next-line eqeqeq + const oldValueMatches = + change.meta.oldEnumValueDescription == enumValueNode.description?.value; + if (!oldValueMatches) { + handleError( + change, + new ValueMismatchError( + Kind.ENUM_TYPE_DEFINITION, + change.meta.oldEnumValueDescription, + enumValueNode.description?.value, + ), + config, + ); + } + (enumValueNode.description as StringValueNode | undefined) = change.meta + .newEnumValueDescription + ? stringNode(change.meta.newEnumValueDescription) + : undefined; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + config, + ); + } + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.ENUM_VALUE_DEFINITION, 'values'), + config, + ); + } +} diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts new file mode 100644 index 0000000000..c3a34da48f --- /dev/null +++ b/packages/patch/src/patches/fields.ts @@ -0,0 +1,492 @@ +import { + ArgumentNode, + ASTNode, + ConstValueNode, + DirectiveNode, + FieldDefinitionNode, + GraphQLDeprecatedDirective, + InputValueDefinitionNode, + Kind, + parseConstValue, + parseType, + print, + StringValueNode, + TypeNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, + DeletedCoordinateNotFound, + handleError, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig } from '../types'; +import { + assertChangeHasPath, + assertValueMatch, + DEPRECATION_REASON_DEFAULT, + findNamedNode, + getChangedNodeOfKind, + getDeletedNodeOfKind, + getDeprecatedDirectiveNode, + parentPath, +} from '../utils.js'; + +export function fieldTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const node = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); + if (node) { + const currentReturnType = print(node.type); + if (change.meta.oldFieldType !== currentReturnType) { + handleError( + change, + new ValueMismatchError(Kind.FIELD_DEFINITION, change.meta.oldFieldType, currentReturnType), + config, + ); + } + (node.type as TypeNode) = parseType(change.meta.newFieldType); + } +} + +export function fieldRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const typeNode = nodeByPath.get(parentPath(change.path)) as + | (ASTNode & { fields?: FieldDefinitionNode[] }) + | undefined; + if (!typeNode) { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.OBJECT_TYPE_DEFINITION, + 'fields', + change.meta.removedFieldName, + ), + config, + ); + } else { + const beforeLength = typeNode.fields?.length ?? 0; + typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); + if (beforeLength === (typeNode.fields?.length ?? 0)) { + handleError( + change, + new DeletedAttributeNotFoundError(typeNode?.kind, 'fields', change.meta.removedFieldName), + config, + ); + } else { + // delete the reference to the removed field. + nodeByPath.delete(change.path); + } + } +} + +export function fieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (assertChangeHasPath(change, config)) { + const changedNode = nodeByPath.get(change.path); + if (changedNode) { + if (changedNode.kind === Kind.OBJECT_FIELD) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(changedNode.kind, change.meta.addedFieldName), + config, + ); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_FIELD, changedNode.kind), + config, + ); + } + } else { + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: FieldDefinitionNode[]; + }; + if (!typeNode) { + handleError(change, new ChangePathMissingError(change), config); + } else if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), + config, + ); + } else { + const node: FieldDefinitionNode = { + kind: Kind.FIELD_DEFINITION, + name: nameNode(change.meta.addedFieldName), + type: parseType(change.meta.addedFieldReturnType), + // description: change.meta.addedFieldDescription + // ? stringNode(change.meta.addedFieldDescription) + // : undefined, + }; + + typeNode.fields = [...(typeNode.fields ?? []), node]; + + // add new field to the node set + nodeByPath.set(change.path, node); + } + } + } +} + +export function fieldArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (assertChangeHasPath(change, config)) { + const existing = nodeByPath.get(change.path); + if (existing) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(Kind.ARGUMENT, change.meta.addedArgumentName), + config, + ); + } else { + const fieldNode = nodeByPath.get(parentPath(change.path!)) as ASTNode & { + arguments?: InputValueDefinitionNode[]; + }; + if (!fieldNode) { + handleError( + change, + new AddedAttributeCoordinateNotFoundError( + change.meta.fieldName, + 'arguments', + change.meta.addedArgumentName, + ), + config, + ); + } else if (fieldNode.kind === Kind.FIELD_DEFINITION) { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + // description: change.meta.addedArgumentDescription + // ? stringNode(change.meta.addedArgumentDescription) + // : undefined, + }; + + fieldNode.arguments = [...(fieldNode.arguments ?? []), node]; + + // add new field to the node set + nodeByPath.set(change.path!, node); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); + } + } + } +} + +export function fieldArgumentTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + if (existingArg) { + assertValueMatch( + change, + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldArgumentType, + print(existingArg.type), + config, + ); + (existingArg.type as TypeNode) = parseType(change.meta.newArgumentType); + } +} + +export function fieldArgumentDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + if (existingArg) { + assertValueMatch( + change, + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDescription ?? undefined, + existingArg.description?.value, + config, + ); + (existingArg.description as StringValueNode | undefined) = change.meta.newDescription + ? stringNode(change.meta.newDescription) + : undefined; + } +} + +export function fieldArgumentDefaultChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existingArg = getChangedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + if (existingArg) { + assertValueMatch( + change, + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDefaultValue, + existingArg.defaultValue && print(existingArg.defaultValue), + config, + ); + (existingArg.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue + ? parseConstValue(change.meta.newDefaultValue) + : undefined; + } +} + +export function fieldArgumentRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.ARGUMENT, config); + if (existing) { + const fieldNode = nodeByPath.get(parentPath(change.path!)) as ASTNode & { + arguments?: InputValueDefinitionNode[]; + }; + if (!fieldNode) { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.FIELD_DEFINITION, + 'arguments', + change.meta.removedFieldArgumentName, + ), + config, + ); + } else if (fieldNode.kind === Kind.FIELD_DEFINITION) { + fieldNode.arguments = fieldNode.arguments?.filter( + a => a.name.value === change.meta.removedFieldArgumentName, + ); + + // add new field to the node set + nodeByPath.delete(change.path!); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); + } + } +} + +export function fieldDeprecationReasonChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const deprecationNode = getChangedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE, config); + if (deprecationNode) { + const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); + if (reasonArgument) { + if (print(reasonArgument.value) !== change.meta.oldDeprecationReason) { + handleError( + change, + new ValueMismatchError( + Kind.ARGUMENT, + print(reasonArgument.value), + change.meta.oldDeprecationReason, + ), + config, + ); + } + + const node = { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.newDeprecationReason), + } as ArgumentNode; + (deprecationNode.arguments as ArgumentNode[] | undefined) = [ + ...(deprecationNode.arguments ?? []), + node, + ]; + } else { + handleError(change, new ChangedCoordinateNotFoundError(Kind.ARGUMENT, 'reason'), config); + } + } +} + +export function fieldDeprecationReasonAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const deprecationNode = getChangedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE, config); + if (deprecationNode) { + const reasonArgument = findNamedNode(deprecationNode.arguments, 'reason'); + if (reasonArgument) { + handleError( + change, + new AddedAttributeAlreadyExistsError(Kind.DIRECTIVE, 'arguments', 'reason'), + config, + ); + } else { + const node = { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.addedDeprecationReason), + } as ArgumentNode; + (deprecationNode.arguments as ArgumentNode[] | undefined) = [ + ...(deprecationNode.arguments ?? []), + node, + ]; + nodeByPath.set(`${change.path}.reason`, node); + } + } +} + +export function fieldDeprecationAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); + if (fieldNode) { + const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); + if (hasExistingDeprecationDirective) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(Kind.DIRECTIVE, '@deprecated'), + config, + ); + } else { + const directiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(GraphQLDeprecatedDirective.name), + ...(change.meta.deprecationReason && + change.meta.deprecationReason !== DEPRECATION_REASON_DEFAULT + ? { + arguments: [ + { + kind: Kind.ARGUMENT, + name: nameNode('reason'), + value: stringNode(change.meta.deprecationReason), + }, + ], + } + : {}), + } as DirectiveNode; + + (fieldNode.directives as DirectiveNode[] | undefined) = [ + ...(fieldNode.directives ?? []), + directiveNode, + ]; + nodeByPath.set([change.path, `@${GraphQLDeprecatedDirective.name}`].join(','), directiveNode); + } + } +} + +export function fieldDeprecationRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); + if (fieldNode) { + const hasExistingDeprecationDirective = getDeprecatedDirectiveNode(fieldNode); + if (hasExistingDeprecationDirective) { + (fieldNode.directives as DirectiveNode[] | undefined) = fieldNode.directives?.filter( + d => d.name.value !== GraphQLDeprecatedDirective.name, + ); + nodeByPath.delete([change.path, `@${GraphQLDeprecatedDirective.name}`].join('.')); + } else { + handleError(change, new DeletedCoordinateNotFound(Kind.DIRECTIVE, '@deprecated'), config); + } + } +} + +export function fieldDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); + if (fieldNode) { + (fieldNode.description as StringValueNode | undefined) = change.meta.addedDescription + ? stringNode(change.meta.addedDescription) + : undefined; + } +} + +export function fieldDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const fieldNode = nodeByPath.get(change.path); + if (fieldNode) { + if (fieldNode.kind === Kind.FIELD_DEFINITION) { + (fieldNode.description as StringValueNode | undefined) = undefined; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + config, + ); + } + } else { + handleError( + change, + new DeletedCoordinateNotFound(Kind.FIELD_DEFINITION, change.meta.fieldName), + config, + ); + } +} + +export function fieldDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); + if (fieldNode) { + if (fieldNode.description?.value !== change.meta.oldDescription) { + handleError( + change, + new ValueMismatchError( + Kind.FIELD_DEFINITION, + change.meta.oldDescription, + fieldNode.description?.value, + ), + config, + ); + } + + (fieldNode.description as StringValueNode | undefined) = stringNode(change.meta.newDescription); + } +} diff --git a/packages/patch/src/patches/inputs.ts b/packages/patch/src/patches/inputs.ts new file mode 100644 index 0000000000..3a309924df --- /dev/null +++ b/packages/patch/src/patches/inputs.ts @@ -0,0 +1,302 @@ +import { + ASTNode, + ConstValueNode, + InputValueDefinitionNode, + Kind, + parseConstValue, + parseType, + print, + StringValueNode, + TypeNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, + handleError, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig } from '../types.js'; +import { + assertValueMatch, + getChangedNodeOfKind, + getDeletedNodeOfKind, + parentPath, +} from '../utils.js'; + +export function inputFieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const existingNode = nodeByPath.get(change.path); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + handleError( + change, + new AddedCoordinateAlreadyExistsError( + Kind.INPUT_VALUE_DEFINITION, + change.meta.addedInputFieldName, + ), + config, + ); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } + } else { + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: InputValueDefinitionNode[]; + }; + if (!typeNode) { + handleError( + change, + new AddedAttributeCoordinateNotFoundError( + change.meta.inputName, + 'fields', + change.meta.addedInputFieldName, + ), + config, + ); + } else if (typeNode.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedInputFieldName), + type: parseType(change.meta.addedInputFieldType), + // description: change.meta.addedInputFieldDescription + // ? stringNode(change.meta.addedInputFieldDescription) + // : undefined, + }; + + typeNode.fields = [...(typeNode.fields ?? []), node]; + + // add new field to the node set + nodeByPath.set(change.path, node); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } +} + +export function inputFieldRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const existingNode = nodeByPath.get(change.path); + if (existingNode) { + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: InputValueDefinitionNode[]; + }; + if (!typeNode) { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.INPUT_OBJECT_TYPE_DEFINITION, + 'fields', + change.meta.removedFieldName, + ), + config, + ); + } else if (typeNode.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION) { + typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); + + // add new field to the node set + nodeByPath.delete(change.path); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError( + change, + new DeletedCoordinateNotFound( + Kind.INPUT_OBJECT_TYPE_DEFINITION, + change.meta.removedFieldName, + ), + config, + ); + } +} + +export function inputFieldDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + const existingNode = nodeByPath.get(change.path); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + (existingNode.description as StringValueNode | undefined) = stringNode( + change.meta.addedInputFieldDescription, + ); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } + } else { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.INPUT_VALUE_DEFINITION, + 'description', + change.meta.addedInputFieldDescription, + ), + config, + ); + } +} + +export function inputFieldTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const inputFieldNode = getChangedNodeOfKind( + change, + nodeByPath, + Kind.INPUT_VALUE_DEFINITION, + config, + ); + if (inputFieldNode) { + assertValueMatch( + change, + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldInputFieldType, + print(inputFieldNode.type), + config, + ); + + (inputFieldNode.type as TypeNode) = parseType(change.meta.newInputFieldType); + } +} + +export function inputFieldDefaultValueChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + const existingNode = nodeByPath.get(change.path); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + const oldValueMatches = + (existingNode.defaultValue && print(existingNode.defaultValue)) === + change.meta.oldDefaultValue; + if (!oldValueMatches) { + handleError( + change, + new ValueMismatchError( + existingNode.defaultValue?.kind ?? Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDefaultValue, + existingNode.defaultValue && print(existingNode.defaultValue), + ), + config, + ); + } + (existingNode.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue + ? parseConstValue(change.meta.newDefaultValue) + : undefined; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + config, + ); + } + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.INPUT_VALUE_DEFINITION, 'defaultValue'), + config, + ); + } +} + +export function inputFieldDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existingNode = getChangedNodeOfKind( + change, + nodeByPath, + Kind.INPUT_VALUE_DEFINITION, + config, + ); + if (existingNode) { + if (existingNode.description?.value !== change.meta.oldInputFieldDescription) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldInputFieldDescription, + existingNode.description?.value, + ), + config, + ); + } + (existingNode.description as StringValueNode | undefined) = stringNode( + change.meta.newInputFieldDescription, + ); + } +} + +export function inputFieldDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + const existingNode = getDeletedNodeOfKind( + change, + nodeByPath, + Kind.INPUT_VALUE_DEFINITION, + config, + ); + if (existingNode) { + if (existingNode.description === undefined) { + console.warn(`Cannot remove a description at ${change.path} because no description is set.`); + } else if (existingNode.description.value !== change.meta.removedDescription) { + console.warn( + `Description at ${change.path} does not match expected description, but proceeding with description removal anyways.`, + ); + } + (existingNode.description as StringValueNode | undefined) = undefined; + } +} diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts new file mode 100644 index 0000000000..8c765595f7 --- /dev/null +++ b/packages/patch/src/patches/interfaces.ts @@ -0,0 +1,118 @@ +import { ASTNode, Kind, NamedTypeNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, + handleError, +} from '../errors.js'; +import { namedTypeNode } from '../node-templates.js'; +import type { PatchConfig } from '../types'; +import { findNamedNode } from '../utils.js'; + +export function objectTypeInterfaceAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (typeNode) { + if ( + typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || + typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION + ) { + const existing = findNamedNode(typeNode.interfaces, change.meta.addedInterfaceName); + if (existing) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + typeNode.kind, + 'interfaces', + change.meta.addedInterfaceName, + ), + config, + ); + } else { + (typeNode.interfaces as NamedTypeNode[] | undefined) = [ + ...(typeNode.interfaces ?? []), + namedTypeNode(change.meta.addedInterfaceName), + ]; + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError( + Kind.OBJECT_TYPE_DEFINITION, // or Kind.INTERFACE_TYPE_DEFINITION + typeNode.kind, + ), + config, + ); + } + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'interfaces'), + config, + ); + } +} + +export function objectTypeInterfaceRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (typeNode) { + if ( + typeNode.kind === Kind.OBJECT_TYPE_DEFINITION || + typeNode.kind === Kind.INTERFACE_TYPE_DEFINITION + ) { + const existing = findNamedNode(typeNode.interfaces, change.meta.removedInterfaceName); + if (existing) { + (typeNode.interfaces as NamedTypeNode[] | undefined) = typeNode.interfaces?.filter( + i => i.name.value !== change.meta.removedInterfaceName, + ); + } else { + // @note this error isnt the best designed for this application + handleError( + change, + new DeletedCoordinateNotFound( + Kind.INTERFACE_TYPE_DEFINITION, + change.meta.removedInterfaceName, + ), + config, + ); + } + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.INPUT_OBJECT_TYPE_DEFINITION, + 'interfaces', + change.meta.removedInterfaceName, + ), + config, + ); + } +} diff --git a/packages/patch/src/patches/schema.ts b/packages/patch/src/patches/schema.ts new file mode 100644 index 0000000000..be7806626f --- /dev/null +++ b/packages/patch/src/patches/schema.ts @@ -0,0 +1,102 @@ +/* eslint-disable unicorn/no-negated-condition */ +import { Kind, NameNode, OperationTypeNode } from 'graphql'; +import type { Change, ChangeType } from '@graphql-inspector/core'; +import { ChangedCoordinateNotFoundError, handleError, ValueMismatchError } from '../errors.js'; +import { nameNode } from '../node-templates.js'; +import { PatchConfig, SchemaNode } from '../types.js'; + +export function schemaMutationTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + for (const schemaNode of schemaNodes) { + const mutation = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!mutation) { + handleError( + change, + new ChangedCoordinateNotFoundError(Kind.SCHEMA_DEFINITION, 'mutation'), + config, + ); + } else { + if (mutation.type.name.value !== change.meta.oldMutationTypeName) { + handleError( + change, + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldMutationTypeName, + mutation?.type.name.value, + ), + config, + ); + } + (mutation.type.name as NameNode) = nameNode(change.meta.newMutationTypeName); + } + } +} + +export function schemaQueryTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + for (const schemaNode of schemaNodes) { + const query = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!query) { + handleError( + change, + new ChangedCoordinateNotFoundError(Kind.SCHEMA_DEFINITION, 'query'), + config, + ); + } else { + if (query.type.name.value !== change.meta.oldQueryTypeName) { + handleError( + change, + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldQueryTypeName, + query?.type.name.value, + ), + config, + ); + } + (query.type.name as NameNode) = nameNode(change.meta.newQueryTypeName); + } + } +} + +export function schemaSubscriptionTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, +) { + for (const schemaNode of schemaNodes) { + const sub = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.SUBSCRIPTION, + ); + if (!sub) { + handleError( + change, + new ChangedCoordinateNotFoundError(Kind.SCHEMA_DEFINITION, 'subscription'), + config, + ); + } else { + if (sub.type.name.value !== change.meta.oldSubscriptionTypeName) { + handleError( + change, + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldSubscriptionTypeName, + sub?.type.name.value, + ), + config, + ); + } + (sub.type.name as NameNode) = nameNode(change.meta.newSubscriptionTypeName); + } + } +} diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts new file mode 100644 index 0000000000..e564d86b46 --- /dev/null +++ b/packages/patch/src/patches/types.ts @@ -0,0 +1,195 @@ +import { ASTNode, isTypeDefinitionNode, Kind, StringValueNode, TypeDefinitionNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, + handleError, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig } from '../types'; + +export function typeAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const existing = nodeByPath.get(change.path); + if (existing) { + handleError( + change, + new AddedCoordinateAlreadyExistsError(existing.kind, change.meta.addedTypeName), + config, + ); + } else { + const node: TypeDefinitionNode = { + name: nameNode(change.meta.addedTypeName), + kind: change.meta.addedTypeKind as TypeDefinitionNode['kind'], + }; + nodeByPath.set(change.path, node); + } +} + +export function typeRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), 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(change.path)) { + nodeByPath.delete(key); + } + } + } else { + handleError( + change, + new DeletedCoordinateNotFound(removedNode.kind, change.meta.removedTypeName), + config, + ); + } + } else { + handleError( + change, + new DeletedCoordinateNotFound(Kind.OBJECT_TYPE_DEFINITION, change.meta.removedTypeName), + config, + ); + } +} + +export function typeDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (typeNode) { + if (isTypeDefinitionNode(typeNode)) { + (typeNode.description as StringValueNode | undefined) = change.meta.addedTypeDescription + ? stringNode(change.meta.addedTypeDescription) + : undefined; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), + config, + ); + } +} + +export function typeDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (typeNode) { + if (isTypeDefinitionNode(typeNode)) { + if (typeNode.description?.value !== change.meta.oldTypeDescription) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldTypeDescription, + typeNode.description?.value, + ), + config, + ); + } + (typeNode.description as StringValueNode | undefined) = stringNode( + change.meta.newTypeDescription, + ); + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.OBJECT_TYPE_DEFINITION, 'description'), + config, + ); + } +} + +export function typeDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, +) { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (typeNode) { + if (isTypeDefinitionNode(typeNode)) { + if (typeNode.description?.value !== change.meta.oldTypeDescription) { + handleError( + change, + new ValueMismatchError( + Kind.STRING, + change.meta.oldTypeDescription, + typeNode.description?.value, + ), + config, + ); + } + (typeNode.description as StringValueNode | undefined) = undefined; + } else { + handleError( + change, + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + config, + ); + } + } else { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.OBJECT_TYPE_DEFINITION, + 'description', + change.meta.oldTypeDescription, + ), + config, + ); + } +} diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts new file mode 100644 index 0000000000..4597455e17 --- /dev/null +++ b/packages/patch/src/patches/unions.ts @@ -0,0 +1,82 @@ +import { ASTNode, Kind, NamedTypeNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, + handleError, +} from '../errors.js'; +import { namedTypeNode } from '../node-templates.js'; +import { PatchConfig } from '../types.js'; +import { findNamedNode, 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 (findNamedNode(union.types, change.meta.addedUnionMemberTypeName)) { + handleError( + change, + new AddedAttributeAlreadyExistsError( + Kind.UNION_TYPE_DEFINITION, + 'types', + change.meta.addedUnionMemberTypeName, + ), + config, + ); + } else { + union.types = [...(union.types ?? []), namedTypeNode(change.meta.addedUnionMemberTypeName)]; + } + } else { + handleError( + change, + new ChangedAncestorCoordinateNotFoundError(Kind.UNION_TYPE_DEFINITION, 'types'), + 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 (findNamedNode(union.types, change.meta.removedUnionMemberTypeName)) { + union.types = union.types!.filter( + t => t.name.value !== change.meta.removedUnionMemberTypeName, + ); + } else { + handleError( + change, + new DeletedAttributeNotFoundError( + Kind.UNION_TYPE_DEFINITION, + 'types', + change.meta.removedUnionMemberTypeName, + ), + config, + ); + } + } else { + handleError( + change, + new DeletedAncestorCoordinateNotFoundError( + Kind.UNION_TYPE_DEFINITION, + 'types', + change.meta.removedUnionMemberTypeName, + ), + config, + ); + } +} diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts new file mode 100644 index 0000000000..fa7fefa35a --- /dev/null +++ b/packages/patch/src/types.ts @@ -0,0 +1,57 @@ +import type { SchemaDefinitionNode, SchemaExtensionNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; + +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 = { + /** + * By default does not throw when hitting errors such as if + * a type that was modified no longer exists. + */ + throwOnError?: boolean; + + /** + * By default does not require the value at time of change to match + * what's currently in the schema. Enable this if you need to be extra + * cautious when detecting conflicts. + */ + requireOldValueMatch?: boolean; + + /** + * Allows handling errors more granularly if you only care about specific types of + * errors or want to capture the errors in a list somewhere etc. If 'true' is returned + * then this error is considered handled and the default error handling will not + * be ran. + * To halt patching, throw the error inside the handler. + * @param err The raised error + * @returns True if the error has been handled + */ + onError?: (err: Error, change: Change) => boolean | undefined | null; + + /** + * Enables debug logging + */ + debug?: boolean; +}; diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts new file mode 100644 index 0000000000..4527348d78 --- /dev/null +++ b/packages/patch/src/utils.ts @@ -0,0 +1,201 @@ +import { + ASTKindToNode, + ASTNode, + DirectiveNode, + GraphQLDeprecatedDirective, + Kind, + NameNode, +} from 'graphql'; +import { Maybe } from 'graphql/jsutils/Maybe'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, + handleError, + NodeAttribute, + ValueMismatchError, +} from './errors.js'; +import { AdditionChangeType, PatchConfig } from './types.js'; + +export function getDeprecatedDirectiveNode( + definitionNode: Maybe<{ readonly directives?: ReadonlyArray }>, +): Maybe { + return findNamedNode(definitionNode?.directives, `@${GraphQLDeprecatedDirective.name}`); +} + +export function findNamedNode( + nodes: Maybe>, + name: string, +): T | undefined { + return nodes?.find(value => value.name.value === name); +} + +export function deleteNamedNode( + nodes: Maybe>, + name: string, +): ReadonlyArray | undefined { + if (nodes) { + const idx = nodes.findIndex(value => value.name.value === name); + return idx >= 0 ? nodes.toSpliced(idx, 1) : nodes; + } +} + +export function parentPath(path: string) { + const lastDividerIndex = path.lastIndexOf('.'); + return lastDividerIndex === -1 ? path : path.substring(0, lastDividerIndex); +} + +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: + case ChangeType.DirectiveUsageArgumentAdded: + case ChangeType.DirectiveUsageArgumentDefinitionAdded: + case ChangeType.DirectiveUsageEnumAdded: + case ChangeType.DirectiveUsageEnumValueAdded: + case ChangeType.DirectiveUsageFieldAdded: + case ChangeType.DirectiveUsageFieldDefinitionAdded: + case ChangeType.DirectiveUsageInputFieldDefinitionAdded: + case ChangeType.DirectiveUsageInputObjectAdded: + case ChangeType.DirectiveUsageInterfaceAdded: + case ChangeType.DirectiveUsageObjectAdded: + case ChangeType.DirectiveUsageScalarAdded: + case ChangeType.DirectiveUsageSchemaAdded: + case ChangeType.DirectiveUsageUnionMemberAdded: + return true; + default: + return false; + } +}; + +export function debugPrintChange(change: Change, nodeByPath: Map) { + if (isAdditionChange(change)) { + console.debug(`"${change.path}" is being added to the schema.`); + } else { + const changedNode = (change.path && nodeByPath.get(change.path)) || false; + + if (changedNode) { + console.debug(`"${change.path}" has a change: [${change.type}] "${change.message}"`); + } else { + console.debug( + `The "${change.type}" change to "${change.path}" cannot be applied. That coordinate does not exist in the schema.`, + ); + } + } +} + +export const DEPRECATION_REASON_DEFAULT = 'No longer supported'; + +export function assertChangeHasPath>( + change: C, + config: PatchConfig, +): change is typeof change & { path: string } { + if (!change.path) { + handleError(change, new ChangePathMissingError(change), config); + return false; + } + return true; +} + +export function assertValueMatch( + change: Change, + expectedKind: Kind, + expected: string | undefined, + actual: string | undefined, + config: PatchConfig, +) { + if (expected !== actual) { + handleError(change, new ValueMismatchError(expectedKind, expected, actual), config); + } +} + +/** + * Handles verifying the change object has a path, that the node exists in the + * nodeByPath Map, and that the found node is the expected Kind. + */ +export function getChangedNodeOfKind( + change: Change, + nodeByPath: Map, + kind: K, + config: PatchConfig, +): ASTKindToNode[K] | void { + if (assertChangeHasPath(change, config)) { + const existing = nodeByPath.get(change.path); + if (!existing) { + handleError( + change, + // @todo improve the error by providing the name or value somehow. + new ChangedCoordinateNotFoundError(kind, undefined), + config, + ); + } else if (existing.kind === kind) { + return existing as ASTKindToNode[K]; + } else { + handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + } + } +} + +export function getDeletedNodeOfKind( + change: Change, + nodeByPath: Map, + kind: K, + config: PatchConfig, +): ASTKindToNode[K] | void { + if (assertChangeHasPath(change, config)) { + const existing = nodeByPath.get(change.path); + if (!existing) { + handleError( + change, + // @todo improve the error by providing the name or value somehow. + new DeletedCoordinateNotFound(kind, undefined), + config, + ); + } else if (existing.kind === kind) { + return existing as ASTKindToNode[K]; + } else { + handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + } + } +} + +export function getDeletedParentNodeOfKind( + change: Change, + nodeByPath: Map, + kind: K, + attribute: NodeAttribute, + config: PatchConfig, +): ASTKindToNode[K] | void { + if (assertChangeHasPath(change, config)) { + const existing = nodeByPath.get(parentPath(change.path)); + if (!existing) { + handleError( + change, + // @todo improve the error by providing the name or value somehow. + new DeletedAncestorCoordinateNotFoundError(kind, attribute, undefined), + config, + ); + } else if (existing.kind === kind) { + return existing as ASTKindToNode[K]; + } else { + handleError(change, new ChangedCoordinateKindMismatchError(kind, existing.kind), config); + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c219b1908..2b7a819cdc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -663,6 +663,23 @@ importers: version: 2.6.2 publishDirectory: dist + packages/patch: + dependencies: + '@graphql-tools/utils': + specifier: ^10.0.0 + version: 10.8.6(graphql@16.10.0) + graphql: + specifier: 16.10.0 + version: 16.10.0 + 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', },