import { DocumentNode, SelectionNode, SelectionSetNode, OperationDefinitionNode, FieldNode, DirectiveNode, FragmentDefinitionNode, ArgumentNode, FragmentSpreadNode, VariableDefinitionNode, VariableNode, } from 'graphql'; import { visit } from 'graphql/language/visitor'; import { checkDocument, getOperationDefinition, getFragmentDefinition, getFragmentDefinitions, createFragmentMap, FragmentMap, getMainDefinition, } from './getFromAST'; import { filterInPlace } from './util/filterInPlace'; import { invariant } from 'ts-invariant'; import { isField, isInlineFragment } from './storeUtils'; export type RemoveNodeConfig<N> = { name?: string; test?: (node: N) => boolean; remove?: boolean; }; export type GetNodeConfig<N> = { name?: string; test?: (node: N) => boolean; }; export type RemoveDirectiveConfig = RemoveNodeConfig<DirectiveNode>; export type GetDirectiveConfig = GetNodeConfig<DirectiveNode>; export type RemoveArgumentsConfig = RemoveNodeConfig<ArgumentNode>; export type GetFragmentSpreadConfig = GetNodeConfig<FragmentSpreadNode>; export type RemoveFragmentSpreadConfig = RemoveNodeConfig<FragmentSpreadNode>; export type RemoveFragmentDefinitionConfig = RemoveNodeConfig< FragmentDefinitionNode >; export type RemoveVariableDefinitionConfig = RemoveNodeConfig< VariableDefinitionNode >; const TYPENAME_FIELD: FieldNode = { kind: 'Field', name: { kind: 'Name', value: '__typename', }, }; function isEmpty( op: OperationDefinitionNode | FragmentDefinitionNode, fragments: FragmentMap, ): boolean { return op.selectionSet.selections.every( selection => selection.kind === 'FragmentSpread' && isEmpty(fragments[selection.name.value], fragments), ); } function nullIfDocIsEmpty(doc: DocumentNode) { return isEmpty( getOperationDefinition(doc) || getFragmentDefinition(doc), createFragmentMap(getFragmentDefinitions(doc)), ) ? null : doc; } function getDirectiveMatcher( directives: (RemoveDirectiveConfig | GetDirectiveConfig)[], ) { return function directiveMatcher(directive: DirectiveNode) { return directives.some( dir => (dir.name && dir.name === directive.name.value) || (dir.test && dir.test(directive)), ); }; } export function removeDirectivesFromDocument( directives: RemoveDirectiveConfig[], doc: DocumentNode, ): DocumentNode | null { const variablesInUse: Record<string, boolean> = Object.create(null); let variablesToRemove: RemoveArgumentsConfig[] = []; const fragmentSpreadsInUse: Record<string, boolean> = Object.create(null); let fragmentSpreadsToRemove: RemoveFragmentSpreadConfig[] = []; let modifiedDoc = nullIfDocIsEmpty( visit(doc, { Variable: { enter(node, _key, parent) { // Store each variable that's referenced as part of an argument // (excluding operation definition variables), so we know which // variables are being used. If we later want to remove a variable // we'll fist check to see if it's being used, before continuing with // the removal. if ( (parent as VariableDefinitionNode).kind !== 'VariableDefinition' ) { variablesInUse[node.name.value] = true; } }, }, Field: { enter(node) { if (directives && node.directives) { // If `remove` is set to true for a directive, and a directive match // is found for a field, remove the field as well. const shouldRemoveField = directives.some( directive => directive.remove, ); if ( shouldRemoveField && node.directives && node.directives.some(getDirectiveMatcher(directives)) ) { if (node.arguments) { // Store field argument variables so they can be removed // from the operation definition. node.arguments.forEach(arg => { if (arg.value.kind === 'Variable') { variablesToRemove.push({ name: (arg.value as VariableNode).name.value, }); } }); } if (node.selectionSet) { // Store fragment spread names so they can be removed from the // docuemnt. getAllFragmentSpreadsFromSelectionSet(node.selectionSet).forEach( frag => { fragmentSpreadsToRemove.push({ name: frag.name.value, }); }, ); } // Remove the field. return null; } } }, }, FragmentSpread: { enter(node) { // Keep track of referenced fragment spreads. This is used to // determine if top level fragment definitions should be removed. fragmentSpreadsInUse[node.name.value] = true; }, }, Directive: { enter(node) { // If a matching directive is found, remove it. if (getDirectiveMatcher(directives)(node)) { return null; } }, }, }), ); // If we've removed fields with arguments, make sure the associated // variables are also removed from the rest of the document, as long as they // aren't being used elsewhere. if ( modifiedDoc && filterInPlace(variablesToRemove, v => !variablesInUse[v.name]).length ) { modifiedDoc = removeArgumentsFromDocument(variablesToRemove, modifiedDoc); } // If we've removed selection sets with fragment spreads, make sure the // associated fragment definitions are also removed from the rest of the // document, as long as they aren't being used elsewhere. if ( modifiedDoc && filterInPlace(fragmentSpreadsToRemove, fs => !fragmentSpreadsInUse[fs.name]) .length ) { modifiedDoc = removeFragmentSpreadFromDocument( fragmentSpreadsToRemove, modifiedDoc, ); } return modifiedDoc; } export function addTypenameToDocument(doc: DocumentNode): DocumentNode { return visit(checkDocument(doc), { SelectionSet: { enter(node, _key, parent) { // Don't add __typename to OperationDefinitions. if ( parent && (parent as OperationDefinitionNode).kind === 'OperationDefinition' ) { return; } // No changes if no selections. const { selections } = node; if (!selections) { return; } // If selections already have a __typename, or are part of an // introspection query, do nothing. const skip = selections.some(selection => { return ( isField(selection) && (selection.name.value === '__typename' || selection.name.value.lastIndexOf('__', 0) === 0) ); }); if (skip) { return; } // If this SelectionSet is @export-ed as an input variable, it should // not have a __typename field (see issue #4691). const field = parent as FieldNode; if ( isField(field) && field.directives && field.directives.some(d => d.name.value === 'export') ) { return; } // Create and return a new SelectionSet with a __typename Field. return { ...node, selections: [...selections, TYPENAME_FIELD], }; }, }, }); } const connectionRemoveConfig = { test: (directive: DirectiveNode) => { const willRemove = directive.name.value === 'connection'; if (willRemove) { if ( !directive.arguments || !directive.arguments.some(arg => arg.name.value === 'key') ) { invariant.warn( 'Removing an @connection directive even though it does not have a key. ' + 'You may want to use the key parameter to specify a store key.', ); } } return willRemove; }, }; export function removeConnectionDirectiveFromDocument(doc: DocumentNode) { return removeDirectivesFromDocument( [connectionRemoveConfig], checkDocument(doc), ); } function hasDirectivesInSelectionSet( directives: GetDirectiveConfig[], selectionSet: SelectionSetNode, nestedCheck = true, ): boolean { return ( selectionSet && selectionSet.selections && selectionSet.selections.some(selection => hasDirectivesInSelection(directives, selection, nestedCheck), ) ); } function hasDirectivesInSelection( directives: GetDirectiveConfig[], selection: SelectionNode, nestedCheck = true, ): boolean { if (!isField(selection)) { return true; } if (!selection.directives) { return false; } return ( selection.directives.some(getDirectiveMatcher(directives)) || (nestedCheck && hasDirectivesInSelectionSet( directives, selection.selectionSet, nestedCheck, )) ); } export function getDirectivesFromDocument( directives: GetDirectiveConfig[], doc: DocumentNode, ): DocumentNode { checkDocument(doc); let parentPath: string; return nullIfDocIsEmpty( visit(doc, { SelectionSet: { enter(node, _key, _parent, path) { const currentPath = path.join('-'); if ( !parentPath || currentPath === parentPath || !currentPath.startsWith(parentPath) ) { if (node.selections) { const selectionsWithDirectives = node.selections.filter( selection => hasDirectivesInSelection(directives, selection), ); if (hasDirectivesInSelectionSet(directives, node, false)) { parentPath = currentPath; } return { ...node, selections: selectionsWithDirectives, }; } else { return null; } } }, }, }), ); } function getArgumentMatcher(config: RemoveArgumentsConfig[]) { return function argumentMatcher(argument: ArgumentNode) { return config.some( (aConfig: RemoveArgumentsConfig) => argument.value && argument.value.kind === 'Variable' && argument.value.name && (aConfig.name === argument.value.name.value || (aConfig.test && aConfig.test(argument))), ); }; } export function removeArgumentsFromDocument( config: RemoveArgumentsConfig[], doc: DocumentNode, ): DocumentNode { const argMatcher = getArgumentMatcher(config); return nullIfDocIsEmpty( visit(doc, { OperationDefinition: { enter(node) { return { ...node, // Remove matching top level variables definitions. variableDefinitions: node.variableDefinitions.filter( varDef => !config.some(arg => arg.name === varDef.variable.name.value), ), }; }, }, Field: { enter(node) { // If `remove` is set to true for an argument, and an argument match // is found for a field, remove the field as well. const shouldRemoveField = config.some(argConfig => argConfig.remove); if (shouldRemoveField) { let argMatchCount = 0; node.arguments.forEach(arg => { if (argMatcher(arg)) { argMatchCount += 1; } }); if (argMatchCount === 1) { return null; } } }, }, Argument: { enter(node) { // Remove all matching arguments. if (argMatcher(node)) { return null; } }, }, }), ); } export function removeFragmentSpreadFromDocument( config: RemoveFragmentSpreadConfig[], doc: DocumentNode, ): DocumentNode { function enter( node: FragmentSpreadNode | FragmentDefinitionNode, ): null | void { if (config.some(def => def.name === node.name.value)) { return null; } } return nullIfDocIsEmpty( visit(doc, { FragmentSpread: { enter }, FragmentDefinition: { enter }, }), ); } function getAllFragmentSpreadsFromSelectionSet( selectionSet: SelectionSetNode, ): FragmentSpreadNode[] { const allFragments: FragmentSpreadNode[] = []; selectionSet.selections.forEach(selection => { if ( (isField(selection) || isInlineFragment(selection)) && selection.selectionSet ) { getAllFragmentSpreadsFromSelectionSet(selection.selectionSet).forEach( frag => allFragments.push(frag), ); } else if (selection.kind === 'FragmentSpread') { allFragments.push(selection); } }); return allFragments; } // If the incoming document is a query, return it as is. Otherwise, build a // new document containing a query operation based on the selection set // of the previous main operation. export function buildQueryFromSelectionSet( document: DocumentNode, ): DocumentNode { const definition = getMainDefinition(document); const definitionOperation = (<OperationDefinitionNode>definition).operation; if (definitionOperation === 'query') { // Already a query, so return the existing document. return document; } // Build a new query using the selection set of the main operation. const modifiedDoc = visit(document, { OperationDefinition: { enter(node) { return { ...node, operation: 'query', }; }, }, }); return modifiedDoc; } // Remove fields / selection sets that include an @client directive. export function removeClientSetsFromDocument( document: DocumentNode, ): DocumentNode | null { checkDocument(document); let modifiedDoc = removeDirectivesFromDocument( [ { test: (directive: DirectiveNode) => directive.name.value === 'client', remove: true, }, ], document, ); // After a fragment definition has had its @client related document // sets removed, if the only field it has left is a __typename field, // remove the entire fragment operation to prevent it from being fired // on the server. if (modifiedDoc) { modifiedDoc = visit(modifiedDoc, { FragmentDefinition: { enter(node) { if (node.selectionSet) { const isTypenameOnly = node.selectionSet.selections.every( selection => isField(selection) && selection.name.value === '__typename', ); if (isTypenameOnly) { return null; } } }, }, }); } return modifiedDoc; }