diff --git a/packages/batch-delegate/tests/typeMerging.example.test.ts b/packages/batch-delegate/tests/typeMerging.example.test.ts index 6ea2467bf01..b16a2f05aa0 100644 --- a/packages/batch-delegate/tests/typeMerging.example.test.ts +++ b/packages/batch-delegate/tests/typeMerging.example.test.ts @@ -227,7 +227,8 @@ describe('merging using type merging', () => { fieldName: '_userById', args: ({ id }) => ({ id }) } - } + }, + batch: true, }, { schema: inventorySchema, @@ -242,7 +243,8 @@ describe('merging using type merging', () => { fieldName: '_productByRepresentation', args: ({ upc, weight, price }) => ({ product: { upc, weight, price } }), } - } + }, + batch: true, }, { schema: productsSchema, @@ -252,7 +254,8 @@ describe('merging using type merging', () => { fieldName: '_productByUpc', args: ({ upc }) => ({ upc }), } - } + }, + batch: true, }, { schema: reviewsSchema, @@ -268,7 +271,8 @@ describe('merging using type merging', () => { fieldName: '_productByUpc', args: ({ upc }) => ({ upc }), }, - } + }, + batch: true, }], mergeTypes: true, }); @@ -284,6 +288,8 @@ describe('merging using type merging', () => { } } `, + undefined, + {}, ); const expectedResult = { @@ -315,6 +321,8 @@ describe('merging using type merging', () => { } } `, + undefined, + {}, ); const expectedResult: ExecutionResult = { @@ -348,6 +356,8 @@ describe('merging using type merging', () => { } } `, + undefined, + {}, ); const expectedResult: ExecutionResult = { @@ -393,6 +403,8 @@ describe('merging using type merging', () => { } } `, + undefined, + {}, ); const expectedResult: ExecutionResult = { diff --git a/packages/delegate/package.json b/packages/delegate/package.json index 5357ecaa533..9a02cc0b51d 100644 --- a/packages/delegate/package.json +++ b/packages/delegate/package.json @@ -21,6 +21,7 @@ "@graphql-tools/schema": "6.1.0", "@graphql-tools/utils": "6.1.0", "@ardatan/aggregate-error": "0.0.1", + "dataloader": "2.0.0", "is-promise": "4.0.0", "tslib": "~2.0.1" }, @@ -28,4 +29,4 @@ "access": "public", "directory": "dist" } -} \ No newline at end of file +} diff --git a/packages/delegate/src/delegateToSchema.ts b/packages/delegate/src/delegateToSchema.ts index 0fd0908e807..b91cdd4da93 100644 --- a/packages/delegate/src/delegateToSchema.ts +++ b/packages/delegate/src/delegateToSchema.ts @@ -24,6 +24,7 @@ import { createRequestFromInfo, getDelegatingOperation } from './createRequest'; import { Transformer } from './Transformer'; import AggregateError from '@ardatan/aggregate-error'; +import { getBatchingExecutor } from './getBatchingExecutor'; export function delegateToSchema(options: IDelegateToSchemaOptions | GraphQLSchema): any { if (isSchema(options)) { @@ -154,9 +155,13 @@ export function delegateRequest({ } if (targetOperation === 'query' || targetOperation === 'mutation') { - const executor = + let executor = subschemaConfig?.executor || createDefaultExecutor(targetSchema, subschemaConfig?.rootValue || targetRootValue); + if (subschemaConfig?.batch) { + executor = getBatchingExecutor(context, subschemaConfig, executor); + } + const executionResult = executor({ document: processedRequest.document, variables: processedRequest.variables, diff --git a/packages/delegate/src/getBatchingExecutor.ts b/packages/delegate/src/getBatchingExecutor.ts new file mode 100644 index 00000000000..9ce59e56439 --- /dev/null +++ b/packages/delegate/src/getBatchingExecutor.ts @@ -0,0 +1,74 @@ +import { getOperationAST } from 'graphql'; + +import isPromise from 'is-promise'; + +import DataLoader from 'dataloader'; + +import { ExecutionResult } from '@graphql-tools/utils'; + +import { SubschemaConfig, ExecutionParams } from './types'; +import { memoize2of3 } from './memoize'; +import { mergeExecutionParams } from './mergeExecutionParams'; +import { splitResult } from './splitResult'; + +export const getBatchingExecutor = memoize2of3(function ( + _context: Record, + subschemaConfig: SubschemaConfig, + executor: ({ document, context, variables, info }: ExecutionParams) => ExecutionResult | Promise +) { + const loader = new DataLoader( + createLoadFn(executor ?? subschemaConfig.executor), + subschemaConfig.batchingOptions?.dataLoaderOptions + ); + return (executionParams: ExecutionParams) => loader.load(executionParams); +}); + +function createLoadFn( + executor: ({ document, context, variables, info }: ExecutionParams) => ExecutionResult | Promise +) { + return async (execs: Array): Promise> => { + const execBatches: Array> = []; + let index = 0; + const exec = execs[index]; + let currentBatch: Array = [exec]; + execBatches.push(currentBatch); + const operationType = getOperationAST(exec.document, undefined).operation; + while (++index < execs.length) { + const currentOperationType = getOperationAST(execs[index].document, undefined).operation; + if (operationType === currentOperationType) { + currentBatch.push(execs[index]); + } else { + currentBatch = [execs[index]]; + execBatches.push(currentBatch); + } + } + + let containsPromises = false; + const executionResults: Array> = []; + execBatches.forEach(execBatch => { + const mergedExecutionParams = mergeExecutionParams(execBatch); + const executionResult = executor(mergedExecutionParams); + + if (isPromise(executionResult)) { + containsPromises = true; + } + executionResults.push(executionResult); + }); + + if (containsPromises) { + return Promise.all(executionResults).then(resultBatches => { + let results: Array = []; + resultBatches.forEach((resultBatch, index) => { + results = results.concat(splitResult(resultBatch, execBatches[index].length)); + }); + return results; + }); + } + + let results: Array = []; + (executionResults as Array).forEach((resultBatch, index) => { + results = results.concat(splitResult(resultBatch, execBatches[index].length)); + }); + return results; + }; +} diff --git a/packages/delegate/src/memoize.ts b/packages/delegate/src/memoize.ts index 05e6d3804a1..e32108a812c 100644 --- a/packages/delegate/src/memoize.ts +++ b/packages/delegate/src/memoize.ts @@ -211,3 +211,43 @@ export function memoize2, T2 extends Record, + T2 extends Record, + T3 extends any, + R extends any +>(fn: (A1: T1, A2: T2, A3: T3) => R): (A1: T1, A2: T2, A3: T3) => R { + let cache1: WeakMap>; + + function memoized(a1: T1, a2: T2, a3: T3) { + if (!cache1) { + cache1 = new WeakMap(); + const cache2: WeakMap = new WeakMap(); + cache1.set(a1, cache2); + const newValue = fn(a1, a2, a3); + cache2.set(a2, newValue); + return newValue; + } + + let cache2 = cache1.get(a1); + if (!cache2) { + cache2 = new WeakMap(); + cache1.set(a1, cache2); + const newValue = fn(a1, a2, a3); + cache2.set(a2, newValue); + return newValue; + } + + const cachedValue = cache2.get(a2); + if (cachedValue === undefined) { + const newValue = fn(a1, a2, a3); + cache2.set(a2, newValue); + return newValue; + } + + return cachedValue; + } + + return memoized; +} diff --git a/packages/delegate/src/mergeExecutionParams.ts b/packages/delegate/src/mergeExecutionParams.ts new file mode 100644 index 00000000000..e97c0e44028 --- /dev/null +++ b/packages/delegate/src/mergeExecutionParams.ts @@ -0,0 +1,274 @@ +// adapted from https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-source-graphql/src/batching/merge-queries.js + +import { + visit, + Kind, + DefinitionNode, + OperationDefinitionNode, + DocumentNode, + FragmentDefinitionNode, + VariableDefinitionNode, + SelectionNode, + FragmentSpreadNode, + VariableNode, + VisitorKeyMap, + ASTKindToNode, + InlineFragmentNode, + FieldNode, +} from 'graphql'; + +import { createPrefix } from './prefix'; +import { ExecutionParams } from './types'; + +/** + * Merge multiple queries into a single query in such a way that query results + * can be split and transformed as if they were obtained by running original queries. + * + * Merging algorithm involves several transformations: + * 1. Replace top-level fragment spreads with inline fragments (... on Query {}) + * 2. Add unique aliases to all top-level query fields (including those on inline fragments) + * 3. Prefix all variable definitions and variable usages + * 4. Prefix names (and spreads) of fragments + * + * i.e transform: + * [ + * `query Foo($id: ID!) { foo, bar(id: $id), ...FooQuery } + * fragment FooQuery on Query { baz }`, + * + * `query Bar($id: ID!) { foo: baz, bar(id: $id), ... on Query { baz } }` + * ] + * to: + * query ( + * $graphqlTools1_id: ID! + * $graphqlTools2_id: ID! + * ) { + * graphqlTools1_foo: foo, + * graphqlTools1_bar: bar(id: $graphqlTools1_id) + * ... on Query { + * graphqlTools1__baz: baz + * } + * graphqlTools1__foo: baz + * graphqlTools1__bar: bar(id: $graphqlTools1__id) + * ... on Query { + * graphqlTools1__baz: baz + * } + * } + */ +export function mergeExecutionParams(execs: Array): ExecutionParams { + const mergedVariables: Record = Object.create(null); + const mergedVariableDefinitions: Array = []; + const mergedSelections: Array = []; + const mergedFragmentDefinitions: Array = []; + + execs.forEach((executionParams, index) => { + const prefixedExecutionParams = prefixExecutionParams(createPrefix(index), executionParams); + + prefixedExecutionParams.document.definitions.forEach(def => { + if (isOperationDefinition(def)) { + mergedSelections.push(...def.selectionSet.selections); + mergedVariableDefinitions.push(...(def.variableDefinitions ?? [])); + } + if (isFragmentDefinition(def)) { + mergedFragmentDefinitions.push(def); + } + }); + Object.assign(mergedVariables, prefixedExecutionParams.variables); + }); + + const mergedOperationDefinition: OperationDefinitionNode = { + kind: Kind.OPERATION_DEFINITION, + operation: `query`, + variableDefinitions: mergedVariableDefinitions, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: mergedSelections, + }, + }; + + return { + document: { + kind: Kind.DOCUMENT, + definitions: [mergedOperationDefinition, ...mergedFragmentDefinitions], + }, + variables: mergedVariables, + context: execs[0].context, + info: execs[0].info, + }; +} + +function prefixExecutionParams(prefix: string, executionParams: ExecutionParams): ExecutionParams { + let document = aliasTopLevelFields(prefix, executionParams.document); + const variableNames = Object.keys(executionParams.variables); + + if (variableNames.length === 0) { + return { ...executionParams, document }; + } + + document = visit(document, { + [Kind.VARIABLE]: (node: VariableNode) => prefixNodeName(node, prefix), + [Kind.FRAGMENT_DEFINITION]: (node: FragmentDefinitionNode) => prefixNodeName(node, prefix), + [Kind.FRAGMENT_SPREAD]: (node: FragmentSpreadNode) => prefixNodeName(node, prefix), + }); + + const prefixedVariables = variableNames.reduce((acc, name) => { + acc[prefix + name] = executionParams.variables[name]; + return acc; + }, Object.create(null)); + + return { + document, + variables: prefixedVariables, + }; +} + +/** + * Adds prefixed aliases to top-level fields of the query. + * + * @see aliasFieldsInSelection for implementation details + */ +function aliasTopLevelFields(prefix: string, document: DocumentNode): DocumentNode { + const transformer = { + [Kind.OPERATION_DEFINITION]: (def: OperationDefinitionNode) => { + const { selections } = def.selectionSet; + return { + ...def, + selectionSet: { + ...def.selectionSet, + selections: aliasFieldsInSelection(prefix, selections, document), + }, + }; + }, + }; + return visit(document, transformer, ({ [Kind.DOCUMENT]: [`definitions`] } as unknown) as VisitorKeyMap< + ASTKindToNode + >); +} + +/** + * Add aliases to fields of the selection, including top-level fields of inline fragments. + * Fragment spreads are converted to inline fragments and their top-level fields are also aliased. + * + * Note that this method is shallow. It adds aliases only to the top-level fields and doesn't + * descend to field sub-selections. + * + * For example, transforms: + * { + * foo + * ... on Query { foo } + * ...FragmentWithBarField + * } + * To: + * { + * graphqlTools1_foo: foo + * ... on Query { graphqlTools1_foo: foo } + * ... on Query { graphqlTools1_bar: bar } + * } + */ +function aliasFieldsInSelection( + prefix: string, + selections: ReadonlyArray, + document: DocumentNode +): Array { + return selections.map(selection => { + switch (selection.kind) { + case Kind.INLINE_FRAGMENT: + return aliasFieldsInInlineFragment(prefix, selection, document); + case Kind.FRAGMENT_SPREAD: { + const inlineFragment = inlineFragmentSpread(selection, document); + return aliasFieldsInInlineFragment(prefix, inlineFragment, document); + } + case Kind.FIELD: + default: + return aliasField(selection, prefix); + } + }); +} + +/** + * Add aliases to top-level fields of the inline fragment. + * Returns new inline fragment node. + * + * For Example, transforms: + * ... on Query { foo, ... on Query { bar: foo } } + * To + * ... on Query { graphqlTools1_foo: foo, ... on Query { graphqlTools1_bar: foo } } + */ +function aliasFieldsInInlineFragment( + prefix: string, + fragment: InlineFragmentNode, + document: DocumentNode +): InlineFragmentNode { + const { selections } = fragment.selectionSet; + return { + ...fragment, + selectionSet: { + ...fragment.selectionSet, + selections: aliasFieldsInSelection(prefix, selections, document), + }, + }; +} + +/** + * Replaces fragment spread with inline fragment + * + * Example: + * query { ...Spread } + * fragment Spread on Query { bar } + * + * Transforms to: + * query { ... on Query { bar } } + */ +function inlineFragmentSpread(spread: FragmentSpreadNode, document: DocumentNode): InlineFragmentNode { + const fragment = document.definitions.find( + def => isFragmentDefinition(def) && def.name.value === spread.name.value + ) as FragmentDefinitionNode; + if (!fragment) { + throw new Error(`Fragment ${spread.name.value} does not exist`); + } + const { typeCondition, selectionSet } = fragment; + return { + kind: Kind.INLINE_FRAGMENT, + typeCondition, + selectionSet, + directives: spread.directives, + }; +} + +function prefixNodeName( + namedNode: T, + prefix: string +): T { + return { + ...namedNode, + name: { + ...namedNode.name, + value: prefix + namedNode.name.value, + }, + }; +} + +/** + * Returns a new FieldNode with prefixed alias + * + * Example. Given prefix === "graphqlTools1_" transforms: + * { foo } -> { graphqlTools1_foo: foo } + * { foo: bar } -> { graphqlTools1_foo: bar } + */ +function aliasField(field: FieldNode, aliasPrefix: string): FieldNode { + const aliasNode = field.alias ? field.alias : field.name; + return { + ...field, + alias: { + ...aliasNode, + value: aliasPrefix + aliasNode.value, + }, + }; +} + +function isOperationDefinition(def: DefinitionNode): def is OperationDefinitionNode { + return def.kind === Kind.OPERATION_DEFINITION; +} + +function isFragmentDefinition(def: DefinitionNode): def is FragmentDefinitionNode { + return def.kind === Kind.FRAGMENT_DEFINITION; +} diff --git a/packages/delegate/src/prefix.ts b/packages/delegate/src/prefix.ts new file mode 100644 index 00000000000..73cb9d52323 --- /dev/null +++ b/packages/delegate/src/prefix.ts @@ -0,0 +1,13 @@ +// adapted from https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-source-graphql/src/batching/merge-queries.js + +export function createPrefix(index: number): string { + return `graphqlTools${index}_`; +} + +export function parseKey(prefixedKey: string): { index: number; originalKey: string } { + const match = /^graphqlTools([\d]+)_(.*)$/.exec(prefixedKey); + if (match && match.length === 3 && !isNaN(Number(match[1])) && match[2]) { + return { index: Number(match[1]), originalKey: match[2] }; + } + return null; +} diff --git a/packages/delegate/src/splitResult.ts b/packages/delegate/src/splitResult.ts new file mode 100644 index 00000000000..fd2682a8e60 --- /dev/null +++ b/packages/delegate/src/splitResult.ts @@ -0,0 +1,63 @@ +// adapted from https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-source-graphql/src/batching/merge-queries.js + +import { ExecutionResult, GraphQLError } from 'graphql'; + +import { relocatedError } from '@graphql-tools/utils'; + +import { parseKey } from './prefix'; + +/** + * Split and transform result of the query produced by the `merge` function + */ +export function splitResult(mergedResult: ExecutionResult, numResults: number): Array { + const splitResults: Array = []; + for (let i = 0; i < numResults; i++) { + splitResults.push({}); + } + + const data = mergedResult.data; + if (data) { + Object.keys(data).forEach(prefixedKey => { + const { index, originalKey } = parseKey(prefixedKey); + if (!splitResults[index].data) { + splitResults[index].data = { [originalKey]: data[prefixedKey] }; + } else { + splitResults[index].data[originalKey] = data[prefixedKey]; + } + }); + } + + const errors = mergedResult.errors; + if (errors) { + const newErrors: Record> = Object.create(null); + errors.forEach(error => { + if (error.path) { + const parsedKey = parseKey(error.path[0] as string); + if (parsedKey) { + const { index, originalKey } = parsedKey; + const newError = relocatedError(error, [originalKey, ...error.path.slice(1)]); + if (!newErrors[index]) { + newErrors[index] = [newError]; + } else { + newErrors[index].push(newError); + } + return; + } + } + + splitResults.forEach((_splitResult, index) => { + if (!newErrors[index]) { + newErrors[index] = [error]; + } else { + newErrors[index].push(error); + } + }); + }); + + Object.keys(newErrors).forEach(index => { + splitResults[index].errors = newErrors[index]; + }); + } + + return splitResults; +} diff --git a/packages/delegate/src/types.ts b/packages/delegate/src/types.ts index 3e5cf6f20fe..b4868f67962 100644 --- a/packages/delegate/src/types.ts +++ b/packages/delegate/src/types.ts @@ -16,6 +16,7 @@ import { import { Operation, Transform, Request, TypeMap, ExecutionResult } from '@graphql-tools/utils'; import { Subschema } from './Subschema'; +import DataLoader from 'dataloader'; export interface DelegationContext { subschema: GraphQLSchema | SubschemaConfig; @@ -123,7 +124,7 @@ export interface ICreateProxyingResolverOptions { export type CreateProxyingResolverFn = (options: ICreateProxyingResolverOptions) => GraphQLFieldResolver; -export interface SubschemaConfig { +export interface SubschemaConfig { schema: GraphQLSchema; rootValue?: Record; executor?: Executor; @@ -131,6 +132,10 @@ export interface SubschemaConfig { createProxyingResolver?: CreateProxyingResolverFn; transforms?: Array; merge?: Record; + batch?: boolean; + batchingOptions?: { + dataLoaderOptions?: DataLoader.Options; + }; } export interface MergedTypeConfig { diff --git a/packages/delegate/tests/batchExecution.test.ts b/packages/delegate/tests/batchExecution.test.ts new file mode 100644 index 00000000000..5eb8c079f46 --- /dev/null +++ b/packages/delegate/tests/batchExecution.test.ts @@ -0,0 +1,61 @@ +import { graphql, execute, ExecutionResult } from 'graphql'; + +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { delegateToSchema, SubschemaConfig, ExecutionParams, SyncExecutor } from '../src'; + +describe('batch execution', () => { + it('should batch', async () => { + const innerSchema = makeExecutableSchema({ + typeDefs: ` + type Query { + field1: String + field2: String + } + `, + resolvers: { + Query: { + field1: () => 'test1', + field2: () => 'test2', + }, + }, + }); + + let executions = 0; + + const innerSubschemaConfig: SubschemaConfig = { + schema: innerSchema, + batch: true, + executor: ((params: ExecutionParams): ExecutionResult => { + executions++; + return execute(innerSchema, params.document, undefined, params.context, params.variables) as ExecutionResult; + }) as SyncExecutor + } + + const outerSchema = makeExecutableSchema({ + typeDefs: ` + type Query { + field1: String + field2: String + } + `, + resolvers: { + Query: { + field1: (_parent, _args, context, info) => delegateToSchema({ schema: innerSubschemaConfig, context, info }), + field2: (_parent, _args, context, info) => delegateToSchema({ schema: innerSubschemaConfig, context, info }), + }, + }, + }); + + const expectedResult = { + data: { + field1: 'test1', + field2: 'test2', + }, + }; + + const result = await graphql(outerSchema, '{ field1 field2 }', undefined, {}); + + expect(result).toEqual(expectedResult); + expect(executions).toEqual(1); + }); +});