graphql-tools
graphql-tools copied to clipboard
pruneSchema doesn't prune unused implementations of interfaces
Describe the bug
pruneSchema will keep all implementations of an interface regardless of whether it is used or not.
To Reproduce Steps to reproduce the behavior:
Prune the following schema:
type Query {
operation: SomeType
}
interface SomeInterface {
field: String
}
type SomeType implements SomeInterface {
field: String
}
type ShouldPrune implements SomeInterface {
field: String
}
Expected behavior
ShouldPrune should be pruned.
Environment:
- OS: MacOS
@graphql-tools/utils: 8.6.5- NodeJS: 16.9.1
Additional context
In visitOutputType the following will automatically flag all implementations of an interface for retaining:
if (graphql.isInterfaceType(type)) {
const implementations = getImplementations(pruningContext, type);
if (implementations) {
for (const typeName in implementations) {
visitOutputType(visitedTypes, pruningContext, pruningContext.schema.getType(typeName));
}
}
}
I took a stab at fixing this and found a fairly simplified way to prune the schema. I'll submit a PR if it looks sane. All tests still pass.
export function pruneSchema(schema: GraphQLSchema, options: PruneSchemaOptions = {}): GraphQLSchema {
const {
skipEmptyCompositeTypePruning,
skipEmptyUnionPruning,
skipPruning,
skipUnimplementedInterfacesPruning,
skipUnusedTypesPruning
} = options;
const queue: string[] = []; //queue of nodes to visit
const visited: Set<string> = new Set<string>(); //set of visited nodes (and therefore these are nodes to keep)
//Grab the root types and start there
for (const type of getRootTypes(schema)) {
queue.push(type.name); //Want to keep these types
}
//Navigate all types starting with pre-queued types (root types)
while (queue.length) {
const typeName = queue.pop() as string;
//Skip types we already visited
if (visited.has(typeName)) {
continue;
}
const type = schema.getType(typeName) as GraphQLNamedType;
//Get types for union
if (isUnionType(type)) {
queue.push(...type.getTypes().map(type => type.name));
}
//If the type has files visit those field types
if ('getFields' in type) {
const fields = type.getFields() as GraphQLFieldMap<any, any>;
const entries = Object.entries(fields);
if (!entries.length) {
continue;
}
for (const [, field] of entries) {
if (isInputObjectType(type)) {
for (const arg of field.args) {
queue.push(getNamedType(arg.type).name); //Visit arg types
}
}
queue.push(getNamedType(field.type).name);
}
}
//Visit interfaces this type is implementing if they haven't been visited yet
if ('getInterfaces' in type) {
queue.push(...type.getInterfaces().map(iface => iface.name));
}
visited.add(typeName); //Mark as visited (and therefore it is used and should be kept)
}
//Pruned types during mapping
const prunedTypes: string[] = [];
const prunedSchema: GraphQLSchema = mapSchema(schema, {
[MapperKind.TYPE]: (type) => {
if (!visited.has(type.name) && !isSpecifiedScalarType(type)) {
if (skipPruning && skipPruning(type)) {
return type;
}
if (isUnionType(type) || isInputObjectType(type) || isInterfaceType(type) || isObjectType(type)) {
//skipUnusedTypesPruning: skip pruning unused types
if (skipUnusedTypesPruning) {
return type;
}
//skipEmptyUnionPruning: skip pruning empty unions
if (isUnionType(type) && skipEmptyUnionPruning && !Object.keys(type.getTypes()).length) {
return type;
}
if (isInputObjectType(type) || isInterfaceType(type) || isObjectType(type)) {
//skipEmptyCompositeTypePruning: skip pruning object types or interfaces with no fields
if (skipEmptyCompositeTypePruning && !Object.keys(type.getFields()).length) {
return type;
}
}
//skipUnimplementedInterfacesPruning: skip pruning interfaces that are not implemented by any other types
if (isInterfaceType(type) && skipUnimplementedInterfacesPruning) {
return type;
}
}
prunedTypes.push(type.name);
return null;
}
return type;
},
});
//Might have empty types and need to prune again
return prunedTypes.length ? pruneSchema(prunedSchema, options) : prunedSchema;
}
That looks right, start from root types rather than type map