diff --git a/.changeset/empty-cougars-grab.md b/.changeset/empty-cougars-grab.md new file mode 100644 index 0000000000..7500abb6e6 --- /dev/null +++ b/.changeset/empty-cougars-grab.md @@ -0,0 +1,22 @@ +--- +'@graphql-inspector/patch': patch +--- + +Initial release. Patch applies a list of changes (output from `@graphql-inspector/core`'s `diff`) to +a GraphQL Schema. + +Example usage: + +```typescript +import { buildSchema } from "graphql"; +import { diff } from "@graphql-inspector/core"; +import { patchSchema } from "@graphql-inspector/patch"; + +const schemaA = buildSchema(before, { assumeValid: true, assumeValidSDL: true }); +const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); + +const changes = await diff(schemaA, schemaB); +const patched = patchSchema(schemaA, changes); +``` + +If working from an AST, you may alternatively use the exported `patch` function. But be careful to make sure directives are included in your AST or those changes will be missed. diff --git a/.changeset/long-rules-shop.md b/.changeset/long-rules-shop.md new file mode 100644 index 0000000000..d4bd7aaa3d --- /dev/null +++ b/.changeset/long-rules-shop.md @@ -0,0 +1,13 @@ +--- +'@graphql-inspector/diff-command': major +--- + +Added option to include nested changes. Use `--rule verboseChanges`. Enabling this will output nested changes. I.e. if adding a new type, then `verboseChanges` will also include the addition of the fields on that type. By default, these changes are excluded from the output because they don't impact the outcome and create a lot of noise. + +Added better directive support. + +Adjusted severity level for conditionally safe changes: +- Adding or removing deprecated directive is considered non-breaking +- Adding an interface to a new type is non-breaking +- Adding an argument to a new field is non-breaking +- Adding a directive to a new object (type, interface, etc..) is non-breaking diff --git a/.changeset/seven-jars-yell.md b/.changeset/seven-jars-yell.md new file mode 100644 index 0000000000..6ed95b4dc2 --- /dev/null +++ b/.changeset/seven-jars-yell.md @@ -0,0 +1,137 @@ +--- +'@graphql-inspector/core': major +--- + +This is a major change to `@graphql-inspector/core` and introduces a new `@graphql-inspector/patch` package, which applies changes output from `diff` on top of a schema -- essentially rebasing these changes onto any schema. + +These changes include: +- Numerous adjustments to Change types to create more accurate severity levels, such as a boolean indicating if the change applies to a new type or an existing type. +- Adjustmented the "path" on several change types in order to consistently map to the exact AST node being changed. For example, `EnumValueDeprecationReasonAdded`'s path previously referenced the enumValue (e.g. `EnumName.value`), not the deprecated directive (e.g. `EnumName.value.@deprecated`). +- Added new attributes in order to provide enough context for a new "@graphql-inspector/patch" function to apply changes accurately. +- Added support for repeatable directives +- Includes all nested changes in `diff` output when a new node is added. This can dramatically increase the number of changes listed which can be noisy, but it makes it possible for "@graphql-inspector/patch" to apply all changes from a schema. This can be optionally filtered using a newly exported `DiffRule.simplifyChanges` rule. + +For example, given an existing schema: + +```graphql +type User { + id: ID! + name: String! +} +``` + +And a diff schema: + +```graphql +type User { + id: ID! + name: String! + address: Address +} + +type Address { + line1: String! + line2: String! +} +``` + +Then previously the output would be: + +```json +[ + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Type 'Address' was added", + "meta": { + "addedTypeKind": "ObjectTypeDefinition", + "addedTypeName": "Address", + }, + "path": "Address", + "type": "TYPE_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'address' was added to object type 'User'", + "meta": { + "addedFieldName": "address", + "addedFieldReturnType": "Address", + "typeName": "User", + "typeType": "object type", + }, + "path": "User.address", + "type": "FIELD_ADDED", + }, +] +``` + +But now the output also includes the fields inside the new `Address` type: + +```json +[ + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Type 'Address' was added", + "meta": { + "addedTypeKind": "ObjectTypeDefinition", + "addedTypeName": "Address", + }, + "path": "Address", + "type": "TYPE_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'line1' was added to object type 'Address'", + "meta": { + "addedFieldName": "line1", + "addedFieldReturnType": "String!", + "typeName": "Address", + "typeType": "object type", + }, + "path": "Address.line1", + "type": "FIELD_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'line2' was added to object type 'Address'", + "meta": { + "addedFieldName": "line2", + "addedFieldReturnType": "String!", + "typeName": "Address", + "typeType": "object type", + }, + "path": "Address.line2", + "type": "FIELD_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'address' was added to object type 'User'", + "meta": { + "addedFieldName": "address", + "addedFieldReturnType": "Address", + "typeName": "User", + "typeType": "object type", + }, + "path": "User.address", + "type": "FIELD_ADDED", + }, +] +``` + +These additional changes can be filtered using a new rule: + +```js +import { DiffRule, diff } from "@graphql-inspector/core"; +const changes = await diff(a, b, [DiffRule.simplifyChanges]); +``` diff --git a/packages/commands/diff/src/index.ts b/packages/commands/diff/src/index.ts index 61e36af53d..25f6bced9f 100644 --- a/packages/commands/diff/src/index.ts +++ b/packages/commands/diff/src/index.ts @@ -32,20 +32,27 @@ export async function handler(input: { ? resolveCompletionHandler(input.onComplete) : failOnBreakingChanges; - const rules = input.rules - ? input.rules - .filter(isString) - .map((name): Rule => { - const rule = resolveRule(name); + let verboseChanges = false; + const rules = [...(input.rules ?? [])] + .filter(isString) + .map((name): Rule | undefined => { + if (name === 'verboseChanges') { + verboseChanges = true; + return; + } - if (!rule) { - throw new Error(`Rule '${name}' does not exist!\n`); - } + const rule = resolveRule(name); - return rule; - }) - .filter(f => f) - : []; + if (!rule) { + throw new Error(`Rule '${name}' does not exist!\n`); + } + + return rule; + }) + .filter((f): f is NonNullable => !!f); + if (!verboseChanges) { + rules.push(DiffRule.simplifyChanges); + } const changes = await diffSchema(input.oldSchema, input.newSchema, rules, { checkUsage: input.onUsage ? resolveUsageHandler(input.onUsage) : undefined, diff --git a/packages/core/__tests__/diff/directive-usage.test.ts b/packages/core/__tests__/diff/directive-usage.test.ts index 500f2aea63..f0fabe2470 100644 --- a/packages/core/__tests__/diff/directive-usage.test.ts +++ b/packages/core/__tests__/diff/directive-usage.test.ts @@ -2,12 +2,435 @@ import { buildSchema } from 'graphql'; import { CriticalityLevel, diff, + DiffRule, directiveUsageFieldAddedFromMeta, directiveUsageFieldRemovedFromMeta, } from '../../src/index.js'; import { findFirstChangeByPath } from '../../utils/testing.js'; describe('directive-usage', () => { + describe('repeatable directives', () => { + test('adding with no args', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag @tag + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(1); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 2, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + ] + `); + }); + + test('adding multiple times', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag @tag(name: "second") @tag + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(4); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 1, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 2, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Argument 'name' was added to '@tag'", + "meta": { + "addedArgumentName": "name", + "addedArgumentValue": ""second"", + "directiveName": "tag", + "directiveRepeatedTimes": 2, + "oldArgumentValue": null, + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_ADDED", + }, + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 3, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + ] + `); + }); + + test('adding with different args', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") @tag(name: "bar") + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(2); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was added to field 'a'", + }, + "message": "Directive 'tag' was added to field 'Query.a'", + "meta": { + "addedDirectiveName": "tag", + "addedToNewType": false, + "directiveRepeatedTimes": 2, + "fieldName": "a", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Argument 'name' was added to '@tag'", + "meta": { + "addedArgumentName": "name", + "addedArgumentValue": ""bar"", + "directiveName": "tag", + "directiveRepeatedTimes": 2, + "oldArgumentValue": null, + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_ADDED", + }, + ] + `); + }); + + test('changing arguments of the second usage', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") @tag(name: "foo2") + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") @tag(name: "bar") + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(2); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Changing an argument on a directive can change runtime behavior.", + }, + "message": "Argument 'name' was removed from '@tag'", + "meta": { + "directiveName": "tag", + "directiveRepeatedTimes": 2, + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + "removedArgumentName": "name", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_REMOVED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Argument 'name' was added to '@tag'", + "meta": { + "addedArgumentName": "name", + "addedArgumentValue": ""bar"", + "directiveName": "tag", + "directiveRepeatedTimes": 2, + "oldArgumentValue": ""foo2"", + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_ADDED", + }, + ] + `); + }); + + test('removing with different args', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") @tag(name: "bar") + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "foo") + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(1); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was removed from field 'a'", + }, + "message": "Directive 'tag' was removed from field 'Query.a'", + "meta": { + "directiveRepeatedTimes": 2, + "fieldName": "a", + "removedDirectiveName": "tag", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + }, + ] + `); + }); + + test('removing in from beginning and end', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "start") @tag(name: "mid") @tag(name: "end") + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag(name: "mid") + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(4); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was removed from field 'a'", + }, + "message": "Directive 'tag' was removed from field 'Query.a'", + "meta": { + "directiveRepeatedTimes": 2, + "fieldName": "a", + "removedDirectiveName": "tag", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + }, + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was removed from field 'a'", + }, + "message": "Directive 'tag' was removed from field 'Query.a'", + "meta": { + "directiveRepeatedTimes": 3, + "fieldName": "a", + "removedDirectiveName": "tag", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + }, + { + "criticality": { + "level": "DANGEROUS", + "reason": "Changing an argument on a directive can change runtime behavior.", + }, + "message": "Argument 'name' was removed from '@tag'", + "meta": { + "directiveName": "tag", + "directiveRepeatedTimes": 1, + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + "removedArgumentName": "name", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_REMOVED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Argument 'name' was added to '@tag'", + "meta": { + "addedArgumentName": "name", + "addedArgumentValue": ""mid"", + "directiveName": "tag", + "directiveRepeatedTimes": 1, + "oldArgumentValue": ""start"", + "parentArgumentName": null, + "parentEnumValueName": null, + "parentFieldName": "a", + "parentTypeName": "Query", + }, + "path": "Query.a.@tag.name", + "type": "DIRECTIVE_USAGE_ARGUMENT_ADDED", + }, + ] + `); + }); + + test('removing with no args', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag @tag + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @tag(name: String) repeatable on FIELD_DEFINITION + + type Query { + a: String @tag + } + `); + + const changes = await diff(a, b); + expect(changes).toHaveLength(1); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "DANGEROUS", + "reason": "Directive 'tag' was removed from field 'a'", + }, + "message": "Directive 'tag' was removed from field 'Query.a'", + "meta": { + "directiveRepeatedTimes": 2, + "fieldName": "a", + "removedDirectiveName": "tag", + "typeName": "Query", + }, + "path": "Query.a.@tag", + "type": "DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED", + }, + ] + `); + }); + }); + describe('field-level directives', () => { test('added directive', async () => { const a = buildSchema(/* GraphQL */ ` @@ -26,13 +449,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 @@ -49,7 +495,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'); @@ -73,7 +519,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'); @@ -96,7 +542,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'); @@ -133,7 +579,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'); @@ -169,7 +615,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'); @@ -204,7 +650,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'); @@ -240,7 +686,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'); @@ -275,7 +721,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(); @@ -307,7 +753,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'); @@ -343,7 +789,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); @@ -378,7 +824,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); @@ -405,7 +851,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); @@ -429,7 +875,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); @@ -456,7 +902,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); @@ -482,7 +928,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); @@ -505,7 +951,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); @@ -523,7 +969,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); @@ -548,7 +994,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'); @@ -569,7 +1015,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'); @@ -593,7 +1039,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'); @@ -615,7 +1061,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'); @@ -639,7 +1085,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'); @@ -663,7 +1109,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'); @@ -695,7 +1141,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'); @@ -722,7 +1168,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..cda02a8741 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 () => { @@ -328,4 +328,91 @@ describe('directive', () => { `Default value for argument 'name' on directive 'e' changed from '"Eee"' to 'undefined'`, ); }); + + describe('repeatable', async () => { + test('added', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @a on FIELD + + type Dummy { + field: String + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @a repeatable on FIELD + + type Dummy { + field: String + } + `); + + const changes = await diff(a, b); + const change = findFirstChangeByPath(changes, '@a'); + + expect(changes).toHaveLength(1); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('DIRECTIVE_REPEATABLE_ADDED'); + expect(change.message).toEqual("Directive 'a' added repeatable."); + }); + + test('removed', async () => { + const a = buildSchema(/* GraphQL */ ` + directive @a repeatable on FIELD + + type Dummy { + field: String + } + `); + const b = buildSchema(/* GraphQL */ ` + directive @a on FIELD + + type Dummy { + field: String + } + `); + + const changes = await diff(a, b); + const change = findFirstChangeByPath(changes, '@a'); + + expect(changes).toHaveLength(1); + + expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.type).toEqual('DIRECTIVE_REPEATABLE_REMOVED'); + expect(change.message).toEqual("Directive 'a' removed repeatable."); + }); + + test('complex remove', async () => { + const before = buildSchema(/* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake @flavor(flavor: "bread") { + radius: Int! + } + `); + const after = buildSchema(/* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake + @flavor(flavor: "sweet") + @flavor(flavor: "bread") + @flavor(flavor: "chocolate") + @flavor(flavor: "strawberry") { + radius: Int! + } + `); + const changes = await diff(before, after); + expect(changes.map(c => `[${c.criticality.level}] ${c.path}: ${c.message}`)) + .toMatchInlineSnapshot(` + [ + "[DANGEROUS] Pancake.@flavor: Directive 'flavor' was added to object 'Pancake'", + "[NON_BREAKING] Pancake.@flavor.flavor: Argument 'flavor' was added to '@flavor'", + "[DANGEROUS] Pancake.@flavor: Directive 'flavor' was added to object 'Pancake'", + "[NON_BREAKING] Pancake.@flavor.flavor: Argument 'flavor' was added to '@flavor'", + "[DANGEROUS] Pancake.@flavor: Directive 'flavor' was added to object 'Pancake'", + "[NON_BREAKING] Pancake.@flavor.flavor: Argument 'flavor' was added to '@flavor'", + "[DANGEROUS] Pancake.@flavor.flavor: Argument 'flavor' was removed from '@flavor'", + "[NON_BREAKING] Pancake.@flavor.flavor: Argument 'flavor' was added to '@flavor'", + ] + `); + }); + }); }); diff --git a/packages/core/__tests__/diff/enum.test.ts b/packages/core/__tests__/diff/enum.test.ts index c3d50056e5..63f9f48910 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 { @@ -34,6 +82,11 @@ describe('enum', () => { expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.criticality.reason).toBeDefined(); expect(change.message).toEqual(`Enum value 'C' was added to enum 'enumA'`); + expect(change.meta).toMatchObject({ + addedEnumValueName: 'C', + enumName: 'enumA', + addedToNewType: false, + }); }); test('value removed', async () => { @@ -65,6 +118,10 @@ describe('enum', () => { expect(change.criticality.level).toEqual(CriticalityLevel.Breaking); expect(change.criticality.reason).toBeDefined(); expect(change.message).toEqual(`Enum value 'B' was removed from enum 'enumA'`); + expect(change.meta).toMatchObject({ + removedEnumValueName: 'B', + enumName: 'enumA', + }); }); test('description changed', async () => { @@ -130,9 +187,10 @@ 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); + // Changes include deprecated change, directive remove argument, and directive add argument. + expect(changes.length).toEqual(3); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.message).toEqual( `Enum value 'enumA.A' deprecation reason changed from 'Old Reason' to 'New Reason'`, @@ -163,11 +221,26 @@ describe('enum', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A'); + expect(changes).toHaveLength(3); + const directiveChanges = findChangesByPath(changes, 'enumA.A.@deprecated'); + expect(directiveChanges).toHaveLength(2); - expect(changes.length).toEqual(2); - expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); - expect(change.message).toEqual(`Enum value 'enumA.A' was deprecated with reason 'New Reason'`); + for (const change of directiveChanges) { + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + if (change.type === ChangeType.EnumValueDeprecationReasonAdded) { + expect(change.message).toEqual( + `Enum value 'enumA.A' was deprecated with reason 'New Reason'`, + ); + } else if (change.type === ChangeType.DirectiveUsageEnumValueAdded) { + expect(change.message).toEqual(`Directive 'deprecated' was added to enum value 'enumA.A'`); + } + } + + { + const change = findFirstChangeByPath(changes, 'enumA.A.@deprecated.reason'); + expect(change.type).toEqual(ChangeType.DirectiveUsageArgumentAdded); + expect(change.message).toEqual(`Argument 'reason' was added to '@deprecated'`); + } }); test('deprecation reason removed', async () => { @@ -300,9 +373,8 @@ describe('enum', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A'); - - expect(changes.length).toEqual(1); + expect(changes.length).toEqual(3); + const change = findFirstChangeByPath(changes, 'enumA.A.@deprecated'); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.message).toEqual( `Enum value 'enumA.A' deprecation reason changed from 'It\\'s old' to 'It\\'s new'`, @@ -333,9 +405,9 @@ describe('enum', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A'); + const change = findFirstChangeByPath(changes, 'enumA.A.@deprecated'); - expect(changes.length).toEqual(2); + expect(changes.length).toEqual(3); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.message).toEqual( `Enum value 'enumA.A' was deprecated with reason 'Don\\'t use this'`, @@ -366,9 +438,9 @@ describe('enum', () => { `); const changes = await diff(a, b); - const change = findFirstChangeByPath(changes, 'enumA.A'); + const change = findFirstChangeByPath(changes, 'enumA.A.@deprecated'); - expect(changes.length).toEqual(1); + expect(changes.length).toEqual(3); expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.message).toEqual( `Enum value 'enumA.A' deprecation reason changed from 'Old Reason' to 'New Reason'`, diff --git a/packages/core/__tests__/diff/input.test.ts b/packages/core/__tests__/diff/input.test.ts index 8f6d2719cf..ef56302b74 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', () => { @@ -26,18 +26,73 @@ describe('input', () => { }; // Non-nullable - expect(change.c.criticality.level).toEqual(CriticalityLevel.Breaking); expect(change.c.type).toEqual('INPUT_FIELD_ADDED'); + expect(change.c.criticality.level).toEqual(CriticalityLevel.Breaking); expect(change.c.message).toEqual( "Input field 'c' of type 'String!' was added to input object type 'Foo'", ); // Nullable - expect(change.d.criticality.level).toEqual(CriticalityLevel.Dangerous); expect(change.d.type).toEqual('INPUT_FIELD_ADDED'); + expect(change.d.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.d.message).toEqual( "Input field 'd' of type 'String' was added to input object type 'Foo'", ); }); + + 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.NonBreaking); + 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..a7d725a11f 100644 --- a/packages/core/__tests__/diff/interface.test.ts +++ b/packages/core/__tests__/diff/interface.test.ts @@ -168,25 +168,25 @@ 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], + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), + c: findFirstChangeByPath(changes, 'Foo.c.@deprecated'), }; // 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.type).toEqual('FIELD_DEPRECATION_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.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 () => { @@ -205,12 +205,12 @@ describe('interface', () => { const changes = await diff(a, b); const change = { - a: findFirstChangeByPath(changes, 'Foo.a'), - b: findFirstChangeByPath(changes, 'Foo.b'), + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), }; // Changed - expect(change.a.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.a.type).toEqual('FIELD_DEPRECATION_REMOVED'); expect(change.a.message).toEqual("Field 'Foo.a' is no longer deprecated"); // Removed @@ -219,4 +219,33 @@ 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); + + // one for deprecation added, and one for the reason added + expect(findChangesByPath(changes, 'Foo.a.@deprecated')).toHaveLength(2); + const change = findFirstChangeByPath(changes, 'Foo.a.@deprecated'); + + // added + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + 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..9a52224944 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(5); + + 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', + }); + } - 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 = cChanges[1]; + expect(change.type).toEqual('OBJECT_TYPE_INTERFACE_ADDED'); + expect(change.meta).toMatchObject({ + addedInterfaceName: 'B', + objectTypeName: 'C', + }); + } + + { + const change = findFirstChangeByPath(changes, 'C.b'); + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + } }); test('removed', async () => { @@ -289,25 +329,25 @@ 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], + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), + c: findFirstChangeByPath(changes, 'Foo.c.@deprecated'), }; // 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.type).toEqual('FIELD_DEPRECATION_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.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 () => { @@ -326,12 +366,12 @@ describe('object', () => { const changes = await diff(a, b); const change = { - a: findFirstChangeByPath(changes, 'Foo.a'), - b: findFirstChangeByPath(changes, 'Foo.b'), + a: findFirstChangeByPath(changes, 'Foo.a.@deprecated'), + b: findFirstChangeByPath(changes, 'Foo.b.@deprecated'), }; // Changed - expect(change.a.criticality.level).toEqual(CriticalityLevel.Dangerous); + expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking); expect(change.a.type).toEqual('FIELD_DEPRECATION_REMOVED'); expect(change.a.message).toEqual("Field 'Foo.a' is no longer deprecated"); // Removed diff --git a/packages/core/__tests__/diff/rules/simplify-changes.test.ts b/packages/core/__tests__/diff/rules/simplify-changes.test.ts new file mode 100644 index 0000000000..ecd7302432 --- /dev/null +++ b/packages/core/__tests__/diff/rules/simplify-changes.test.ts @@ -0,0 +1,272 @@ +import { buildSchema } from 'graphql'; +import { simplifyChanges } from '../../../src/diff/rules/index.js'; +import { ChangeType, CriticalityLevel, diff } from '../../../src/index.js'; +import { findFirstChangeByPath } from '../../../utils/testing.js'; + +describe('simplifyChanges rule', () => { + test('added field on new object', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a(b: String): String! @deprecated(reason: "As a test") + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.a'); + expect(added).toBe(undefined); + }); + + test('removed field on a removed object', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a(b: String): String! @deprecated(reason: "As a test") + } + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + + const removed = findFirstChangeByPath(changes, 'Foo.a'); + expect(removed).toBe(undefined); + }); + + test('added field on new interface', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + interface Foo { + a(b: String): String! @deprecated(reason: "As a test") + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.a'); + expect(added).toBe(undefined); + }); + + test('added value on new enum', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + """ + Here is a new enum named B + """ + enum B { + """ + It has newly added values + """ + C @deprecated(reason: "With deprecations") + D + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + criticality: { + level: CriticalityLevel.NonBreaking, + }, + message: "Type 'B' was added", + meta: { + addedTypeKind: 'EnumTypeDefinition', + addedTypeName: 'B', + }, + path: 'B', + type: ChangeType.TypeAdded, + }); + }); + + test('removed value on removed enum', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + """ + Here is a new enum named B + """ + enum B { + """ + It has newly added values + """ + C @deprecated(reason: "With deprecations") + D + } + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + criticality: { + level: CriticalityLevel.Breaking, + }, + message: "Type 'B' was removed", + meta: { + removedTypeName: 'B', + }, + path: 'B', + type: ChangeType.TypeRemoved, + }); + }); + + test('added argument / directive / deprecation / reason on new field', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a: String! + } + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + type Foo { + a: String! + b(b: String): String! @deprecated(reason: "As a test") + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, 'Foo.b'); + expect(added.type).toBe(ChangeType.FieldAdded); + expect(added.meta).toEqual({ + addedFieldName: 'b', + addedFieldReturnType: 'String!', + typeName: 'Foo', + typeType: 'object type', + }); + }); + + test('added type / directive / directive argument on new union', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + directive @special(reason: String) on UNION + + type Foo { + a: String! + } + + union FooUnion @special(reason: "As a test") = Foo + `); + + const changes = await diff(a, b, [simplifyChanges]); + + { + const added = findFirstChangeByPath(changes, 'FooUnion'); + expect(added?.type).toBe(ChangeType.TypeAdded); + } + + { + const added = findFirstChangeByPath(changes, 'Foo'); + expect(added?.type).toBe(ChangeType.TypeAdded); + } + + { + const added = findFirstChangeByPath(changes, '@special'); + expect(added?.type).toBe(ChangeType.DirectiveAdded); + } + + expect(changes).toHaveLength(3); + }); + + test('added argument / location / description on new directive', async () => { + const a = buildSchema(/* GraphQL */ ` + scalar A + `); + const b = buildSchema(/* GraphQL */ ` + scalar A + directive @special(reason: String) on UNION | FIELD_DEFINITION + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + + const added = findFirstChangeByPath(changes, '@special'); + expect(added.type).toBe(ChangeType.DirectiveAdded); + }); + + test('deprecation added', async () => { + const a = buildSchema(/* GraphQL */ ` + type Foo { + bar: String + } + `); + const b = buildSchema(/* GraphQL */ ` + type Foo { + bar: String @deprecated(reason: "Because") + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + const change = findFirstChangeByPath(changes, 'Foo.bar.@deprecated'); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('FIELD_DEPRECATION_ADDED'); + expect(change.message).toEqual("Field 'Foo.bar' is deprecated"); + }); + + test('deprecation changed', async () => { + const a = buildSchema(/* GraphQL */ ` + type Foo { + bar: String @deprecated(reason: "Before") + } + `); + const b = buildSchema(/* GraphQL */ ` + type Foo { + bar: String @deprecated(reason: "After") + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + const change = findFirstChangeByPath(changes, 'Foo.bar.@deprecated'); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('FIELD_DEPRECATION_REASON_CHANGED'); + expect(change.message).toEqual( + "Deprecation reason on field 'Foo.bar' has changed from 'Before' to 'After'", + ); + }); + + test('deprecation removed', async () => { + const a = buildSchema(/* GraphQL */ ` + type Foo { + bar: String @deprecated(reason: "Because") + } + `); + const b = buildSchema(/* GraphQL */ ` + type Foo { + bar: String + } + `); + + const changes = await diff(a, b, [simplifyChanges]); + expect(changes).toHaveLength(1); + const change = findFirstChangeByPath(changes, 'Foo.bar.@deprecated'); + + expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(change.type).toEqual('FIELD_DEPRECATION_REMOVED'); + expect(change.message).toEqual("Field 'Foo.bar' is no longer deprecated"); + }); +}); diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index 90392ece63..da75efb265 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 */ ` @@ -275,121 +276,61 @@ test('huge test', async () => { `); const changes = await diff(schemaA, schemaB); - - for (const msg of [ - `Type 'WillBeRemoved' was removed`, - `Type 'DType' was added`, - `Field 'Query.a' description changed from 'Just a simple string' to 'This description has been changed'`, - `Argument 'anArg: String' was removed from field 'Query.a'`, - `Field 'Query.b' changed type from 'BType' to 'Int!'`, - `Description 'The Query Root of this schema' on type 'Query' has changed to 'Query Root description changed'`, - `'BType' kind changed from 'ObjectTypeDefinition' to 'InputObjectTypeDefinition'`, - `Input field 'b' was removed from input object type 'AInput'`, - `Input field 'c' of type 'String!' was added to input object type 'AInput'`, - `Input field 'AInput.a' description changed from 'a' to 'changed'`, - `Input field 'AInput.a' default value changed from '"1"' to '1'`, - `Input field 'ListInput.a' default value changed from '[ 'foo' ]' to '[ 'bar' ]'`, - `Input field 'AInput.a' changed type from 'String' to 'Int'`, - `'CType' object implements 'AnInterface' interface`, - `Field 'c' was removed from object type 'CType'`, - `Field 'b' was added to object type 'CType'`, - `Deprecation reason on field 'CType.a' has changed from 'whynot' to 'cuz'`, - `Argument 'arg: Int' added to field 'CType.a'`, - `Default value '10' was added to argument 'arg' on field 'CType.d'`, - `Member 'BType' was removed from Union type 'MyUnion'`, - `Member 'DType' was added to Union type 'MyUnion'`, - `Field 'anotherInterfaceField' was removed from interface 'AnotherInterface'`, - `Field 'b' was added to interface 'AnotherInterface'`, - `'WithInterfaces' object type no longer implements 'AnotherInterface' interface`, - `Description for argument 'a' on field 'WithArguments.a' changed from 'Meh' to 'Description for a'`, - `Type for argument 'b' on field 'WithArguments.a' changed from 'String' to 'String!'`, - `Default value for argument 'arg' on field 'WithArguments.b' changed from '1' to '2'`, - `Enum value 'C' was removed from enum 'Options'`, - `Enum value 'D' was added to enum 'Options'`, - `Description 'Stuff' was added to enum value 'Options.A'`, - `Enum value 'Options.E' was deprecated with reason 'No longer supported'`, - `Enum value 'Options.F' deprecation reason changed from 'Old' to 'New'`, - `Directive 'willBeRemoved' was removed`, - `Directive 'yolo2' was added`, - `Directive 'yolo' description changed from 'Old' to 'New'`, - `Location 'FRAGMENT_SPREAD' was removed from directive 'yolo'`, - `Location 'INLINE_FRAGMENT' was removed from directive 'yolo'`, - `Location 'FIELD_DEFINITION' was added to directive 'yolo'`, - `Argument 'willBeRemoved' was removed from directive 'yolo'`, - `Description for argument 'someArg' on directive 'yolo' changed from 'Included when true.' to 'someArg does stuff'`, - `Type for argument 'someArg' on directive 'yolo' changed from 'Boolean!' to 'String!'`, - `Default value '"Test"' was added to argument 'anotherArg' on directive 'yolo'`, - ]) { - try { - expect(changes.some(c => c.message === msg)).toEqual(true); - } catch (e) { - console.log(`Couldn't find: ${msg}`); - const match = findBestMatch( - msg, - changes.map(c => ({ - typeId: c.path || '', - value: c.message, - })), - ); - - if (match.bestMatch) { - console.log(`We found a similar change: ${match.bestMatch.target.value}`); - } - - throw e; - } - } - - for (const path of [ - 'WillBeRemoved', - 'DType', - '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.a', - 'CType.a.arg', - 'CType.d.arg', - 'MyUnion', - 'MyUnion', - 'AnotherInterface.anotherInterfaceField', - 'AnotherInterface.b', - 'WithInterfaces', - 'WithArguments.a.a', - 'WithArguments.a.b', - 'WithArguments.b.arg', - 'Options.C', - 'Options.D', - 'Options.A', - 'Options.E', - 'Options.F', - '@willBeRemoved', - '@yolo2', - '@yolo', - '@yolo', - '@yolo', - '@yolo', - '@yolo.willBeRemoved', - '@yolo.someArg', - '@yolo.someArg', - '@yolo.anotherArg', - ]) { - try { - expect(changes.some(c => c.path === path)).toEqual(true); - } catch (e) { - console.log(`Couldn't find: ${path}`); - throw e; - } - } + expect(changes.map(c => `[${c.criticality.level}] ${c.path}: ${c.message}`)) + .toMatchInlineSnapshot(` + [ + "[BREAKING] WillBeRemoved: Type 'WillBeRemoved' was removed", + "[NON_BREAKING] DType: Type 'DType' was added", + "[NON_BREAKING] DType.b: Field 'b' was added to object type 'DType'", + "[BREAKING] AInput.b: Input field 'b' was removed from input object type 'AInput'", + "[BREAKING] AInput.c: Input field 'c' of type 'String!' was added to input object type 'AInput'", + "[NON_BREAKING] AInput.a: Input field 'AInput.a' description changed from 'a' to 'changed'", + "[DANGEROUS] AInput.a: Input field 'AInput.a' default value changed from '"1"' to '1'", + "[BREAKING] AInput.a: Input field 'AInput.a' changed type from 'String' to 'Int'", + "[DANGEROUS] ListInput.a: Input field 'ListInput.a' default value changed from '[ 'foo' ]' to '[ 'bar' ]'", + "[NON_BREAKING] Query.a: Field 'Query.a' description changed from 'Just a simple string' to 'This description has been changed'", + "[BREAKING] Query.a.anArg: Argument 'anArg: String' was removed from field 'Query.a'", + "[BREAKING] Query.b: Field 'Query.b' changed type from 'BType' to 'Int!'", + "[NON_BREAKING] Query: Description 'The Query Root of this schema' on type 'Query' has changed to 'Query Root description changed'", + "[BREAKING] BType: 'BType' kind changed from 'ObjectTypeDefinition' to 'InputObjectTypeDefinition'", + "[DANGEROUS] CType: 'CType' object implements 'AnInterface' interface", + "[BREAKING] CType.c: Field 'c' was removed from object type 'CType'", + "[NON_BREAKING] CType.b: Field 'b' was added to object type 'CType'", + "[NON_BREAKING] CType.a.@deprecated: Deprecation reason on field 'CType.a' has changed from 'whynot' to 'cuz'", + "[DANGEROUS] CType.a.arg: Argument 'arg: Int' added to field 'CType.a'", + "[DANGEROUS] CType.a.@deprecated.reason: Argument 'reason' was removed from '@deprecated'", + "[NON_BREAKING] CType.a.@deprecated.reason: Argument 'reason' was added to '@deprecated'", + "[DANGEROUS] CType.d.arg: Default value '10' was added to argument 'arg' on field 'CType.d'", + "[BREAKING] MyUnion: Member 'BType' was removed from Union type 'MyUnion'", + "[DANGEROUS] MyUnion: Member 'DType' was added to Union type 'MyUnion'", + "[BREAKING] AnotherInterface.anotherInterfaceField: Field 'anotherInterfaceField' was removed from interface 'AnotherInterface'", + "[NON_BREAKING] AnotherInterface.b: Field 'b' was added to interface 'AnotherInterface'", + "[BREAKING] WithInterfaces: 'WithInterfaces' object type no longer implements 'AnotherInterface' interface", + "[NON_BREAKING] WithArguments.a.a: Description for argument 'a' on field 'WithArguments.a' changed from 'Meh' to 'Description for a'", + "[BREAKING] WithArguments.a.b: Type for argument 'b' on field 'WithArguments.a' changed from 'String' to 'String!'", + "[DANGEROUS] WithArguments.b.arg: Default value for argument 'arg' on field 'WithArguments.b' changed from '1' to '2'", + "[BREAKING] Options.C: Enum value 'C' was removed from enum 'Options'", + "[DANGEROUS] Options.D: Enum value 'D' was added to enum 'Options'", + "[NON_BREAKING] Options.A: Description 'Stuff' was added to enum value 'Options.A'", + "[NON_BREAKING] Options.E.@deprecated: Enum value 'Options.E' was deprecated with reason 'No longer supported'", + "[NON_BREAKING] Options.E.@deprecated: Directive 'deprecated' was added to enum value 'Options.E'", + "[NON_BREAKING] Options.F.@deprecated: Enum value 'Options.F' deprecation reason changed from 'Old' to 'New'", + "[DANGEROUS] Options.F.@deprecated.reason: Argument 'reason' was removed from '@deprecated'", + "[NON_BREAKING] Options.F.@deprecated.reason: Argument 'reason' was added to '@deprecated'", + "[BREAKING] @willBeRemoved: Directive 'willBeRemoved' was removed", + "[NON_BREAKING] @yolo2: Directive 'yolo2' was added", + "[NON_BREAKING] @yolo2: Location 'FIELD' was added to directive 'yolo2'", + "[NON_BREAKING] @yolo2: Argument 'someArg' was added to directive 'yolo2'", + "[NON_BREAKING] @yolo: Directive 'yolo' description changed from 'Old' to 'New'", + "[NON_BREAKING] @yolo: Location 'FIELD_DEFINITION' was added to directive 'yolo'", + "[BREAKING] @yolo: Location 'FRAGMENT_SPREAD' was removed from directive 'yolo'", + "[BREAKING] @yolo: Location 'INLINE_FRAGMENT' was removed from directive 'yolo'", + "[BREAKING] @yolo.willBeRemoved: Argument 'willBeRemoved' was removed from directive 'yolo'", + "[NON_BREAKING] @yolo.someArg: Description for argument 'someArg' on directive 'yolo' changed from 'Included when true.' to 'someArg does stuff'", + "[BREAKING] @yolo.someArg: Type for argument 'someArg' on directive 'yolo' changed from 'Boolean!' to 'String!'", + "[DANGEROUS] @yolo.anotherArg: Default value '"Test"' was added to argument 'anotherArg' on directive 'yolo'", + ] + `); }); test('array as default value in argument (same)', async () => { @@ -820,9 +761,45 @@ test('adding root type should not be breaking', async () => { `); const changes = await diff(schemaA, schemaB); - const subscription = changes[0]; - - expect(changes).toHaveLength(1); - expect(subscription).toBeDefined(); - expect(subscription!.criticality.level).toEqual(CriticalityLevel.NonBreaking); + expect(changes).toMatchInlineSnapshot(` + [ + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Schema subscription root has changed from 'unknown' to 'Subscription'", + "meta": { + "newSubscriptionTypeName": "Subscription", + "oldSubscriptionTypeName": "unknown", + }, + "type": "SCHEMA_SUBSCRIPTION_TYPE_CHANGED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Type 'Subscription' was added", + "meta": { + "addedTypeKind": "ObjectTypeDefinition", + "addedTypeName": "Subscription", + }, + "path": "Subscription", + "type": "TYPE_ADDED", + }, + { + "criticality": { + "level": "NON_BREAKING", + }, + "message": "Field 'onFoo' was added to object type 'Subscription'", + "meta": { + "addedFieldName": "onFoo", + "addedFieldReturnType": "String", + "typeName": "Subscription", + "typeType": "object type", + }, + "path": "Subscription.onFoo", + "type": "FIELD_ADDED", + }, + ] + `); }); diff --git a/packages/core/src/diff/argument.ts b/packages/core/src/diff/argument.ts index a93652c860..e9e9d915fa 100644 --- a/packages/core/src/diff/argument.ts +++ b/packages/core/src/diff/argument.ts @@ -5,56 +5,77 @@ import { GraphQLObjectType, Kind, } from 'graphql'; -import { compareLists, diffArrays, isNotEqual } from '../utils/compare.js'; +import { compareDirectiveLists, diffArrays, isNotEqual } from '../utils/compare.js'; import { fieldArgumentDefaultChanged, 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) { + compareDirectiveLists(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..ba7815bfa3 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', @@ -35,6 +37,8 @@ export const ChangeType = { DirectiveArgumentDescriptionChanged: 'DIRECTIVE_ARGUMENT_DESCRIPTION_CHANGED', DirectiveArgumentDefaultValueChanged: 'DIRECTIVE_ARGUMENT_DEFAULT_VALUE_CHANGED', DirectiveArgumentTypeChanged: 'DIRECTIVE_ARGUMENT_TYPE_CHANGED', + DirectiveRepeatableAdded: 'DIRECTIVE_REPEATABLE_ADDED', + DirectiveRepeatableRemoved: 'DIRECTIVE_REPEATABLE_REMOVED', // Enum EnumValueRemoved: 'ENUM_VALUE_REMOVED', EnumValueAdded: 'ENUM_VALUE_ADDED', @@ -76,9 +80,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 +110,8 @@ export const ChangeType = { DirectiveUsageFieldDefinitionRemoved: 'DIRECTIVE_USAGE_FIELD_DEFINITION_REMOVED', DirectiveUsageInputFieldDefinitionAdded: 'DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_ADDED', DirectiveUsageInputFieldDefinitionRemoved: 'DIRECTIVE_USAGE_INPUT_FIELD_DEFINITION_REMOVED', + DirectiveUsageArgumentAdded: 'DIRECTIVE_USAGE_ARGUMENT_ADDED', + DirectiveUsageArgumentRemoved: 'DIRECTIVE_USAGE_ARGUMENT_REMOVED', } as const; export type TypeOfChangeType = (typeof ChangeType)[keyof typeof ChangeType]; @@ -159,6 +163,9 @@ export type DirectiveAddedChange = { type: typeof ChangeType.DirectiveAdded; meta: { addedDirectiveName: string; + addedDirectiveRepeatable: boolean; + addedDirectiveLocations: string[]; + addedDirectiveDescription: string | null; }; }; @@ -171,6 +178,20 @@ export type DirectiveDescriptionChangedChange = { }; }; +export type DirectiveRepeatableAddedChange = { + type: typeof ChangeType.DirectiveRepeatableAdded; + meta: { + directiveName: string; + }; +}; + +export type DirectiveRepeatableRemovedChange = { + type: typeof ChangeType.DirectiveRepeatableRemoved; + meta: { + directiveName: string; + }; +}; + export type DirectiveLocationAddedChange = { type: typeof ChangeType.DirectiveLocationAdded; meta: { @@ -193,6 +214,10 @@ export type DirectiveArgumentAddedChange = { directiveName: string; addedDirectiveArgumentName: string; addedDirectiveArgumentTypeIsNonNull: boolean; + addedToNewDirective: boolean; + addedDirectiveArgumentDescription?: string /* | null */; + addedDirectiveArgumentType: string; + addedDirectiveDefaultValue?: string /* | null */; }; }; @@ -252,6 +277,8 @@ export type EnumValueAddedChange = { meta: { enumName: string; addedEnumValueName: string; + addedToNewType: boolean; + addedDirectiveDescription: string | null; }; }; @@ -311,6 +338,7 @@ export type FieldAddedChange = { typeName: string; addedFieldName: string; typeType: string; + addedFieldReturnType: string; }; }; @@ -346,6 +374,7 @@ export type FieldDeprecationAddedChange = { meta: { typeName: string; fieldName: string; + deprecationReason: string; }; }; @@ -401,6 +430,8 @@ export type DirectiveUsageUnionMemberAddedChange = { unionName: string; addedUnionMemberTypeName: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -410,6 +441,7 @@ export type DirectiveUsageUnionMemberRemovedChange = { unionName: string; removedUnionMemberTypeName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -422,6 +454,7 @@ export type FieldArgumentAddedChange = { addedArgumentType: string; hasDefaultValue: boolean; isAddedFieldArgumentBreaking: boolean; + addedToNewField: boolean; }; }; @@ -453,6 +486,8 @@ export type InputFieldAddedChange = { addedInputFieldName: string; isAddedInputFieldTypeNullable: boolean; addedInputFieldType: string; + addedFieldDefault?: string; + addedToNewType: boolean; }; }; @@ -512,6 +547,7 @@ export type ObjectTypeInterfaceAddedChange = { meta: { objectTypeName: string; addedInterfaceName: string; + addedToNewType: boolean; }; }; @@ -558,11 +594,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 +665,7 @@ export type UnionMemberAddedChange = { meta: { unionName: string; addedUnionMemberTypeName: string; + addedToNewType: boolean; }; }; @@ -624,6 +676,8 @@ export type DirectiveUsageEnumAddedChange = { meta: { enumName: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -632,6 +686,7 @@ export type DirectiveUsageEnumRemovedChange = { meta: { enumName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -641,6 +696,8 @@ export type DirectiveUsageEnumValueAddedChange = { enumName: string; enumValueName: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -650,6 +707,7 @@ export type DirectiveUsageEnumValueRemovedChange = { enumName: string; enumValueName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -661,6 +719,7 @@ export type DirectiveUsageInputObjectRemovedChange = { isRemovedInputFieldTypeNullable: boolean; removedInputFieldType: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -672,6 +731,8 @@ export type DirectiveUsageInputObjectAddedChange = { isAddedInputFieldTypeNullable: boolean; addedInputFieldType: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -680,7 +741,10 @@ export type DirectiveUsageInputFieldDefinitionAddedChange = { meta: { inputObjectName: string; inputFieldName: string; + inputFieldType: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -690,6 +754,7 @@ export type DirectiveUsageInputFieldDefinitionRemovedChange = { inputObjectName: string; inputFieldName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -699,6 +764,7 @@ export type DirectiveUsageFieldAddedChange = { typeName: string; fieldName: string; addedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -708,6 +774,7 @@ export type DirectiveUsageFieldRemovedChange = { typeName: string; fieldName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -716,6 +783,8 @@ export type DirectiveUsageScalarAddedChange = { meta: { scalarName: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -724,6 +793,7 @@ export type DirectiveUsageScalarRemovedChange = { meta: { scalarName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -732,6 +802,8 @@ export type DirectiveUsageObjectAddedChange = { meta: { objectName: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -740,6 +812,7 @@ export type DirectiveUsageObjectRemovedChange = { meta: { objectName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -748,6 +821,8 @@ export type DirectiveUsageInterfaceAddedChange = { meta: { interfaceName: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -756,6 +831,8 @@ export type DirectiveUsageSchemaAddedChange = { meta: { addedDirectiveName: string; schemaTypeName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -764,6 +841,7 @@ export type DirectiveUsageSchemaRemovedChange = { meta: { removedDirectiveName: string; schemaTypeName: string; + directiveRepeatedTimes: number; }; }; @@ -773,6 +851,8 @@ export type DirectiveUsageFieldDefinitionAddedChange = { typeName: string; fieldName: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; }; }; @@ -782,16 +862,7 @@ export type DirectiveUsageFieldDefinitionRemovedChange = { typeName: string; fieldName: string; removedDirectiveName: string; - }; -}; - -export type DirectiveUsageArgumentDefinitionChange = { - type: typeof ChangeType.DirectiveUsageArgumentDefinitionAdded; - meta: { - typeName: string; - fieldName: string; - argumentName: string; - addedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -802,6 +873,7 @@ export type DirectiveUsageArgumentDefinitionRemovedChange = { fieldName: string; argumentName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -810,6 +882,7 @@ export type DirectiveUsageInterfaceRemovedChange = { meta: { interfaceName: string; removedDirectiveName: string; + directiveRepeatedTimes: number; }; }; @@ -820,6 +893,47 @@ export type DirectiveUsageArgumentDefinitionAddedChange = { fieldName: string; argumentName: string; addedDirectiveName: string; + addedToNewType: boolean; + directiveRepeatedTimes: number; + }; +}; + +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; + directiveRepeatedTimes: number; + }; +}; + +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; + directiveRepeatedTimes: number; }; }; @@ -851,26 +965,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; @@ -879,6 +973,8 @@ type Changes = { [ChangeType.DirectiveArgumentDefaultValueChanged]: DirectiveArgumentDefaultValueChangedChange; [ChangeType.DirectiveArgumentTypeChanged]: DirectiveArgumentTypeChangedChange; [ChangeType.DirectiveDescriptionChanged]: DirectiveDescriptionChangedChange; + [ChangeType.DirectiveRepeatableAdded]: DirectiveRepeatableAddedChange; + [ChangeType.DirectiveRepeatableRemoved]: DirectiveRepeatableRemovedChange; [ChangeType.FieldArgumentDescriptionChanged]: FieldArgumentDescriptionChangedChange; [ChangeType.FieldArgumentDefaultChanged]: FieldArgumentDefaultChangedChange; [ChangeType.FieldArgumentTypeChanged]: FieldArgumentTypeChangedChange; @@ -920,6 +1016,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 3ad675933d..b6d8da22d8 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -7,18 +7,26 @@ 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, + DirectiveAddedChange, + DirectiveRemovedChange, + DirectiveUsageArgumentAddedChange, + DirectiveUsageArgumentDefinitionAddedChange, DirectiveUsageArgumentDefinitionRemovedChange, + DirectiveUsageArgumentRemovedChange, DirectiveUsageEnumAddedChange, DirectiveUsageEnumRemovedChange, DirectiveUsageEnumValueAddedChange, @@ -70,6 +78,13 @@ function removedSpecialDirective( } type KindToPayload = { + [Kind.FIELD]: { + input: { + field: GraphQLField; + parentType: GraphQLInterfaceType | GraphQLObjectType; + }; + change: DirectiveAddedChange | DirectiveRemovedChange; + }; [Kind.ENUM_TYPE_DEFINITION]: { input: GraphQLEnumType; change: DirectiveUsageEnumAddedChange | DirectiveUsageEnumRemovedChange; @@ -117,7 +132,9 @@ type KindToPayload = { field: GraphQLInputField; type: GraphQLInputObjectType; }; - change: DirectiveUsageArgumentDefinitionChange | DirectiveUsageArgumentDefinitionRemovedChange; + change: + | DirectiveUsageArgumentDefinitionAddedChange + | DirectiveUsageArgumentDefinitionRemovedChange; }; [Kind.ARGUMENT]: { input: { @@ -125,22 +142,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, @@ -149,7 +170,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; @@ -175,7 +196,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; @@ -190,12 +211,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; } @@ -216,7 +239,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; } @@ -230,12 +253,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; } @@ -254,7 +279,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; } @@ -270,14 +295,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; } @@ -301,7 +330,7 @@ export function directiveUsageInputFieldDefinitionRemovedFromMeta( path: [ args.meta.inputObjectName, args.meta.inputFieldName, - args.meta.removedDirectiveName, + `@${args.meta.removedDirectiveName}`, ].join('.'), meta: args.meta, } as const; @@ -316,12 +345,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; } @@ -340,7 +371,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; } @@ -352,12 +383,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; } @@ -376,7 +409,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; } @@ -411,12 +444,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; } @@ -456,7 +491,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; } @@ -470,12 +505,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; } @@ -494,7 +533,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; } @@ -508,12 +549,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; } @@ -532,7 +575,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; } @@ -546,12 +589,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; } @@ -570,7 +615,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; } @@ -584,12 +629,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; } @@ -610,16 +657,45 @@ 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 + | typeof ChangeType.DirectiveUsageFieldRemoved; + 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, @@ -628,6 +704,11 @@ export function directiveUsageAdded( argumentName: payload.argument.name, fieldName: payload.field.name, typeName: payload.type.name, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes( + payload.argument.astNode?.directives ?? [], + directive, + ), }, }); } @@ -637,7 +718,13 @@ export function directiveUsageAdded( meta: { addedDirectiveName: directive.name.value, inputFieldName: payload.field.name, + inputFieldType: payload.field.type.toString(), inputObjectName: payload.type.name, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -650,6 +737,8 @@ export function directiveUsageAdded( addedInputFieldType: payload.name, inputObjectName: payload.name, isAddedInputFieldTypeNullable: kind === Kind.INPUT_VALUE_DEFINITION, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -659,6 +748,8 @@ export function directiveUsageAdded( meta: { addedDirectiveName: directive.name.value, interfaceName: payload.name, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -668,6 +759,8 @@ export function directiveUsageAdded( meta: { objectName: payload.name, addedDirectiveName: directive.name.value, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -677,6 +770,22 @@ export function directiveUsageAdded( meta: { enumName: payload.name, addedDirectiveName: directive.name.value, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), + }, + }); + } + if (isOfKind(kind, Kind.FIELD, payload)) { + return directiveUsageFieldAddedFromMeta({ + type: ChangeType.DirectiveUsageFieldAdded, + meta: { + addedDirectiveName: directive.name.value, + fieldName: payload.field.name, + typeName: payload.parentType.name, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -687,6 +796,11 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, fieldName: payload.field.name, typeName: payload.parentType.name, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -697,6 +811,8 @@ export function directiveUsageAdded( addedDirectiveName: directive.name.value, addedUnionMemberTypeName: payload.name, unionName: payload.name, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -707,6 +823,11 @@ export function directiveUsageAdded( enumName: payload.type.name, enumValueName: payload.value.name, addedDirectiveName: directive.name.value, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes( + payload.value.astNode?.directives ?? [], + directive, + ), }, }); } @@ -716,6 +837,8 @@ export function directiveUsageAdded( meta: { addedDirectiveName: directive.name.value, schemaTypeName: payload.getQueryType()?.name || '', + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -725,6 +848,8 @@ export function directiveUsageAdded( meta: { scalarName: payload.name, addedDirectiveName: directive.name.value, + addedToNewType, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -732,6 +857,30 @@ export function directiveUsageAdded( return {} as any; } +/** + * Counts the number of times a directive with the same name + * exists in the directives array before the passed directive. + * + * This is important for repeatable directives because it + * determines which instance of the directive usage the change applies to. + */ +function directiveRepeatTimes( + directives: readonly ConstDirectiveNode[], + directive: ConstDirectiveNode, +) { + const name = directive.name.value; + let repeats = 0; + for (const d of directives) { + if (d.name.value === name) { + repeats += 1; + } + if (d === directive) { + return repeats; + } + } + return 0; +} + export function directiveUsageRemoved( kind: K, directive: ConstDirectiveNode, @@ -745,6 +894,10 @@ export function directiveUsageRemoved( argumentName: payload.argument.name, fieldName: payload.field.name, typeName: payload.type.name, + directiveRepeatedTimes: directiveRepeatTimes( + payload.argument.astNode?.directives ?? [], + directive, + ), }, }); } @@ -755,6 +908,10 @@ export function directiveUsageRemoved( removedDirectiveName: directive.name.value, inputFieldName: payload.field.name, inputObjectName: payload.type.name, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -767,6 +924,7 @@ export function directiveUsageRemoved( removedInputFieldType: payload.name, inputObjectName: payload.name, isRemovedInputFieldTypeNullable: kind === Kind.INPUT_VALUE_DEFINITION, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -776,6 +934,7 @@ export function directiveUsageRemoved( meta: { removedDirectiveName: directive.name.value, interfaceName: payload.name, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -785,6 +944,7 @@ export function directiveUsageRemoved( meta: { objectName: payload.name, removedDirectiveName: directive.name.value, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -794,6 +954,21 @@ export function directiveUsageRemoved( meta: { enumName: payload.name, removedDirectiveName: directive.name.value, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), + }, + }); + } + if (isOfKind(kind, Kind.FIELD, payload)) { + return directiveUsageFieldRemovedFromMeta({ + type: ChangeType.DirectiveUsageFieldRemoved, + meta: { + removedDirectiveName: directive.name.value, + fieldName: payload.field.name, + typeName: payload.parentType.name, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -804,6 +979,10 @@ export function directiveUsageRemoved( removedDirectiveName: directive.name.value, fieldName: payload.field.name, typeName: payload.parentType.name, + directiveRepeatedTimes: directiveRepeatTimes( + payload.field.astNode?.directives ?? [], + directive, + ), }, }); } @@ -814,6 +993,7 @@ export function directiveUsageRemoved( removedDirectiveName: directive.name.value, removedUnionMemberTypeName: payload.name, unionName: payload.name, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -824,6 +1004,10 @@ export function directiveUsageRemoved( enumName: payload.type.name, enumValueName: payload.value.name, removedDirectiveName: directive.name.value, + directiveRepeatedTimes: directiveRepeatTimes( + payload.value.astNode?.directives ?? [], + directive, + ), }, }); } @@ -833,6 +1017,7 @@ export function directiveUsageRemoved( meta: { removedDirectiveName: directive.name.value, schemaTypeName: payload.getQueryType()?.name || '', + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -842,6 +1027,7 @@ export function directiveUsageRemoved( meta: { scalarName: payload.name, removedDirectiveName: directive.name.value, + directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive), }, }); } @@ -856,3 +1042,167 @@ function isOfKind( ): _value is KindToPayload[K]['input'] { return kind === expectedKind; } + +export function directiveUsageArgumentAddedFromMeta( + 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 directiveUsageArgumentRemovedFromMeta( + 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, + ] + .filter(a => a !== null) + .join('.'), + meta: args.meta, + }; +} + +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( + directiveUsageArgumentAddedFromMeta({ + 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, + directiveRepeatedTimes: + // @todo should this lastly fall back to the GraphQLSchema? + directiveRepeatTimes( + (parentEnumValue || parentArgument || parentField || parentType)?.astNode + ?.directives ?? [], + newDirective, + ), + }, + }), + ); + }, + + /** Treat a mutual change as a removal then addition. */ + onMutual(argument) { + if ( + argument.oldVersion && + print(argument.oldVersion.value) === print(argument.newVersion.value) + ) { + return; + } + + if (argument.oldVersion) { + addChange( + directiveUsageArgumentRemovedFromMeta({ + type: ChangeType.DirectiveUsageArgumentRemoved, + meta: { + removedArgumentName: argument.oldVersion.name.value, + directiveName: newDirective.name.value, + parentTypeName: parentType?.name ?? null, + parentFieldName: parentField?.name ?? null, + parentArgumentName: parentArgument?.name ?? null, + parentEnumValueName: parentEnumValue?.name ?? null, + directiveRepeatedTimes: + // @todo should this lastly fall back to the GraphQLSchema? + directiveRepeatTimes( + (parentEnumValue || parentArgument || parentField || parentType)?.astNode + ?.directives ?? [], + newDirective, + ), + }, + }), + ); + } + + addChange( + directiveUsageArgumentAddedFromMeta({ + type: ChangeType.DirectiveUsageArgumentAdded, + meta: { + addedArgumentName: argument.newVersion.name.value, + addedArgumentValue: print(argument.newVersion.value), + oldArgumentValue: + (argument.oldVersion?.value && print(argument.oldVersion.value)) ?? null, + directiveName: newDirective.name.value, + parentTypeName: parentType?.name ?? null, + parentFieldName: parentField?.name ?? null, + parentArgumentName: parentArgument?.name ?? null, + parentEnumValueName: parentEnumValue?.name ?? null, + directiveRepeatedTimes: + // @todo should this lastly fall back to the GraphQLSchema? + directiveRepeatTimes( + (parentEnumValue || parentArgument || parentField || parentType)?.astNode + ?.directives ?? [], + newDirective, + ), + }, + }), + ); + }, + + onRemoved(argument) { + addChange( + directiveUsageArgumentRemovedFromMeta({ + 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, + directiveRepeatedTimes: + // @todo should this lastly fall back to the GraphQLSchema? + directiveRepeatTimes( + (parentEnumValue || parentArgument || parentField || parentType)?.astNode + ?.directives ?? [], + newDirective, + ), + }, + }), + ); + }, + }); +} diff --git a/packages/core/src/diff/changes/directive.ts b/packages/core/src/diff/changes/directive.ts index 06392d1bdd..9ac9b9db92 100644 --- a/packages/core/src/diff/changes/directive.ts +++ b/packages/core/src/diff/changes/directive.ts @@ -15,6 +15,8 @@ import { DirectiveLocationAddedChange, DirectiveLocationRemovedChange, DirectiveRemovedChange, + DirectiveRepeatableAddedChange, + DirectiveRepeatableRemovedChange, } from './change.js'; function buildDirectiveRemovedMessage(args: DirectiveRemovedChange['meta']): string { @@ -70,6 +72,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,19 +100,73 @@ 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, }, }); } +function buildDirectiveRepeatableAddedMessage(args: DirectiveRepeatableAddedChange['meta']) { + return `Directive '${args.directiveName}' added repeatable.`; +} + +function directiveRepeatableAddedFromMeta( + args: DirectiveRepeatableAddedChange, +): Change { + return { + criticality: { + level: CriticalityLevel.NonBreaking, + }, + type: ChangeType.DirectiveRepeatableAdded, + message: buildDirectiveRepeatableAddedMessage(args.meta), + path: `@${args.meta.directiveName}`, + meta: args.meta, + } as const; +} + +export function directiveRepeatableAdded(directive: GraphQLDirective) { + return directiveRepeatableAddedFromMeta({ + type: ChangeType.DirectiveRepeatableAdded, + meta: { + directiveName: directive.name, + }, + }); +} + +function buildDirectiveRepeatableRemovedMessage(args: DirectiveRepeatableAddedChange['meta']) { + return `Directive '${args.directiveName}' removed repeatable.`; +} + +function directiveRepeatableRemovedFromMeta( + args: DirectiveRepeatableRemovedChange, +): Change { + return { + criticality: { + level: CriticalityLevel.Dangerous, + }, + type: ChangeType.DirectiveRepeatableRemoved, + message: buildDirectiveRepeatableRemovedMessage(args.meta), + path: `@${args.meta.directiveName}`, + meta: args.meta, + } as const; +} + +export function directiveRepeatableRemoved(directive: GraphQLDirective) { + return directiveRepeatableRemovedFromMeta({ + type: ChangeType.DirectiveRepeatableRemoved, + meta: { + directiveName: directive.name, + }, + }); +} + function buildDirectiveLocationAddedMessage(args: DirectiveLocationAddedChange['meta']): string { return `Location '${args.addedDirectiveLocation}' was added to directive '${args.directiveName}'`; } @@ -132,7 +191,7 @@ export function directiveLocationAdded( type: ChangeType.DirectiveLocationAdded, meta: { directiveName: directive.name, - addedDirectiveLocation: location.toString(), + addedDirectiveLocation: String(location), }, }); } @@ -172,19 +231,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 +260,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 ? undefined : safeString(arg.defaultValue), addedDirectiveArgumentTypeIsNonNull: isNonNullType(arg.type), + addedDirectiveArgumentDescription: arg.description ?? undefined, + addedToNewDirective, }, }); } @@ -262,15 +333,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 +375,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 +430,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 25060bacd3..3fb0666e19 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 { fmt } from '../../utils/string.js'; import { @@ -55,11 +55,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, @@ -68,14 +70,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, }, }); } @@ -104,15 +109,15 @@ export function enumValueDescriptionChangedFromMeta( export function enumValueDescriptionChanged( newEnum: GraphQLEnumType, - oldValue: GraphQLEnumValue, + oldValue: GraphQLEnumValue | null, newValue: GraphQLEnumValue, ): Change { return enumValueDescriptionChangedFromMeta({ type: ChangeType.EnumValueDescriptionChanged, meta: { enumName: newEnum.name, - enumValueName: oldValue.name, - oldEnumValueDescription: oldValue.description ?? null, + enumValueName: newValue.name, + oldEnumValueDescription: oldValue?.description ?? null, newEnumValueDescription: newValue.description ?? null, }, }); @@ -135,7 +140,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; } @@ -172,21 +179,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..8eda935af0 100644 --- a/packages/core/src/diff/changes/field.ts +++ b/packages/core/src/diff/changes/field.ts @@ -1,5 +1,6 @@ import { GraphQLArgument, + GraphQLDeprecatedDirective, GraphQLField, GraphQLInterfaceType, GraphQLObjectType, @@ -90,6 +91,7 @@ export function fieldAdded( typeName: type.name, addedFieldName: field.name, typeType: entity, + addedFieldReturnType: field.type.toString(), }, }); } @@ -197,7 +199,9 @@ export function fieldDeprecationAddedFromMeta(args: FieldDeprecationAddedChange) }, message: buildFieldDeprecatedAddedMessage(args.meta), meta: args.meta, - path: [args.meta.typeName, args.meta.fieldName].join('.'), + path: [args.meta.typeName, args.meta.fieldName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), } as const; } @@ -210,6 +214,7 @@ export function fieldDeprecationAdded( meta: { typeName: type.name, fieldName: field.name, + deprecationReason: field.deprecationReason ?? '', }, }); } @@ -218,11 +223,13 @@ export function fieldDeprecationRemovedFromMeta(args: FieldDeprecationRemovedCha return { type: ChangeType.FieldDeprecationRemoved, criticality: { - level: CriticalityLevel.Dangerous, + level: CriticalityLevel.NonBreaking, }, message: `Field '${args.meta.typeName}.${args.meta.fieldName}' is no longer deprecated`, meta: args.meta, - path: [args.meta.typeName, args.meta.fieldName].join('.'), + path: [args.meta.typeName, args.meta.fieldName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), } as const; } @@ -253,7 +260,9 @@ export function fieldDeprecationReasonChangedFromMeta(args: FieldDeprecationReas }, message: buildFieldDeprecationReasonChangedMessage(args.meta), meta: args.meta, - path: [args.meta.typeName, args.meta.fieldName].join('.'), + path: [args.meta.typeName, args.meta.fieldName, `@${GraphQLDeprecatedDirective.name}`].join( + '.', + ), } as const; } @@ -285,7 +294,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 +384,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 +400,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 +412,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..7b8012470d 100644 --- a/packages/core/src/diff/changes/input.ts +++ b/packages/core/src/diff/changes/input.ts @@ -50,21 +50,24 @@ 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 - ? { - 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.', - }, + criticality: + args.meta.addedToNewType || + args.meta.isAddedInputFieldTypeNullable || + args.meta.addedFieldDefault !== undefined + ? { + 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.', + }, message: buildInputFieldAddedMessage(args.meta), meta: args.meta, path: [args.meta.inputName, args.meta.addedInputFieldName].join('.'), @@ -74,6 +77,7 @@ export function inputFieldAddedFromMeta(args: InputFieldAddedChange) { export function inputFieldAdded( input: GraphQLInputObjectType, field: GraphQLInputField, + addedToNewType: boolean, ): Change { return inputFieldAddedFromMeta({ type: ChangeType.InputFieldAdded, @@ -82,6 +86,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 +197,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 +218,7 @@ export function inputFieldDefaultValueChanged( ): Change { const meta: InputFieldDefaultValueChangedChange['meta'] = { inputName: input.name, - inputFieldName: oldField.name, + inputFieldName: newField.name, }; if (oldField.defaultValue !== undefined) { @@ -256,7 +265,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..cc7dc2e8aa 100644 --- a/packages/core/src/diff/changes/object.ts +++ b/packages/core/src/diff/changes/object.ts @@ -15,9 +15,9 @@ 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.', + 'Adding an interface to an object type may break existing clients that are consuming a field returning the interface type that are not programming defensively against this new possible type.', }, message: buildObjectTypeInterfaceAddedMessage(args.meta), meta: args.meta, @@ -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/schema.ts b/packages/core/src/diff/changes/schema.ts index 1793e50e16..ea349d384a 100644 --- a/packages/core/src/diff/changes/schema.ts +++ b/packages/core/src/diff/changes/schema.ts @@ -16,7 +16,10 @@ export function schemaQueryTypeChangedFromMeta(args: SchemaQueryTypeChangedChang return { type: ChangeType.SchemaQueryTypeChanged, criticality: { - level: CriticalityLevel.Breaking, + level: + args.meta.oldQueryTypeName === 'unknown' + ? CriticalityLevel.NonBreaking + : CriticalityLevel.Breaking, }, message: buildSchemaQueryTypeChangedMessage(args.meta), meta: args.meta, @@ -49,7 +52,10 @@ export function schemaMutationTypeChangedFromMeta(args: SchemaMutationTypeChange return { type: ChangeType.SchemaMutationTypeChanged, criticality: { - level: CriticalityLevel.Breaking, + level: + args.meta.oldMutationTypeName === 'unknown' + ? CriticalityLevel.NonBreaking + : CriticalityLevel.Breaking, }, message: buildSchemaMutationTypeChangedMessage(args.meta), meta: args.meta, @@ -82,7 +88,10 @@ export function schemaSubscriptionTypeChangedFromMeta(args: SchemaSubscriptionTy return { type: ChangeType.SchemaSubscriptionTypeChanged, criticality: { - level: CriticalityLevel.Breaking, + level: + args.meta.oldSubscriptionTypeName === 'unknown' + ? CriticalityLevel.NonBreaking + : CriticalityLevel.Breaking, }, message: buildSchemaSubscriptionTypeChangedMessage(args.meta), meta: args.meta, diff --git a/packages/core/src/diff/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..a88bcb3ced 100644 --- a/packages/core/src/diff/directive.ts +++ b/packages/core/src/diff/directive.ts @@ -9,21 +9,33 @@ import { directiveDescriptionChanged, directiveLocationAdded, directiveLocationRemoved, + directiveRepeatableAdded, + directiveRepeatableRemoved, } from './changes/directive.js'; 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)); } + // repeatable removed + if (!newDirective.isRepeatable && oldDirective?.isRepeatable) { + addChange(directiveRepeatableRemoved(newDirective)); + } + + // repeatable added + if (newDirective.isRepeatable && !oldDirective?.isRepeatable) { + addChange(directiveRepeatableAdded(newDirective)); + } + const locations = { - added: diffArrays(newDirective.locations, oldDirective.locations), - removed: diffArrays(oldDirective.locations, newDirective.locations), + added: diffArrays(newDirective.locations, oldDirective?.locations ?? []), + removed: diffArrays(oldDirective?.locations ?? [], newDirective.locations), }; // locations added @@ -32,17 +44,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 +65,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..0f6f8ea201 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 { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { GraphQLEnumType, GraphQLEnumValue, Kind } from 'graphql'; +import { compareDirectiveLists, compareLists, isNotEqual, isVoid } from '../utils/compare.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 || [], { + compareDirectiveLists(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)); + } + } + + compareDirectiveLists(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..58b0660c4f 100644 --- a/packages/core/src/diff/field.ts +++ b/packages/core/src/diff/field.ts @@ -1,8 +1,12 @@ import { GraphQLField, GraphQLInterfaceType, GraphQLObjectType, Kind } from 'graphql'; -import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; +import { compareDirectiveLists, compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { isDeprecated } from '../utils/is-deprecated.js'; import { changesInArgument } from './argument.js'; -import { 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 || [], { + compareDirectiveLists(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..78cf3423b3 100644 --- a/packages/core/src/diff/input.ts +++ b/packages/core/src/diff/input.ts @@ -1,6 +1,16 @@ import { GraphQLInputField, GraphQLInputObjectType, Kind } from 'graphql'; -import { compareLists, diffArrays, isNotEqual, isVoid } from '../utils/compare.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { + compareDirectiveLists, + compareLists, + diffArrays, + isNotEqual, + isVoid, +} from '../utils/compare.js'; +import { + directiveUsageAdded, + directiveUsageChanged, + directiveUsageRemoved, +} from './changes/directive-usage.js'; import { inputFieldAdded, inputFieldDefaultValueChanged, @@ -13,80 +23,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 || [], { + compareDirectiveLists(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) { + compareDirectiveLists(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..f4beed7033 100644 --- a/packages/core/src/diff/interface.ts +++ b/packages/core/src/diff/interface.ts @@ -1,32 +1,68 @@ import { GraphQLInterfaceType, Kind } from 'graphql'; -import { compareLists } from '../utils/compare.js'; -import { directiveUsageAdded, directiveUsageRemoved } from './changes/directive-usage.js'; +import { compareDirectiveLists, compareLists } from '../utils/compare.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 || [], { - onAdded(directive) { - addChange(directiveUsageAdded(Kind.INTERFACE_TYPE_DEFINITION, directive, newInterface)); + compareDirectiveLists( + oldInterface?.astNode?.directives || [], + newInterface.astNode?.directives || [], + { + onAdded(directive) { + addChange( + directiveUsageAdded( + Kind.INTERFACE_TYPE_DEFINITION, + directive, + newInterface, + oldInterface === null, + ), + ); + directiveUsageChanged(null, directive, addChange, newInterface); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange, newInterface); + }, + onRemoved(directive) { + addChange(directiveUsageRemoved(Kind.INTERFACE_TYPE_DEFINITION, directive, oldInterface!)); + }, }, - onRemoved(directive) { - addChange(directiveUsageRemoved(Kind.INTERFACE_TYPE_DEFINITION, directive, oldInterface)); - }, - }); + ); } diff --git a/packages/core/src/diff/object.ts b/packages/core/src/diff/object.ts index 12817e1f0f..3b8ad529fd 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 { compareDirectiveLists, compareLists } from '../utils/compare.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 || [], { + compareDirectiveLists(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/index.ts b/packages/core/src/diff/rules/index.ts index fb9f10a602..23de5be55a 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 './simplify-changes.js'; diff --git a/packages/core/src/diff/rules/simplify-changes.ts b/packages/core/src/diff/rules/simplify-changes.ts new file mode 100644 index 0000000000..92d7089a01 --- /dev/null +++ b/packages/core/src/diff/rules/simplify-changes.ts @@ -0,0 +1,69 @@ +import { ChangeType } from '../changes/change.js'; +import { Rule } from './types.js'; + +const simpleChangeTypes = 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, + + // These are not additions -- but this is necessary to eliminate nested removals for directives + // because the deprecationReasons are redundant with directives + ChangeType.FieldDeprecationRemoved, + ChangeType.FieldDeprecationReasonChanged, + ChangeType.EnumValueDeprecationReasonChanged, + + ChangeType.FieldDeprecationReasonAdded, + ChangeType.FieldDescriptionAdded, + ChangeType.InputFieldAdded, + 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 simplifyChanges: Rule = ({ changes }) => { + // Track which paths contained changes that represent a group of changes to the schema + // e.g. the addition of a type implicity contains the addition of that type's fields. + const changePaths: string[] = []; + + const filteredChanges = changes.filter(({ path, type }) => { + if (path) { + const parent = parentPath(path); + const matches = changePaths.filter(matchedPath => matchedPath.startsWith(parent)); + const hasChangedParent = matches.length > 0; + + if (simpleChangeTypes.has(type)) { + changePaths.push(path); + } + + return !hasChangedParent; + } + return true; + }); + + return filteredChanges; +}; diff --git a/packages/core/src/diff/scalar.ts b/packages/core/src/diff/scalar.ts index 020752b322..8273f59272 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 { compareDirectiveLists } from '../utils/compare.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 || [], { + compareDirectiveLists(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..d29b49d03b 100644 --- a/packages/core/src/diff/schema.ts +++ b/packages/core/src/diff/schema.ts @@ -10,10 +10,14 @@ import { isUnionType, Kind, } from 'graphql'; -import { compareLists, isNotEqual, isVoid } from '../utils/compare.js'; +import { compareDirectiveLists, compareLists, isNotEqual, isVoid } from '../utils/compare.js'; import { isPrimitive } from '../utils/graphql.js'; import { Change } from './changes/change.js'; -import { 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)); @@ -75,9 +81,13 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): }, }); - compareLists(oldSchema.astNode?.directives || [], newSchema.astNode?.directives || [], { + compareDirectiveLists(oldSchema.astNode?.directives || [], newSchema.astNode?.directives || [], { onAdded(directive) { - addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema)); + 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)); @@ -88,26 +98,15 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): } function changesInSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema, addChange: AddChange) { - const defaultNames = { - query: 'Query', - mutation: 'Mutation', - subscription: 'Subscription', - }; const oldRoot = { - query: (oldSchema.getQueryType() || ({} as GraphQLObjectType)).name ?? defaultNames.query, - mutation: - (oldSchema.getMutationType() || ({} as GraphQLObjectType)).name ?? defaultNames.mutation, - subscription: - (oldSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name ?? - defaultNames.subscription, + query: (oldSchema.getQueryType() || ({} as GraphQLObjectType)).name, + mutation: (oldSchema.getMutationType() || ({} as GraphQLObjectType)).name, + subscription: (oldSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name, }; const newRoot = { - query: (newSchema.getQueryType() || ({} as GraphQLObjectType)).name ?? defaultNames.query, - mutation: - (newSchema.getMutationType() || ({} as GraphQLObjectType)).name ?? defaultNames.mutation, - subscription: - (newSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name ?? - defaultNames.subscription, + query: (newSchema.getQueryType() || ({} as GraphQLObjectType)).name, + mutation: (newSchema.getMutationType() || ({} as GraphQLObjectType)).name, + subscription: (newSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name, }; if (isNotEqual(oldRoot.query, newRoot.query)) { @@ -123,27 +122,32 @@ 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.description && isVoid(newType.description)) { addChange(typeDescriptionRemoved(oldType)); } else { addChange(typeDescriptionChanged(oldType, newType)); diff --git a/packages/core/src/diff/union.ts b/packages/core/src/diff/union.ts index 030539b675..2c2da1927d 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 { compareDirectiveLists, compareLists } from '../utils/compare.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 || [], { + compareDirectiveLists(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 3a954581dd..53483fb93f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,6 +58,8 @@ export { directiveUsageSchemaRemovedFromMeta, directiveUsageUnionMemberAddedFromMeta, directiveUsageUnionMemberRemovedFromMeta, + directiveUsageArgumentRemovedFromMeta, + directiveUsageArgumentAddedFromMeta, } from './diff/changes/directive-usage.js'; export { directiveRemovedFromMeta, @@ -180,7 +182,6 @@ export { SerializableChange, DirectiveUsageArgumentDefinitionAddedChange, DirectiveUsageArgumentDefinitionRemovedChange, - DirectiveUsageArgumentDefinitionChange, DirectiveUsageEnumAddedChange, DirectiveUsageEnumRemovedChange, DirectiveUsageEnumValueAddedChange, @@ -203,4 +204,6 @@ export { DirectiveUsageSchemaRemovedChange, DirectiveUsageUnionMemberAddedChange, DirectiveUsageUnionMemberRemovedChange, + DirectiveUsageArgumentAddedChange, + DirectiveUsageArgumentRemovedChange, } from './diff/changes/change.js'; diff --git a/packages/core/src/utils/compare.ts b/packages/core/src/utils/compare.ts index 170a31df02..badc2d92b8 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)); @@ -78,13 +78,13 @@ export function compareLists( const mutual: Array<{ newVersion: T; oldVersion: T }> = []; for (const oldItem of oldList) { - const newItem = newMap[extractName(oldItem.name)]; - if (newItem === undefined) { + const newItem = newMap[extractName(oldItem.name)] ?? null; + if (newItem === null) { removed.push(oldItem); } else { mutual.push({ newVersion: newItem, - oldVersion: oldItem, + oldVersion: oldItem ?? null, }); } } @@ -96,16 +96,101 @@ export function compareLists( } if (callbacks) { + if (callbacks.onRemoved) { + for (const item of removed) { + callbacks.onRemoved(item); + } + } if (callbacks.onAdded) { for (const item of added) { callbacks.onAdded(item); } } + if (callbacks.onMutual) { + for (const item of mutual) { + callbacks.onMutual(item); + } + } + } + + return { + added, + removed, + mutual, + }; +} + +/** + * This is special because directives can be repeated and a name alone is not enough + * to identify whether or not an instance was changed, added, or removed. + * The best option is to assume the order is the same, and treat the changes as they come. + * So `type T @foo` to `type T @foo(f: 'bar') @foo` would be adding an argument `f: 'bar'` and + * then adding a new directive `@foo`. Rather than adding `@foo(f: 'bar')` + */ +export function compareDirectiveLists( + oldList: readonly T[], + newList: readonly T[], + callbacks?: { + onAdded?(t: T): void; + onRemoved?(t: T): void; + onMutual?(t: { newVersion: T; oldVersion: T | null }): void; + }, +) { + // collect all the usages, in order, by name for the old and new version of the schema + const oldMap = keyMapList(oldList, ({ name }) => extractName(name)); + const newMap = keyMapList(newList, ({ name }) => extractName(name)); + + const added: T[] = []; + const removed: T[] = []; + const mutual: Array<{ newVersion: T; oldVersion: T }> = []; + + for (const oldItem of oldList) { + // check if the oldItem exists in the new schema + const newItems = newMap[extractName(oldItem.name)] ?? null; + // if not, then it's been removed + if (newItems === null) { + removed.push(oldItem); + } else { + // if so, then consider this a mutual change, and remove it from the list of newItems to avoid counting it in the future + const [newItem, ...rest] = newItems; + if (rest.length > 0) { + newMap[extractName(oldItem.name)] = rest as [T] & T[]; + } else { + delete newMap[extractName(oldItem.name)]; + } + + mutual.push({ + newVersion: newItem, + oldVersion: oldItem ?? null, + }); + } + } + + for (const newItem of newList) { + const existingItems = oldMap[extractName(newItem.name)] ?? null; + if (existingItems === null) { + added.push(newItem); + } else { + const [_, ...rest] = existingItems; + if (rest.length > 0) { + oldMap[extractName(newItem.name)] = rest as [T] & T[]; + } else { + delete oldMap[extractName(newItem.name)]; + } + } + } + + if (callbacks) { if (callbacks.onRemoved) { for (const item of removed) { callbacks.onRemoved(item); } } + if (callbacks.onAdded) { + for (const item of added) { + callbacks.onAdded(item); + } + } if (callbacks.onMutual) { for (const item of mutual) { callbacks.onMutual(item); @@ -119,3 +204,14 @@ export function compareLists( mutual, }; } + +export function keyMapList( + list: readonly T[], + keyFn: (item: T) => string, +): Record { + return list.reduce((map, item) => { + const key = keyFn(item); + map[key] = [...(map[key] ?? []), item]; + return map; + }, Object.create(null)); +} diff --git a/packages/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/README.md b/packages/patch/README.md new file mode 100644 index 0000000000..36b1535a56 --- /dev/null +++ b/packages/patch/README.md @@ -0,0 +1,60 @@ +# GraphQL Change Patch + +This package applies a list of changes (output from `@graphql-inspector/core`'s `diff`) to a GraphQL Schema. + +## Usage + +```typescript +import { buildSchema } from "graphql"; +import { diff } from "@graphql-inspector/core"; +import { patchSchema } from "@graphql-inspector/patch"; + +const schemaA = buildSchema(before, { assumeValid: true, assumeValidSDL: true }); +const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); + +const changes = await diff(schemaA, schemaB); +const patched = patchSchema(schemaA, changes); +``` + +## Configuration + +### `debug?: boolean` + +> Enables debug logging + +### `onError?: (err: Error, change: Change) => void` + +> Define how you want errors to be handled. This package exports three predefined error handlers: `errors.strictErrorHandler`, `errors.defaultErrorHandler`, and `errors.looseErrorHandler`. Strict is recommended if you want to manually resolve value conflicts. + +> [!CAUTION] +> Error classes are still being actively improved. It's recommended to use one of the exported error functions rather than build your own at this time. + +#### `defaultErrorHandler` + +A convenient, semi-strict error handler. This ignores "no-op" errors -- if +the change wouldn't impact the patched schema at all. And it ignores +value mismatches, which are when the change notices that the value captured in +the change doesn't match the value in the patched schema. + +For example, if the change indicates the default value WAS "foo" before being +changed, but the patch is applied to a schema where the default value is "bar". +This is useful to avoid overwriting changes unknowingly that may have occurred +from other sources. + +#### `strictErrorHandler` + +The strictest of the standard error handlers. This checks if the error is a "No-op", +meaning if the change wouldn't impact the schema at all, and ignores the error +only in this one case. Otherwise, the error is raised. + +#### `looseErrorHandler` + +The least strict error handler. This will only log errors and will never +raise an error. This is potentially useful for getting a patched schema +rendered, and then handling the conflict/error in a separate step. E.g. +if creating a merge conflict resolution UI. + +## Remaining Work + +- [] Support type extensions +- [] Fully support schema operation types diff --git a/packages/patch/__tests__/directive-usage.test.ts b/packages/patch/__tests__/directive-usage.test.ts new file mode 100644 index 0000000000..5676d01ea9 --- /dev/null +++ b/packages/patch/__tests__/directive-usage.test.ts @@ -0,0 +1,1213 @@ +import { expectDiffAndPatchToMatch } 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('directiveUsageFieldDefinitionAdded: @deprecated', async () => { + const before = ` + type Foo { + new: String + old: String + } + `; + const after = /* GraphQL */ ` + type Foo { + new: String + old: String @deprecated(reason: "No good") + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + 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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/__tests__/directives.test.ts b/packages/patch/__tests__/directives.test.ts new file mode 100644 index 0000000000..40c96bea3a --- /dev/null +++ b/packages/patch/__tests__/directives.test.ts @@ -0,0 +1,421 @@ +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} from './utils.js'; + +describe('directives', () => { + test('directiveAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('directiveAdded: ignores if directive already exists', async () => { + const before = /* GraphQL */ ` + scalar Food + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + /** + * @note this is somewhat counter intuitive, but if the directive already exists + * and has all the same properties of the change -- but with more, then it's + * assumed that this addition was intentional and there should be no conflict. + * This change can result in an invalid schema though. If the change adds a + * directive usage that is lacking these arguments. + */ + test('directiveAdded: ignores if directive exists but only arguments do not match', async () => { + const before = /* GraphQL */ ` + scalar Food + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(flavor: String) on FIELD_DEFINITION + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); + + test('directiveAdded: errors if directive exists but locations do not match', async () => { + const before = /* GraphQL */ ` + scalar Food + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(flavor: String) on INTERFACE + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('directiveRemoved', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('directiveRemoved: ignores if patching schema does not have directive', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + test('directiveArgumentAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('directiveArgumentAdded: ignores if directive argument is already added', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + test('directiveArgumentAdded: errors if directive argument is already added but type differs', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String!) on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('directiveArgumentAdded: errors if directive argument is already added but defaultValue differs', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String = "ok") on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String! = "not ok") on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('directiveArgumentRemoved', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('directiveArgumentRemoved: ignores if non-existent', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty on FIELD_DEFINITION + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + test('directiveLocationAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION | OBJECT + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('directiveLocationAdded: ignores if already exists', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION | OBJECT + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + /** + * This is okay because the change is to add another location. It says nothing about whether or not + * the existing locations are sufficient otherwise. + */ + test('directiveLocationAdded: passes if already exists with a different location', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION | OBJECT + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION | INTERFACE + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); + + test('directiveArgumentDefaultValueChanged', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String = "It tastes good.") on FIELD_DEFINITION + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('directiveArgumentDefaultValueChanged: throws if old default value does not match schema', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String = "It tastes good.") on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String = "Flavertown") on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('directiveDescriptionChanged', async () => { + const before = /* GraphQL */ ` + scalar Food + 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 expectDiffAndPatchToMatch(before, after); + }); + + test('directiveDescriptionChanged: throws if old description does not match schema', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + """ + Signals that this thing is extra yummy + """ + directive @tasty(reason: String) on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + """ + I change this + """ + directive @tasty(reason: String) on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('directiveArgumentTypeChanged', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('directiveArgumentTypeChanged: throws if old argument type does not match schema', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Food + directive @tasty(scale: String!) on FIELD_DEFINITION + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('directiveRepeatableAdded', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('directiveRepeatableAdded: throws if directive does not exist in patched schema', async () => { + const before = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + scalar Food + directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION + `; + const patchSchema = /* GraphQL */ ` + scalar Food + `; + await expectDiffAndPatchToThrow(before, after, patchSchema); + }); + + test('directiveRepeatableRemoved', async () => { + const before = /* GraphQL */ ` + directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('directiveRepeatableRemoved: ignores if directive does not exist in patched schema', async () => { + const before = /* GraphQL */ ` + directive @tasty(scale: Int!) repeatable on FIELD_DEFINITION + `; + const after = /* GraphQL */ ` + directive @tasty(scale: Int!) on FIELD_DEFINITION + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); +}); + +describe('repeat directives', () => { + test('Directives Added', async () => { + const before = /* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake @flavor(flavor: "bread") { + radius: Int! + } + `; + const after = /* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake + @flavor(flavor: "sweet") + @flavor(flavor: "bread") + @flavor(flavor: "chocolate") + @flavor(flavor: "strawberry") { + radius: Int! + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('Directives Removed', async () => { + const before = /* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake + @flavor(flavor: "sweet") + @flavor(flavor: "bread") + @flavor(flavor: "chocolate") + @flavor(flavor: "strawberry") { + radius: Int! + } + `; + const after = /* GraphQL */ ` + directive @flavor(flavor: String!) repeatable on OBJECT + type Pancake @flavor(flavor: "bread") { + radius: Int! + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('Directive Arguments', async () => { + const before = /* GraphQL */ ` + directive @flavor(flavor: String) repeatable on OBJECT + type Pancake + @flavor(flavor: "sweet") + @flavor(flavor: "bread") + @flavor(flavor: "chocolate") + @flavor(flavor: "strawberry") { + radius: Int! + } + `; + const after = /* GraphQL */ ` + directive @flavor(flavor: String) repeatable on OBJECT + type Pancake + @flavor + @flavor(flavor: "bread") + @flavor(flavor: "banana") + @flavor(flavor: "strawberry") { + radius: Int! + } + `; + await expectDiffAndPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/__tests__/enum.test.ts b/packages/patch/__tests__/enum.test.ts new file mode 100644 index 0000000000..6c9ba88c04 --- /dev/null +++ b/packages/patch/__tests__/enum.test.ts @@ -0,0 +1,115 @@ +import { expectDiffAndPatchToMatch } 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 expectDiffAndPatchToMatch(before, after); + }); + + test('enumValueAdded', async () => { + const before = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + SUPER_BROKE + } + `; + await expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(before, after); + }); + + test('enumValueDescriptionChanged: Changed', async () => { + const before = /* GraphQL */ ` + enum Status { + """ + Before + """ + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + """ + After + """ + SUCCESS + ERROR + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('enumValueDescriptionChanged: Removed', async () => { + const before = /* GraphQL */ ` + enum Status { + """ + Before + """ + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + await expectDiffAndPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/__tests__/fields.test.ts b/packages/patch/__tests__/fields.test.ts new file mode 100644 index 0000000000..dfd6e491ff --- /dev/null +++ b/packages/patch/__tests__/fields.test.ts @@ -0,0 +1,263 @@ +import { expectDiffAndPatchToMatch, expectDiffAndPatchToThrow } from './utils.js'; + +describe('fields', () => { + test('fieldTypeChanged', async () => { + const before = /* GraphQL */ ` + type Product { + id: ID! + } + `; + const after = /* GraphQL */ ` + type Product { + id: String! + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('fieldRemoved', async () => { + const before = /* GraphQL */ ` + type Product { + id: ID! + name: String + } + `; + const after = /* GraphQL */ ` + type Product { + id: ID! + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('fieldAdded', async () => { + const before = /* GraphQL */ ` + type Product { + id: ID! + } + `; + const after = /* GraphQL */ ` + type Product { + id: ID! + name: String + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('fieldAdded: adding field with a new type', async () => { + const before = /* GraphQL */ ` + scalar Foo + `; + const after = /* GraphQL */ ` + scalar Foo + type Product { + id: ID! + name: String + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('fieldAdded: throws if adding a field and the field already exists with a different returnType', async () => { + const before = /* GraphQL */ ` + type Product { + id: ID! + } + `; + const after = /* GraphQL */ ` + type Product { + id: ID! + name: String + } + `; + + const patchTarget = /* GraphQL */ ` + type Product { + id: ID! + name: String! + } + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('fieldArgumentAdded', async () => { + const before = /* GraphQL */ ` + scalar ChatSession + type Query { + chat: ChatSession + } + `; + const after = /* GraphQL */ ` + scalar ChatSession + type Query { + chat(firstMessage: String): ChatSession + } + `; + await expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/__tests__/inputs.test.ts b/packages/patch/__tests__/inputs.test.ts new file mode 100644 index 0000000000..b470700c2a --- /dev/null +++ b/packages/patch/__tests__/inputs.test.ts @@ -0,0 +1,201 @@ +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} 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 expectDiffAndPatchToMatch(before, after); + }); + + test('inputFieldAdded: field added to new input', async () => { + const before = /* GraphQL */ ` + scalar Foo + `; + const after = /* GraphQL */ ` + scalar Foo + input FooInput { + id: ID! + other: String + } + type Query { + foo(foo: FooInput): Foo + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('inputFieldAdded: passes if input already exists', async () => { + const before = /* GraphQL */ ` + scalar Foo + `; + const after = /* GraphQL */ ` + scalar Foo + input FooInput { + id: ID! + other: String + } + type Query { + foo(foo: FooInput): Foo + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + input FooInput { + id: ID! + other: String + } + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); + + test('inputFieldRemoved', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + other: String + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('inputFieldRemoved: passes for non-existent field', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + other: String + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); + + test('inputFieldDescriptionAdded', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + """ + After + """ + input FooInput { + id: ID! + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('inputFieldDescriptionAdded: errors for non-existent field', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + """ + After + """ + input FooInput { + id: ID! + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('inputFieldTypeChanged', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('inputFieldTypeChanged: errors for non-existent input', async () => { + const before = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('inputFieldDescriptionRemoved', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('inputFieldDescriptionRemoved: passes for non-existent input', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + input FooInput { + id: ID! + } + `; + const after = /* GraphQL */ ` + input FooInput { + id: ID! + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); +}); diff --git a/packages/patch/__tests__/interfaces.test.ts b/packages/patch/__tests__/interfaces.test.ts new file mode 100644 index 0000000000..8c63b26188 --- /dev/null +++ b/packages/patch/__tests__/interfaces.test.ts @@ -0,0 +1,234 @@ +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} 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 expectDiffAndPatchToMatch(before, after); + }); + + test('objectTypeInterfaceAdded: passes if already exists', async () => { + const before = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo { + id: ID! + } + `; + const after = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + test('objectTypeInterfaceRemoved', async () => { + const before = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + + const after = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo { + id: ID! + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('objectTypeInterfaceRemoved: passes if interface is not applied to type', async () => { + const before = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + + const after = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo { + id: ID! + } + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + test('fieldAdded', async () => { + const before = /* GraphQL */ ` + interface Node { + 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 expectDiffAndPatchToMatch(before, after); + }); + + test('fieldAdded: passes if field already added', async () => { + const before = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + + const after = /* GraphQL */ ` + interface Node { + id: ID! + name: String + } + type Foo implements Node { + id: ID! + name: String + } + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + test('fieldAdded: throws if type is non-existent', async () => { + const before = /* GraphQL */ ` + interface Node { + id: ID! + } + type Foo implements Node { + id: ID! + } + `; + + const after = /* GraphQL */ ` + interface Node { + id: ID! + name: String + } + type Foo implements Node { + id: ID! + name: String + } + `; + + const patchTarget = /* GraphQL */ ` + interface Node { + id: ID! + name: String + } + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('fieldRemoved', async () => { + const before = /* GraphQL */ ` + interface Node { + 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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(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 expectDiffAndPatchToMatch(before, after); + }); +}); diff --git a/packages/patch/__tests__/types.test.ts b/packages/patch/__tests__/types.test.ts new file mode 100644 index 0000000000..8c200c72cd --- /dev/null +++ b/packages/patch/__tests__/types.test.ts @@ -0,0 +1,185 @@ +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} from './utils.js'; + +describe('enum', () => { + test('typeRemoved', async () => { + const before = /* GraphQL */ ` + scalar Foo + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('typeRemoved: passes if type is non-existent', async () => { + const before = /* GraphQL */ ` + scalar Foo + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + scalar Foo + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); + + test('typeAdded', async () => { + const before = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + SUPER_BROKE + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('typeAdded: ignores if already exists', async () => { + const before = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + } + `; + const after = /* GraphQL */ ` + enum Status { + SUCCESS + ERROR + SUPER_BROKE + } + `; + await expectDiffAndPatchToPass(before, after, after); + }); + + test('typeAdded: patches Mutation', async () => { + const before = /* GraphQL */ ` + type Query { + foo: String + } + `; + const after = /* GraphQL */ ` + type Query { + foo: String + } + + type Mutation { + dooFoo: String + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('typeDescriptionChanged: Added', async () => { + const before = /* GraphQL */ ` + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + """ + The status of something. + """ + enum Status { + OK + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('typeDescriptionChanged: Changed', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + """ + After + """ + enum Status { + OK + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('typeDescriptionChanged: errors for non-existent types', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + """ + After + """ + enum Status { + OK + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('typeDescriptionChanged: Removed', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + enum Status { + OK + } + `; + await expectDiffAndPatchToMatch(before, after); + }); + + test('typeDescriptionChanged: remove ignored for non-existent type', async () => { + const before = /* GraphQL */ ` + """ + Before + """ + enum Status { + OK + } + `; + const after = /* GraphQL */ ` + enum Status { + OK + } + `; + const patchTarget = /* GraphQL */ ` + scalar Foo + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); +}); diff --git a/packages/patch/__tests__/unions.test.ts b/packages/patch/__tests__/unions.test.ts new file mode 100644 index 0000000000..f964c3387e --- /dev/null +++ b/packages/patch/__tests__/unions.test.ts @@ -0,0 +1,111 @@ +import { + expectDiffAndPatchToMatch, + expectDiffAndPatchToPass, + expectDiffAndPatchToThrow, +} 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 expectDiffAndPatchToMatch(before, after); + }); + + test('unionMemberAdded: errors if union does not exist', async () => { + const before = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + union U = A + `; + const after = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + union U = A | B + `; + const patchTarget = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + `; + await expectDiffAndPatchToThrow(before, after, patchTarget); + }); + + test('unionMemberRemoved', async () => { + const before = /* GraphQL */ ` + type A { + 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 expectDiffAndPatchToMatch(before, after); + }); + + test('unionMemberRemoved: ignores already removed union', async () => { + const before = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + union U = A | B + `; + const after = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + union U = A + `; + const patchTarget = /* GraphQL */ ` + type A { + foo: String + } + type B { + foo: String + } + `; + await expectDiffAndPatchToPass(before, after, patchTarget); + }); +}); diff --git a/packages/patch/__tests__/utils.ts b/packages/patch/__tests__/utils.ts new file mode 100644 index 0000000000..66d45dd33a --- /dev/null +++ b/packages/patch/__tests__/utils.ts @@ -0,0 +1,67 @@ +import { + buildASTSchema, + buildSchema, + GraphQLSchema, + lexicographicSortSchema, + parse, +} from 'graphql'; +import { Change, diff } from '@graphql-inspector/core'; +import { printSchemaWithDirectives } from '@graphql-tools/utils'; +import { errors, patch } from '../src/index.js'; + +function printSortedSchema(schema: GraphQLSchema) { + return printSchemaWithDirectives(lexicographicSortSchema(schema)); +} + +async function buildDiffPatch(before: string, after: string, patchTarget: string = before) { + const schemaA = buildSchema(before, { assumeValid: true, assumeValidSDL: true }); + const schemaB = buildSchema(after, { assumeValid: true, assumeValidSDL: true }); + + const changes = await diff(schemaA, schemaB); + const patched = patch(parse(patchTarget), changes, { + debug: process.env.DEBUG === 'true', + onError: errors.strictErrorHandler, + }); + return buildASTSchema(patched, { assumeValid: true, assumeValidSDL: true }); +} + +export async function expectDiffAndPatchToMatch( + before: string, + after: string, +): Promise { + const patched = await buildDiffPatch(before, after); + expect(printSortedSchema(patched)).toBe(printSortedSchema(buildSchema(after))); + return patched; +} + +export async function expectDiffAndPatchToThrow( + before: string, + after: string, + /** The schema that gets patched using the diff */ + patchSchema: string, +): Promise { + await expect(async () => { + try { + return await buildDiffPatch(before, after, patchSchema); + } catch (e) { + const err = e as Error; + console.error(`Patch threw as expected with error: ${err.message}`); + throw e; + } + }).rejects.toThrow(); +} + +/** + * Differs from "expectDiffAndPatchToMatch" because the end result doesn't need to match the "after" + * argument. Instead, it just needs to not result in an error when patching another schema. + */ +export async function expectDiffAndPatchToPass( + before: string, + after: string, + /** The schema that gets patched using the diff */ + patchSchema: string, +): Promise { + const result = buildDiffPatch(before, after, patchSchema); + await expect(result).resolves.toBeInstanceOf(GraphQLSchema); + return result; +} diff --git a/packages/patch/package.json b/packages/patch/package.json new file mode 100644 index 0000000000..359df6495f --- /dev/null +++ b/packages/patch/package.json @@ -0,0 +1,75 @@ +{ + "name": "@graphql-inspector/patch", + "version": "0.0.0", + "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/errors.ts b/packages/patch/src/errors.ts new file mode 100644 index 0000000000..041d6c0081 --- /dev/null +++ b/packages/patch/src/errors.ts @@ -0,0 +1,234 @@ +import { Kind } from 'graphql'; +import type { Change } from '@graphql-inspector/core'; +import type { ChangesByType, ErrorHandler } from './types.js'; +import { parentPath } from './utils.js'; + +/** + * The strictest of the standard error handlers. This checks if the error is a "No-op", + * meaning if the change wouldn't impact the schema at all, and ignores the error + * only in this one case. Otherwise, the error is raised. + */ +export const strictErrorHandler: ErrorHandler = (err, _change) => { + if (err instanceof NoopError) { + console.debug(`[IGNORED] ${err.message}`); + } else { + throw err; + } +}; + +/** + * A convenient, semi-strict error handler. This ignores "no-op" errors -- if + * the change wouldn't impact the patched schema at all. And it ignores + * value mismatches, which are when the change notices that the value captured in + * the change doesn't match the value in the patched schema. + * + * For example, if the change indicates the default value WAS "foo" before being + * changed, but the patch is applied to a schema where the default value is "bar". + * This is useful to avoid overwriting changes unknowingly that may have occurred + * from other sources. + */ +export const defaultErrorHandler: ErrorHandler = (err, change) => { + if (err instanceof NoopError) { + console.debug(`[IGNORED] ${err.message}`); + } else if (err instanceof ValueMismatchError) { + console.debug(`Ignoring old value mismatch at "${change.path}".`); + } else { + throw err; + } +}; + +/** + * The least strict error handler. This will only log errors and will never + * raise an error. This is potentially useful for getting a patched schema + * rendered, and then handling the conflict/error in a separate step. E.g. + * if creating a merge conflict resolution UI. + */ +export const looseErrorHandler: ErrorHandler = (err, change) => { + if (err instanceof NoopError) { + console.debug(`[IGNORED] ${err.message}`); + } else if (err instanceof ValueMismatchError) { + console.debug(`Ignoring old value mismatch at "${change.path}".`); + } else { + console.warn(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 "${String(expected)}" but found "${String(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 path: string, + public readonly changeType: keyof ChangesByType, + ) { + const subpath = path.substring(path.lastIndexOf('.') + 1); + const parent = parentPath(path); + const printedParent = parent === subpath ? 'schema' : `"${parent}"`; + super( + `Cannot apply "${changeType}" to add "${subpath}" to ${printedParent} because that schema coordinate already exists.`, + ); + } +} + +export class AddedAttributeCoordinateNotFoundError extends Error { + constructor( + public readonly path: string, + public readonly changeType: keyof ChangesByType, + /** + * The value of what is being changed at the path. E.g. if the description is being changed, then this should + * be the description string. + */ + public readonly changeValue: string | number | null, + ) { + const subpath = path.substring(path.lastIndexOf('.')); + super( + `Cannot apply addition "${changeType}" (${changeValue}) to "${subpath}", because "${path}" 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 path: string, + public readonly changeType: keyof ChangesByType, + /** + * The value of what is being changed at the path. E.g. if the description is being changed, then this should + * be the description string. + */ + public readonly changeValue: string | number | boolean | null, + ) { + const subpath = path.substring(path.lastIndexOf('.')); + super( + `Cannot apply change "${changeType}" (${typeof changeValue === 'string' ? `"${changeValue}"` : changeValue}) to "${subpath}", because the "${parentPath(path)}" does not exist.`, + ); + } +} + +/** + * 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 path: string, + public readonly changeType: keyof ChangesByType, + /** + * The value of what is being changed at the path. E.g. if the description is being changed, then this should + * be the description string. + */ + public readonly expectedValue: string | number | boolean | null, + ) { + const subpath = path.substring(path.lastIndexOf('.')); + super( + `Cannot apply "${changeType}" to remove ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue} from "${subpath}", because "${parentPath(path)}" 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 path: string, + public readonly changeType: string, + /** The property's path on the node. E.g. defaultValue */ + public readonly attribute: string, + public readonly expectedValue?: string, + ) { + const subpath = path.substring(path.lastIndexOf('.')); + super( + `Cannot apply "${changeType}" to add ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue} to "${subpath}.${attribute}", 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 path: string, + public readonly changeType: string, + /** The property's path on the node. E.g. defaultValue */ + public readonly attribute: string, + public readonly expectedValue?: string, + ) { + const subpath = path.substring(path.lastIndexOf('.')); + super( + `Cannot apply "${changeType}" to remove ${typeof expectedValue === 'string' ? `"${expectedValue}"` : expectedValue} from ${subpath}'s "${attribute}", because "${attribute}" does not exist at "${path}".`, + ); + } +} + +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( + public readonly path: string, + public readonly changeType: string, + ) { + const subpath = path.substring(path.lastIndexOf('.')); + const parent = parentPath(path); + const printedParent = parent === subpath ? 'schema' : `"${parent}"`; + super( + `Cannot apply "${changeType}" on "${printedParent}", because "${subpath}" does not exist.`, + ); + } +} + +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 "${change.type}" at "${change.path}" is missing a "path" value. Cannot apply.`, + ); + } +} diff --git a/packages/patch/src/index.ts b/packages/patch/src/index.ts new file mode 100644 index 0000000000..e14d8167dc --- /dev/null +++ b/packages/patch/src/index.ts @@ -0,0 +1,548 @@ +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 { defaultErrorHandler } from './errors.js'; +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, + directiveRepeatableAdded, + directiveRepeatableRemoved, +} from './patches/directives.js'; +import { enumValueAdded, enumValueDescriptionChanged, enumValueRemoved } from './patches/enum.js'; +import { + fieldAdded, + fieldArgumentAdded, + fieldArgumentDefaultChanged, + fieldArgumentDescriptionChanged, + fieldArgumentRemoved, + fieldArgumentTypeChanged, + 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, PatchContext, 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. + * The schema is not validated in this function. That it is the responsibility of the caller. + */ +export function patchSchema( + schema: GraphQLSchema, + changes: Change[], + config?: Partial, +): 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, key) { + 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}[${key}]`); + } else { + pathArray.push(`@${node.name.value}[${key}]`); + } + // const path = pathArray.join('.'); + // nodesByCoordinate.set(path, node); + // @note skip setting the node for directives because repeat directives screw this up. + break; + } + case Kind.DOCUMENT: { + break; + } + case Kind.SCHEMA_EXTENSION: + case Kind.SCHEMA_DEFINITION: { + // @todo There can be only one. Replace `schemaNodes` with using `nodesByCoordinate.get('')`. + schemaNodes.push(node); + nodesByCoordinate.set('', 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: Partial = {}, +): DocumentNode { + const config: PatchConfig = { + onError: defaultErrorHandler, + debug: false, + ...patchConfig, + }; + const context: PatchContext = { + removedDirectiveNodes: [], + }; + + for (const change of changes) { + if (config.debug) { + debugPrintChange(change, nodesByCoordinate); + } + + switch (change.type) { + case ChangeType.SchemaMutationTypeChanged: { + schemaMutationTypeChanged(change, schemaNodes, config, context); + break; + } + case ChangeType.SchemaQueryTypeChanged: { + schemaQueryTypeChanged(change, schemaNodes, config, context); + break; + } + case ChangeType.SchemaSubscriptionTypeChanged: { + schemaSubscriptionTypeChanged(change, schemaNodes, config, context); + break; + } + case ChangeType.DirectiveAdded: { + directiveAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveRemoved: { + directiveRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveArgumentAdded: { + directiveArgumentAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveArgumentRemoved: { + directiveArgumentRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveLocationAdded: { + directiveLocationAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveLocationRemoved: { + directiveLocationRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.EnumValueAdded: { + enumValueAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldAdded: { + fieldAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldRemoved: { + fieldRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldTypeChanged: { + fieldTypeChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldArgumentAdded: { + fieldArgumentAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldArgumentTypeChanged: { + fieldArgumentTypeChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldArgumentRemoved: { + fieldArgumentRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldArgumentDescriptionChanged: { + fieldArgumentDescriptionChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldArgumentDefaultChanged: { + fieldArgumentDefaultChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldDescriptionAdded: { + fieldDescriptionAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldDescriptionChanged: { + fieldDescriptionChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.InputFieldAdded: { + inputFieldAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.InputFieldRemoved: { + inputFieldRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.InputFieldDescriptionAdded: { + inputFieldDescriptionAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.InputFieldTypeChanged: { + inputFieldTypeChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.InputFieldDescriptionChanged: { + inputFieldDescriptionChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.InputFieldDescriptionRemoved: { + inputFieldDescriptionRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.InputFieldDefaultValueChanged: { + inputFieldDefaultValueChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.ObjectTypeInterfaceAdded: { + objectTypeInterfaceAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.ObjectTypeInterfaceRemoved: { + objectTypeInterfaceRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.TypeDescriptionAdded: { + typeDescriptionAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.TypeDescriptionChanged: { + typeDescriptionChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.TypeDescriptionRemoved: { + typeDescriptionRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.TypeAdded: { + typeAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.UnionMemberAdded: { + unionMemberAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.UnionMemberRemoved: { + unionMemberRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.TypeRemoved: { + typeRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.EnumValueRemoved: { + enumValueRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.EnumValueDescriptionChanged: { + enumValueDescriptionChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.FieldDescriptionRemoved: { + fieldDescriptionRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveArgumentDefaultValueChanged: { + directiveArgumentDefaultValueChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveArgumentDescriptionChanged: { + directiveArgumentDescriptionChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveArgumentTypeChanged: { + directiveArgumentTypeChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveDescriptionChanged: { + directiveDescriptionChanged(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveRepeatableAdded: { + directiveRepeatableAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveRepeatableRemoved: { + directiveRepeatableRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageArgumentDefinitionAdded: { + directiveUsageArgumentDefinitionAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageArgumentDefinitionRemoved: { + directiveUsageArgumentDefinitionRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageEnumAdded: { + directiveUsageEnumAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageEnumRemoved: { + directiveUsageEnumRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageEnumValueAdded: { + directiveUsageEnumValueAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageEnumValueRemoved: { + directiveUsageEnumValueRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageFieldAdded: { + directiveUsageFieldAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageFieldDefinitionAdded: { + directiveUsageFieldDefinitionAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageFieldDefinitionRemoved: { + directiveUsageFieldDefinitionRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageFieldRemoved: { + directiveUsageFieldRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageInputFieldDefinitionAdded: { + directiveUsageInputFieldDefinitionAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageInputFieldDefinitionRemoved: { + directiveUsageInputFieldDefinitionRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageInputObjectAdded: { + directiveUsageInputObjectAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageInputObjectRemoved: { + directiveUsageInputObjectRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageInterfaceAdded: { + directiveUsageInterfaceAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageInterfaceRemoved: { + directiveUsageInterfaceRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageObjectAdded: { + directiveUsageObjectAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageObjectRemoved: { + directiveUsageObjectRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageScalarAdded: { + directiveUsageScalarAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageScalarRemoved: { + directiveUsageScalarRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageSchemaAdded: { + directiveUsageSchemaAdded(change, schemaNodes, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageSchemaRemoved: { + directiveUsageSchemaRemoved(change, schemaNodes, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageUnionMemberAdded: { + directiveUsageUnionMemberAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageUnionMemberRemoved: { + directiveUsageUnionMemberRemoved(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageArgumentAdded: { + directiveUsageArgumentAdded(change, nodesByCoordinate, config, context); + break; + } + case ChangeType.DirectiveUsageArgumentRemoved: { + directiveUsageArgumentRemoved(change, nodesByCoordinate, config, context); + break; + } + default: { + console.log(`${change.type} is not implemented yet.`); + } + } + } + + for (const node of context.removedDirectiveNodes) { + node.directives = node.directives?.filter(d => d != null); + } + + return { + kind: Kind.DOCUMENT, + // filter out the non-definition nodes (e.g. field definitions) + definitions: [ + ...schemaNodes, + ...Array.from(nodesByCoordinate.values()).filter(isDefinitionNode), + ], + }; +} + +/** This method wraps groupByCoordinateAST and patchCoordinatesAST for convenience. */ +export function patch( + ast: DocumentNode, + changes: Change[], + patchConfig?: Partial, +): 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..12b03d8168 --- /dev/null +++ b/packages/patch/src/patches/directive-usages.ts @@ -0,0 +1,597 @@ +import { ArgumentNode, ASTNode, DirectiveNode, Kind, parseValue, print, ValueNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, + ValueMismatchError, +} from '../errors.js'; +import { nameNode } from '../node-templates.js'; +import { PatchConfig, PatchContext, 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; + +/** + * Tried to find the correct instance of the directive if it's repeated. + * @note Should this should compare the arguments also to find the exact match if possible? + */ +function findNthDirective(directives: readonly DirectiveNode[], name: string, n: number) { + let lastDirective: DirectiveNode | undefined; + let count = 0; + for (const d of directives) { + // @note this nullish check is critical even though the types dont recognize it. + if (d?.name.value === name) { + lastDirective = d; + count += 1; + if (count === n) { + break; + } + } + } + return lastDirective; +} + +function directiveUsageDefinitionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError( + new ChangedCoordinateNotFoundError(Kind.DIRECTIVE, change.meta.addedDirectiveName), + change, + ); + return; + } + + const parentNode = nodeByPath.get(parentPath(change.path)) as + | { kind: Kind; directives?: DirectiveNode[] } + | undefined; + if (!parentNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedDirectiveName, + ), + change, + ); + return; + } + + const definition = nodeByPath.get(`@${change.meta.addedDirectiveName}`); + let repeatable = false; + if (!definition) { + console.warn( + `Patch cannot determine the repeatability of directive "@${change.meta.addedDirectiveName}" because it's missing a definition.`, + ); + } + if (definition?.kind === Kind.DIRECTIVE_DEFINITION) { + repeatable = definition.repeatable; + } + const directiveNode = findNthDirective( + parentNode?.directives ?? [], + change.meta.addedDirectiveName, + change.meta.directiveRepeatedTimes, + ); + if (!repeatable && directiveNode) { + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); + return; + } + + const newDirective: DirectiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(change.meta.addedDirectiveName), + }; + parentNode.directives = [...(parentNode.directives ?? []), newDirective]; +} + +function schemaDirectiveUsageDefinitionAdded( + change: Change, + schemaNodes: SchemaNode[], + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError( + new ChangedCoordinateNotFoundError(Kind.DIRECTIVE, change.meta.addedDirectiveName), + change, + ); + return; + } + const definition = nodeByPath.get(`@${change.meta.addedDirectiveName}`); + let repeatable = false; + if (!definition) { + console.warn(`Directive "@${change.meta.addedDirectiveName}" is missing a definition.`); + } + if (definition?.kind === Kind.DIRECTIVE_DEFINITION) { + repeatable = definition.repeatable; + } + + const directiveAlreadyExists = schemaNodes.some(schemaNode => + findNthDirective( + schemaNode.directives ?? [], + change.meta.addedDirectiveName, + change.meta.directiveRepeatedTimes, + ), + ); + if (!repeatable && directiveAlreadyExists) { + config.onError( + new AddedAttributeAlreadyExistsError( + change.path, + change.type, + 'directives', + change.meta.addedDirectiveName, + ), + change, + ); + return; + } + + const directiveNode: DirectiveNode = { + kind: Kind.DIRECTIVE, + name: nameNode(change.meta.addedDirectiveName), + }; + (schemaNodes[0].directives as DirectiveNode[] | undefined) = [ + ...(schemaNodes[0].directives ?? []), + directiveNode, + ]; +} + +function schemaDirectiveUsageDefinitionRemoved( + change: Change, + schemaNodes: SchemaNode[], + _nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + let deleted = false; + for (const node of schemaNodes) { + const directiveNode = findNthDirective( + node?.directives ?? [], + change.meta.removedDirectiveName, + change.meta.directiveRepeatedTimes, + ); + if (directiveNode) { + (node.directives as DirectiveNode[] | undefined) = node.directives?.filter( + d => d.name.value !== change.meta.removedDirectiveName, + ); + deleted = true; + break; + } + } + if (!deleted) { + config.onError( + new DeletedAttributeNotFoundError( + change.path ?? '', + change.type, + 'directives', + change.meta.removedDirectiveName, + ), + change, + ); + } +} + +function directiveUsageDefinitionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const parentNode = nodeByPath.get(parentPath(change.path)) as + | { kind: Kind; directives?: DirectiveNode[] } + | undefined; + if (!parentNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedDirectiveName, + ), + change, + ); + return; + } + + const directiveNode = findNthDirective( + parentNode?.directives ?? [], + change.meta.removedDirectiveName, + change.meta.directiveRepeatedTimes, + ); + if (!directiveNode) { + config.onError( + new DeletedAttributeNotFoundError( + change.path, + change.type, + 'directives', + change.meta.removedDirectiveName, + ), + change, + ); + return; + } + // null the value out for filtering later. The index is important so that changes reference + // the correct DirectiveNode. + // @note the nullish check is critical here even though the types dont show it + const removedIndex = (parentNode.directives ?? []).findIndex(d => d === directiveNode); + const directiveList = [...(parentNode.directives ?? [])]; + if (removedIndex !== -1) { + (directiveList[removedIndex] as any) = undefined; + } + parentNode.directives = directiveList; + context.removedDirectiveNodes.push(parentNode); +} + +export function directiveUsageArgumentDefinitionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageArgumentDefinitionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageEnumAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageEnumRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageEnumValueAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageEnumValueRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageFieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageFieldDefinitionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageFieldDefinitionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageFieldRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageInputFieldDefinitionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageInputFieldDefinitionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageInputObjectAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageInputObjectRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageInterfaceAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageInterfaceRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageObjectAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageObjectRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageScalarAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageScalarRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageSchemaAdded( + change: Change, + schemaDefs: SchemaNode[], + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return schemaDirectiveUsageDefinitionAdded(change, schemaDefs, nodeByPath, config, context); +} + +export function directiveUsageSchemaRemoved( + change: Change, + schemaDefs: SchemaNode[], + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return schemaDirectiveUsageDefinitionRemoved(change, schemaDefs, nodeByPath, config, context); +} + +export function directiveUsageUnionMemberAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionAdded(change, nodeByPath, config, context); +} + +export function directiveUsageUnionMemberRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + context: PatchContext, +) { + return directiveUsageDefinitionRemoved(change, nodeByPath, config, context); +} + +export function directiveUsageArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + // Must use double parentPath b/c the path is referencing the argument + const parentNode = nodeByPath.get(parentPath(parentPath(change.path))) as + | { kind: Kind; directives?: DirectiveNode[] } + | undefined; + const directiveNode = findNthDirective( + parentNode?.directives ?? [], + change.meta.directiveName, + change.meta.directiveRepeatedTimes, + ); + if (!directiveNode) { + config.onError( + new AddedAttributeCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedArgumentName, + ), + change, + ); + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), + change, + ); + return; + } + + const existing = findNamedNode(directiveNode.arguments, change.meta.addedArgumentName); + // "ArgumentAdded" but argument already exists. + if (existing) { + config.onError(new ValueMismatchError(directiveNode.kind, null, print(existing.value)), change); + (existing.value as ValueNode) = parseValue(change.meta.addedArgumentValue); + return; + } + + const argNode: ArgumentNode = { + kind: Kind.ARGUMENT, + name: nameNode(change.meta.addedArgumentName), + value: parseValue(change.meta.addedArgumentValue), + }; + (directiveNode.arguments as ArgumentNode[] | undefined) = [ + ...(directiveNode.arguments ?? []), + argNode, + ]; + nodeByPath.set(change.path, argNode); +} + +export function directiveUsageArgumentRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + // Must use double parentPath b/c the path is referencing the argument + const parentNode = nodeByPath.get(parentPath(parentPath(change.path))) as + | { kind: Kind; directives?: DirectiveNode[] } + | undefined; + + const directiveNode = findNthDirective( + parentNode?.directives ?? [], + change.meta.directiveName, + change.meta.directiveRepeatedTimes, + ); + if (!directiveNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedArgumentName, + ), + change, + ); + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE, directiveNode.kind), + change, + ); + return; + } + + const existing = findNamedNode(directiveNode.arguments, change.meta.removedArgumentName); + if (!existing) { + config.onError( + new DeletedAttributeNotFoundError( + change.path, + change.type, + 'arguments', + change.meta.removedArgumentName, + ), + change, + ); + } + + (directiveNode.arguments as ArgumentNode[] | undefined) = ( + directiveNode.arguments as ArgumentNode[] | undefined + )?.filter(a => a.name.value !== change.meta.removedArgumentName); +} diff --git a/packages/patch/src/patches/directives.ts b/packages/patch/src/patches/directives.ts new file mode 100644 index 0000000000..b52619e5a5 --- /dev/null +++ b/packages/patch/src/patches/directives.ts @@ -0,0 +1,597 @@ +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, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import { PatchConfig, PatchContext } from '../types.js'; +import { deleteNamedNode, findNamedNode, getDeletedNodeOfKind, parentPath } from '../utils.js'; + +export function directiveAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (change.path === undefined) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const changedNode = nodeByPath.get(change.path); + + if (!changedNode) { + const node: DirectiveDefinitionNode = { + kind: Kind.DIRECTIVE_DEFINITION, + name: nameNode(change.meta.addedDirectiveName), + repeatable: change.meta.addedDirectiveRepeatable, + locations: change.meta.addedDirectiveLocations.map(l => nameNode(l)), + description: change.meta.addedDirectiveDescription + ? stringNode(change.meta.addedDirectiveDescription) + : undefined, + }; + nodeByPath.set(change.path, node); + return; + } + + if (changedNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + change, + ); + return; + } + + // eslint-disable-next-line eqeqeq + if (change.meta.addedDirectiveRepeatable != changedNode.repeatable) { + config.onError( + new ValueMismatchError( + changedNode.kind, + `repeatable=${change.meta.addedDirectiveRepeatable}`, + `repeatable=${changedNode.repeatable}`, + ), + change, + ); + return; + } + + if ( + change.meta.addedDirectiveLocations.join('|') !== + changedNode.locations.map(l => l.value).join('|') + ) { + config.onError( + new ValueMismatchError( + changedNode.kind, + `locations=${change.meta.addedDirectiveLocations.join('|')}`, + `locations=${changedNode.locations.map(l => l.value).join('|')}`, + ), + change, + ); + return; + } + + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); +} + +export function directiveRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.DIRECTIVE_DEFINITION, config); + if (existing) { + nodeByPath.delete(change.path!); + } +} + +export function directiveArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const directiveNode = nodeByPath.get(change.path); + if (!directiveNode) { + config.onError( + new AddedAttributeCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedDirectiveArgumentName, + ), + change, + ); + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + change, + ); + return; + } + + const existingArg = findNamedNode( + directiveNode.arguments, + change.meta.addedDirectiveArgumentName, + ); + if (!existingArg) { + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedDirectiveArgumentName), + type: parseType(change.meta.addedDirectiveArgumentType), + }; + (directiveNode.arguments as InputValueDefinitionNode[] | undefined) = [ + ...(directiveNode.arguments ?? []), + node, + ]; + nodeByPath.set(`${change.path}.${change.meta.addedDirectiveArgumentName}`, node); + return; + } + + const existingType = print(existingArg.type); + if (existingType !== change.meta.addedDirectiveArgumentType) { + config.onError( + new ValueMismatchError( + existingArg.kind, + `type=${change.meta.addedDirectiveArgumentType}`, + `type=${existingType}`, + ), + change, + ); + } + + const existingDefaultValue = existingArg.defaultValue + ? print(existingArg.defaultValue) + : undefined; + if (change.meta.addedDirectiveDefaultValue !== existingDefaultValue) { + config.onError( + new ValueMismatchError( + existingArg.kind, + `defaultValue=${change.meta.addedDirectiveDefaultValue}`, + `defaultValue=${existingDefaultValue}`, + ), + change, + ); + } + + config.onError( + new AddedAttributeAlreadyExistsError( + change.path, + change.type, + 'arguments', + change.meta.addedDirectiveArgumentName, + ), + change, + ); +} + +export function directiveArgumentRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + const argNode = getDeletedNodeOfKind(change, nodeByPath, Kind.INPUT_VALUE_DEFINITION, config); + + if (argNode) { + const directiveNode = nodeByPath.get(parentPath(change.path)); + if (!directiveNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedDirectiveArgumentName, + ), + change, + ); + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + change, + ); + return; + } + + (directiveNode.arguments as ReadonlyArray | undefined) = + deleteNamedNode(directiveNode.arguments, change.meta.removedDirectiveArgumentName); + } +} + +export function directiveLocationAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const changedNode = nodeByPath.get(change.path); + if (!changedNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedDirectiveLocation, + ), + change, + ); + return; + } + + if (changedNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + change, + ); + return; + } + + if (changedNode.locations.some(l => l.value === change.meta.addedDirectiveLocation)) { + config.onError( + new AddedAttributeAlreadyExistsError( + change.path, + change.type, + 'locations', + change.meta.addedDirectiveLocation, + ), + change, + ); + return; + } + + (changedNode.locations as NameNode[]) = [ + ...changedNode.locations, + nameNode(change.meta.addedDirectiveLocation), + ]; +} + +export function directiveLocationRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const changedNode = nodeByPath.get(change.path); + if (!changedNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedDirectiveLocation, + ), + change, + ); + return; + } + + if (changedNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, changedNode.kind), + change, + ); + return; + } + + const existing = changedNode.locations.findIndex( + l => l.value === change.meta.removedDirectiveLocation, + ); + if (existing >= 0) { + (changedNode.locations as NameNode[]) = changedNode.locations.toSpliced(existing, 1); + } else { + config.onError( + new DeletedAttributeNotFoundError( + change.path, + change.type, + 'locations', + change.meta.removedDirectiveLocation, + ), + change, + ); + } +} + +export function directiveDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const directiveNode = nodeByPath.get(change.path); + if (!directiveNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDirectiveDescription, + ), + change, + ); + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + change, + ); + return; + } + + if ((directiveNode.description?.value ?? null) !== change.meta.oldDirectiveDescription) { + config.onError( + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveDescription, + directiveNode.description?.value, + ), + change, + ); + } + + (directiveNode.description as StringValueNode | undefined) = change.meta.newDirectiveDescription + ? stringNode(change.meta.newDirectiveDescription) + : undefined; +} + +export function directiveArgumentDefaultValueChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const argumentNode = nodeByPath.get(change.path); + if (!argumentNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDirectiveArgumentDefaultValue ?? null, + ), + change, + ); + return; + } + + if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + change, + ); + return; + } + + if ( + (argumentNode.defaultValue && print(argumentNode.defaultValue)) === + change.meta.oldDirectiveArgumentDefaultValue + ) { + (argumentNode.defaultValue as ValueNode | undefined) = change.meta + .newDirectiveArgumentDefaultValue + ? parseConstValue(change.meta.newDirectiveArgumentDefaultValue) + : undefined; + } else { + config.onError( + new ValueMismatchError( + Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDirectiveArgumentDefaultValue, + argumentNode.defaultValue && print(argumentNode.defaultValue), + ), + change, + ); + } +} + +export function directiveArgumentDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const argumentNode = nodeByPath.get(change.path); + if (!argumentNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDirectiveArgumentDescription, + ), + change, + ); + return; + } + + if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + change, + ); + return; + } + + if ((argumentNode.description?.value ?? null) !== change.meta.oldDirectiveArgumentDescription) { + config.onError( + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveArgumentDescription ?? undefined, + argumentNode.description?.value, + ), + change, + ); + } + (argumentNode.description as StringValueNode | undefined) = change.meta + .newDirectiveArgumentDescription + ? stringNode(change.meta.newDirectiveArgumentDescription) + : undefined; +} + +export function directiveArgumentTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const argumentNode = nodeByPath.get(change.path); + if (!argumentNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDirectiveArgumentType, + ), + change, + ); + return; + } + if (argumentNode.kind !== Kind.INPUT_VALUE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, argumentNode.kind), + change, + ); + return; + } + + if (print(argumentNode.type) !== change.meta.oldDirectiveArgumentType) { + config.onError( + new ValueMismatchError( + Kind.STRING, + change.meta.oldDirectiveArgumentType, + print(argumentNode.type), + ), + change, + ); + } + (argumentNode.type as TypeNode | undefined) = parseType(change.meta.newDirectiveArgumentType); +} + +export function directiveRepeatableAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const directiveNode = nodeByPath.get(change.path); + if (!directiveNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError(change.path, change.type, true), + change, + ); + return; + } + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + change, + ); + return; + } + + if (directiveNode.repeatable !== false) { + config.onError( + new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'false'), + change, + ); + } + + (directiveNode.repeatable as boolean) = true; +} + +export function directiveRepeatableRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const directiveNode = nodeByPath.get(change.path); + if (!directiveNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError(change.path, change.type, true), + change, + ); + return; + } + + if (directiveNode.kind !== Kind.DIRECTIVE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.DIRECTIVE_DEFINITION, directiveNode.kind), + change, + ); + return; + } + + if (directiveNode.repeatable !== true) { + config.onError( + new ValueMismatchError(Kind.BOOLEAN, String(directiveNode.repeatable), 'true'), + change, + ); + } + + (directiveNode.repeatable as boolean) = false; +} diff --git a/packages/patch/src/patches/enum.ts b/packages/patch/src/patches/enum.ts new file mode 100644 index 0000000000..2e2208c9d0 --- /dev/null +++ b/packages/patch/src/patches/enum.ts @@ -0,0 +1,184 @@ +import { ASTNode, EnumValueDefinitionNode, Kind, StringValueNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAttributeNotFoundError, + DeletedCoordinateNotFound, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig, PatchContext } from '../types'; +import { parentPath } from '../utils.js'; + +export function enumValueRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const enumNode = nodeByPath.get(parentPath(change.path)) as + | (ASTNode & { values?: EnumValueDefinitionNode[] }) + | undefined; + if (!enumNode) { + config.onError( + new DeletedCoordinateNotFound(Kind.ENUM_TYPE_DEFINITION, change.meta.removedEnumValueName), + change, + ); + return; + } + + if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), + change, + ); + return; + } + + if (enumNode.values === undefined || enumNode.values.length === 0) { + config.onError( + new DeletedAttributeNotFoundError( + Kind.ENUM_TYPE_DEFINITION, + 'values', + change.meta.removedEnumValueName, + ), + change, + ); + return; + } + + const beforeLength = enumNode.values.length; + enumNode.values = enumNode.values.filter(f => f.name.value !== change.meta.removedEnumValueName); + if (beforeLength === enumNode.values.length) { + config.onError( + new DeletedAttributeNotFoundError( + change.path, + change.type, + 'values', + change.meta.removedEnumValueName, + ), + change, + ); + return; + } + + // delete the reference to the removed field. + nodeByPath.delete(change.path); +} + +export function enumValueAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + const enumValuePath = change.path; + const enumNode = nodeByPath.get(parentPath(enumValuePath)) as + | (ASTNode & { values: EnumValueDefinitionNode[] }) + | undefined; + const changedNode = nodeByPath.get(enumValuePath); + if (!enumNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedEnumValueName, + ), + change, + ); + return; + } + + if (changedNode) { + config.onError( + new AddedAttributeAlreadyExistsError( + change.path, + change.type, + 'values', + change.meta.addedEnumValueName, + ), + change, + ); + return; + } + + if (enumNode.kind !== Kind.ENUM_TYPE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, enumNode.kind), + change, + ); + return; + } + + const c = change as Change; + const node: EnumValueDefinitionNode = { + kind: Kind.ENUM_VALUE_DEFINITION, + name: nameNode(c.meta.addedEnumValueName), + description: c.meta.addedDirectiveDescription + ? stringNode(c.meta.addedDirectiveDescription) + : undefined, + }; + (enumNode.values as EnumValueDefinitionNode[]) = [...(enumNode.values ?? []), node]; + nodeByPath.set(enumValuePath, node); +} + +export function enumValueDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const enumValueNode = nodeByPath.get(change.path); + if (!enumValueNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newEnumValueDescription, + ), + change, + ); + return; + } + + if (enumValueNode.kind !== Kind.ENUM_VALUE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.ENUM_VALUE_DEFINITION, enumValueNode.kind), + change, + ); + return; + } + + const oldValueMatches = + change.meta.oldEnumValueDescription === (enumValueNode.description?.value ?? null); + if (!oldValueMatches) { + config.onError( + new ValueMismatchError( + Kind.ENUM_TYPE_DEFINITION, + change.meta.oldEnumValueDescription, + enumValueNode.description?.value, + ), + change, + ); + } + (enumValueNode.description as StringValueNode | undefined) = change.meta.newEnumValueDescription + ? stringNode(change.meta.newEnumValueDescription) + : undefined; +} diff --git a/packages/patch/src/patches/fields.ts b/packages/patch/src/patches/fields.ts new file mode 100644 index 0000000000..fb7129b1e6 --- /dev/null +++ b/packages/patch/src/patches/fields.ts @@ -0,0 +1,387 @@ +import { + ASTNode, + ConstValueNode, + FieldDefinitionNode, + InputValueDefinitionNode, + Kind, + parseConstValue, + parseType, + print, + StringValueNode, + TypeNode, +} from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeCoordinateNotFoundError, + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, + DeletedCoordinateNotFound, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig, PatchContext } from '../types'; +import { + assertValueMatch, + getChangedNodeOfKind, + getDeletedNodeOfKind, + parentPath, +} from '../utils.js'; + +export function fieldTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + const node = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); + if (node) { + const currentReturnType = print(node.type); + if (change.meta.oldFieldType !== currentReturnType) { + config.onError( + new ValueMismatchError(Kind.FIELD_DEFINITION, change.meta.oldFieldType, currentReturnType), + change, + ); + } + (node.type as TypeNode) = parseType(change.meta.newFieldType); + } +} + +export function fieldRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const typeNode = nodeByPath.get(parentPath(change.path)) as + | (ASTNode & { fields?: FieldDefinitionNode[] }) + | undefined; + if (!typeNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedFieldName, + ), + change, + ); + return; + } + + const beforeLength = typeNode.fields?.length ?? 0; + typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); + if (beforeLength === (typeNode.fields?.length ?? 0)) { + config.onError( + new DeletedAttributeNotFoundError( + change.path, + change.type, + 'fields', + change.meta.removedFieldName, + ), + change, + ); + } else { + // delete the reference to the removed field. + nodeByPath.delete(change.path); + } +} + +export function fieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + const changedNode = nodeByPath.get(change.path); + if (changedNode) { + if (changedNode.kind === Kind.FIELD_DEFINITION) { + if (print(changedNode.type) === change.meta.addedFieldReturnType) { + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); + } else { + config.onError( + new ValueMismatchError( + Kind.FIELD_DEFINITION, + undefined, + change.meta.addedFieldReturnType, + ), + change, + ); + } + } else { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, changedNode.kind), + change, + ); + } + return; + } + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: FieldDefinitionNode[]; + }; + if (!typeNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedFieldName, + ), + change, + ); + return; + } + + if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.ENUM_TYPE_DEFINITION, typeNode.kind), + change, + ); + return; + } + const node: FieldDefinitionNode = { + kind: Kind.FIELD_DEFINITION, + name: nameNode(change.meta.addedFieldName), + type: parseType(change.meta.addedFieldReturnType), + // description: change.meta.addedFieldDescription + // ? stringNode(change.meta.addedFieldDescription) + // : undefined, + }; + + typeNode.fields = [...(typeNode.fields ?? []), node]; + + // add new field to the node set + nodeByPath.set(change.path, node); +} + +export function fieldArgumentAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const existing = nodeByPath.get(change.path); + if (existing) { + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); + return; + } + + const fieldNode = nodeByPath.get(parentPath(change.path!)) as ASTNode & { + arguments?: InputValueDefinitionNode[]; + }; + if (!fieldNode) { + config.onError( + new AddedAttributeCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedArgumentName, + ), + change, + ); + return; + } + if (fieldNode.kind !== Kind.FIELD_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + change, + ); + return; + } + const node: InputValueDefinitionNode = { + kind: Kind.INPUT_VALUE_DEFINITION, + name: nameNode(change.meta.addedArgumentName), + type: parseType(change.meta.addedArgumentType), + // description: change.meta.addedArgumentDescription + // ? stringNode(change.meta.addedArgumentDescription) + // : undefined, + }; + + fieldNode.arguments = [...(fieldNode.arguments ?? []), node]; + + // add new field to the node set + nodeByPath.set(change.path!, node); +} + +export function fieldArgumentTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + 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, + _context: PatchContext, +) { + 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, + _context: PatchContext, +) { + 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, + _context: PatchContext, +) { + const existing = getDeletedNodeOfKind(change, nodeByPath, Kind.ARGUMENT, config); + if (!existing) { + config.onError(new DeletedCoordinateNotFound(change.path ?? '', change.type), change); + return; + } + + const fieldNode = nodeByPath.get(parentPath(change.path!)) as ASTNode & { + arguments?: InputValueDefinitionNode[]; + }; + if (!fieldNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path!, // asserted by "getDeletedNodeOfKind" + change.type, + change.meta.removedFieldArgumentName, + ), + change, + ); + return; + } + if (fieldNode.kind !== Kind.FIELD_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + change, + ); + } + fieldNode.arguments = fieldNode.arguments?.filter( + a => a.name.value === change.meta.removedFieldArgumentName, + ); + + // add new field to the node set + nodeByPath.delete(change.path!); +} + +export function fieldDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + 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, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const fieldNode = nodeByPath.get(change.path); + if (!fieldNode) { + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); + return; + } + if (fieldNode.kind !== Kind.FIELD_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.FIELD_DEFINITION, fieldNode.kind), + change, + ); + return; + } + + (fieldNode.description as StringValueNode | undefined) = undefined; +} + +export function fieldDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + const fieldNode = getChangedNodeOfKind(change, nodeByPath, Kind.FIELD_DEFINITION, config); + if (!fieldNode) { + return; + } + if (fieldNode.description?.value !== change.meta.oldDescription) { + config.onError( + new ValueMismatchError( + Kind.FIELD_DEFINITION, + change.meta.oldDescription, + fieldNode.description?.value, + ), + change, + ); + } + + (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..98d671a985 --- /dev/null +++ b/packages/patch/src/patches/inputs.ts @@ -0,0 +1,300 @@ +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, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig, PatchContext } from '../types.js'; +import { + assertValueMatch, + getChangedNodeOfKind, + getDeletedNodeOfKind, + parentPath, +} from '../utils.js'; + +export function inputFieldAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const existingNode = nodeByPath.get(change.path); + if (existingNode) { + if (existingNode.kind === Kind.INPUT_VALUE_DEFINITION) { + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); + } else { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + change, + ); + } + return; + } + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: InputValueDefinitionNode[]; + }; + if (!typeNode) { + config.onError( + new AddedAttributeCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedInputFieldName, + ), + change, + ); + return; + } + if (typeNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + change, + ); + return; + } + + 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); +} + +export function inputFieldRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const existingNode = nodeByPath.get(change.path); + if (!existingNode) { + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); + return; + } + + const typeNode = nodeByPath.get(parentPath(change.path)) as ASTNode & { + fields?: InputValueDefinitionNode[]; + }; + if (!typeNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedFieldName, + ), + change, + ); + return; + } + + if (typeNode.kind !== Kind.INPUT_OBJECT_TYPE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.INPUT_OBJECT_TYPE_DEFINITION, typeNode.kind), + change, + ); + return; + } + + typeNode.fields = typeNode.fields?.filter(f => f.name.value !== change.meta.removedFieldName); + + // add new field to the node set + nodeByPath.delete(change.path); +} + +export function inputFieldDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + const existingNode = nodeByPath.get(change.path); + if (!existingNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedInputFieldDescription, + ), + change, + ); + return; + } + if (existingNode.kind !== Kind.INPUT_VALUE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + change, + ); + return; + } + + (existingNode.description as StringValueNode | undefined) = stringNode( + change.meta.addedInputFieldDescription, + ); +} + +export function inputFieldTypeChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + 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, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + const existingNode = nodeByPath.get(change.path); + if (!existingNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newDefaultValue ?? null, + ), + change, + ); + return; + } + + if (existingNode.kind !== Kind.INPUT_VALUE_DEFINITION) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.INPUT_VALUE_DEFINITION, existingNode.kind), + change, + ); + return; + } + + const oldValueMatches = + (existingNode.defaultValue && print(existingNode.defaultValue)) === change.meta.oldDefaultValue; + if (!oldValueMatches) { + config.onError( + new ValueMismatchError( + existingNode.defaultValue?.kind ?? Kind.INPUT_VALUE_DEFINITION, + change.meta.oldDefaultValue, + existingNode.defaultValue && print(existingNode.defaultValue), + ), + change, + ); + } + (existingNode.defaultValue as ConstValueNode | undefined) = change.meta.newDefaultValue + ? parseConstValue(change.meta.newDefaultValue) + : undefined; +} + +export function inputFieldDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + const existingNode = getChangedNodeOfKind( + change, + nodeByPath, + Kind.INPUT_VALUE_DEFINITION, + config, + ); + if (!existingNode) { + return; + } + if (existingNode.description?.value !== change.meta.oldInputFieldDescription) { + config.onError( + new ValueMismatchError( + Kind.STRING, + change.meta.oldInputFieldDescription, + existingNode.description?.value, + ), + change, + ); + } + (existingNode.description as StringValueNode | undefined) = stringNode( + change.meta.newInputFieldDescription, + ); +} + +export function inputFieldDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + const existingNode = getDeletedNodeOfKind( + change, + nodeByPath, + Kind.INPUT_VALUE_DEFINITION, + config, + ); + if (!existingNode) { + return; + } + + if (existingNode.description === undefined) { + console.warn(`Cannot remove a description at ${change.path} because no description is set.`); + } else if (existingNode.description.value !== change.meta.removedDescription) { + console.warn( + `Description at ${change.path} does not match expected description, but proceeding with description removal anyways.`, + ); + } + (existingNode.description as StringValueNode | undefined) = undefined; +} diff --git a/packages/patch/src/patches/interfaces.ts b/packages/patch/src/patches/interfaces.ts new file mode 100644 index 0000000000..2980992be1 --- /dev/null +++ b/packages/patch/src/patches/interfaces.ts @@ -0,0 +1,117 @@ +import { ASTNode, Kind, NamedTypeNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, +} from '../errors.js'; +import { namedTypeNode } from '../node-templates.js'; +import type { PatchConfig, PatchContext } from '../types'; +import { findNamedNode } from '../utils.js'; + +export function objectTypeInterfaceAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (!typeNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedInterfaceName, + ), + change, + ); + return; + } + + if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + config.onError( + new ChangedCoordinateKindMismatchError( + Kind.OBJECT_TYPE_DEFINITION, // or Kind.INTERFACE_TYPE_DEFINITION + typeNode.kind, + ), + change, + ); + return; + } + + const existing = findNamedNode(typeNode.interfaces, change.meta.addedInterfaceName); + if (existing) { + config.onError( + new AddedAttributeAlreadyExistsError( + change.path, + change.type, + 'interfaces', + change.meta.addedInterfaceName, + ), + change, + ); + return; + } + + (typeNode.interfaces as NamedTypeNode[] | undefined) = [ + ...(typeNode.interfaces ?? []), + namedTypeNode(change.meta.addedInterfaceName), + ]; +} + +export function objectTypeInterfaceRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (!typeNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedInterfaceName, + ), + change, + ); + return; + } + + if ( + typeNode.kind !== Kind.OBJECT_TYPE_DEFINITION && + typeNode.kind !== Kind.INTERFACE_TYPE_DEFINITION + ) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + change, + ); + return; + } + + const existing = findNamedNode(typeNode.interfaces, change.meta.removedInterfaceName); + if (!existing) { + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); + return; + } + + (typeNode.interfaces as NamedTypeNode[] | undefined) = typeNode.interfaces?.filter( + i => i.name.value !== change.meta.removedInterfaceName, + ); +} diff --git a/packages/patch/src/patches/schema.ts b/packages/patch/src/patches/schema.ts new file mode 100644 index 0000000000..c24572c249 --- /dev/null +++ b/packages/patch/src/patches/schema.ts @@ -0,0 +1,146 @@ +/* eslint-disable unicorn/no-negated-condition */ +import { Kind, NameNode, OperationTypeDefinitionNode, OperationTypeNode } from 'graphql'; +import type { Change, ChangeType } from '@graphql-inspector/core'; +import { ValueMismatchError } from '../errors.js'; +import { nameNode } from '../node-templates.js'; +import { PatchConfig, PatchContext, SchemaNode } from '../types.js'; + +export function schemaMutationTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, + _context: PatchContext, +) { + for (const schemaNode of schemaNodes) { + const mutation = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!mutation) { + if (change.meta.oldMutationTypeName !== 'unknown') { + config.onError( + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldMutationTypeName, + 'unknown', + ), + change, + ); + } + (schemaNode.operationTypes as OperationTypeDefinitionNode[]) = [ + ...(schemaNode.operationTypes ?? []), + { + kind: Kind.OPERATION_TYPE_DEFINITION, + operation: OperationTypeNode.MUTATION, + type: { + kind: Kind.NAMED_TYPE, + name: nameNode(change.meta.newMutationTypeName), + }, + }, + ]; + } else { + if (mutation.type.name.value !== change.meta.oldMutationTypeName) { + config.onError( + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldMutationTypeName, + mutation?.type.name.value, + ), + change, + ); + } + (mutation.type.name as NameNode) = nameNode(change.meta.newMutationTypeName); + } + } +} + +export function schemaQueryTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, + _context: PatchContext, +) { + for (const schemaNode of schemaNodes) { + const query = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.MUTATION, + ); + if (!query) { + if (change.meta.oldQueryTypeName !== 'unknown') { + config.onError( + new ValueMismatchError(Kind.SCHEMA_DEFINITION, change.meta.oldQueryTypeName, 'unknown'), + change, + ); + } + (schemaNode.operationTypes as OperationTypeDefinitionNode[]) = [ + ...(schemaNode.operationTypes ?? []), + { + kind: Kind.OPERATION_TYPE_DEFINITION, + operation: OperationTypeNode.QUERY, + type: { + kind: Kind.NAMED_TYPE, + name: nameNode(change.meta.newQueryTypeName), + }, + }, + ]; + } else { + if (query.type.name.value !== change.meta.oldQueryTypeName) { + config.onError( + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldQueryTypeName, + query?.type.name.value, + ), + change, + ); + } + (query.type.name as NameNode) = nameNode(change.meta.newQueryTypeName); + } + } +} + +export function schemaSubscriptionTypeChanged( + change: Change, + schemaNodes: SchemaNode[], + config: PatchConfig, + _context: PatchContext, +) { + for (const schemaNode of schemaNodes) { + const sub = schemaNode.operationTypes?.find( + ({ operation }) => operation === OperationTypeNode.SUBSCRIPTION, + ); + if (!sub) { + if (change.meta.oldSubscriptionTypeName !== 'unknown') { + config.onError( + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldSubscriptionTypeName, + 'unknown', + ), + change, + ); + } + (schemaNode.operationTypes as OperationTypeDefinitionNode[]) = [ + ...(schemaNode.operationTypes ?? []), + { + kind: Kind.OPERATION_TYPE_DEFINITION, + operation: OperationTypeNode.QUERY, + type: { + kind: Kind.NAMED_TYPE, + name: nameNode(change.meta.newSubscriptionTypeName), + }, + }, + ]; + } else { + if (sub.type.name.value !== change.meta.oldSubscriptionTypeName) { + config.onError( + new ValueMismatchError( + Kind.SCHEMA_DEFINITION, + change.meta.oldSubscriptionTypeName, + sub?.type.name.value, + ), + change, + ); + } + (sub.type.name as NameNode) = nameNode(change.meta.newSubscriptionTypeName); + } + } +} diff --git a/packages/patch/src/patches/types.ts b/packages/patch/src/patches/types.ts new file mode 100644 index 0000000000..7fb2044f41 --- /dev/null +++ b/packages/patch/src/patches/types.ts @@ -0,0 +1,193 @@ +import { ASTNode, isTypeDefinitionNode, Kind, StringValueNode, TypeDefinitionNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedCoordinateAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangedCoordinateKindMismatchError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedCoordinateNotFound, + ValueMismatchError, +} from '../errors.js'; +import { nameNode, stringNode } from '../node-templates.js'; +import type { PatchConfig, PatchContext } from '../types'; + +export function typeAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const existing = nodeByPath.get(change.path); + if (existing) { + config.onError(new AddedCoordinateAlreadyExistsError(change.path, change.type), change); + return; + } + 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, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const removedNode = nodeByPath.get(change.path); + if (!removedNode) { + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); + return; + } + + if (!isTypeDefinitionNode(removedNode)) { + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); + return; + } + + // delete the reference to the removed field. + for (const key of nodeByPath.keys()) { + if (key.startsWith(change.path)) { + nodeByPath.delete(key); + } + } +} + +export function typeDescriptionAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (!typeNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedTypeDescription, + ), + change, + ); + return; + } + if (!isTypeDefinitionNode(typeNode)) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + change, + ); + return; + } + + (typeNode.description as StringValueNode | undefined) = change.meta.addedTypeDescription + ? stringNode(change.meta.addedTypeDescription) + : undefined; +} + +export function typeDescriptionChanged( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (!typeNode) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.newTypeDescription, + ), + change, + ); + return; + } + + if (!isTypeDefinitionNode(typeNode)) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + change, + ); + return; + } + if (typeNode.description?.value !== change.meta.oldTypeDescription) { + config.onError( + new ValueMismatchError( + Kind.STRING, + change.meta.oldTypeDescription, + typeNode.description?.value, + ), + change, + ); + } + (typeNode.description as StringValueNode | undefined) = stringNode( + change.meta.newTypeDescription, + ); +} + +export function typeDescriptionRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const typeNode = nodeByPath.get(change.path); + if (!typeNode) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedTypeDescription, + ), + change, + ); + return; + } + + if (!isTypeDefinitionNode(typeNode)) { + config.onError( + new ChangedCoordinateKindMismatchError(Kind.OBJECT_TYPE_DEFINITION, typeNode.kind), + change, + ); + return; + } + + if (typeNode.description?.value !== change.meta.removedTypeDescription) { + config.onError( + new ValueMismatchError( + Kind.STRING, + change.meta.removedTypeDescription, + typeNode.description?.value, + ), + change, + ); + } + (typeNode.description as StringValueNode | undefined) = undefined; +} diff --git a/packages/patch/src/patches/unions.ts b/packages/patch/src/patches/unions.ts new file mode 100644 index 0000000000..d02392f404 --- /dev/null +++ b/packages/patch/src/patches/unions.ts @@ -0,0 +1,94 @@ +import { ASTNode, NamedTypeNode } from 'graphql'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + AddedAttributeAlreadyExistsError, + ChangedAncestorCoordinateNotFoundError, + ChangePathMissingError, + DeletedAncestorCoordinateNotFoundError, + DeletedAttributeNotFoundError, +} from '../errors.js'; +import { namedTypeNode } from '../node-templates.js'; +import { PatchConfig, PatchContext } from '../types.js'; +import { findNamedNode, parentPath } from '../utils.js'; + +export function unionMemberAdded( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + const union = nodeByPath.get(parentPath(change.path)) as + | (ASTNode & { types?: NamedTypeNode[] }) + | undefined; + if (!union) { + config.onError( + new ChangedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.addedUnionMemberTypeName, + ), + change, + ); + return; + } + + if (findNamedNode(union.types, change.meta.addedUnionMemberTypeName)) { + config.onError( + new AddedAttributeAlreadyExistsError( + change.path, + change.type, + 'types', + change.meta.addedUnionMemberTypeName, + ), + change, + ); + return; + } + + union.types = [...(union.types ?? []), namedTypeNode(change.meta.addedUnionMemberTypeName)]; +} + +export function unionMemberRemoved( + change: Change, + nodeByPath: Map, + config: PatchConfig, + _context: PatchContext, +) { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + const union = nodeByPath.get(parentPath(change.path)) as + | (ASTNode & { types?: NamedTypeNode[] }) + | undefined; + if (!union) { + config.onError( + new DeletedAncestorCoordinateNotFoundError( + change.path, + change.type, + change.meta.removedUnionMemberTypeName, + ), + change, + ); + return; + } + + if (!findNamedNode(union.types, change.meta.removedUnionMemberTypeName)) { + config.onError( + new DeletedAttributeNotFoundError( + change.path, + change.type, + 'types', + change.meta.removedUnionMemberTypeName, + ), + change, + ); + return; + } + + union.types = union.types!.filter(t => t.name.value !== change.meta.removedUnionMemberTypeName); +} diff --git a/packages/patch/src/types.ts b/packages/patch/src/types.ts new file mode 100644 index 0000000000..a8754b6cbf --- /dev/null +++ b/packages/patch/src/types.ts @@ -0,0 +1,53 @@ +import type { DirectiveNode, 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 = { + /** + * 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. + * + * To halt patching, throw the error inside the handler. + * @param err The raised error + * @returns void + */ + onError: (err: Error, change: Change) => void; + + /** + * Enables debug logging + */ + debug: boolean; +}; + +export type PatchContext = { + /** + * tracks which nodes have have their directives removed so that patch can + * go back and filter out the null records in the lists. + */ + removedDirectiveNodes: Array<{ directives?: DirectiveNode[] }>; +}; + +export type ErrorHandler = (err: Error, change: Change) => void; diff --git a/packages/patch/src/utils.ts b/packages/patch/src/utils.ts new file mode 100644 index 0000000000..77a652775c --- /dev/null +++ b/packages/patch/src/utils.ts @@ -0,0 +1,149 @@ +import { ASTKindToNode, ASTNode, Kind, NameNode } from 'graphql'; +import { Maybe } from 'graphql/jsutils/Maybe'; +import { Change, ChangeType } from '@graphql-inspector/core'; +import { + ChangedCoordinateKindMismatchError, + ChangedCoordinateNotFoundError, + ChangePathMissingError, + DeletedCoordinateNotFound, + ValueMismatchError, +} from './errors.js'; +import { AdditionChangeType, PatchConfig } from './types.js'; + +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 function assertValueMatch( + change: Change, + expectedKind: Kind, + expected: string | undefined, + actual: string | undefined, + config: PatchConfig, +) { + if (expected !== actual) { + config.onError(new ValueMismatchError(expectedKind, expected, actual), change); + } +} + +/** + * 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 (kind === Kind.DIRECTIVE) { + throw new Error('Directives cannot be found using this method.'); + } + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + + const existing = nodeByPath.get(change.path); + if (!existing) { + config.onError(new ChangedCoordinateNotFoundError(kind, undefined), change); + return; + } + if (existing.kind !== kind) { + config.onError(new ChangedCoordinateKindMismatchError(kind, existing.kind), change); + } + return existing as ASTKindToNode[K]; +} + +export function getDeletedNodeOfKind( + change: Change, + nodeByPath: Map, + kind: K, + config: PatchConfig, +): ASTKindToNode[K] | void { + if (!change.path) { + config.onError(new ChangePathMissingError(change), change); + return; + } + const existing = nodeByPath.get(change.path); + if (!existing) { + config.onError(new DeletedCoordinateNotFound(change.path, change.type), change); + return; + } + if (existing.kind !== kind) { + config.onError(new ChangedCoordinateKindMismatchError(kind, existing.kind), change); + return; + } + return existing as ASTKindToNode[K]; +} 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', },