@apollo/query-planner
Version:
Apollo Query Planner
251 lines (228 loc) • 10.7 kB
text/typescript
import {
assert,
Directive,
isNonEmptyArray,
isVariable,
NonEmptyArray,
OperationElement,
Selection,
selectionOfElement,
SelectionSet,
Variable,
VariableDefinitions
} from "@apollo/federation-internals";
import { extractOperationConditionals } from "@apollo/query-graphs";
import { ConditionNode } from ".";
export type VariableCondition = {
variable: Variable,
negated: boolean,
}
export type Condition = VariableCondition | boolean;
// The invariant maintained on this type are that if is an array of variable conditions, then:
// 1. that array is not empty (it has at least one condition).
// 2. that array has at most one condition for any given variable name.
export type Conditions = NonEmptyArray<VariableCondition> | boolean;
export function isConstantCondition(cond: Condition | Conditions): cond is boolean {
return typeof cond === 'boolean';
}
export function mergeConditions(conditions1: Conditions, conditions2: Conditions): Conditions {
if (isConstantCondition(conditions1)) {
return conditions1 ? conditions2 : false;
}
if (isConstantCondition(conditions2)) {
return conditions2 ? conditions1 : false;
}
// `Conditions` needs to maintains the invariant that it can have only a single `VariableCondition` for a given variable name.
// So we start with `conditions1`, and then adds all of `conditions2` but for condition that are already in `conditions1`. For
// those, if the negation is the same, then we just ignore the condition from `conditions2` (keeping only the one from `conditions1`).
// But if the negation is opposite, then it means the whole conditions are impossible and we just return false.
const merged: NonEmptyArray<VariableCondition> = [...conditions1];
for (const cond2 of conditions2) {
const cond1 = conditions1.find((c1) => c1.variable.name === cond2.variable.name);
if (cond1) {
if (cond1.negated !== cond2.negated) {
return false;
}
} else {
merged.push(cond2);
}
}
return merged;
}
function sameConditions(conditions1: Conditions, conditions2: Conditions): boolean {
if (isConstantCondition(conditions1)) {
return isConstantCondition(conditions2) && conditions1 === conditions2;
}
if (isConstantCondition(conditions2)) {
return false;
}
// We treat the array of variable conditions as a set, because that's what it is really.
return conditions1.length === conditions2.length
&& conditions1.every((cond1) => conditions2.some((cond2) => cond1.variable.name === cond2.variable.name && cond1.negated === cond2.negated));
}
export function conditionsOfSelectionSet(selectionSet: SelectionSet): Conditions {
// If the conditions of all the selections within the set are the same, then those are conditions of the whole set
// and we return it. Otherwise, we just return `true` (which essentially translate to "that selection always need
// to be queried"). Note that for the case where the set has only 1 selection, then this just mean we return
// the condition of that one selection. Also note that in theory we could be a tad more precise, and when
// all the selections have variable conditions, we could return the intersection of all of them, but
// we don't bother for now as that has probably extremely rarely an impact in practice.
const selections = selectionSet.selections();
if (selections.length === 0) {
// we shouldn't really get here for well-formed selection, so whether we return true or false doesn't matter
// too much, but in principle, if there is no selection, we should be cool not including it.
return false;
}
const conditions = conditionsOfSelection(selections[0]);
for (let i = 1; i < selections.length; i++) {
const otherConditions = conditionsOfSelection(selections[i]);
if (!sameConditions(conditions, otherConditions)) {
return true;
}
}
return conditions;
}
function conditionsOfSelection(selection: Selection): Conditions {
const elementConditions = conditionsOfElement(selection.element);
if (!selection.selectionSet) {
return elementConditions;
}
if (isConstantCondition(elementConditions)) {
// If we get a constant, and it's `false`, then it means that element is never
// included and no point in recursing, we can return false immediately.
//
// If it's `true`, then it means that element is included. If it is a field,
// then we should also stop and return `true`, because no matter what the
// sub-selection is, we need to get that field. But if it's a fragment, it
// doesn't really select anything by itself, so we can recurse in that case.
if (!elementConditions || selection.kind === 'FieldSelection') {
return elementConditions;
}
}
const selectionConditions = conditionsOfSelectionSet(selection.selectionSet);
return mergeConditions(elementConditions, selectionConditions);
}
function conditionsOfElement(element: OperationElement): Conditions {
const conditionals = extractOperationConditionals(element);
if (conditionals.length === 0) {
return true;
}
const conditions: VariableCondition[] = [];
for (const conditional of conditionals) {
const value = conditional.value;
if (typeof value === 'boolean') {
// We want to "resolve" @include/@skip that have a constant value. If that constant value means skipping
// (so 'skip' + true or 'include' + false), then we can skip the whole element (return `false`). But
// it it means "always include" then it's a useless condition we can ignore.
if (value === (conditional.kind === 'skip')) {
return false;
}
} else {
conditions.push({
variable: value,
negated: conditional.kind === 'skip',
});
}
}
if (isNonEmptyArray(conditions)) {
// Technically, users are not forbidden to write something useless like:
// ... on X @include(if: $x) @skip(if: $x)
// so if we want to maintain our invariant on `Conditions` that a variable only appear once, we need to check for
// that case manually.
if (conditions.length === 2 && conditions[0].variable.name === conditions[1].variable.name) {
// Note that neither @include or @skip are repeatable, so this is necessarily a @skip and an @include on the
// same variable, and this mean this element is always excluded.
return false;
}
return conditions;
}
return true;
}
export function updatedConditions(newConditions: Conditions, handledConditions: Conditions): Conditions {
if (isConstantCondition(newConditions) || isConstantCondition(handledConditions)) {
return newConditions;
}
const filtered: VariableCondition[] = [];
for (const cond of newConditions) {
const handledCond = handledConditions.find((r) => cond.variable.name === r.variable.name);
if (handledCond) {
// If we've already handled that exact condition, we can skip it.
// But if we've already handled the _negation_ of this condition, then this mean the overall conditions
// are unreachable and we can just return `false` directly.
if (cond.negated !== handledCond.negated) {
return false;
}
} else {
filtered.push(cond);
}
}
return isNonEmptyArray(filtered) ? filtered : true;
}
export function removeConditionsFromSelectionSet(selectionSet: SelectionSet, conditions: Conditions): SelectionSet {
if (isConstantCondition(conditions)) {
// If the conditions are the constant false, this means we know the selection will not be included
// in the plan in practice, and it doesn't matter too much what we return here. So we just
// the input unchanged as a shortcut.
// If the conditions are the constant true, then it means we have no conditions to remove and we can
// keep the selection "as is".
return selectionSet;
}
return selectionSet.lazyMap((selection) => {
// We remove any of the conditions on the element and recurse.
const updatedElement = removeConditionsOfElement(selection.element, conditions);
if (selection.selectionSet) {
const updatedSelectionSet = removeConditionsFromSelectionSet(selection.selectionSet, conditions);
if (updatedElement === selection.element) {
if (updatedSelectionSet === selection.selectionSet) {
return selection;
} else {
return selection.withUpdatedSelectionSet(updatedSelectionSet);
}
} else {
return selectionOfElement(updatedElement, updatedSelectionSet);
}
} else {
return updatedElement === selection.element ? selection : selectionOfElement(updatedElement);
}
});
}
function removeConditionsOfElement(element: OperationElement, conditions: VariableCondition[]): OperationElement {
const updatedDirectives = (element.appliedDirectives as Directive<OperationElement>[]).filter((d) => !matchesConditionForKind(d, conditions, 'include') && !matchesConditionForKind(d, conditions, 'skip'));
if (updatedDirectives.length === element.appliedDirectives.length) {
return element;
}
return element.withUpdatedDirectives(updatedDirectives);
}
function matchesConditionForKind(
directive: Directive<OperationElement>,
conditions: VariableCondition[],
kind: 'include' | 'skip'
): boolean {
if (directive.name !== kind) {
return false;
}
const value = directive.arguments()['if'];
return !isVariable(value) || conditions.some((cond) => cond.variable.name === value.name && cond.negated === (kind === 'skip'));
}
/**
* Evaluates the provided condition given variable definitions and concrete values.
*
* Note that this method allows the entry of `values` to be of `any` type, but this is only to make it possible
* to call this method with all of the variables values of a query, but this method assumes that:
* 1. `values` contains a value for `condition.condition`, _or_ the variable in question is defined with a default value.
* 2. that the value found for the `condition.condition` variable (in `values`, or the default for the variable) is a boolean.
*/
export function evaluateCondition(
condition: ConditionNode,
variables: VariableDefinitions,
values: Record<string, any> | undefined,
): boolean {
const variable = condition.condition;
let val = values ? values[variable] : undefined;
if (val === undefined) {
val = variables.definition(variable)?.defaultValue;
}
assert(val !== undefined, () => `Missing value for variable \$${variable} (and no default found)`);
assert(typeof val === 'boolean', () => `Invalid non-boolean value ${val} for Boolean! variable \$${variable}`)
return val;
}