diff --git a/gateway-js/src/QueryPlan.ts b/gateway-js/src/QueryPlan.ts index 07f38ae9a..26219e75a 100644 --- a/gateway-js/src/QueryPlan.ts +++ b/gateway-js/src/QueryPlan.ts @@ -2,27 +2,27 @@ import { FragmentDefinitionNode, GraphQLSchema, OperationDefinitionNode, - SelectionSetNode, - VariableDefinitionNode, + Kind, + SelectionNode as GraphQLJSSelectionNode, } from 'graphql'; -import { astSerializer, queryPlanSerializer } from './snapshotSerializers'; import prettyFormat from 'pretty-format'; +import { queryPlanSerializer, astSerializer } from './snapshotSerializers'; export type ResponsePath = (string | number)[]; export type FragmentMap = { [fragmentName: string]: FragmentDefinitionNode }; -export interface QueryPlan { - kind: 'QueryPlan'; - node?: PlanNode; -} - export type OperationContext = { schema: GraphQLSchema; operation: OperationDefinitionNode; fragments: FragmentMap; }; +export interface QueryPlan { + kind: 'QueryPlan'; + node?: PlanNode; +} + export type PlanNode = SequenceNode | ParallelNode | FetchNode | FlattenNode; export interface SequenceNode { @@ -38,20 +38,86 @@ export interface ParallelNode { export interface FetchNode { kind: 'Fetch'; serviceName: string; - selectionSet: SelectionSetNode; - variableUsages?: { [name: string]: VariableDefinitionNode }; - requires?: SelectionSetNode; - internalFragments: Set; - source: string; + variableUsages?: string[]; + requires?: QueryPlanSelectionNode[]; + operation: string; } + export interface FlattenNode { kind: 'Flatten'; path: ResponsePath; node: PlanNode; } +/** + * SelectionNodes from GraphQL-js _can_ have a FragmentSpreadNode + * but this SelectionNode is specifically typing the `requires` key + * in a built query plan, where there can't be FragmentSpreadNodes + * since that info is contained in the `FetchNode.operation` + */ +export type QueryPlanSelectionNode = QueryPlanFieldNode | QueryPlanInlineFragmentNode; + +export interface QueryPlanFieldNode { + readonly kind: 'Field'; + readonly alias?: string; + readonly name: string; + readonly selections?: QueryPlanSelectionNode[]; +} + +export interface QueryPlanInlineFragmentNode { + readonly kind: 'InlineFragment'; + readonly typeCondition?: string; + readonly selections: QueryPlanSelectionNode[]; +} + export function serializeQueryPlan(queryPlan: QueryPlan) { return prettyFormat(queryPlan, { plugins: [queryPlanSerializer, astSerializer], }); } + +export function getResponseName(node: QueryPlanFieldNode): string { + return node.alias ? node.alias : node.name; +} + +/** + * Converts a GraphQL-js SelectionNode to our newly defined SelectionNode + * + * This function is used to remove the unneeded pieces of a SelectionSet's + * `.selections`. It is only ever called on a query plan's `requires` field, + * so we can guarantee there won't be any FragmentSpreads passed in. That's why + * we can ignore the case where `selection.kind === Kind.FRAGMENT_SPREAD` + */ +export const trimSelectionNodes = ( + selections: readonly GraphQLJSSelectionNode[], +): QueryPlanSelectionNode[] => { + /** + * Using an array to push to instead of returning value from `selections.map` + * because TypeScript thinks we can encounter a `Kind.FRAGMENT_SPREAD` here, + * so if we mapped the array directly to the return, we'd have to `return undefined` + * from one branch of the map and then `.filter(Boolean)` on that returned + * array + */ + const remapped: QueryPlanSelectionNode[] = []; + + selections.forEach((selection) => { + if (selection.kind === Kind.FIELD) { + remapped.push({ + kind: Kind.FIELD, + name: selection.name.value, + selections: + selection.selectionSet && + trimSelectionNodes(selection.selectionSet.selections), + }); + } + if (selection.kind === Kind.INLINE_FRAGMENT) { + remapped.push({ + kind: Kind.INLINE_FRAGMENT, + typeCondition: selection.typeCondition?.name.value, + selections: trimSelectionNodes(selection.selectionSet.selections), + }); + } + }); + + return remapped; +}; diff --git a/gateway-js/src/__tests__/executeQueryPlan.test.ts b/gateway-js/src/__tests__/executeQueryPlan.test.ts index ac9cb795c..a8041520f 100644 --- a/gateway-js/src/__tests__/executeQueryPlan.test.ts +++ b/gateway-js/src/__tests__/executeQueryPlan.test.ts @@ -41,7 +41,7 @@ describe('executeQueryPlan', () => { request: { variables: {}, }, - }; + } as GraphQLRequestContext; } describe(`errors`, () => { diff --git a/gateway-js/src/__tests__/queryPlanCucumber.test.ts b/gateway-js/src/__tests__/queryPlanCucumber.test.ts index 432f675ee..120e3a681 100644 --- a/gateway-js/src/__tests__/queryPlanCucumber.test.ts +++ b/gateway-js/src/__tests__/queryPlanCucumber.test.ts @@ -49,10 +49,9 @@ features.forEach((feature) => { options ); - const serializedPlan = JSON.parse(JSON.stringify(queryPlan, serializeQueryPlanNode)); const parsedExpectedPlan = JSON.parse(expectedQueryPlan); - expect(serializedPlan).toEqual(parsedExpectedPlan); + expect(queryPlan).toEqual(parsedExpectedPlan); }) } @@ -69,37 +68,3 @@ features.forEach((feature) => { }); }); }); - -const serializeQueryPlanNode = (k: string , v: any) => { - switch(k){ - case "selectionSet": - case "internalFragments": - case "loc": - case "arguments": - case "directives": - case "source": - return undefined; - case "kind": - if(v === Kind.SELECTION_SET) return undefined; - return v; - case "variableUsages": - // TODO check this - return Object.keys(v); - case "typeCondition": - return v.name.value; - case "name": - return v.value; - case "requires": - return v?.selections; - default: - // replace source with operation - if(v?.kind === "Fetch"){ - return { ...v, operation: v.source }; - } - // replace selectionSet with selections[] - if(v?.kind === Kind.INLINE_FRAGMENT){ - return { ...v, selections: v.selectionSet.selections } - } - return v; - } -} diff --git a/gateway-js/src/buildQueryPlan.ts b/gateway-js/src/buildQueryPlan.ts index 1b38faf6d..ac528f5a3 100644 --- a/gateway-js/src/buildQueryPlan.ts +++ b/gateway-js/src/buildQueryPlan.ts @@ -47,6 +47,7 @@ import { QueryPlan, ResponsePath, OperationContext, + trimSelectionNodes, FragmentMap, } from './QueryPlan'; import { getFieldDef, getResponseName } from './utilities/graphql'; @@ -101,6 +102,8 @@ export function buildQueryPlan( return { kind: 'QueryPlan', node: nodes.length + // if an operation is a mutation, we run the root fields in sequence, + // otherwise we run them in parallel ? flatWrap(isMutation ? 'Sequence' : 'Parallel', nodes) : undefined, }; @@ -144,11 +147,9 @@ function executionNodeForGroup( const fetchNode: FetchNode = { kind: 'Fetch', serviceName, - selectionSet, - requires, - variableUsages, - internalFragments, - source: stripIgnoredCharacters(print(operation)), + requires: requires ? trimSelectionNodes(requires?.selections) : undefined, + variableUsages: Object.keys(variableUsages), + operation: stripIgnoredCharacters(print(operation)), }; const node: PlanNode = diff --git a/gateway-js/src/executeQueryPlan.ts b/gateway-js/src/executeQueryPlan.ts index 5c477c0b3..9b919d07d 100644 --- a/gateway-js/src/executeQueryPlan.ts +++ b/gateway-js/src/executeQueryPlan.ts @@ -7,7 +7,6 @@ import { execute, GraphQLError, Kind, - SelectionSetNode, TypeNameMetaFieldDef, GraphQLFieldResolver, } from 'graphql'; @@ -20,9 +19,11 @@ import { QueryPlan, ResponsePath, OperationContext, + QueryPlanSelectionNode, + QueryPlanFieldNode, + getResponseName } from './QueryPlan'; import { deepMerge } from './utilities/deepMerge'; -import { getResponseName } from './utilities/graphql'; export type ServiceMap = { [serviceName: string]: GraphQLDataSource; @@ -207,7 +208,7 @@ async function executeFetch( let variables = Object.create(null); if (fetch.variableUsages) { - for (const variableName of Object.keys(fetch.variableUsages)) { + for (const variableName of fetch.variableUsages) { const providedVariables = context.requestContext.request.variables; if ( providedVariables && @@ -221,7 +222,7 @@ async function executeFetch( if (!fetch.requires) { const dataReceivedFromService = await sendOperation( context, - fetch.source, + fetch.operation, variables, ); @@ -248,7 +249,7 @@ async function executeFetch( const dataReceivedFromService = await sendOperation( context, - fetch.source, + fetch.operation, { ...variables, representations }, ); @@ -388,7 +389,7 @@ async function executeFetch( */ function executeSelectionSet( source: Record | null, - selectionSet: SelectionSetNode, + selections: QueryPlanSelectionNode[], ): Record | null { // If the underlying service has returned null for the parent (source) @@ -399,23 +400,23 @@ function executeSelectionSet( const result: Record = Object.create(null); - for (const selection of selectionSet.selections) { + for (const selection of selections) { switch (selection.kind) { case Kind.FIELD: - const responseName = getResponseName(selection); - const selectionSet = selection.selectionSet; + const responseName = getResponseName(selection as QueryPlanFieldNode); + const selections = (selection as QueryPlanFieldNode).selections; if (typeof source[responseName] === 'undefined') { throw new Error(`Field "${responseName}" was not found in response.`); } if (Array.isArray(source[responseName])) { result[responseName] = source[responseName].map((value: any) => - selectionSet ? executeSelectionSet(value, selectionSet) : value, + selections ? executeSelectionSet(value, selections) : value, ); - } else if (selectionSet) { + } else if (selections) { result[responseName] = executeSelectionSet( source[responseName], - selectionSet, + selections, ); } else { result[responseName] = source[responseName]; @@ -427,10 +428,10 @@ function executeSelectionSet( const typename = source && source['__typename']; if (!typename) continue; - if (typename === selection.typeCondition.name.value) { + if (typename === selection.typeCondition) { deepMerge( result, - executeSelectionSet(source, selection.selectionSet), + executeSelectionSet(source, selection.selections), ); } break; diff --git a/gateway-js/src/snapshotSerializers/astSerializer.ts b/gateway-js/src/snapshotSerializers/astSerializer.ts index b3dc7eb54..f3cee95e7 100644 --- a/gateway-js/src/snapshotSerializers/astSerializer.ts +++ b/gateway-js/src/snapshotSerializers/astSerializer.ts @@ -1,5 +1,7 @@ -import { ASTNode, print } from 'graphql'; +import { ASTNode, print, Kind, visit } from 'graphql'; import { Plugin, Config, Refs } from 'pretty-format'; +import { QueryPlanSelectionNode, QueryPlanInlineFragmentNode } from '../QueryPlan'; +import { SelectionNode as GraphQLJSSelectionNode } from 'graphql'; export default { test(value: any) { @@ -14,8 +16,101 @@ export default { _refs: Refs, _printer: any, ): string { - return print(value) + return print(remapInlineFragmentNodes(value)) .trim() + .replace(/\n\n/g, '\n') .replace(/\n/g, '\n' + indentation); }, } as Plugin; + +/** + * This function converts potential InlineFragmentNodes that WE created + * (defined in ../QueryPlan, not graphql-js) to GraphQL-js compliant AST nodes + * for the graphql-js printer to work with + * + * The arg type here SHOULD be (node: AstNode | SelectionNode (from ../QueryPlan)), + * but that breaks the graphql-js visitor, as it won't allow our redefined + * SelectionNode to be passed in. + * + * Since our SelectionNode still has a `kind`, this will still functionally work + * at runtime to call the InlineFragment visitor defined below + * + * We have to cast the `fragmentNode as unknown` and then to an InlineFragmentNode + * at the bottom though, since there's no way to cast it appropriately to an + * `InlineFragmentNode` as defined in ../QueryPlan.ts. TypeScript will complain + * about there not being overlapping fields + */ +export function remapInlineFragmentNodes(node: ASTNode): ASTNode { + return visit(node, { + InlineFragment: (fragmentNode) => { + // if the fragmentNode is already a proper graphql AST Node, return it + if (fragmentNode.selectionSet) return fragmentNode; + + /** + * Since the above check wasn't hit, we _know_ that fragmentNode is an + * InlineFragmentNode from ../QueryPlan, but we can't actually type that + * without causing ourselves a lot of headache, so we cast to unknown and + * then to InlineFragmentNode (from ../QueryPlan) below + */ + + // if the fragmentNode is a QueryPlan InlineFragmentNode, convert it to graphql-js node + return { + kind: Kind.INLINE_FRAGMENT, + typeCondition: fragmentNode.typeCondition + ? { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: fragmentNode.typeCondition, + }, + } + : undefined, + selectionSet: { + kind: Kind.SELECTION_SET, + // we have to recursively rebuild the selectionSet using selections + selections: remapSelections( + ((fragmentNode as unknown) as QueryPlanInlineFragmentNode).selections, + ), + }, + }; + }, + }); +} + +function remapSelections( + selections: QueryPlanSelectionNode[], +): ReadonlyArray { + return selections.map((selection) => { + switch (selection.kind) { + case Kind.FIELD: + return { + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: selection.name, + }, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: remapSelections(selection.selections || []), + }, + }; + case Kind.INLINE_FRAGMENT: + return { + kind: Kind.INLINE_FRAGMENT, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: remapSelections(selection.selections || []), + }, + typeCondition: selection.typeCondition + ? { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: selection.typeCondition, + }, + } + : undefined, + }; + } + }); +} diff --git a/gateway-js/src/snapshotSerializers/queryPlanSerializer.ts b/gateway-js/src/snapshotSerializers/queryPlanSerializer.ts index 5a2db1fdb..08023fe2e 100644 --- a/gateway-js/src/snapshotSerializers/queryPlanSerializer.ts +++ b/gateway-js/src/snapshotSerializers/queryPlanSerializer.ts @@ -1,5 +1,6 @@ import { Config, Plugin, Refs } from 'pretty-format'; import { PlanNode, QueryPlan } from '../QueryPlan'; +import { parse, Kind, visit, DocumentNode } from 'graphql'; export default { test(value: any) { @@ -50,7 +51,9 @@ function printNode( indentationNext + (node.requires ? printer( - node.requires, + // this is an array of selections, so we need to make it a proper + // selectionSet so we can print it + { kind: Kind.SELECTION_SET, selections: node.requires }, config, indentationNext, depth, @@ -62,7 +65,7 @@ function printNode( indentationNext : '') + printer( - node.selectionSet, + flattenEntitiesField(parse(node.operation)), config, indentationNext, depth, @@ -71,23 +74,6 @@ function printNode( ) + config.spacingOuter + indentation + - (node.internalFragments.size > 0 - ? ' ' + - Array.from(node.internalFragments) - .map(fragment => - printer( - fragment, - config, - indentationNext, - depth, - refs, - printer, - ), - ) - .join(`\n${indentationNext}`) + - config.spacingOuter + - indentation - : '') + '}'; break; case 'Flatten': @@ -142,3 +128,25 @@ function printNodes( return result; } + +/** + * when we serialize a query plan, we want to serialize the operation, but not + * show the root level `query` definition or the `_entities` call. This function + * flattens those nodes to only show their selectionSets + */ +function flattenEntitiesField(node: DocumentNode) { + return visit(node, { + OperationDefinition: ({ operation, selectionSet }) => { + const firstSelection = selectionSet.selections[0]; + if ( + operation === 'query' && + firstSelection.kind === Kind.FIELD && + firstSelection.name.value === '_entities' + ) { + return firstSelection.selectionSet; + } + // we don't want to print the `query { }` definition either for query plan printing + return selectionSet; + }, + }); +}