Skip to content

Commit

Permalink
introduce pruneSchema function, PruneSchema transform, and pruneSchem…
Browse files Browse the repository at this point in the history
…a options for makeExecutableSchema and stitchSchemas (#1632)

Docs and tests for the below new functionality are sorely lacking. As this introduces no changes to existing functionality, deferring that at this time, but PRs (and bug reports) are very, very welcome.

* introduce pruneSchema

* add PruneSchemaOptions

* introduce PruneSchema transform

* add pruning options to makeExecutableSchema and stitchSchemas

* 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
  • Loading branch information
yaacovCR committed Jun 11, 2020
1 parent b74ebcd commit fa07fd1
Show file tree
Hide file tree
Showing 9 changed files with 235 additions and 4 deletions.
5 changes: 3 additions & 2 deletions 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';
Expand All @@ -22,6 +22,7 @@ export function makeExecutableSchema<TContext = any>({
schemaTransforms = [],
parseOptions = {},
inheritResolversFromInterfaces = false,
pruningOptions,
}: IExecutableSchemaDefinition<TContext>) {
// Validate and clean up arguments
if (typeof resolverValidationOptions !== 'object') {
Expand Down Expand Up @@ -76,5 +77,5 @@ export function makeExecutableSchema<TContext = any>({
SchemaDirectiveVisitor.visitSchemaDirectives(schema, schemaDirectives);
}

return schema;
return pruningOptions ? pruneSchema(schema, pruningOptions) : schema;
}
2 changes: 2 additions & 0 deletions packages/schema/src/types.ts
Expand Up @@ -6,6 +6,7 @@ import {
SchemaDirectiveVisitorClass,
GraphQLParseOptions,
SchemaTransform,
PruneSchemaOptions,
} from '@graphql-tools/utils';

export interface ILogger {
Expand All @@ -23,4 +24,5 @@ export interface IExecutableSchemaDefinition<TContext = any> {
schemaTransforms?: Array<SchemaTransform>;
parseOptions?: GraphQLParseOptions;
inheritResolversFromInterfaces?: boolean;
pruningOptions: PruneSchemaOptions;
}
12 changes: 10 additions & 2 deletions packages/stitch/src/stitchSchemas.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 11 additions & 0 deletions packages/utils/src/heal.ts
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<string, GraphQLNamedType | null>, directives: ReadonlyArray<GraphQLDirective>) {
const implementedInterfaces = {};
Object.values(typeMap).forEach(namedType => {
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/index.ts
Expand Up @@ -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';
Expand Down
182 changes: 182 additions & 0 deletions 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<string, boolean>;
implementations: Record<string, Record<string, boolean>>;
}

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<string, boolean>,
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<string, boolean>,
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<string, boolean> = 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);
});
});
}
10 changes: 10 additions & 0 deletions packages/utils/src/rewire.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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<GraphQLDirective>
Expand Down
15 changes: 15 additions & 0 deletions 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);
}
}
1 change: 1 addition & 0 deletions packages/wrap/src/transforms/index.ts
Expand Up @@ -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';
Expand Down

0 comments on commit fa07fd1

Please sign in to comment.