UNPKG

apollo-language-server

Version:

A language server for Apollo GraphQL projects

433 lines (386 loc) 11.9 kB
import { GraphQLSchema, GraphQLCompositeType, GraphQLField, FieldNode, SchemaMetaFieldDef, TypeMetaFieldDef, TypeNameMetaFieldDef, ASTNode, Kind, NameNode, visit, print, DirectiveNode, SelectionSetNode, DirectiveDefinitionNode, isObjectType, isInterfaceType, isUnionType, FragmentDefinitionNode, InlineFragmentNode, } from "graphql"; import { ExecutionContext } from "graphql/execution/execute"; export function isNode(maybeNode: any): maybeNode is ASTNode { return maybeNode && typeof maybeNode.kind === "string"; } export type NamedNode = ASTNode & { name: NameNode; }; export function isNamedNode(node: ASTNode): node is NamedNode { return "name" in node; } export function isDirectiveDefinitionNode( node: ASTNode ): node is DirectiveDefinitionNode { return node.kind === Kind.DIRECTIVE_DEFINITION; } export function highlightNodeForNode(node: ASTNode): ASTNode { switch (node.kind) { case Kind.VARIABLE_DEFINITION: return node.variable; default: return isNamedNode(node) ? node.name : node; } } /** * Not exactly the same as the executor's definition of getFieldDef, in this * statically evaluated environment we do not always have an Object type, * and need to handle Interface and Union types. */ export function getFieldDef( schema: GraphQLSchema, parentType: GraphQLCompositeType, fieldAST: FieldNode ): GraphQLField<any, any> | undefined { const name = fieldAST.name.value; if ( name === SchemaMetaFieldDef.name && schema.getQueryType() === parentType ) { return SchemaMetaFieldDef; } if (name === TypeMetaFieldDef.name && schema.getQueryType() === parentType) { return TypeMetaFieldDef; } if ( name === TypeNameMetaFieldDef.name && (isObjectType(parentType) || isInterfaceType(parentType) || isUnionType(parentType)) ) { return TypeNameMetaFieldDef; } if (isObjectType(parentType) || isInterfaceType(parentType)) { return parentType.getFields()[name]; } return undefined; } /** * Remove specific directives * * The `ast` param must extend ASTNode. We use a generic to indicate that this function returns the same type * of it's first parameter. */ export function removeDirectives<AST extends ASTNode>( ast: AST, directiveNames: string[] ): AST { if (!directiveNames.length) return ast; return visit(ast, { Directive(node: DirectiveNode): DirectiveNode | null { if (!!directiveNames.find((name) => name === node.name.value)) return null; return node; }, }); } /** * Recursively remove orphaned fragment definitions that have their names included in * `fragmentNamesEligibleForRemoval` * * We expclitily require the fragments to be listed in `fragmentNamesEligibleForRemoval` so we only strip * fragments that were orphaned by an operation, not fragments that started as oprhans * * The `ast` param must extend ASTNode. We use a generic to indicate that this function returns the same type * of it's first parameter. */ function removeOrphanedFragmentDefinitions<AST extends ASTNode>( ast: AST, fragmentNamesEligibleForRemoval: Set<string> ): AST { /** * Flag to keep track of removing any fragments */ let anyFragmentsRemoved = false; // Aquire names of all fragment spreads const fragmentSpreadNodeNames = new Set<string>(); visit(ast, { FragmentSpread(node) { fragmentSpreadNodeNames.add(node.name.value); }, }); // Strip unused fragment definitions. Flag if we've removed any so we know if we need to continue // recursively checking. ast = visit(ast, { FragmentDefinition(node) { if ( fragmentNamesEligibleForRemoval.has(node.name.value) && !fragmentSpreadNodeNames.has(node.name.value) ) { // This definition is not used, remove it. anyFragmentsRemoved = true; return null; } return undefined; }, }); if (anyFragmentsRemoved) { /* Handles the special case where a Fragment was not removed because it was not yet orphaned when being `visit`ed. As an example: ```jsx fragment Two on Node { id } fragment One on Query { hero { ...Two @client } } { ...One } ``` On the first visit, `Two` will not be removed. After `One` is removed, `Two` becomes orphaned. If any nodes were removed on this pass; run another pass to see if there are more nodes that are now orphaned. */ return removeOrphanedFragmentDefinitions( ast, fragmentNamesEligibleForRemoval ); } return ast; } /** * Remove nodes that have zero-length selection sets * * The `ast` param must extend ASTNode. We use a generic to indicate that this function returns the same type * of it's first parameter. */ function removeNodesWithEmptySelectionSets<AST extends ASTNode>(ast: AST): AST { ast = visit(ast, { enter(node) { // If this node _has_ a `selectionSet` and it's zero-length, then remove it. return "selectionSet" in node && node.selectionSet != null && node.selectionSet.selections.length === 0 ? null : undefined; }, }); return ast; } /** * Remove nodes from `ast` when they have a directive in `directiveNames` * * The `ast` param must extend ASTNode. We use a generic to indicate that this function returns the same type * of it's first parameter. */ export function removeDirectiveAnnotatedFields<AST extends ASTNode>( ast: AST, directiveNames: string[] ): AST { print; if (!directiveNames.length) return ast; /** * All fragment definition names we've removed due to a matching directive * * We keep track of these so we can remove associated spreads */ const removedFragmentDefinitionNames = new Set<string>(); /** * All fragment spreads that have been removed * * We can only remove fragment definitions for fragment spreads that we've removed */ const removedFragmentSpreadNames = new Set<string>(); // Remove all nodes with a matching directive in `directiveNames`. Also, remove any operations that now have // no selection set ast = visit(ast, { enter(node) { // Strip all nodes that contain a directive we wish to remove if ( "directives" in node && node.directives && node.directives.find((directive) => directiveNames.includes(directive.name.value) ) ) { /* If we're removing a fragment definition then save the name so we can remove anywhere this fragment was spread. This happens when a fragment definition itself has a matching directive on it, like this (assuming that `@client` is a directive we want to remove): ```graphql fragment SomeFragmentDefinition on SomeType @client { fields } ``` */ if (node.kind === Kind.FRAGMENT_DEFINITION) { removedFragmentDefinitionNames.add(node.name.value); } /* This node is going to be removed. Mark all fragment spreads nested under this node as eligible for removal from the document. For example, assuming `@client` is a directive we want to remove: ```graphql clientObject @client { ...ClientObjectFragment } ``` We're going to remove `clientObject` here, which will also remove `ClientObjectFragment`. If there are no other instances of `ClientObjectFragment`, we're goign to remove it's definition as well. We only remove definitions for spreads we've removed so we don't remove fragment definitions that were never spread; as this is the kind of error `client:check` is inteded to flag. */ visit(node, { FragmentSpread(node) { removedFragmentSpreadNames.add(node.name.value); }, }); // Remove this node return null; } return undefined; }, }); // For all fragment definitions we removed, also remove the fragment spreads ast = visit(ast, { FragmentSpread(node) { if (removedFragmentDefinitionNames.has(node.name.value)) { removedFragmentSpreadNames.add(node.name.value); return null; } return undefined; }, }); // Remove all orphaned fragment definitions ast = removeOrphanedFragmentDefinitions(ast, removedFragmentSpreadNames); // Finally, remove nodes with empty selection sets return removeNodesWithEmptySelectionSets(ast); } const typenameField = { kind: Kind.FIELD, name: { kind: Kind.NAME, value: "__typename" }, }; export function withTypenameFieldAddedWhereNeeded(ast: ASTNode) { return visit(ast, { enter: { SelectionSet(node: SelectionSetNode) { return { ...node, selections: node.selections.filter( (selection) => !( selection.kind === "Field" && (selection as FieldNode).name.value === "__typename" ) ), }; }, }, leave(node: ASTNode) { if ( !( node.kind === Kind.FIELD || node.kind === Kind.FRAGMENT_DEFINITION || node.kind === Kind.INLINE_FRAGMENT ) ) { return undefined; } if (!node.selectionSet) return undefined; return { ...node, selectionSet: { ...node.selectionSet, selections: [typenameField, ...node.selectionSet.selections], }, }; }, }); } function getFieldEntryKey(node: FieldNode): string { return node.alias ? node.alias.value : node.name.value; } // this is a simplified verison of the collect fields algorithm that the // reference implementation uses during execution // in this case, we don't care about boolean conditions of validating the // type conditions as other validation has done that already export function simpleCollectFields( context: ExecutionContext, selectionSet: SelectionSetNode, fields: Record<string, FieldNode[]>, visitedFragmentNames: Record<string, boolean> ): Record<string, FieldNode[]> { for (const selection of selectionSet.selections) { switch (selection.kind) { case Kind.FIELD: { const name = getFieldEntryKey(selection); if (!fields[name]) { fields[name] = []; } fields[name].push(selection); break; } case Kind.INLINE_FRAGMENT: { simpleCollectFields( context, selection.selectionSet, fields, visitedFragmentNames ); break; } case Kind.FRAGMENT_SPREAD: { const fragName = selection.name.value; if (visitedFragmentNames[fragName]) continue; visitedFragmentNames[fragName] = true; const fragment = context.fragments[fragName]; if (!fragment) continue; simpleCollectFields( context, fragment.selectionSet, fields, visitedFragmentNames ); break; } } } return fields; } export function hasClientDirective( node: FieldNode | InlineFragmentNode | FragmentDefinitionNode ) { return ( node.directives && node.directives.some((directive) => directive.name.value === "client") ); } export interface ClientSchemaInfo { localFields?: string[]; } declare module "graphql/type/definition" { interface GraphQLScalarType { clientSchema?: ClientSchemaInfo; } interface GraphQLObjectType { clientSchema?: ClientSchemaInfo; } interface GraphQLInterfaceType { clientSchema?: ClientSchemaInfo; } interface GraphQLUnionType { clientSchema?: ClientSchemaInfo; } interface GraphQLEnumType { clientSchema?: ClientSchemaInfo; } }