diff --git a/packages/schema/src/makeExecutableSchema.ts b/packages/schema/src/makeExecutableSchema.ts index 05aa58ff888..8da016401a1 100644 --- a/packages/schema/src/makeExecutableSchema.ts +++ b/packages/schema/src/makeExecutableSchema.ts @@ -1,6 +1,6 @@ import { GraphQLFieldResolver } from 'graphql'; -import { mergeDeep, SchemaDirectiveVisitor } from '@graphql-tools/utils'; +import { mergeDeep, SchemaDirectiveVisitor, pruneSchema } from '@graphql-tools/utils'; import { addResolversToSchema } from './addResolversToSchema'; import { attachDirectiveResolvers } from './attachDirectiveResolvers'; @@ -22,6 +22,7 @@ export function makeExecutableSchema({ schemaTransforms = [], parseOptions = {}, inheritResolversFromInterfaces = false, + pruningOptions, }: IExecutableSchemaDefinition) { // Validate and clean up arguments if (typeof resolverValidationOptions !== 'object') { @@ -76,5 +77,5 @@ export function makeExecutableSchema({ SchemaDirectiveVisitor.visitSchemaDirectives(schema, schemaDirectives); } - return schema; + return pruningOptions ? pruneSchema(schema, pruningOptions) : schema; } diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts index 477d9efe93b..69ba0a9ead7 100644 --- a/packages/schema/src/types.ts +++ b/packages/schema/src/types.ts @@ -6,6 +6,7 @@ import { SchemaDirectiveVisitorClass, GraphQLParseOptions, SchemaTransform, + PruneSchemaOptions, } from '@graphql-tools/utils'; export interface ILogger { @@ -23,4 +24,5 @@ export interface IExecutableSchemaDefinition { schemaTransforms?: Array; parseOptions?: GraphQLParseOptions; inheritResolversFromInterfaces?: boolean; + pruningOptions: PruneSchemaOptions; } diff --git a/packages/stitch/src/stitchSchemas.ts b/packages/stitch/src/stitchSchemas.ts index da547adc9b1..12fbd92a4ac 100644 --- a/packages/stitch/src/stitchSchemas.ts +++ b/packages/stitch/src/stitchSchemas.ts @@ -10,7 +10,14 @@ import { GraphQLNamedType, } from 'graphql'; -import { SchemaDirectiveVisitor, cloneDirective, mergeDeep, IResolvers, rewireTypes } from '@graphql-tools/utils'; +import { + SchemaDirectiveVisitor, + cloneDirective, + mergeDeep, + IResolvers, + rewireTypes, + pruneSchema, +} from '@graphql-tools/utils'; import { addResolversToSchema, @@ -45,6 +52,7 @@ export function stitchSchemas({ directiveResolvers, schemaTransforms = [], parseOptions = {}, + pruningOptions, }: IStitchSchemasOptions): GraphQLSchema { if (typeof resolverValidationOptions !== 'object') { throw new Error('Expected `resolverValidationOptions` to be an object'); @@ -170,7 +178,7 @@ export function stitchSchemas({ SchemaDirectiveVisitor.visitSchemaDirectives(schema, schemaDirectives); } - return schema; + return pruningOptions ? pruneSchema(schema, pruningOptions) : schema; } export function isDocumentNode(object: any): object is DocumentNode { diff --git a/packages/utils/src/heal.ts b/packages/utils/src/heal.ts index 1757c0ec856..b1791476fae 100644 --- a/packages/utils/src/heal.ts +++ b/packages/utils/src/heal.ts @@ -121,6 +121,9 @@ export function healTypes( } if (!config.skipPruning) { + // TODO: + // consider removing the default level of pruning in v7, + // see comments below on the pruneTypes function. pruneTypes(originalTypeMap, directives); } @@ -220,6 +223,14 @@ export function healTypes( } } +// TODO: +// consider removing the default level of pruning in v7 +// +// Pruning was introduced into healSchema in v5, so legacy schema directives relying on pruning +// during healing are likely to be rare. pruning is now recommended via the dedicated pruneSchema +// function which does not force pruning on library users and gives granular control in terms of +// pruning types. pruneSchema does recreate the schema -- a parallel version that prunes in place +// could be considered. function pruneTypes(typeMap: Record, directives: ReadonlyArray) { const implementedInterfaces = {}; Object.values(typeMap).forEach(namedType => { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index af48b3f6d59..aaaa5ab27f6 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -28,6 +28,7 @@ export * from './forEachDefaultValue'; export * from './mapSchema'; export * from './addTypes'; export * from './rewire'; +export * from './prune'; export * from './mergeDeep'; export * from './Interfaces'; export * from './fieldNodes'; diff --git a/packages/utils/src/prune.ts b/packages/utils/src/prune.ts new file mode 100644 index 00000000000..6840d5afc98 --- /dev/null +++ b/packages/utils/src/prune.ts @@ -0,0 +1,182 @@ +import { + GraphQLSchema, + GraphQLNamedType, + GraphQLScalarType, + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLUnionType, + GraphQLEnumType, + GraphQLInputObjectType, + getNamedType, + isObjectType, + isInterfaceType, + isUnionType, + isInputObjectType, +} from 'graphql'; + +import { mapSchema, MapperKind } from '@graphql-tools/utils'; + +type NamedOutputType = + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLUnionType + | GraphQLEnumType + | GraphQLScalarType; +type NamedInputType = GraphQLInputObjectType | GraphQLEnumType | GraphQLScalarType; + +interface PruningContext { + schema: GraphQLSchema; + unusedTypes: Record; + implementations: Record>; +} + +export interface PruneSchemaOptions { + skipEmptyCompositeTypePruning?: boolean; + skipUnimplementedInterfacesPruning?: boolean; + skipEmptyUnionPruning?: boolean; + skipUnusedTypesPruning?: boolean; +} + +export function pruneSchema(schema: GraphQLSchema, options: PruneSchemaOptions = {}): GraphQLSchema { + const pruningContext: PruningContext = { + schema, + unusedTypes: Object.create(null), + implementations: Object.create(null), + }; + + Object.keys(schema.getTypeMap()).forEach(typeName => { + const type = schema.getType(typeName); + if ('getInterfaces' in type) { + type.getInterfaces().forEach(iface => { + if (pruningContext.implementations[iface.name] == null) { + pruningContext.implementations[iface.name] = Object.create(null); + } + pruningContext.implementations[iface.name][type.name] = true; + }); + } + }); + + visitTypes(pruningContext, schema); + + return mapSchema(schema, { + [MapperKind.TYPE]: (type: GraphQLNamedType) => { + if (isObjectType(type) || isInputObjectType(type)) { + if ( + (!Object.keys(type.getFields()).length && !options.skipEmptyCompositeTypePruning) || + (pruningContext.unusedTypes[type.name] && !options.skipUnusedTypesPruning) + ) { + return null; + } + } else if (isUnionType(type)) { + if ( + (!type.getTypes().length && !options.skipEmptyUnionPruning) || + (pruningContext.unusedTypes[type.name] && !options.skipUnusedTypesPruning) + ) { + return null; + } + } else if (isInterfaceType(type)) { + if ( + (!Object.keys(type.getFields()).length && !options.skipEmptyCompositeTypePruning) || + (!Object.keys(pruningContext.implementations[type.name]).length && + !options.skipUnimplementedInterfacesPruning) || + (pruningContext.unusedTypes[type.name] && !options.skipUnusedTypesPruning) + ) { + return null; + } + } else { + if (pruningContext.unusedTypes[type.name] && !options.skipUnusedTypesPruning) { + return null; + } + } + }, + }); +} + +function visitOutputType( + visitedTypes: Record, + pruningContext: PruningContext, + type: NamedOutputType +): void { + if (visitedTypes[type.name]) { + return; + } + + visitedTypes[type.name] = true; + pruningContext.unusedTypes[type.name] = false; + + if (isObjectType(type) || isInterfaceType(type)) { + const fields = type.getFields(); + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + const namedType = getNamedType(field.type) as NamedOutputType; + visitOutputType(visitedTypes, pruningContext, namedType); + + const args = field.args; + args.forEach(arg => { + const type = getNamedType(arg.type) as NamedInputType; + visitInputType(visitedTypes, pruningContext, type); + }); + }); + + if (isInterfaceType(type)) { + Object.keys(pruningContext.implementations[type.name]).forEach(typeName => { + visitOutputType(visitedTypes, pruningContext, pruningContext.schema.getType(typeName) as NamedOutputType); + }); + } + + if ('getInterfaces' in type) { + type.getInterfaces().forEach(type => { + visitOutputType(visitedTypes, pruningContext, type); + }); + } + } else if (isUnionType(type)) { + const types = type.getTypes(); + types.forEach(type => visitOutputType(visitedTypes, pruningContext, type)); + } +} + +function visitInputType( + visitedTypes: Record, + pruningContext: PruningContext, + type: NamedInputType +): void { + if (visitedTypes[type.name]) { + return; + } + + pruningContext.unusedTypes[type.name] = false; + + if (isInputObjectType(type)) { + const fields = type.getFields(); + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + const namedType = getNamedType(field.type) as NamedInputType; + visitInputType(visitedTypes, pruningContext, namedType); + }); + } + + visitedTypes[type.name] = true; +} + +function visitTypes(pruningContext: PruningContext, schema: GraphQLSchema): void { + Object.keys(schema.getTypeMap()).forEach(typeName => { + if (!typeName.startsWith('__')) { + pruningContext.unusedTypes[typeName] = true; + } + }); + + const visitedTypes: Record = Object.create(null); + + const rootTypes = [schema.getQueryType(), schema.getMutationType(), schema.getSubscriptionType()].filter( + type => type != null + ); + + rootTypes.forEach(rootType => visitOutputType(visitedTypes, pruningContext, rootType)); + + schema.getDirectives().forEach(directive => { + directive.args.forEach(arg => { + const type = getNamedType(arg.type) as NamedInputType; + visitInputType(visitedTypes, pruningContext, type); + }); + }); +} diff --git a/packages/utils/src/rewire.ts b/packages/utils/src/rewire.ts index 873f0304c90..1994bb6461a 100644 --- a/packages/utils/src/rewire.ts +++ b/packages/utils/src/rewire.ts @@ -67,6 +67,9 @@ export function rewireTypes( const newDirectives = directives.map(directive => rewireDirective(directive)); + // TODO: + // consider removing the default level of pruning in v7, + // see comments below on the pruneTypes function. return options.skipPruning ? { typeMap: newTypeMap, @@ -199,6 +202,13 @@ export function rewireTypes( } } +// TODO: +// consider removing the default level of pruning in v7 +// +// Pruning during mapSchema limits the ability to create an unpruned schema, which may be of use +// to some library users. pruning is now recommended via the dedicated pruneSchema function +// which does not force pruning on library users and gives granular control in terms of pruning +// types. function pruneTypes( typeMap: TypeMap, directives: Array diff --git a/packages/wrap/src/transforms/PruneSchema.ts b/packages/wrap/src/transforms/PruneSchema.ts new file mode 100644 index 00000000000..13843a7e450 --- /dev/null +++ b/packages/wrap/src/transforms/PruneSchema.ts @@ -0,0 +1,15 @@ +import { GraphQLSchema } from 'graphql'; + +import { Transform, PruneSchemaOptions, pruneSchema } from '@graphql-tools/utils'; + +export default class PruneTypes implements Transform { + private readonly options: PruneSchemaOptions; + + constructor(options: PruneSchemaOptions) { + this.options = options; + } + + public transformSchema(schema: GraphQLSchema): GraphQLSchema { + return pruneSchema(schema, this.options); + } +} diff --git a/packages/wrap/src/transforms/index.ts b/packages/wrap/src/transforms/index.ts index 09cb95d9484..a007ea34305 100644 --- a/packages/wrap/src/transforms/index.ts +++ b/packages/wrap/src/transforms/index.ts @@ -17,6 +17,7 @@ export { default as FilterInputObjectFields } from './FilterInputObjectFields'; export { default as TransformQuery } from './TransformQuery'; export { default as ExtendSchema } from './ExtendSchema'; +export { default as PruneSchema } from './PruneSchema'; export { default as WrapType } from './WrapType'; export { default as WrapFields } from './WrapFields'; export { default as HoistField } from './HoistField';