import { assert, arrayEquals, baseType, CompositeType, Field, FieldSelection, FragmentElement, isAbstractType, isCompositeType, isListType, isObjectType, isNamedType, ListType, NonNullType, ObjectType, Operation, OperationPath, sameOperationPaths, Schema, SchemaRootKind, Selection, SelectionSet, selectionSetOf, Variable, VariableDefinition, VariableDefinitions, newDebugLogger, selectionOfElement, selectionSetOfElement, NamedFragments, operationToDocument, MapWithCachedArrays, FederationMetadata, federationMetadata, entitiesFieldName, concatOperationPaths, Directive, directiveApplicationsSubstraction, conditionalDirectivesInOperationPath, SetMultiMap, OperationElement, Concrete, DeferDirectiveArgs, setValues, MultiMap, typenameFieldName, mapKeys, operationPathToStringPath, mapValues, isInterfaceObjectType, isInterfaceType, Type, MutableSelectionSet, SelectionSetUpdates, AbstractType, isDefined, InterfaceType, FragmentSelection, typesCanBeMerged, Supergraph, sameType, isInputType, possibleRuntimeTypes, NamedType, VariableCollector, DEFAULT_MIN_USAGES_TO_OPTIMIZE, } from "@apollo/federation-internals"; import { advanceSimultaneousPathsWithOperation, Edge, emptyContext, ExcludedDestinations, QueryGraph, GraphPath, isPathContext, isRootPathTree, OpGraphPath, OpPathTree, OpRootPathTree, PathContext, PathTree, RootVertex, Vertex, isRootVertex, ExcludedConditions, advanceOptionsToString, ConditionResolution, unsatisfiedConditionsResolution, cachingConditionResolver, ConditionResolver, addConditionExclusion, SimultaneousPathsWithLazyIndirectPaths, simultaneousPathsToString, SimultaneousPaths, terminateWithNonRequestedTypenameField, getLocallySatisfiableKey, createInitialOptions, buildFederatedQueryGraph, FEDERATED_GRAPH_ROOT_SOURCE, NonLocalSelectionsState, NonLocalSelectionsMetadata, } from "@apollo/query-graphs"; import { stripIgnoredCharacters, print, OperationTypeNode, SelectionSetNode, Kind } from "graphql"; import { DeferredNode, FetchDataKeyRenamer, FetchDataRewrite } from "."; import { Conditions, conditionsOfSelectionSet, isConstantCondition, mergeConditions, removeConditionsFromSelectionSet, updatedConditions } from "./conditions"; import { enforceQueryPlannerConfigDefaults, QueryPlannerConfig, validateQueryPlannerConfig } from "./config"; import { generateAllPlansAndFindBest } from "./generateAllPlans"; import { QueryPlan, ResponsePath, SequenceNode, PlanNode, ParallelNode, FetchNode, SubscriptionNode, trimSelectionNodes } from "./QueryPlan"; import { validateRecursiveSelections } from './recursiveSelectionsLimit'; const debug = newDebugLogger('plan'); // Somewhat random string used to optimise handling __typename in some cases. See usage for details. The concrete value // has no particular significance. const SIBLING_TYPENAME_KEY = 'sibling_typename'; type CostFunction = FetchGroupProcessor; /** * Constant used during query plan cost computation to account for the base cost of doing a fetch, that is the * fact any fetch imply some networking cost, request serialization/deserialization, validation, ... * * The number is a little bit arbitrary, but insofar as we roughly assign a cost of 1 to a single field queried * (see `selectionCost` method), this can be though of as saying that resolving a single field is in general * a tiny fraction of the actual cost of doing a subgraph fetch. */ const fetchCost = 1000; /** * Constant used during query plan cost computation as a multiplier to the cost of fetches made in sequences. * * This means that if 3 fetches are done in sequence, the cost of 1nd one is multiplied by this number, the * 2nd by twice this number, and the 3rd one by thrice this number. The goal is to heavily favor query plans * with the least amount of sequences, since this affect overall latency directly. The exact number is a tad * arbitrary however. */ const pipeliningCost = 100; /** * Computes the cost of a Plan. * * A plan is essentially some mix of sequences and parallels of fetches. And the plan cost * is about minimizing both: * 1. The expected total latency of executing the plan. Typically, doing 2 fetches in * parallel will most likely have much better latency then executing those exact same * fetches in sequence, and so the cost of the latter must be greater than that of * the former. * 2. The underlying use of resources. For instance, if we query 2 fields and we have * the choice between getting those 2 fields from a single subgraph in 1 fetch, or * get each from a different subgraph with 2 fetches in parallel, then we want to * favor the former as just doing a fetch in and of itself has a cost in terms of * resources consumed. * * Do note that at the moment, this cost is solely based on the "shape" of the plan and has * to make some conservative assumption regarding concrete runtime behaviour. In particular, * it assumes that: * - all fields have the same cost (all resolvers take the same time). * - that field cost is relative small compare to actually doing a subgraph fetch. That is, * it assumes that the networking and other query processing costs are much higher than * the cost of resolving a single field. Or to put it more concretely, it assumes that * a fetch of 5 fields is probably not too different from than of 2 fields. */ const defaultCostFunction: CostFunction = { /** * The cost of a fetch roughly proportional to how many fields it fetches (but see `selectionCost` for more details) * plus some constant "premium" to account for the fact than doing each fetch is costly (and that fetch cost often * dwarfted the actual cost of fields resolution). */ onFetchGroup: (group: FetchGroup) => (fetchCost + group.cost()), /** * We don't take conditions into account in costing for now as they don't really know anything on the condition * and this shouldn't really play a role in picking a plan over another. */ onConditions: (_: Conditions, value: number) => value, /** * We sum the cost of fetch groups in parallel. Note that if we were only concerned about expected latency, * we could instead take the `max` of the values, but as we also try to minimize general resource usage, we * want 2 parallel fetches with cost 1000 to be more costly than one with cost 1000 and one with cost 10, * so suming is a simple option. */ reduceParallel: (values: number[]) => parallelCost(values), /** * For sequences, we want to heavily favor "shorter" pipelines of fetches as this directly impact the * expected latency of the overall plan. * * To do so, each "stage" of a sequence/pipeline gets an additional multiplier on the intrinsic cost * of that stage. */ reduceSequence: (values: number[]) => sequenceCost(values), /** * This method exists so we can inject the necessary information for deferred block when * genuinely creating plan nodes. It's irrelevant to cost computation however and we just * return the cost of the block unchanged. */ reduceDeferred(_: DeferredInfo, value: number): number { return value; }, /** * It is unfortunately a bit difficult to properly compute costs for defers because in theory * some of the deferred blocks (the costs in `deferredValues`) can be started _before_ the full * `nonDeferred` part finishes (more precisely, the "structure" of query plans express the fact * that there is a non-deferred part and other deferred parts, but the complete dependency of * when a deferred part can be start is expressed through the `FetchNode.id` field, and as * this cost function is currently mainly based on the "structure" of query plans, we don't * have easy access to this info). * * Anyway, the approximation we make here is that all the deferred starts strictly after the * non-deferred one, and that all the deferred parts can be done in parallel. */ reduceDefer(nonDeferred: number, _: SelectionSet, deferredValues: number[]): number { return sequenceCost([nonDeferred, parallelCost(deferredValues)]); }, }; function parallelCost(values: number[]): number { return sum(values); } function sequenceCost(stages: number[]): number { return stages.reduceRight((acc, stage, idx) => (acc + (Math.max(1, idx * pipeliningCost) * stage)), 0); } type ClosedPath = { paths: SimultaneousPaths, selection?: SelectionSet, } function closedPathToString(p: ClosedPath): string { const pathStr = simultaneousPathsToString(p.paths); return p.selection ? `${pathStr} -> ${p.selection}` : pathStr; } function flattenClosedPath( p: ClosedPath ): { path: OpGraphPath, selection?: SelectionSet }[] { return p.paths.map((path) => ({ path, selection: p.selection})); } type ClosedBranch = ClosedPath[]; function allTailVertices(options: SimultaneousPathsWithLazyIndirectPaths[]): Set { const vertices = new Set(); for (const option of options) { for (const path of option.paths) { vertices.add(path.tail); } } return vertices; } function selectionIsFullyLocalFromAllVertices( selection: SelectionSet, vertices: Set, inconsistentAbstractTypesRuntimes: Set, ): boolean { let _useInconsistentAbstractTypes: boolean | undefined = undefined; const useInconsistentAbstractTypes = (): boolean => { if (_useInconsistentAbstractTypes === undefined) { _useInconsistentAbstractTypes = selection.some((elt) => elt.kind === 'FragmentElement' && !!elt.typeCondition && inconsistentAbstractTypesRuntimes.has(elt.typeCondition.name) ); } return _useInconsistentAbstractTypes; } for (const vertex of vertices) { // To guarantee that the selection is fully local from the provided vertex/type, we must have: // - no edge crossing subgraphs from that vertex. // - the type must be compositeType (mostly just ensuring the selection make sense). // - everything in the selection must be avaiable in the type (which `rebaseOn` essentially validates). // - the selection must not "type-cast" into any abstract type that has inconsistent runtimes acrosse subgraphs. The reason for the // later condition is that `selection` is originally a supergraph selection, but that we're looking to apply "as-is" to a subgraph. // But suppose it has a `... on I` where `I` is an interface. Then it's possible that `I` includes "more" types in the supergraph // than in the subgraph, and so we might have to type-explode it. If so, we cannot use the selection "as-is". if (vertex.hasReachableCrossSubgraphEdges || !isCompositeType(vertex.type) || !selection.canRebaseOn(vertex.type) || useInconsistentAbstractTypes() ) { return false; } } return true; } /** * Given 2 paths that are 2 different options to reach the same query leaf field, checks if one can be shown * to be always "better" (more efficient/optimal) than the other one, and this regardless of any surrounding context (that * is regardless of what the rest of the query plan would be for any other query leaf field. * * Note that this method is used on final options of a given "query path", so all the heuristics done within `GraphPath` * to avoid unecessary option have already been applied (say, avoiding to consider a path that do 2 successives key jumps * when there is a 1 jump equivalent, ...), so this focus on what can be done based on the fact that the path considered * are "finished". * * @return -1 if `opt1` is known to be strictly better than `opt2`, 1 if it is `opt2` that is strictly better, and 0 if we * cannot really guarantee anything (at least "out of context"). */ export function compareOptionsComplexityOutOfContext(opt1: SimultaneousPaths, opt2: SimultaneousPaths): number { // Currently, we only every compare single-path options. We may find smart things to do for multi-path options later, // but unsure what currently. if (opt1.length === 1) { if (opt2.length === 1) { return compareSinglePathOptionsComplexityOutOfContext(opt1[0], opt2[0]); } else { return compareSingleVsMultiPathOptionsComplexityOutOfContext(opt1[0], opt2); } } else if (opt2.length === 1) { return -compareSingleVsMultiPathOptionsComplexityOutOfContext(opt2[0], opt1); } return 0; } function compareSinglePathOptionsComplexityOutOfContext(p1: OpGraphPath, p2: OpGraphPath): number { // Currently, this method only handle the case where we have something like: // - `p1`: -[t]-> T(A) -[u]-> U(A) -[x] -> Int(A) // - `p2`: -[t]-> T(A) -[key]-> T(B) -[u]-> U(B) -[x] -> Int(B) // That is, we have 2 choices that are identical up to the "end", when one stays in the subgraph (p1, which stays in A) // while the other use a key to another subgraph (p2, going to B). // // In such a case, whatever else the a query might be doing, it can never be "worst" // to use `p1` than to use `p2` because both will force the same "fetch group" up to the // end, but `p2` may force one more fetch that `p` does not. // Do note that we say "may" above, because the rest of the plan may well have a forced // choice like: // - `other`: -[t]-> T(A) -[key]-> T(B) -[u]-> U(B) -[y] -> Int(B) // in which case the plan will have the jump from A to B after `t` whether we use `p1` or // `p2`, but while in that particular case `p1` and `p2` are about comparable in term // of performance, `p1` is still not worst than `p2` (and in other situtation, `p1` may // genuinely be better). // // Note that this is in many ways just a generalization of a heuristic we use earlier for leaf field. That is, // we will never get as input to this method something like: // - `p1`: -[t]-> T(A) -[x] -> Int(A) // - `p2`: -[t]-> T(A) -[key]-> T(B) -[x] -> Int(B) // because when the code is asked for option for `x` after ` -[t]-> T(A)`, it notices that `x` // is a leaf and is in `A`, so it doesn't ever look for alternative path. But this only work for direct // leaf of an entity. In the example at the beginning, field `u` makes this not working, because when // we compute choices for `u`, we don't yet know what comes after that, and so we have to take the option // of going to subgraph `B` into account (it may very be that whatever comes after `u` is not in `A` for // instance). if (p1.tail.source !== p2.tail.source) { const { thisJumps: p1Jumps, thatJumps: p2Jumps } = p1.countSubgraphJumpsAfterLastCommonVertex(p2); // As described above, we want to known if one of the path has no jumps at all (after the common prefix) while // the other do have some. if (p1Jumps === 0 && p2Jumps > 0) { return -1; } else if (p1Jumps > 0 && p2Jumps === 0) { return 1; } else { return 0; } } return 0; } function compareSingleVsMultiPathOptionsComplexityOutOfContext(p1: OpGraphPath, p2s: SimultaneousPaths): number { // This handle the same case than for the single-path only case, but compares the single path against // each of the option of the multi-path, and only "ignore" the multi-path if all its paths can be ignored. // Note that this happens less often than the single-path only case, but with @provides on an interface, you can // have case where one the one side you can get something entirely on the current graph, but the type-exploded case // has still be generated due to the leaf field not being the one just after "provided" interface. for (const p2 of p2s) { // Note: not sure if it is possible for a branch of the multi-path option to subsume the single-path one in practice, but // if it does, we ignore it because it's not obvious that this is enough to get rid of `p1` (maybe `p1` is provably a bit // costlier than one of the path of `p2s`, but `p2s` may have many paths and could still be collectively worst than `p1`). if (compareSinglePathOptionsComplexityOutOfContext(p1, p2) >= 0) { return 0; } } return -1; } class QueryPlanningTraversal { // The stack contains all states that aren't terminal. private bestPlan: [FetchDependencyGraph, OpPathTree, number] | undefined; private readonly isTopLevel: boolean; private conditionResolver: ConditionResolver; private stack: [Selection, SimultaneousPathsWithLazyIndirectPaths[]][]; private readonly closedBranches: ClosedBranch[] = []; private readonly optionsLimit: number | null; private readonly typeConditionedFetching: boolean; constructor( readonly parameters: PlanningParameters, selectionSet: SelectionSet, readonly startFetchIdGen: number, readonly hasDefers: boolean, private readonly rootKind: SchemaRootKind, readonly costFunction: CostFunction, initialContext: PathContext, typeConditionedFetching: boolean, nonLocalSelectionsState: NonLocalSelectionsState | null, initialSubgraphConstraint: string | null, excludedDestinations: ExcludedDestinations = [], excludedConditions: ExcludedConditions = [], ) { const { root, federatedQueryGraph } = parameters; this.typeConditionedFetching = typeConditionedFetching || false; this.isTopLevel = isRootVertex(root); this.optionsLimit = parameters.config.debug?.pathsLimit; this.conditionResolver = cachingConditionResolver( (edge, context, excludedEdges, excludedConditions, extras) => this.resolveConditionPlan(edge, context, excludedEdges, excludedConditions, extras), ); const initialPath: OpGraphPath = GraphPath.create(federatedQueryGraph, root); const initialOptions = createInitialOptions( initialPath, initialContext, this.conditionResolver, excludedDestinations, excludedConditions, parameters.overrideConditions, initialSubgraphConstraint, ); this.stack = mapOptionsToSelections(selectionSet, initialOptions); if ( this.parameters.federatedQueryGraph.nonLocalSelectionsMetadata && nonLocalSelectionsState ) { if (this.parameters.federatedQueryGraph.nonLocalSelectionsMetadata .checkNonLocalSelectionsLimitExceededAtRoot( this.stack, nonLocalSelectionsState, this.parameters.supergraphSchema, this.parameters.inconsistentAbstractTypesRuntimes, this.parameters.overrideConditions, initialSubgraphConstraint !== null, ) ) { throw Error(`Number of non-local selections exceeds limit of ${ NonLocalSelectionsMetadata.MAX_NON_LOCAL_SELECTIONS }`); } } } private debugStack() { if (this.isTopLevel && debug.enabled) { debug.group('Query planning open branches:'); for (const [selection, options] of this.stack) { debug.groupedValues(options, opt => `${simultaneousPathsToString(opt)}`, `${selection}:`); } debug.groupEnd(); } } findBestPlan(): [FetchDependencyGraph, OpPathTree, number] | undefined { while (this.stack.length > 0) { this.debugStack(); const [selection, options] = this.stack.pop()!; this.handleOpenBranch(selection, options); } this.computeBestPlanFromClosedBranches(); return this.bestPlan; } private recordClosedBranch(closed: ClosedBranch) { const maybeTrimmed = this.maybeEliminateStrictlyMoreCostlyPaths(closed); debug.log(() => `Closed branch has ${maybeTrimmed.length} options (eliminated ${closed.length - maybeTrimmed.length} that could be proved as unecessary)`); this.closedBranches.push(maybeTrimmed); } private handleOpenBranch(selection: Selection, options: SimultaneousPathsWithLazyIndirectPaths[]) { const operation = selection.element; debug.group(() => `Handling open branch: ${operation}`); let newOptions: SimultaneousPathsWithLazyIndirectPaths[] = []; for (const option of options) { const followupForOption = advanceSimultaneousPathsWithOperation( this.parameters.supergraphSchema, option, operation, this.parameters.overrideConditions, ); if (!followupForOption) { // There is no valid way to advance the current `operation` from this option, so this option is a dead branch // that cannot produce a valid query plan. So we simply ignore it and rely on other options. continue; } if (followupForOption.length === 0) { // This `operation` is valid from that option but is guarantee to yield no result (it's a type condition that, along // with prior condition, has no intersection). Given that (assuming the user do properly resolve all versions of a // given field the same way from all subgraphs) all options should return the same results, we know that operation // should return no result from all options (even if we can't provide it technically). // More concretely, this usually means the current operation is a type condition that has no intersection with the possible // current runtime types at this point, and this means whatever fields the type condition sub-selection selects, they // will never be part of the results. That said, we cannot completely ignore the type-condition/fragment or we'd end // up with the wrong results. Consider the example a sub-part of the query is : // { // foo { // ... on Bar { // field // } // } // } // and suppose that `... on Bar` can never match a concrete runtime type at this point. Because that's the only sub-selection // of `foo`, if we completely ignore it, we'll end up not querying this at all. Which means that, during execution, // we'd either return (for that sub-part of the query) `{ foo: null }` if `foo` happens to be nullable, or just `null` for // the whole sub-part otherwise. But what we *should* return (assuming foo doesn't actually return `null`) is `{ foo: {} }`. // Meaning, we have queried `foo` and it returned something, but it's simply not a `Bar` and so nothing was included. // Long story short, to avoid that situation, we replace the whole `... on Bar` section that can never match the runtime // type by simply getting the `__typename` of `foo`. This ensure we do query `foo` but don't end up including condiditions // that may not even make sense to the subgraph we're querying. // Do note that we'll only need that `__typename` if there is no other selections inside `foo`, and so we might include // it unecessarally in practice: it's a very minor inefficiency though. if (operation.kind === 'FragmentElement') { this.recordClosedBranch(options.map((o) => ({ paths: o.paths.map(p => terminateWithNonRequestedTypenameField(p, this.parameters.overrideConditions)) }))); } debug.groupEnd(() => `Terminating branch with no possible results`); return; } newOptions = newOptions.concat(followupForOption); if (this.optionsLimit && newOptions.length > this.optionsLimit) { throw new Error(`Too many options generated for ${selection}, reached the limit of ${this.optionsLimit}`); } } if (newOptions.length === 0) { // If we have no options, it means there is no way to build a plan for that branch, and // that means the whole query planning has no plan. // This should never happen for a top-level query planning (unless the supergraph has *not* been // validated), but can happen when computing sub-plans for a key condition and when computing // a top-level plan for a mutation field on a specific subgraph. if (this.isTopLevel && this.rootKind !== 'mutation') { debug.groupEnd(() => `No valid options to advance ${selection} from ${advanceOptionsToString(options)}`); throw new Error(`Was not able to find any options for ${selection}: This shouldn't have happened.`); } else { // We clear both open branches and closed ones as a mean to terminate the plan computation with // no plan this.stack.splice(0, this.stack.length); this.closedBranches.splice(0, this.closedBranches.length); debug.groupEnd(() => `No possible plan for ${selection} from ${advanceOptionsToString(options)}; terminating condition`); return; } } if (selection.selectionSet) { const allTails = allTailVertices(newOptions); if (selectionIsFullyLocalFromAllVertices(selection.selectionSet, allTails, this.parameters.inconsistentAbstractTypesRuntimes) && !selection.hasDefer() ) { // We known the rest of the selection is local to whichever subgraph the current options are in, and so we're // going to keep that selection around and add it "as-is" to the `FetchNode` when needed, saving a bunch of // work (created `GraphPath`, merging `PathTree`, ...). However, as we're skipping the "normal path" for that // sub-selection, there is a few things that are handled in said "normal path" that we need to still handle. // More precisely: // - we have this "attachment" trick that removes requested `__typename` temporarily, so we should add it back. // - we still need to add the selection of `__typename` for abstract types. It is not really necessary for the // execution per-se, but if we don't do it, then we will not be able to reuse named fragments as often // as we should (we add `__typename` for abstract types on the "normal path" and so we add them too to // named fragments; as such, we need them here too). const selectionSet = addTypenameFieldForAbstractTypes(addBackTypenameInAttachments(selection.selectionSet)); this.recordClosedBranch(newOptions.map((opt) => ({ paths: opt.paths, selection: selectionSet }))); } else { for (const branch of mapOptionsToSelections(selection.selectionSet, newOptions)) { this.stack.push(branch); } } debug.groupEnd(); } else { this.recordClosedBranch(newOptions.map((opt) => ({ paths: opt.paths }))); debug.groupEnd(() => `Branch finished`); } } /** * This method is called on a closed branch, that is on all the possible options found * to get a particular leaf of the query being planned. And when there is more than one * option, it tries a last effort at checking an option can be shown to be less efficient * than another one _whatever the rest of the query plan is_ (that is, whatever the options * for any other leaf of the query are). * * In practice, this compare all pair of options and call the heuristics * of `compareOptionsComplexityOutOfContext` on them to see if one strictly * subsume the other (and if that's the case, the subsumed one is ignored). */ private maybeEliminateStrictlyMoreCostlyPaths(branch: ClosedBranch): ClosedBranch { if (branch.length <= 1) { return branch; } // Copying the branch because we're going to modify in place. const toHandle = branch.concat(); const keptOptions: ClosedPath[] = []; while (toHandle.length >= 2) { const first = toHandle[0]; let shouldKeepFirst = true; // We compare `first` to every other remaining. But we iterate from end to beginning // because we may remove in place some of those we iterate on and that makes it safe. for (let i = toHandle.length - 1 ; i >= 1; i--) { const other = toHandle[i]; const cmp = compareOptionsComplexityOutOfContext(first.paths, other.paths); if (cmp < 0) { // This means that `first` is always better than `other`. So we eliminate `other`. toHandle.splice(i, 1); } else if (cmp > 0) { // This means that `first` is always worst than `other`. So we eliminate `first` ( // and we're done with this inner loop). toHandle.splice(0, 1); shouldKeepFirst = false; break; } } if (shouldKeepFirst) { // Means that we found no other option that make first unecessary. We mark first as kept, // and remove it from our working set (which we know it hasn't yet). keptOptions.push(first); toHandle.splice(0, 1); } } // We know toHandle has at most 1 element, but it may have one and we should keep it. if (toHandle.length > 0) { keptOptions.push(toHandle[0]); } return keptOptions; } private newDependencyGraph(): FetchDependencyGraph { const { supergraphSchema, federatedQueryGraph } = this.parameters; const rootType = this.isTopLevel && this.hasDefers ? supergraphSchema.schemaDefinition.rootType(this.rootKind) : undefined; return FetchDependencyGraph.create(supergraphSchema, federatedQueryGraph, this.startFetchIdGen, rootType, this.parameters.config.generateQueryFragments); } // Moves the first closed branch to after any branch having more options. // This method assumes that closed branches are sorted by decreasing number of options _except_ for the first element // which may be out of order, and this method restore that order. private reorderFirstBranch() { const firstBranch = this.closedBranches[0]; let i = 1; while (i < this.closedBranches.length && this.closedBranches[i].length > firstBranch.length) { i++; } // `i` is the smallest index of an element having the same number or less options than the first one, // so we switch that first branch with the element "before" `i` (which has more elements). this.closedBranches[0] = this.closedBranches[i - 1]; this.closedBranches[i - 1] = firstBranch; } private sortOptionsInClosedBranches() { this.closedBranches.forEach((branch) => branch.sort((p1, p2) => { const p1Jumps = Math.max(...p1.paths.map((p) => p.subgraphJumps())); const p2Jumps = Math.max(...p2.paths.map((p) => p.subgraphJumps())); return p1Jumps - p2Jumps; })); } private computeBestPlanFromClosedBranches() { if (this.closedBranches.length === 0) { return; } // We now sort the options within each branch, putting those with the least amount of subgraph jumps first. // The idea is that for each branch taken individually, the option with the least jumps is going to be // the most efficient, and while it is not always the case that the best plan is built for those // individual bests, they are still statistically more likely to be part of the best plan. So putting // them first has 2 benefits for the rest of this method: // 1. if we end up cutting some options of a branch below (due to having too many possible plans), // we'll cut the last option first (we `pop()`), so better cut what it the least likely to be good. // 2. when we finally generate the plan, we use the cost of previously computed plans to cut computation // early when possible (see `generateAllPlansAndFindBest`), so there is a premium in generating good // plans early (it cuts more computation), and putting those more-likely-to-be-good options first helps // this. this.sortOptionsInClosedBranches(); // We're out of smart ideas for now, so we look at how many plans we'd have to generate, and if it's // "too much", we reduce it to something manageable by arbitrarilly throwing out options. This effectively // means that when a query has too many options, we give up on always finding the "best" // query plan in favor of an "ok" query plan. // TODO: currently, when we need to reduce options, we do so somewhat arbitrarilly. More // precisely, we reduce the branches with the most options first and then drop the last // option of the branch, repeating until we have a reasonable number of plans to consider. // The sorting we do about help making this slightly more likely to be a good choice, but // there is likely more "smarts" we could add to this. // We sort branches by those that have the most options first. this.closedBranches.sort((b1, b2) => b1.length > b2.length ? -1 : (b1.length < b2.length ? 1 : 0)); let planCount = possiblePlans(this.closedBranches); debug.log(() => `Query has ${planCount} possible plans`); let firstBranch = this.closedBranches[0]; const maxPlansToCompute = this.parameters.config.debug.maxEvaluatedPlans; while (planCount > maxPlansToCompute && firstBranch.length > 1) { // we remove the right-most option of the first branch, and them move that branch to it's new place. const prevSize = BigInt(firstBranch.length); firstBranch.pop(); planCount -= planCount / prevSize; this.reorderFirstBranch(); // Note that if firstBranch is our only branch, it's fine, we'll continue to remove options from // it (but that is beyond unlikely). firstBranch = this.closedBranches[0]; debug.log(() => `Reduced plans to consider to ${planCount} plans`); } // Note that if `!this.isTopLevel`, then this means we're resolving a sub-plan for an edge condition, and we // don't want to count those as "evaluated plans". if (this.parameters.statistics && this.isTopLevel) { this.parameters.statistics.evaluatedPlanCount += Number(planCount); } debug.log(() => `All branches:${this.closedBranches.map((opts, i) => `\n${i}:${opts.map((opt => `\n - ${closedPathToString(opt)}`))}`)}`); // Note that usually, we'll have a majority of branches with just one option. We can group them in // a PathTree first with no fuss. When then need to do a cartesian product between this created // tree an other branches however to build the possible plans and chose. let idxFirstOfLengthOne = 0; while (idxFirstOfLengthOne < this.closedBranches.length && this.closedBranches[idxFirstOfLengthOne].length > 1) { idxFirstOfLengthOne++; } let initialTree: OpPathTree; let initialDependencyGraph: FetchDependencyGraph; const { federatedQueryGraph, root } = this.parameters; if (idxFirstOfLengthOne === this.closedBranches.length) { initialTree = PathTree.createOp(federatedQueryGraph, root); initialDependencyGraph = this.newDependencyGraph(); } else { const singleChoiceBranches = this .closedBranches .slice(idxFirstOfLengthOne) .flat() .map((cp) => flattenClosedPath(cp)) .flat(); initialTree = PathTree.createFromOpPaths(federatedQueryGraph, root, singleChoiceBranches); initialDependencyGraph = this.updatedDependencyGraph(this.newDependencyGraph(), initialTree); if (idxFirstOfLengthOne === 0) { // Well, we have the only possible plan; it's also the best. this.bestPlan = [initialDependencyGraph, initialTree, this.cost(initialDependencyGraph)]; return; } } const otherTrees = this .closedBranches .slice(0, idxFirstOfLengthOne) .map(b => b.map(opt => PathTree.createFromOpPaths(federatedQueryGraph, root, flattenClosedPath(opt)))); const { best, cost} = generateAllPlansAndFindBest({ initial: { graph: initialDependencyGraph, tree: initialTree }, toAdd: otherTrees, addFct: (p, t) => { const updatedDependencyGraph = p.graph.clone(); this.updatedDependencyGraph(updatedDependencyGraph, t); const updatedTree = p.tree.merge(t); return { graph: updatedDependencyGraph, tree: updatedTree }; }, costFct: (p) => this.cost(p.graph), onPlan: (p, cost, prevCost) => { debug.log(() => { if (!prevCost) { return `Computed plan with cost ${cost}: ${p.tree}`; } else if (cost > prevCost) { return `Ignoring plan with cost ${cost} (a better plan with cost ${prevCost} exists): ${p.tree}` } else { return `Found better with cost ${cost} (previous had cost ${prevCost}: ${p.tree}`; } }); }, }); this.bestPlan = [best.graph, best.tree, cost]; } private cost(dependencyGraph: FetchDependencyGraph): number { const { main, deferred } = dependencyGraph.process(this.costFunction, this.rootKind); return deferred.length === 0 ? main : this.costFunction.reduceDefer(main, dependencyGraph.deferTracking.primarySelection!.get(), deferred); } private updatedDependencyGraph(dependencyGraph: FetchDependencyGraph, tree: OpPathTree): FetchDependencyGraph { return isRootPathTree(tree) ? computeRootFetchGroups(dependencyGraph, tree, this.rootKind, this.typeConditionedFetching) : computeNonRootFetchGroups(dependencyGraph, tree, this.rootKind, this.typeConditionedFetching); } private resolveConditionPlan(edge: Edge, context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions, extraConditions?: SelectionSet): ConditionResolution { const bestPlan = new QueryPlanningTraversal( { ...this.parameters, root: edge.head, }, extraConditions ?? edge.conditions!, 0, false, 'query', this.costFunction, context, this.typeConditionedFetching, null, null, excludedDestinations, addConditionExclusion(excludedConditions, edge.conditions), ).findBestPlan(); // Note that we want to return 'null', not 'undefined', because it's the latter that means "I cannot resolve that // condition" within `advanceSimultaneousPathsWithOperation`. return bestPlan ? { satisfied: true, cost: bestPlan[2], pathTree: bestPlan[1] } : unsatisfiedConditionsResolution; } } /** * Used in `FetchDependencyGraph` to store, for a given group, information about one of its parent. * Namely, this structure stores: * 1. the actual parent group, and * 2. the path of the group for which this is a "parent relation" into said parent (`group`). This information * is maintained for the case where we want/need to merge groups into each other. One can roughly think of * this as similar to a `mergeAt`, but that is relative to the start of `group`. It can be `undefined`, which * either mean we don't know that path or that this simply doesn't make sense (there is case where a child `mergeAt` can * be shorter than its parent's, in which case the `path`, which is essentially `child-mergeAt - parent-mergeAt`, does * not make sense (or rather, it's negative, which we cannot represent)). Tl;dr, `undefined` for the `path` means that * should make no assumption and bail on any merging that uses said path. */ type ParentRelation = { group: FetchGroup, path?: OperationPath, } const conditionsMemoizer = (selectionSet: SelectionSet) => ({ conditions: conditionsOfSelectionSet(selectionSet) }); class GroupInputs { readonly usedContexts = new Map; private readonly perType = new Map(); onUpdateCallback?: () => void | undefined = undefined; constructor( readonly supergraphSchema: Schema, ) { } add(selection: Selection | SelectionSet) { assert(selection.parentType.schema() === this.supergraphSchema, 'Inputs selections must be based on the supergraph schema'); const typeName = selection.parentType.name; let typeSelection = this.perType.get(typeName); if (!typeSelection) { typeSelection = MutableSelectionSet.empty(selection.parentType); this.perType.set(typeName, typeSelection); } typeSelection.updates().add(selection); this.onUpdateCallback?.(); } addContext(context: string, type: Type) { this.usedContexts.set(context, type); } addAll(other: GroupInputs) { for (const otherSelection of other.perType.values()) { this.add(otherSelection.get()); } for (const [context, type] of other.usedContexts) { this.addContext(context, type); } } selectionSets(): SelectionSet[] { return mapValues(this.perType).map((s) => s.get()); } toSelectionSetNode(variablesDefinitions: VariableDefinitions, handledConditions: Conditions): SelectionSetNode { const selectionSets = mapValues(this.perType).map((s) => removeConditionsFromSelectionSet(s.get(), handledConditions)); // Making sure we're not generating something invalid. selectionSets.forEach((s) => s.validate(variablesDefinitions)); const selections = selectionSets.flatMap((sSet) => sSet.selections().map((s) => s.toSelectionNode())); return { kind: Kind.SELECTION_SET, selections, } } contains(other: GroupInputs): boolean { for (const [type, otherSelection] of other.perType) { const thisSelection = this.perType.get(type); if (!thisSelection || !thisSelection.get().contains(otherSelection.get())) { return false; } } if (this.usedContexts.size < other.usedContexts.size) { return false; } for (const [c,_] of other.usedContexts) { if (!this.usedContexts.has(c)) { return false; } } return true; } equals(other: GroupInputs): boolean { if (this.perType.size !== other.perType.size) { return false; } for (const [type, thisSelection] of this.perType) { const otherSelection = other.perType.get(type); if (!otherSelection || !thisSelection.get().equals(otherSelection.get())) { return false; } } if (this.usedContexts.size !== other.usedContexts.size) { return false; } for (const [c,_] of other.usedContexts) { if (!this.usedContexts.has(c)) { return false; } } return true; } clone(): GroupInputs { const cloned = new GroupInputs(this.supergraphSchema); for (const [type, selection] of this.perType.entries()) { cloned.perType.set(type, selection.clone()); } for (const [c,v] of this.usedContexts) { cloned.usedContexts.set(c,v); } return cloned; } toString(): string { const inputs = mapValues(this.perType); if (inputs.length === 0) { return '{}'; } if (inputs.length === 1) { return inputs[0].toString(); } return '[' + inputs.join(',') + ']'; } } /** * Represents a subgraph fetch of a query plan, and is a vertex of a `FetchDependencyGraph` (and as such provides links to * its parent and children in that dependency graph). */ class FetchGroup { private readonly _parents: ParentRelation[] = []; private readonly _children: FetchGroup[] = []; private _id: string | undefined; // Set in some code-path to indicate that the selection of the group not be optimized away even if it "looks" useless. mustPreserveSelection: boolean = false; private constructor( readonly dependencyGraph: FetchDependencyGraph, public index: number, readonly subgraphName: string, readonly rootKind: SchemaRootKind, readonly parentType: CompositeType, readonly isEntityFetch: boolean, private _selection: MutableSelectionSet<{ conditions: Conditions}>, private _inputs?: GroupInputs, private _contextInputs?: FetchDataKeyRenamer[], readonly mergeAt?: ResponsePath, readonly deferRef?: string, // Some of the processing on the dependency graph checks for groups to the same subgraph and same mergeAt, and we use this // key for that. Having it here saves us from re-computing it more than once. readonly subgraphAndMergeAtKey?: string, private cachedCost?: number, private generateQueryFragments: boolean = false, // Cache used to save unecessary recomputation of the `isUseless` method. private isKnownUseful: boolean = false, private readonly inputRewrites: FetchDataRewrite[] = [], ) { if (this._inputs) { this._inputs.onUpdateCallback = () => { // We're trying to avoid the full recomputation of `isUseless` when we're already // shown that the group is known useful (if it is shown useless, the group is removed, // so we're not caching that result but it's ok). And `isUseless` basically checks if // `inputs.contains(selection)`, so if a group is shown useful, it means that there // is some selections not in the inputs, but as long as we add to selections (and we // never remove from selections; `MutableSelectionSet` don't have removing methods), // then this won't change. Only changing inputs may require some recomputation. this.isKnownUseful = false; } } } static create({ dependencyGraph, index, subgraphName, rootKind, parentType, hasInputs, mergeAt, deferRef, generateQueryFragments, }: { dependencyGraph: FetchDependencyGraph, index: number, subgraphName: string, rootKind: SchemaRootKind, parentType: CompositeType, hasInputs: boolean, mergeAt?: ResponsePath, deferRef?: string, generateQueryFragments: boolean, }): FetchGroup { // Sanity checks that the selection parent type belongs to the schema of the subgraph we're querying. assert(parentType.schema() === dependencyGraph.subgraphSchemas.get(subgraphName), `Expected parent type ${parentType} to belong to ${subgraphName}`); return new FetchGroup( dependencyGraph, index, subgraphName, rootKind, parentType, hasInputs, MutableSelectionSet.emptyWithMemoized(parentType, conditionsMemoizer), hasInputs ? new GroupInputs(dependencyGraph.supergraphSchema) : undefined, undefined, mergeAt, deferRef, hasInputs ? `${toValidGraphQLName(subgraphName)}-${mergeAt?.join('::') ?? ''}` : undefined, undefined, generateQueryFragments, ); } // Clones everything on the group itself, but not it's `_parents` or `_children` links. cloneShallow(newDependencyGraph: FetchDependencyGraph): FetchGroup { return new FetchGroup( newDependencyGraph, this.index, this.subgraphName, this.rootKind, this.parentType, this.isEntityFetch, this._selection.clone(), this._inputs?.clone(), this._contextInputs ? this._contextInputs.map((c) => ({ ...c})) : undefined, this.mergeAt, this.deferRef, this.subgraphAndMergeAtKey, this.cachedCost, this.generateQueryFragments, this.isKnownUseful, [...this.inputRewrites], ); } cost(): number { if (!this.cachedCost) { this.cachedCost = selectionCost(this.selection); } return this.cachedCost; } set id(id: string | undefined) { assert(!this._id, () => `The id for fetch group ${this} is already set`); this._id = id; } get id(): string | undefined { return this._id; } get isTopLevel(): boolean { return !this.mergeAt; } get selection(): SelectionSet { return this._selection.get(); } private selectionUpdates(): SelectionSetUpdates { this.cachedCost = undefined; return this._selection.updates(); } get inputs(): GroupInputs | undefined { return this._inputs; } addParents(parents: readonly ParentRelation[]) { for (const parent of parents) { this.addParent(parent); } } /** * Adds another group as a parent of this one (meaning that this fetch should happen after the provided one). */ addParent(parent: ParentRelation) { if (this.isChildOf(parent.group)) { // Due to how we handle the building of multiple query plans when there is choices, it's possible that we re-traverse // key edges we've already traversed before, and that can means hitting this condition. While we could try to filter // "already-children" before calling this method, it's easier to just make this a no-op. return; } assert(!parent.group.isParentOf(this), () => `Group ${parent.group} is a parent of ${this}, but the child relationship is broken`); assert(!parent.group.isChildOf(this), () => `Group ${parent.group} is a child of ${this}: adding it as parent would create a cycle`); this.dependencyGraph.onModification(); this._parents.push(parent); parent.group._children.push(this); } removeChild(child: FetchGroup) { if (!this.isParentOf(child)) { return; } this.dependencyGraph.onModification(); findAndRemoveInPlace((g) => g === child, this._children); findAndRemoveInPlace((p) => p.group === this, child._parents); } isParentOf(maybeChild: FetchGroup): boolean { return this._children.includes(maybeChild); } isChildOf(maybeParent: FetchGroup): boolean { return !!this.parentRelation(maybeParent); } isDescendantOf(maybeAncestor: FetchGroup): boolean { const children = Array.from(maybeAncestor.children()); while (children.length > 0) { const child = children.pop()!; if (child === this) { return true; } child.children().forEach((c) => children.push(c)); } return false; } /** * Returns whether this group is both a child of `maybeParent` but also if we can show that the * dependency between the group is "artificial" in the sense that this group inputs do not truly * depend on anything `maybeParent` fetches. */ isChildOfWithArtificialDependency(maybeParent: FetchGroup): boolean { const relation = this.parentRelation(maybeParent); // To be a child with an artificial dependency, it needs to be a child first, and the "path in parent" should be know. if (!relation || !relation.path) { return false; } // Then, if we have no inputs, we know we don't depend on anything from the parent no matter what. if (!this.inputs) { return true; } // If we do have inputs, then we first look at the path to `maybeParent`, and it needs to be // essentially empty, "essentially" is because path can sometimes have some leading fragment(s) // and those are fine to ignore. But if the path has some field, then this implies that the inputs // of `this` are based on something at a deeper level than those of `maybeParent`, and the "contains" // comparison we do below would not make sense. if (relation.path.some((elt) => elt.kind === 'Field')) { return false; } // In theory, the most general test we could have here is to check if `this.inputs` "intersects" // `maybeParent.selection. As if it doesn't, we know our inputs don't depend on anything the // parent fetches. However, selection set intersection is a bit tricky to implement (due to fragments, // it would be a bit of code to do not-too-inefficiently, but both fragments and alias makes the // definition of what the intersection we'd need here fairly subtle), and getting it wrong could // make us generate incorrect query plans. Adding to that, the current know cases for this method // being useful happens to be when `this.inputs` and `maybeParent.inputs` are the same. Now, checking // inputs is a bit weaker, in the sense that the input could be different and yet the child group // not depend on anything the parent fetches, but it is "sufficient", in that if the inputs of the // parent includes entirely the child inputs, then we know nothing the child needs can be fetched // by the parent (or rather, if it does, it's useless). Anyway, checking inputs inclusion is easier // to do so we rely on this for now. If we run into examples where this happens to general non-optimal // query plan, we can decide then to optimize further and implement a proper intersections. return !!maybeParent.inputs && maybeParent.inputs.contains(this.inputs); } parentRelation(maybeParent: FetchGroup): ParentRelation | undefined { return this._parents.find(({ group }) => maybeParent === group); } parents(): readonly ParentRelation[] { return this._parents; } parentGroups(): readonly FetchGroup[] { return this.parents().map((p) => p.group); } children(): readonly FetchGroup[] { return this._children; } addInputs(selection: Selection | SelectionSet, rewrites?: FetchDataRewrite[]) { assert(this._inputs, "Shouldn't try to add inputs to a root fetch group"); this._inputs.add(selection); if (rewrites) { rewrites.forEach((r) => this.inputRewrites.push(r)); } } addInputContext(context: string, type: Type) { assert(this._inputs, "Shouldn't try to add inputs to a root fetch group"); this._inputs.addContext(context, type); } copyInputsOf(other: FetchGroup) { if (other.inputs) { this.inputs?.addAll(other.inputs); if (other.inputRewrites) { other.inputRewrites.forEach((r) => { if (!this.inputRewrites.some((r2) => r2 === r)) { this.inputRewrites.push(r); } }); } if (other._contextInputs) { if (!this._contextInputs) { this._contextInputs = []; } other._contextInputs.forEach((r) => { if (!this._contextInputs!.some((r2) => sameKeyRenamer(r, r2))) { this._contextInputs!.push(r); } }); } } } addAtPath(path: OperationPath, selection?: Selection | SelectionSet | readonly Selection[]) { this.selectionUpdates().addAtPath(path, selection); } addSelections(selection: SelectionSet) { this.selectionUpdates().add(selection); } canMergeChildIn(child: FetchGroup): boolean { return this.deferRef === child.deferRef && !!child.parentRelation(this)?.path; } removeInputsFromSelection() { const inputs = this.inputs; if (inputs) { this.cachedCost = undefined; const updated = inputs.selectionSets().reduce((prev, value) => prev.minus(value), this.selection); this._selection = MutableSelectionSet.ofWithMemoized(updated, conditionsMemoizer); } } // If a group is such that everything is fetches is already included in the inputs, then // this group does useless fetches. isUseless(): boolean { if (this.isKnownUseful || !this.inputs || this.mustPreserveSelection) { return false; } // For groups that fetches from an @interfaceObject, we can sometimes have something like // { ... on Book { id } } => { ... on Product { id } } // where `Book` is an implementation of interface `Product`. // And that is because while only "books" are concerned by this fetch, the `Book` type is unknown // of the queried subgraph (in that example, it defines `Product` as an @interfaceObject) and // so we have to "cast" into `Product` instead of `Book`. // But the fetch above _is_ useless, it does only fetch its inputs, and we wouldn't catch this // if we do a raw inclusion check of `selection` into `inputs` // // We only care about this problem at the top-level of the selections however, so we do that // top-level check manually (instead of just calling `this.inputs.contains(this.selection)`) // but fallback on `contains` for anything deeper. const conditionInSupergraphIfInterfaceObject = (selection: Selection): InterfaceType | undefined => { if (selection.kind === 'FragmentSelection') { const condition = selection.element.typeCondition; if (condition && isObjectType(condition)) { const conditionInSupergraph = this.dependencyGraph.supergraphSchema.type(condition.name); // Note that we're checking the true supergraph, not the API schema, so even @inaccessible types will be found. assert(conditionInSupergraph, () => `Type ${condition.name} should exists in the supergraph`) if (isInterfaceType(conditionInSupergraph)) { return conditionInSupergraph; } } } return undefined; } // This condition is specific to the case where we're resolving the _concrete_ // `__typename` field of an interface when coming from an interfaceObject type. // i.e. { ... on Product { __typename id }} => { ... on Product { __typename} } // This is usually useless at a glance, but in this case we need to actually // keep this since this is our only path to resolving the concrete `__typename`. const isInterfaceTypeConditionOnInterfaceObject = ( selection: Selection ): boolean => { if (selection.kind === "FragmentSelection") { const parentType = selection.element.typeCondition; if (parentType && isInterfaceType(parentType)) { // Lastly, we just need to check that we're coming from a subgraph // that has the type as an interface object in its schema. return this.parents().some((p) => { const typeInParent = this.dependencyGraph.subgraphSchemas .get(p.group.subgraphName) ?.type(parentType.name); return typeInParent && isInterfaceObjectType(typeInParent); }); } } return false; }; const inputSelections = this.inputs.selectionSets().flatMap((s) => s.selections()); // Checks that every selection is contained in the input selections. const isUseless = this.selection.selections().every((selection) => { // If we're coming from an interfaceObject _to_ an interface, we're // "resolving" the concrete type of the interface and don't want to treat // this as useless. if (isInterfaceTypeConditionOnInterfaceObject(selection)) { return false; } const conditionInSupergraph = conditionInSupergraphIfInterfaceObject(selection); if (!conditionInSupergraph) { // We're not in the @interfaceObject case described above. We just check that an input selection contains the // one we check. return inputSelections.some((input) => input.contains(selection)); } const implemTypeNames = conditionInSupergraph.possibleRuntimeTypes().map((t) => t.name); // Find all the input selections that selects object for this interface, that is selection on // either the interface directly or on one of it's implementation type (we keep both kind separate). const interfaceInputSelections: FragmentSelection[] = []; const implementationInputSelections: FragmentSelection[] = []; for (const inputSelection of inputSelections) { // We know that fetch inputs are wrapped in fragments whose condition is an entity type: // that's how we build them and we couldn't select inputs correctly otherwise. assert(inputSelection.kind === 'FragmentSelection', () => `Unexpecting input selection ${inputSelection} on ${this}`); const inputCondition = inputSelection.element.typeCondition; assert(inputCondition, () => `Unexpecting input selection ${inputSelection} on ${this} (missing condition)`); if (inputCondition.name == conditionInSupergraph.name) { interfaceInputSelections.push(inputSelection); } else if (implemTypeNames.includes(inputCondition.name)) { implementationInputSelections.push(inputSelection); } } const subSelectionSet = selection.selectionSet; // we're only here if `conditionInSupergraphIfInterfaceObject` returned something, we imply that selection is a fragment // selection and so has a sub-selectionSet. assert(subSelectionSet, () => `Should not be here for ${selection}`); // If there is some selections on the interface, then the selection needs to be contained in those. // Otherwise, if there is implementation selections, it must be contained in _each_ of them (we // shouldn't have the case where there is neither interface nor implementation selections, but // we just return false if that's the case as a "safe" default). if (interfaceInputSelections.length > 0) { return interfaceInputSelections.some((input) => input.selectionSet.contains(subSelectionSet)); } return implementationInputSelections.length > 0 && implementationInputSelections.every((input) => input.selectionSet.contains(subSelectionSet)); }); this.isKnownUseful = !isUseless; return isUseless; } /** * Merges a child of `this` group into it. * * Note that it is up to the caller to know that doing such merging is reasonable in the first place, which * generally means knowing that 1) `child.inputs` are included in `this.inputs` and 2) all of `child.selection` * can safely be queried on the `this.subgraphName` subgraph. * * @param child - a group that must be a `child` of this, and for which the 'path in parent' (for `this`) is * known. The `canMergeChildIn` method can be used to ensure that `child` meets those requirement. */ mergeChildIn(child: FetchGroup) { const relationToChild = child.parentRelation(this); assert(relationToChild, () => `Cannot merge ${child} into ${this}: the former is not a child of the latter`); const childPathInThis = relationToChild.path; assert(childPathInThis, () => `Cannot merge ${child} into ${this}: the path of the former into the later is unknown`); this.mergeInInternal(child, childPathInThis); } canMergeSiblingIn(sibling: FetchGroup): boolean { // We only allow merging sibling on the same subgraph, same "mergeAt" and when the common parent is their only parent: // - there is no reason merging siblings of different subgraphs could ever make sense. // - ensuring the same "mergeAt" makes so we can merge the inputs and selections without having to worry about those // not being at the same level (hence the empty path in the call to `mergeInInternal` below). In theory, we could // relax this when we have the "path in parent" for both sibling, and if `siblingToMerge` is "deeper" than `this`, // we could still merge it in using the appropriate path. We don't use this yet, but if this get in the way of // some query plan optimisation, we may have to do so. // - only handling a single parent could be expanded on later, but we don't need it yet so we focus on the simpler case. const ownParents = this.parents(); const siblingParents = sibling.parents(); return this.deferRef === sibling.deferRef && this.subgraphName === sibling.subgraphName && sameMergeAt(this.mergeAt, sibling.mergeAt) && ownParents.length === 1 && siblingParents.length === 1 && ownParents[0].group === siblingParents[0].group; } /** * Merges the provided sibling (shares a common parent) of `this` group into it. * * Callers _must_ ensures that such merging is possible by calling `canMergeSiblingIn`. * * @param sibling - a sibling group of `this`. Both `this` and `sibling` must share a parent but it should also be * their _only_ parent. Further `this` and `sibling` must be on the same subgraph and have the same `mergeAt`. */ mergeSiblingIn(sibling: FetchGroup) { this.copyInputsOf(sibling); this.mergeInInternal(sibling, []); } canMergeGrandChildIn(grandChild: FetchGroup): boolean { const gcParents = grandChild.parents(); if (gcParents.length !== 1) { return false; } return this.deferRef === grandChild.deferRef && !!gcParents[0].path && !!gcParents[0].group.parentRelation(this)?.path; } /** * Merges a grand child of `this` group into it. * * Note that it is up to the caller to know that doing such merging is reasonable in the first place, which * generally means knowing that 1) `grandChild.inputs` are included in `this.inputs` and 2) all of `grandChild.selection` * can safely be queried on the `this.subgraphName` subgraph (the later of which is trivially true if `this` and * `grandChild` are on the same subgraph and same mergeAt). * * @param grandChild - a group that must be a "grand child" (a child of a child) of `this`, and for which the * 'path in parent' is know for _both_ the grand child to tis parent and that parent to `this`. The `canMergeGrandChildIn` * method can be used to ensure that `grandChild` meets those requirement. */ mergeGrandChildIn(grandChild: FetchGroup) { const gcParents = grandChild.parents(); assert(gcParents.length === 1, () => `Cannot merge ${grandChild} as it has multiple parents ([${gcParents}])`); const gcParent = gcParents[0]; const gcGrandParent = gcParent.group.parentRelation(this); assert(gcGrandParent, () => `Cannot merge ${grandChild} into ${this}: the former parent (${gcParent.group}) is not a child of the latter`); assert(gcParent.path && gcGrandParent.path, () => `Cannot merge ${grandChild} into ${this}: some paths in parents are unknown`); this.mergeInInternal(grandChild, concatOperationPaths(gcGrandParent.path, gcParent.path)); } /** * Merges another group into `this` group, without knowing the dependencies between those 2 groups. * * Note that it is up to the caller to know if such merging is desirable. In particular, if both group have completely * different inputs, merging them, which also merges their dependencies, might not be judicious for the optimality of * the query plan. * * @param other - another group to merge into `this`. Both `this` and `other` must be on the same subgraph and have the same * `mergeAt`. */ mergeInWithAllDependencies(other: FetchGroup) { assert(this.deferRef === other.deferRef, () => `Can only merge unrelated groups within the same @defer block: cannot merge ${this} and ${other}`); assert(this.subgraphName === other.subgraphName, () => `Can only merge unrelated groups to the same subraphs: cannot merge ${this} and ${other}`); assert(sameMergeAt(this.mergeAt, other.mergeAt), () => `Can only merge unrelated groups at the same "mergeAt": ${this} has mergeAt=${this.mergeAt}, but ${other} has mergeAt=${other.mergeAt}`); this.copyInputsOf(other); this.mergeInInternal(other, [], true); } private mergeInInternal(merged: FetchGroup, path: OperationPath, mergeParentDependencies: boolean = false) { assert(!merged.isTopLevel, "Shouldn't remove top level groups"); if (path.length === 0) { this.addSelections(merged.selection); } else { // The merged groups might have some @include/@skip at top-level that are already part of the path. If so, // we clean things up a bit. const mergePathConditionalDirectives = conditionalDirectivesInOperationPath(path); this.addAtPath(path, removeUnneededTopLevelFragmentDirectives(merged.selection, mergePathConditionalDirectives)); } this.dependencyGraph.onModification(); this.relocateChildrenOnMergedIn(merged, path); if (mergeParentDependencies) { this.relocateParentsOnMergedIn(merged); } if (merged.mustPreserveSelection) { this.mustPreserveSelection = true; } this.dependencyGraph.remove(merged); } removeUselessChild(child: FetchGroup) { const relationToChild = child.parentRelation(this); assert(relationToChild, () => `Cannot remove useless ${child} of ${this}: the former is not a child of the latter`); const childPathInThis = relationToChild.path; assert(childPathInThis, () => `Cannot remove useless ${child} of ${this}: the path of the former into the later is unknown`); this.dependencyGraph.onModification(); // Removing the child means atttaching all it's children to the parent, so it's the same relocation than on a "mergeIn". this.relocateChildrenOnMergedIn(child, childPathInThis); this.dependencyGraph.remove(child); } private relocateChildrenOnMergedIn(merged: FetchGroup, pathInThis: OperationPath) { for (const child of merged.children()) { // This could already be a child of `this`. Typically, we can have case where we have: // 1 // / \ // 0 3 // \ / // 2 // and we can merge siblings 2 into 1. if (this.isParentOf(child)) { continue; } const pathInMerged = child.parentRelation(merged)?.path; child.addParent({ group: this, path: concatPathsInParents(pathInThis, pathInMerged) }); } } private relocateParentsOnMergedIn(merged: FetchGroup) { for (const parent of merged.parents()) { // If the parent of the merged is already a parent of ours, don't re-create the already existing relationship. if (parent.group.isParentOf(this)) { continue; } // Further, if the parent is a descendant of `this`, we also should ignore that relationship, becuase // adding it a parent of `this` would create a cycle. And assuming this method is called properly, // that when `merged` can genuinely be safely merged into `this`, then this just mean the `parent` -> `merged` // relationship was unecessary after all (which can happen given how groups are generated). if (parent.group.isDescendantOf(this)) { continue; } this.addParent(parent); } } private finalizeSelection( variableDefinitions: VariableDefinitions, handledConditions: Conditions, ): { selection: SelectionSet, outputRewrites: FetchDataRewrite[] } { // Finalizing the selection involves the following: // 1. removing any @include/@skip that are not necessary because they are already handled earlier in the query plan by // some `ConditionNode`. // 2. adding __typename to all abstract types. This is because any follow-up fetch may need to select some of the entities fetched by this // group, and so we need to have the __typename of those. // 3. checking if some selection violates `https://spec.graphql.org/draft/#FieldsInSetCanMerge()`: while the original query we plan for will // never violate this, because the planner adds some additional fields to the query (due to @key and @requires) and because type-explosion // changes the query, we could have violation of this. If that is the case, we introduce aliases to the selection to make it valid, and // then generate a rewrite on the output of the fetch so that data aliased this way is rewritten back to the original/proper response name. const selectionWithoutConditions = removeConditionsFromSelectionSet(this.selection, handledConditions); const selectionWithTypenames = addTypenameFieldForAbstractTypes(selectionWithoutConditions); const { updated: selection, outputRewrites } = addAliasesForNonMergingFields(selectionWithTypenames); selection.validate(variableDefinitions, true); return { selection, outputRewrites }; } /** * Returns the conditions (in the sense of @include/@skip) necessary for actually fetching ("including") that group. * * Note that in most cases, this will just return `true`, meaning that the group always need to be executed (which does not mean * that there isn't any @include/@skip in the group selection, only that those are either not top-level, or they do not include * the whole group selection). */ conditions(): Conditions { return this._selection.memoized().conditions; } toPlanNode( queryPlannerConfig: Concrete, handledConditions: Conditions, variableDefinitions: VariableDefinitions, fragments?: RebasedFragments, operationName?: string, directives?: readonly Directive[], ) : PlanNode | undefined { if (this.selection.isEmpty()) { return undefined; } // for all contextual arguments, the values will be provided as an inputRewrite rather than in the variableDefintions. // Note that it won't match the actual type, so we just use String! here as a placeholder for (const [context, type] of this.inputs?.usedContexts ?? []) { assert(isInputType(type), () => `Expected ${type} to be a input type`); variableDefinitions.add(new VariableDefinition(type.schema(), new Variable(context), type)); } const { selection, outputRewrites } = this.finalizeSelection(variableDefinitions, handledConditions); const inputNodes = this._inputs ? this._inputs.toSelectionSetNode(variableDefinitions, handledConditions) : undefined; const subgraphSchema = this.dependencyGraph.subgraphSchemas.get(this.subgraphName)!; let operation = this.isEntityFetch ? operationForEntitiesFetch( subgraphSchema, selection, variableDefinitions, operationName, directives, ) : operationForQueryFetch( subgraphSchema, this.rootKind, selection, variableDefinitions, operationName, directives, ); if (this.generateQueryFragments) { operation = operation.generateQueryFragments(); } else { operation = operation.optimize( fragments?.forSubgraph(this.subgraphName, subgraphSchema), DEFAULT_MIN_USAGES_TO_OPTIMIZE, variableDefinitions, ); } // collect all used variables in the selection and the used directives and fragments. const collector = new VariableCollector(); // Note: The operation's selectionSet includes `representations` variable, // thus we use `selection` here instead. selection.collectVariables(collector); operation.collectVariablesInAppliedDirectives(collector); if (operation.fragments) { for (const namedFragment of operation.fragments.definitions()) { namedFragment.collectVariables(collector); } } const usedVariables = collector.variables(); const operationDocument = operationToDocument(operation); const fetchNode: FetchNode = { kind: 'Fetch', id: this.id, serviceName: this.subgraphName, requires: inputNodes ? trimSelectionNodes(inputNodes.selections) : undefined, variableUsages: usedVariables.map((v) => v.name), operation: stripIgnoredCharacters(print(operationDocument)), operationKind: schemaRootKindToOperationKind(operation.rootKind), operationName: operation.name, operationDocumentNode: queryPlannerConfig.exposeDocumentNodeInFetchNode ? operationDocument : undefined, inputRewrites: this.inputRewrites.length === 0 ? undefined : this.inputRewrites, outputRewrites: outputRewrites.length === 0 ? undefined : outputRewrites, contextRewrites: this._contextInputs, }; return this.isTopLevel ? fetchNode : { kind: 'Flatten', path: this.mergeAt!, node: fetchNode, }; } addContextRenamer(renamer: FetchDataKeyRenamer) { if (!this._contextInputs) { this._contextInputs = []; } if (!this._contextInputs.some((c) => sameKeyRenamer(c, renamer))) { this._contextInputs.push(renamer); } } toString(): string { const base = `[${this.index}]${this.deferRef ? '(deferred)' : ''}${this._id ? `{id: ${this._id}}` : ''} ${this.subgraphName}`; return this.isTopLevel ? `${base}[${this._selection}]` : `${base}@(${this.mergeAt})[${this._inputs} => ${this._selection}]`; } } class RebasedFragments { private readonly bySubgraph = new Map(); constructor(private readonly queryFragments: NamedFragments) { } forSubgraph(name: string, schema: Schema): NamedFragments | undefined { let frags = this.bySubgraph.get(name); if (frags === undefined) { frags = this.queryFragments.rebaseOn(schema) ?? null; this.bySubgraph.set(name, frags); } return frags ?? undefined; } } function genAliasName(baseName: string, unavailableNames: Map): string { let counter = 0; let candidate = `${baseName}__alias_${counter}`; while (unavailableNames.has(candidate)) { candidate = `${baseName}__alias_${++counter}`; } return candidate; } type SelectionSetAtPath = { path: string[], selections: SelectionSet, } type FieldToAlias = { path: string[], responseName: string, alias: string, } function selectionSetAsKeyRenamers(selectionSet: SelectionSet | undefined, relPath: string[], alias: string): FetchDataKeyRenamer[] { if (!selectionSet || selectionSet.isEmpty()) { return [ { kind: 'KeyRenamer', path: relPath, renameKeyTo: alias, } ]; } return selectionSet.selections().map((selection: Selection): FetchDataKeyRenamer[] | undefined => { if (selection.kind === 'FieldSelection') { if (relPath[relPath.length - 1] === '..' && selectionSet.parentType.name !== 'Query') { return possibleRuntimeTypes(selectionSet.parentType).map((t) => selectionSetAsKeyRenamers(selectionSet, [...relPath, `... on ${t.name}`], alias)).flat(); } else { return selectionSetAsKeyRenamers(selection.selectionSet, [...relPath, selection.element.name], alias); } } else if (selection.kind === 'FragmentSelection') { const element = selection.element; if (element.typeCondition) { return selectionSetAsKeyRenamers(selection.selectionSet, [...relPath, `... on ${element.typeCondition.name}`], alias); } } return undefined; }).filter(isDefined) .reduce((acc, val) => acc.concat(val), []); } function computeAliasesForNonMergingFields(selections: SelectionSetAtPath[], aliasCollector: FieldToAlias[]) { const seenResponseNames = new Map(); const rebasedFieldsInSet = (s: SelectionSetAtPath) => ( s.selections.fieldsInSet().map(({ path, field }) => ({ fieldPath: s.path.concat(path), field })) ); for (const { fieldPath, field } of selections.map((s) => rebasedFieldsInSet(s)).flat()) { const fieldName = field.element.name; const responseName = field.element.responseName(); const fieldType = field.element.definition.type!; const previous = seenResponseNames.get(responseName); if (previous) { if (previous.fieldName === fieldName && typesCanBeMerged(previous.fieldType, fieldType)) { // If the type is non-composite, then we're all set. But if it is composite, we need to record the sub-selection to that response name // as we need to "recurse" on the merged of both the previous and this new field. if (isCompositeType(baseType(fieldType))) { assert(previous.selections, () => `Should have added selections for ${previous.fieldType}`); const selections = previous.selections.concat({ path: fieldPath.concat(responseName), selections: field.selectionSet! }); seenResponseNames.set(responseName, { ...previous, selections }); } } else { // We need to alias the new occurence. const alias = genAliasName(responseName, seenResponseNames); // Given how we generate aliases, it's is very unlikely that the generated alias will conflict with any of the other response name // at the level, but it's theoretically possible. By adding the alias to the seen names, we ensure that in the remote change that // this ever happen, we'll avoid the conflict by giving another alias to the followup occurence. const selections = field.selectionSet ? [{ path: fieldPath.concat(alias), selections: field.selectionSet }] : undefined; seenResponseNames.set(alias, { fieldName, fieldType, selections }); // Lastly, we record that the added alias need to be rewritten back to the proper response name post query. aliasCollector.push({ path: fieldPath, responseName, alias }); } } else { const selections = field.selectionSet ? [{ path: fieldPath.concat(responseName), selections: field.selectionSet }] : undefined; seenResponseNames.set(responseName, { fieldName, fieldType, selections }); } } for (const selections of seenResponseNames.values()) { if (!selections.selections) { continue; } computeAliasesForNonMergingFields(selections.selections, aliasCollector); } } function addAliasesForNonMergingFields(selectionSet: SelectionSet): { updated: SelectionSet, outputRewrites: FetchDataRewrite[] } { const aliases: FieldToAlias[] = []; computeAliasesForNonMergingFields([{ path: [], selections: selectionSet}], aliases); const updated = withFieldAliased(selectionSet, aliases); const outputRewrites = aliases.map(({path, responseName, alias}) => ({ kind: 'KeyRenamer', path: path.concat(alias), renameKeyTo: responseName, })); return { updated, outputRewrites }; } function withFieldAliased(selectionSet: SelectionSet, aliases: FieldToAlias[]): SelectionSet { if (aliases.length === 0) { return selectionSet; } const atCurrentLevel = new Map(); const remaining = new Array(); for (const alias of aliases) { if (alias.path.length > 0) { remaining.push(alias); } else { atCurrentLevel.set(alias.responseName, alias); } } return selectionSet.lazyMap((selection) => { const pathElement = selection.element.asPathElement(); const subselectionAliases = remaining.map((alias) => { if (alias.path[0] === pathElement) { return { ...alias, path: alias.path.slice(1), }; } else { return undefined; } }).filter(isDefined); const updatedSelectionSet = selection.selectionSet ? withFieldAliased(selection.selectionSet, subselectionAliases) : undefined; if (selection.kind === 'FieldSelection') { const field = selection.element; const alias = pathElement && atCurrentLevel.get(pathElement); return !alias && selection.selectionSet === updatedSelectionSet ? selection : selection.withUpdatedComponents(alias ? field.withUpdatedAlias(alias.alias) : field, updatedSelectionSet); } else { return selection.selectionSet === updatedSelectionSet ? selection : selection.withUpdatedSelectionSet(updatedSelectionSet!); } }); } class DeferredInfo { private constructor( readonly label: string, readonly path: GroupPath, readonly subselection: MutableSelectionSet, readonly deferred = new Set(), readonly dependencies = new Set(), ) { } static empty(label: string, path: GroupPath, parentType: CompositeType): DeferredInfo { return new DeferredInfo( label, path, MutableSelectionSet.empty(parentType), ); } clone(): DeferredInfo { return new DeferredInfo( this.label, this.path, this.subselection.clone(), new Set(this.deferred), new Set(this.dependencies), ); } } type DeferContext = { currentDeferRef: string | undefined, pathToDeferParent: OperationPath, activeDeferRef: string | undefined, isPartOfQuery: boolean, } const emptyDeferContext: DeferContext = { currentDeferRef: undefined, pathToDeferParent: [], activeDeferRef: undefined, isPartOfQuery: true, } function deferContextForConditions(baseContext: DeferContext): DeferContext { return { ...baseContext, isPartOfQuery: false, currentDeferRef: baseContext.activeDeferRef, }; } function deferContextAfterSubgraphJump(baseContext: DeferContext): DeferContext { return baseContext.currentDeferRef === baseContext.activeDeferRef ? baseContext : { ...baseContext, activeDeferRef: baseContext.currentDeferRef, }; } /** * Filter any fragment element in the provided path whose type condition does not exists in the provide schema. * Not that if the fragment element should be filtered but it has applied directives, then we preserve those applications by * replacing with a fragment with no condition (but if there is not directives, we simply remove the fragment from the path). */ function filterOperationPath(path: OperationPath, schema: Schema): OperationPath { return path.map((elt) => { if (elt.kind === 'FragmentElement' && elt.typeCondition && !schema.type(elt.typeCondition.name)) { return elt.appliedDirectives.length > 0 ? elt.withUpdatedCondition(undefined) : undefined; } return elt; }).filter(isDefined); } class GroupPath { private constructor( private readonly fullPath: OperationPath, private readonly pathInGroup: OperationPath, private readonly responsePath: ResponsePath, private readonly typeConditionedFetching: boolean, private readonly possibleTypes: ObjectType[], private readonly possibleTypesAfterLastField: ObjectType[], ) { } static empty(typeConditionedFetching: boolean, rootType: CompositeType): GroupPath { const rootPossibleRuntimeTypes = typeConditionedFetching ? Array.from(possibleRuntimeTypes(rootType)): []; rootPossibleRuntimeTypes.sort(); return new GroupPath([], [], [], typeConditionedFetching, rootPossibleRuntimeTypes, rootPossibleRuntimeTypes); } inGroup(): OperationPath { return this.pathInGroup; } full(): OperationPath { return this.fullPath; } inResponse(): ResponsePath { return this.responsePath; } forNewKeyFetch(newGroupContext: OperationPath): GroupPath { return new GroupPath( this.fullPath, newGroupContext, this.responsePath, this.typeConditionedFetching, this.possibleTypes, this.possibleTypesAfterLastField, ); } forParentOfGroup(pathOfGroupInParent: OperationPath, parentSchema: Schema): GroupPath { return new GroupPath( this.fullPath, // The group refered by `this` may have types that do not exists in the group "parent", so we filter // out any type conditions on those. This typically happens jumping to a group that use an @interfaceObject // from a (parent) group that does not know the corresponding interface but has some of the type that // implements it (in the supergraph). concatOperationPaths(pathOfGroupInParent, filterOperationPath(this.pathInGroup, parentSchema)), this.responsePath, this.typeConditionedFetching, this.possibleTypes, this.possibleTypesAfterLastField ); } private updatedResponsePath(element: OperationElement): ResponsePath { switch (element.kind){ case 'FragmentElement': return this.responsePath; case 'Field': let newPath = this.responsePath; if (this.possibleTypesAfterLastField.length !== this.possibleTypes.length) { const conditions = `|[${this.possibleTypes.join(',')}]`; const previousLastElement = newPath[newPath.length -1] as string || ''; if (previousLastElement.startsWith('|[')) { newPath = [...newPath.slice(0, -1), conditions]; } else { newPath = [...newPath.slice(0, -1), `${previousLastElement}${conditions}`]; } } let type = element.definition.type!; if (newPath.length === 0 && this.typeConditionedFetching) { newPath = newPath.concat(''); } newPath = newPath.concat(`${element.responseName()}`); while (!isNamedType(type)) { if (isListType(type)) { newPath.push('@'); } type = type.ofType; } return newPath; } } add(element: OperationElement): GroupPath { const responsePath = this.updatedResponsePath(element); const newPossibleTypes = this.computeNewPossibleTypes(element); return new GroupPath( this.fullPath.concat(element), this.pathInGroup.concat(element), responsePath, this.typeConditionedFetching, newPossibleTypes, element.kind === 'Field'? newPossibleTypes: this.possibleTypesAfterLastField ); } toString() { return this.inResponse().join('.'); } computeNewPossibleTypes(element: OperationElement): ObjectType[] { if (!this.typeConditionedFetching) { return []; } switch (element.kind){ case 'FragmentElement': if (!element.typeCondition) { return this.possibleTypes; } const elementPossibleTypes = possibleRuntimeTypes(element.typeCondition); return this.possibleTypes.filter((pt) => elementPossibleTypes.some((ept) => ept.name === pt.name)); case 'Field': return this.advanceFieldType(element); } } advanceFieldType(element: Field): ObjectType[] { if (!isCompositeType(element.baseType())) { return []; } const res = Array.from( new Set( this.possibleTypes.map( (pt) => possibleRuntimeTypes( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion baseType(pt.field(element.name)!.type!) as CompositeType ) ).flat() ) ); res.sort(); return res; } } class DeferTracking { private readonly topLevelDeferred = new Set(); private readonly deferred = new MapWithCachedArrays(); private constructor( readonly primarySelection: MutableSelectionSet | undefined ) { } static empty(rootType: CompositeType | undefined): DeferTracking { return new DeferTracking(rootType ? MutableSelectionSet.empty(rootType) : undefined); } clone(): DeferTracking { const cloned = new DeferTracking(this.primarySelection?.clone()); this.topLevelDeferred.forEach((label) => cloned.topLevelDeferred.add(label)); for (const deferredBlock of this.deferred.values()) { cloned.deferred.set(deferredBlock.label, deferredBlock.clone()); } return cloned; } registerDefer({ deferContext, deferArgs, path, parentType, }: { deferContext: DeferContext, deferArgs: DeferDirectiveArgs, path: GroupPath, parentType: CompositeType, }): void { // Having the primary selection undefined means that @defer handling is actually disabled, so save anything costly that we won't be using. if (!this.primarySelection) { return; } assert(deferArgs.label, 'All @defer should have be labelled at this point'); let deferredBlock = this.deferred.get(deferArgs.label); if (!deferredBlock) { deferredBlock = DeferredInfo.empty(deferArgs.label, path, parentType); this.deferred.set(deferArgs.label, deferredBlock); } const parentRef = deferContext.currentDeferRef; if (!parentRef) { this.topLevelDeferred.add(deferArgs.label); this.primarySelection.updates().addAtPath(deferContext.pathToDeferParent); } else { const parentInfo = this.deferred.get(parentRef); assert(parentInfo, `Cannot find info for parent ${parentRef} or ${deferArgs.label}`); parentInfo.deferred.add(deferArgs.label); parentInfo.subselection.updates().addAtPath(deferContext.pathToDeferParent); } } updateSubselection(deferContext: DeferContext, selection?: SelectionSet): void { if (!this.primarySelection || !deferContext.isPartOfQuery) { return; } const parentRef = deferContext.currentDeferRef; let updates: SelectionSetUpdates; if (parentRef) { const info = this.deferred.get(parentRef); assert(info, () => `Cannot find info for label ${parentRef}`); updates = info.subselection.updates(); } else { updates = this.primarySelection.updates(); } updates.addAtPath(deferContext.pathToDeferParent, selection); } getBlock(label: string): DeferredInfo | undefined { return this.deferred.get(label); } addDependency(label: string, idDependency: string): void { const info = this.deferred.get(label); assert(info, () => `Cannot find info for label ${label}`); info.dependencies.add(idDependency); } defersInParent(parentRef: string | undefined): readonly DeferredInfo[] { const labels = parentRef ? this.deferred.get(parentRef)?.deferred : this.topLevelDeferred; return labels ? setValues(labels).map((label) => { const info = this.deferred.get(label); assert(info, () => `Should not have referenced ${label} without an existing info`); return info; }) : []; } } /** * UnhandledGroups is used while processing fetch groups in dependency order to track group for which * one of the parent has been processed/handled but which has other parents. So it is a set of * groups and for each group which parent(s) remains to be processed before the group itself can be * processed. */ type UnhandledGroups = [FetchGroup, UnhandledParentRelations][]; type UnhandledParentRelations = ParentRelation[]; function printUnhandled(u: UnhandledGroups): string { return '[' + u.map(([g, relations]) => `${g.index} (missing: [${relations.map((r) => r.group.index).join(', ')}])` ).join(', ') + ']'; } /* * Used during the processing of fetch groups in dependency order. */ class ProcessingState { private constructor( // Groups that can be handled (because all their parents/dependencies have been processed before). readonly next: readonly FetchGroup[], // Groups that needs some parents/dependencies to be processed first because they can be themselves. // Note that we make sure that this never hold group with no "edges". readonly unhandled: UnhandledGroups, ) { } static empty(): ProcessingState { return new ProcessingState([], []); } static forChildrenOfProcessedGroup(processed: FetchGroup, children: FetchGroup[]): ProcessingState { const ready: FetchGroup[] = []; const unhandled: UnhandledGroups = []; for (const c of children) { const parents = c.parents(); if (parents.length === 1) { // The parent we have processed is the only one parent of that children; we can handle the children ready.push(c); } else { unhandled.push([c, parents.filter((p) => p.group !== processed)]); } } return new ProcessingState(ready, unhandled); } static ofReadyGroups(groups: readonly FetchGroup[]): ProcessingState { return new ProcessingState(groups, []); } withOnlyUnhandled(): ProcessingState { return new ProcessingState([], this.unhandled); } mergeWith(that: ProcessingState): ProcessingState { const next: FetchGroup[] = this.next.concat(that.next.filter((g) => !this.next.includes(g))); const unhandled: UnhandledGroups = []; const thatUnhandled = that.unhandled.concat(); for (const [g, edges] of this.unhandled) { const newEdges = this.mergeRemaingsAndRemoveIfFound(g, edges, thatUnhandled); if (newEdges.length == 0) { if (!next.includes(g)) { next.push(g); } } else { unhandled.push([g, newEdges]) } } // Anything remaining in `thatUnhandled` are groups that were not in `this` at all. unhandled.push(...thatUnhandled); return new ProcessingState(next, unhandled); } private mergeRemaingsAndRemoveIfFound(group: FetchGroup, inEdges: UnhandledParentRelations, otherGroups: UnhandledGroups): UnhandledParentRelations { const idx = otherGroups.findIndex(g => g[0] === group); if (idx < 0) { return inEdges; } else { const otherEdges = otherGroups[idx][1]; otherGroups.splice(idx, 1); // The uhandled are the one that are unhandled on both side. return inEdges.filter(e => otherEdges.includes(e)) } } updateForProcessedGroups(processed: readonly FetchGroup[]): ProcessingState { const next: FetchGroup[] = this.next.concat(); const unhandled: UnhandledGroups = []; for (const [g, edges] of this.unhandled) { // Remove any of the processed groups from the unhandled edges of that group. // And if there is no remaining edge, that group can be handled. const newEdges = edges.filter((edge) => !processed.includes(edge.group)); if (newEdges.length === 0) { if (!next.includes(g)) { next.push(g); } } else { unhandled.push([g, newEdges]); } } return new ProcessingState(next, unhandled); } } /** * A Directed Acyclic Graph (DAG) of `FetchGroup` and their dependencies. * * In the graph, 2 groups are connected if one of them (the parent) must be performed strictly before the other one (the child). */ class FetchDependencyGraph { private isReduced: boolean = false; private isOptimized: boolean = false; private fetchIdGen: number; private constructor( readonly supergraphSchema: Schema, readonly subgraphSchemas: ReadonlyMap, readonly federatedQueryGraph: QueryGraph, readonly startingIdGen: number, private readonly rootGroups: MapWithCachedArrays, readonly groups: FetchGroup[], readonly deferTracking: DeferTracking, readonly generateQueryFragments: boolean, ) { this.fetchIdGen = startingIdGen; } static create(supergraphSchema: Schema, federatedQueryGraph: QueryGraph, startingIdGen: number, rootTypeForDefer: CompositeType | undefined, generateQueryFragments: boolean) { return new FetchDependencyGraph( supergraphSchema, federatedQueryGraph.sources, federatedQueryGraph, startingIdGen, new MapWithCachedArrays(), [], DeferTracking.empty(rootTypeForDefer), generateQueryFragments, ); } private federationMetadata(subgraphName: string): FederationMetadata { const schema = this.subgraphSchemas.get(subgraphName); assert(schema, () => `Unknown schema ${subgraphName}`) const metadata = federationMetadata(schema); assert(metadata, () => `Schema ${subgraphName} should be a federation subgraph`); return metadata; } nextFetchId(): number { return this.fetchIdGen; } clone(): FetchDependencyGraph { const cloned = new FetchDependencyGraph( this.supergraphSchema, this.subgraphSchemas, this.federatedQueryGraph, this.startingIdGen, new MapWithCachedArrays(), new Array(this.groups.length), this.deferTracking.clone(), this.generateQueryFragments, ); for (const group of this.groups) { cloned.groups[group.index] = group.cloneShallow(cloned); } for (const root of this.rootGroups.values()) { cloned.rootGroups.set(root.subgraphName, cloned.groups[root.index]); } for (const group of this.groups) { const clonedGroup = cloned.groups[group.index]; // Note that `addParent` makes sure to set both the parent link in // `clonedGroup` but also the corresponding child link in `parent`. for (const parent of group.parents()) { clonedGroup.addParent({ group: cloned.groups[parent.group.index], path: parent.path }); } } return cloned; } supergraphSchemaType(typeName: string): NamedType | undefined { return this.supergraphSchema.type(typeName) } getOrCreateRootFetchGroup({ subgraphName, rootKind, parentType, }: { subgraphName: string, rootKind: SchemaRootKind, parentType: CompositeType, }): FetchGroup { let group = this.rootGroups.get(subgraphName); if (!group) { group = this.createRootFetchGroup({ subgraphName, rootKind, parentType }); this.rootGroups.set(subgraphName, group); } return group; } rootSubgraphs(): readonly string[] { return this.rootGroups.keys(); } isRootGroup(group: FetchGroup): boolean { return group === this.rootGroups.get(group.subgraphName); } createRootFetchGroup({ subgraphName, rootKind, parentType, }: { subgraphName: string, rootKind: SchemaRootKind, parentType: CompositeType, }): FetchGroup { const group = this.newFetchGroup({ subgraphName, parentType, rootKind, hasInputs: false }); this.rootGroups.set(subgraphName, group); return group; } private newFetchGroup({ subgraphName, parentType, hasInputs, rootKind, // always "query" for entity fetches mergeAt, deferRef, }: { subgraphName: string, parentType: CompositeType, hasInputs: boolean, rootKind: SchemaRootKind, mergeAt?: ResponsePath, deferRef?: string, }): FetchGroup { this.onModification(); const newGroup = FetchGroup.create({ dependencyGraph: this, index: this.groups.length, subgraphName, rootKind, parentType, hasInputs, mergeAt, deferRef, generateQueryFragments: this.generateQueryFragments, }); this.groups.push(newGroup); return newGroup; } getOrCreateKeyFetchGroup({ subgraphName, mergeAt, type, parent, conditionsGroups, deferRef, }: { subgraphName: string, mergeAt: ResponsePath, type: CompositeType, parent: ParentRelation, conditionsGroups: FetchGroup[], deferRef?: string, }): FetchGroup { // Let's look if we can reuse a group we have, that is an existing child of the parent that: // 1. is for the same subgraph // 2. has the same mergeAt // 3. is for the same entity type (we don't reuse groups for different entities just yet, as this can create unecessary dependencies that // gets in the way of some optimizations; the final optimizations in `reduceAndOptimize` will however later merge groups on the same subgraph // and mergeAt when possible). // 4. is not part of our conditions or our conditions ancestors (meaning that we annot reuse a group if it fetches something we take as input). // 5. is part of the same "defer" grouping // 6. has the same path in parents (here again, we _will_ eventually merge fetches for which this is not true later in `reduceAndOptimize`, but // for now, keeping groups separated when they have a different path in their parent allows to keep that "path in parent" more precisely, // which is important for some case of @requires). for (const existing of parent.group.children()) { if (existing.subgraphName === subgraphName && existing.mergeAt && sameMergeAt(existing.mergeAt, mergeAt) && existing.selection.selections().every((s) => s.kind === 'FragmentSelection' && s.element.castedType() === type) && !this.isInGroupsOrTheirAncestors(existing, conditionsGroups) && existing.deferRef === deferRef && samePathsInParents(existing.parentRelation(parent.group)?.path, parent.path) ) { return existing; } } const newGroup = this.newKeyFetchGroup({ subgraphName, mergeAt, deferRef }); newGroup.addParent(parent); return newGroup } newRootTypeFetchGroup({ subgraphName, rootKind, parentType, mergeAt, deferRef, }: { subgraphName: string, rootKind: SchemaRootKind, parentType: ObjectType, mergeAt: ResponsePath, deferRef?: string, }): FetchGroup { return this.newFetchGroup({ subgraphName, parentType, rootKind, hasInputs: false, mergeAt, deferRef }); } // Returns true if `toCheck` is either part of `conditions`, or is one of their ancestors (potentially recursively). private isInGroupsOrTheirAncestors(toCheck: FetchGroup, conditions: FetchGroup[]): boolean { const stack = conditions.concat(); while (stack.length > 0) { const group = stack.pop()!; if (toCheck === group) { return true; } stack.push(...group.parentGroups()); } return false; } typeForFetchInputs(name: string): CompositeType { const type = this.supergraphSchema.type(name); assert(type, `Type ${name} should exist in the supergraph`) assert(isCompositeType(type), `Type ${type} should be a composite, but got ${type.kind}`); return type; } newKeyFetchGroup({ subgraphName, mergeAt, deferRef, }: { subgraphName: string, mergeAt: ResponsePath, deferRef?: string, }): FetchGroup { const parentType = this.federationMetadata(subgraphName).entityType(); assert(parentType, () => `Subgraph ${subgraphName} has no entities defined`); return this.newFetchGroup({ subgraphName, parentType, hasInputs: true, rootKind: 'query', mergeAt, deferRef }); } remove(toRemove: FetchGroup) { this.onModification(); // We copy the initial children and parents since we're going to modify them and wand to avoid issue with // modifying the list we're iterating one. const children = toRemove.children().concat(); const parents = toRemove.parents().concat(); // We remove any relationship from the removed group to it's children. for (const child of children) { // At the point where we call this, any potential child of the removed group should have been "dealt with": on // merge, the children should have relocated to whichever group we merged into, and if we remove some useless // group, then its child should have other parents, or we would be breaking our graph (leaving those child groups // unreachable). So this is our sanity check to ensure we haven't forgotten some step. assert(child.parents().length > 1, () => `Cannot remove ${toRemove} as it is the *only* parent of ${child}`); toRemove.removeChild(child); } // and then any parent relationship to the removed group. for (const parent of parents) { parent.group.removeChild(toRemove); } // We can now remove the entries for the removed group itself since it shouldn't be referenced anymore. this.groups.splice(toRemove.index, 1); // But as our group indexes index into arrays, we need to keep all existing group indexes contiguous, and as // we just removed `toRemove.index`, we need to "fixup" every index of groups with a higher index (decrementing // said index). for (let i = toRemove.index; i < this.groups.length; i++) { --this.groups[i].index; } } /** * Must be called every time the "shape" of the graph is modified to know that the graph may not be minimal/optimized anymore. * * This is not private due to the fact that the graph is implemented through the inter-connection of this class and the * `FetchGroup` one and so `FetchGroup` needs to call this. But this has no reason to be called from any other place. */ onModification() { this.isReduced = false; this.isOptimized = false; } // Do a transitive reduction (https://en.wikipedia.org/wiki/Transitive_reduction) of the graph // We keep it simple and do a DFS from each vertex. The complexity is not amazing, but dependency // graphs between fetch groups will almost surely never be huge and query planning performance // is not paramount so this is almost surely "good enough". reduce() { if (this.isReduced) { return; } for (const group of this.groups) { this.dfsRemoveRedundantEdges(group); } this.isReduced = true; } // Reduce the graph (see `reduce`) and then do a some additional traversals to optimize for: // 1) fetches with no selection: this can happen when we have a require if the only field requested // was the one with the require and that forced some dependencies. Those fetch should have // no dependents and we can just remove them. // 2) fetches that are made in parallel to the same subgraph and the same path, and merge those. private reduceAndOptimize() { if (this.isOptimized) { return; } this.reduce(); for (const group of this.rootGroups.values()) { this.removeEmptyGroups(group); } for (const group of this.rootGroups.values()) { this.removeUselessGroups(group); } for (const group of this.rootGroups.values()) { this.mergeChildFetchesForSameSubgraphAndPath(group); } this.mergeFetchesToSameSubgraphAndSameInputs(); this.isOptimized = true; } private removeEmptyGroups(group: FetchGroup) { const children = group.children().concat(); // Note: usually, empty groups are due to temporary groups created during the handling of @require // and note needed. There is a special case with @defer however whereby everything in a query is // deferred (not very useful in practice, but not disallowed by the spec), and in that case we will // end up with an empty root group. In that case, we don't remove that group, but instead will // recognize that case when processing groups later. if (group.selection.isEmpty() && !this.isRootGroup(group)) { this.remove(group); } for (const g of children) { this.removeEmptyGroups(g); } } private removeUselessGroups(group: FetchGroup) { // Since we can potentially remove child in place, we need to // explicitly copy the list of all children to ensure that we // process ALL children const children = group.children().concat(); // Recursing first, this makes it a bit easier to reason about. for (const child of children) { this.removeUselessGroups(child); } if (group.isUseless()) { // In general, removing a group is a bit tricky because we need to deal // with the fact that the group can have multiple parents, and we don't // have the "path in parent" in all cases. To keep thing relatively // easily, we only handle the following cases (other cases will remain // non-optimal, but hopefully this handle all the cases we care about in // practice): // 1. if the group has no children. In which case we can just remove it // with no ceremony. // 2. if the group has only a single parent and we have a path to that // parent. if (group.children().length === 0) { this.remove(group); } else { const parents = group.parents(); const parent = parents[0]; if (parents.length === 1 && parent.path) { parent.group.removeUselessChild(group); } } } } private mergeChildFetchesForSameSubgraphAndPath(group: FetchGroup) { const children = group.children(); if (children.length > 1) { // We iterate on all pairs of children and merge those siblings that can be merged together. Note // that when we merged, we effectively modify the array we're iterating over, so we use indexes // and re-test against `children.length` every time to ensure we don't miss elements as we do so. for (let i = 0; i < children.length; i++) { const gi = children[i]; let j = i + 1; while (j < children.length) { const gj = children[j]; if (gi.canMergeSiblingIn(gj)) { // In theory, we can merge gi into gj, or gj into gi, it shouldn't matter. But we // merge gi into gj so our iteration can continue properly. gi.mergeSiblingIn(gj); // We're working on a minimal graph (we've done a transitive reduction beforehand) and we need to keep the graph // minimal as post-reduce steps (the `process` method) rely on it. But merging 2 groups _can_ break minimality. // Say we have: // 0 ------ // \ // 4 // 1 -- 3 --/ // and we merge groups 0 and 1 (and let's call 2 the result), then we now have: // ------ // / \ // 0 <-- 3 -- 4 // which is not minimal. // // So to fix it, we just re-run our dfs removal from that merged edge (which is probably a tad overkill in theory, // but for the reasons mentioned on `reduce`, this is most likely a non-issue in practice). // // Note that this DFS can only affect the descendants of gi (its children and recursively so), so it does not // affect our current iteration. this.dfsRemoveRedundantEdges(gi); // The merging removed `gj`, so we shouldn't bump `j`, it's already on the next group. } else { ++j; } } } } // Now recurse to the sub-groups. for (const g of children) { this.mergeChildFetchesForSameSubgraphAndPath(g); } } private mergeFetchesToSameSubgraphAndSameInputs() { // Sometimes, the query will directly query some fields that are also requirements for some other queried fields, and because there // is complex dependencies involved, we won't be able to easily realize that we're doing the same fetch to a subgraph twice in 2 // different places (once for the user query, once for the require). For an example of this happening, see the test called 'handles // diamond-shaped dependencies' in `buildPlan.test.ts` Of course, doing so is always inefficient and so this method ensures we // merge such fetches. // In practice, this method merges any 2 fetches that are to the same subgraph and same mergeAt, and have the exact same inputs. // To find which groups are to the same subgraph and mergeAt somewhat efficient, we generate a simple string key from each // group subgraph name and mergeAt. We do "sanitize" subgraph name, but have no worries for `mergeAt` since it contains either // number of field names, and the later is restricted by graphQL so as to not be an issue. const bySubgraphs = new MultiMap(); for (const group of this.groups) { // we exclude groups without inputs because that's what we look for. In practice, this mostly just exclude // root groups, which we don't really want to bother with anyway. if (group.inputs) { bySubgraphs.add(group.subgraphAndMergeAtKey!, group); } } for (const groups of bySubgraphs.values()) { // In the vast majority of cases `groups` is going be a single element, so optimize that trival case away. if (groups.length <= 1) { continue; } // An array where each entry is a "bucket" of groups that can all be merge together. const toMergeBuckets: FetchGroup[][] = []; while (groups.length > 1) { const group = groups.pop()!; const bucket: FetchGroup[] = [ group ]; // Bit of a hand-rolled loop here, but we're removing some elements, so this feel clearer overall let i = 0; while (i < groups.length) { const current = groups[i]; if (group.deferRef === current.deferRef && group.inputs!.equals(current.inputs!)) { bucket.push(current); groups.splice(i, 1); // Note that we don't change `i` since we just removed the element at that index and so the new // element at that index is the next one we need to check. } else { ++i; } } // The bucket always has at leat the initial group, but there is only merging to be done if there is at least one more if (bucket.length > 1) { toMergeBuckets.push(bucket); } } for (const bucket of toMergeBuckets) { // We pick one fo the group and merge all other into it. Note that which group we choose shouldn't matter since // the merging preserves all the dependencies of each group (both parents and children). const group = bucket.pop()!; for (const other of bucket) { group.mergeInWithAllDependencies(other); } } } // We may have merged groups and broke the graph miminality in doing so, so we re-reduce to make sure. Note // that if we did no modification to the graph, calling `reduce` is cheap (the `isReduced` variable will still be `true`). this.reduce(); } private dfsRemoveRedundantEdges(from: FetchGroup) { for (const startVertex of from.children()) { const stack = startVertex.children().concat(); while (stack.length > 0) { const v = stack.pop()!; // Note that we rely on `removeChild` to be a no-op if there is not parent-child relation. from.removeChild(v); stack.push(...v.children()); } } } private extractChildrenAndDeferredDependencies( group: FetchGroup ): { children: FetchGroup[], deferredGroups: SetMultiMap, } { const children: FetchGroup[] = []; const deferredGroups = new SetMultiMap(); for (const child of group.children()) { if (group.deferRef === child.deferRef) { children.push(child); } else { assert(child.deferRef, () => `${group} has deferRef "${group.deferRef}", so its child ${child} cannot have a top-level deferRef`); // In general, we want to mark the group as a dependency for its deferred children. An exception is where this group // is empty, in which case it won't be included in the plan, and so we don't want to indicate a "broken" dependency // in the resulting plan. Note that in practice this only happen for a case where everything in a query is deferred, // and so the "primary" part of the `DeferNode` will be empyt, so having an empty set of dependencies for the deferred // part is harmless (basically, it says "wait for everyting in the primary part" but there is nothing in the primary // part so there is not actualy "wait"). if (!group.selection.isEmpty()) { if (!group.id) { group.id = String(this.fetchIdGen++); } this.deferTracking.addDependency(child.deferRef, group.id); } deferredGroups.add(child.deferRef, child); } } return { children, deferredGroups }; } private processGroup( processor: FetchGroupProcessor, group: FetchGroup, handledConditions: Conditions, ): { main: TProcessed, state: ProcessingState, deferredGroups: SetMultiMap, } { const conditions = updatedConditions(group.conditions(), handledConditions); const newHandledConditions = mergeConditions(conditions, handledConditions); const { children, deferredGroups } = this.extractChildrenAndDeferredDependencies(group); const processed = processor.onFetchGroup(group, newHandledConditions); if (children.length == 0) { return { main: processor.onConditions(conditions, processed), state: ProcessingState.empty(), deferredGroups }; } const state = ProcessingState.forChildrenOfProcessedGroup(group, children); if (state.next.length > 0) { // We process the ready children as if they were parallel roots (they are from `processed` // in a way), and then just add process at the beginning of the sequence. const { mainSequence, newState, deferredGroups: allDeferredGroups, } = this.processRootMainGroups({ processor, state, rootsAreParallel: true, initialDeferredGroups: deferredGroups, handledConditions: newHandledConditions, }); return { main: processor.onConditions(conditions, processor.reduceSequence([processed].concat(mainSequence))), state: newState, deferredGroups: allDeferredGroups, }; } else { return { main: processor.onConditions(conditions, processed), state, deferredGroups, }; } } private processGroups( processor: FetchGroupProcessor, state: ProcessingState, processInParallel: boolean, handledConditions: Conditions, ): { processed: TProcessed, newState: ProcessingState, deferredGroups: SetMultiMap, } { const processedNodes: TProcessed[] = []; const allDeferredGroups = new SetMultiMap(); let newState = state.withOnlyUnhandled(); for (const group of state.next) { const { main, deferredGroups, state: stateAfterGroup } = this.processGroup(processor, group, handledConditions); processedNodes.push(main); allDeferredGroups.addAll(deferredGroups); newState = newState.mergeWith(stateAfterGroup); } // Note that `newState` is the merged result of everything after each individual group (anything that was _only_ depending // on it), but the fact that groups themselves (`state.next`) have been handled has not necessarily be taking into // account yet, so we do it below. Also note that this must be done outside of the `for` loop above, because any // group that dependend on multiple of the input groups of this function must not be handled _within_ this function // but rather after it, and this is what ensures it. return { processed: processInParallel ? processor.reduceParallel(processedNodes) : processor.reduceSequence(processedNodes), newState: newState.updateForProcessedGroups(state.next), deferredGroups: allDeferredGroups, }; } /** * Process the "main" (non-deferred) groups starting at the provided roots. The deferred groups are collected * by this method but not otherwise processed. */ private processRootMainGroups({ processor, state, rootsAreParallel, initialDeferredGroups, handledConditions, }: { processor: FetchGroupProcessor, state: ProcessingState, rootsAreParallel: boolean, initialDeferredGroups?: SetMultiMap, handledConditions: Conditions, }): { mainSequence: TProcessed[], newState: ProcessingState, deferredGroups: SetMultiMap, } { const mainSequence: TProcessed[] = []; const allDeferredGroups = initialDeferredGroups ? new SetMultiMap(initialDeferredGroups) : new SetMultiMap(); let processInParallel = rootsAreParallel; while (state.next.length > 0) { const { processed, newState, deferredGroups } = this.processGroups(processor, state, processInParallel, handledConditions); // After the root groups, handled on the first iteration, we can process everything in parallel. processInParallel = true; mainSequence.push(processed); state = newState; allDeferredGroups.addAll(deferredGroups); } return { mainSequence, newState: state, deferredGroups: allDeferredGroups, }; } private processRootGroups({ processor, rootGroups, rootsAreParallel = true, currentDeferRef, otherDeferGroups = undefined, handledConditions, }: { processor: FetchGroupProcessor, rootGroups: readonly FetchGroup[], rootsAreParallel: boolean, unhandledGroups?: UnhandledGroups, currentDeferRef?: string, otherDeferGroups?: SetMultiMap, handledConditions: Conditions, }): { mainSequence: TProcessed[], deferred: TDeferred[], } { const { mainSequence, newState, deferredGroups, } = this.processRootMainGroups({ processor, rootsAreParallel, state: ProcessingState.ofReadyGroups(rootGroups), handledConditions }); assert(newState.next.length === 0, () => `Should not have left some ready groups, but got ${newState.next}`); assert( newState.unhandled.length == 0, () => `Root groups:\n${rootGroups.map((g) => ` - ${g}`).join('\n')}\nshould have no remaining groups unhandled, but got: ${printUnhandled(newState.unhandled)}` ); const allDeferredGroups = new SetMultiMap(); if (otherDeferGroups) { allDeferredGroups.addAll(otherDeferGroups); } allDeferredGroups.addAll(deferredGroups); // We're going to handled all @defer at our "current level" (so at top-level, that's all the non-nested @defer), // and the "starting" group for those defers, if any, are in `allDeferredGroups`. However, `allDeferredGroups` // can actually contains defer groups that are for "deeper" level of @defer-nestedness, and that is because // sometimes the key we need to resume a nested @defer is the same than for the current @defer (or put another way, // a @defer B may be nested inside @defer A "in the query", but be such that we don't need anything fetched within // the deferred part of A to start the deferred part of B). // Long story short, we first collect the groups from `allDeferredGroups` that are _not_ in our current level, if // any, and pass those to recursion call below so they can be use a their proper level of nestedness. const defersInCurrent = this.deferTracking.defersInParent(currentDeferRef); const handledDefersInCurrent = new Set(defersInCurrent.map((d) => d.label)); const unhandledDefersInCurrent = mapKeys(allDeferredGroups).filter((label) => !handledDefersInCurrent.has(label)); let unhandledDeferGroups: SetMultiMap | undefined = undefined; if (unhandledDefersInCurrent.length > 0) { unhandledDeferGroups = new SetMultiMap(); for (const label of unhandledDefersInCurrent) { unhandledDeferGroups.set(label, allDeferredGroups.get(label)!); } } // We now iterate on every @defer of said "current level". Note in particular that we may not be able to truly defer // anything for some of those @defer due the limitations of what can be done at the query planner level. However, we // still create `DeferNode` and `DeferredNode` in those case so that the execution can at least defer the sending of // the response back (future handling of defer-passthrough will also piggy-back on this). const allDeferred: TDeferred[] = []; for (const defer of defersInCurrent) { const groups = allDeferredGroups.get(defer.label) ?? []; const { mainSequence: mainSequenceOfDefer, deferred: deferredOfDefer } = this.processRootGroups({ processor, rootGroups: Array.from(groups), rootsAreParallel: true, currentDeferRef: defer.label, otherDeferGroups: unhandledDeferGroups, handledConditions, }); const mainReduced = processor.reduceSequence(mainSequenceOfDefer); const processed = deferredOfDefer.length === 0 ? mainReduced : processor.reduceDefer(mainReduced, defer.subselection.get(), deferredOfDefer); allDeferred.push(processor.reduceDeferred(defer, processed)); } return { mainSequence, deferred: allDeferred }; } /** * Processes the "plan" represented by this dependency graph using the provided `processor`. * * @return both a "main" (non-deferred) part and a (potentially empty) deferred part. */ process( processor: FetchGroupProcessor, rootKind: SchemaRootKind, ): { main: TProcessed, deferred: TDeferred[], } { this.reduceAndOptimize(); const { mainSequence, deferred } = this.processRootGroups({ processor, rootGroups: this.rootGroups.values(), rootsAreParallel: rootKind === 'query', handledConditions: true, }); // Note that the return of `processRootGroups` should always be reduced as a sequence, regardless of `rootKind`. // For queries, it just happens in that the majority of cases, `mainSequence` will be an array of a single element // and that single element will be a parallel node of the actual roots. But there is some special cases where some // while the roots are started in parallel, the overall plan shape is something like: // Root1 \ // -> Other // Root2 / // And so it is a sequence, even if the roots will be queried in parallel. return { main: processor.reduceSequence(mainSequence), deferred, }; } dumpOnConsole(msg?: string) { if (msg) { console.log(msg); } console.log('Groups:'); for (const group of this.groups) { console.log(` ${group}`); } console.log('Children relationships:'); for (const group of this.groups) { const children = group.children(); if (children.length === 1) { console.log(` [${group.index}] => [ ${children[0]} ]`); } else if (children.length !== 0) { console.log(` [${group.index}] => [\n ${children.join('\n ')}\n ]`); } } console.log('Parent relationships:'); const printParentRelation = (rel: ParentRelation) => ( rel.path ? `${rel.group} (path: [${rel.path.join(', ')}])` : rel.group.toString() ); for (const group of this.groups) { const parents = group.parents(); if (parents.length === 1) { console.log(` [${group.index}] => [ ${printParentRelation(parents[0])} ]`); } else if (parents.length !== 0) { console.log(` [${group.index}] => [\n ${parents.map(printParentRelation).join('\n ')}\n ]`); } } console.log('--------'); } toString() : string { return this.rootGroups.values().map(g => this.toStringInternal(g, "")).join('\n'); } toStringInternal(group: FetchGroup, indent: string): string { const children = group.children(); return [indent + group.subgraphName + ' <- ' + children.map((child) => child.subgraphName).join(', ')] .concat(children .flatMap(g => g.children().length == 0 ? [] : this.toStringInternal(g, indent + " "))) .join('\n'); } } /** * Generic interface for "processing" a (reduced) dependency graph of fetch groups (a `FetchDependencyGraph`). * * The processor methods will be called in a way that "respects" the dependency graph. More precisely, a * reduced fetch group dependency graph can be expressed as an alternance of parallel branches and sequences * of groups (the roots needing to be either parallel or sequential depending on whether we represent a `query` * or a `mutation`), and the processor will be called on groups in such a way. */ interface FetchGroupProcessor { onFetchGroup(group: FetchGroup, handledConditions: Conditions): TProcessed; onConditions(conditions: Conditions, value: TProcessed): TProcessed; reduceParallel(values: TProcessed[]): TProcessed; reduceSequence(values: TProcessed[]): TProcessed; reduceDeferred(deferInfo: DeferredInfo, value: TProcessed): TDeferred; reduceDefer(main: TProcessed, subSelection: SelectionSet, deferredBlocks: TDeferred[]): TProcessed, } export type PlanningStatistics = { evaluatedPlanCount: number, } type PlanningParameters = { supergraphSchema: Schema, federatedQueryGraph: QueryGraph, operation: Operation, statistics?: PlanningStatistics, processor: FetchGroupProcessor root: RV, inconsistentAbstractTypesRuntimes: Set, config: Concrete, overrideConditions: Map, } interface BuildQueryPlanOptions { /** * A set of labels which will be used _during query planning_ to * enable/disable edges with a matching label in their override condition. * Edges with override conditions require their label to be present or absent * from this set in order to be traversable. These labels enable the * progressive @override feature. */ overrideConditions?: Map, /** * Normally, we impose a limit on the number of recursive selections, which if * high can cause poor query planner performance. Setting this flag to `true` * will disable this limit check. This is not advised if gateway is being used * to serve queries outside your control, as doing so will leave query planner * susceptible to denial-of-service attacks. */ recursiveSelectionsLimitDisabled?: boolean, /** * Normally, we impose a limit on the number of non-local selections, which if * high can cause poor query planner performance. Setting this flag to `true` * will disable this limit check. This is not advised if gateway is being used * to serve queries outside your control, as doing so will leave query planner * susceptible to denial-of-service attacks. */ nonLocalSelectionsLimitDisabled?: boolean, } export class QueryPlanner { private readonly config: Concrete; private readonly federatedQueryGraph: QueryGraph; private _lastGeneratedPlanStatistics: PlanningStatistics | undefined; private _defaultOverrideConditions: Map = new Map(); // A set of the names of interface types for which at least one subgraph use an @interfaceObject to abstract // that interface. private readonly interfaceTypesWithInterfaceObjects = new Set(); // A set of the names of interface or union types that have inconsistent "runtime types" across subgraphs. private readonly inconsistentAbstractTypesRuntimes = new Set(); constructor( public readonly supergraph: Supergraph, config?: QueryPlannerConfig ) { this.config = enforceQueryPlannerConfigDefaults(config); // Validating post default-setting to catch any fat-fingering of the defaults themselves. validateQueryPlannerConfig(this.config); this.federatedQueryGraph = buildFederatedQueryGraph(supergraph, true); this.collectInterfaceTypesWithInterfaceObjects(); this.collectInconsistentAbstractTypesRuntimes(); this.collectAllOverrideLabels(); if (this.config.debug.bypassPlannerForSingleSubgraph && this.config.incrementalDelivery.enableDefer) { throw new Error(`Cannot use the "debug.bypassPlannerForSingleSubgraph" query planner option when @defer support is enabled`); } } private collectInterfaceTypesWithInterfaceObjects() { const isInterfaceObject = (name: string, schema: Schema) => { const typeInSchema = schema.type(name); return !!typeInSchema && isInterfaceObjectType(typeInSchema); } for (const itfType of this.supergraph.schema.interfaceTypes()) { if (mapValues(this.federatedQueryGraph.sources).some((s) => isInterfaceObject(itfType.name, s))) { this.interfaceTypesWithInterfaceObjects.add(itfType.name); } } } private collectInconsistentAbstractTypesRuntimes() { const subgraphs = mapValues(this.federatedQueryGraph.sources); const isInconsistent = (name: string) => { // Note that we use type names since we're comparing types from different subgraphs (and so the objects themselves // will not be equal). let expectedRuntimes: Set | undefined = undefined; for (const subgraph of subgraphs) { const typeInSubgraph = subgraph.type(name); // This is only called for type name that are abstract in the supergraph, so it // can only be an object in a subgraph if it is an @interfaceObject. And as @interfaceObject // "stand-in" for all possible runtimes, they don't create inconsistencies by themselves // and we can ignore them. if (!typeInSubgraph || isObjectType(typeInSubgraph)) { continue; } assert(isAbstractType(typeInSubgraph), () => `Expected type ${typeInSubgraph} to be abstract but is ${typeInSubgraph.kind}`); const runtimes = possibleRuntimeTypes(typeInSubgraph); if (!expectedRuntimes) { expectedRuntimes = new Set(runtimes.map((t) => t.name)); } else if (runtimes.length !== expectedRuntimes.size || runtimes.some((t) => !expectedRuntimes?.has(t.name))) { return true; } } return false; } for (const type of this.supergraph.schema.types()) { if (!isAbstractType(type)) { continue; } if (isAbstractType(type) && isInconsistent(type.name)) { this.inconsistentAbstractTypesRuntimes.add(type.name); } } } private collectAllOverrideLabels() { // inspect every join__field directive application in the supergraph and collect all `overrideLabel` argument values const applications = this.supergraph.schema.directives() .find((d) => d.name === 'join__field') ?.applications() ?? new Set(); this._defaultOverrideConditions = new Map( Array.from(applications) .map((application) => application.arguments().overrideLabel) .filter(Boolean) .map(label => [label, false]) ); } buildQueryPlan(operation: Operation, options?: BuildQueryPlanOptions): QueryPlan { if (operation.selectionSet.isEmpty()) { return { kind: 'QueryPlan' }; } if (!options?.recursiveSelectionsLimitDisabled) { // Before running anything that might expand named fragments recursively, // we ensure that doing so won't generate too many selections. // // This is done before the single-subgraph bypass to avoid those subgraphs // being sent such a query. For gateway, note that top-level introspection // fields are not split off from the query given to query planning, and // this allows the check below to also impose a limit on the introspection // part of queries. (Which is important, since query plan execution in // gateway expands introspection fragments at the moment.) validateRecursiveSelections(operation); } const isSubscription = operation.rootKind === 'subscription'; const statistics: PlanningStatistics = { evaluatedPlanCount: 0, }; this._lastGeneratedPlanStatistics = statistics; if (this.config.debug.bypassPlannerForSingleSubgraph) { // A federated query graph always have 1 more sources than there is subgraph, because the root vertices // belong to no subgraphs and use a special source named '_'. So we skip that "fake" source. const subgraphs = mapKeys(this.federatedQueryGraph.sources).filter((name) => name !== FEDERATED_GRAPH_ROOT_SOURCE); if (subgraphs.length === 1) { const operationDocument = operationToDocument(operation); const node: FetchNode = { kind: 'Fetch', serviceName: subgraphs[0], variableUsages: operation.variableDefinitions.definitions().map(v => v.variable.name), operation: stripIgnoredCharacters(print(operationDocument)), operationKind: schemaRootKindToOperationKind(operation.rootKind), operationName: operation.name, operationDocumentNode: this.config.exposeDocumentNodeInFetchNode ? operationDocument : undefined, }; return { kind: 'QueryPlan', node }; } } const reuseQueryFragments = this.config.reuseQueryFragments ?? true; let fragments = operation.fragments; if (fragments && !fragments.isEmpty() && reuseQueryFragments) { // For all subgraph fetches we query `__typename` on every abstract types (see `FetchGroup.toPlanNode`) so if we want // to have a chance to reuse fragments, we should make sure those fragments also query `__typename` for every abstract type. fragments = addTypenameFieldForAbstractTypesInNamedFragments(fragments); } else { fragments = undefined; } // We expand all fragments. This might merge a number of common branches and save us some work, and we're // going to expand everything during the algorithm anyway. We'll re-optimize subgraph fetches with fragments // later if possible (which is why we saved them above before expansion). operation = operation.expandAllFragments(); operation = withoutIntrospection(operation); operation = this.withSiblingTypenameOptimizedAway(operation); let assignedDeferLabels: Set | undefined = undefined; let hasDefers = false; let deferConditions: SetMultiMap | undefined = undefined; if (this.config.incrementalDelivery.enableDefer) { ({ operation, hasDefers, assignedDeferLabels, deferConditions } = operation.withNormalizedDefer()); if (isSubscription && hasDefers) { throw new Error(`@defer is not supported on subscriptions`); } } else { // If defer is not enabled, we remove all @defer from the query. This feels cleaner do this once here than // having to guard all the code dealing with defer later, and is probably less error prone too (less likely // to end up passing through a @defer to a subgraph by mistake). operation = operation.withoutDefer(); } debug.group(() => `Computing plan for\n${operation}`); if (operation.selectionSet.isEmpty()) { debug.groupEnd('Empty plan'); return { kind: 'QueryPlan' }; } const root = this.federatedQueryGraph.root(operation.rootKind); assert(root, () => `Shouldn't have a ${operation.rootKind} operation if the subgraphs don't have a ${operation.rootKind} root`); const processor = fetchGroupToPlanProcessor({ config: this.config, variableDefinitions: operation.variableDefinitions, fragments: fragments ? new RebasedFragments(fragments) : undefined, operationName: operation.name, directives: operation.appliedDirectives, assignedDeferLabels, }); // Default all override conditions to false (not overridden) in case any // aren't provided by the caller const overrideConditions = new Map(this._defaultOverrideConditions); if (options?.overrideConditions) { for (const [label, value] of options.overrideConditions) { overrideConditions.set(label, value); } } const parameters: PlanningParameters = { supergraphSchema: this.supergraph.schema, federatedQueryGraph: this.federatedQueryGraph, operation, processor, root, statistics, inconsistentAbstractTypesRuntimes: this.inconsistentAbstractTypesRuntimes, config: this.config, overrideConditions, } let rootNode: PlanNode | SubscriptionNode | undefined; let nonLocalSelectionsState = options?.nonLocalSelectionsLimitDisabled ? null : new NonLocalSelectionsState(); if (deferConditions && deferConditions.size > 0) { assert(hasDefers, 'Should not have defer conditions without @defer'); rootNode = computePlanForDeferConditionals({ parameters, deferConditions, nonLocalSelectionsState, }) } else { rootNode = computePlanInternal({ parameters, hasDefers, nonLocalSelectionsState, }); } // If this is a subscription, we want to make sure that we return a SubscriptionNode rather than a PlanNode // We potentially will need to separate "primary" from "rest" // Note that if it is a subscription, we are guaranteed that nothing is deferred. if (rootNode && isSubscription) { switch (rootNode.kind) { case 'Fetch': { rootNode = { kind: 'Subscription', primary: rootNode, }; } break; case 'Sequence': { const [primary, ...rest] = rootNode.nodes; assert(primary.kind === 'Fetch', 'Primary node of a subscription is not a Fetch'); rootNode = { kind: 'Subscription', primary, rest: { kind: 'Sequence', nodes: rest, }, }; } break; default: throw new Error(`Unexpected top level PlanNode kind: '${rootNode.kind}' when processing subscription`); } } debug.groupEnd('Query plan computed'); return { kind: 'QueryPlan', node: rootNode }; } /** * Modifies the provided selection set to optimize the handling of __typename selection for query planning. * * Explicit querying of __typename can create some inefficiency for the query planning process if not * handled specially. More precisely, query planning performance is directly proportional to how many possible * plans a query has, since it compute all those options to compare them. Further, the number of possible * plans double for every field for which there is a choice, so miminizing the number of field for which we * have choices is paramount. * * And for a given type, __typename can always be provided by any subgraph having that type (it works as a * kind of "always @shareable" field), so it often creates theoretical choices. In practice it doesn't * matter which subgraph we use for __typename: we're happy to use whichever subgraph we're using for * the "other" fields queried for the type. But the default query planning algorithm does not know how * to do that. * * Let's note that this isn't an issue in most cases, because the query planning algorithm knows not to * consider "obviously" inefficient paths. Typically, querying the __typename of an entity is generally * ok because when looking at a path, the query planning algorithm always favor getting a field "locally" * if it can (which it always can for __typename) and ignore alternative that would jump subgraphs. * * But this can still be a performance issue when a __typename is queried after a @shareable field: in * that case, the algorithm would consider getting the __typename from each version of the @shareable * field and this would add to the options to consider. But as, again, __typename can always be fetched * from any subgraph, it's more efficient to ignore those options and simply get __typename from whichever * subgraph we get any other of the other field requested (on the type on which we request __typename). * * It is unclear how to do this cleanly with the current planning algorithm however, so this method * implements an alternative: to avoid the query planning spending time of exploring options for * __typename, we "remove" the __typename selections from the operation. But of course, we still * need to ensure that __typename is effectively queried, so as we do that removal, we also "tag" * one of the "sibling" selection (using `addAttachment`) to remember that __typename needs to * be added back eventually. The core query planning algorithm will ignore that tag, and because * __typename has been otherwise removed, we'll save any related work. But as we build the final * query plan, we'll check back for those "tags" (see `getAttachment` in `computeGroupsForTree`), * and when we fine one, we'll add back the request to __typename. As this only happen after the * query planning algorithm has computed all choices, we achieve our goal of not considering useless * choices due to __typename. Do note that if __typename is the "only" selection of some selection * set, then we leave it untouched, and let the query planning algorithm treat it as any other * field. We have no other choice in that case, and that's actually what we want. */ private optimizeSiblingTypenames(selectionSet: SelectionSet): SelectionSet { const selections = selectionSet.selections(); const parentType = selectionSet.parentType; const parentMaybeInterfaceObject = this.interfaceTypesWithInterfaceObjects.has(parentType.name); let updatedSelections: Selection[] | undefined = undefined; let typenameSelection: Selection | undefined = undefined; // We remember the first non-__typename field selection found. This is the one we'll "tag" if we do find a __typename // occurrence that we want to remove. We only use for _field_ selections because at the stage where this is applied, // we cannot be sure the selection set is "minimized" and so some of the inline fragments may end up being eliminated // (for instance, the fragment condition could be "less precise" than the parent type, in which case query planning // will ignore it) and tagging those could lose the tagging. let firstFieldSelection: FieldSelection | undefined = undefined; let firstFieldIndex = -1; for (let i = 0; i < selections.length; i++) { const selection = selections[i]; let updated: Selection | undefined; if ( !typenameSelection && selection.kind === 'FieldSelection' && selection.isPlainTypenameField() && !parentMaybeInterfaceObject ) { // The reason we check for `!typenameSelection` is that due to aliasing, there can be more than one __typename selection // in theory, and so this will only kick in on the first one. This is fine in practice: it only means that if there _is_ // 2 selection of __typename, then we won't optimise things as much as we could, but there is no practical reason // whatsoever to have 2 selection of __typename in the first place, so not being optimal is moot. // // Also note that we do not remove __typename if on (interface) types that are implemented by // an @interfaceObject in some subgraph: the reason is that those types are an exception to the rule // that __typename can be resolved from _any_ subgraph, as the __typename of @interfaceObject is not // one we should return externally and so cannot fulfill the user query. updated = undefined; typenameSelection = selection; } else { const updatedSubSelection = selection.selectionSet ? this.optimizeSiblingTypenames(selection.selectionSet) : undefined; if (updatedSubSelection === selection.selectionSet) { updated = selection; } else { // Note that updateSubSelection can genuinely be undefined for leaf fields, but the type system can track it properly // so we force it to accept it with `!` (to avoid it, we would have to duplicate the code for field and fragment // separately, and that doesn't feel like it would be cleaner). updated = selection.withUpdatedSelectionSet(updatedSubSelection!); } if (!firstFieldSelection && updated.kind === 'FieldSelection') { firstFieldSelection = updated; firstFieldIndex = updatedSelections ? updatedSelections.length : i; } } // As soon as we find a selection that is discarded or modified, we need to create new selection set so we // first copy everything up to this selection. if (updated !== selection && !updatedSelections) { updatedSelections = []; for (let j = 0; j < i; j++) { updatedSelections.push(selections[j]); } } // Record the (potentially updated) selection if we're creating a new selection set, and said selection is not discarded. if (updatedSelections && !!updated) { updatedSelections.push(updated); } } if (!updatedSelections || updatedSelections.length === 0) { // No selection was modified at all, or there is no other field selection than __typename one. // In both case, we just return the current selectionSet unmodified. return selectionSet; } // If we have some __typename selection that was removed but need to be "remembered" for later, // "tag" whichever first field selection is still part of the operation. if (typenameSelection) { if (firstFieldSelection) { // Note that as we tag the element, we also record the alias used if any since that needs to be preserved. updatedSelections[firstFieldIndex] = firstFieldSelection.withAttachment(SIBLING_TYPENAME_KEY, typenameSelection.element.alias ? typenameSelection.element.alias : ''); } else { // If we have no other field selection, then we can't optimize __typename and we need to add // it back to the updated subselections (we add it first because that's usually where we // put __typename by convention). updatedSelections = [typenameSelection as Selection].concat(updatedSelections); } } return new SelectionSetUpdates().add(updatedSelections).toSelectionSet(selectionSet.parentType); } /** * Applies `optimizeSiblingTypenames` to the provided operation selection set. */ private withSiblingTypenameOptimizedAway(operation: Operation): Operation { const updatedSelectionSet = this.optimizeSiblingTypenames(operation.selectionSet); if (updatedSelectionSet === operation.selectionSet) { return operation; } return new Operation( operation.schema(), operation.rootKind, updatedSelectionSet, operation.variableDefinitions, operation.fragments, operation.name, operation.appliedDirectives, ); } lastGeneratedPlanStatistics(): PlanningStatistics | undefined { return this._lastGeneratedPlanStatistics; } } function computePlanInternal({ parameters, hasDefers, nonLocalSelectionsState, }: { parameters: PlanningParameters, hasDefers: boolean, nonLocalSelectionsState: NonLocalSelectionsState | null, }): PlanNode | undefined { let main: PlanNode | undefined = undefined; let primarySelection: MutableSelectionSet | undefined = undefined; let deferred: DeferredNode[] = []; const { operation, processor } = parameters; if (operation.rootKind === 'mutation') { const dependencyGraphs = computeRootSerialDependencyGraphForMutation( parameters, hasDefers, nonLocalSelectionsState, ); for (const dependencyGraph of dependencyGraphs) { const { main: localMain, deferred: localDeferred } = dependencyGraph.process(processor, operation.rootKind); // Note that `reduceSequence` "flatten" sequence if needs be. main = main ? processor.reduceSequence([main, localMain]) : localMain; deferred = deferred.concat(localDeferred); const newSelection = dependencyGraph.deferTracking.primarySelection; if (newSelection) { if (primarySelection) { primarySelection.updates().add(newSelection.get()); } else { primarySelection = newSelection.clone(); } } } } else { const dependencyGraph = computeRootParallelDependencyGraph( parameters, 0, hasDefers, nonLocalSelectionsState, ); ({ main, deferred } = dependencyGraph.process(processor, operation.rootKind)); primarySelection = dependencyGraph.deferTracking.primarySelection; } if (deferred.length > 0) { assert(primarySelection, 'Should have had a primary selection created'); return processor.reduceDefer(main, primarySelection.get(), deferred); } return main; } function computePlanForDeferConditionals({ parameters, deferConditions, nonLocalSelectionsState, }: { parameters: PlanningParameters, deferConditions: SetMultiMap, nonLocalSelectionsState: NonLocalSelectionsState | null, }): PlanNode | undefined { return generateConditionNodes( parameters.operation, Array.from(deferConditions.entries()), 0, (op) => computePlanInternal({ parameters: { ...parameters, operation: op, }, hasDefers: true, nonLocalSelectionsState, }), ); } function generateConditionNodes( operation: Operation, conditions: [string, Set][], idx: number, onFinalOperation: (operation: Operation) => PlanNode | undefined, ): PlanNode | undefined { if (idx >= conditions.length) { return onFinalOperation(operation); } const [variable, labels] = conditions[idx]; const ifOperation = operation; const elseOperation = operation.withoutDefer(labels); return { kind: 'Condition', condition: variable, // Note: for the `: true` case, we don't modify the operation at all. In theory, it would be cleaner to // modify the operation to remove the `if` condition on all the `@defer` from `labels` (or modify it to hard-coded 'true'), // to make it clear those @defer are "enabled" on that branch. In practice though, the rest of the query planning // completely ignores the `if` argument, so leaving it in untouched ends up equivalent and that saves us a few cyclesf. ifClause: generateConditionNodes(ifOperation, conditions, idx+1, onFinalOperation), elseClause: generateConditionNodes(elseOperation, conditions, idx+1, onFinalOperation), }; } function isIntrospectionSelection(selection: Selection): boolean { return selection.kind == 'FieldSelection' && selection.element.definition.isIntrospectionField(); } function mapOptionsToSelections( selectionSet: SelectionSet, options: SimultaneousPathsWithLazyIndirectPaths[] ): [Selection, SimultaneousPathsWithLazyIndirectPaths[]][] { // We reverse the selections because we're going to pop from `openPaths` and this ensure we end up handling things in the query order. return selectionSet.selectionsInReverseOrder().map(node => [node, options]); } function possiblePlans(closedBranches: ClosedBranch[]): bigint { let totalCombinations = BigInt(1); for (let i = 0; i < closedBranches.length; ++i){ const eltSize = BigInt(closedBranches[i].length); if (eltSize === BigInt(0)) { // This would correspond to not being to find *any* path for a particular queried field, which means we have no plan // for the overall query. Now, this shouldn't happen in practice if composition validation has been run successfully // (and is not buggy), since the goal of composition validation is exactly to ensure we can never run into this path. // In any case, we will throw later if that happens, but let's just return the proper result here, which is no plan at all. return BigInt(0); } totalCombinations *= eltSize; } return totalCombinations; } function sum(arr: number[]): number { return arr.reduce((a, b) => a + b, 0); } function selectionCost(selection?: SelectionSet, depth: number = 1): number { // The cost is essentially the number of elements in the selection, but we make deeped element cost a tiny bit more, mostly to make things a tad more // deterministic (typically, if we have an interface with a single implementation, then we can have a choice between a query plan that type-explode a // field of the interface and one that doesn't, and both will be almost identical, except that the type-exploded field will be a different depth; by // favoring lesser depth in that case, we favor not type-exploding). //return selection ? 10 + depth : 0; return selection ? selection.selections().reduce((prev, curr) => prev + depth + selectionCost(curr.selectionSet, depth + 1), 0) : 0; } function withoutIntrospection(operation: Operation): Operation { // Note that, because we only apply this to the top-level selections, we skip all introspection, including // __typename. In general, we don't want o ignore __typename during query plans, but at top-level, we // can let the gateway execution deal with it rather than querying some service for that. if (!operation.selectionSet.selections().some(isIntrospectionSelection)) { return operation } return new Operation( operation.schema(), operation.rootKind, operation.selectionSet.lazyMap((s) => isIntrospectionSelection(s) ? undefined : s), operation.variableDefinitions, operation.fragments, operation.name, operation.appliedDirectives, ); } function computeRootParallelDependencyGraph( parameters: PlanningParameters, startFetchIdGen: number, hasDefer: boolean, nonLocalSelectionsState: NonLocalSelectionsState | null, ): FetchDependencyGraph { return computeRootParallelBestPlan( parameters, parameters.operation.selectionSet, startFetchIdGen, hasDefer, nonLocalSelectionsState, )[0]; } function computeRootParallelBestPlan( parameters: PlanningParameters, selection: SelectionSet, startFetchIdGen: number, hasDefers: boolean, nonLocalSelectionsState: NonLocalSelectionsState | null, ): [FetchDependencyGraph, OpPathTree, number] { const planningTraversal = new QueryPlanningTraversal( parameters, selection, startFetchIdGen, hasDefers, parameters.root.rootKind, defaultCostFunction, emptyContext, parameters.config.typeConditionedFetching, nonLocalSelectionsState, null, ); const plan = planningTraversal.findBestPlan(); // Getting no plan means the query is essentially unsatisfiable (it's a valid query, but we can prove it will never return a result), // so we just return an empty plan. return plan ?? createEmptyPlan(parameters); } function computeRootParallelBestPlanForMutation( parameters: PlanningParameters, selection: SelectionSet, startFetchIdGen: number, hasDefers: boolean, nonLocalSelectionsState: NonLocalSelectionsState | null, ): [FetchDependencyGraph, OpPathTree, number] { let bestPlan: | [FetchDependencyGraph, OpPathTree, number] | undefined; const mutationSubgraphs = parameters.federatedQueryGraph .outEdges(parameters.root).map((edge) => edge.tail.source); for (const mutationSubgraph of mutationSubgraphs) { const planningTraversal = new QueryPlanningTraversal( parameters, selection, startFetchIdGen, hasDefers, parameters.root.rootKind, defaultCostFunction, emptyContext, parameters.config.typeConditionedFetching, nonLocalSelectionsState, mutationSubgraph, ); const plan = planningTraversal.findBestPlan(); if (!bestPlan || (plan && plan[2] < bestPlan[2])) { bestPlan = plan; } } if (!bestPlan) { throw new Error( `Was not able to plan ${parameters.operation.toString(false, false)} starting from a single subgraph: This shouldn't have happened.`, ); } return bestPlan; } function createEmptyPlan( parameters: PlanningParameters, ): [FetchDependencyGraph, OpPathTree, number] { const { supergraphSchema, federatedQueryGraph, root, config } = parameters; return [ FetchDependencyGraph.create(supergraphSchema, federatedQueryGraph, 0, undefined, config.generateQueryFragments), PathTree.createOp(federatedQueryGraph, root), 0 ]; } function onlyRootSubgraph(graph: FetchDependencyGraph): string { const subgraphs = graph.rootSubgraphs(); assert(subgraphs.length === 1, () => `${graph} should have only one root, but has [${graph.rootSubgraphs()}]`); return subgraphs[0]; } function computeRootSerialDependencyGraphForMutation( parameters: PlanningParameters, hasDefers: boolean, nonLocalSelectionsState: NonLocalSelectionsState | null, ): FetchDependencyGraph[] { const { supergraphSchema, federatedQueryGraph, operation, root } = parameters; const rootType = hasDefers ? supergraphSchema.schemaDefinition.rootType(root.rootKind) : undefined; // We have to serially compute a plan for each top-level selection. const splittedRoots = splitTopLevelFields(operation.selectionSet); const graphs: FetchDependencyGraph[] = []; let startingFetchId = 0; let [prevDepGraph, prevPaths] = computeRootParallelBestPlanForMutation( parameters, splittedRoots[0], startingFetchId, hasDefers, nonLocalSelectionsState, ); let prevSubgraph = onlyRootSubgraph(prevDepGraph); for (let i = 1; i < splittedRoots.length; i++) { const [newDepGraph, newPaths] = computeRootParallelBestPlanForMutation( parameters, splittedRoots[i], prevDepGraph.nextFetchId(), hasDefers, nonLocalSelectionsState, ); const newSubgraph = onlyRootSubgraph(newDepGraph); if (prevSubgraph === newSubgraph) { // The new operation (think 'mutation' operation) is on the same subgraph than the previous one, so we can concat them in a single fetch // and rely on the subgraph to enforce seriability. Do note that we need to `concat()` and not `merge()` because if we have // mutation Mut { // mut1 {...} // mut2 {...} // mut1 {...} // } // then we should _not_ merge the 2 `mut1` fields (contrarily to what happens on queried fields). prevPaths = prevPaths.concat(newPaths); prevDepGraph = computeRootFetchGroups( FetchDependencyGraph.create( supergraphSchema, federatedQueryGraph, startingFetchId, rootType, parameters.config.generateQueryFragments, ), prevPaths, root.rootKind, parameters.config.typeConditionedFetching ); } else { startingFetchId = prevDepGraph.nextFetchId(); graphs.push(prevDepGraph); [prevDepGraph, prevPaths, prevSubgraph] = [newDepGraph, newPaths, newSubgraph]; } } graphs.push(prevDepGraph); return graphs; } function splitTopLevelFields(selectionSet: SelectionSet): SelectionSet[] { return selectionSet.selections().flatMap(selection => { if (selection.kind === 'FieldSelection') { return [selectionSetOf(selectionSet.parentType, selection)]; } else { return splitTopLevelFields(selection.selectionSet).map(s => selectionSetOfElement(selection.element, s)); } }); } function toValidGraphQLName(subgraphName: string): string { // We have almost no limitations on subgraph names, so we cannot use them inside query names // without some cleaning up. GraphQL names can only be: [_A-Za-z][_0-9A-Za-z]*. // To do so, we: // 1. replace '-' by '_' because the former is not allowed but it's probably pretty // common and using the later should be fairly readable. // 2. remove any character in what remains that is not allowed. // 3. Unsure the first character is not a number, and if it is, add a leading `_`. // Note that this could theoretically lead to substantial changes to the name but should // work well in practice (and if it's a huge problem for someone, we can change it). const sanitized = subgraphName .replace(/-/ig, '_') .replace(/[^_0-9A-Za-z]/ig, ''); return sanitized.match(/^[0-9].*/i) ? '_' + sanitized : sanitized; } function sanitizeAndPrintSubselection(subSelection: SelectionSet): string | undefined { return subSelection.withoutEmptyBranches()?.toString(); } function fetchGroupToPlanProcessor({ config, variableDefinitions, fragments, operationName, directives, assignedDeferLabels, }: { config: Concrete, variableDefinitions: VariableDefinitions, fragments?: RebasedFragments, operationName?: string, directives?: readonly Directive[], assignedDeferLabels?: Set, }): FetchGroupProcessor { let counter = 0; return { onFetchGroup: (group: FetchGroup, handledConditions: Conditions) => { const opName = operationName ? `${operationName}__${toValidGraphQLName(group.subgraphName)}__${counter++}` : undefined; return group.toPlanNode(config, handledConditions, variableDefinitions, fragments, opName, directives); }, onConditions: (conditions: Conditions, value: PlanNode | undefined) => { if (!value) { return undefined; } if (isConstantCondition(conditions)) { // Note that currently `ConditionNode` only works for variables (`ConditionNode.condition` is expected to be a variable name // and nothing else). We could change that, but really, why have a trivial `ConditionNode` when we can optimise things righ away. return conditions ? value : undefined; } else { return conditions.reduce( (node, condition) => ({ kind: 'Condition', condition: condition.variable.name, ifClause: condition.negated ? undefined : node, elseClause: condition.negated ? node : undefined, }), value, ); } }, reduceParallel: (values: (PlanNode | undefined)[]) => flatWrapNodes('Parallel', values), reduceSequence: (values: (PlanNode | undefined)[]) => flatWrapNodes('Sequence', values), reduceDeferred: (deferInfo: DeferredInfo, value: PlanNode | undefined): DeferredNode => ({ depends: [...deferInfo.dependencies].map((id) => ({ id })), label: assignedDeferLabels?.has(deferInfo.label) ? undefined : deferInfo.label, queryPath: operationPathToStringPath(deferInfo.path.full()), // Note that if the deferred block has nested @defer, then the `value` is going to be a `DeferNode` and we'll // use it's own `subselection`, so we don't need it here. subselection: deferInfo.deferred.size === 0 ? sanitizeAndPrintSubselection(deferInfo.subselection.get()) : undefined, node: value, }), reduceDefer: (main: PlanNode | undefined, subselection: SelectionSet, deferredBlocks: DeferredNode[]) => ({ kind: 'Defer', primary: { subselection: sanitizeAndPrintSubselection(subselection), node: main, }, deferred: deferredBlocks, }), }; } // Wraps the given nodes in a ParallelNode or SequenceNode, unless there's only // one node, in which case it is returned directly. Any nodes of the same kind // in the given list have their sub-nodes flattened into the list: ie, // flatWrapNodes('Sequence', [a, flatWrapNodes('Sequence', b, c), d]) returns a SequenceNode // with four children. function flatWrapNodes( kind: ParallelNode['kind'] | SequenceNode['kind'], nodes: (PlanNode | undefined)[], ): PlanNode | undefined { const filteredNodes = nodes.filter((n) => !!n) as PlanNode[]; if (filteredNodes.length === 0) { return undefined; } if (filteredNodes.length === 1) { return filteredNodes[0]; } return { kind, nodes: filteredNodes.flatMap((n) => n.kind === kind ? n.nodes : [n]), }; } function addTypenameFieldForAbstractTypesInNamedFragments(fragments: NamedFragments): NamedFragments { // This method is a bit tricky due to potentially nested fragments. More precisely, suppose that // we have: // fragment MyFragment on T { // a { // b { // ...InnerB // } // } // } // // fragment InnerB on B { // __typename // x // y // } // then if we were to "naively" add `__typename`, the first fragment would end up being: // fragment MyFragment on T { // a { // __typename // b { // __typename // ...InnerX // } // } // } // but that's not ideal because the inner-most `__typename` is already within `InnerX`. And that // gets in the way to re-adding fragments (the `SelectionSet.optimize` method) because if we start // with: // { // a { // __typename // b { // __typename // x // y // } // } // } // and add `InnerB` first, we get: // { // a { // __typename // b { // ...InnerB // } // } // } // and it becomes tricky to recognize the "updated-with-typename" version of `MyFragment` now (we "seem" // to miss a `__typename`). // // Anyway, to avoid this issue, what we do is that for every fragment, we: // 1. expand any nested fragments in its selection. // 2. add `__typename` where we should in that expanded selection. // 3. re-optimize all fragments (using the "updated-with-typename" versions). // which is what `mapToExpandedSelectionSets` gives us. assert(!fragments.isEmpty(), 'Should not pass empty fragments to this method'); const updated = fragments.mapToExpandedSelectionSets(addTypenameFieldForAbstractTypes); assert(updated, 'No fragments should have been removed'); return updated; } /** * Given a selection select (`selectionSet`) and given a set of directive applications that can be eliminated (`unneededDirectives`; in * practice those are conditionals (@skip and @include) already accounted for), returns an equivalent selection set but with unecessary * "starting" fragments having the unneeded condition/directives removed. */ function removeUnneededTopLevelFragmentDirectives( selectionSet: SelectionSet, unneededDirectives: Directive[], ): SelectionSet { return selectionSet.lazyMap((selection) => { if (selection.kind !== 'FragmentSelection') { return selection; } const fragment = selection.element; const fragmentType = fragment.typeCondition; if (!fragmentType) { return selection; } let neededDirectives: Directive[] = []; if (fragment.appliedDirectives.length > 0) { neededDirectives = directiveApplicationsSubstraction(fragment.appliedDirectives, unneededDirectives); } // We recurse, knowing that we'll stop as soon a we hit field selections, so this only cover the fragments // at the "top-level" of the set. const updated = removeUnneededTopLevelFragmentDirectives(selection.selectionSet, unneededDirectives); if (neededDirectives.length === fragment.appliedDirectives.length) { // We need all the directives that the fragment has. Return it unchanged. return selection.selectionSet === updated ? selection : selection.withUpdatedSelectionSet(updated); } // We can skip some of the fragment directives directive. return selection.withUpdatedComponents(fragment.withUpdatedDirectives(neededDirectives), updated); }); } function schemaRootKindToOperationKind(operation: SchemaRootKind): OperationTypeNode { switch(operation) { case "query": return OperationTypeNode.QUERY; case "mutation": return OperationTypeNode.MUTATION; case "subscription": return OperationTypeNode.SUBSCRIPTION; } } function findAndRemoveInPlace(predicate: (v: T) => boolean, array: T[]): number { const idx = array.findIndex((v) => predicate(v)); if (idx >= 0) { array.splice(idx, 1); } return idx; } function sameMergeAt(m1: ResponsePath | undefined, m2: ResponsePath | undefined): boolean { if (!m1) { return !m2; } if (!m2) { return false; } return arrayEquals(m1, m2); } function concatPathsInParents(first: OperationPath | undefined, second: OperationPath | undefined): OperationPath | undefined { return first && second ? concatOperationPaths(first, second) : undefined; } function samePathsInParents(first: OperationPath | undefined, second: OperationPath | undefined): boolean { if (!first) { return !second; } return !!second && sameOperationPaths(first, second); } function computeRootFetchGroups(dependencyGraph: FetchDependencyGraph, pathTree: OpRootPathTree, rootKind: SchemaRootKind, typeConditionedFetching: boolean): FetchDependencyGraph { // The root of the pathTree is one of the "fake" root of the subgraphs graph, which belongs to no subgraph but points to each ones. // So we "unpack" the first level of the tree to find out our top level groups (and initialize our stack). // Note that we can safely ignore the triggers of that first level as it will all be free transition, and we know we cannot have conditions. for (const [edge, _trigger, _conditions, child] of pathTree.childElements()) { assert(edge !== null, `The root edge should not be null`); const subgraphName = edge.tail.source; // The edge tail type is one of the subgraph root type, so it has to be an ObjectType. const rootType = edge.tail.type as ObjectType; const group = dependencyGraph.getOrCreateRootFetchGroup({ subgraphName, rootKind, parentType: rootType }); // If a type is in a subgraph, it has to be in the supergraph. // A root type has to be a Composite type. const rootTypeInSupergraph = dependencyGraph.supergraphSchemaType(rootType.name) as CompositeType; computeGroupsForTree({ dependencyGraph, pathTree: child, startGroup: group, initialGroupPath: GroupPath.empty(typeConditionedFetching, rootTypeInSupergraph), initialDeferContext: emptyDeferContext, }); } return dependencyGraph; } function computeNonRootFetchGroups(dependencyGraph: FetchDependencyGraph, pathTree: OpPathTree, rootKind: SchemaRootKind, typeConditionedFetching: boolean): FetchDependencyGraph { const subgraphName = pathTree.vertex.source; // The edge tail type is one of the subgraph root type, so it has to be an ObjectType. const rootType = pathTree.vertex.type; assert(isCompositeType(rootType), () => `Should not have condition on non-selectable type ${rootType}`); const group = dependencyGraph.getOrCreateRootFetchGroup({ subgraphName, rootKind, parentType: rootType} ); // If a type is in a subgraph, it has to be in the supergraph. // A root type has to be a Composite type. const rootTypeInSupergraph = dependencyGraph.supergraphSchemaType(rootType.name) as CompositeType; computeGroupsForTree({ dependencyGraph, pathTree, startGroup: group, initialGroupPath: GroupPath.empty(typeConditionedFetching, rootTypeInSupergraph), initialDeferContext: emptyDeferContext, }); return dependencyGraph; } function wrapInputsSelections( wrappingType: CompositeType, selections: SelectionSet, context: PathContext ): SelectionSet { return wrapSelectionWithTypeAndConditions( wrappingType, selections, (fragment, currentSeletions) => selectionSetOf(fragment.parentType, selectionOfElement(fragment, currentSeletions)), context ); } function createFetchInitialPath(supergraphSchema: Schema, wrappingType: CompositeType, context: PathContext): OperationPath { // We make sure that all `OperationPath` are based on the supergraph as `OperationPath` is really about path on the input query/overall supergraph data // (most other places already do this as the elements added to the operation path are from the input query, but this is an exception // when we create an element from an type that may/usually will not be from the supergraph). Doing this make sure we can rely on things like checking // subtyping between the types of a given path. const rebasedType = supergraphSchema.type(wrappingType.name); assert(rebasedType && isCompositeType(rebasedType), () => `${wrappingType} should be composite in the supergraph but got ${rebasedType?.kind}`) return wrapSelectionWithTypeAndConditions( rebasedType, [], (fragment, path) => [fragment as OperationElement].concat(path), context, ); } function wrapSelectionWithTypeAndConditions( wrappingType: CompositeType, initialSelection: TSelection, wrapInFragment: (fragment: FragmentElement, current: TSelection) => TSelection, context: PathContext, ): TSelection { if (context.conditionals.length === 0) { return wrapInFragment(new FragmentElement(wrappingType, wrappingType.name), initialSelection); } // We add the first include/skip to the current typeCast and then wrap in additional type-casts for the next ones // if necessary. Note that we use type-casts (... on ), but, outside of the first one, we could well also // use fragments with no type-condition. We do the former mostly to preverve older behavior, but doing the latter // would technically produce slightly small query plans. const { kind: name0, value: ifs0 } = context.conditionals[0]; let updatedSelection = wrapInFragment( new FragmentElement(wrappingType, wrappingType.name, [new Directive(name0, { 'if': ifs0 })]), initialSelection, ); for (let i = 1; i < context.conditionals.length; i++) { const { kind: name, value: ifs } = context.conditionals[i]; updatedSelection = wrapInFragment( new FragmentElement(wrappingType, wrappingType.name, [new Directive(name, { 'if': ifs })]), updatedSelection ); } return updatedSelection; } /** * If `maybePrefix` is a prefix of `basePath`, then return the path corresponding to the end of `basePath` after `maybePrefix` (which may * be empty if those are the same path). Otherwise, if `maybePrefix` is not a proper prefix, return `undefined`. */ function maybeSubstratPathPrefix(basePath: OperationPath, maybePrefix: OperationPath): OperationPath | undefined { if (maybePrefix.length <= basePath.length && sameOperationPaths(maybePrefix, basePath.slice(0, maybePrefix.length))) { return basePath.slice(maybePrefix.length); } return undefined; } function updateCreatedGroups(createdGroups: FetchGroup[], ...newCreatedGroups: FetchGroup[]) { for (const newGroup of newCreatedGroups) { if (!createdGroups.includes(newGroup)) { createdGroups.push(newGroup); } } } function computeGroupsForTree( { dependencyGraph, pathTree, startGroup, initialGroupPath, initialDeferContext, initialContext = emptyContext, initialContextsToConditionsGroups = new Map(), }: { dependencyGraph: FetchDependencyGraph, pathTree: OpPathTree, startGroup: FetchGroup, initialGroupPath: GroupPath, initialDeferContext: DeferContext, initialContext?: PathContext, initialContextsToConditionsGroups?: Map, }): FetchGroup[] { const stack: { tree: OpPathTree, group: FetchGroup, path: GroupPath, context: PathContext, deferContext: DeferContext, contextToConditionsGroups: Map, }[] = [{ tree: pathTree, group: startGroup, path: initialGroupPath, context: initialContext, deferContext: initialDeferContext, contextToConditionsGroups: initialContextsToConditionsGroups, }]; const createdGroups: FetchGroup[] = [ ]; while (stack.length > 0) { const { tree, group, path, context, deferContext, contextToConditionsGroups } = stack.pop()!; if (tree.localSelections) { for (const selection of tree.localSelections) { group.addAtPath(path.inGroup(), selection); dependencyGraph.deferTracking.updateSubselection(deferContext, selection); } } if (tree.isLeaf()) { group.addAtPath(path.inGroup()); dependencyGraph.deferTracking.updateSubselection(deferContext); } else { // We want to preserve the order of the elements in the child, but the stack will reverse everything, so we iterate // in reverse order to counter-balance it. for (const [edge, operation, conditions, child, contextToSelection, parameterToContext] of tree.childElements(true)) { if (isPathContext(operation)) { const newContext = operation; // The only 3 cases where we can take edge not "driven" by an operation is either when we resolve a key, resolve // a query (switch subgraphs because the query root type is the type of a field), or at the root of subgraph graph. // The latter case has already be handled the beginning of `computeFetchGroups` so only the 2 former remains. assert(edge !== null, () => `Unexpected 'null' edge with no trigger at ${path}`); if (edge.transition.kind === 'KeyResolution') { assert(conditions, () => `Key edge ${edge} should have some conditions paths`); // First, we need to ensure we fetch the conditions from the current group. const conditionsGroups = computeGroupsForTree({ dependencyGraph, pathTree: conditions, startGroup: group, initialGroupPath: path, initialDeferContext: deferContextForConditions(deferContext), }); updateCreatedGroups(createdGroups, ...conditionsGroups); // Then we can "take the edge", creating a new group. That group depends // on the condition ones. const sourceType = edge.head.type as CompositeType; // We shouldn't have a key on a non-composite type const destType = edge.tail.type as CompositeType; // We shouldn't have a key on a non-composite type const pathInParent = path.inGroup(); const updatedDeferContext = deferContextAfterSubgraphJump(deferContext); // Note that we use the name of `destType` for the inputs parent type, which can seem strange, but the reason is that we // 2 kind of cases: // - either sourceType == destType, which is the case for an object entity key, or for a key from an @interfaceObject // to an interface key. // - or sourceType !== destType, and that means the source is an implementation type X of some interface I, and // destType is an @interfaceObject corresponding to I. But in that case, using I as base for the inputs is a // bit more flexible as it ensure that if the query uses multiple such key for multiple implementations (so, // key from X to I, and then Y to I), then the same fetch is properly reused. Note that it is ok to do so // since 1) inputs are based on the supergraph schema, so I is going to exist there and 2) we wrap the input // selection properly against `sourceType` below anyway. const newGroup = dependencyGraph.getOrCreateKeyFetchGroup({ subgraphName: edge.tail.source, mergeAt: path.inResponse(), type: destType, parent: { group, path: pathInParent }, conditionsGroups, deferRef: updatedDeferContext.activeDeferRef, }); updateCreatedGroups(createdGroups, newGroup); newGroup.addParents(conditionsGroups.map((conditionGroup) => { // If `conditionGroup` parent is `group`, that is the same as `groupCopy` current parent, then we can infer the path of `groupCopy` into // that condition `group` by looking at the paths of each to their common parent. But otherwise, we cannot have a proper // "path in parent". const conditionGroupParents = conditionGroup.parents(); let path: OperationPath | undefined = undefined; if (conditionGroupParents.length === 1 && conditionGroupParents[0].group === group && conditionGroupParents[0].path) { path = maybeSubstratPathPrefix(conditionGroupParents[0].path, pathInParent); } return { group: conditionGroup, path }; })); // Note that inputs must be based on the supergraph schema, not any particular subgraph, since sometimes key conditions // are fetched from multiple subgraphs (and so no one subgraph has a type definition with all the proper fields, only // the supergraph does). const inputType = dependencyGraph.typeForFetchInputs(sourceType.name); const inputSelections = newCompositeTypeSelectionSet(inputType); inputSelections.updates().add(edge.conditions!); newGroup.addInputs( wrapInputsSelections(inputType, inputSelections.get(), newContext), computeInputRewritesOnKeyFetch(inputType.name, destType), ); // We also ensure to get the __typename of the current type in the "original" group. group.addAtPath(path.inGroup().concat(new Field(sourceType.typenameField()!))); stack.push({ tree: child, group: newGroup, path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, edge.tail.type as CompositeType, newContext)), context: newContext, deferContext: updatedDeferContext, contextToConditionsGroups, }); } else { assert(edge.transition.kind === 'RootTypeResolution', () => `Unexpected non-collecting edge ${edge}`); const rootKind = edge.transition.rootKind; assert(!conditions, () => `Root type resolution edge ${edge} should not have conditions`); assert(isObjectType(edge.head.type) && isObjectType(edge.tail.type), () => `Expected an objects for the vertices of ${edge}`); const type = edge.tail.type; assert(type === type.schema().schemaDefinition.rootType(rootKind), () => `Expected ${type} to be the root ${rootKind} type, but that is ${type.schema().schemaDefinition.rootType(rootKind)}`); // Usually, we get here because a field (say `q`) has query root type as type, and the field queried for that root // type is on another subgraph. When that happens, it means that on the original subgraph we may not have // added _any_ subselection for type `q` and that would make the query to the original subgraph invalid. // To avoid this, we request the __typename field. // One exception however is if we're at the "top" of the current group (`pathInGroup.length === 0`, which is a corner // case but can happen with @defer when everything in a query is deferred): in that case, there is no // point in adding __typename because if we don't add any other selection, the group will be empty // and we've rather detect that and remove the group entirely later. if (path.inGroup().length > 0) { group.addAtPath(path.inGroup().concat(new Field((edge.head.type as CompositeType).typenameField()!))); } // We take the edge, creating a new group. Note that we always create a new group because this // correspond to jumping subgraph after a field returned the query root type, and we want to // preserve this ordering somewhat (debatable, possibly). const updatedDeferContext = deferContextAfterSubgraphJump(deferContext); const newGroup = dependencyGraph.newRootTypeFetchGroup({ subgraphName: edge.tail.source, rootKind, parentType: type, mergeAt: path.inResponse(), deferRef: updatedDeferContext.activeDeferRef, }); newGroup.addParent({ group, path: path.inGroup() }); stack.push({ tree: child, group: newGroup, path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, type, newContext)), context: newContext, deferContext: updatedDeferContext, contextToConditionsGroups, }); } } else if (edge === null) { // A null edge means that the operation does nothing but may contain directives to preserve. // If it does contains directives, we look for @defer in particular. If we find it, this // means that we should change our current group to one for the defer in question. const { updatedOperation, updatedDeferContext } = extractDeferFromOperation({ dependencyGraph, operation, deferContext, path, }); // We're now removed any @defer. If the operation contains other directives or a non-trivial // type condition, we need to preserve it and so we add operation. Otherwise, we just skip it as a minor optimization (it makes the subgraph query // slighly smaller and on complex queries, it might also deduplicate similar selections). let newPath = path; if (updatedOperation && updatedOperation.appliedDirectives.length > 0) { newPath = path.add(updatedOperation) } stack.push({ tree: child, group, path: newPath, context, deferContext: updatedDeferContext, contextToConditionsGroups, }); } else { assert(edge.head.source === edge.tail.source, () => `Collecting edge ${edge} for ${operation} should not change the underlying subgraph`) // We have a operation element, field or inline fragment. We first check if it's been "tagged" to remember that __typename // must be queried. See the comment on the `optimizeSiblingTypenames()` method to see why this exists. const typenameAttachment = operation.getAttachment(SIBLING_TYPENAME_KEY); if (typenameAttachment !== undefined) { // We need to add the query __typename for the current type in the current group. // Note that the value of the "attachment" is the alias or '' if there is no alias const alias = typenameAttachment === '' ? undefined : typenameAttachment; const typenameField = new Field(operation.parentType.typenameField()!, undefined, undefined, alias); group.addAtPath(path.inGroup().concat(typenameField)); dependencyGraph.deferTracking.updateSubselection({ ...deferContext, pathToDeferParent: deferContext.pathToDeferParent.concat(typenameField), }); } const { updatedOperation, updatedDeferContext } = extractDeferFromOperation({ dependencyGraph, operation, deferContext, path, }); assert(updatedOperation, () => `Extracting @defer from ${operation} should not have resulted in no operation`); const updated = { tree: child, group, path, context, deferContext: updatedDeferContext, contextToConditionsGroups, }; if (conditions) { // add __typename to site where we are retrieving context from // this is necessary because the context rewrites path will start with a type condition let addTypenameAtPath; if (contextToSelection) { assert(isCompositeType(edge.head.type), () => `Expected a composite type for ${edge.head.type}`); addTypenameAtPath = { pathType: edge.head.type, }; } // We have @requires, an fake interface object downcast, or a @fromContext // to create groups for. const handleRequiresResult = handleRequiresTree( dependencyGraph, conditions, group, path, deferContext, addTypenameAtPath, ); updateCreatedGroups(createdGroups, ...handleRequiresResult.createdGroups); if (contextToSelection) { const newContextToConditionsGroups = new Map([...contextToConditionsGroups]); for (const context of contextToSelection) { newContextToConditionsGroups.set(context, [handleRequiresResult.conditionsMergeGroup, ...handleRequiresResult.createdGroups]); } updated.contextToConditionsGroups = newContextToConditionsGroups; } if (edge.conditions) { // This edge needs the conditions just fetched, to be provided via // _entities (@requires or fake interface object downcast). So we // create the post-@requires group, adding the subgraph jump (if // needed). const createPostRequiresResult = createPostRequiresGroup( dependencyGraph, edge, group, path, context, handleRequiresResult, ); updated.group = createPostRequiresResult.group; updated.path = createPostRequiresResult.path; updateCreatedGroups(createdGroups, ...createPostRequiresResult.createdGroups); } } // if we're going to start using context variables, every variable used must be set in a different parent // fetch group or else we need to create a new one if (parameterToContext && Array.from(parameterToContext.values()).some(({ contextId }) => updated.contextToConditionsGroups.get(contextId)?.[0] === updated.group)) { assert(group === updated.group, "Group created by @requires handling shouldn't have set context already"); // All groups that get the contextual variable should be parents of this group const conditionGroups: Set = new Set(); for (const { contextId } of parameterToContext.values()) { const groups = updated.contextToConditionsGroups.get(contextId); assert(groups, () => `Could not find groups for context ${contextId}`); for (const conditionGroup of groups) { conditionGroups.add(conditionGroup); } } assert(isCompositeType(edge.head.type), () => `Expected a composite type for ${edge.head.type}`); updated.group = dependencyGraph.getOrCreateKeyFetchGroup({ subgraphName: edge.head.source, mergeAt: updated.path.inResponse(), type: edge.head.type, parent: { group: group, path: path.inGroup() }, conditionsGroups: [...conditionGroups], }); updateCreatedGroups(createdGroups, updated.group); updated.path = path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, edge.head.type, context)); const keyCondition = getLocallySatisfiableKey(dependencyGraph.federatedQueryGraph, edge.head); assert(keyCondition, () => `canSatisfyConditions() validation should have required a key to be present for ${edge}`); const keyInputs = newCompositeTypeSelectionSet(edge.head.type); keyInputs.updates().add(keyCondition); group.addAtPath(path.inGroup(), keyInputs.get()); const inputType = dependencyGraph.typeForFetchInputs(edge.head.type.name); const inputSelectionSet = newCompositeTypeSelectionSet(inputType); inputSelectionSet.updates().add(keyCondition); const inputs = wrapInputsSelections(inputType, inputSelectionSet.get(), context); updated.group.addInputs( inputs, computeInputRewritesOnKeyFetch(edge.head.type.name, edge.head.type), ); // Add the condition groups as parent groups. for (const parentGroup of conditionGroups) { updated.group.addParent({ group: parentGroup }); } // Add context renamers. for (const [_, { contextId, selectionSet, relativePath, subgraphArgType }] of parameterToContext) { updated.group.addInputContext(contextId, subgraphArgType); const keyRenamers = selectionSetAsKeyRenamers(selectionSet, relativePath, contextId); for (const keyRenamer of keyRenamers) { updated.group.addContextRenamer(keyRenamer); } } } else { // in this case we can just continue with the current group, but we need to add the context rewrites if (parameterToContext) { const numFields = updated.path.inGroup().filter((e) => e.kind === 'Field').length; for (const [_, { selectionSet, relativePath, contextId, subgraphArgType }] of parameterToContext) { const newRelativePath = relativePath.slice(0, relativePath.length - numFields); updated.group.addInputContext(contextId, subgraphArgType); const keyRenamers = selectionSetAsKeyRenamers(selectionSet, newRelativePath, contextId); for (const keyRenamer of keyRenamers) { updated.group.addContextRenamer(keyRenamer); } } } } if (updatedOperation.kind === 'Field' && updatedOperation.name === typenameFieldName) { // Because of the optimization done in `QueryPlanner.optimizeSiblingTypenames`, we will rarely get an explicit `__typename` // edge here. But one case where it can happen is where an @interfaceObject was involved, and we had to force jumping to // another subgraph for getting the "true" `__typename`. However, this case can sometimes lead to fetch group that only // exists for that `__typename` resolution and that "look" useless. That, we could have a fetch group that looks like: // Fetch(service: "Subgraph2") { // { // ... on I { // __typename // id // } // } => // { // ... on I { // __typename // } // } // } // but the trick is that the `__typename` in the input will be the name of the interface itself (`I` in this case) // but the one return after the fetch will the name of the actual implementation (some implementation of `I`). // *But* we later have optimizations that would remove such a group, on the group that the output is included // in the input, which is in general the right thing to do (and genuinely ensure that some useless groups created when // handling complex @require gets eliminated). So we "protect" the group in this case to ensure that later // optimization doesn't kick in in this case. updated.group.mustPreserveSelection = true } if (edge.transition.kind === 'InterfaceObjectFakeDownCast') { // We shouldn't add the operation "as is" as it's a down-cast but we're "faking it". However, // if the operation has directives, we should preserve that. assert(updatedOperation.kind === 'FragmentElement', () => `Unexpected operation ${updatedOperation} for edge ${edge}`); if (updatedOperation.appliedDirectives.length > 0) { // We want to keep the directives, but we clear the condition since it's to a type that doesn't exists in the // subgraph we're currently in. updated.path = updated.path.add(updatedOperation.withUpdatedCondition(undefined)); } } else { updated.path = updated.path.add(updatedOperation); } stack.push(updated); } } } } return createdGroups; } function computeInputRewritesOnKeyFetch(inputTypeName: string, destType: CompositeType): FetchDataRewrite[] | undefined { // When we send a fetch to a subgraph, the inputs __typename must essentially match `destType` so the proper __resolveReference // is called. If `destType` is a "normal" object type, that's going to be fine by default, but if `destType` is an interface // in the supergraph (meaning that it is either an interface or an interface object), then the underlying object might have // a __typename that is the concrete implementation type of the object, and we need to rewrite it. if (isInterfaceObjectType(destType) || isInterfaceType(destType)) { return [{ kind: 'ValueSetter', path: [ `... on ${inputTypeName}`, typenameFieldName ], setValueTo: destType.name, }]; } return undefined; } function extractDeferFromOperation({ dependencyGraph, operation, deferContext, path, }: { dependencyGraph: FetchDependencyGraph, operation: OperationElement, deferContext: DeferContext, path: GroupPath, }): { updatedOperation: OperationElement | undefined, updatedDeferContext: DeferContext, }{ const deferArgs = operation.deferDirectiveArgs(); if (!deferArgs) { return { updatedOperation: operation, updatedDeferContext: { ...deferContext, pathToDeferParent: deferContext.pathToDeferParent.concat(operation), } }; } assert(deferArgs.label, 'All defers should have a lalel at this point'); const updatedDeferRef = deferArgs.label; const updatedOperation = operation.withoutDefer(); const updatedPathToDeferParent = updatedOperation ? [ updatedOperation ] : []; dependencyGraph.deferTracking.registerDefer({ deferContext, deferArgs, path, parentType: operation.parentType, }); return { updatedOperation, updatedDeferContext: { ...deferContext, currentDeferRef: updatedDeferRef, pathToDeferParent: updatedPathToDeferParent, }, }; } function subselectionTypeIfAbstract(selection: Selection): AbstractType | undefined { if (selection.kind === 'FieldSelection') { const fieldBaseType = baseType(selection.element.definition.type!); return isAbstractType(fieldBaseType) ? fieldBaseType : undefined; } else { const conditionType = selection.element.typeCondition; return conditionType && isAbstractType(conditionType) ? conditionType : undefined; } } function addTypenameFieldForAbstractTypes(selectionSet: SelectionSet, parentTypeIfAbstract?: AbstractType): SelectionSet { const handleSelection = (selection: Selection): Selection => { if (!selection.selectionSet) { return selection; } const typeIfAbstract = subselectionTypeIfAbstract(selection); const updatedSelectionSet = addTypenameFieldForAbstractTypes(selection.selectionSet, typeIfAbstract); if (updatedSelectionSet === selection.selectionSet) { return selection; } else { return selection.withUpdatedSelectionSet(updatedSelectionSet); } } if (!parentTypeIfAbstract || selectionSet.hasTopLevelTypenameField()) { return selectionSet.lazyMap((selection) => handleSelection(selection)); } const updates = new SelectionSetUpdates(); updates.add(new FieldSelection(new Field(parentTypeIfAbstract.typenameField()!))); selectionSet.selections().forEach((selection) => updates.add(handleSelection(selection))) return updates.toSelectionSet(selectionSet.parentType); } function addBackTypenameInAttachments(selectionSet: SelectionSet): SelectionSet { return selectionSet.lazyMap((s) => { const updated = s.mapToSelectionSet((ss) => addBackTypenameInAttachments(ss)); const typenameAttachment = s.element.getAttachment(SIBLING_TYPENAME_KEY); if (typenameAttachment === undefined) { return updated; } else { // We need to add the query __typename for the current type in the current group. // Note that the value of the "attachment" is the alias or '' if there is no alias const alias = typenameAttachment === '' ? undefined : typenameAttachment; const typenameField = new Field(s.element.parentType.typenameField()!, undefined, undefined, alias); return [ selectionOfElement(typenameField), updated, ]; } }); } function pathHasOnlyFragments(path: OperationPath): boolean { return path.every((element) => element.kind === 'FragmentElement'); } function typeAtPath(parentType: CompositeType, path: OperationPath): CompositeType { let type = parentType; for (const element of path) { if (element.kind === 'Field') { const fieldType = baseType(type.field(element.name)!.type!); assert(isCompositeType(fieldType), () => `Invalid call fro ${path} starting at ${parentType}: ${element.definition.coordinate} is not composite`); type = fieldType; } else if (element.typeCondition) { const rebasedType = parentType.schema().type(element.typeCondition.name); assert(rebasedType && isCompositeType(rebasedType), () => `Type condition of ${element} should be composite`); type = rebasedType; } } return type; } function createPostRequiresGroup( dependencyGraph: FetchDependencyGraph, edge: Edge, group: FetchGroup, path: GroupPath, context: PathContext, result: { fullyLocalRequires: boolean, createdGroups: FetchGroup[], requiresParent: ParentRelation | undefined, } ): { group: FetchGroup, path: GroupPath, createdGroups: FetchGroup[], } { const { fullyLocalRequires, createdGroups } = result; const entityType = edge.head.type as ObjectType; // if we never created any groups, all conditions were local if (fullyLocalRequires) { return { group, path, ...result }; } const parents = group.parents(); const triedToMerge = parents.length === 1 && pathHasOnlyFragments(path.inGroup()); if (triedToMerge) { const parent = parents[0]; if (createdGroups.length === 0) { group.addInputs( inputsForRequire( dependencyGraph, entityType, edge, context, false).inputs); return { group, path, createdGroups: [] }; } // If we get here, it means that @requires needs the information from `createdGroups` (plus whatever has // been merged before) _and_ that relies on some information from the current `group` (if it hadn't, we // would have been able to merge `groupCopy` to `group`'s parent). So the group we should return, which // is the group where the "post-@require" fields will be added, needs to a be a new group that depends // on all those `createdGroups`. const postRequireGroup = dependencyGraph.newKeyFetchGroup({ subgraphName: group.subgraphName, mergeAt: group.mergeAt!, deferRef: group.deferRef }); // Note that `postRequireGroup` cannot generally be merged into any of the `createdGroups`, so we don't provide a `path`. postRequireGroup.addParents(createdGroups.map((group) => ({ group }))); // That group also needs, in general, to depend on the current `group`. That said, if we detected that the @requires // didn't need anything of said `group` (if `newGroupIsUnneeded`), then we can depend on the parent instead. if (result.requiresParent) { postRequireGroup.addParent(result.requiresParent); } // Note(Sylvain): I'm not 100% sure about this assert in the sense that while I cannot think of a case where `parent.path` wouldn't // exist, the code paths are complex enough that I'm not able to prove this easily and could easily be missing something. That said, // we need the path here, so this will have to do for now, and if this ever breaks in practice, we'll at least have an example to // guide us toward improving/fixing. assert(parent.path, `Missing path-in-parent for @requires on ${edge} with group ${group} and parent ${parent}`); let requirePath = path.forParentOfGroup(parent.path, parent.group.parentType.schema()); let preRequireGroup = parent.group; // The `postRequireGroup` needs a key. This can come from `group` (and code // in `canSatisfyConditions()` guarantees such a locally satisfiable key // exists in `group`), but it can also potentially come from `parent.group`, // and previous code had (wrongfully) always assumed it could. // // To keep this previous optimization, we now explicitly check whether the // known locally satisfiable key can be rebased in `parent.group`, and we // fall back to `group` if it doesn't. const keyCondition = getLocallySatisfiableKey(dependencyGraph.federatedQueryGraph, edge.head); assert(keyCondition, () => `Due to @requires, validation should have required a key to be present for ${edge}`); if (!keyCondition.canRebaseOn(typeAtPath(preRequireGroup.selection.parentType, requirePath.inGroup()))) { requirePath = path; preRequireGroup = group; // It's possible we didn't add `group` as a parent previously, so we do so // here similarly to how `handleRequiresTree()` specifies it. postRequireGroup.addParent({ group, path: [] }); } addPostRequireInputs( dependencyGraph, requirePath, entityType, edge, context, preRequireGroup, postRequireGroup, ); return { group: postRequireGroup, path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, entityType, context)), createdGroups: [postRequireGroup], }; } else { const postRequireGroup = dependencyGraph.newKeyFetchGroup({ subgraphName: group.subgraphName, mergeAt: path.inResponse(), }); postRequireGroup.addParents( createdGroups.map((group) => ({ group, // Usually, computing the path of our new group into the created groups // is not entirely trivial, but there is at least the relatively common // case where the 2 groups we look at have: // 1) the same `mergeAt`, and // 2) the same parentType; in that case, we can basically infer those 2 // groups apply at the same "place" and so the "path in parent" is // empty. TODO: it should probably be possible to generalize this by // checking the `mergeAt` plus analyzing the selection but that // warrants some reflection... path: sameMergeAt(group.mergeAt, postRequireGroup.mergeAt) && sameType(group.parentType, postRequireGroup.parentType) ? [] : undefined, })), ); addPostRequireInputs( dependencyGraph, path, entityType, edge, context, group, postRequireGroup, ); return { group: postRequireGroup, path: path.forNewKeyFetch(createFetchInitialPath(dependencyGraph.supergraphSchema, entityType, context)), createdGroups: [postRequireGroup], }; } } function handleRequiresTree( dependencyGraph: FetchDependencyGraph, requiresConditions: OpPathTree, group: FetchGroup, path: GroupPath, deferContext: DeferContext, addTypenameAtPath: { pathType: CompositeType, } | undefined, ): { fullyLocalRequires: boolean, createdGroups: FetchGroup[], requiresParent: ParentRelation | undefined, conditionsMergeGroup: FetchGroup, } { // In many case, we can optimize requires by merging the requirement to previously existing groups. However, // we only do this when the current group has only a single parent (it's hard to reason about it otherwise). // But the current could have multiple parents due to the graph lacking minimimality, and we don't want that // to needlessly prevent us from this optimization. So we do a graph reduction first (which effectively // just eliminate unecessary edges). To illustrate, we could be in a case like: // 1 // / \ // 0 --- 2 // with current group 2. And while the group currently has 2 parents, the `reduce` step will ensure // the edge `0 --- 2` is removed (since the dependency of 2 on 0 is already provide transitively through 1). dependencyGraph.reduce(); const parents = group.parents(); // In general, we should do like for an edge, and create a new group _for the current subgraph_ // that depends on the createdGroups and have the created groups depend on the current one. // However, we can be more efficient in general (and this is expected by the user) because // required fields will usually come just after a key edge (at the top of a fetch group). // In that case (when the path is only typeCasts), we can put the created groups directly // as dependency of the current group, avoiding to create a new one. Additionally, if the // group we're coming from is our "direct parent", we can merge it to said direct parent (which // effectively means that the parent group will collect the provides before taking the edge // to our current group). let groupCopy : FetchGroup | undefined; if (parents.length === 1 && pathHasOnlyFragments(path.inGroup())) { const parent = parents[0]; // We start by computing the groups for the conditions. We do this using a copy of the current // group (with only the inputs) as that allows to modify this copy without modifying `group`. groupCopy = dependencyGraph.newKeyFetchGroup({ subgraphName: group.subgraphName, mergeAt: group.mergeAt!, deferRef: group.deferRef }); groupCopy.addParent(parent); groupCopy.copyInputsOf(group); if (addTypenameAtPath) { groupCopy.addAtPath(path.inGroup().concat(new Field(addTypenameAtPath.pathType.typenameField()!))); } } else { if (addTypenameAtPath) { group.addAtPath(path.inGroup().concat(new Field(addTypenameAtPath.pathType.typenameField()!))); } } // Call computeGroupsForTree on the requiresConditions. The start group will either be group or the copy of group if we // expect to be able to optimize const createdGroups = computeGroupsForTree({ dependencyGraph, pathTree: requiresConditions, startGroup: groupCopy ?? group, initialGroupPath: path, initialDeferContext: deferContextForConditions(deferContext) }); if (createdGroups.length == 0) { // All conditions were local. If we created a copy of the current group, merge it back in if (groupCopy) { assert(group.canMergeSiblingIn(groupCopy), () => `We should be able to merge ${groupCopy} into ${group} by construction`); group.mergeSiblingIn(groupCopy); } return { fullyLocalRequires: true, createdGroups: [], requiresParent: undefined, conditionsMergeGroup: group }; } if (groupCopy) { const parent = groupCopy.parents()[0]; // We know the @require needs createdGroups. We do want to know however if any of the conditions was // fetched from our `groupCopy`. If not, then this means that the `createdGroups` don't really depend on // the current `group` and can be dependencies of the parent (or even merged into this parent). // // So we want to know if anything in `groupCopy` selection cannot be fetched directly from the parent. // For that, we first remove any of `groupCopy` inputs from its selection: in most case, `groupCopy` // will just contain the key needed to jump back to its parent, and those would usually be the same // as the inputs. And since by definition we know `groupCopy`'s inputs are already fetched, we // know they are not things that we need. Then, we check if what remains (often empty) can be // directly fetched from the parent. If it can, then we can just merge `groupCopy` into that parent. // Otherwise, we will have to "keep it". // Note: it is to be sure this test is not poluted by other things in `group` that we created `groupCopy`. groupCopy.removeInputsFromSelection(); const newGroupIsUnneeded = parent.path && groupCopy.selection.canRebaseOn(typeAtPath(parent.group.selection.parentType, parent.path)); const unmergedGroups = []; if (newGroupIsUnneeded) { // Up to this point, `groupCopy` had no parent, so let's first merge `groupCopy` to the parent, thus "rooting" // its children to it. Note that we just checked that `groupCopy` selection was just its inputs, so // we know that merging it to the parent is mostly a no-op from that POV, except maybe for requesting // a few additional `__typename` we didn't before (due to the exclusion of `__typename` in the `newGroupIsUnneeded` check) parent.group.mergeChildIn(groupCopy); // Now, all created groups are going to be descendant of `parentGroup`. But some of them may actually be // mergeable into it. for (const created of createdGroups) { // Note that `created` may not be a direct child of `parent.group`, but `canMergeChildIn` just return `false` in // that case, yielding the behaviour we want (not trying to merge it in). if (created.subgraphName === parent.group.subgraphName && parent.group.canMergeChildIn(created)) { parent.group.mergeChildIn(created); } else { unmergedGroups.push(created); // `created` cannot be merged into `parent.group`, which may typically be because they are not to the same // subgraph. However, while `created` currently depend on `parent.group` (directly or indirectly), that // dependency just comes from the fact that `parent.group` is the parent of the group whose @require we're // dealing with. And in practice, it could well be that some of the fetches needed for that require don't // really depend on anything that parent fetches and could be done in parallel with it. If we detect that // this is the case for `created`, we can move it "up the chain of dependency". let currentParent: ParentRelation | undefined = parent; while (currentParent && !currentParent.group.isTopLevel && created.isChildOfWithArtificialDependency(currentParent.group) ) { currentParent.group.removeChild(created); const grandParents = currentParent.group.parents(); assert(grandParents.length > 0, `${currentParent.group} is not top-level, so it should have parents`); for (const grandParent of grandParents) { created.addParent({ group: grandParent.group, path: concatPathsInParents(grandParent.path, currentParent.path), }); } // If we have more that 1 "grand parent", let's stop there as it would get more complicated // and that's probably not needed. Otherwise, we can check if `created` may be able to move even // further up. currentParent = grandParents.length === 1 ? grandParents[0] : undefined; } } } } else { // We cannot merge `groupCopy` to the parent, either because there it fetches some things necessary to the // @require, or because we had more than one parent and don't know how to handle this (unsure if the later // can actually happen at this point tbh (?)). Bu not reason not to merge `groupCopy` back to `group` so // we do that first. assert(group.canMergeSiblingIn(groupCopy), () => `We should be able to merge ${groupCopy} into ${group} by construction`); group.mergeSiblingIn(groupCopy); // The created group depends on `group` and the dependency cannot be moved to the parent in // this case. However, we might still be able to merge some created group directly in the // parent. But for this to be true, we should essentially make sure that the dependency // on `group` is not a "true" dependency. That is, if the created group inputs are the same // as `group` inputs (and said created group is the same subgraph than the parent of // `group`, then it means we're only depending on values that are already in the parent and // can merge the group). if (parent.path) { for (const created of createdGroups) { if (created.subgraphName === parent.group.subgraphName && parent.group.canMergeGrandChildIn(created) && sameMergeAt(created.mergeAt, group.mergeAt) && group.inputs!.contains(created.inputs!) ) { parent.group.mergeGrandChildIn(created); } else { unmergedGroups.push(created); } } } } return { fullyLocalRequires: false, createdGroups: unmergedGroups, requiresParent: newGroupIsUnneeded ? parent : { group, path: [] }, conditionsMergeGroup: newGroupIsUnneeded ? parent.group : group, }; } else { return { fullyLocalRequires: false, createdGroups, requiresParent: undefined, conditionsMergeGroup: group, }; } } function addPostRequireInputs( dependencyGraph: FetchDependencyGraph, requirePath: GroupPath, entityType: ObjectType, edge: Edge, context: PathContext, preRequireGroup: FetchGroup, postRequireGroup: FetchGroup, ) { const { inputs, keyInputs } = inputsForRequire(dependencyGraph, entityType, edge, context); // Note that `computeInputRewritesOnKeyFetch` will return `undefined` in general, but if `entityType` is an interface/interface object, // then we need those rewrites to ensure the underlying fetch is valid. postRequireGroup.addInputs( inputs, computeInputRewritesOnKeyFetch(entityType.name, entityType) ); if (keyInputs) { // It could be the key used to resume fetching after the @require is already fetched in the original group, but we cannot // guarantee it, so we add it now (and if it was already selected, this is a no-op). preRequireGroup.addAtPath(requirePath.inGroup(), keyInputs.selections()); } } function newCompositeTypeSelectionSet(type: CompositeType): MutableSelectionSet { const selectionSet = MutableSelectionSet.empty(type); selectionSet.updates().add(new FieldSelection(new Field(type.typenameField()!))); return selectionSet; } function inputsForRequire( dependencyGraph: FetchDependencyGraph, entityType: ObjectType, edge: Edge, context: PathContext, includeKeyInputs: boolean = true ): { inputs: SelectionSet, keyInputs: SelectionSet | undefined, }{ // This method is actually called for to handle conditions of @requires, but also to fetch `__typename` in the // case of "fake downcast on an @interfaceObject". In that later case, once we're fetched that `__typename`, // we want to wrap the input into the "downcasted" type, not the @interfaceObject one, so that we don't end // up querying some fields in the @interfaceObject subgraph for entities that we know won't match a type // condition of the query. const isInterfaceObjectDownCast = edge.transition.kind === 'InterfaceObjectFakeDownCast'; const inputTypeName = isInterfaceObjectDownCast ? edge.transition.castedTypeName : entityType.name; const inputType = dependencyGraph.supergraphSchema.type(inputTypeName); assert(inputType && isCompositeType(inputType), () => `Type ${inputTypeName} should exist in the supergraph and be a composite type`); const fullSelectionSet = newCompositeTypeSelectionSet(inputType); if (edge.conditions) { fullSelectionSet.updates().add(edge.conditions); } let keyInputs: MutableSelectionSet | undefined = undefined; if (includeKeyInputs) { const keyCondition = getLocallySatisfiableKey(dependencyGraph.federatedQueryGraph, edge.head); assert(keyCondition, () => `Due to @require, validation should have required a key to be present for ${edge}`); let keyConditionAsInput = keyCondition; if (isInterfaceObjectDownCast) { // This means that conditions parents are on the @interfaceObject type, but we actually want to select only the // `inputTypeName` implementation, the `mergeIn` below will try to add fields from the interface to one of the // implementationt type. Which `mergeIn` usually let us do as that's safe, but because `keyCondition` are on // the @interfaceObject subgraph, the type there is not an interface. To work around this, we "rebase" the // condition on the supergraph type (which is an interface) first, which lets the `mergeIn` work. const supergraphItfType = dependencyGraph.supergraphSchema.type(entityType.name); assert(supergraphItfType && isInterfaceType(supergraphItfType), () => `Type ${entityType} should be an interface in the supergraph`); // Note: we are rebasing on another schema below, but we also known that we're working on a full expanded // selection set (no spread), so passing undefined is actually correct. keyConditionAsInput = keyConditionAsInput.rebaseOn({ parentType: supergraphItfType, fragments: undefined, errorIfCannotRebase: true }); } fullSelectionSet.updates().add(keyConditionAsInput); // Note that `keyInputs` are used to ensure those input are fetch on the original group, the one having `edge`. In // the case of an @interfaceObject downcast, that's the subgraph with said @interfaceObject, so in that case we // should just use `entityType` (that @interfaceObject type), not input type which will be an implementation the // subgraph does not know in that particular case. keyInputs = newCompositeTypeSelectionSet(entityType); keyInputs.updates().add(keyCondition); } return { inputs: wrapInputsSelections(inputType, fullSelectionSet.get(), context), keyInputs: keyInputs?.get(), }; } const representationsVariable = new Variable('representations'); function representationsVariableDefinition(schema: Schema): VariableDefinition { const metadata = federationMetadata(schema); assert(metadata, 'Expected schema to be a federation subgraph') const representationsType = new NonNullType(new ListType(new NonNullType(metadata.anyType()))); return new VariableDefinition(schema, representationsVariable, representationsType); } // Collects all variables used in the operation to be created. // - It's computed based on its selection set and directives. function collectUsedVariables(selectionSet: SelectionSet, operationDirectives?: readonly Directive[]) { const collector = new VariableCollector(); selectionSet.collectVariables(collector); if (operationDirectives) { for (const applied of operationDirectives) { collector.collectInArguments(applied.arguments()); } } return collector.variables(); } function operationForEntitiesFetch( subgraphSchema: Schema, selectionSet: SelectionSet, allVariableDefinitions: VariableDefinitions, operationName?: string, directives?: readonly Directive[], ): Operation { const variableDefinitions = new VariableDefinitions(); variableDefinitions.add(representationsVariableDefinition(subgraphSchema)); variableDefinitions.addAll( allVariableDefinitions.filter(collectUsedVariables(selectionSet, directives)), ); const queryType = subgraphSchema.schemaDefinition.rootType('query'); assert( queryType, `Subgraphs should always have a query root (they should at least provides _entities)`, ); const entities = queryType.field(entitiesFieldName); assert(entities, `Subgraphs should always have the _entities field`); const entitiesCall = selectionSetOfElement( new Field( entities, { representations: representationsVariable }, ), selectionSet, ); // Note that this is called _before_ named fragments reuse is attempted, so there is not spread in // the selection, hence the `undefined` for fragments. return new Operation(subgraphSchema, 'query', entitiesCall, variableDefinitions, undefined, operationName, directives); } function operationForQueryFetch( subgraphSchema: Schema, rootKind: SchemaRootKind, selectionSet: SelectionSet, allVariableDefinitions: VariableDefinitions, operationName?: string, directives?: readonly Directive[], ): Operation { // Note that this is called _before_ named fragments reuse is attempted, so there is not spread in // the selection, hence the `undefined` for fragments. return new Operation(subgraphSchema, rootKind, selectionSet, allVariableDefinitions.filter(collectUsedVariables(selectionSet, directives)), /*fragments*/undefined, operationName, directives); } const sameKeyRenamer = (k1: FetchDataKeyRenamer, k2: FetchDataKeyRenamer): boolean => { if (k1.renameKeyTo !== k2.renameKeyTo || k1.path.length !== k2.path.length) { return false; } for (let i = 0; i < k1.path.length; i++) { if (k1.path[i] !== k2.path[i]) { return false; } } return true; }