@apollo/client
Version:
A fully-featured caching GraphQL client.
300 lines (299 loc) • 14.5 kB
JavaScript
import { Kind, visit } from "graphql";
import { invariant } from "@apollo/client/utilities/invariant";
import { checkDocument } from "./checkDocument.js";
import { createFragmentMap } from "./createFragmentMap.js";
import { getFragmentDefinition } from "./getFragmentDefinition.js";
import { getFragmentDefinitions } from "./getFragmentDefinitions.js";
import { getOperationDefinition } from "./getOperationDefinition.js";
import { isArray } from "./isArray.js";
import { isNonEmptyArray } from "./isNonEmptyArray.js";
/**
* @internal
*
* @deprecated This is an internal API and should not be used directly. This can be removed or changed at any time.
*/
export function removeDirectivesFromDocument(directives, doc) {
checkDocument(doc);
// Passing empty strings to makeInUseGetterFunction means we handle anonymous
// operations as if their names were "". Anonymous fragment definitions are
// not supposed to be possible, but the same default naming strategy seems
// appropriate for that case as well.
const getInUseByOperationName = makeInUseGetterFunction("");
const getInUseByFragmentName = makeInUseGetterFunction("");
const getInUse = (ancestors) => {
for (let p = 0, ancestor; p < ancestors.length && (ancestor = ancestors[p]); ++p) {
if (isArray(ancestor))
continue;
if (ancestor.kind === Kind.OPERATION_DEFINITION) {
// If an operation is anonymous, we use the empty string as its key.
return getInUseByOperationName(ancestor.name && ancestor.name.value);
}
if (ancestor.kind === Kind.FRAGMENT_DEFINITION) {
return getInUseByFragmentName(ancestor.name.value);
}
}
invariant.error(14);
return null;
};
let operationCount = 0;
for (let i = doc.definitions.length - 1; i >= 0; --i) {
if (doc.definitions[i].kind === Kind.OPERATION_DEFINITION) {
++operationCount;
}
}
const directiveMatcher = getDirectiveMatcher(directives);
const shouldRemoveField = (nodeDirectives) => isNonEmptyArray(nodeDirectives) &&
nodeDirectives
.map(directiveMatcher)
.some((config) => config && config.remove);
const originalFragmentDefsByPath = new Map();
// Any time the first traversal of the document below makes a change like
// removing a fragment (by returning null), this variable should be set to
// true. Once it becomes true, it should never be set to false again. If this
// variable remains false throughout the traversal, then we can return the
// original doc immediately without any modifications.
let firstVisitMadeChanges = false;
const fieldOrInlineFragmentVisitor = {
enter(node) {
if (shouldRemoveField(node.directives)) {
firstVisitMadeChanges = true;
return null;
}
},
};
const docWithoutDirectiveSubtrees = visit(doc, {
// These two AST node types share the same implementation, defined above.
Field: fieldOrInlineFragmentVisitor,
InlineFragment: fieldOrInlineFragmentVisitor,
VariableDefinition: {
enter() {
// VariableDefinition nodes do not count as variables in use, though
// they do contain Variable nodes that might be visited below. To avoid
// counting variable declarations as usages, we skip visiting the
// contents of this VariableDefinition node by returning false.
return false;
},
},
Variable: {
enter(node, _key, _parent, _path, ancestors) {
const inUse = getInUse(ancestors);
if (inUse) {
inUse.variables.add(node.name.value);
}
},
},
FragmentSpread: {
enter(node, _key, _parent, _path, ancestors) {
if (shouldRemoveField(node.directives)) {
firstVisitMadeChanges = true;
return null;
}
const inUse = getInUse(ancestors);
if (inUse) {
inUse.fragmentSpreads.add(node.name.value);
}
// We might like to remove this FragmentSpread by returning null here if
// the corresponding FragmentDefinition node is also going to be removed
// by the logic below, but we can't control the relative order of those
// events, so we have to postpone the removal of dangling FragmentSpread
// nodes until after the current visit of the document has finished.
},
},
FragmentDefinition: {
enter(node, _key, _parent, path) {
originalFragmentDefsByPath.set(JSON.stringify(path), node);
},
leave(node, _key, _parent, path) {
const originalNode = originalFragmentDefsByPath.get(JSON.stringify(path));
if (node === originalNode) {
// If the FragmentNode received by this leave function is identical to
// the one received by the corresponding enter function (above), then
// the visitor must not have made any changes within this
// FragmentDefinition node. This fragment definition may still be
// removed if there are no ...spread references to it, but it won't be
// removed just because it has only a __typename field.
return node;
}
if (
// This logic applies only if the document contains one or more
// operations, since removing all fragments from a document containing
// only fragments makes the document useless.
operationCount > 0 &&
node.selectionSet.selections.every((selection) => selection.kind === Kind.FIELD &&
selection.name.value === "__typename")) {
// This is a somewhat opinionated choice: if a FragmentDefinition ends
// up having no fields other than __typename, we remove the whole
// fragment definition, and later prune ...spread references to it.
getInUseByFragmentName(node.name.value).removed = true;
firstVisitMadeChanges = true;
return null;
}
},
},
Directive: {
leave(node) {
// If a matching directive is found, remove the directive itself. Note
// that this does not remove the target (field, argument, etc) of the
// directive, but only the directive itself.
if (directiveMatcher(node)) {
firstVisitMadeChanges = true;
return null;
}
},
},
});
if (!firstVisitMadeChanges) {
// If our first pass did not change anything about the document, then there
// is no cleanup we need to do, and we can return the original doc.
return doc;
}
// Utility for making sure inUse.transitiveVars is recursively populated.
// Because this logic assumes inUse.fragmentSpreads has been completely
// populated and inUse.removed has been set if appropriate,
// populateTransitiveVars must be called after that information has been
// collected by the first traversal of the document.
const populateTransitiveVars = (inUse) => {
if (!inUse.transitiveVars) {
inUse.transitiveVars = new Set(inUse.variables);
if (!inUse.removed) {
inUse.fragmentSpreads.forEach((childFragmentName) => {
populateTransitiveVars(getInUseByFragmentName(childFragmentName)).transitiveVars.forEach((varName) => {
inUse.transitiveVars.add(varName);
});
});
}
}
return inUse;
};
// Since we've been keeping track of fragment spreads used by particular
// operations and fragment definitions, we now need to compute the set of all
// spreads used (transitively) by any operations in the document.
const allFragmentNamesUsed = new Set();
docWithoutDirectiveSubtrees.definitions.forEach((def) => {
if (def.kind === Kind.OPERATION_DEFINITION) {
populateTransitiveVars(getInUseByOperationName(def.name && def.name.value)).fragmentSpreads.forEach((childFragmentName) => {
allFragmentNamesUsed.add(childFragmentName);
});
}
else if (def.kind === Kind.FRAGMENT_DEFINITION &&
// If there are no operations in the document, then all fragment
// definitions count as usages of their own fragment names. This heuristic
// prevents accidentally removing all fragment definitions from the
// document just because it contains no operations that use the fragments.
operationCount === 0 &&
!getInUseByFragmentName(def.name.value).removed) {
allFragmentNamesUsed.add(def.name.value);
}
});
// Now that we have added all fragment spreads used by operations to the
// allFragmentNamesUsed set, we can complete the set by transitively adding
// all fragment spreads used by those fragments, and so on.
allFragmentNamesUsed.forEach((fragmentName) => {
// Once all the childFragmentName strings added here have been seen already,
// the top-level allFragmentNamesUsed.forEach loop will terminate.
populateTransitiveVars(getInUseByFragmentName(fragmentName)).fragmentSpreads.forEach((childFragmentName) => {
allFragmentNamesUsed.add(childFragmentName);
});
});
const fragmentWillBeRemoved = (fragmentName) => !!(
// A fragment definition will be removed if there are no spreads that refer
// to it, or the fragment was explicitly removed because it had no fields
// other than __typename.
(!allFragmentNamesUsed.has(fragmentName) ||
getInUseByFragmentName(fragmentName).removed));
const enterVisitor = {
enter(node) {
if (fragmentWillBeRemoved(node.name.value)) {
return null;
}
},
};
return nullIfDocIsEmpty(visit(docWithoutDirectiveSubtrees, {
// If the fragment is going to be removed, then leaving any dangling
// FragmentSpread nodes with the same name would be a mistake.
FragmentSpread: enterVisitor,
// This is where the fragment definition is actually removed.
FragmentDefinition: enterVisitor,
OperationDefinition: {
leave(node) {
// Upon leaving each operation in the depth-first AST traversal, prune
// any variables that are declared by the operation but unused within.
if (node.variableDefinitions) {
const usedVariableNames = populateTransitiveVars(
// If an operation is anonymous, we use the empty string as its key.
getInUseByOperationName(node.name && node.name.value)).transitiveVars;
// According to the GraphQL spec, all variables declared by an
// operation must either be used by that operation or used by some
// fragment included transitively into that operation:
// https://spec.graphql.org/draft/#sec-All-Variables-Used
//
// To stay on the right side of this validation rule, if/when we
// remove the last $var references from an operation or its fragments,
// we must also remove the corresponding $var declaration from the
// enclosing operation. This pruning applies only to operations and
// not fragment definitions, at the moment. Fragments may be able to
// declare variables eventually, but today they can only consume them.
if (usedVariableNames.size < node.variableDefinitions.length) {
return {
...node,
variableDefinitions: node.variableDefinitions.filter((varDef) => usedVariableNames.has(varDef.variable.name.value)),
};
}
}
},
},
}));
}
function makeInUseGetterFunction(defaultKey) {
const map = new Map();
return function inUseGetterFunction(key = defaultKey) {
let inUse = map.get(key);
if (!inUse) {
map.set(key, (inUse = {
// Variable and fragment spread names used directly within this
// operation or fragment definition, as identified by key. These sets
// will be populated during the first traversal of the document in
// removeDirectivesFromDocument below.
variables: new Set(),
fragmentSpreads: new Set(),
}));
}
return inUse;
};
}
function getDirectiveMatcher(configs) {
const names = new Map();
const tests = new Map();
configs.forEach((directive) => {
if (directive) {
if (directive.name) {
names.set(directive.name, directive);
}
else if (directive.test) {
tests.set(directive.test, directive);
}
}
});
return (directive) => {
let config = names.get(directive.name.value);
if (!config && tests.size) {
tests.forEach((testConfig, test) => {
if (test(directive)) {
config = testConfig;
}
});
}
return config;
};
}
function isEmpty(op, fragmentMap) {
return (!op ||
op.selectionSet.selections.every((selection) => selection.kind === Kind.FRAGMENT_SPREAD &&
isEmpty(fragmentMap[selection.name.value], fragmentMap)));
}
function nullIfDocIsEmpty(doc) {
return (isEmpty(getOperationDefinition(doc) || getFragmentDefinition(doc), createFragmentMap(getFragmentDefinitions(doc)))) ?
null
: doc;
}
//# sourceMappingURL=removeDirectivesFromDocument.js.map