diff --git a/.changeset/spicy-falcons-learn.md b/.changeset/spicy-falcons-learn.md new file mode 100644 index 000000000..292659720 --- /dev/null +++ b/.changeset/spicy-falcons-learn.md @@ -0,0 +1,9 @@ +--- +"apollo-federation-integration-testsuite": minor +"@apollo/query-planner": minor +"@apollo/federation-internals": minor +--- + +Add new `generateQueryFragments` option to query planner config + +If enabled, the query planner will extract inline fragments into fragment definitions before sending queries to subgraphs. This can significantly reduce the size of the query sent to subgraphs, but may increase the time it takes to plan the query. diff --git a/internals-js/src/operations.ts b/internals-js/src/operations.ts index c98573a06..b44e0e9aa 100644 --- a/internals-js/src/operations.ts +++ b/internals-js/src/operations.ts @@ -973,6 +973,18 @@ export class Operation { return this.withUpdatedSelectionSetAndFragments(optimizedSelection, finalFragments ?? undefined); } + generateQueryFragments(): Operation { + const [minimizedSelectionSet, fragments] = this.selectionSet.minimizeSelectionSet(); + return new Operation( + this.schema, + this.rootKind, + minimizedSelectionSet, + this.variableDefinitions, + fragments, + this.name, + ); + } + expandAllFragments(): Operation { // We clear up the fragments since we've expanded all. // Also note that expanding fragment usually generate unecessary fragments/inefficient selections, so it @@ -1535,6 +1547,59 @@ export class SelectionSet { this._selections = mapValues(keyedSelections); } + /** + * Takes a selection set and extracts inline fragments into named fragments, + * reusing generated named fragments when possible. + */ + minimizeSelectionSet( + namedFragments: NamedFragments = new NamedFragments(), + seenSelections: Map = new Map(), + ): [SelectionSet, NamedFragments] { + const minimizedSelectionSet = this.lazyMap((selection) => { + if (selection.kind === 'FragmentSelection' && selection.element.typeCondition && selection.element.appliedDirectives.length === 0 && selection.selectionSet) { + // No proper hash code, so we use a unique enough number that's cheap to + // compute and handle collisions as necessary. + const mockHashCode = `on${selection.element.typeCondition}` + selection.selectionSet.selections().length; + const equivalentSelectionSetCandidates = seenSelections.get(mockHashCode); + if (equivalentSelectionSetCandidates) { + // See if any candidates have an equivalent selection set, i.e. {x y} and {y x}. + const match = equivalentSelectionSetCandidates.find(([candidateSet]) => candidateSet.equals(selection.selectionSet!)); + if (match) { + // If we found a match, we can reuse the fragment (but we still need + // to create a new FragmentSpread since parent types may differ). + return new FragmentSpreadSelection(this.parentType, namedFragments, match[1], []); + } + } + + // No match, so we need to create a new fragment. First, we minimize the + // selection set before creating the fragment with it. + const [minimizedSelectionSet] = selection.selectionSet.minimizeSelectionSet(namedFragments, seenSelections); + const fragmentDefinition = new NamedFragmentDefinition( + this.parentType.schema(), + `_generated_${mockHashCode}_${equivalentSelectionSetCandidates?.length ?? 0}`, + selection.element.typeCondition + ).setSelectionSet(minimizedSelectionSet); + + // Create a new "hash code" bucket or add to the existing one. + if (!equivalentSelectionSetCandidates) { + seenSelections.set(mockHashCode, [[selection.selectionSet, fragmentDefinition]]); + namedFragments.add(fragmentDefinition); + } else { + equivalentSelectionSetCandidates.push([selection.selectionSet, fragmentDefinition]); + } + + return new FragmentSpreadSelection(this.parentType, namedFragments, fragmentDefinition, []); + } else if (selection.kind === 'FieldSelection') { + if (selection.selectionSet) { + selection = selection.withUpdatedSelectionSet(selection.selectionSet.minimizeSelectionSet(namedFragments, seenSelections)[0]); + } + } + return selection; + }); + + return [minimizedSelectionSet, namedFragments]; + } + selectionsInReverseOrder(): readonly Selection[] { const length = this._selections.length; const reversed = new Array(length); @@ -2413,7 +2478,7 @@ export function selectionOfElement(element: OperationElement, subSelection?: Sel return element.kind === 'Field' ? new FieldSelection(element, subSelection) : new InlineFragmentSelection(element, subSelection!); } -export type Selection = FieldSelection | FragmentSelection; +export type Selection = FieldSelection | FragmentSelection | FragmentSpreadSelection; abstract class AbstractSelection> { constructor( readonly element: TElement, diff --git a/query-planner-js/src/__tests__/buildPlan.test.ts b/query-planner-js/src/__tests__/buildPlan.test.ts index f40831dc3..04bdbaec7 100644 --- a/query-planner-js/src/__tests__/buildPlan.test.ts +++ b/query-planner-js/src/__tests__/buildPlan.test.ts @@ -5042,6 +5042,190 @@ describe('Named fragments preservation', () => { }); }); +describe('Fragment autogeneration', () => { + const subgraph = { + name: 'Subgraph1', + typeDefs: gql` + type Query { + t: T + t2: T + } + + union T = A | B + + type A { + x: Int + y: Int + t: T + } + + type B { + z: Int + } + `, + }; + + it('respects generateQueryFragments option', () => { + const [api, queryPlanner] = composeAndCreatePlannerWithOptions([subgraph], { + generateQueryFragments: true, + }); + const operation = operationFromDocument( + api, + gql` + query { + t { + ... on A { + x + y + } + ... on B { + z + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Fetch(service: "Subgraph1") { + { + t { + __typename + ..._generated_onA2_0 + ..._generated_onB1_0 + } + } + + fragment _generated_onA2_0 on A { + x + y + } + + fragment _generated_onB1_0 on B { + z + } + }, + } + `); + }); + + it('handles nested fragment generation', () => { + const [api, queryPlanner] = composeAndCreatePlannerWithOptions([subgraph], { + generateQueryFragments: true, + }); + const operation = operationFromDocument( + api, + gql` + query { + t { + ... on A { + x + y + t { + ... on A { + x + } + ... on B { + z + } + } + } + ... on B { + z + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Fetch(service: "Subgraph1") { + { + t { + __typename + ..._generated_onA3_0 + ..._generated_onB1_0 + } + } + + fragment _generated_onA1_0 on A { + x + } + + fragment _generated_onB1_0 on B { + z + } + + fragment _generated_onA3_0 on A { + x + y + t { + __typename + ..._generated_onA1_0 + ..._generated_onB1_0 + } + } + }, + } + `); + }); + + it("identifies and reuses equivalent fragments that aren't identical", () => { + const [api, queryPlanner] = composeAndCreatePlannerWithOptions([subgraph], { + generateQueryFragments: true, + }); + const operation = operationFromDocument( + api, + gql` + query { + t { + ... on A { + x + y + } + } + t2 { + ... on A { + y + x + } + } + } + `, + ); + + const plan = queryPlanner.buildQueryPlan(operation); + + expect(plan).toMatchInlineSnapshot(` + QueryPlan { + Fetch(service: "Subgraph1") { + { + t { + __typename + ..._generated_onA2_0 + } + t2 { + __typename + ..._generated_onA2_0 + } + } + + fragment _generated_onA2_0 on A { + x + y + } + }, + } + `); + }); +}); + test('works with key chains', () => { const subgraph1 = { name: 'Subgraph1', diff --git a/query-planner-js/src/buildPlan.ts b/query-planner-js/src/buildPlan.ts index 4965dc77b..c20ebf3d0 100644 --- a/query-planner-js/src/buildPlan.ts +++ b/query-planner-js/src/buildPlan.ts @@ -597,7 +597,7 @@ class QueryPlanningTraversal { 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); + return FetchDependencyGraph.create(supergraphSchema, federatedQueryGraph, this.startFetchIdGen, rootType, this.parameters.config.generateQueryFragments); } // Moves the first closed branch to after any branch having more options. @@ -907,6 +907,7 @@ class FetchGroup { // 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[] = [], @@ -934,6 +935,7 @@ class FetchGroup { hasInputs, mergeAt, deferRef, + generateQueryFragments, }: { dependencyGraph: FetchDependencyGraph, index: number, @@ -943,6 +945,7 @@ class FetchGroup { 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}`); @@ -957,7 +960,9 @@ class FetchGroup { hasInputs ? new GroupInputs(dependencyGraph.supergraphSchema) : undefined, mergeAt, deferRef, - hasInputs ? `${toValidGraphQLName(subgraphName)}-${mergeAt?.join('::') ?? ''}` : undefined + hasInputs ? `${toValidGraphQLName(subgraphName)}-${mergeAt?.join('::') ?? ''}` : undefined, + undefined, + generateQueryFragments, ); } @@ -976,6 +981,7 @@ class FetchGroup { this.deferRef, this.subgraphAndMergeAtKey, this.cachedCost, + this.generateQueryFragments, this.isKnownUseful, [...this.inputRewrites], ); @@ -1516,7 +1522,11 @@ class FetchGroup { operationName, ); - operation = operation.optimize(fragments?.forSubgraph(this.subgraphName, subgraphSchema)); + if (this.generateQueryFragments) { + operation = operation.generateQueryFragments(); + } else { + operation = operation.optimize(fragments?.forSubgraph(this.subgraphName, subgraphSchema)); + } const operationDocument = operationToDocument(operation); const fetchNode: FetchNode = { @@ -2060,11 +2070,12 @@ class FetchDependencyGraph { 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) { + static create(supergraphSchema: Schema, federatedQueryGraph: QueryGraph, startingIdGen: number, rootTypeForDefer: CompositeType | undefined, generateQueryFragments: boolean) { return new FetchDependencyGraph( supergraphSchema, federatedQueryGraph.sources, @@ -2073,6 +2084,7 @@ class FetchDependencyGraph { new MapWithCachedArrays(), [], DeferTracking.empty(rootTypeForDefer), + generateQueryFragments, ); } @@ -2097,6 +2109,7 @@ class FetchDependencyGraph { new MapWithCachedArrays(), new Array(this.groups.length), this.deferTracking.clone(), + this.generateQueryFragments, ); for (const group of this.groups) { @@ -2186,6 +2199,7 @@ class FetchDependencyGraph { hasInputs, mergeAt, deferRef, + generateQueryFragments: this.generateQueryFragments, }); this.groups.push(newGroup); return newGroup; @@ -3485,9 +3499,9 @@ function computeRootParallelBestPlan( function createEmptyPlan( parameters: PlanningParameters, ): [FetchDependencyGraph, OpPathTree, number] { - const { supergraphSchema, federatedQueryGraph, root } = parameters; + const { supergraphSchema, federatedQueryGraph, root, config } = parameters; return [ - FetchDependencyGraph.create(supergraphSchema, federatedQueryGraph, 0, undefined), + FetchDependencyGraph.create(supergraphSchema, federatedQueryGraph, 0, undefined, config.generateQueryFragments), PathTree.createOp(federatedQueryGraph, root), 0 ]; @@ -3524,7 +3538,17 @@ function computeRootSerialDependencyGraph( // } // 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), prevPaths, root.rootKind); + prevDepGraph = computeRootFetchGroups( + FetchDependencyGraph.create( + supergraphSchema, + federatedQueryGraph, + startingFetchId, + rootType, + parameters.config.generateQueryFragments, + ), + prevPaths, + root.rootKind, + ); } else { startingFetchId = prevDepGraph.nextFetchId(); graphs.push(prevDepGraph); @@ -3805,7 +3829,7 @@ function computeNonRootFetchGroups(dependencyGraph: FetchDependencyGraph, pathTr // 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} ); + const group = dependencyGraph.getOrCreateRootFetchGroup({ subgraphName, rootKind, parentType: rootType } ); computeGroupsForTree(dependencyGraph, pathTree, group, GroupPath.empty(), emptyDeferContext); return dependencyGraph; } diff --git a/query-planner-js/src/config.ts b/query-planner-js/src/config.ts index cabe584d6..1cf014c62 100644 --- a/query-planner-js/src/config.ts +++ b/query-planner-js/src/config.ts @@ -34,6 +34,14 @@ export type QueryPlannerConfig = { */ reuseQueryFragments?: boolean, + /** + * If enabled, the query planner will extract inline fragments into fragment + * definitions before sending queries to subgraphs. This can significantly + * reduce the size of the query sent to subgraphs, but may increase the time + * it takes to plan the query. + */ + generateQueryFragments?: boolean, + // Side-note: implemented as an object instead of single boolean because we expect to add more to this soon // enough. In particular, once defer-passthrough to subgraphs is implemented, the idea would be to add a // new `passthroughSubgraphs` option that is the list of subgraph to which we can pass-through some @defer @@ -60,7 +68,7 @@ export type QueryPlannerConfig = { debug?: { /** * If used and the supergraph is built from a single subgraph, then user queries do not go through the - * normal query planning and instead a fetch to the one subgraph is built directly from the input query. + * normal query planning and instead a fetch to the one subgraph is built directly from the input query. */ bypassPlannerForSingleSubgraph?: boolean, @@ -68,7 +76,7 @@ export type QueryPlannerConfig = { * Query planning is an exploratory process. Depending on the specificities and feature used by * subgraphs, there could exist may different theoretical valid (if not always efficient) plans * for a given query, and at a high level, the query planner generates those possible choices, - * evaluate them, and return the best one. In some complex cases however, the number of + * evaluate them, and return the best one. In some complex cases however, the number of * theoretically possible plans can be very large, and to keep query planning time acceptable, * the query planner cap the maximum number of plans it evaluates. This config allows to configure * that cap. Note if planning a query hits that cap, then the planner will still always return a @@ -92,7 +100,7 @@ export type QueryPlannerConfig = { * each constituent object type. The number of options generated in this computation can grow * large if the schema or query are sufficiently complex, and that will increase the time spent * planning. - * + * * This config allows specifying a per-path limit to the number of options considered. If any * path's options exceeds this limit, query planning will abort and the operation will fail. * @@ -108,6 +116,7 @@ export function enforceQueryPlannerConfigDefaults( return { exposeDocumentNodeInFetchNode: false, reuseQueryFragments: true, + generateQueryFragments: false, cache: new InMemoryLRUCache({maxSize: Math.pow(2, 20) * 50 }), ...config, incrementalDelivery: {