From 57e65ab1a3f39e76c5eb378cceb96987304d03c8 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 10 Jun 2020 23:39:53 -0400 Subject: [PATCH 1/5] introduce pruneSchema --- packages/utils/src/index.ts | 1 + packages/utils/src/prune.ts | 169 ++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 packages/utils/src/prune.ts 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..b0c204bf56d --- /dev/null +++ b/packages/utils/src/prune.ts @@ -0,0 +1,169 @@ +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 function pruneSchema(schema: GraphQLSchema): 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)) { + // prune types with no fields or are unused + if (!Object.keys(type.getFields()).length || pruningContext.unusedTypes[type.name]) { + return null; + } + } else if (isUnionType(type)) { + // prune unions without underlying types or are unused + if (!type.getTypes().length || pruningContext.unusedTypes[type.name]) { + return null; + } + } else if (isInterfaceType(type)) { + // prune interfaces without fields or without implementations or are unused + if ( + !Object.keys(type.getFields()).length || + !Object.keys(pruningContext.implementations[type.name]).length || + pruningContext.unusedTypes[type.name] + ) { + return null; + } + } else if (pruningContext.unusedTypes[type.name]) { + 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); + }); + }); +} From 9b8fa7d385d70414460207ec93448fcad0128e76 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 10 Jun 2020 23:52:45 -0400 Subject: [PATCH 2/5] add PruneSchemaOptions --- packages/utils/src/prune.ts | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/utils/src/prune.ts b/packages/utils/src/prune.ts index b0c204bf56d..e8c528328c1 100644 --- a/packages/utils/src/prune.ts +++ b/packages/utils/src/prune.ts @@ -30,7 +30,14 @@ interface PruningContext { implementations: Record>; } -export function pruneSchema(schema: GraphQLSchema): GraphQLSchema { +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), @@ -54,26 +61,32 @@ export function pruneSchema(schema: GraphQLSchema): GraphQLSchema { return mapSchema(schema, { [MapperKind.TYPE]: (type: GraphQLNamedType) => { if (isObjectType(type) || isInputObjectType(type)) { - // prune types with no fields or are unused - if (!Object.keys(type.getFields()).length || pruningContext.unusedTypes[type.name]) { + if ( + (!Object.keys(type.getFields()).length && !options.skipEmptyCompositeTypePruning) || + (pruningContext.unusedTypes[type.name] && !options.skipUnusedTypesPruning) + ) { return null; } } else if (isUnionType(type)) { - // prune unions without underlying types or are unused - if (!type.getTypes().length || pruningContext.unusedTypes[type.name]) { + if ( + (!type.getTypes().length && !options.skipEmptyUnionPruning) || + (pruningContext.unusedTypes[type.name] && !options.skipUnusedTypesPruning) + ) { return null; } } else if (isInterfaceType(type)) { - // prune interfaces without fields or without implementations or are unused if ( - !Object.keys(type.getFields()).length || - !Object.keys(pruningContext.implementations[type.name]).length || - pruningContext.unusedTypes[type.name] + (!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]) { - return null; + } else { + if (pruningContext.unusedTypes[type.name] && !options.skipUnusedTypesPruning) { + return null; + } } }, }); From 0f28e621ac08c1395ede6c4d4d5ade288e74ead6 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 10 Jun 2020 23:58:31 -0400 Subject: [PATCH 3/5] introduce PruneSchema transform --- packages/utils/src/prune.ts | 2 +- packages/wrap/src/transforms/PruneSchema.ts | 15 +++++++++++++++ packages/wrap/src/transforms/index.ts | 1 + 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 packages/wrap/src/transforms/PruneSchema.ts diff --git a/packages/utils/src/prune.ts b/packages/utils/src/prune.ts index e8c528328c1..69a00f723ee 100644 --- a/packages/utils/src/prune.ts +++ b/packages/utils/src/prune.ts @@ -30,7 +30,7 @@ interface PruningContext { implementations: Record>; } -interface PruneSchemaOptions { +export interface PruneSchemaOptions { skipEmptyCompositeTypePruning: boolean; skipUnimplementedInterfacesPruning: boolean; skipEmptyUnionPruning: boolean; 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'; From 9fef473f5ba82688ce32ae776ebd317dabbef05f Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Thu, 11 Jun 2020 00:06:05 -0400 Subject: [PATCH 4/5] add pruning options to makeExecutableSchema and stitchSchemas --- packages/schema/src/makeExecutableSchema.ts | 5 +++-- packages/schema/src/types.ts | 2 ++ packages/stitch/src/stitchSchemas.ts | 12 ++++++++++-- packages/utils/src/prune.ts | 10 +++++----- 4 files changed, 20 insertions(+), 9 deletions(-) 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/prune.ts b/packages/utils/src/prune.ts index 69a00f723ee..6840d5afc98 100644 --- a/packages/utils/src/prune.ts +++ b/packages/utils/src/prune.ts @@ -31,13 +31,13 @@ interface PruningContext { } export interface PruneSchemaOptions { - skipEmptyCompositeTypePruning: boolean; - skipUnimplementedInterfacesPruning: boolean; - skipEmptyUnionPruning: boolean; - skipUnusedTypesPruning: boolean; + skipEmptyCompositeTypePruning?: boolean; + skipUnimplementedInterfacesPruning?: boolean; + skipEmptyUnionPruning?: boolean; + skipUnusedTypesPruning?: boolean; } -export function pruneSchema(schema: GraphQLSchema, options: PruneSchemaOptions): GraphQLSchema { +export function pruneSchema(schema: GraphQLSchema, options: PruneSchemaOptions = {}): GraphQLSchema { const pruningContext: PruningContext = { schema, unusedTypes: Object.create(null), From 4d7774b154884086c5306c42209185f9efae1400 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Thu, 11 Jun 2020 00:19:39 -0400 Subject: [PATCH 5/5] add comments for v7 consider removing minimal level of pruning built into healSchema and mapSchema in the next major version, as built in pruning makes it impossible to build unpruned schemas, which may be of use to some library users --- packages/utils/src/heal.ts | 11 +++++++++++ packages/utils/src/rewire.ts | 10 ++++++++++ 2 files changed, 21 insertions(+) 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/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