Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

introduce pruneSchema function, PruneSchema transform, and pruneSchema options for makeExecutableSchema and stitchSchemas #1632

Merged
merged 5 commits into from Jun 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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