543 lines
14 KiB
TypeScript
543 lines
14 KiB
TypeScript
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;
|
|
}
|