diff --git a/.changeset/olive-papayas-act.md b/.changeset/olive-papayas-act.md new file mode 100644 index 000000000..6a845e28e --- /dev/null +++ b/.changeset/olive-papayas-act.md @@ -0,0 +1,9 @@ +--- +"@apollo/query-planner": patch +--- + +Type conditioned fetching + +When querying a field that is in a path of 2 or more unions, the query planner was not able to handle different selections and would aggressively collapse selections in fetches yielding an incorrect plan. + +This change introduces new syntax to express type conditions in (key and flatten) paths. Type conditioned fetching can be enabled through a flag, and execution is supported in the router only. (#2938) diff --git a/gateway-js/src/index.ts b/gateway-js/src/index.ts index 4254947db..786614356 100644 --- a/gateway-js/src/index.ts +++ b/gateway-js/src/index.ts @@ -57,6 +57,7 @@ import { LocalCompose, } from './supergraphManagers'; import { + assert, operationFromDocument, Schema, ServiceDefinition, @@ -186,7 +187,7 @@ export class ApolloGateway implements GatewayInterface { this.pollIntervalInMs = this.config?.pollIntervalInMs; } - this.issueConfigurationWarningsIfApplicable(); + this.validateConfigAndEmitWarnings(); this.logger.debug('Gateway successfully initialized (but not yet loaded)'); this.state = { phase: 'initialized' }; @@ -213,7 +214,9 @@ export class ApolloGateway implements GatewayInterface { }); } - private issueConfigurationWarningsIfApplicable() { + private validateConfigAndEmitWarnings() { + assert(!this.config.queryPlannerConfig?.typeConditionedFetching, "Type conditions are not supported in the gateway"); + // Warn against using the pollInterval and a serviceList simultaneously // TODO(trevor:removeServiceList) if (this.pollIntervalInMs && isServiceListConfig(this.config)) { @@ -568,6 +571,7 @@ export class ApolloGateway implements GatewayInterface { this.queryPlanStore.clear(); this.apiSchema = supergraph.apiSchema(); this.schema = addExtensions(this.apiSchema.toGraphQLJSSchema()); + this.queryPlanner = new QueryPlanner(supergraph, this.config.queryPlannerConfig); // Notify onSchemaChange listeners of the updated schema diff --git a/query-planner-js/src/__tests__/typeConditions.test.ts b/query-planner-js/src/__tests__/typeConditions.test.ts new file mode 100644 index 000000000..7908bf6b3 --- /dev/null +++ b/query-planner-js/src/__tests__/typeConditions.test.ts @@ -0,0 +1,763 @@ +import { operationFromDocument } from '@apollo/federation-internals'; +import gql from 'graphql-tag'; +import { + composeAndCreatePlanner, + composeAndCreatePlannerWithOptions, +} from './testHelper'; + +describe('Type Condition field merging', () => { + const subgraph1 = { + name: 's1', + typeDefs: gql` + type Query { + f1: [U1] + } + + union U1 = T1 | T2 + union U2 = T3 | T4 + + type T1 @key(fields: "id") { + id: ID! + f2: [U2] + } + + type T2 @key(fields: "id") { + id: ID! + f2: [U2] + } + + type T3 @key(fields: "id") { + id: ID! + } + + type T4 @key(fields: "id") { + id: ID! + } + `, + }; + + const subgraph2 = { + name: 's2', + typeDefs: gql` + type Query { + me: String + } + + type T3 @key(fields: "id") { + id: ID! + f2: String + f3(params: String): String + } + + type T4 @key(fields: "id") { + id: ID! + f3(params: String): String + } + `, + }; + + const [api, queryPlanner] = composeAndCreatePlanner(subgraph1, subgraph2); + const [_, queryPlannerWithTypeConditionedFetching] = + composeAndCreatePlannerWithOptions( + [subgraph1, subgraph2], + { typeConditionedFetching: true }, + false, + ); + + test('does eagerly merge fields on different type conditions if flag is absent', () => { + const operation = operationFromDocument( + api, + gql` + query f1($p1: String, $p2: String) { + f1 { + __typename + ... on T1 { + id + f2 { + ... on T3 { + id + f3(params: $p1) + } + } + } + ... on T2 { + id + f2 { + ... on T3 { + id + f3(params: $p2) + f2 + } + } + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "s1") { + { + f1 { + __typename + ... on T1 { + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + } + ... on T2 { + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + } + } + } + }, + Flatten(path: "f1.@.f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + } => + { + ... on T3 { + f3(params: $p1) + f2 + } + } + }, + }, + }, + } + `); + }); + + test('does not eagerly merge fields on different type conditions if flag is present', () => { + const operation = operationFromDocument( + api, + gql` + query f1($p1: String, $p2: String) { + f1 { + ... on T1 { + f2 { + ... on T3 { + id + f2 + f3(params: $p1) + } + ... on T4 { + f3(params: $p1) + id + } + } + id + } + ... on T2 { + id + f2 { + ... on T4 { + f3(params: $p2) + } + ... on T3 { + f3(params: $p2) + } + } + } + } + } + `, + ); + + const plan = + queryPlannerWithTypeConditionedFetching.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "s1") { + { + f1 { + __typename + ... on T1 { + f2 { + __typename + ... on T3 { + __typename + id + } + ... on T4 { + __typename + id + } + } + id + } + ... on T2 { + id + f2 { + __typename + ... on T4 { + __typename + id + } + ... on T3 { + __typename + id + } + } + } + } + } + }, + Parallel { + Flatten(path: ".f1.@|[T1].f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + ... on T4 { + __typename + id + } + } => + { + ... on T3 { + f2 + f3(params: $p1) + } + ... on T4 { + f3(params: $p1) + } + } + }, + }, + Flatten(path: ".f1.@|[T2].f2.@") { + Fetch(service: "s2") { + { + ... on T4 { + __typename + id + } + ... on T3 { + __typename + id + } + } => + { + ... on T4 { + f3(params: $p2) + } + ... on T3 { + f3(params: $p2) + } + } + }, + }, + }, + }, + } + `); + }); + + const subgraph3 = { + name: 's1', + typeDefs: gql` + type Query { + f1: [I1] + } + + union U1 = T3 | T4 + + interface I1 @key(fields: "id") { + id: ID! + f2: [U1] + } + + interface I2 implements I1 @key(fields: "id") { + id: ID! + f2: [U1] + } + + type T1 implements I2 & I1 @key(fields: "id") { + id: ID! + f2: [U1] + } + + type T5 implements I2 & I1 @key(fields: "id") { + id: ID! + f2: [U1] + } + + type T2 implements I1 @key(fields: "id") { + id: ID! + f2: [U1] + } + + type T3 @key(fields: "id") { + id: ID! + } + + type T4 @key(fields: "id") { + id: ID! + } + `, + }; + + test('does not eagerly merge fields on different type conditions if flag is present with interface', () => { + const [api2, queryPlanner2] = composeAndCreatePlannerWithOptions( + [subgraph3, subgraph2], + { typeConditionedFetching: true }, + false, + ); + + const operation = operationFromDocument( + api2, + gql` + query f1($p1: String, $p2: String) { + f1 { + __typename + ... on T1 { + id + f2 { + ... on T3 { + id + f3(params: $p1) + } + } + } + ... on T2 { + id + f2 { + ... on T3 { + id + f3(params: $p2) + f2 + } + } + } + } + } + `, + ); + + const plan = queryPlanner2.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "s1") { + { + f1 { + __typename + ... on T1 { + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + } + ... on T2 { + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + } + } + } + }, + Parallel { + Flatten(path: ".f1.@|[T1].f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + } => + { + ... on T3 { + f3(params: $p1) + } + } + }, + }, + Flatten(path: ".f1.@|[T2].f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + } => + { + ... on T3 { + f3(params: $p2) + f2 + } + } + }, + }, + }, + }, + } + `); + }); + + test('does generate type conditions with interface fragment', () => { + const [api2, queryPlanner2] = composeAndCreatePlannerWithOptions( + [subgraph3, subgraph2], + { typeConditionedFetching: true }, + ); + + const operation = operationFromDocument( + api2, + gql` + query f1($p1: String, $p2: String) { + f1 { + __typename + ... on I1 { + id + f2 { + ... on T3 { + id + f3(params: $p1) + } + } + } + ... on T2 { + id + f2 { + ... on T3 { + id + f3(params: $p2) + f2 + } + } + } + } + } + `, + ); + + const plan = queryPlanner2.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "s1") { + { + f1 { + __typename + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + ... on T2 { + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + } + } + } + }, + Parallel { + Flatten(path: ".f1.@.f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + } => + { + ... on T3 { + f3(params: $p1) + } + } + }, + }, + Flatten(path: ".f1.@|[T2].f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + } => + { + ... on T3 { + f3(params: $p2) + f2 + } + } + }, + }, + }, + }, + } + `); + }); + + test('does generate type conditions with interface fragment', () => { + const [api, queryPlanner] = composeAndCreatePlannerWithOptions( + [ + { + name: 's2', + typeDefs: gql` + type Query { + me: String + } + + type T3 @key(fields: "id") { + id: ID! + f2: String + f3(params: String): String + } + + type T4 @key(fields: "id") { + id: ID! + f3(params: String): String + } + `, + }, + { + name: 's1', + typeDefs: gql` + type Query { + f1: [U1] + oneF1: U1 + } + + union U1 = T1 | T2 + union U2 = T3 | T4 + + type T1 @key(fields: "id") { + id: ID! + f2: [U2] + oneU2: U2 + } + + type T2 @key(fields: "id") { + id: ID! + f2: [U2] + oneU2: U2 + } + + type T3 @key(fields: "id") { + id: ID! + } + + type T4 @key(fields: "id") { + id: ID! + } + `, + }, + ], + { typeConditionedFetching: true }, + ); + + const operation = operationFromDocument( + api, + gql` + query f1($p1: String, $p2: String) { + oneF1 { + __typename + ... on T1 { + id + f2 { + ... on T3 { + id + f3(params: $p1) + } + } + } + ... on T2 { + id + f2 { + ... on T3 { + id + f3(params: $p2) + f2 + } + } + } + } + f1 { + __typename + ... on T1 { + id + f2 { + ... on T3 { + id + f3(params: $p1) + } + } + } + ... on T2 { + id + f2 { + ... on T3 { + id + f3(params: $p2) + f2 + } + } + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Sequence { + Fetch(service: "s1") { + { + oneF1 { + __typename + ... on T1 { + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + } + ... on T2 { + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + } + } + f1 { + __typename + ... on T1 { + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + } + ... on T2 { + id + f2 { + __typename + ... on T3 { + __typename + id + } + } + } + } + } + }, + Parallel { + Flatten(path: ".oneF1|[T1].f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + } => + { + ... on T3 { + f3(params: $p1) + } + } + }, + }, + Flatten(path: ".oneF1|[T2].f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + } => + { + ... on T3 { + f3(params: $p2) + f2 + } + } + }, + }, + Flatten(path: ".f1.@|[T1].f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + } => + { + ... on T3 { + f3(params: $p1) + } + } + }, + }, + Flatten(path: ".f1.@|[T2].f2.@") { + Fetch(service: "s2") { + { + ... on T3 { + __typename + id + } + } => + { + ... on T3 { + f3(params: $p2) + f2 + } + } + }, + }, + }, + }, + } + `); + }); +}); diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 823e7be52..bf1fe75b4 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -57,10 +57,11 @@ import { isDefined, InterfaceType, FragmentSelection, - possibleRuntimeTypes, typesCanBeMerged, Supergraph, sameType, + possibleRuntimeTypes, + NamedType, } from "@apollo/federation-internals"; import { advanceSimultaneousPathsWithOperation, @@ -378,6 +379,7 @@ class QueryPlanningTraversal { private stack: [Selection, SimultaneousPathsWithLazyIndirectPaths[]][]; private readonly closedBranches: ClosedBranch[] = []; private readonly optionsLimit: number | null; + private readonly typeConditionedFetching: boolean; constructor( readonly parameters: PlanningParameters, @@ -387,10 +389,12 @@ class QueryPlanningTraversal { private readonly rootKind: SchemaRootKind, readonly costFunction: CostFunction, initialContext: PathContext, + typeConditionedFetching: boolean, 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( @@ -747,8 +751,8 @@ class QueryPlanningTraversal { private updatedDependencyGraph(dependencyGraph: FetchDependencyGraph, tree: OpPathTree): FetchDependencyGraph { return isRootPathTree(tree) - ? computeRootFetchGroups(dependencyGraph, tree, this.rootKind) - : computeNonRootFetchGroups(dependencyGraph, tree, this.rootKind); + ? computeRootFetchGroups(dependencyGraph, tree, this.rootKind, this.typeConditionedFetching) + : computeNonRootFetchGroups(dependencyGraph, tree, this.rootKind, this.typeConditionedFetching); } private resolveConditionPlan(edge: Edge, context: PathContext, excludedDestinations: ExcludedDestinations, excludedConditions: ExcludedConditions): ConditionResolution { @@ -763,6 +767,7 @@ class QueryPlanningTraversal { 'query', this.costFunction, context, + this.typeConditionedFetching, excludedDestinations, addConditionExclusion(excludedConditions, edge.conditions), ).findBestPlan(); @@ -1784,11 +1789,16 @@ class GroupPath { 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(): GroupPath { - return new GroupPath([], [], []); + static empty(typeConditionedFetching: boolean, rootType: CompositeType): GroupPath { + const rootPossibleRuntimeTypes = typeConditionedFetching ? Array.from(possibleRuntimeTypes(rootType)): []; + rootPossibleRuntimeTypes.sort(); + return new GroupPath([], [], [], typeConditionedFetching, rootPossibleRuntimeTypes, rootPossibleRuntimeTypes); } inGroup(): OperationPath { @@ -1808,6 +1818,9 @@ class GroupPath { this.fullPath, newGroupContext, this.responsePath, + this.typeConditionedFetching, + this.possibleTypes, + this.possibleTypesAfterLastField, ); } @@ -1820,35 +1833,94 @@ class GroupPath { // implements it (in the supergraph). concatOperationPaths(pathOfGroupInParent, filterOperationPath(this.pathInGroup, parentSchema)), this.responsePath, + this.typeConditionedFetching, + this.possibleTypes, + this.possibleTypesAfterLastField ); } - private updatedResponsePath(element: OperationElement) { - if (element.kind !== 'Field') { - return this.responsePath; - } + 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 || ''; - let type = element.definition.type!; - const newPath = this.responsePath.concat(element.responseName()); - while (!isNamedType(type)) { - if (isListType(type)) { - newPath.push('@'); - } - type = type.ofType; + 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; } - 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), - this.updatedResponsePath(element), + responsePath, + this.typeConditionedFetching, + newPossibleTypes, + element.kind === 'Field'? newPossibleTypes: this.possibleTypesAfterLastField ); } toString() { - return `[${this.fullPath}]:[${this.pathInGroup}]`; + 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; } } @@ -2138,6 +2210,11 @@ class FetchDependencyGraph { return cloned; } + + supergraphSchemaType(typeName: string): NamedType | undefined { + return this.supergraphSchema.type(typeName) + } + getOrCreateRootFetchGroup({ subgraphName, rootKind, @@ -3495,6 +3572,7 @@ function computeRootParallelBestPlan( parameters.root.rootKind, defaultCostFunction, emptyContext, + parameters.config.typeConditionedFetching, ); 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), @@ -3554,6 +3632,7 @@ function computeRootSerialDependencyGraph( ), prevPaths, root.rootKind, + parameters.config.typeConditionedFetching ); } else { startingFetchId = prevDepGraph.nextFetchId(); @@ -3817,7 +3896,7 @@ function samePathsInParents(first: OperationPath | undefined, second: OperationP return !!second && sameOperationPaths(first, second); } -function computeRootFetchGroups(dependencyGraph: FetchDependencyGraph, pathTree: OpRootPathTree, rootKind: SchemaRootKind): FetchDependencyGraph { +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. @@ -3827,18 +3906,24 @@ function computeRootFetchGroups(dependencyGraph: FetchDependencyGraph, pathTree: // 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 }); - computeGroupsForTree(dependencyGraph, child, group, GroupPath.empty(), emptyDeferContext); + // 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, child, group, GroupPath.empty(typeConditionedFetching, rootTypeInSupergraph), emptyDeferContext); } return dependencyGraph; } -function computeNonRootFetchGroups(dependencyGraph: FetchDependencyGraph, pathTree: OpPathTree, rootKind: SchemaRootKind): FetchDependencyGraph { +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 } ); - computeGroupsForTree(dependencyGraph, pathTree, group, GroupPath.empty(), emptyDeferContext); + 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, group, GroupPath.empty(typeConditionedFetching, rootTypeInSupergraph), emptyDeferContext); return dependencyGraph; } diff --git a/query-planner-js/src/config.ts b/query-planner-js/src/config.ts index 1cf014c62..db460174f 100644 --- a/query-planner-js/src/config.ts +++ b/query-planner-js/src/config.ts @@ -108,6 +108,16 @@ export type QueryPlannerConfig = { */ pathsLimit?: number | null }, + + /** + * Enables type conditioned fetching. + * This flag is a workaround, which may yield significant + * performance degradation when computing query plans, + * and increase query plan size. + * + * If you aren't aware of this flag, you probably don't need it. + */ + typeConditionedFetching?: boolean, } export function enforceQueryPlannerConfigDefaults( @@ -132,6 +142,7 @@ export function enforceQueryPlannerConfigDefaults( pathsLimit: null, ...config?.debug, }, + typeConditionedFetching: config?.typeConditionedFetching || false, }; }