Skip to content

Diff returns all nested changes for additions #2893

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/seven-jars-yell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@graphql-inspector/core': major
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could be convinced that this is a minor patch because the changes to the output's format doesnt break anything. But the actual content of the changes has changed drastically which is why I thought we should be safe and declare a major change.

---

"diff" includes all nested changes when a node is added. Some change types have had additional meta fields added.
On deprecation add with a reason, a separate "fieldDeprecationReasonAdded" change is no longer included.
23 changes: 23 additions & 0 deletions packages/core/__tests__/diff/directive-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,29 @@ describe('directive-usage', () => {
expect(change.message).toEqual("Directive 'external' was added to field 'Query.a'");
});

test('added directive on added field', async () => {
const a = buildSchema(/* GraphQL */ `
type Query {
_: String
}
`);
const b = buildSchema(/* GraphQL */ `
directive @external on FIELD_DEFINITION
type Query {
_: String
a: String @external
}
`);

const changes = await diff(a, b);
const change = findFirstChangeByPath(changes, 'Query.a.external');

expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.type).toEqual('DIRECTIVE_USAGE_FIELD_DEFINITION_ADDED');
expect(change.message).toEqual("Directive 'external' was added to field 'Query.a'");
});

test('removed directive', async () => {
const a = buildSchema(/* GraphQL */ `
directive @external on FIELD_DEFINITION
Expand Down
48 changes: 48 additions & 0 deletions packages/core/__tests__/diff/enum.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,54 @@ import { CriticalityLevel, diff, DiffRule } from '../../src/index.js';
import { findFirstChangeByPath } from '../../utils/testing.js';

describe('enum', () => {
test('added', async () => {
const a = buildSchema(/* GraphQL */ `
type Query {
fieldA: String
}
`);

const b = buildSchema(/* GraphQL */ `
type Query {
fieldA: String
}

enum enumA {
"""
A is the first letter in the alphabet
"""
A
B
}
`);

const changes = await diff(a, b);
expect(changes.length).toEqual(4);

{
const change = findFirstChangeByPath(changes, 'enumA');
expect(change.meta).toMatchObject({
addedTypeKind: 'EnumTypeDefinition',
addedTypeName: 'enumA',
});
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.criticality.reason).not.toBeDefined();
expect(change.message).toEqual(`Type 'enumA' was added`);
}

{
const change = findFirstChangeByPath(changes, 'enumA.A');
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test shows that enum additions also contain all nested changes within that enum, and that those changes are flagged as non-breaking.

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 {
Expand Down
57 changes: 56 additions & 1 deletion packages/core/__tests__/diff/input.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -38,6 +38,61 @@ describe('input', () => {
"Input field 'd' of type 'String' was added to input object type 'Foo'",
);
});

test('added with a default value', async () => {
const a = buildSchema(/* GraphQL */ `
input Foo {
a: String!
}
`);
const b = buildSchema(/* GraphQL */ `
input Foo {
a: String!
b: String! = "B"
}
`);

const change = findFirstChangeByPath(await diff(a, b), 'Foo.b');
expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous);
expect(change.type).toEqual('INPUT_FIELD_ADDED');
expect(change.meta).toMatchObject({
addedFieldDefault: '"B"',
addedInputFieldName: 'b',
addedInputFieldType: 'String!',
addedToNewType: false,
inputName: 'Foo',
isAddedInputFieldTypeNullable: false,
});
expect(change.message).toEqual(
`Input field 'b' of type 'String!' with default value '"B"' was added to input object type 'Foo'`,
);
});

test('added to an added input', async () => {
const a = buildSchema(/* GraphQL */ `
type Query {
_: String
}
`);
const b = buildSchema(/* GraphQL */ `
type Query {
_: String
}

input Foo {
a: String!
}
`);

const change = findFirstChangeByPath(await diff(a, b), 'Foo.a');

expect(change.type).toEqual('INPUT_FIELD_ADDED');
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.message).toEqual(
"Input field 'a' of type 'String!' was added to input object type 'Foo'",
);
});

test('removed', async () => {
const a = buildSchema(/* GraphQL */ `
input Foo {
Expand Down
44 changes: 36 additions & 8 deletions packages/core/__tests__/diff/interface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,24 +169,24 @@ describe('interface', () => {
const changes = await diff(a, b);
const change = {
a: findFirstChangeByPath(changes, 'Foo.a'),
b: findChangesByPath(changes, 'Foo.b')[1],
c: findChangesByPath(changes, 'Foo.c')[1],
b: findFirstChangeByPath(changes, 'Foo.b'),
c: findFirstChangeByPath(changes, 'Foo.c'),
};

// Changed
expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.a.type).toEqual('FIELD_DEPRECATION_REASON_CHANGED');
expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.a.message).toEqual(
"Deprecation reason on field 'Foo.a' has changed from 'OLD' to 'NEW'",
);
// Removed
expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.b.type).toEqual('FIELD_DEPRECATION_REASON_REMOVED');
expect(change.b.message).toEqual("Deprecation reason was removed from field 'Foo.b'");
expect(change.b.type).toEqual('FIELD_DEPRECATION_REMOVED');
expect(change.b.criticality.level).toEqual(CriticalityLevel.Dangerous);
expect(change.b.message).toEqual("Field 'Foo.b' is no longer deprecated");
// Added
expect(change.c.type).toEqual('FIELD_DEPRECATION_ADDED');
expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.c.type).toEqual('FIELD_DEPRECATION_REASON_ADDED');
expect(change.c.message).toEqual("Field 'Foo.c' has deprecation reason 'CCC'");
expect(change.c.message).toEqual("Field 'Foo.c' is deprecated");
});

test('deprecation added / removed', async () => {
Expand Down Expand Up @@ -219,4 +219,32 @@ describe('interface', () => {
expect(change.b.message).toEqual("Field 'Foo.b' is deprecated");
});
});

test('deprecation added w/reason', async () => {
const a = buildSchema(/* GraphQL */ `
interface Foo {
a: String!
}
`);
const b = buildSchema(/* GraphQL */ `
interface Foo {
a: String! @deprecated(reason: "A is the first letter.")
}
`);

const changes = await diff(a, b);

expect(findChangesByPath(changes, 'Foo.a')).toHaveLength(1);
const change = findFirstChangeByPath(changes, 'Foo.a');

// added
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.type).toEqual('FIELD_DEPRECATION_ADDED');
expect(change.message).toEqual("Field 'Foo.a' is deprecated");
expect(change.meta).toMatchObject({
deprecationReason: 'A is the first letter.',
fieldName: 'a',
typeName: 'Foo',
});
});
});
70 changes: 55 additions & 15 deletions packages/core/__tests__/diff/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,18 @@ describe('object', () => {
}
`);

const change = findFirstChangeByPath(await diff(a, b), 'B');
const mutation = findFirstChangeByPath(await diff(a, b), 'Mutation');
const changes = await diff(a, b);
expect(changes).toHaveLength(4);

const change = findFirstChangeByPath(changes, 'B');
const mutation = findFirstChangeByPath(changes, 'Mutation');

expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(mutation.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.meta).toMatchObject({
addedTypeKind: 'ObjectTypeDefinition',
addedTypeName: 'B',
});
});

describe('interfaces', () => {
Expand Down Expand Up @@ -63,7 +70,8 @@ describe('object', () => {
b: String!
}

interface C {
interface C implements B {
b: String!
c: String!
}

Expand All @@ -74,11 +82,43 @@ describe('object', () => {
}
`);

const change = findFirstChangeByPath(await diff(a, b), 'Foo');
const changes = await diff(a, b);

{
const change = findFirstChangeByPath(changes, 'Foo');
expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous);
expect(change.type).toEqual('OBJECT_TYPE_INTERFACE_ADDED');
expect(change.message).toEqual("'Foo' object implements 'C' interface");
expect(change.meta).toMatchObject({
addedInterfaceName: 'C',
objectTypeName: 'Foo',
});
}

const cChanges = findChangesByPath(changes, 'C');
expect(cChanges).toHaveLength(2);
{
const change = cChanges[0];
expect(change.type).toEqual('TYPE_ADDED');
expect(change.meta).toMatchObject({
addedTypeKind: 'InterfaceTypeDefinition',
addedTypeName: 'C',
});
}

{
const change = cChanges[1];
expect(change.type).toEqual('OBJECT_TYPE_INTERFACE_ADDED');
expect(change.meta).toMatchObject({
addedInterfaceName: 'B',
objectTypeName: 'C',
});
}

expect(change.criticality.level).toEqual(CriticalityLevel.Dangerous);
expect(change.type).toEqual('OBJECT_TYPE_INTERFACE_ADDED');
expect(change.message).toEqual("'Foo' object implements 'C' interface");
{
const change = findFirstChangeByPath(changes, 'C.b');
expect(change.criticality.level).toEqual(CriticalityLevel.NonBreaking);
}
});

test('removed', async () => {
Expand Down Expand Up @@ -290,24 +330,24 @@ describe('object', () => {
const changes = await diff(a, b);
const change = {
a: findFirstChangeByPath(changes, 'Foo.a'),
b: findChangesByPath(changes, 'Foo.b')[1],
c: findChangesByPath(changes, 'Foo.c')[1],
b: findFirstChangeByPath(changes, 'Foo.b'),
c: findFirstChangeByPath(changes, 'Foo.c'),
};

// Changed
expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.a.type).toEqual('FIELD_DEPRECATION_REASON_CHANGED');
expect(change.a.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.a.message).toEqual(
"Deprecation reason on field 'Foo.a' has changed from 'OLD' to 'NEW'",
);
// Removed
expect(change.b.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.b.type).toEqual('FIELD_DEPRECATION_REASON_REMOVED');
expect(change.b.message).toEqual("Deprecation reason was removed from field 'Foo.b'");
expect(change.b.type).toEqual('FIELD_DEPRECATION_REMOVED');
expect(change.b.criticality.level).toEqual(CriticalityLevel.Dangerous);
expect(change.b.message).toEqual("Field 'Foo.b' is no longer deprecated");
// Added
expect(change.c.type).toEqual('FIELD_DEPRECATION_ADDED');
expect(change.c.criticality.level).toEqual(CriticalityLevel.NonBreaking);
expect(change.c.type).toEqual('FIELD_DEPRECATION_REASON_ADDED');
expect(change.c.message).toEqual("Field 'Foo.c' has deprecation reason 'CCC'");
expect(change.c.message).toEqual("Field 'Foo.c' is deprecated");
});

test('deprecation added / removed', async () => {
Expand Down
5 changes: 3 additions & 2 deletions packages/core/__tests__/diff/schema.test.ts
Original file line number Diff line number Diff line change
@@ -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 */ `
Expand Down Expand Up @@ -820,9 +821,9 @@ test('adding root type should not be breaking', async () => {
`);

const changes = await diff(schemaA, schemaB);
const subscription = changes[0];
expect(changes).toHaveLength(2);

expect(changes).toHaveLength(1);
const subscription = findFirstChangeByPath(changes, 'Subscription');
expect(subscription).toBeDefined();
expect(subscription!.criticality.level).toEqual(CriticalityLevel.NonBreaking);
});
Loading