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..54e55bd8ea 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,10 @@ 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 +56,12 @@ 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,45 +76,57 @@ 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)); - }, - onMutual(directive) { - changesInDirective(directive.oldVersion, directive.newVersion, addChange); + compareLists( + (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); + }, }, - }); + ); - 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, 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)) {