UNPKG

@apollo/query-graphs

Version:

Apollo Federation library to work with 'query graphs'

1,201 lines (1,096 loc) 145 kB
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