@apollo/query-graphs
Version:
Apollo Federation library to work with 'query graphs'
1,201 lines (1,096 loc) • 145 kB
text/typescript
import {
assert,
Field,
FragmentElement,
InterfaceType,
NamedType,
OperationElement,
Schema,
SchemaRootKind,
SelectionSet,
typenameFieldName,
isLeafType,
baseType,
CompositeType,
isAbstractType,
newDebugLogger,
isCompositeType,
parseFieldSetArgument,
possibleRuntimeTypes,
ObjectType,
isObjectType,
mapValues,
federationMetadata,
isSchemaRootType,
Directive,
FieldDefinition,
printSubgraphNames,
allFieldDefinitionsInSelectionSet,
DeferDirectiveArgs,
isInterfaceType,
isSubset,
parseSelectionSet,
Variable,
Type,
isScalarType,
isEnumType,
isUnionType,
Selection,
} from "@apollo/federation-internals";
import { OpPathTree, traversePathTree } from "./pathTree";
import { Vertex, QueryGraph, Edge, RootVertex, isRootVertex, isFederatedGraphRootType, FEDERATED_GRAPH_ROOT_SOURCE } from "./querygraph";
import { DownCast, Transition } from "./transition";
import { PathContext, emptyContext, isPathContext } from "./pathContext";
import { v4 as uuidv4 } from 'uuid';
const debug = newDebugLogger('path');
export type ContextAtUsageEntry = {
contextId: string,
relativePath: string[],
selectionSet: SelectionSet,
subgraphArgType: Type,
};
function updateRuntimeTypes(currentRuntimeTypes: readonly ObjectType[], edge: Edge | null): readonly ObjectType[] {
if (!edge) {
return currentRuntimeTypes;
}
switch (edge.transition.kind) {
case 'FieldCollection':
const field = edge.transition.definition;
if (!isCompositeType(baseType(field.type!))) {
return [];
}
const newRuntimeTypes: ObjectType[] = [];
for (const parentType of currentRuntimeTypes) {
const fieldType = parentType.field(field.name)?.type;
if (fieldType) {
for (const type of possibleRuntimeTypes(baseType(fieldType) as CompositeType)) {
if (!newRuntimeTypes.includes(type)) {
newRuntimeTypes.push(type);
}
}
}
}
return newRuntimeTypes;
case 'DownCast':
const castedType = edge.transition.castedType;
const castedRuntimeTypes = possibleRuntimeTypes(castedType);
return currentRuntimeTypes.filter(t => castedRuntimeTypes.includes(t));
case 'InterfaceObjectFakeDownCast':
return currentRuntimeTypes;
case 'KeyResolution':
const currentType = edge.tail.type as CompositeType;
// We've taken a key into a new subgraph, so any of the possible runtime types of the new subgraph could be returned.
return possibleRuntimeTypes(currentType);
case 'RootTypeResolution':
case 'SubgraphEnteringTransition':
assert(isObjectType(edge.tail.type), () => `Query edge should be between object type but got ${edge}`);
return [ edge.tail.type ];
}
}
function withReplacedLastElement<T>(arr: readonly T[], newLast: T): T[] {
assert(arr.length > 0, 'Should not have been called on empty array');
const newArr = new Array<T>(arr.length);
for (let i = 0; i < arr.length - 1; i++) {
newArr[i] = arr[i];
}
newArr[arr.length - 1] = newLast;
return newArr;
}
/**
* An immutable path in a query graph.
*
* Path is mostly understood in the graph theoretical sense of the term, that is as "a connected series of edges"
* and a `GraphPath` is generated by traversing a graph query.
* However, as query graph edges may have conditions, a `GraphPath` also records, for reach edges it is composed of,
* the set of paths (an `OpPathTree` in practice) that were taken to fulfill the edge conditions (when the edge has
* one).
*
* Additionally, for each edge of the path, a `GraphPath` records the "trigger" that made the traversal take that
* edge. In practice, the "trigger" can be seen as a way to decorate a path with some additional metadata for each
* elements of the path. In practice, that trigger is used in 2 main ways (corresponding to our 2 main query graph
* traversals):
* - for composition validation, the traversal of the federated query graph is driven by other transitions into the
* supergraph API query graphs (essentially, composition validation is about finding, for every supergraph API
* query graph path, a "matching" traversal of the federated query graph). In that case, for the graph paths
* we build on the federated query graph, the "trigger" will be one of the `Transition` from the supergraph
* API graph (which, granted, will be fairly similar to the one of the edge we're taking in the federated query
* graph; in practice, triggers are more useful in the query planning case).
* - for query planning, the traversal of the federated query graph is driven by the elements of the query we are
* planning. Which means that the "trigger" for taking an edge in this case will be an `OperationElement`
* (or null). See the specialized `OpGraphPath` that is defined for this use case.
*
* Lastly, some `GraphPath` can actually encode "null" edges: this is used during query planning in the (rare)
* case where the query we plan for has fragment spread without type condition (or a "useless" one, on that doesn't
* restrict the possible types anymore than they already were) but with some directives. In that case, we want
* to preserve the information about the directive (to properly rebuild query plans later) but it doesn't correspond
* to taking any edges, so we add a "null" edge and use the trigger to store the fragment spread.
*
* @param TTrigger - the type of the paths "triggers", metadata that can associated to each element of the path (see
* above for more details).
* @param RV - the type of the vertex starting the path. This simply default to `Vertex` but is used in `RootPath`/`OpRootPath`
* to easily distinguish those paths that starts from a root of a query graph.
* @param TNullEdge - typing information to indicate whether the path can have "null" edges or not. Either `null` (
* meaning that the path may have null edges) or `never` (the path cannot have null edges).
*/
type PathProps<TTrigger, RV extends Vertex = Vertex, TNullEdge extends null | never = never> = {
/** The query graph of which this is a path. */
readonly graph: QueryGraph,
/** The vertex at which the path starts (the head vertex of the first edge in path, aliased here for convenience). */
readonly root: RV,
/** The vertex at which the path stops (the tail vertex of the last edge in path, aliased here for convenience). */
readonly tail: Vertex,
/** The triggers associated to each edges in the paths (see `GraphPath` for more details on triggers). */
readonly edgeTriggers: readonly TTrigger[],
/** The edges (stored by edge index) composing the path. */
readonly edgeIndexes: readonly (number | TNullEdge)[],
/**
* For each edge in the path, if the edge has conditions, the set of paths that fulfill that condition.
* Note that no matter which kind of traversal we are doing, fulfilling the conditions is always driven by
* the conditions themselves, and as conditions are a graphQL result set, the resulting set of paths are
* `OpGraphPath` (and as they are all rooted at the edge head vertex, we use the `OpPathTree` representation
* for that set of paths).
*/
readonly edgeConditions: readonly (OpPathTree | null)[],
readonly subgraphEnteringEdge?: {
index: number,
edge: Edge,
cost: number,
},
readonly ownPathIds: readonly string[],
readonly overriddingPathIds: readonly string[],
readonly edgeToTail?: Edge | TNullEdge,
/** Names of the all the possible runtime types the tail of the path can be. */
readonly runtimeTypesOfTail: readonly ObjectType[],
/** If the last edge (the one getting to tail) was a DownCast, the runtime types before that edge. */
readonly runtimeTypesBeforeTailIfLastIsCast?: readonly ObjectType[],
readonly deferOnTail?: DeferDirectiveArgs,
/** We may have a map of selections that get mapped to a context */
readonly contextToSelection: readonly (Set<string> | null)[],
/** This parameter is for mapping contexts back to the parameter used to collect the field */
readonly parameterToContext: readonly (Map<string, ContextAtUsageEntry> | null)[],
}
export class GraphPath<TTrigger, RV extends Vertex = Vertex, TNullEdge extends null | never = never> implements Iterable<[Edge | TNullEdge, TTrigger, OpPathTree | null, Set<string> | null, Map<string, ContextAtUsageEntry> | null]> {
private constructor(
private readonly props: PathProps<TTrigger, RV, TNullEdge>,
) {
}
get graph(): QueryGraph {
return this.props.graph;
}
get root(): RV {
return this.props.root;
}
get tail(): Vertex {
return this.props.tail;
}
get deferOnTail(): DeferDirectiveArgs | undefined {
return this.props.deferOnTail;
}
get subgraphEnteringEdge(): { index: number, edge: Edge, cost: number } | undefined {
return this.props.subgraphEnteringEdge;
}
/**
* Creates a new (empty) path starting at the provided vertex.
*/
static create<TTrigger, RV extends Vertex = Vertex, TNullEdge extends null | never = never>(
graph: QueryGraph,
root: RV
): GraphPath<TTrigger, RV, TNullEdge> {
// If 'graph' is a federated query graph, federation renames all root type to their default names, so we rely on this here.
const runtimeTypes = isFederatedGraphRootType(root.type) ? [] : possibleRuntimeTypes(root.type as CompositeType);
return new GraphPath({
graph,
root,
tail: root,
edgeTriggers: [],
edgeIndexes: [],
edgeConditions: [],
ownPathIds: [],
overriddingPathIds: [],
runtimeTypesOfTail: runtimeTypes,
contextToSelection: [],
parameterToContext: [],
});
}
/**
* Creates a new (empty) path starting from the root vertex in `graph` corresponding to the provide `rootKind`.
*/
static fromGraphRoot<TTrigger, TNullEdge extends null | never = never>(
graph: QueryGraph,
rootKind: SchemaRootKind
): RootPath<TTrigger, TNullEdge> | undefined {
const root = graph.root(rootKind);
return root ? this.create(graph, root) : undefined;
}
/**
* The size of the path, that is the number of edges composing it.
*
* Note that this only the "main" edges composing the path: some of those edges may have conditions for which the
* path will also store the "sub-paths" necessary to fulfill said conditions, but the edges of those sub-paths are
* _not_ counted here.
*/
get size(): number {
return this.props.edgeIndexes.length;
}
/**
* That method first look for the biggest common prefix to `this` and `that` (assuming that both path are build as choices
* of the same "query path"), and the count how many subgraph jumps each of the path has after said prefix.
*
* Note that this method always returns something but the biggest common prefix considered might well be empty.
*
* Please note that this method assumes that the 2 paths have the same root, and will fail if that's not the case.
*/
countSubgraphJumpsAfterLastCommonVertex(that: GraphPath<TTrigger, RV, TNullEdge>): {
thisJumps: number,
thatJumps: number
} {
const { vertex, index } = this.findLastCommonVertex(that);
return {
thisJumps: this.subgraphJumpsAtIdx(vertex, index),
thatJumps: that.subgraphJumpsAtIdx(vertex, index),
};
}
private findLastCommonVertex(that: GraphPath<TTrigger, RV, TNullEdge>): { vertex: Vertex, index: number } {
let vertex: Vertex = this.root;
assert(that.root === vertex, () => `Expected both path to start on the same root, but 'this' has root ${vertex} while 'that' has ${that.root}`);
const minSize = Math.min(this.size, that.size);
let index = 0;
for (; index < minSize; index++) {
const thisEdge = this.edgeAt(index, vertex);
const thatEdge = that.edgeAt(index, vertex);
if (thisEdge !== thatEdge) {
break;
}
if (thisEdge) {
vertex = thisEdge.tail;
}
}
return { vertex, index};
}
private subgraphJumpsAtIdx(vertex: Vertex, index: number): number {
let jumps = 0;
let v: Vertex = vertex;
for (let i = index; i < this.size; i++) {
const edge = this.edgeAt(i, v);
if (!edge) {
continue;
}
if (edge.changesSubgraph()) {
++jumps;
}
v = edge.tail;
}
return jumps;
}
subgraphJumps(): number {
return this.subgraphJumpsAtIdx(this.root, 0);
}
isEquivalentSaveForTypeExplosionTo(that: GraphPath<TTrigger, RV, TNullEdge>): boolean {
// We're looking a the specific case were both path are basically equivalent except
// for a single step of type-explosion, so if either the paths don't start and end in the
// same vertex, or if `other` is not exactly 1 more step than `this`, we're done.
if (this.root !== that.root || this.tail !== that.tail || this.size !== that.size - 1) {
return false;
}
// If that's true, then we get to our comparison.
let thisV: Vertex = this.root;
let thatV: Vertex = that.root;
for (let i = 0; i < this.size; i++) {
let thisEdge = this.edgeAt(i, thisV);
let thatEdge = that.edgeAt(i, thatV);
if (thisEdge !== thatEdge) {
// First difference. If it's not a "type-explosion", that is `that` is a cast from an
// interface to one of the implementation, then we're not in the case we're looking for.
if (!thisEdge || !thatEdge || !isInterfaceType(thatV.type) || thatEdge.transition.kind !== 'DownCast') {
return false;
}
thatEdge = that.edgeAt(i+1, thatEdge.tail);
if (!thatEdge) {
return false;
}
thisV = thisEdge.tail;
thatV = thatEdge.tail;
// At that point, we want both path to take the "same" key, but because one is starting
// from the interface while the other one from an implementation, they won't be technically
// the "same" edge object. So we check that both are key, to the same subgraph and type,
// and with the same condition.
if (thisEdge.transition.kind !== 'KeyResolution'
|| thatEdge.transition.kind !== 'KeyResolution'
|| thisEdge.tail.source !== thatEdge.tail.source
|| thisV !== thatV
|| !thisEdge.conditions!.equals(thatEdge.conditions!)
) {
return false;
}
// So far, so good. `thisV` and `thatV` are positioned on the vertex after which the path
// must be equal again. So check that it's true, and if it is, we're good.
// Note that for `this`, the last edge we looked at was `i`, so the next is `i+1`. And
// for `that`, we've skipped over one more edge, so need to use `j+1`.
for (let j = i + 1; j < this.size; j++) {
thisEdge = this.edgeAt(j, thisV);
thatEdge = that.edgeAt(j+1, thatV);
if (thisEdge !== thatEdge) {
return false;
}
if (thisEdge) {
thisV = thisEdge.tail;
thatV = thatEdge!.tail;
}
}
return true;
}
if (thisEdge) {
thisV = thisEdge.tail;
thatV = thatEdge!.tail;
}
}
// If we get here, both path are actually exactly the same. So technically there is not additional
// type explosion, but they are equivalent and we can return `true`.
return true;
}
[Symbol.iterator](): PathIterator<TTrigger, TNullEdge> {
const path = this;
return {
currentIndex: 0,
currentVertex: this.root,
next(): IteratorResult<[Edge | TNullEdge, TTrigger, OpPathTree | null, Set<string> | null, Map<string, ContextAtUsageEntry> | null]> {
if (this.currentIndex >= path.size) {
return { done: true, value: undefined };
}
const idx = this.currentIndex++;
const edge = path.edgeAt(idx, this.currentVertex);
if (edge) {
this.currentVertex = edge.tail;
}
return { done: false, value: [
edge,
path.props.edgeTriggers[idx],
path.props.edgeConditions[idx],
path.props.contextToSelection[idx],
path.props.parameterToContext[idx],
] };
}
};
}
/**
* The last edge in the path (if it isn't empty).
*/
lastEdge(): Edge | TNullEdge | undefined {
return this.props.edgeToTail;
}
lastTrigger(): TTrigger | undefined {
return this.props.edgeTriggers[this.size - 1];
}
/** The possible runtime types the tail of the path can be (this is deduplicated). */
tailPossibleRuntimeTypes(): readonly ObjectType[] {
return this.props.runtimeTypesOfTail;
}
/**
* Returns `true` if the last edge of the path correspond to an @interfaceObject "fake cast" while the the previous edge was an edge that "entered" the subgraph (a key edge from another subgraph).
*/
lastIsIntefaceObjectFakeDownCastAfterEnteringSubgraph(): boolean {
return this.lastIsInterfaceObjectFakeDownCast()
&& this.subgraphEnteringEdge?.index === this.size - 2; // size - 1 is the last index (the fake cast), so size - 2 is the previous edge.
}
private lastIsInterfaceObjectFakeDownCast(): boolean {
return this.lastEdge()?.transition.kind === 'InterfaceObjectFakeDownCast';
}
/**
* Creates the new path corresponding to appending to this path the provided `edge`.
*
* @param trigger - the trigger for taking the edge in the created path.
* @param edge - the edge to add (which may be 'null' if this type of path allows it, but if it isn't should be an out-edge
* for `s.tail`).
* @param conditionsResolution - the result of resolving the conditions for this edge.
* @param defer - if the trigger is an operation with a @defer on it, the arguments of this @defer.
* @returns the newly created path.
*/
add(trigger: TTrigger, edge: Edge | TNullEdge, conditionsResolution: ConditionResolution, defer?: DeferDirectiveArgs): GraphPath<TTrigger, RV, TNullEdge> {
assert(!edge || this.tail.index === edge.head.index, () => `Cannot add edge ${edge} to path ending at ${this.tail}`);
assert(conditionsResolution.satisfied, 'Should add to a path if the conditions cannot be satisfied');
assert(!edge || edge.conditions || edge.requiredContexts.length > 0 || !conditionsResolution.pathTree, () => `Shouldn't have conditions paths (got ${conditionsResolution.pathTree}) for edge without conditions (edge: ${edge})`);
// We clear `subgraphEnteringEdge` as we enter a @defer: that is because `subgraphEnteringEdge` is used to eliminate some
// non-optimal paths, but we don't want those optimizations to bypass a defer.
let subgraphEnteringEdge = defer ? undefined : this.subgraphEnteringEdge;
if (edge) {
if (edge.transition.kind === 'DownCast' && this.props.edgeToTail) {
const previousOperation = this.lastTrigger();
if (previousOperation instanceof FragmentElement && previousOperation.appliedDirectives.length === 0) {
// This mean we have 2 type-cast back-to-back and that means the previous operation might not be
// useful on this path. More precisely, the previous type-cast was only useful if it restricted
// the possible runtime types of the type on which it applied more than the current type-cast
// does (but note that if the previous type-cast had directives, we keep it no matter what in
// case those directives are important).
// That is, we're in the case where we have (somewhere potentially deep in a query):
// f { # field 'f' of type A
// ... on B {
// ... on C {
// # more stuffs
// }
// }
// }
// If the intersection of A and C is non empty and included (or equal) to the intersection of A and B,
// then there is no reason to have `... on B` at all because:
// 1. you can do `... on C` on `f` directly since the intersection of A and C is non-empty.
// 2. `... on C` restricts strictly more than `... on B` and so the latter can't impact the result.
// So if we detect that we're in that situation, we remove the `... on B` (but note that this is an
// optimization, keeping `... on B` wouldn't be incorrect, just useless).
const runtimeTypesWithoutPreviousCast = updateRuntimeTypes(this.props.runtimeTypesBeforeTailIfLastIsCast!, edge);
if (runtimeTypesWithoutPreviousCast.length > 0
&& runtimeTypesWithoutPreviousCast.every(t => this.props.runtimeTypesOfTail.includes(t))
) {
// Note that edge is from the vertex we've eliminating from the path. So we need to get the edge goes
// directly from the prior vertex to the new tail for that path.
const updatedEdge = this.graph.outEdges(this.props.edgeToTail!.head).find(e => e.tail.type === edge.tail.type);
if (updatedEdge) {
// We replace the previous operation by the new one.
debug.log(() => `Previous cast ${previousOperation} is made obsolete by new cast ${trigger}, removing from path.`);
return new GraphPath({
...this.props,
tail: updatedEdge.tail,
edgeTriggers: withReplacedLastElement(this.props.edgeTriggers, trigger),
edgeIndexes: withReplacedLastElement(this.props.edgeIndexes, updatedEdge.index),
edgeConditions: withReplacedLastElement(this.props.edgeConditions, conditionsResolution.pathTree ?? null),
edgeToTail: updatedEdge,
runtimeTypesOfTail: runtimeTypesWithoutPreviousCast,
// We know the edge is a DownCast, so if there is no new `defer` taking precedence, we just inherit the
// prior version.
deferOnTail: defer ?? this.props.deferOnTail,
});
}
}
}
}
// Again, we don't want to set `subgraphEnteringEdge` if we're entering a @defer (see above).
if (!defer && edge.changesSubgraph()) {
subgraphEnteringEdge = {
index: this.size,
edge,
cost: conditionsResolution.cost,
};
}
if (edge.transition.kind === 'KeyResolution') {
// We're adding a key edge. If the last edge to that point is an @interfaceObject fake downcast, and if our destination
// type is not an @interfaceObject itself, then we can eliminate that last edge as it does nothing useful, but also,
// it has conditions and we don't need/want the key we're following to depend on those conditions, since it doesn't have
// to.
if (this.lastIsInterfaceObjectFakeDownCast() && isInterfaceType(edge.tail.type)) {
return new GraphPath({
...this.props,
tail: edge.tail,
edgeTriggers: withReplacedLastElement(this.props.edgeTriggers, trigger),
edgeIndexes: withReplacedLastElement(this.props.edgeIndexes, edge.index),
edgeConditions: withReplacedLastElement(this.props.edgeConditions, conditionsResolution.pathTree ?? null),
subgraphEnteringEdge,
edgeToTail: edge,
runtimeTypesOfTail: updateRuntimeTypes(this.props.runtimeTypesOfTail, edge),
runtimeTypesBeforeTailIfLastIsCast: undefined, // we know last is not a cast
deferOnTail: defer,
});
}
}
}
const { edgeConditions, contextToSelection, parameterToContext } = this.mergeEdgeConditionsWithResolution(conditionsResolution);
const lastParameterToContext = parameterToContext[parameterToContext.length-1];
let newTrigger = trigger;
if (lastParameterToContext !== null && (trigger as any).kind === 'Field') {
// If this is the last edge that reaches a contextual element, we should update the trigger to use the contextual arguments
const args = Array.from(lastParameterToContext).reduce((acc: {[key: string]: any}, [key, value]: [string, ContextAtUsageEntry]) => {
acc[key] = new Variable(value.contextId);
return acc;
}, {});
newTrigger = (trigger as Field).withUpdatedArguments(args) as TTrigger;
}
return new GraphPath({
...this.props,
tail: edge ? edge.tail : this.tail,
edgeTriggers: this.props.edgeTriggers.concat(newTrigger),
edgeIndexes: this.props.edgeIndexes.concat((edge ? edge.index : null) as number | TNullEdge),
edgeConditions,
subgraphEnteringEdge,
edgeToTail: edge,
runtimeTypesOfTail: updateRuntimeTypes(this.props.runtimeTypesOfTail, edge),
runtimeTypesBeforeTailIfLastIsCast: edge?.transition?.kind === 'DownCast' ? this.props.runtimeTypesOfTail : undefined,
// If there is no new `defer` taking precedence, and the edge is downcast, then we inherit the prior version. This
// is because we only try to re-enter subgraphs for @defer on concrete fields, and so as long as we add downcasts,
// we should remember that we still need to try re-entering the subgraph.
deferOnTail: defer ?? (edge && edge.transition.kind === 'DownCast' ? this.props.deferOnTail : undefined),
contextToSelection,
parameterToContext,
});
}
/**
* We are going to grow the conditions by one element with the pathTree on the resolution. Additionally, we may need to merge or replace
* the existing elements with elements from the ContextMap
*/
private mergeEdgeConditionsWithResolution(conditionsResolution: ConditionResolution): {
edgeConditions: (OpPathTree | null)[],
contextToSelection: (Set<string> | null)[],
parameterToContext: (Map<string, ContextAtUsageEntry> | null)[],
}{
const edgeConditions = this.props.edgeConditions.concat(conditionsResolution.pathTree ?? null);
const contextToSelection = this.props.contextToSelection.concat(null);
const parameterToContext = this.props.parameterToContext.concat(null);
if (conditionsResolution.contextMap === undefined || conditionsResolution.contextMap.size === 0) {
return {
edgeConditions,
contextToSelection,
parameterToContext,
};
}
parameterToContext[parameterToContext.length-1] = new Map();
for (const [_, entry] of conditionsResolution.contextMap) {
const idx = edgeConditions.length - entry.levelsInQueryPath -1;
assert(idx >= 0, 'calculated condition index must be positive');
if (entry.pathTree) {
edgeConditions[idx] = edgeConditions[idx]?.merge(entry.pathTree) ?? entry.pathTree;
}
if (contextToSelection[idx] === null) {
contextToSelection[idx] = new Set();
}
contextToSelection[idx]?.add(entry.id);
parameterToContext[parameterToContext.length-1]?.set(entry.paramName, { contextId: entry.id, relativePath: Array(entry.levelsInDataPath).fill(".."), selectionSet: entry.selectionSet, subgraphArgType: entry.argType } );
}
return {
edgeConditions,
contextToSelection,
parameterToContext,
};
}
/**
* Creates a new path corresponding to concatenating the provide path _after_ this path.
*
* @param tailPath - the path to concatenate at the end of this path. That path must start on the vertex at which
* this path ends.
* @returns the newly created path.
*/
concat(tailPath: GraphPath<TTrigger, Vertex, TNullEdge>): GraphPath<TTrigger, RV, TNullEdge> {
assert(this.tail.index === tailPath.root.index, () => `Cannot concat ${tailPath} after ${this}`);
if (tailPath.size === 0) {
return this;
}
let prevRuntimeTypes = this.props.runtimeTypesBeforeTailIfLastIsCast;
let runtimeTypes = this.props.runtimeTypesOfTail;
for (const [edge] of tailPath) {
prevRuntimeTypes = runtimeTypes;
runtimeTypes = updateRuntimeTypes(runtimeTypes, edge);
}
return new GraphPath({
...this.props,
tail: tailPath.tail,
edgeTriggers: this.props.edgeTriggers.concat(tailPath.props.edgeTriggers),
edgeIndexes: this.props.edgeIndexes.concat(tailPath.props.edgeIndexes),
edgeConditions: this.props.edgeConditions.concat(tailPath.props.edgeConditions),
subgraphEnteringEdge: tailPath.subgraphEnteringEdge ? tailPath.subgraphEnteringEdge : this.subgraphEnteringEdge,
ownPathIds: this.props.ownPathIds.concat(tailPath.props.ownPathIds),
overriddingPathIds: this.props.overriddingPathIds.concat(tailPath.props.overriddingPathIds),
edgeToTail: tailPath.props.edgeToTail,
runtimeTypesOfTail: runtimeTypes,
runtimeTypesBeforeTailIfLastIsCast: tailPath.props.edgeToTail?.transition?.kind === 'DownCast' ? prevRuntimeTypes : undefined,
deferOnTail: tailPath.deferOnTail,
});
}
checkDirectPathFromPreviousSubgraphTo(
typeName: string,
triggerToEdge: (graph: QueryGraph, vertex: Vertex, t: TTrigger, overrideConditions: Map<string, boolean>) => Edge | null | undefined,
overrideConditions: Map<string, boolean>,
prevSubgraphStartingVertex?: Vertex,
): Vertex | undefined {
const enteringEdge = this.subgraphEnteringEdge;
if (!enteringEdge) {
return undefined;
}
// TODO: Temporary fix to avoid optimization if context exists.
// permanent fix is described here: https://github.com/apollographql/federation/pull/3017#pullrequestreview-2083949094
if (this.graph.subgraphToArgs.size > 0) {
return undefined;
}
// Usually, the starting subgraph in which we want to look for a direct path is the head of
// `subgraphEnteringEdge`, that is, where we were just before coming to the current subgraph.
// But for subgraph entering edges, we're not coming from a subgraph, so instead we pass the
// "root" vertex of the subgraph of interest in `prevSubgraphStartingVertex`. And if that
// is undefined (for a subgraph entering edge), then that means the subgraph does not have
// the root type in question (say, no mutation type), and so there can be no direct path in
// that subgraph.
if (enteringEdge.edge.transition.kind === 'SubgraphEnteringTransition' && !prevSubgraphStartingVertex) {
return undefined;
}
let prevSubgraphVertex = prevSubgraphStartingVertex ?? enteringEdge.edge.head;
for (let i = enteringEdge.index + 1; i < this.size; i++) {
const triggerToMatch = this.props.edgeTriggers[i];
const prevSubgraphMatchingEdge = triggerToEdge(this.graph, prevSubgraphVertex, triggerToMatch, overrideConditions);
if (prevSubgraphMatchingEdge === null) {
// This means the trigger doesn't make us move (it's typically an inline fragment with no conditions, just directive), which we can always match.
continue;
}
// If the edge has conditions, we don't consider it a direct path as we don't know if that condition can be satisfied and at what cost.
if (!prevSubgraphMatchingEdge || prevSubgraphMatchingEdge.conditions) {
return undefined;
}
prevSubgraphVertex = prevSubgraphMatchingEdge.tail;
}
// If we got here, that mean we were able to match all the triggers from the path since we switched from the previous graph directly into
// the previous graph, and so, assuming we're on the proper type, we have a direct path in that previous graph.
return prevSubgraphVertex.type.name === typeName ? prevSubgraphVertex : undefined;
}
/**
* The set of edges that may legally continue this path.
*/
nextEdges(): readonly Edge[] {
if (this.deferOnTail) {
// If we path enters a @defer (meaning that what comes after needs to be deferred), then it's the one special case where we
// explicitly need to ask for edges-to-self, as we _will_ force the use of a @key edge (so we can send the non-deferred part
// immediately) and we may have to resume the deferred part in the same subgraph than the one in which we were (hence the need
// for edges to self).
return this.graph.outEdges(this.tail, true);
}
// In theory, we could always return `this.graph.outEdges(this.tail)` here. But in practice, `nonTrivialFollowupEdges` may give us a subset
// of those "out edges" that avoids some of the edges that we know we don't need to check because they are guaranteed to be inefficient
// after the previous `tailEdge`. Note that is purely an optimization (see https://github.com/apollographql/federation/pull/1653 for more details).
const tailEdge = this.props.edgeToTail;
return tailEdge
? this.graph.nonTrivialFollowupEdges(tailEdge)
: this.graph.outEdges(this.tail);
}
/**
* Whether the path is terminal, that is ends on a terminal vertex.
*/
isTerminal() {
return this.graph.isTerminal(this.tail);
}
/**
* Whether this path is a `RootPath`, that is one whose starting vertex is one of the underlying query graph root.
*/
isRootPath(): this is RootPath<TTrigger, TNullEdge> {
return isRootVertex(this.root);
}
mapMainPath<T>(mapper: (e: Edge | TNullEdge, pathIdx: number) => T): T[] {
const result = new Array(this.size);
let v: Vertex = this.root;
for (let i = 0; i < this.size; i++) {
const edge = this.edgeAt(i, v);
result[i] = mapper(edge, i);
if (edge) {
v = edge.tail;
}
}
return result;
}
private edgeAt(index: number, v: Vertex): Edge | TNullEdge {
const edgeIdx = this.props.edgeIndexes[index];
return (edgeIdx !== null ? this.graph.outEdge(v, edgeIdx) : null) as Edge | TNullEdge;
}
reduceMainPath<T>(reducer: (accumulator: T, edge: Edge | TNullEdge, pathIdx: number) => T, initialValue: T): T {
let value = initialValue;
let v: Vertex = this.root;
for (let i = 0; i < this.size; i++) {
const edge = this.edgeAt(i, v);
value = reducer(value, edge, i);
if (edge) {
v = edge.tail;
}
}
return value;
}
/**
* Whether the path forms a cycle on the its end vertex, that is if the end vertex of this path has already been encountered earlier in the path.
*/
hasJustCycled(): boolean {
if (this.root.index == this.tail.index) {
return true;
}
let v: Vertex = this.root;
// We ignore the last edge since it's the one leading to the current vertex.
for (let i = 0; i < this.size - 1; i++) {
const edge = this.edgeAt(i, v);
if (!edge) {
continue;
}
v = edge.tail;
if (v.index == this.tail.index) {
return true;
}
}
return false;
}
/**
* Whether any of the edge in the path has associated conditions paths.
*/
hasAnyEdgeConditions(): boolean {
return this.props.edgeConditions.some(c => c !== null);
}
isOnTopLevelQueryRoot(): boolean {
if (!isRootVertex(this.root)) {
return false;
}
// We walk the vertices and as soon as we take a field (or move out of the root type),
// we know we're not on the top-level query/mutation/subscription root anymore. The reason we don't
// just check that size <= 1 is that we could have top-level `... on Query`
// conditions that don't actually move us.
let vertex: Vertex = this.root;
for (let i = 0; i < this.size; i++) {
const edge = this.edgeAt(i, vertex);
if (!edge) {
continue;
}
if (edge.transition.kind === 'FieldCollection' || !isSchemaRootType(edge.tail.type)) {
return false;
}
vertex = edge.tail;
}
return true;
}
truncateTrailingDowncasts(): GraphPath<TTrigger, RV, TNullEdge> {
let lastNonDowncastIdx = -1;
let v: Vertex = this.root;
let lastNonDowncastVertex = v;
let lastNonDowncastEdge: Edge | undefined;
let runtimeTypes = isFederatedGraphRootType(this.root.type) ? [] : possibleRuntimeTypes(this.root.type as CompositeType);
let runtimeTypesAtLastNonDowncastEdge = runtimeTypes;
for (let i = 0; i < this.size; i++) {
const edge = this.edgeAt(i, v);
runtimeTypes = updateRuntimeTypes(runtimeTypes, edge);
if (edge) {
v = edge.tail;
if (edge.transition.kind !== 'DownCast') {
lastNonDowncastIdx = i;
lastNonDowncastVertex = v;
lastNonDowncastEdge = edge;
runtimeTypesAtLastNonDowncastEdge = runtimeTypes;
}
}
}
if (lastNonDowncastIdx < 0 || lastNonDowncastIdx === this.size -1) {
return this;
}
const newSize = lastNonDowncastIdx + 1;
return new GraphPath({
...this.props,
tail: lastNonDowncastVertex,
edgeTriggers: this.props.edgeTriggers.slice(0, newSize),
edgeIndexes: this.props.edgeIndexes.slice(0, newSize),
edgeConditions: this.props.edgeConditions.slice(0, newSize),
edgeToTail: lastNonDowncastEdge,
runtimeTypesOfTail: runtimeTypesAtLastNonDowncastEdge,
runtimeTypesBeforeTailIfLastIsCast: undefined,
});
}
markOverridding(otherOptions: GraphPath<TTrigger, RV, TNullEdge>[][]): {
thisPath: GraphPath<TTrigger, RV, TNullEdge>,
otherOptions: GraphPath<TTrigger, RV, TNullEdge>[][],
} {
const newId = uuidv4();
return {
thisPath: new GraphPath({
...this.props,
ownPathIds: this.props.ownPathIds.concat(newId),
}),
otherOptions: otherOptions.map((paths) => paths.map((p) => new GraphPath({
...p.props,
overriddingPathIds: p.props.overriddingPathIds.concat(newId),
}))),
};
}
isOverriddenBy(otherPath: GraphPath<TTrigger, RV, TNullEdge>): boolean {
for (const overriddingId of this.props.overriddingPathIds) {
if (otherPath.props.ownPathIds.includes(overriddingId)) {
return true;
}
}
return false;
}
tailIsInterfaceObject(): boolean {
if (!isObjectType(this.tail.type)) {
return false;
}
const schema = this.graph.sources.get(this.tail.source);
const metadata = federationMetadata(schema!);
return metadata?.isInterfaceObjectType(this.tail.type) ?? false;
}
toString(): string {
const isRoot = isRootVertex(this.root);
if (isRoot && this.size === 0) {
return '_';
}
const pathStr = this.mapMainPath((edge, idx) => {
if (edge) {
if (isRoot && idx == 0) {
return edge.tail.toString();
}
const label = edge.label();
return ` -${label === "" ? "" : '-[' + label + ']-'}-> ${edge.tail}`
}
return ` (${this.props.edgeTriggers[idx]}) `;
}).join('');
const deferStr = this.deferOnTail ? ` <defer='${this.deferOnTail.label}'>` : '';
const typeStr = this.props.runtimeTypesOfTail.length > 0 ? ` (types: [${this.props.runtimeTypesOfTail.join(', ')}])` : '';
return `${isRoot ? '' : this.root}${pathStr}${deferStr}${typeStr}`;
}
}
export interface PathIterator<TTrigger, TNullEdge extends null | never = never> extends Iterator<[Edge | TNullEdge, TTrigger, OpPathTree | null, Set<string> | null, Map<string, ContextAtUsageEntry> | null]> {
currentIndex: number,
currentVertex: Vertex
}
/**
* A `GraphPath` that starts on a vertex that is a root vertex (of the query graph of which this is a path).
*/
export type RootPath<TTrigger, TNullEdge extends null | never = never> = GraphPath<TTrigger, RootVertex, TNullEdge>;
export type OpTrigger = OperationElement | PathContext;
/**
* A `GraphPath` whose triggers are `OperationElement` (essentially meaning that the path has been guided by a graphQL query).
*/
export type OpGraphPath<RV extends Vertex = Vertex> = GraphPath<OpTrigger, RV, null>;
/**
* An `OpGraphPath` that starts on a vertex that is a root vertex (of the query graph of which this is a path).
*/
export type OpRootPath = OpGraphPath<RootVertex>;
export function isRootPath(path: OpGraphPath<any>): path is OpRootPath {
return isRootVertex(path.root);
}
export function terminateWithNonRequestedTypenameField<V extends Vertex>(path: OpGraphPath<V>, overrideConditions: Map<string, boolean>): OpGraphPath<V> {
// If the last step of the path was a fragment/type-condition, we want to remove it before we get __typename.
// The reason is that this avoid cases where this method would make us build plans like:
// {
// foo {
// __typename
// ... on A {
// __typename
// }
// ... on B {
// __typename
// }
// }
// Instead, we just generate:
// {
// foo {
// __typename
// }
// }
// Note it's ok to do this because the __typename we add is _not_ requested, it is just added in cases where we
// need to ensure a selection is not empty, and so this transformation is fine to do.
path = path.truncateTrailingDowncasts();
if (!isCompositeType(path.tail.type)) {
return path;
}
const typenameField = new Field(path.tail.type.typenameField()!);
const edge = edgeForField(path.graph, path.tail, typenameField, overrideConditions);
assert(edge, () => `We should have an edge from ${path.tail} for ${typenameField}`);
return path.add(typenameField, edge, noConditionsResolution);
}
export function traversePath(
path: GraphPath<any>,
onEdges: (edge: Edge) => void
){
for (const [edge, _, conditions] of path) {
if (conditions) {
traversePathTree(conditions, onEdges);
}
onEdges(edge);
}
}
// Note that ConditionResolver are guaranteed to be only called for edge with conditions.
export type ConditionResolver =
(edge: Edge, context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions, extraConditions?: SelectionSet) => ConditionResolution;
type ContextMapEntry = {
levelsInDataPath: number,
levelsInQueryPath: number,
pathTree?: OpPathTree,
selectionSet: SelectionSet,
inboundEdge: Edge,
paramName: string,
argType: Type,
id: string,
}
export type ConditionResolution = {
satisfied: boolean,
cost: number,
pathTree?: OpPathTree,
contextMap?: Map<string, ContextMapEntry>,
// Note that this is not guaranteed to be set even if satistied === false.
unsatisfiedConditionReason?: UnsatisfiedConditionReason
}
export enum UnsatisfiedConditionReason {
NO_POST_REQUIRE_KEY,
NO_CONTEXT_SET
}
export const noConditionsResolution: ConditionResolution = { satisfied: true, cost: 0 };
export const unsatisfiedConditionsResolution: ConditionResolution = { satisfied: false, cost: -1 };
export enum UnadvanceableReason {
UNSATISFIABLE_KEY_CONDITION,
UNSATISFIABLE_REQUIRES_CONDITION,
UNRESOLVABLE_INTERFACE_OBJECT,
NO_MATCHING_TRANSITION,
UNREACHABLE_TYPE,
IGNORED_INDIRECT_PATH,
UNSATISFIABLE_OVERRIDE_CONDITION,
}
export type Unadvanceable = {
sourceSubgraph: string,
destSubgraph: string,
reason: UnadvanceableReason,
details: string
};
export class Unadvanceables {
constructor(readonly reasons: Unadvanceable[]) {}
toString() {
return '[' + this.reasons.map((r) => `[${r.reason}](${r.sourceSubgraph}->${r.destSubgraph}) ${r.details}`).join(', ') + ']';
}
}
export type UnadvanceableClosure = () => Unadvanceable | Unadvanceable[];
export class UnadvanceableClosures {
private _unadvanceables: Unadvanceables | undefined;
readonly closures: UnadvanceableClosure[];
constructor(closures: UnadvanceableClosure | UnadvanceableClosure[]) {
if (Array.isArray(closures)) {
this.closures = closures;
} else {
this.closures = [closures];
}
}
toUnadvanceables(): Unadvanceables {
if (!this._unadvanceables) {
this._unadvanceables = new Unadvanceables(this.closures.map((c) => c()).flat());
}
return this._unadvanceables;
}
}
export function isUnadvanceableClosures(result: any[] | UnadvanceableClosures): result is UnadvanceableClosures {
return result instanceof UnadvanceableClosures;
}
function pathTransitionToEdge(graph: QueryGraph, vertex: Vertex, transition: Transition, overrideConditions: Map<string, boolean>): Edge | null | undefined {
for (const edge of graph.outEdges(vertex)) {
// The edge must match the transition.
if (!edge.matchesSupergraphTransition(transition)) {
continue;
}
if (edge.satisfiesOverrideConditions(overrideConditions)) {
return edge;
}
}
return undefined;
}
/**
* Wraps a 'composition validation' path (one built from `Transition`) along with the information necessary to compute
* the indirect paths following that path, and cache the result of that computation when triggered.
*
* In other words, this is a `GraphPath<Transition, V>` plus lazy memoization of the computation of its following indirect
* options.
*
* The rational is that after we've reached a given path, we might never need to compute the indirect paths following it
* (maybe all the fields we'll care about are available "directive" (from the same subgraph)), or we might need to compute
* it once, or we might need them multiple times, but the way the algorithm work, we don't know this in advance. So
* this abstraction ensure that we only compute such indirect paths lazily, if we ever need them, but while ensuring
* we don't recompute them multiple times if we do need them multiple times.
*/
export class TransitionPathWithLazyIndirectPaths<V extends Vertex = Vertex> {
private lazilyComputedIndirectPaths: IndirectPaths<Transition, V> | undefined;
constructor(
readonly path: GraphPath<Transition, V>,
readonly conditionResolver: ConditionResolver,
readonly overrideConditions: Map<string, boolean>,
) {
}
static initial<V extends Vertex = Vertex>(
initialPath: GraphPath<Transition, V>,
conditionResolver: ConditionResolver,
overrideConditions: Map<string, boolean>,
): TransitionPathWithLazyIndirectPaths<V> {
return new TransitionPathWithLazyIndirectPaths(initialPath, conditionResolver, overrideConditions);
}
indirectOptions(): IndirectPaths<Transition, V> {
if (!this.lazilyComputedIndirectPaths) {
this.lazilyComputedIndirectPaths = this.computeIndirectPaths();
}
return this.lazilyComputedIndirectPaths;
}
private computeIndirectPaths(): IndirectPaths<Transition, V> {
return advancePathWithNonCollectingAndTypePreservingTransitions(
this.path,
emptyContext,
this.conditionResolver,
[],
[],
(t) => t,
pathTransitionToEdge,
this.overrideConditions,
getFieldParentTypeForEdge,
);
}
toString(): string {
return this.path.toString();
}
}
// Note: conditions resolver should return `null` if the condition cannot be satisfied. If it is satisfied, it has the choice of computing
// the actual tree, which we need for query planning, or simply returning "undefined" which means "The condition can be satisfied but I didn't
// bother computing a tree for it", which we use for simple validation.
// Returns some a `Unadvanceables` object if there is no way to advance the path with this transition. Otherwise, it returns a list of options (paths) we can be in after advancing the transition.
// The lists of options can be empty, which has the special meaning that the transition is guaranteed to have no results (it corresponds to unsatisfiable conditions),
// meaning that as far as composition validation goes, we can ignore that transition (and anything that follows) and otherwise continue.
export function advancePathWithTransition<V extends Vertex>(
subgraphPath: TransitionPathWithLazyIndirectPaths<V>,
transition: Transition,
targetType: NamedType,
overrideConditions: Map<string, boolean>,
) : TransitionPathWithLazyIndirectPaths<V>[] | UnadvanceableClosures {
// The `transition` comes from the supergraph. Now, it is possible that a transition can be expressed on the supergraph, but correspond
// to an 'unsatisfiable' condition on the subgraph. Let's consider:
// - Subgraph A:
// type Query {
// get: [I]
// }
//
// interface I {
// k: Int
// }
//
// type T1 implements I @key(fields: "k") {
// k: Int
// a: String
// }
//
// type T2 implements I @key(fields: "k") {
// k: Int
// b: String
// }
//
// - Subgraph B:
// interface I {
// k: Int
// }
//
// type T1 implements I @key(fields: "k") {
// k: Int
// myself: I
// }
//
// On the resulting supergraph, we will have a path for:
// {
// get {
// ... on T1 {
// myself {
// ... on T2 {
// b
// }
// }
// }
// }
// }
//
// However, as we compute possible subgraph paths, the `myself` field will get us
// in subgraph `B` through `T1`'s key. But then, as we look at transition `... on T2`
// from subgraph `B`, we have no such type/transition. But this does not mean that
// the subgraphs shouldn't compose. What it really means is that the corresponding
// query above can be done, but is guaranteed to never return anything (essentially,
// we can query subgraph 'B' but will never get a `T2` so the result of the query
// should be empty).
//
// So we need to handle this case and we do this first. Note that the only kind of
// transition that can give use this is a 'DownCast' transition.
// Also note that if the subgraph type we're on is an @interfaceObject type, then we
// also can't be in this situation as an @interfaceObject type "stands in" for all
// the possible implementations of that interface. And one way to detect if the subgraph
// type an @interfaceObject is to check if the subgraph type is an object type while the
// supergraph type is an interface one.
if (transition.kind === 'DownCast' && !(isInterfaceType(transition.sourceType) && isObjectType(subgraphPath.path.tail.type))) {
// If we consider a 'downcast' transition, it means that the target of that cast is composite, but also that the
// last type of the