Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/twelve-ties-pretend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@graphql-inspector/core': minor
---

diff can be passed null schemas. This lets it output the full list of additions on the new schema.
160 changes: 158 additions & 2 deletions packages/core/__tests__/diff/schema.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
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 @@ -803,3 +801,161 @@ test('adding root type should not be breaking', async () => {
]
`);
});

test('null old schema', async () => {
const schemaA = null;

const schemaB = buildSchema(/* GraphQL */ `
type Query {
foo: String
}

type Subscription {
onFoo: String
}
`);

const changes = await diff(schemaA, schemaB);
expect(changes).toMatchInlineSnapshot(`
[
{
"criticality": {
"level": "NON_BREAKING",
},
"message": "Schema query root has changed from 'unknown' to 'Query'",
"meta": {
"newQueryTypeName": "Query",
"oldQueryTypeName": "unknown",
Copy link
Collaborator

@n1ru4l n1ru4l Nov 21, 2025

Choose a reason for hiding this comment

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

Isn't unknown also a legit name that the root Query type could be named after? Maybe it should be null and not a string instead.

type unknown {
  foo: Int
}

schema {
  query: unknown
}

},
"type": "SCHEMA_QUERY_TYPE_CHANGED",
},
{
"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 'Query' was added",
"meta": {
"addedTypeKind": "ObjectTypeDefinition",
"addedTypeName": "Query",
},
"path": "Query",
"type": "TYPE_ADDED",
},
{
"criticality": {
"level": "NON_BREAKING",
},
"message": "Field 'foo' was added to object type 'Query'",
"meta": {
"addedFieldName": "foo",
"addedFieldReturnType": "String",
"typeName": "Query",
"typeType": "object type",
},
"path": "Query.foo",
"type": "FIELD_ADDED",
},
{
"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",
},
]
`);
});

test('null new schema', async () => {
const schemaA = buildSchema(/* GraphQL */ `
type Query {
foo: String
}

type Subscription {
onFoo: String
}
`);

const schemaB = null;

const changes = await diff(schemaA, schemaB);
expect(changes).toMatchInlineSnapshot(`
[
{
"criticality": {
"level": "BREAKING",
},
"message": "Schema query root has changed from 'Query' to 'unknown'",
"meta": {
"newQueryTypeName": "unknown",
"oldQueryTypeName": "Query",
},
"type": "SCHEMA_QUERY_TYPE_CHANGED",
},
{
"criticality": {
"level": "BREAKING",
},
"message": "Schema subscription root has changed from 'Subscription' to 'unknown'",
"meta": {
"newSubscriptionTypeName": "unknown",
"oldSubscriptionTypeName": "Subscription",
},
"type": "SCHEMA_SUBSCRIPTION_TYPE_CHANGED",
},
{
"criticality": {
"level": "BREAKING",
},
"message": "Type 'Query' was removed",
"meta": {
"removedTypeName": "Query",
},
"path": "Query",
"type": "TYPE_REMOVED",
},
{
"criticality": {
"level": "BREAKING",
},
"message": "Type 'Subscription' was removed",
"meta": {
"removedTypeName": "Subscription",
},
"path": "Subscription",
"type": "TYPE_REMOVED",
},
]
`);
});
10 changes: 5 additions & 5 deletions packages/core/src/diff/changes/directive-usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ type KindToPayload = {
change: DirectiveUsageEnumValueAddedChange | DirectiveUsageEnumValueRemovedChange;
};
[Kind.SCHEMA_DEFINITION]: {
input: GraphQLSchema;
input: GraphQLSchema | null;
change: DirectiveUsageSchemaAddedChange | DirectiveUsageSchemaRemovedChange;
};
[Kind.SCALAR_TYPE_DEFINITION]: {
Expand Down Expand Up @@ -836,9 +836,9 @@ export function directiveUsageAdded<K extends keyof KindToPayload>(
type: ChangeType.DirectiveUsageSchemaAdded,
meta: {
addedDirectiveName: directive.name.value,
schemaTypeName: payload.getQueryType()?.name || '',
schemaTypeName: payload?.getQueryType()?.name || '',
addedToNewType,
directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive),
directiveRepeatedTimes: directiveRepeatTimes(payload?.astNode?.directives ?? [], directive),
},
});
}
Expand Down Expand Up @@ -1016,8 +1016,8 @@ export function directiveUsageRemoved<K extends keyof KindToPayload>(
type: ChangeType.DirectiveUsageSchemaRemoved,
meta: {
removedDirectiveName: directive.name.value,
schemaTypeName: payload.getQueryType()?.name || '',
directiveRepeatedTimes: directiveRepeatTimes(payload.astNode?.directives ?? [], directive),
schemaTypeName: payload?.getQueryType()?.name || '',
directiveRepeatedTimes: directiveRepeatTimes(payload?.astNode?.directives ?? [], directive),
},
});
}
Expand Down
24 changes: 12 additions & 12 deletions packages/core/src/diff/changes/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ export function schemaQueryTypeChangedFromMeta(args: SchemaQueryTypeChangedChang
}

export function schemaQueryTypeChanged(
oldSchema: GraphQLSchema,
newSchema: GraphQLSchema,
oldSchema: GraphQLSchema | null,
newSchema: GraphQLSchema | null,
): Change<typeof ChangeType.SchemaQueryTypeChanged> {
const oldName = (oldSchema.getQueryType() || ({} as any)).name || 'unknown';
const newName = (newSchema.getQueryType() || ({} as any)).name || 'unknown';
const oldName = (oldSchema?.getQueryType() || ({} as any)).name || 'unknown';
const newName = (newSchema?.getQueryType() || ({} as any)).name || 'unknown';

return schemaQueryTypeChangedFromMeta({
type: ChangeType.SchemaQueryTypeChanged,
Expand Down Expand Up @@ -63,11 +63,11 @@ export function schemaMutationTypeChangedFromMeta(args: SchemaMutationTypeChange
}

export function schemaMutationTypeChanged(
oldSchema: GraphQLSchema,
newSchema: GraphQLSchema,
oldSchema: GraphQLSchema | null,
newSchema: GraphQLSchema | null,
): Change<typeof ChangeType.SchemaMutationTypeChanged> {
const oldName = (oldSchema.getMutationType() || ({} as any)).name || 'unknown';
const newName = (newSchema.getMutationType() || ({} as any)).name || 'unknown';
const oldName = (oldSchema?.getMutationType() || ({} as any)).name || 'unknown';
const newName = (newSchema?.getMutationType() || ({} as any)).name || 'unknown';

return schemaMutationTypeChangedFromMeta({
type: ChangeType.SchemaMutationTypeChanged,
Expand Down Expand Up @@ -99,11 +99,11 @@ export function schemaSubscriptionTypeChangedFromMeta(args: SchemaSubscriptionTy
}

export function schemaSubscriptionTypeChanged(
oldSchema: GraphQLSchema,
newSchema: GraphQLSchema,
oldSchema: GraphQLSchema | null,
newSchema: GraphQLSchema | null,
): Change<typeof ChangeType.SchemaSubscriptionTypeChanged> {
const oldName = (oldSchema.getSubscriptionType() || ({} as any)).name || 'unknown';
const newName = (newSchema.getSubscriptionType() || ({} as any)).name || 'unknown';
const oldName = (oldSchema?.getSubscriptionType() || ({} as any)).name || 'unknown';
const newName = (newSchema?.getSubscriptionType() || ({} as any)).name || 'unknown';

return schemaSubscriptionTypeChangedFromMeta({
type: ChangeType.SchemaSubscriptionTypeChanged,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/diff/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ export * from './onComplete/types.js';
export type { UsageHandler } from './rules/consider-usage.js';

export function diff(
oldSchema: GraphQLSchema,
newSchema: GraphQLSchema,
oldSchema: GraphQLSchema | null,
newSchema: GraphQLSchema | null,
rules: Rule[] = [],
config?: rules.ConsiderUsageConfig,
): Promise<Change[]> {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/diff/rules/safe-unreachable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CriticalityLevel } from '../changes/change.js';
import { Rule } from './types.js';

export const safeUnreachable: Rule = ({ changes, oldSchema }) => {
const reachable = getReachableTypes(oldSchema);
const reachable = oldSchema ? getReachableTypes(oldSchema) : new Set();

return changes.map(change => {
if (change.criticality.level === CriticalityLevel.Breaking && change.path) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const suppressRemovalOfDeprecatedField: Rule = ({ changes, oldSchema, new
change.path
) {
const [typeName, fieldName] = parsePath(change.path);
const type = oldSchema.getType(typeName);
const type = oldSchema?.getType(typeName);

if (isObjectType(type) || isInterfaceType(type)) {
const field = type.getFields()[fieldName];
Expand All @@ -35,7 +35,7 @@ export const suppressRemovalOfDeprecatedField: Rule = ({ changes, oldSchema, new
change.path
) {
const [enumName, enumItem] = parsePath(change.path);
const type = oldSchema.getType(enumName);
const type = oldSchema?.getType(enumName);

if (isEnumType(type)) {
const item = type.getValue(enumItem);
Expand All @@ -58,7 +58,7 @@ export const suppressRemovalOfDeprecatedField: Rule = ({ changes, oldSchema, new
change.path
) {
const [inputName, inputItem] = parsePath(change.path);
const type = oldSchema.getType(inputName);
const type = oldSchema?.getType(inputName);

if (isInputObjectType(type)) {
const item = type.getFields()[inputItem];
Expand All @@ -81,7 +81,7 @@ export const suppressRemovalOfDeprecatedField: Rule = ({ changes, oldSchema, new
change.path
) {
const [typeName] = parsePath(change.path);
const type = newSchema.getType(typeName);
const type = newSchema?.getType(typeName);

if (!type) {
return {
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/diff/rules/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Change } from '../changes/change.js';

export type Rule<TConfig = any> = (input: {
changes: Change[];
oldSchema: GraphQLSchema;
newSchema: GraphQLSchema;
oldSchema: GraphQLSchema | null;
newSchema: GraphQLSchema | null;
config: TConfig;
}) => Change[] | Promise<Change[]>;
Loading
Loading