From 892a47eab02452f10a7a66ab21f557805274911e Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:19:04 -0800 Subject: [PATCH 1/6] Generate changes for new or deleted schemas --- .changeset/twelve-ties-pretend.md | 5 + packages/core/__tests__/diff/schema.test.ts | 160 +++++++++++++++++- .../core/src/diff/changes/directive-usage.ts | 10 +- packages/core/src/diff/changes/schema.ts | 24 +-- packages/core/src/diff/index.ts | 4 +- .../core/src/diff/rules/safe-unreachable.ts | 2 +- .../suppress-removal-of-deprecated-field.ts | 8 +- packages/core/src/diff/rules/types.ts | 4 +- packages/core/src/diff/schema.ts | 30 ++-- packages/core/src/utils/graphql.ts | 3 + 10 files changed, 209 insertions(+), 41 deletions(-) create mode 100644 .changeset/twelve-ties-pretend.md diff --git a/.changeset/twelve-ties-pretend.md b/.changeset/twelve-ties-pretend.md new file mode 100644 index 0000000000..8b1e025b9e --- /dev/null +++ b/.changeset/twelve-ties-pretend.md @@ -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. diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index da75efb265..f1beda3b09 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -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 */ ` @@ -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", + }, + "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", + }, + ] + `); +}); diff --git a/packages/core/src/diff/changes/directive-usage.ts b/packages/core/src/diff/changes/directive-usage.ts index b6d8da22d8..28d6bf89df 100644 --- a/packages/core/src/diff/changes/directive-usage.ts +++ b/packages/core/src/diff/changes/directive-usage.ts @@ -108,7 +108,7 @@ type KindToPayload = { change: DirectiveUsageEnumValueAddedChange | DirectiveUsageEnumValueRemovedChange; }; [Kind.SCHEMA_DEFINITION]: { - input: GraphQLSchema; + input: GraphQLSchema | null; change: DirectiveUsageSchemaAddedChange | DirectiveUsageSchemaRemovedChange; }; [Kind.SCALAR_TYPE_DEFINITION]: { @@ -836,9 +836,9 @@ export function directiveUsageAdded( 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), }, }); } @@ -1016,8 +1016,8 @@ export function directiveUsageRemoved( 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), }, }); } diff --git a/packages/core/src/diff/changes/schema.ts b/packages/core/src/diff/changes/schema.ts index ea349d384a..206458c37c 100644 --- a/packages/core/src/diff/changes/schema.ts +++ b/packages/core/src/diff/changes/schema.ts @@ -27,11 +27,11 @@ export function schemaQueryTypeChangedFromMeta(args: SchemaQueryTypeChangedChang } export function schemaQueryTypeChanged( - oldSchema: GraphQLSchema, - newSchema: GraphQLSchema, + oldSchema: GraphQLSchema | null, + newSchema: GraphQLSchema | null, ): Change { - 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, @@ -63,11 +63,11 @@ export function schemaMutationTypeChangedFromMeta(args: SchemaMutationTypeChange } export function schemaMutationTypeChanged( - oldSchema: GraphQLSchema, - newSchema: GraphQLSchema, + oldSchema: GraphQLSchema | null, + newSchema: GraphQLSchema | null, ): Change { - 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, @@ -99,11 +99,11 @@ export function schemaSubscriptionTypeChangedFromMeta(args: SchemaSubscriptionTy } export function schemaSubscriptionTypeChanged( - oldSchema: GraphQLSchema, - newSchema: GraphQLSchema, + oldSchema: GraphQLSchema | null, + newSchema: GraphQLSchema | null, ): Change { - 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, diff --git a/packages/core/src/diff/index.ts b/packages/core/src/diff/index.ts index c8e7ce4c9c..74cf7f2f06 100644 --- a/packages/core/src/diff/index.ts +++ b/packages/core/src/diff/index.ts @@ -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 { diff --git a/packages/core/src/diff/rules/safe-unreachable.ts b/packages/core/src/diff/rules/safe-unreachable.ts index 062eb94226..69c9457f6f 100644 --- a/packages/core/src/diff/rules/safe-unreachable.ts +++ b/packages/core/src/diff/rules/safe-unreachable.ts @@ -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) { diff --git a/packages/core/src/diff/rules/suppress-removal-of-deprecated-field.ts b/packages/core/src/diff/rules/suppress-removal-of-deprecated-field.ts index e36c0faa81..29c9a6e7fa 100644 --- a/packages/core/src/diff/rules/suppress-removal-of-deprecated-field.ts +++ b/packages/core/src/diff/rules/suppress-removal-of-deprecated-field.ts @@ -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]; @@ -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); @@ -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]; @@ -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 { diff --git a/packages/core/src/diff/rules/types.ts b/packages/core/src/diff/rules/types.ts index ea70bb9413..b85e8824a5 100644 --- a/packages/core/src/diff/rules/types.ts +++ b/packages/core/src/diff/rules/types.ts @@ -3,7 +3,7 @@ import { Change } from '../changes/change.js'; export type Rule = (input: { changes: Change[]; - oldSchema: GraphQLSchema; - newSchema: GraphQLSchema; + oldSchema: GraphQLSchema | null; + newSchema: GraphQLSchema | null; config: TConfig; }) => Change[] | Promise; diff --git a/packages/core/src/diff/schema.ts b/packages/core/src/diff/schema.ts index d29b49d03b..de8360ceea 100644 --- a/packages/core/src/diff/schema.ts +++ b/packages/core/src/diff/schema.ts @@ -7,11 +7,12 @@ import { isInterfaceType, isObjectType, isScalarType, + isSpecifiedDirective, isUnionType, Kind, } from 'graphql'; import { compareDirectiveLists, compareLists, isNotEqual, isVoid } from '../utils/compare.js'; -import { isPrimitive } from '../utils/graphql.js'; +import { isForIntrospection, isPrimitive } from '../utils/graphql.js'; import { Change } from './changes/change.js'; import { directiveUsageAdded, @@ -42,7 +43,7 @@ import { changesInUnion } from './union.js'; export type AddChange = (change: Change) => void; -export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): Change[] { +export function diffSchema(oldSchema: GraphQLSchema | null, newSchema: GraphQLSchema | null): Change[] { const changes: Change[] = []; function addChange(change: Change) { @@ -52,8 +53,8 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): changesInSchema(oldSchema, newSchema, addChange); compareLists( - Object.values(oldSchema.getTypeMap()).filter(t => !isPrimitive(t)), - Object.values(newSchema.getTypeMap()).filter(t => !isPrimitive(t)), + Object.values(oldSchema?.getTypeMap() ?? {}).filter(t => !isPrimitive(t) && !isForIntrospection(t)), + Object.values(newSchema?.getTypeMap() ?? {}).filter(t => !isPrimitive(t) && !isForIntrospection(t)), { onAdded(type) { addChange(typeAdded(type)); @@ -68,7 +69,10 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): }, ); - compareLists(oldSchema.getDirectives(), newSchema.getDirectives(), { + compareLists( + (oldSchema?.getDirectives() ?? []).filter(t => !isSpecifiedDirective(t)), + (newSchema?.getDirectives() ?? []).filter(t => !isSpecifiedDirective(t)), + { onAdded(directive) { addChange(directiveAdded(directive)); changesInDirective(null, directive, addChange); @@ -81,7 +85,7 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): }, }); - compareDirectiveLists(oldSchema.astNode?.directives || [], newSchema.astNode?.directives || [], { + compareDirectiveLists(oldSchema?.astNode?.directives || [], newSchema?.astNode?.directives || [], { onAdded(directive) { addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema, false)); directiveUsageChanged(null, directive, addChange); @@ -97,16 +101,16 @@ export function diffSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema): return changes; } -function changesInSchema(oldSchema: GraphQLSchema, newSchema: GraphQLSchema, addChange: AddChange) { +function changesInSchema(oldSchema: GraphQLSchema | null, newSchema: GraphQLSchema | null, addChange: AddChange) { const oldRoot = { - query: (oldSchema.getQueryType() || ({} as GraphQLObjectType)).name, - mutation: (oldSchema.getMutationType() || ({} as GraphQLObjectType)).name, - subscription: (oldSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name, + 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, - mutation: (newSchema.getMutationType() || ({} as GraphQLObjectType)).name, - subscription: (newSchema.getSubscriptionType() || ({} as GraphQLObjectType)).name, + query: (newSchema?.getQueryType() || ({} as GraphQLObjectType)).name, + mutation: (newSchema?.getMutationType() || ({} as GraphQLObjectType)).name, + subscription: (newSchema?.getSubscriptionType() || ({} as GraphQLObjectType)).name, }; if (isNotEqual(oldRoot.query, newRoot.query)) { diff --git a/packages/core/src/utils/graphql.ts b/packages/core/src/utils/graphql.ts index 4c803c4259..b99caae4e0 100644 --- a/packages/core/src/utils/graphql.ts +++ b/packages/core/src/utils/graphql.ts @@ -2,6 +2,7 @@ import { DocumentNode, FieldNode, getNamedType, + GraphQLDirective, GraphQLEnumType, GraphQLError, GraphQLInputObjectType, @@ -13,12 +14,14 @@ import { GraphQLScalarType, GraphQLSchema, GraphQLUnionType, + isDirective, isInputObjectType, isInterfaceType, isListType, isNonNullType, isObjectType, isScalarType, + isSpecifiedDirective, isUnionType, isWrappingType, Kind, From a610dbbf089ec9d85ff4ed1be60ed3ac2f019142 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:48:26 -0800 Subject: [PATCH 2/6] Remove unused imports --- packages/core/src/utils/graphql.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/utils/graphql.ts b/packages/core/src/utils/graphql.ts index b99caae4e0..4c803c4259 100644 --- a/packages/core/src/utils/graphql.ts +++ b/packages/core/src/utils/graphql.ts @@ -2,7 +2,6 @@ import { DocumentNode, FieldNode, getNamedType, - GraphQLDirective, GraphQLEnumType, GraphQLError, GraphQLInputObjectType, @@ -14,14 +13,12 @@ import { GraphQLScalarType, GraphQLSchema, GraphQLUnionType, - isDirective, isInputObjectType, isInterfaceType, isListType, isNonNullType, isObjectType, isScalarType, - isSpecifiedDirective, isUnionType, isWrappingType, Kind, From 88fd4e9a4cbe3ef4792bdd0a808b897ac20ae425 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Thu, 20 Nov 2025 15:55:46 -0800 Subject: [PATCH 3/6] Prettier --- packages/core/src/diff/schema.ts | 66 ++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/packages/core/src/diff/schema.ts b/packages/core/src/diff/schema.ts index de8360ceea..54e55bd8ea 100644 --- a/packages/core/src/diff/schema.ts +++ b/packages/core/src/diff/schema.ts @@ -43,7 +43,10 @@ import { changesInUnion } from './union.js'; export type AddChange = (change: Change) => void; -export function diffSchema(oldSchema: GraphQLSchema | null, newSchema: GraphQLSchema | null): Change[] { +export function diffSchema( + oldSchema: GraphQLSchema | null, + newSchema: GraphQLSchema | null, +): Change[] { const changes: Change[] = []; function addChange(change: Change) { @@ -53,8 +56,12 @@ export function diffSchema(oldSchema: GraphQLSchema | null, newSchema: GraphQLSc changesInSchema(oldSchema, newSchema, addChange); compareLists( - Object.values(oldSchema?.getTypeMap() ?? {}).filter(t => !isPrimitive(t) && !isForIntrospection(t)), - Object.values(newSchema?.getTypeMap() ?? {}).filter(t => !isPrimitive(t) && !isForIntrospection(t)), + Object.values(oldSchema?.getTypeMap() ?? {}).filter( + t => !isPrimitive(t) && !isForIntrospection(t), + ), + Object.values(newSchema?.getTypeMap() ?? {}).filter( + t => !isPrimitive(t) && !isForIntrospection(t), + ), { onAdded(type) { addChange(typeAdded(type)); @@ -73,35 +80,44 @@ export function diffSchema(oldSchema: GraphQLSchema | null, newSchema: GraphQLSc (oldSchema?.getDirectives() ?? []).filter(t => !isSpecifiedDirective(t)), (newSchema?.getDirectives() ?? []).filter(t => !isSpecifiedDirective(t)), { - onAdded(directive) { - addChange(directiveAdded(directive)); - changesInDirective(null, directive, addChange); - }, - onRemoved(directive) { - addChange(directiveRemoved(directive)); - }, - onMutual(directive) { - changesInDirective(directive.oldVersion, directive.newVersion, addChange); + onAdded(directive) { + addChange(directiveAdded(directive)); + changesInDirective(null, directive, addChange); + }, + onRemoved(directive) { + addChange(directiveRemoved(directive)); + }, + onMutual(directive) { + changesInDirective(directive.oldVersion, directive.newVersion, addChange); + }, }, - }); + ); - compareDirectiveLists(oldSchema?.astNode?.directives || [], newSchema?.astNode?.directives || [], { - onAdded(directive) { - addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema, false)); - directiveUsageChanged(null, directive, addChange); - }, - onMutual(directive) { - directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange); - }, - onRemoved(directive) { - addChange(directiveUsageRemoved(Kind.SCHEMA_DEFINITION, directive, oldSchema)); + compareDirectiveLists( + oldSchema?.astNode?.directives || [], + newSchema?.astNode?.directives || [], + { + onAdded(directive) { + addChange(directiveUsageAdded(Kind.SCHEMA_DEFINITION, directive, newSchema, false)); + directiveUsageChanged(null, directive, addChange); + }, + onMutual(directive) { + directiveUsageChanged(directive.oldVersion, directive.newVersion, addChange); + }, + onRemoved(directive) { + addChange(directiveUsageRemoved(Kind.SCHEMA_DEFINITION, directive, oldSchema)); + }, }, - }); + ); return changes; } -function changesInSchema(oldSchema: GraphQLSchema | null, newSchema: GraphQLSchema | null, addChange: AddChange) { +function changesInSchema( + oldSchema: GraphQLSchema | null, + newSchema: GraphQLSchema | null, + addChange: AddChange, +) { const oldRoot = { query: (oldSchema?.getQueryType() || ({} as GraphQLObjectType)).name, mutation: (oldSchema?.getMutationType() || ({} as GraphQLObjectType)).name, From ed57c921c80feafc1e499b3e92955f7a79b551ee Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Tue, 9 Dec 2025 17:17:58 -0800 Subject: [PATCH 4/6] Adjust SCHEMA\_\*\_TYPE_CHANGED changes to use null instead of 'unknown' when these types are not defined --- .changeset/sharp-files-sin.md | 7 ++ packages/core/__tests__/diff/schema.test.ts | 20 ++-- packages/core/src/diff/changes/change.ts | 12 +-- packages/core/src/diff/changes/schema.ts | 18 ++-- packages/patch/src/patches/schema.ts | 107 ++++++++++++-------- 5 files changed, 95 insertions(+), 69 deletions(-) create mode 100644 .changeset/sharp-files-sin.md diff --git a/.changeset/sharp-files-sin.md b/.changeset/sharp-files-sin.md new file mode 100644 index 0000000000..67d64ad89c --- /dev/null +++ b/.changeset/sharp-files-sin.md @@ -0,0 +1,7 @@ +--- +'@graphql-inspector/patch': minor +'@graphql-inspector/core': minor +--- + +Adjust SCHEMA\_\*\_TYPE_CHANGED changes to use null instead of 'unknown' when these types are not +defined diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index f1beda3b09..24c99069fb 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -765,10 +765,10 @@ test('adding root type should not be breaking', async () => { "criticality": { "level": "NON_BREAKING", }, - "message": "Schema subscription root has changed from 'unknown' to 'Subscription'", + "message": "Schema subscription root has changed from 'null' to 'Subscription'", "meta": { "newSubscriptionTypeName": "Subscription", - "oldSubscriptionTypeName": "unknown", + "oldSubscriptionTypeName": null, }, "type": "SCHEMA_SUBSCRIPTION_TYPE_CHANGED", }, @@ -822,10 +822,10 @@ test('null old schema', async () => { "criticality": { "level": "NON_BREAKING", }, - "message": "Schema query root has changed from 'unknown' to 'Query'", + "message": "Schema query root has changed from 'null' to 'Query'", "meta": { "newQueryTypeName": "Query", - "oldQueryTypeName": "unknown", + "oldQueryTypeName": null, }, "type": "SCHEMA_QUERY_TYPE_CHANGED", }, @@ -833,10 +833,10 @@ test('null old schema', async () => { "criticality": { "level": "NON_BREAKING", }, - "message": "Schema subscription root has changed from 'unknown' to 'Subscription'", + "message": "Schema subscription root has changed from 'null' to 'Subscription'", "meta": { "newSubscriptionTypeName": "Subscription", - "oldSubscriptionTypeName": "unknown", + "oldSubscriptionTypeName": null, }, "type": "SCHEMA_SUBSCRIPTION_TYPE_CHANGED", }, @@ -916,9 +916,9 @@ test('null new schema', async () => { "criticality": { "level": "BREAKING", }, - "message": "Schema query root has changed from 'Query' to 'unknown'", + "message": "Schema query root has changed from 'Query' to 'null'", "meta": { - "newQueryTypeName": "unknown", + "newQueryTypeName": null, "oldQueryTypeName": "Query", }, "type": "SCHEMA_QUERY_TYPE_CHANGED", @@ -927,9 +927,9 @@ test('null new schema', async () => { "criticality": { "level": "BREAKING", }, - "message": "Schema subscription root has changed from 'Subscription' to 'unknown'", + "message": "Schema subscription root has changed from 'Subscription' to 'null'", "meta": { - "newSubscriptionTypeName": "unknown", + "newSubscriptionTypeName": null, "oldSubscriptionTypeName": "Subscription", }, "type": "SCHEMA_SUBSCRIPTION_TYPE_CHANGED", diff --git a/packages/core/src/diff/changes/change.ts b/packages/core/src/diff/changes/change.ts index ba7815bfa3..edd40328bd 100644 --- a/packages/core/src/diff/changes/change.ts +++ b/packages/core/src/diff/changes/change.ts @@ -564,24 +564,24 @@ export type ObjectTypeInterfaceRemovedChange = { export type SchemaQueryTypeChangedChange = { type: typeof ChangeType.SchemaQueryTypeChanged; meta: { - oldQueryTypeName: string; - newQueryTypeName: string; + oldQueryTypeName: string | null; + newQueryTypeName: string | null; }; }; export type SchemaMutationTypeChangedChange = { type: typeof ChangeType.SchemaMutationTypeChanged; meta: { - oldMutationTypeName: string; - newMutationTypeName: string; + oldMutationTypeName: string | null; + newMutationTypeName: string | null; }; }; export type SchemaSubscriptionTypeChangedChange = { type: typeof ChangeType.SchemaSubscriptionTypeChanged; meta: { - oldSubscriptionTypeName: string; - newSubscriptionTypeName: string; + oldSubscriptionTypeName: string | null; + newSubscriptionTypeName: string | null; }; }; diff --git a/packages/core/src/diff/changes/schema.ts b/packages/core/src/diff/changes/schema.ts index 206458c37c..76d7cf4b4a 100644 --- a/packages/core/src/diff/changes/schema.ts +++ b/packages/core/src/diff/changes/schema.ts @@ -17,7 +17,7 @@ export function schemaQueryTypeChangedFromMeta(args: SchemaQueryTypeChangedChang type: ChangeType.SchemaQueryTypeChanged, criticality: { level: - args.meta.oldQueryTypeName === 'unknown' + args.meta.oldQueryTypeName === null ? CriticalityLevel.NonBreaking : CriticalityLevel.Breaking, }, @@ -30,8 +30,8 @@ export function schemaQueryTypeChanged( oldSchema: GraphQLSchema | null, newSchema: GraphQLSchema | null, ): Change { - const oldName = (oldSchema?.getQueryType() || ({} as any)).name || 'unknown'; - const newName = (newSchema?.getQueryType() || ({} as any)).name || 'unknown'; + const oldName = oldSchema?.getQueryType()?.name || null; + const newName = newSchema?.getQueryType()?.name || null; return schemaQueryTypeChangedFromMeta({ type: ChangeType.SchemaQueryTypeChanged, @@ -53,7 +53,7 @@ export function schemaMutationTypeChangedFromMeta(args: SchemaMutationTypeChange type: ChangeType.SchemaMutationTypeChanged, criticality: { level: - args.meta.oldMutationTypeName === 'unknown' + args.meta.oldMutationTypeName === null ? CriticalityLevel.NonBreaking : CriticalityLevel.Breaking, }, @@ -66,8 +66,8 @@ export function schemaMutationTypeChanged( oldSchema: GraphQLSchema | null, newSchema: GraphQLSchema | null, ): Change { - const oldName = (oldSchema?.getMutationType() || ({} as any)).name || 'unknown'; - const newName = (newSchema?.getMutationType() || ({} as any)).name || 'unknown'; + const oldName = oldSchema?.getMutationType()?.name || null; + const newName = newSchema?.getMutationType()?.name || null; return schemaMutationTypeChangedFromMeta({ type: ChangeType.SchemaMutationTypeChanged, @@ -89,7 +89,7 @@ export function schemaSubscriptionTypeChangedFromMeta(args: SchemaSubscriptionTy type: ChangeType.SchemaSubscriptionTypeChanged, criticality: { level: - args.meta.oldSubscriptionTypeName === 'unknown' + args.meta.oldSubscriptionTypeName === null ? CriticalityLevel.NonBreaking : CriticalityLevel.Breaking, }, @@ -102,8 +102,8 @@ export function schemaSubscriptionTypeChanged( oldSchema: GraphQLSchema | null, newSchema: GraphQLSchema | null, ): Change { - const oldName = (oldSchema?.getSubscriptionType() || ({} as any)).name || 'unknown'; - const newName = (newSchema?.getSubscriptionType() || ({} as any)).name || 'unknown'; + const oldName = oldSchema?.getSubscriptionType()?.name || null; + const newName = newSchema?.getSubscriptionType()?.name || null; return schemaSubscriptionTypeChangedFromMeta({ type: ChangeType.SchemaSubscriptionTypeChanged, diff --git a/packages/patch/src/patches/schema.ts b/packages/patch/src/patches/schema.ts index c24572c249..93d56e1b09 100644 --- a/packages/patch/src/patches/schema.ts +++ b/packages/patch/src/patches/schema.ts @@ -16,27 +16,25 @@ export function schemaMutationTypeChanged( ({ operation }) => operation === OperationTypeNode.MUTATION, ); if (!mutation) { - if (change.meta.oldMutationTypeName !== 'unknown') { + if (change.meta.oldMutationTypeName !== null) { config.onError( - new ValueMismatchError( - Kind.SCHEMA_DEFINITION, - change.meta.oldMutationTypeName, - 'unknown', - ), + new ValueMismatchError(Kind.SCHEMA_DEFINITION, change.meta.oldMutationTypeName, null), 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), + if (change.meta.newMutationTypeName) { + (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( @@ -48,6 +46,13 @@ export function schemaMutationTypeChanged( change, ); } + if (change.meta.newMutationTypeName === null) { + (schemaNode.operationTypes as OperationTypeDefinitionNode[] | undefined) = + schemaNode.operationTypes?.filter( + ({ operation }) => operation !== OperationTypeNode.MUTATION, + ); + return; + } (mutation.type.name as NameNode) = nameNode(change.meta.newMutationTypeName); } } @@ -64,23 +69,25 @@ export function schemaQueryTypeChanged( ({ operation }) => operation === OperationTypeNode.MUTATION, ); if (!query) { - if (change.meta.oldQueryTypeName !== 'unknown') { + if (change.meta.oldQueryTypeName !== null) { config.onError( - new ValueMismatchError(Kind.SCHEMA_DEFINITION, change.meta.oldQueryTypeName, 'unknown'), + new ValueMismatchError(Kind.SCHEMA_DEFINITION, change.meta.oldQueryTypeName, null), 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), + if (change.meta.newQueryTypeName) { + (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( @@ -92,6 +99,13 @@ export function schemaQueryTypeChanged( change, ); } + if (change.meta.newQueryTypeName === null) { + (schemaNode.operationTypes as OperationTypeDefinitionNode[] | undefined) = + schemaNode.operationTypes?.filter( + ({ operation }) => operation !== OperationTypeNode.QUERY, + ); + return; + } (query.type.name as NameNode) = nameNode(change.meta.newQueryTypeName); } } @@ -108,27 +122,25 @@ export function schemaSubscriptionTypeChanged( ({ operation }) => operation === OperationTypeNode.SUBSCRIPTION, ); if (!sub) { - if (change.meta.oldSubscriptionTypeName !== 'unknown') { + if (change.meta.oldSubscriptionTypeName !== null) { config.onError( - new ValueMismatchError( - Kind.SCHEMA_DEFINITION, - change.meta.oldSubscriptionTypeName, - 'unknown', - ), + new ValueMismatchError(Kind.SCHEMA_DEFINITION, change.meta.oldSubscriptionTypeName, null), 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), + if (change.meta.newSubscriptionTypeName) { + (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( @@ -140,6 +152,13 @@ export function schemaSubscriptionTypeChanged( change, ); } + if (change.meta.newSubscriptionTypeName === null) { + (schemaNode.operationTypes as OperationTypeDefinitionNode[] | undefined) = + schemaNode.operationTypes?.filter( + ({ operation }) => operation !== OperationTypeNode.SUBSCRIPTION, + ); + return; + } (sub.type.name as NameNode) = nameNode(change.meta.newSubscriptionTypeName); } } From 83e88e0012f7cbffde895b877242a59aee57ecc0 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 10 Dec 2025 13:43:23 -0800 Subject: [PATCH 5/6] Improve message for root field type changes --- packages/core/__tests__/diff/schema.test.ts | 12 +++++------ packages/core/src/diff/changes/schema.ts | 24 ++++++++++++++++++--- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/packages/core/__tests__/diff/schema.test.ts b/packages/core/__tests__/diff/schema.test.ts index 24c99069fb..66307aaeeb 100644 --- a/packages/core/__tests__/diff/schema.test.ts +++ b/packages/core/__tests__/diff/schema.test.ts @@ -67,7 +67,7 @@ test('renamed query', async () => { expect(changed).toBeDefined(); expect(changed.criticality.level).toEqual(CriticalityLevel.Breaking); - expect(changed.message).toEqual(`Schema query root has changed from 'Query' to 'RootQuery'`); + expect(changed.message).toEqual(`Schema query root type was changed from 'Query' to 'RootQuery'`); }); test('new field and field changed', async () => { @@ -765,7 +765,7 @@ test('adding root type should not be breaking', async () => { "criticality": { "level": "NON_BREAKING", }, - "message": "Schema subscription root has changed from 'null' to 'Subscription'", + "message": "Schema subscription type was set to 'Subscription'.", "meta": { "newSubscriptionTypeName": "Subscription", "oldSubscriptionTypeName": null, @@ -822,7 +822,7 @@ test('null old schema', async () => { "criticality": { "level": "NON_BREAKING", }, - "message": "Schema query root has changed from 'null' to 'Query'", + "message": "Schema query root type was set to 'Query'.", "meta": { "newQueryTypeName": "Query", "oldQueryTypeName": null, @@ -833,7 +833,7 @@ test('null old schema', async () => { "criticality": { "level": "NON_BREAKING", }, - "message": "Schema subscription root has changed from 'null' to 'Subscription'", + "message": "Schema subscription type was set to 'Subscription'.", "meta": { "newSubscriptionTypeName": "Subscription", "oldSubscriptionTypeName": null, @@ -916,7 +916,7 @@ test('null new schema', async () => { "criticality": { "level": "BREAKING", }, - "message": "Schema query root has changed from 'Query' to 'null'", + "message": "Schema query root type 'Query' was removed.", "meta": { "newQueryTypeName": null, "oldQueryTypeName": "Query", @@ -927,7 +927,7 @@ test('null new schema', async () => { "criticality": { "level": "BREAKING", }, - "message": "Schema subscription root has changed from 'Subscription' to 'null'", + "message": "Schema subscription type 'Subscription' was removed.", "meta": { "newSubscriptionTypeName": null, "oldSubscriptionTypeName": "Subscription", diff --git a/packages/core/src/diff/changes/schema.ts b/packages/core/src/diff/changes/schema.ts index 76d7cf4b4a..c5ed0eeea2 100644 --- a/packages/core/src/diff/changes/schema.ts +++ b/packages/core/src/diff/changes/schema.ts @@ -9,7 +9,13 @@ import { } from './change.js'; function buildSchemaQueryTypeChangedMessage(args: SchemaQueryTypeChangedChange['meta']): string { - return `Schema query root has changed from '${args.oldQueryTypeName}' to '${args.newQueryTypeName}'`; + if (args.oldQueryTypeName === null) { + return `Schema query root type was set to '${args.newQueryTypeName}'.`; + } + if (args.newQueryTypeName === null) { + return `Schema query root type '${args.oldQueryTypeName}' was removed.`; + } + return `Schema query root type was changed from '${args.oldQueryTypeName}' to '${args.newQueryTypeName}'`; } export function schemaQueryTypeChangedFromMeta(args: SchemaQueryTypeChangedChange) { @@ -45,7 +51,13 @@ export function schemaQueryTypeChanged( function buildSchemaMutationTypeChangedMessage( args: SchemaMutationTypeChangedChange['meta'], ): string { - return `Schema mutation root has changed from '${args.oldMutationTypeName}' to '${args.newMutationTypeName}'`; + if (args.oldMutationTypeName === null) { + return `Schema mutation type was set to '${args.newMutationTypeName}'.`; + } + if (args.newMutationTypeName === null) { + return `Schema mutation type '${args.oldMutationTypeName}' was removed.`; + } + return `Schema mutation type was changed from '${args.oldMutationTypeName}' to '${args.newMutationTypeName}'`; } export function schemaMutationTypeChangedFromMeta(args: SchemaMutationTypeChangedChange) { @@ -81,7 +93,13 @@ export function schemaMutationTypeChanged( function buildSchemaSubscriptionTypeChangedMessage( args: SchemaSubscriptionTypeChangedChange['meta'], ): string { - return `Schema subscription root has changed from '${args.oldSubscriptionTypeName}' to '${args.newSubscriptionTypeName}'`; + if (args.oldSubscriptionTypeName === null) { + return `Schema subscription type was set to '${args.newSubscriptionTypeName}'.`; + } + if (args.newSubscriptionTypeName === null) { + return `Schema subscription type '${args.oldSubscriptionTypeName}' was removed.`; + } + return `Schema subscription type was changed from '${args.oldSubscriptionTypeName}' to '${args.newSubscriptionTypeName}'`; } export function schemaSubscriptionTypeChangedFromMeta(args: SchemaSubscriptionTypeChangedChange) { From 671f8b3cdccca39e371f19a9a1cbf9a8ed76eac6 Mon Sep 17 00:00:00 2001 From: jdolle <1841898+jdolle@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:06:33 -0800 Subject: [PATCH 6/6] Update sharp-files-sin.md --- .changeset/sharp-files-sin.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/sharp-files-sin.md b/.changeset/sharp-files-sin.md index 67d64ad89c..1fdee7b05c 100644 --- a/.changeset/sharp-files-sin.md +++ b/.changeset/sharp-files-sin.md @@ -4,4 +4,4 @@ --- Adjust SCHEMA\_\*\_TYPE_CHANGED changes to use null instead of 'unknown' when these types are not -defined +defined and improve the change messages.