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