graphql-tools icon indicating copy to clipboard operation
graphql-tools copied to clipboard

pruneSchema doesn't prune unused implementations of interfaces

Open tlivings opened this issue 3 years ago • 2 comments

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));
        }
    }
}

tlivings avatar Apr 05 '22 16:04 tlivings

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;
}

tlivings avatar Apr 08 '22 15:04 tlivings

That looks right, start from root types rather than type map

yaacovCR avatar Apr 11 '22 08:04 yaacovCR