diff --git a/.changeset/wild-mangos-deny.md b/.changeset/wild-mangos-deny.md new file mode 100644 index 00000000000..a6e098b013e --- /dev/null +++ b/.changeset/wild-mangos-deny.md @@ -0,0 +1,12 @@ +--- +'@graphql-tools/delegate': major +'@graphql-tools/mock': major +'@graphql-tools/utils': major +'@graphql-tools/wrap': major +'@graphql-tools/batch-delegate': major +'@graphql-tools/batch-execute': major +'graphql-tools': major +'@graphql-tools/stitch': major +--- + +defer support diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bcd0e515c36..afd4cbddc56 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,11 +31,11 @@ jobs: - name: Lint run: yarn lint build: - name: Build on ${{matrix.os}} GraphQL v${{matrix.graphql_version}} + name: Build on ${{matrix.os}} GraphQL ${{matrix.graphql_version_or_tag}} runs-on: ubuntu-latest strategy: matrix: - graphql_version: [14, 15] + graphql_version_or_tag: [experimental-stream-defer] steps: - name: Checkout Master uses: actions/checkout@v2 @@ -47,23 +47,23 @@ jobs: uses: actions/cache@v2 with: path: '**/node_modules' - key: ${{ runner.os }}-16-${{matrix.graphql_version}}-yarn-${{ hashFiles('yarn.lock') }} + key: ${{ runner.os }}-16-${{matrix.graphql_version_or_tag}}-yarn-${{ hashFiles('yarn.lock') }} restore-keys: | - ${{ runner.os }}-16-${{matrix.graphql_version}}-yarn - - name: Use GraphQL v${{matrix.graphql_version}} - run: node ./scripts/match-graphql.js ${{matrix.graphql_version}} + ${{ runner.os }}-16-${{matrix.graphql_version_or_tag}}-yarn + - name: Use GraphQL ${{matrix.graphql_version_or_tag}} + run: node ./scripts/match-graphql.js ${{matrix.graphql_version_or_tag}} - name: Install Dependencies using Yarn run: yarn install --ignore-engines && git checkout yarn.lock - name: Build run: yarn ts:transpile test: - name: Test on ${{matrix.os}}, Node ${{matrix.node_version}} and GraphQL v${{matrix.graphql_version}} + name: Test on ${{matrix.os}}, Node ${{matrix.node_version}} and GraphQL ${{matrix.graphql_version_or_tag}} runs-on: ${{matrix.os}} strategy: matrix: os: [ubuntu-latest] # remove windows to speed up the tests node_version: [10, 16] - graphql_version: [14, 15] + graphql_version_or_tag: [experimental-stream-defer] steps: - name: Checkout Master uses: actions/checkout@v2 @@ -75,20 +75,20 @@ jobs: uses: actions/cache@v2 with: path: '**/node_modules' - key: ${{ runner.os }}-${{matrix.node_version}}-${{matrix.graphql_version}}-yarn-${{ hashFiles('yarn.lock') }} + key: ${{ runner.os }}-${{matrix.node_version}}-${{matrix.graphql_version_or_tag}}-yarn-${{ hashFiles('yarn.lock') }} restore-keys: | - ${{ runner.os }}-${{matrix.node_version}}-${{matrix.graphql_version}}-yarn - - name: Use GraphQL v${{matrix.graphql_version}} - run: node ./scripts/match-graphql.js ${{matrix.graphql_version}} + ${{ runner.os }}-${{matrix.node_version}}-${{matrix.graphql_version_or_tag}}-yarn + - name: Use GraphQL ${{matrix.graphql_version_or_tag}} + run: node ./scripts/match-graphql.js ${{matrix.graphql_version_or_tag}} - name: Install Dependencies using Yarn run: yarn install --ignore-engines && git checkout yarn.lock - name: Cache Jest uses: actions/cache@v2 with: path: .cache/jest - key: ${{ runner.os }}-${{matrix.node_version}}-${{matrix.graphql_version}}-jest-${{ hashFiles('yarn.lock') }} + key: ${{ runner.os }}-${{matrix.node_version}}-${{matrix.graphql_version_or_tag}}-jest-${{ hashFiles('yarn.lock') }} restore-keys: | - ${{ runner.os }}-${{matrix.node_version}}-${{matrix.graphql_version}}-jest- + ${{ runner.os }}-${{matrix.node_version}}-${{matrix.graphql_version_or_tag}}-jest- - name: Test run: yarn test --ci env: diff --git a/package.json b/package.json index c14381bb125..b4e76acb6bc 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "eslint-plugin-node": "11.1.0", "eslint-plugin-promise": "5.1.0", "eslint-plugin-standard": "5.0.0", - "graphql": "15.5.0", + "graphql": "15.4.0-experimental-stream-defer.1", "graphql-helix": "1.6.1", "graphql-subscriptions": "1.2.1", "husky": "6.0.0", @@ -93,7 +93,7 @@ ] }, "resolutions": { - "graphql": "15.5.0", + "graphql": "15.4.0-experimental-stream-defer.1", "@changesets/apply-release-plan": "5.0.0" } } diff --git a/packages/batch-delegate/src/batchDelegateToSchema.ts b/packages/batch-delegate/src/batchDelegateToSchema.ts index ee5057522b8..81e47bdd8dc 100644 --- a/packages/batch-delegate/src/batchDelegateToSchema.ts +++ b/packages/batch-delegate/src/batchDelegateToSchema.ts @@ -1,14 +1,89 @@ import { BatchDelegateOptions } from './types'; +import { AsyncExecutionResult, ExecutionResult, getNullableType, GraphQLList } from 'graphql'; + +import { + DelegationContext, + createRequestFromInfo, + externalValueFromResult, + getDelegationContext, + getDelegatingOperation, + Receiver, +} from '@graphql-tools/delegate'; + +import { isAsyncIterable, relocatedError } from '@graphql-tools/utils'; + import { getLoader } from './getLoader'; -export function batchDelegateToSchema(options: BatchDelegateOptions): any { +export async function batchDelegateToSchema(options: BatchDelegateOptions): Promise { const key = options.key; if (key == null) { return null; } else if (Array.isArray(key) && !key.length) { return []; } - const loader = getLoader(options); - return Array.isArray(key) ? loader.loadMany(key) : loader.load(key); + + const { + info, + operationName, + operation = getDelegatingOperation(info.parentType, info.schema), + fieldName = info.fieldName, + returnType = info.returnType, + selectionSet, + fieldNodes, + } = options; + + if (operation !== 'query' && operation !== 'mutation') { + throw new Error(`Batch delegation not possible for operation '${operation}'.`); + } + + const request = createRequestFromInfo({ + info, + operation, + fieldName, + selectionSet, + fieldNodes, + operationName, + }); + + const delegationContext = getDelegationContext({ + request, + onLocatedError: originalError => relocatedError(originalError, originalError.path.slice(1)), + ...options, + operation, + fieldName, + returnType, + }); + + const loader = getLoader(options, request, delegationContext); + + if (Array.isArray(key)) { + const results = await loader.loadMany(key); + + return results.map(result => + onResult(result, { + ...delegationContext, + returnType: (getNullableType(delegationContext.returnType) as GraphQLList).ofType, + }) + ); + } + + const result = await loader.load(key); + return onResult(result, delegationContext); +} + +function onResult( + result: Error | ExecutionResult | AsyncIterableIterator, + delegationContext: DelegationContext +): any { + if (result instanceof Error) { + return result; + } + + if (isAsyncIterable(result)) { + const receiver = new Receiver(result, delegationContext); + return receiver.getInitialValue(); + } + + return externalValueFromResult(result, delegationContext); } diff --git a/packages/batch-delegate/src/createBatchDelegateFn.ts b/packages/batch-delegate/src/createBatchDelegateFn.ts deleted file mode 100644 index 5b335063023..00000000000 --- a/packages/batch-delegate/src/createBatchDelegateFn.ts +++ /dev/null @@ -1,31 +0,0 @@ -import DataLoader from 'dataloader'; - -import { CreateBatchDelegateFnOptions, BatchDelegateOptionsFn, BatchDelegateFn } from './types'; - -import { getLoader } from './getLoader'; - -export function createBatchDelegateFn( - optionsOrArgsFromKeys: CreateBatchDelegateFnOptions | ((keys: ReadonlyArray) => Record), - lazyOptionsFn?: BatchDelegateOptionsFn, - dataLoaderOptions?: DataLoader.Options, - valuesFromResults?: (results: any, keys: ReadonlyArray) => Array -): BatchDelegateFn { - return typeof optionsOrArgsFromKeys === 'function' - ? createBatchDelegateFnImpl({ - argsFromKeys: optionsOrArgsFromKeys, - lazyOptionsFn, - dataLoaderOptions, - valuesFromResults, - }) - : createBatchDelegateFnImpl(optionsOrArgsFromKeys); -} - -function createBatchDelegateFnImpl(options: CreateBatchDelegateFnOptions): BatchDelegateFn { - return batchDelegateOptions => { - const loader = getLoader({ - ...options, - ...batchDelegateOptions, - }); - return loader.load(batchDelegateOptions.key); - }; -} diff --git a/packages/batch-delegate/src/getLoader.ts b/packages/batch-delegate/src/getLoader.ts index 03d2d1b2742..f27ce64ceab 100644 --- a/packages/batch-delegate/src/getLoader.ts +++ b/packages/batch-delegate/src/getLoader.ts @@ -1,9 +1,17 @@ -import { getNamedType, GraphQLOutputType, GraphQLList, GraphQLSchema, FieldNode } from 'graphql'; +import { GraphQLSchema, FieldNode } from 'graphql'; import DataLoader from 'dataloader'; -import { delegateToSchema, SubschemaConfig } from '@graphql-tools/delegate'; -import { relocatedError } from '@graphql-tools/utils'; +import { SubschemaConfig, Transformer, getExecutor, validateRequest, DelegationContext } from '@graphql-tools/delegate'; +import { + AsyncExecutionResult, + ExecutionPatchResult, + ExecutionResult, + isAsyncIterable, + mapAsyncIterator, + Request, + splitAsyncIterator, +} from '@graphql-tools/utils'; import { BatchDelegateOptions } from './types'; @@ -12,43 +20,68 @@ const cache1: WeakMap< WeakMap>> > = new WeakMap(); -function createBatchFn(options: BatchDelegateOptions) { +function createBatchFn(options: BatchDelegateOptions, request: Request, delegationContext: DelegationContext) { const argsFromKeys = options.argsFromKeys ?? ((keys: ReadonlyArray) => ({ ids: keys })); - const { valuesFromResults, lazyOptionsFn } = options; + + const { binding, skipValidation } = options; + + const { fieldName, context, info } = delegationContext; return async (keys: ReadonlyArray) => { - const results = await delegateToSchema({ - returnType: new GraphQLList(getNamedType(options.info.returnType) as GraphQLOutputType), - onLocatedError: originalError => - relocatedError(originalError, originalError.path.slice(0, 0).concat(originalError.path.slice(2))), - args: argsFromKeys(keys), - ...(lazyOptionsFn == null ? options : lazyOptionsFn(options)), + const transformer = new Transformer( + { + ...delegationContext, + args: argsFromKeys(keys), + }, + binding + ); + + const processedRequest = transformer.transformRequest(request); + + if (!skipValidation) { + validateRequest(delegationContext, processedRequest.document); + } + + const executor = getExecutor(delegationContext); + + const batchResult = await executor({ + ...processedRequest, + context, + info, }); - if (results instanceof Error) { - return keys.map(() => results); + const numKeys = keys.length; + if (isAsyncIterable(batchResult)) { + const mappedBatchResult = mapAsyncIterator(batchResult, result => transformer.transformResult(result)); + return splitAsyncIterator(mappedBatchResult, numKeys, result => splitAsyncResult(result, fieldName)); } - const values = valuesFromResults == null ? results : valuesFromResults(results, keys); - - return Array.isArray(values) ? values : keys.map(() => values); + return splitResult(transformer.transformResult(batchResult), fieldName, numKeys); }; } -export function getLoader(options: BatchDelegateOptions): DataLoader { +export function getLoader( + options: BatchDelegateOptions, + request: Request, + delegationContext: DelegationContext +): DataLoader, C> { const fieldName = options.fieldName ?? options.info.fieldName; - let cache2: WeakMap>> = cache1.get( - options.info.fieldNodes - ); + let cache2: WeakMap< + GraphQLSchema | SubschemaConfig, + Record, C>> + > = cache1.get(options.info.fieldNodes); if (cache2 === undefined) { cache2 = new WeakMap(); cache1.set(options.info.fieldNodes, cache2); const loaders = Object.create(null); cache2.set(options.schema, loaders); - const batchFn = createBatchFn(options); - const loader = new DataLoader(keys => batchFn(keys), options.dataLoaderOptions); + const batchFn = createBatchFn(options, request, delegationContext); + const loader = new DataLoader, C>( + keys => batchFn(keys), + options.dataLoaderOptions + ); loaders[fieldName] = loader; return loader; } @@ -58,8 +91,11 @@ export function getLoader(options: BatchDelegateOptions if (loaders === undefined) { loaders = Object.create(null); cache2.set(options.schema, loaders); - const batchFn = createBatchFn(options); - const loader = new DataLoader(keys => batchFn(keys), options.dataLoaderOptions); + const batchFn = createBatchFn(options, request, delegationContext); + const loader = new DataLoader, C>( + keys => batchFn(keys), + options.dataLoaderOptions + ); loaders[fieldName] = loader; return loader; } @@ -67,10 +103,84 @@ export function getLoader(options: BatchDelegateOptions let loader = loaders[fieldName]; if (loader === undefined) { - const batchFn = createBatchFn(options); - loader = new DataLoader(keys => batchFn(keys), options.dataLoaderOptions); + const batchFn = createBatchFn(options, request, delegationContext); + loader = new DataLoader, C>( + keys => batchFn(keys), + options.dataLoaderOptions + ); loaders[fieldName] = loader; } return loader; } + +function splitResult(result: ExecutionResult, fieldName: string, numItems: number): Array { + const { data, errors } = result; + const fieldData = data?.[fieldName]; + + if (fieldData === undefined) { + if (errors === undefined) { + return Array(numItems).fill({}); + } + + return Array(numItems).fill({ errors }); + } + + return fieldData.map((value: any) => ({ + data: { + [fieldName]: value, + }, + errors, + })); +} + +function splitAsyncResult(result: AsyncExecutionResult, fieldName: string): [[number, AsyncExecutionResult]] { + const { data, errors, path } = result as ExecutionPatchResult; + + if (path === undefined || path.length === 0) { + const fieldData = data?.[fieldName]; + if (fieldData !== undefined) { + return fieldData.map((value: any, index: number) => [ + index, + { + data: { + [fieldName]: value, + }, + errors, + }, + ]); + } + } else if (path[0] === fieldName) { + const index = path[1] as number; + + if (path.length === 2) { + return [ + [ + index, + { + ...result, + data: { + [fieldName]: data, + }, + errors, + }, + ], + ]; + } + + const newPath = [fieldName, ...path.slice(2)]; + return [ + [ + index, + { + ...result, + data, + errors, + path: newPath, + }, + ], + ]; + } + + return [[undefined, result]]; +} diff --git a/packages/batch-delegate/src/index.ts b/packages/batch-delegate/src/index.ts index 2b26b57b77b..acc6780239a 100644 --- a/packages/batch-delegate/src/index.ts +++ b/packages/batch-delegate/src/index.ts @@ -1,4 +1,3 @@ export * from './batchDelegateToSchema'; -export * from './createBatchDelegateFn'; export * from './types'; diff --git a/packages/batch-delegate/src/types.ts b/packages/batch-delegate/src/types.ts index 8c30f74ecae..2cd886e227f 100644 --- a/packages/batch-delegate/src/types.ts +++ b/packages/batch-delegate/src/types.ts @@ -1,14 +1,6 @@ -import { FieldNode, GraphQLSchema } from 'graphql'; - import DataLoader from 'dataloader'; -import { IDelegateToSchemaOptions, SubschemaConfig } from '@graphql-tools/delegate'; - -// TODO: remove in next major release -export type DataLoaderCache = WeakMap< - ReadonlyArray, - WeakMap> ->; +import { IDelegateToSchemaOptions } from '@graphql-tools/delegate'; export type BatchDelegateFn, K = any> = ( batchDelegateOptions: BatchDelegateOptions @@ -23,14 +15,4 @@ export interface BatchDelegateOptions, K = any, V dataLoaderOptions?: DataLoader.Options; key: K; argsFromKeys?: (keys: ReadonlyArray) => Record; - valuesFromResults?: (results: any, keys: ReadonlyArray) => Array; - lazyOptionsFn?: BatchDelegateOptionsFn; -} - -export interface CreateBatchDelegateFnOptions, K = any, V = any, C = K> - extends Partial, 'args' | 'info'>> { - dataLoaderOptions?: DataLoader.Options; - argsFromKeys?: (keys: ReadonlyArray) => Record; - valuesFromResults?: (results: any, keys: ReadonlyArray) => Array; - lazyOptionsFn?: (batchDelegateOptions: BatchDelegateOptions) => IDelegateToSchemaOptions; } diff --git a/packages/batch-delegate/tests/errorPaths.test.ts b/packages/batch-delegate/tests/errorPaths.test.ts new file mode 100644 index 00000000000..b85a5318c25 --- /dev/null +++ b/packages/batch-delegate/tests/errorPaths.test.ts @@ -0,0 +1,153 @@ +import { graphql, GraphQLError } from 'graphql'; +import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; +import { delegateToSchema } from '@graphql-tools/delegate'; +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { stitchSchemas } from '@graphql-tools/stitch'; + +class NotFoundError extends GraphQLError { + constructor(id: unknown) { + super('Not Found', undefined, undefined, undefined, undefined, undefined, { id }); + } +} + +describe('preserves error path indices', () => { + const getProperty = jest.fn((id: unknown) => { + return new NotFoundError(id); + }); + + beforeEach(() => { + getProperty.mockClear(); + }); + + const subschema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Property { + id: ID! + } + + type Object { + id: ID! + propertyId: ID! + } + + type Query { + objects: [Object!]! + propertyById(id: ID!): Property + propertiesByIds(ids: [ID!]!): [Property]! + } + `, + resolvers: { + Query: { + objects: () => { + return [ + { id: '1', propertyId: '1' }, + { id: '2', propertyId: '1' }, + ]; + }, + propertyById: (_, args) => getProperty(args.id), + propertiesByIds: (_, args) => args.ids.map(getProperty), + }, + }, + }); + + const subschemas = [subschema]; + const typeDefs = /* GraphQL */ ` + extend type Object { + property: Property + } + `; + + const query = /* GraphQL */ ` + query { + objects { + id + property { + id + } + } + } + `; + + const expected = { + errors: [ + { + message: 'Not Found', + extensions: { id: '1' }, + path: ['objects', 0, 'property'], + }, + { + message: 'Not Found', + extensions: { id: '1' }, + path: ['objects', 1, 'property'], + }, + ], + data: { + objects: [ + { + id: '1', + property: null as null, + }, + { + id: '2', + property: null as null, + }, + ], + }, + }; + + test('using delegateToSchema', async () => { + const schema = stitchSchemas({ + subschemas, + typeDefs, + resolvers: { + Object: { + property: { + selectionSet: '{ propertyId }', + resolve: (source, _, context, info) => { + return delegateToSchema({ + schema: subschema, + fieldName: 'propertyById', + args: { id: source.propertyId }, + context, + info, + }); + }, + }, + }, + }, + }); + + const result = await graphql(schema, query); + + expect(getProperty).toBeCalledTimes(2); + expect(result).toMatchObject(expected); + }); + + test('using batchDelegateToSchema', async () => { + const schema = stitchSchemas({ + subschemas, + typeDefs, + resolvers: { + Object: { + property: { + selectionSet: '{ propertyId }', + resolve: (source, _, context, info) => { + return batchDelegateToSchema({ + schema: subschema, + fieldName: 'propertiesByIds', + key: source.propertyId, + context, + info, + }); + }, + }, + }, + }, + }); + + const result = await graphql(schema, query); + + expect(getProperty).toBeCalledTimes(1); + expect(result).toMatchObject(expected); + }); +}); diff --git a/packages/batch-delegate/tests/withTransforms.test.ts b/packages/batch-delegate/tests/withTransforms.test.ts index e204dcf8db6..3b6c2752fca 100644 --- a/packages/batch-delegate/tests/withTransforms.test.ts +++ b/packages/batch-delegate/tests/withTransforms.test.ts @@ -1,4 +1,4 @@ -import { graphql, GraphQLList, Kind } from 'graphql'; +import { graphql, Kind } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; @@ -6,7 +6,7 @@ import { stitchSchemas } from '@graphql-tools/stitch'; import { TransformQuery } from '@graphql-tools/wrap' describe('works with complex transforms', () => { - test('using TransformQuery instead of valuesFromResults', async () => { + test('using TransformQuery', async () => { const bookSchema = makeExecutableSchema({ typeDefs: ` type Book { @@ -100,7 +100,6 @@ describe('works with complex transforms', () => { context, info, transforms: [queryTransform], - returnType: new GraphQLList(new GraphQLList(info.schema.getType('Book'))) }), }, }, diff --git a/packages/batch-execute/package.json b/packages/batch-execute/package.json index 83d392e02fd..36b19adf867 100644 --- a/packages/batch-execute/package.json +++ b/packages/batch-execute/package.json @@ -27,6 +27,12 @@ "tslib": "~2.2.0", "value-or-promise": "1.0.8" }, + "devDependencies": { + "@graphql-tools/delegate": "^7.1.2", + "@graphql-tools/mock": "^8.1.1", + "@graphql-tools/schema": "^7.1.3", + "@graphql-tools/utils": "^7.7.3" + }, "publishConfig": { "access": "public", "directory": "dist" diff --git a/packages/batch-execute/src/createBatchingExecutor.ts b/packages/batch-execute/src/createBatchingExecutor.ts index b3ccbe1381c..2db1cbee23c 100644 --- a/packages/batch-execute/src/createBatchingExecutor.ts +++ b/packages/batch-execute/src/createBatchingExecutor.ts @@ -1,57 +1,79 @@ -import { getOperationAST } from 'graphql'; +import { getOperationAST, GraphQLSchema } from 'graphql'; import DataLoader from 'dataloader'; import { ValueOrPromise } from 'value-or-promise'; -import { ExecutionParams, Executor, ExecutionResult } from '@graphql-tools/utils'; +import { AsyncExecutionResult, ExecutionParams, Executor, ExecutionResult } from '@graphql-tools/utils'; import { mergeExecutionParams } from './mergeExecutionParams'; import { splitResult } from './splitResult'; export function createBatchingExecutor( executor: Executor, + targetSchema: GraphQLSchema, dataLoaderOptions?: DataLoader.Options, extensionsReducer?: (mergedExtensions: Record, executionParams: ExecutionParams) => Record ): Executor { const loader = new DataLoader( - createLoadFn(executor, extensionsReducer ?? defaultExtensionsReducer), + createLoadFn(executor, targetSchema, extensionsReducer ?? defaultExtensionsReducer), dataLoaderOptions ); return (executionParams: ExecutionParams) => loader.load(executionParams); } function createLoadFn( - executor: ({ document, context, variables, info }: ExecutionParams) => ExecutionResult | Promise, + executor: ({ + document, + context, + variables, + info, + }: ExecutionParams) => + | ExecutionResult + | AsyncIterableIterator + | Promise>, + targetSchema: GraphQLSchema, extensionsReducer: (mergedExtensions: Record, executionParams: ExecutionParams) => Record ) { - return async (execs: Array): Promise> => { - const execBatches: Array> = []; + return async ( + executionParamSet: Array + ): Promise< + Array< + | ExecutionResult + | AsyncIterableIterator + | Promise> + > + > => { + const batchedExecutionParamSets: 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; + const executionParams = executionParamSet[index]; + let currentBatch: Array = [executionParams]; + batchedExecutionParamSets.push(currentBatch); + const operationType = getOperationAST(executionParams.document, undefined).operation; + while (++index < executionParamSet.length) { + const currentOperationType = getOperationAST(executionParamSet[index].document, undefined).operation; if (operationType === currentOperationType) { - currentBatch.push(execs[index]); + currentBatch.push(executionParamSet[index]); } else { - currentBatch = [execs[index]]; - execBatches.push(currentBatch); + currentBatch = [executionParamSet[index]]; + batchedExecutionParamSets.push(currentBatch); } } - const executionResults: Array> = []; - execBatches.forEach(execBatch => { - const mergedExecutionParams = mergeExecutionParams(execBatch, extensionsReducer); + const executionResults: Array>> = []; + batchedExecutionParamSets.forEach(batchedExecutionParamSet => { + const mergedExecutionParams = mergeExecutionParams(batchedExecutionParamSet, targetSchema, extensionsReducer); executionResults.push(new ValueOrPromise(() => executor(mergedExecutionParams))); }); return ValueOrPromise.all(executionResults).then(resultBatches => { - let results: Array = []; + const results: Array< + | ExecutionResult + | AsyncIterableIterator + | Promise> + > = []; resultBatches.forEach((resultBatch, index) => { - results = results.concat(splitResult(resultBatch, execBatches[index].length)); + results.push(...splitResult(resultBatch, batchedExecutionParamSets[index].length)); }); return results; }).resolve(); diff --git a/packages/batch-execute/src/getBatchingExecutor.ts b/packages/batch-execute/src/getBatchingExecutor.ts index ba267b0fd0c..580d6b761e8 100644 --- a/packages/batch-execute/src/getBatchingExecutor.ts +++ b/packages/batch-execute/src/getBatchingExecutor.ts @@ -2,13 +2,15 @@ import DataLoader from 'dataloader'; import { ExecutionParams, Executor } from '@graphql-tools/utils'; import { createBatchingExecutor } from './createBatchingExecutor'; -import { memoize2of4 } from './memoize'; +import { memoize2of5 } from './memoize'; +import { GraphQLSchema } from 'graphql'; -export const getBatchingExecutor = memoize2of4(function ( +export const getBatchingExecutor = memoize2of5(function ( _context: Record = self ?? window ?? global, executor: Executor, + targetSchema: GraphQLSchema, dataLoaderOptions?: DataLoader.Options, extensionsReducer?: (mergedExtensions: Record, executionParams: ExecutionParams) => Record ): Executor { - return createBatchingExecutor(executor, dataLoaderOptions, extensionsReducer); + return createBatchingExecutor(executor, targetSchema, dataLoaderOptions, extensionsReducer); }); diff --git a/packages/batch-execute/src/memoize.ts b/packages/batch-execute/src/memoize.ts index 8b963a8ded5..352bf991b67 100644 --- a/packages/batch-execute/src/memoize.ts +++ b/packages/batch-execute/src/memoize.ts @@ -1,18 +1,19 @@ -export function memoize2of4< +export function memoize2of5< T1 extends Record, T2 extends Record, T3 extends any, T4 extends any, + T5 extends any, R extends any ->(fn: (A1: T1, A2: T2, A3: T3, A4: T4) => R): (A1: T1, A2: T2, A3: T3, A4: T4) => R { +>(fn: (A1: T1, A2: T2, A3: T3, A4: T4, A5: T5) => R): (A1: T1, A2: T2, A3: T3, A4: T4, A5: T5) => R { let cache1: WeakMap>; - function memoized(a1: T1, a2: T2, a3: T3, a4: T4) { + function memoized(a1: T1, a2: T2, a3: T3, a4: T4, a5: T5) { if (!cache1) { cache1 = new WeakMap(); const cache2: WeakMap = new WeakMap(); cache1.set(a1, cache2); - const newValue = fn(a1, a2, a3, a4); + const newValue = fn(a1, a2, a3, a4, a5); cache2.set(a2, newValue); return newValue; } @@ -21,14 +22,14 @@ export function memoize2of4< if (!cache2) { cache2 = new WeakMap(); cache1.set(a1, cache2); - const newValue = fn(a1, a2, a3, a4); + const newValue = fn(a1, a2, a3, a4, a5); cache2.set(a2, newValue); return newValue; } const cachedValue = cache2.get(a2); if (cachedValue === undefined) { - const newValue = fn(a1, a2, a3, a4); + const newValue = fn(a1, a2, a3, a4, a5); cache2.set(a2, newValue); return newValue; } diff --git a/packages/batch-execute/src/mergeExecutionParams.ts b/packages/batch-execute/src/mergeExecutionParams.ts index 61b4184a634..ab31b633299 100644 --- a/packages/batch-execute/src/mergeExecutionParams.ts +++ b/packages/batch-execute/src/mergeExecutionParams.ts @@ -16,6 +16,7 @@ import { InlineFragmentNode, FieldNode, OperationTypeNode, + GraphQLSchema, } from 'graphql'; import { ExecutionParams } from '@graphql-tools/utils'; @@ -31,6 +32,7 @@ import { createPrefix } from './prefix'; * 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 + * 5. Defer each set of top-level root fields. * * i.e transform: * [ @@ -44,20 +46,25 @@ import { createPrefix } from './prefix'; * $graphqlTools1_id: ID! * $graphqlTools2_id: ID! * ) { - * graphqlTools1_foo: foo, - * graphqlTools1_bar: bar(id: $graphqlTools1_id) - * ... on Query { - * graphqlTools1__baz: baz + * ... on Query @defer(label: "graphqlTools1_") { + * 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 + * ... on Query @defer(label: "graphqlTools2_") { + * graphqlTools2_foo: baz + * graphqlTools2_bar: bar(id: $graphqlTools1_id) + * ... on Query { + * graphqlTools2_baz: baz + * } * } * } */ export function mergeExecutionParams( - execs: Array, + executionParamSets: Array, + targetSchema: GraphQLSchema, extensionsReducer: (mergedExtensions: Record, executionParams: ExecutionParams) => Record ): ExecutionParams { const mergedVariables: Record = Object.create(null); @@ -67,16 +74,47 @@ export function mergeExecutionParams( let mergedExtensions: Record = Object.create(null); let operation: OperationTypeNode; + executionParamSets.forEach((executionParams, index) => { + const prefix = createPrefix(index); - execs.forEach((executionParams, index) => { - const prefixedExecutionParams = prefixExecutionParams(createPrefix(index), executionParams); + const prefixedExecutionParams = prefixExecutionParams(prefix, executionParams); prefixedExecutionParams.document.definitions.forEach(def => { if (isOperationDefinition(def)) { operation = def.operation; - mergedSelections.push(...def.selectionSet.selections); + + const selections = targetSchema.getDirective('defer') + ? [ + { + kind: Kind.INLINE_FRAGMENT, + typeCondition: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: (operation === 'query' ? targetSchema.getQueryType() : targetSchema.getMutationType()).name, + }, + }, + directives: [ + { + kind: Kind.DIRECTIVE, + name: { + kind: Kind.NAME, + value: 'defer', + }, + }, + ], + selectionSet: { + kind: Kind.SELECTION_SET, + selections: def.selectionSet.selections, + }, + }, + ] + : def.selectionSet.selections; + + mergedSelections.push(...selections); mergedVariableDefinitions.push(...(def.variableDefinitions ?? [])); } + if (isFragmentDefinition(def)) { mergedFragmentDefinitions.push(def); } @@ -102,14 +140,14 @@ export function mergeExecutionParams( }, variables: mergedVariables, extensions: mergedExtensions, - context: execs[0].context, - info: execs[0].info, + context: executionParamSets[0].context, + info: executionParamSets[0].info, }; } function prefixExecutionParams(prefix: string, executionParams: ExecutionParams): ExecutionParams { let document = aliasTopLevelFields(prefix, executionParams.document); - const variableNames = Object.keys(executionParams.variables); + const variableNames = executionParams.variables !== undefined ? Object.keys(executionParams.variables) : []; if (variableNames.length === 0) { return { ...executionParams, document }; diff --git a/packages/batch-execute/src/splitResult.ts b/packages/batch-execute/src/splitResult.ts index fd2682a8e60..c62222cd5cc 100644 --- a/packages/batch-execute/src/splitResult.ts +++ b/packages/batch-execute/src/splitResult.ts @@ -2,14 +2,139 @@ import { ExecutionResult, GraphQLError } from 'graphql'; -import { relocatedError } from '@graphql-tools/utils'; +import { + AsyncExecutionResult, + ExecutionPatchResult, + isAsyncIterable, + relocatedError, + splitAsyncIterator, +} from '@graphql-tools/utils'; + +import { ValueOrPromise } from 'value-or-promise'; import { parseKey } from './prefix'; +export function splitResult( + mergedResult: + | ExecutionResult + | AsyncIterableIterator + | Promise>, + numResults: number +): Array< + | ExecutionResult + | AsyncIterableIterator + | Promise> +> { + const result = new ValueOrPromise(() => mergedResult).then(r => + splitExecutionResultOrAsyncIterableIterator(r, numResults) + ); + + const splitResults: Array< + | ExecutionResult + | AsyncIterableIterator + | Promise> + > = []; + for (let i = 0; i < numResults; i++) { + splitResults.push( + result.then(r => r[i]).resolve() as + | ExecutionResult + | AsyncIterableIterator + | Promise> + ); + } + + return splitResults; +} + +export function splitExecutionResultOrAsyncIterableIterator( + mergedResult: ExecutionResult | AsyncIterableIterator, + numResults: number +): Array> { + if (isAsyncIterable(mergedResult)) { + return splitAsyncIterator(mergedResult, numResults, originalResult => + splitExecutionPatchResult(originalResult as ExecutionPatchResult) + ); + } + + return splitExecutionResult(mergedResult, numResults); +} + +function splitExecutionPatchResult(originalResult: ExecutionPatchResult): [[number, ExecutionPatchResult]] { + const path = originalResult.path; + if (path && path.length) { + const { index, originalKey } = parseKey(path[0] as string); + const newPath = ([originalKey] as Array).concat(path.slice(1)); + + const newResult: ExecutionPatchResult = { + ...originalResult, + path: newPath, + }; + + const errors = originalResult.errors; + if (errors) { + const newErrors: Array = []; + errors.forEach(error => { + if (error.path) { + const parsedKey = parseKey(error.path[0] as string); + if (parsedKey) { + const { originalKey } = parsedKey; + const newError = relocatedError(error, [originalKey, ...error.path.slice(1)]); + newErrors.push(newError); + return; + } + } + + newErrors.push(error); + }); + newResult.errors = newErrors; + } + + return [[index, newResult]]; + } + + let resultIndex: number; + const newResult: ExecutionPatchResult = { ...originalResult }; + const data = originalResult.data; + if (data) { + const newData = {}; + Object.keys(data).forEach(prefixedKey => { + const { index, originalKey } = parseKey(prefixedKey); + resultIndex = index; + newData[originalKey] = data[prefixedKey]; + }); + newResult.data = newData; + } + + const errors = originalResult.errors; + if (errors) { + const newErrors: Array = []; + errors.forEach(error => { + if (error.path) { + const parsedKey = parseKey(error.path[0] as string); + if (parsedKey) { + const { index, originalKey } = parsedKey; + resultIndex = index; + const newError = relocatedError(error, [originalKey, ...error.path.slice(1)]); + newErrors.push(newError); + return; + } + } + + newErrors.push(error); + }); + newResult.errors = newErrors; + } + + return [[resultIndex, newResult]]; +} + /** * Split and transform result of the query produced by the `merge` function + * Similar to above, but while an ExecutionPatchResult will only contain a + * data destined for a single target, an ExecutionResult may contain results + * for multiple targets, indexed by key. */ -export function splitResult(mergedResult: ExecutionResult, numResults: number): Array { +function splitExecutionResult(mergedResult: ExecutionResult, numResults: number): Array { const splitResults: Array = []; for (let i = 0; i < numResults; i++) { splitResults.push({}); diff --git a/packages/batch-execute/tests/mergeExecutionParams.spec.ts b/packages/batch-execute/tests/mergeExecutionParams.spec.ts new file mode 100644 index 00000000000..f2225404590 --- /dev/null +++ b/packages/batch-execute/tests/mergeExecutionParams.spec.ts @@ -0,0 +1,48 @@ +import { parse } from 'graphql'; + +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { ExecutionParams } from '@graphql-tools/delegate'; + +import { mergeExecutionParams } from '../src/mergeExecutionParams'; + +describe('mergeExecutionParams', () => { + test('it works', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + object: Object + } + type Object { + field1: String + field2: String + } + `, + }); + + const query1 = parse(`{ object { field1 } }`, { noLocation: true }); + const query2 = parse(`{ object { field2 } }`, { noLocation: true }); + + const mergedParams = mergeExecutionParams([{ document: query1 }, { document: query2 }], schema, () => ({})); + + const expectedMergedResult: ExecutionParams = { + document: parse(`{ + ... on Query @defer { + graphqlTools0_object: object { + field1 + } + } + ... on Query @defer { + graphqlTools1_object: object { + field2 + } + } + }`, { noLocation: true }), + variables: {}, + extensions: {}, + context: undefined, + info: undefined, + }; + + expect(expectedMergedResult).toMatchObject(mergedParams); + }); +}); diff --git a/packages/batch-execute/tests/splitResult.spec.ts b/packages/batch-execute/tests/splitResult.spec.ts new file mode 100644 index 00000000000..973b61b1178 --- /dev/null +++ b/packages/batch-execute/tests/splitResult.spec.ts @@ -0,0 +1,81 @@ +import { graphql } from 'graphql'; + +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { addMocksToSchema } from '@graphql-tools/mock'; +import { isAsyncIterable } from '@graphql-tools/utils'; + +import { splitResult } from '../src/splitResult'; + +describe('splitResult', () => { + test('it works', async () => { + const schema = addMocksToSchema({ + schema: makeExecutableSchema({ + typeDefs: ` + type Query { + object: Object + } + type Object { + field1: String + field2: String + } + `, + }), + }); + + const mergedQuery = `{ + ... on Query @defer { + graphqlTools0_object: object { + field1 + } + } + ... on Query @defer { + graphqlTools1_object: object { + field2 + } + } + }`; + + const result = await graphql(schema, mergedQuery); + + const [zeroResult, oneResult] = splitResult(result, 2); + + const zeroResults = []; + if (isAsyncIterable(zeroResult)) { + for await (const payload of zeroResult) { + zeroResults.push(payload); + } + } + + const oneResults = []; + if (isAsyncIterable(oneResult)) { + for await (const payload of oneResult) { + oneResults.push(payload); + } + } + + expect(zeroResults).toEqual([{ + data: {}, + hasNext: true, + }, { + data: { + object: { + field1: 'Hello World', + }, + }, + path: [], + hasNext: true, + }]); + expect(oneResults).toEqual([{ + data: {}, + hasNext: true, + }, { + data: { + object: { + field2: 'Hello World', + }, + }, + path: [], + hasNext: false, + }]); + }); +}); diff --git a/packages/delegate/package.json b/packages/delegate/package.json index 66a35adeeee..3b99ddf5d3c 100644 --- a/packages/delegate/package.json +++ b/packages/delegate/package.json @@ -22,11 +22,13 @@ "input": "./src/index.ts" }, "dependencies": { + "@ardatan/aggregate-error": "0.0.6", "@graphql-tools/batch-execute": "^7.1.2", "@graphql-tools/schema": "^7.1.5", "@graphql-tools/utils": "^7.7.1", - "@ardatan/aggregate-error": "0.0.6", + "@repeaterjs/repeater": "^3.0.4", "dataloader": "2.0.0", + "is-promise": "4.0.0", "tslib": "~2.2.0", "value-or-promise": "1.0.8" }, diff --git a/packages/delegate/src/Receiver.ts b/packages/delegate/src/Receiver.ts new file mode 100644 index 00000000000..d479467897f --- /dev/null +++ b/packages/delegate/src/Receiver.ts @@ -0,0 +1,259 @@ +import { + ExecutionResult, + getNamedType, + GraphQLObjectType, + GraphQLResolveInfo, + GraphQLSchema, + isCompositeType, + isObjectType, + Kind, + responsePathAsArray, + SelectionSetNode, +} from 'graphql'; + +import DataLoader from 'dataloader'; + +import { Repeater, Stop } from '@repeaterjs/repeater'; + +import { + AsyncExecutionResult, + collectFields, + getResponseKeyFromInfo, + GraphQLExecutionContext, +} from '@graphql-tools/utils'; + +import { DelegationContext, MergedExecutionResult } from './types'; +import { mergeDataAndErrors } from './mergeDataAndErrors'; +import { ExpectantStore } from './expectantStore'; +import { fieldShouldStream } from './fieldShouldStream'; +import { createExternalValue } from './externalValues'; + +export class Receiver { + private readonly asyncIterator: AsyncIterator; + private readonly delegationContext: DelegationContext; + private readonly fieldName: string; + private readonly asyncSelectionSets: Record; + private readonly initialResultDepth: number; + private cache: ExpectantStore; + private stoppers: Array; + private loaders: Record>; + + constructor(asyncIterator: AsyncIterator, delegationContext: DelegationContext) { + this.asyncIterator = asyncIterator; + + this.delegationContext = delegationContext; + const { fieldName, info, asyncSelectionSets } = delegationContext; + + this.fieldName = fieldName; + this.asyncSelectionSets = asyncSelectionSets; + + this.initialResultDepth = info ? responsePathAsArray(info.path).length - 1 : 0; + + this.cache = new ExpectantStore(); + this.stoppers = []; + this.loaders = Object.create(null); + } + + public async getInitialValue(): Promise { + const { subschema, fieldName, context, info, returnType, onLocatedError } = this.delegationContext; + + let initialResult: ExecutionResult; + let initialData: any; + while (initialData == null) { + const payload = await this.asyncIterator.next(); + if (payload.done) { + break; + } + initialResult = payload.value; + initialData = initialResult?.data?.[fieldName]; + } + + const newResult = mergeDataAndErrors(initialData, initialResult.errors, onLocatedError); + this.cache.set(getResponseKeyFromInfo(info), newResult); + + this._iterate(); + + const { data, unpathedErrors } = newResult; + const initialPath = responsePathAsArray(info.path); + return createExternalValue(data, unpathedErrors, initialPath, subschema, context, info, this, returnType); + } + + public update(info: GraphQLResolveInfo, result: MergedExecutionResult): void { + const path = responsePathAsArray(info.path).slice(this.initialResultDepth); + const pathKey = path.join('.'); + + this._update(info, result, pathKey); + } + + private _update(info: GraphQLResolveInfo, result: MergedExecutionResult, pathKey: string): void { + this.onNewResult( + pathKey, + result, + isCompositeType(getNamedType(info.returnType)) + ? { + kind: Kind.SELECTION_SET, + selections: [].concat(...info.fieldNodes.map(fieldNode => fieldNode.selectionSet.selections)), + } + : undefined + ); + } + + public request( + info: GraphQLResolveInfo + ): Promise> { + const path = responsePathAsArray(info.path).slice(this.initialResultDepth); + const pathKey = path.join('.'); + let loader = this.loaders[pathKey]; + + if (loader === undefined) { + loader = this.loaders[pathKey] = new DataLoader(infos => this._request(path, pathKey, infos)); + } + + return loader.load(info); + } + + private async _request( + path: Array, + pathKey: string, + infos: ReadonlyArray + ): Promise> { + const parentPath = path.slice(); + const responseKey = parentPath.pop() as string; + + const indices: Array = []; + let lastSegment = parentPath.length - 1; + while (typeof parentPath[lastSegment] === 'number') { + const index = parentPath.pop() as number; + indices.push(index); + lastSegment--; + } + + const parentKey = parentPath.join('.'); + + const combinedInfo: GraphQLResolveInfo = { + ...infos[0], + fieldNodes: [].concat(...infos.map(info => info.fieldNodes)), + }; + + const parent = this.cache.get(parentKey); + + if (parent === undefined) { + throw new Error(`Parent with key "${parentKey}" not available.`); + } + + let data = parent.data; + for (const index of indices) { + data = data[index]; + } + data = data[responseKey]; + + if (data !== undefined) { + const newResult = { data, unpathedErrors: parent.unpathedErrors }; + this._update(combinedInfo, newResult, pathKey); + } + + if (fieldShouldStream(combinedInfo)) { + return infos.map(() => this._stream(pathKey)); + } + + const result = await this.cache.request(pathKey); + return new Array(infos.length).fill(result); + } + + private _stream(pathKey: string): AsyncIterator { + const cache = this.cache; + return new Repeater(async (push, stop) => { + const initialResult = await cache.request(pathKey); + const initialData = initialResult.data; + + let stopped = false; + stop.then(() => (stopped = true)); + this.stoppers.push(stop); + + let index = 0; + + /* eslint-disable no-unmodified-loop-condition */ + while (!stopped && index < initialData.length) { + const data = initialData[index++]; + await push({ data, unpathedErrors: initialResult.unpathedErrors }); + } + + while (!stopped) { + await push(cache.request(`${pathKey}.${index++}`)); + } + /* eslint-disable no-unmodified-loop-condition */ + }); + } + + private async _iterate(): Promise { + let hasNext = true; + while (hasNext) { + const payload = await this.asyncIterator.next(); + + hasNext = !payload.done; + const asyncResult = payload.value; + + if (asyncResult == null) { + continue; + } + + const path = asyncResult.path; + + if (path[0] !== this.fieldName) { + // TODO: throw error? + continue; + } + + const newResult = mergeDataAndErrors(asyncResult.data, asyncResult.errors, this.delegationContext.onLocatedError); + + this.onNewResult(path.join('.'), newResult, this.asyncSelectionSets[asyncResult.label]); + } + + setTimeout(() => { + this.cache.clear(); + this.stoppers.forEach(stop => stop()); + }); + } + + private onNewResult(pathKey: string, newResult: MergedExecutionResult, selectionSet: SelectionSetNode): void { + const result = this.cache.get(pathKey); + const mergedResult = + result === undefined + ? newResult + : mergeResults(this.delegationContext.info.schema, result.data.__typename, result, newResult, selectionSet); + + this.cache.set(pathKey, mergedResult); + } +} + +export function mergeResults( + schema: GraphQLSchema, + typeName: string, + target: MergedExecutionResult, + source: MergedExecutionResult, + selectionSet: SelectionSetNode +): MergedExecutionResult { + if (isObjectType(schema.getType(typeName))) { + const fieldNodes = collectFields( + { + schema, + variableValues: {}, + fragments: {}, + } as GraphQLExecutionContext, + schema.getType(typeName) as GraphQLObjectType, + selectionSet, + Object.create(null), + Object.create(null) + ); + + const targetData = target.data; + const sourceData = source.data; + Object.keys(fieldNodes).forEach(responseKey => { + targetData[responseKey] = sourceData[responseKey]; + }); + } + + target.unpathedErrors.push(...(source.unpathedErrors ?? [])); + + return target; +} diff --git a/packages/delegate/src/Subschema.ts b/packages/delegate/src/Subschema.ts index 01754e00e43..5c77d3d9047 100644 --- a/packages/delegate/src/Subschema.ts +++ b/packages/delegate/src/Subschema.ts @@ -9,13 +9,9 @@ export function isSubschema(value: any): value is Subschema { return Boolean(value.transformedSchema); } -interface ISubschema> - extends SubschemaConfig { - transformedSchema: GraphQLSchema; -} - export class Subschema> - implements ISubschema { + implements SubschemaConfig +{ public schema: GraphQLSchema; public rootValue?: Record; @@ -28,7 +24,7 @@ export class Subschema> public transforms: Array; public transformedSchema: GraphQLSchema; - public merge?: Record>; + public merge?: Record>; constructor(config: SubschemaConfig) { this.schema = config.schema; diff --git a/packages/delegate/src/Transformer.ts b/packages/delegate/src/Transformer.ts index 59bf49a83ec..253448dc908 100644 --- a/packages/delegate/src/Transformer.ts +++ b/packages/delegate/src/Transformer.ts @@ -23,7 +23,7 @@ export class Transformer { this.transformations.push({ transform, context }); } - public transformRequest(originalRequest: Request) { + public transformRequest(originalRequest: Request): Request { return this.transformations.reduce( (request: Request, transformation: Transformation) => transformation.transform.transformRequest != null @@ -33,7 +33,7 @@ export class Transformer { ); } - public transformResult(originalResult: ExecutionResult) { + public transformResult(originalResult: ExecutionResult): any { return this.transformations.reduceRight( (result: ExecutionResult, transformation: Transformation) => transformation.transform.transformResult != null diff --git a/packages/delegate/src/defaultMergedResolver.ts b/packages/delegate/src/defaultMergedResolver.ts index 8ae4e9ce24d..31e6f813a3d 100644 --- a/packages/delegate/src/defaultMergedResolver.ts +++ b/packages/delegate/src/defaultMergedResolver.ts @@ -1,38 +1,102 @@ -import { defaultFieldResolver, GraphQLResolveInfo } from 'graphql'; +import { GraphQLResolveInfo, defaultFieldResolver, GraphQLList, GraphQLOutputType } from 'graphql'; -import { getResponseKeyFromInfo } from '@graphql-tools/utils'; +import { getResponseKeyFromInfo, mapAsyncIterator } from '@graphql-tools/utils'; -import { resolveExternalValue } from './resolveExternalValue'; -import { getSubschema, getUnpathedErrors, isExternalObject } from './externalObjects'; -import { ExternalObject } from './types'; +import { ExternalObject, MergedExecutionResult } from './types'; + +import { createExternalValue } from './externalValues'; +import { + getInitialPath, + getInitialPossibleFields, + getReceiver, + getSubschema, + getUnpathedErrors, + isExternalObject, +} from './externalObjects'; + +import { getMergedParent } from './getMergedParent'; +import { fieldShouldStream } from './fieldShouldStream'; /** * Resolver that knows how to: * a) handle aliases for proxied schemas * b) handle errors from proxied schemas - * c) handle external to internal enum conversion + * c) handle external to internal enum/scalar conversion + * d) handle type merging + * e) handle deferred values */ export function defaultMergedResolver( parent: ExternalObject, args: Record, context: Record, info: GraphQLResolveInfo -) { - if (!parent) { - return null; +): any { + if (!isExternalObject(parent)) { + return defaultFieldResolver(parent, args, context, info); } const responseKey = getResponseKeyFromInfo(info); - // check to see if parent is not a proxied result, i.e. if parent resolver was manually overwritten - // See https://github.com/apollographql/graphql-tools/issues/967 - if (!isExternalObject(parent)) { - return defaultFieldResolver(parent, args, context, info); + const initialPossibleFields = getInitialPossibleFields(parent); + + if (initialPossibleFields === undefined) { + // TODO: can this be removed in the next major release? + // legacy use of delegation without setting transformedSchema + const data = parent[responseKey]; + if (data !== undefined) { + const unpathedErrors = getUnpathedErrors(parent); + const initialPath = getInitialPath(parent); + const subschema = getSubschema(parent, responseKey); + return createExternalValue(data, unpathedErrors, initialPath, subschema, context, info); + } + } else if (info.fieldNodes[0].name.value in initialPossibleFields) { + return resolveField(parent, responseKey, context, info); } - const data = parent[responseKey]; - const unpathedErrors = getUnpathedErrors(parent); + return getMergedParent(parent, context, info).then(mergedParent => + resolveField(mergedParent, responseKey, context, info) + ); +} + +function resolveField( + parent: ExternalObject, + responseKey: string, + context: Record, + info: GraphQLResolveInfo +): any { + const initialPath = getInitialPath(parent); const subschema = getSubschema(parent, responseKey); + const receiver = getReceiver(parent, subschema); + + const data = parent[responseKey]; + if (receiver !== undefined) { + if (fieldShouldStream(info)) { + return receiver.request(info).then(asyncIterator => { + const listMemberInfo: GraphQLResolveInfo = { + ...info, + returnType: (info.returnType as GraphQLList).ofType, + }; + return mapAsyncIterator(asyncIterator as AsyncIterableIterator, ({ data, unpathedErrors }) => + createExternalValue(data, unpathedErrors, initialPath, subschema, context, listMemberInfo, receiver)); + }); + } + + if (data === undefined) { + return receiver.request(info).then(result => { + const { data, unpathedErrors } = result as MergedExecutionResult; + return createExternalValue(data, unpathedErrors, initialPath, subschema, context, info, receiver); + }); + } + + const unpathedErrors = getUnpathedErrors(parent); + receiver.update(info, { data, unpathedErrors }); + return createExternalValue(data, unpathedErrors, initialPath, subschema, context, info, receiver); + } + + if (data !== undefined) { + const unpathedErrors = getUnpathedErrors(parent); + return createExternalValue(data, unpathedErrors, initialPath, subschema, context, info, receiver); + } - return resolveExternalValue(data, unpathedErrors, subschema, context, info); + // throw error? } diff --git a/packages/delegate/src/delegateToSchema.ts b/packages/delegate/src/delegateToSchema.ts index a120ae46db1..86c9ecbfee4 100644 --- a/packages/delegate/src/delegateToSchema.ts +++ b/packages/delegate/src/delegateToSchema.ts @@ -10,6 +10,7 @@ import { DocumentNode, GraphQLOutputType, GraphQLObjectType, + GraphQLError, } from 'graphql'; import { ValueOrPromise } from 'value-or-promise'; @@ -18,15 +19,33 @@ import AggregateError from '@ardatan/aggregate-error'; import { getBatchingExecutor } from '@graphql-tools/batch-execute'; -import { mapAsyncIterator, ExecutionResult, Executor, ExecutionParams, Subscriber } from '@graphql-tools/utils'; +import { + AsyncExecutionResult, + ExecutionParams, + ExecutionResult, + Executor, + Request, + Subscriber, + isAsyncIterable, + mapAsyncIterator, +} from '@graphql-tools/utils'; -import { IDelegateToSchemaOptions, IDelegateRequestOptions, StitchingInfo, DelegationContext } from './types'; +import { + DelegationBinding, + DelegationContext, + IDelegateToSchemaOptions, + IDelegateRequestOptions, + StitchingInfo, +} from './types'; import { isSubschemaConfig } from './subschemaConfig'; import { Subschema } from './Subschema'; import { createRequestFromInfo, getDelegatingOperation } from './createRequest'; import { Transformer } from './Transformer'; import { memoize2 } from './memoize'; +import { Receiver } from './Receiver'; +import { externalValueFromResult } from './externalValues'; +import { defaultDelegationBinding } from './delegationBindings'; export function delegateToSchema, TArgs = any>( options: IDelegateToSchemaOptions @@ -76,53 +95,23 @@ function getDelegationReturnType( return rootType.getFields()[fieldName].type; } -export function delegateRequest, TArgs = any>(options: IDelegateRequestOptions) { +export function delegateRequest, TArgs = any>( + options: IDelegateRequestOptions +) { const delegationContext = getDelegationContext(options); - const transformer = new Transformer(delegationContext, options.binding); - - const processedRequest = transformer.transformRequest(options.request); - - if (!options.skipValidation) { - validateRequest(delegationContext, processedRequest.document); - } - - const { operation, context, info } = delegationContext; + const operation = delegationContext.operation; if (operation === 'query' || operation === 'mutation') { - const executor = getExecutor(delegationContext); - - return new ValueOrPromise(() => executor({ - ...processedRequest, - context, - info, - })).then(originalResult => transformer.transformResult(originalResult)).resolve(); + return delegateQueryOrMutation(options.request, delegationContext, options.skipValidation, options.binding); } - const subscriber = getSubscriber(delegationContext); - - return subscriber({ - ...processedRequest, - context, - info, - }).then((subscriptionResult: AsyncIterableIterator | ExecutionResult) => { - if (Symbol.asyncIterator in subscriptionResult) { - // "subscribe" to the subscription result and map the result through the transforms - return mapAsyncIterator( - subscriptionResult as AsyncIterableIterator, - originalResult => ({ - [delegationContext.fieldName]: transformer.transformResult(originalResult), - }) - ); - } - - return transformer.transformResult(subscriptionResult as ExecutionResult); - }); + return delegateSubscription(options.request, delegationContext, options.skipValidation, options.binding); } const emptyObject = {}; -function getDelegationContext({ +export function getDelegationContext({ request, schema, operation, @@ -134,7 +123,7 @@ function getDelegationContext({ rootValue, transforms = [], transformedSchema, - skipTypeMerging, + onLocatedError, }: IDelegateRequestOptions): DelegationContext { let operationDefinition: OperationDefinitionNode; let targetOperation: OperationTypeNode; @@ -149,7 +138,7 @@ function getDelegationContext({ if (fieldName == null) { operationDefinition = operationDefinition ?? getOperationAST(request.document, undefined); - targetFieldName = ((operationDefinition.selectionSet.selections[0] as unknown) as FieldDefinitionNode).name.value; + targetFieldName = (operationDefinition.selectionSet.selections[0] as unknown as FieldDefinitionNode).name.value; } else { targetFieldName = fieldName; } @@ -170,13 +159,16 @@ function getDelegationContext({ context, info, rootValue: rootValue ?? subschemaOrSubschemaConfig?.rootValue ?? info?.rootValue ?? emptyObject, - returnType: returnType ?? info?.returnType ?? getDelegationReturnType(targetSchema, targetOperation, targetFieldName), + returnType: + returnType ?? info?.returnType ?? getDelegationReturnType(targetSchema, targetOperation, targetFieldName), transforms: subschemaOrSubschemaConfig.transforms != null ? subschemaOrSubschemaConfig.transforms.concat(transforms) : transforms, - transformedSchema: transformedSchema ?? (subschemaOrSubschemaConfig as Subschema)?.transformedSchema ?? targetSchema, - skipTypeMerging, + transformedSchema: + transformedSchema ?? (subschemaOrSubschemaConfig as Subschema)?.transformedSchema ?? targetSchema, + onLocatedError: onLocatedError ?? ((error: GraphQLError) => error), + asyncSelectionSets: Object.create(null), }; } @@ -190,14 +182,17 @@ function getDelegationContext({ context, info, rootValue: rootValue ?? info?.rootValue ?? emptyObject, - returnType: returnType ?? info?.returnType ?? getDelegationReturnType(subschemaOrSubschemaConfig, targetOperation, targetFieldName), + returnType: + returnType ?? + info?.returnType ?? + getDelegationReturnType(subschemaOrSubschemaConfig, targetOperation, targetFieldName), transforms, transformedSchema: transformedSchema ?? subschemaOrSubschemaConfig, - skipTypeMerging, + asyncSelectionSets: Object.create(null), }; } -function validateRequest(delegationContext: DelegationContext, document: DocumentNode) { +export function validateRequest(delegationContext: DelegationContext, document: DocumentNode) { const errors = validate(delegationContext.targetSchema, document); if (errors.length > 0) { if (errors.length > 1) { @@ -209,7 +204,18 @@ function validateRequest(delegationContext: DelegationContext, document: Documen } } -function getExecutor(delegationContext: DelegationContext): Executor { +const createDefaultExecutor = memoize2(function (schema: GraphQLSchema, rootValue: Record): Executor { + return (({ document, context, variables, info }: ExecutionParams) => + execute({ + schema, + document, + contextValue: context, + variableValues: variables, + rootValue: rootValue ?? info?.rootValue, + })) as Executor; +}); + +export function getExecutor(delegationContext: DelegationContext): Executor { const { subschemaConfig, targetSchema, context, rootValue } = delegationContext; let executor: Executor = @@ -220,6 +226,7 @@ function getExecutor(delegationContext: DelegationContext): Executor { executor = getBatchingExecutor( context, executor, + targetSchema, batchingOptions?.dataLoaderOptions, batchingOptions?.extensionsReducer ); @@ -228,29 +235,109 @@ function getExecutor(delegationContext: DelegationContext): Executor { return executor; } -function getSubscriber(delegationContext: DelegationContext): Subscriber { - const { subschemaConfig, targetSchema, rootValue } = delegationContext; - return subschemaConfig?.subscriber || createDefaultSubscriber(targetSchema, subschemaConfig?.rootValue || rootValue); +function handleExecutionResult( + executionResult: ExecutionResult | AsyncIterableIterator, + delegationContext: DelegationContext, + resultTransformer: (originalResult: ExecutionResult) => ExecutionResult +): any { + if (isAsyncIterable(executionResult)) { + const transformedIterable = mapAsyncIterator(executionResult, value => resultTransformer(value)); + const receiver = new Receiver(transformedIterable, delegationContext); + return receiver.getInitialValue(); + } + + return externalValueFromResult(resultTransformer(executionResult), delegationContext); } -const createDefaultExecutor = memoize2(function (schema: GraphQLSchema, rootValue: Record): Executor { - return (({ document, context, variables, info }: ExecutionParams) => - execute({ - schema, - document, - contextValue: context, - variableValues: variables, - rootValue: rootValue ?? info?.rootValue, - })) as Executor; -}); +export function delegateQueryOrMutation( + request: Request, + delegationContext: DelegationContext, + skipValidation?: boolean, + binding: DelegationBinding = defaultDelegationBinding +) { + const transformer = new Transformer(delegationContext, binding); + + const processedRequest = transformer.transformRequest(request); + + if (!skipValidation) { + validateRequest(delegationContext, processedRequest.document); + } -function createDefaultSubscriber(schema: GraphQLSchema, rootValue: Record) { - return ({ document, context, variables, info }: ExecutionParams) => + const { context, info } = delegationContext; + + const executor = getExecutor(delegationContext); + + return new ValueOrPromise(() => + executor({ + ...processedRequest, + context, + info, + }) + ) + .then(executionResult => + handleExecutionResult(executionResult, delegationContext, originalResult => + transformer.transformResult(originalResult) + ) + ) + .resolve(); +} + +function createDefaultSubscriber(schema: GraphQLSchema, rootValue: Record): Subscriber { + return (async ({ document, context, variables, info }: ExecutionParams) => subscribe({ schema, document, contextValue: context, variableValues: variables, rootValue: rootValue ?? info?.rootValue, - }) as any; + })) as Subscriber; +} + +function getSubscriber(delegationContext: DelegationContext): Subscriber { + const { subschemaConfig, targetSchema, rootValue } = delegationContext; + return subschemaConfig?.subscriber || createDefaultSubscriber(targetSchema, subschemaConfig?.rootValue || rootValue); +} + +function handleSubscriptionResult( + subscriptionResult: AsyncIterableIterator | ExecutionResult, + delegationContext: DelegationContext, + resultTransformer: (originalResult: ExecutionResult) => any +): ExecutionResult | AsyncIterableIterator { + if (isAsyncIterable(subscriptionResult)) { + // "subscribe" to the subscription result and map the result through the transforms + return mapAsyncIterator(subscriptionResult, originalResult => ({ + [delegationContext.fieldName]: externalValueFromResult(resultTransformer(originalResult), delegationContext), + })); + } + + return resultTransformer(subscriptionResult); +} + +export function delegateSubscription( + request: Request, + delegationContext: DelegationContext, + skipValidation = false, + binding = defaultDelegationBinding +) { + const transformer = new Transformer(delegationContext, binding); + + const processedRequest = transformer.transformRequest(request); + + if (!skipValidation) { + validateRequest(delegationContext, processedRequest.document); + } + + const { context, info } = delegationContext; + + const subscriber = getSubscriber(delegationContext); + + return subscriber({ + ...processedRequest, + context, + info, + }).then(subscriptionResult => + handleSubscriptionResult(subscriptionResult, delegationContext, originalResult => + transformer.transformResult(originalResult) + ) + ); } diff --git a/packages/delegate/src/delegationBindings.ts b/packages/delegate/src/delegationBindings.ts index d5946f1f94f..c3ff577090a 100644 --- a/packages/delegate/src/delegationBindings.ts +++ b/packages/delegate/src/delegationBindings.ts @@ -1,38 +1,33 @@ import { Transform, StitchingInfo, DelegationContext } from './types'; -import AddSelectionSets from './transforms/AddSelectionSets'; +import AddFieldNodes from './transforms/AddFieldNodes'; import ExpandAbstractTypes from './transforms/ExpandAbstractTypes'; import WrapConcreteTypes from './transforms/WrapConcreteTypes'; import FilterToSchema from './transforms/FilterToSchema'; -import AddTypenameToAbstract from './transforms/AddTypenameToAbstract'; -import CheckResultAndHandleErrors from './transforms/CheckResultAndHandleErrors'; +import AddTypename from './transforms/AddTypename'; import AddArgumentsAsVariables from './transforms/AddArgumentsAsVariables'; +import StoreAsyncSelectionSets from './transforms/StoreAsyncSelectionSets'; export function defaultDelegationBinding(delegationContext: DelegationContext): Array { - let delegationTransforms: Array = [new CheckResultAndHandleErrors()]; + const delegationTransforms: Array = []; const info = delegationContext.info; const stitchingInfo: StitchingInfo = info?.schema.extensions?.stitchingInfo; if (stitchingInfo != null) { - delegationTransforms = delegationTransforms.concat([ + delegationTransforms.push( new ExpandAbstractTypes(), - new AddSelectionSets( - stitchingInfo.selectionSetsByType, - stitchingInfo.selectionSetsByField, - stitchingInfo.dynamicSelectionSetsByField - ), - new WrapConcreteTypes(), - ]); + new AddFieldNodes(stitchingInfo.fieldNodesByField, stitchingInfo.dynamicFieldNodesByField) + ); } else if (info != null) { - delegationTransforms = delegationTransforms.concat([new WrapConcreteTypes(), new ExpandAbstractTypes()]); - } else { - delegationTransforms.push(new WrapConcreteTypes()); + delegationTransforms.push(new ExpandAbstractTypes()); } + delegationTransforms.push(new WrapConcreteTypes(), new StoreAsyncSelectionSets()); + const transforms = delegationContext.transforms; if (transforms != null) { - delegationTransforms = delegationTransforms.concat(transforms.slice().reverse()); + delegationTransforms.push(...transforms.slice().reverse()); } const args = delegationContext.args; @@ -40,7 +35,7 @@ export function defaultDelegationBinding(delegationContext: DelegationContext): delegationTransforms.push(new AddArgumentsAsVariables(args)); } - delegationTransforms = delegationTransforms.concat([new FilterToSchema(), new AddTypenameToAbstract()]); + delegationTransforms.push(new AddTypename(), new FilterToSchema()); return delegationTransforms; } diff --git a/packages/delegate/src/expectantStore.ts b/packages/delegate/src/expectantStore.ts new file mode 100644 index 00000000000..0a199e1c156 --- /dev/null +++ b/packages/delegate/src/expectantStore.ts @@ -0,0 +1,57 @@ +interface Settler { + resolve(value: T): void; + reject(reason?: any): void; +} + +export class ExpectantStore { + protected settlers: Record>> = {}; + protected cache: Record = {}; + + set(key: string, value: T): void { + if (Array.isArray(value)) { + value.forEach((v, index) => this.set(`${key}.${index}`, v)); + } + + this.cache[key] = value; + const settlers = this.settlers[key]; + if (settlers != null) { + for (const { resolve } of settlers) { + resolve(value); + } + settlers.clear(); + delete this.settlers[key]; + } + } + + get(key: string): T { + return this.cache[key]; + } + + request(key: string): Promise { + const value = this.cache[key]; + + if (value !== undefined) { + return Promise.resolve(value); + } + + let settlers = this.settlers[key]; + if (settlers === undefined) { + settlers = this.settlers[key] = new Set(); + } + + return new Promise((resolve, reject) => { + settlers.add({ resolve, reject }); + }); + } + + clear(): void { + for (const [key, settlers] of Object.entries(this.settlers)) { + for (const { reject } of settlers) { + reject(`"${key}" requested, but never provided.`); + } + } + + this.settlers = {}; + this.cache = {}; + } +} diff --git a/packages/delegate/src/externalObjects.ts b/packages/delegate/src/externalObjects.ts index 631afc45114..11339bdedd2 100644 --- a/packages/delegate/src/externalObjects.ts +++ b/packages/delegate/src/externalObjects.ts @@ -1,98 +1,165 @@ -import { GraphQLSchema, GraphQLError, GraphQLObjectType, SelectionSetNode, locatedError } from 'graphql'; +import { + GraphQLSchema, + GraphQLError, + GraphQLFieldMap, + GraphQLObjectType, + GraphQLResolveInfo, + SelectionSetNode, + locatedError, + responsePathAsArray, +} from 'graphql'; -import { mergeDeep, relocatedError, GraphQLExecutionContext, collectFields } from '@graphql-tools/utils'; +import { relocatedError, GraphQLExecutionContext, collectFields } from '@graphql-tools/utils'; import { SubschemaConfig, ExternalObject } from './types'; -import { OBJECT_SUBSCHEMA_SYMBOL, FIELD_SUBSCHEMA_MAP_SYMBOL, UNPATHED_ERRORS_SYMBOL } from './symbols'; +import { + OBJECT_SUBSCHEMA_SYMBOL, + INITIAL_POSSIBLE_FIELDS, + INFO_SYMBOL, + FIELD_SUBSCHEMA_MAP_SYMBOL, + UNPATHED_ERRORS_SYMBOL, + RECEIVER_MAP_SYMBOL, + INITIAL_PATH_SYMBOL, +} from './symbols'; +import { isSubschemaConfig } from './subschemaConfig'; +import { Receiver } from './Receiver'; +import { Subschema } from './Subschema'; export function isExternalObject(data: any): data is ExternalObject { - return data[UNPATHED_ERRORS_SYMBOL] !== undefined; + return data != null && data[UNPATHED_ERRORS_SYMBOL] !== undefined; } -export function annotateExternalObject( +export function createExternalObject( object: any, errors: Array, - subschema: GraphQLSchema | SubschemaConfig + initialPath: Array, + subschema: GraphQLSchema | SubschemaConfig, + info: GraphQLResolveInfo, + receiver: Receiver ): ExternalObject { - Object.defineProperties(object, { + const schema = isSubschemaConfig(subschema) + ? (subschema as Subschema)?.transformedSchema ?? subschema.schema + : subschema; + + const initialPossibleFields = (schema.getType(object.__typename) as GraphQLObjectType)?.getFields(); + + const receiverMap: Map = new Map(); + receiverMap.set(subschema, receiver); + + const newObject = { ...object }; + + Object.defineProperties(newObject, { + [INITIAL_PATH_SYMBOL]: { value: initialPath }, [OBJECT_SUBSCHEMA_SYMBOL]: { value: subschema }, + [INITIAL_POSSIBLE_FIELDS]: { value: initialPossibleFields }, + [INFO_SYMBOL]: { value: info }, [FIELD_SUBSCHEMA_MAP_SYMBOL]: { value: Object.create(null) }, [UNPATHED_ERRORS_SYMBOL]: { value: errors }, + [RECEIVER_MAP_SYMBOL]: { value: receiverMap }, }); - return object; + + return newObject; +} + +export function getInitialPath(object: ExternalObject): Array { + return object[INITIAL_PATH_SYMBOL]; +} + +export function getSubschema(object: ExternalObject, responseKey?: string): GraphQLSchema | SubschemaConfig { + return responseKey === undefined + ? object[OBJECT_SUBSCHEMA_SYMBOL] + : object[FIELD_SUBSCHEMA_MAP_SYMBOL][responseKey] ?? object[OBJECT_SUBSCHEMA_SYMBOL]; } -export function getSubschema(object: ExternalObject, responseKey: string): GraphQLSchema | SubschemaConfig { - return object[FIELD_SUBSCHEMA_MAP_SYMBOL][responseKey] ?? object[OBJECT_SUBSCHEMA_SYMBOL]; +export function getInitialPossibleFields(object: ExternalObject): GraphQLFieldMap { + return object[INITIAL_POSSIBLE_FIELDS]; +} + +export function getInfo(object: ExternalObject): GraphQLResolveInfo { + return object[INFO_SYMBOL]; } export function getUnpathedErrors(object: ExternalObject): Array { return object[UNPATHED_ERRORS_SYMBOL]; } +export function getReceiver(object: ExternalObject, subschema: GraphQLSchema | SubschemaConfig): Receiver { + return object[RECEIVER_MAP_SYMBOL].get(subschema); +} + export function mergeExternalObjects( - schema: GraphQLSchema, - path: Array, - typeName: string, target: ExternalObject, sources: Array, selectionSets: Array ): ExternalObject { - const results: Array = []; - let errors: Array = []; + const initialPath = getInitialPath(target); + const parentInfo = getInfo(target); + const schema = parentInfo.schema; + const typeName = target.__typename; + const parentPath = responsePathAsArray(parentInfo.path); + + if (target[FIELD_SUBSCHEMA_MAP_SYMBOL] == null) { + target[FIELD_SUBSCHEMA_MAP_SYMBOL] = Object.create(null); + } + + const newFieldSubschemaMap = target[FIELD_SUBSCHEMA_MAP_SYMBOL]; + const newReceiverMap = target[RECEIVER_MAP_SYMBOL]; + const newUnpathedErrors = target[UNPATHED_ERRORS_SYMBOL]; sources.forEach((source, index) => { + const fieldNodes = collectFields( + { + schema, + variableValues: {}, + fragments: {}, + } as GraphQLExecutionContext, + schema.getType(typeName) as GraphQLObjectType, + selectionSets[index], + Object.create(null), + Object.create(null) + ); + if (source instanceof Error || source === null) { - const selectionSet = selectionSets[index]; - const fieldNodes = collectFields( - { - schema, - variableValues: {}, - fragments: {}, - } as GraphQLExecutionContext, - schema.getType(typeName) as GraphQLObjectType, - selectionSet, - Object.create(null), - Object.create(null) - ); - const nullResult = {}; Object.keys(fieldNodes).forEach(responseKey => { if (source instanceof GraphQLError) { - nullResult[responseKey] = relocatedError(source, path.concat([responseKey])); + const basePath = parentPath.slice(initialPath.length); + const tailPath = + source.path.length === parentPath.length ? [responseKey] : source.path.slice(initialPath.length); + const newPath = basePath.concat(tailPath); + target[responseKey] = relocatedError(source, newPath); } else if (source instanceof Error) { - nullResult[responseKey] = locatedError(source, fieldNodes[responseKey], path.concat([responseKey])); + const basePath = parentPath.slice(initialPath.length); + const newPath = basePath.concat([responseKey]); + target[responseKey] = locatedError(source, fieldNodes[responseKey], newPath); } else { - nullResult[responseKey] = null; + target[responseKey] = null; } }); - results.push(nullResult); - } else { - errors = errors.concat(source[UNPATHED_ERRORS_SYMBOL]); - results.push(source); + return; } - }); - - const combinedResult: ExternalObject = results.reduce(mergeDeep, target); - const newFieldSubschemaMap = target[FIELD_SUBSCHEMA_MAP_SYMBOL] ?? Object.create(null); - - results.forEach((source: ExternalObject) => { const objectSubschema = source[OBJECT_SUBSCHEMA_SYMBOL]; - const fieldSubschemaMap = source[FIELD_SUBSCHEMA_MAP_SYMBOL]; - if (fieldSubschemaMap === undefined) { - Object.keys(source).forEach(responseKey => { - newFieldSubschemaMap[responseKey] = objectSubschema; + Object.keys(fieldNodes).forEach(responseKey => { + target[responseKey] = source[responseKey]; + newFieldSubschemaMap[responseKey] = objectSubschema; + }); + + if (isExternalObject(source)) { + const receiverMap = source[RECEIVER_MAP_SYMBOL]; + receiverMap.forEach((receiver, subschema) => { + if (receiver) { + newReceiverMap.set(subschema, receiver); + } }); - } else { - Object.keys(source).forEach(responseKey => { - newFieldSubschemaMap[responseKey] = fieldSubschemaMap[responseKey] ?? objectSubschema; + + newUnpathedErrors.push(...source[UNPATHED_ERRORS_SYMBOL]); + + const fieldSubschemaMap = source[FIELD_SUBSCHEMA_MAP_SYMBOL]; + Object.keys(fieldSubschemaMap).forEach(responseKey => { + newFieldSubschemaMap[responseKey] = fieldSubschemaMap[responseKey]; }); } }); - combinedResult[FIELD_SUBSCHEMA_MAP_SYMBOL] = newFieldSubschemaMap; - combinedResult[OBJECT_SUBSCHEMA_SYMBOL] = target[OBJECT_SUBSCHEMA_SYMBOL]; - combinedResult[UNPATHED_ERRORS_SYMBOL] = target[UNPATHED_ERRORS_SYMBOL].concat(errors); - - return combinedResult; + return target; } diff --git a/packages/delegate/src/externalValues.ts b/packages/delegate/src/externalValues.ts new file mode 100644 index 00000000000..0b82342a437 --- /dev/null +++ b/packages/delegate/src/externalValues.ts @@ -0,0 +1,152 @@ +import { + GraphQLError, + GraphQLList, + GraphQLResolveInfo, + GraphQLOutputType, + GraphQLSchema, + GraphQLType, + getNullableType, + isCompositeType, + isLeafType, + isListType, + locatedError, + responsePathAsArray, +} from 'graphql'; + +import AggregateError from '@ardatan/aggregate-error'; + +import { ExecutionResult, relocatedError } from '@graphql-tools/utils'; + +import { DelegationContext, SubschemaConfig } from './types'; +import { createExternalObject } from './externalObjects'; +import { mergeDataAndErrors } from './mergeDataAndErrors'; +import { Receiver } from './Receiver'; + +export function externalValueFromResult( + originalResult: ExecutionResult, + delegationContext: DelegationContext, + receiver?: Receiver +): any { + const { fieldName, context, subschema, onLocatedError, returnType, info } = delegationContext; + + const data = originalResult.data?.[fieldName]; + const errors = originalResult.errors ?? []; + const initialPath = info ? responsePathAsArray(info.path) : []; + + const { data: newData, unpathedErrors } = mergeDataAndErrors(data, errors, onLocatedError); + + return createExternalValue(newData, unpathedErrors, initialPath, subschema, context, info, receiver, returnType); +} + +export function createExternalValue( + data: any, + unpathedErrors: Array = [], + initialPath: Array, + subschema: GraphQLSchema | SubschemaConfig, + context: Record, + info: GraphQLResolveInfo, + receiver?: Receiver, + returnType: GraphQLOutputType = info?.returnType +): any { + const type = getNullableType(returnType); + + if (data instanceof GraphQLError) { + return relocatedError(data, initialPath.concat(data.path)); + } + + if (data instanceof Error) { + return data; + } + + if (data == null) { + return reportUnpathedErrorsViaNull(unpathedErrors); + } + + if (isLeafType(type)) { + return type.parseValue(data); + } else if (isCompositeType(type)) { + return createExternalObject(data, unpathedErrors, initialPath, subschema, info, receiver); + } else if (isListType(type)) { + return createExternalList(type, data, unpathedErrors, initialPath, subschema, context, info, receiver); + } +} + +function createExternalList( + type: GraphQLList, + list: Array, + unpathedErrors: Array, + initialPath: Array, + subschema: GraphQLSchema | SubschemaConfig, + context: Record, + info: GraphQLResolveInfo, + receiver?: Receiver +) { + return list.map(listMember => + createExternalListMember( + getNullableType(type.ofType), + listMember, + unpathedErrors, + initialPath, + subschema, + context, + info, + receiver + ) + ); +} + +function createExternalListMember( + type: GraphQLType, + listMember: any, + unpathedErrors: Array, + initialPath: Array, + subschema: GraphQLSchema | SubschemaConfig, + context: Record, + info: GraphQLResolveInfo, + receiver?: Receiver +): any { + if (listMember instanceof GraphQLError) { + return relocatedError(listMember, initialPath.concat(listMember.path)); + } + + if (listMember instanceof Error) { + return listMember; + } + + if (listMember == null) { + return reportUnpathedErrorsViaNull(unpathedErrors); + } + + if (isLeafType(type)) { + return type.parseValue(listMember); + } else if (isCompositeType(type)) { + return createExternalObject(listMember, unpathedErrors, initialPath, subschema, info, receiver); + } else if (isListType(type)) { + return createExternalList(type, listMember, unpathedErrors, initialPath, subschema, context, info, receiver); + } +} + +const reportedErrors: WeakMap = new Map(); + +function reportUnpathedErrorsViaNull(unpathedErrors: Array) { + if (unpathedErrors.length) { + const unreportedErrors: Array = []; + unpathedErrors.forEach(error => { + if (!reportedErrors.has(error)) { + unreportedErrors.push(error); + reportedErrors.set(error, true); + } + }); + + if (unreportedErrors.length) { + if (unreportedErrors.length === 1) { + return unreportedErrors[0]; + } + + const combinedError = new AggregateError(unreportedErrors); + return locatedError(combinedError, undefined, unreportedErrors[0].path); + } + } + + return null; +} diff --git a/packages/delegate/src/fieldShouldStream.ts b/packages/delegate/src/fieldShouldStream.ts new file mode 100644 index 00000000000..91d3be819b4 --- /dev/null +++ b/packages/delegate/src/fieldShouldStream.ts @@ -0,0 +1,6 @@ +import { GraphQLResolveInfo } from 'graphql'; + +export function fieldShouldStream(info: GraphQLResolveInfo): boolean { + const directives = info.fieldNodes[0]?.directives; + return directives !== undefined && directives.some(directive => directive.name.value === 'stream'); +} diff --git a/packages/delegate/src/getFieldsNotInSubschema.ts b/packages/delegate/src/getFieldsNotInSubschema.ts deleted file mode 100644 index c3a2249304a..00000000000 --- a/packages/delegate/src/getFieldsNotInSubschema.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { GraphQLSchema, FieldNode, GraphQLObjectType, GraphQLResolveInfo } from 'graphql'; - -import { collectFields, GraphQLExecutionContext } from '@graphql-tools/utils'; - -import { isSubschemaConfig } from './subschemaConfig'; -import { MergedTypeInfo, SubschemaConfig, StitchingInfo } from './types'; -import { memoizeInfoAnd2Objects } from './memoize'; - -function collectSubFields(info: GraphQLResolveInfo, typeName: string): Record> { - let subFieldNodes: Record> = Object.create(null); - const visitedFragmentNames = Object.create(null); - - const type = info.schema.getType(typeName) as GraphQLObjectType; - const partialExecutionContext = ({ - schema: info.schema, - variableValues: info.variableValues, - fragments: info.fragments, - } as unknown) as GraphQLExecutionContext; - - info.fieldNodes.forEach(fieldNode => { - subFieldNodes = collectFields( - partialExecutionContext, - type, - fieldNode.selectionSet, - subFieldNodes, - visitedFragmentNames - ); - }); - - const stitchingInfo = info.schema.extensions.stitchingInfo as StitchingInfo; - const selectionSetsByField = stitchingInfo.selectionSetsByField; - - Object.keys(subFieldNodes).forEach(responseName => { - const fieldName = subFieldNodes[responseName][0].name.value; - const fieldSelectionSet = selectionSetsByField?.[typeName]?.[fieldName]; - if (fieldSelectionSet != null) { - subFieldNodes = collectFields( - partialExecutionContext, - type, - fieldSelectionSet, - subFieldNodes, - visitedFragmentNames - ); - } - }); - - return subFieldNodes; -} - -export const getFieldsNotInSubschema = memoizeInfoAnd2Objects(function ( - info: GraphQLResolveInfo, - subschema: GraphQLSchema | SubschemaConfig, - mergedTypeInfo: MergedTypeInfo -): Array { - const typeMap = isSubschemaConfig(subschema) ? mergedTypeInfo.typeMaps.get(subschema) : subschema.getTypeMap(); - const typeName = mergedTypeInfo.typeName; - const fields = (typeMap[typeName] as GraphQLObjectType).getFields(); - - const subFieldNodes = collectSubFields(info, typeName); - - let fieldsNotInSchema: Array = []; - Object.keys(subFieldNodes).forEach(responseName => { - const fieldName = subFieldNodes[responseName][0].name.value; - if (!(fieldName in fields)) { - fieldsNotInSchema = fieldsNotInSchema.concat(subFieldNodes[responseName]); - } - }); - - return fieldsNotInSchema; -}); diff --git a/packages/delegate/src/getMergedParent.ts b/packages/delegate/src/getMergedParent.ts new file mode 100644 index 00000000000..21fb374ad8f --- /dev/null +++ b/packages/delegate/src/getMergedParent.ts @@ -0,0 +1,398 @@ +import { + FieldNode, + GraphQLObjectType, + GraphQLResolveInfo, + Kind, + SelectionSetNode, + getNamedType, + print, + GraphQLFieldMap, +} from 'graphql'; + +import isPromise from 'is-promise'; + +import DataLoader from 'dataloader'; + +import { getResponseKeyFromInfo } from '@graphql-tools/utils'; + +import { ExternalObject, MergedTypeInfo, StitchingInfo } from './types'; +import { getInfo, getSubschema, mergeExternalObjects } from './externalObjects'; +import { memoize4, memoize3, memoize2 } from './memoize'; +import { Subschema } from './Subschema'; +import { Repeater } from '@repeaterjs/repeater'; + +const loaders: WeakMap>> = new WeakMap(); + +export async function getMergedParent( + parent: ExternalObject, + context: Record, + info: GraphQLResolveInfo +): Promise { + let loader = loaders.get(parent); + if (loader === undefined) { + loader = new DataLoader(infos => getMergedParentsFromInfos(parent, context, infos)); + loaders.set(parent, loader); + } + return loader.load(info); +} + +async function getMergedParentsFromInfos( + parent: ExternalObject, + context: Record, + infos: ReadonlyArray +): Promise>> { + const parentInfo = getInfo(parent); + + const schema = parentInfo.schema; + const stitchingInfo: StitchingInfo = schema.extensions?.stitchingInfo; + const parentTypeName = infos[0].parentType.name; + const mergedTypeInfo = stitchingInfo?.mergedTypes[parentTypeName]; + if (mergedTypeInfo === undefined) { + return infos.map(() => Promise.resolve(parent)); + } + + // In the stitching context, all subschemas are compiled Subschema objects rather than SubschemaConfig objects + const sourceSubschema = getSubschema(parent) as Subschema; + const targetSubschemas = mergedTypeInfo.targetSubschemas.get(sourceSubschema); + if (targetSubschemas === undefined || targetSubschemas.length === 0) { + return infos.map(() => Promise.resolve(parent)); + } + + const sourceSubschemaParentType = sourceSubschema.transformedSchema.getType(parentTypeName) as GraphQLObjectType; + const sourceSubschemaFields = sourceSubschemaParentType.getFields(); + const subschemaFields = mergedTypeInfo.subschemaFields; + const keyResponseKeys: Record> = Object.create(null); + const keyFieldNodeMap: Map = new Map(); + const typeFieldNodes = stitchingInfo?.fieldNodesByField?.[parentTypeName]; + const typeDynamicFieldNodes = stitchingInfo?.dynamicFieldNodesByField?.[parentTypeName]; + + let fieldNodes: Array = []; + infos.forEach(info => { + const responseKey = getResponseKeyFromInfo(info); + const fieldName = info.fieldName; + if (subschemaFields[fieldName] !== undefined) { + fieldNodes.push(...info.fieldNodes); + } else { + if (keyResponseKeys[responseKey] === undefined) { + keyResponseKeys[responseKey] = new Set(); + } + } + + const keyFieldNodes: Array = []; + + const fieldNodesByField = typeFieldNodes?.[fieldName]; + if (fieldNodesByField !== undefined) { + keyFieldNodes.push(...fieldNodesByField); + } + + const dynamicFieldNodesByField = typeDynamicFieldNodes?.[fieldName]; + if (dynamicFieldNodesByField !== undefined) { + info.fieldNodes.forEach(fieldNode => { + dynamicFieldNodesByField.forEach(fieldNodeFn => { + keyFieldNodes.push(...fieldNodeFn(fieldNode)); + }); + }); + } + + if (keyResponseKeys[responseKey] !== undefined) { + keyFieldNodes.forEach(fieldNode => { + const keyResponseKey = fieldNode.alias?.value ?? fieldNode.name.value; + keyResponseKeys[responseKey].add(keyResponseKey); + }); + } + addFieldNodesToMap(keyFieldNodeMap, sourceSubschemaFields, keyFieldNodes); + }); + + fieldNodes = fieldNodes.concat(...Array.from(keyFieldNodeMap.values())); + + const mergedParents = getMergedParentsFromFieldNodes( + mergedTypeInfo, + parent, + fieldNodes, + sourceSubschema, + targetSubschemas, + context, + parentInfo + ); + + return infos.map(info => { + const responseKey = getResponseKeyFromInfo(info); + if (keyResponseKeys[responseKey] === undefined) { + return mergedParents[responseKey]; + } + + return slowRace(Array.from(keyResponseKeys[responseKey].values()).map(keyResponseKey => mergedParents[keyResponseKey])); + }); +} + +function getMergedParentsFromFieldNodes( + mergedTypeInfo: MergedTypeInfo, + object: any, + fieldNodes: Array, + sourceSubschemaOrSourceSubschemas: Subschema | Array, + targetSubschemas: Array, + context: Record, + parentInfo: GraphQLResolveInfo +): Record> { + if (!fieldNodes.length) { + return Object.create(null); + } + + const { proxiableSubschemas, nonProxiableSubschemas } = sortSubschemasByProxiability( + mergedTypeInfo, + sourceSubschemaOrSourceSubschemas, + targetSubschemas, + fieldNodes + ); + + const { delegationMap, unproxiableFieldNodes } = buildDelegationPlan(mergedTypeInfo, fieldNodes, proxiableSubschemas); + + if (!delegationMap.size) { + const mergedParentMap = Object.create(null); + unproxiableFieldNodes.forEach(fieldNode => { + const responseKey = fieldNode.alias?.value ?? fieldNode.name.value; + mergedParentMap[responseKey] = Promise.resolve(object); + }); + return mergedParentMap; + } + + const resultMap: Map | any, SelectionSetNode> = new Map(); + + const mergedParentMap = Object.create(null); + delegationMap.forEach((fieldNodes: Array, s: Subschema) => { + const resolver = mergedTypeInfo.resolvers.get(s); + const selectionSet = { kind: Kind.SELECTION_SET, selections: fieldNodes }; + let maybePromise = resolver(object, context, parentInfo, s, selectionSet); + if (isPromise(maybePromise)) { + maybePromise = maybePromise.then(undefined, error => error); + } + resultMap.set(maybePromise, selectionSet); + + const promise = Promise.resolve(maybePromise).then(result => + mergeExternalObjects( + object, + [result], + [selectionSet] + ) + ); + + fieldNodes.forEach(fieldNode => { + const responseKey = fieldNode.alias?.value ?? fieldNode.name.value; + mergedParentMap[responseKey] = promise; + }); + }); + + const nextPromise = Promise.all(resultMap.keys()) + .then(results => getMergedParentsFromFieldNodes( + mergedTypeInfo, + mergeExternalObjects( + object, + results, + Array.from(resultMap.values()) + ), + unproxiableFieldNodes, + combineSubschemas(sourceSubschemaOrSourceSubschemas, proxiableSubschemas), + nonProxiableSubschemas, + context, + parentInfo + ) + ); + + unproxiableFieldNodes.forEach(fieldNode => { + const responseKey = fieldNode.alias?.value ?? fieldNode.name.value; + mergedParentMap[responseKey] = nextPromise.then(nextParent => nextParent[responseKey]); + }); + + return mergedParentMap; +} + +const sortSubschemasByProxiability = memoize4(function ( + mergedTypeInfo: MergedTypeInfo, + sourceSubschemaOrSourceSubschemas: Subschema | Array, + targetSubschemas: Array, + fieldNodes: Array +): { + proxiableSubschemas: Array; + nonProxiableSubschemas: Array; +} { + // 1. calculate if possible to delegate to given subschema + + const proxiableSubschemas: Array = []; + const nonProxiableSubschemas: Array = []; + + targetSubschemas.forEach(t => { + const selectionSet = mergedTypeInfo.selectionSets.get(t); + const fieldSelectionSets = mergedTypeInfo.fieldSelectionSets.get(t); + if ( + selectionSet != null && + !subschemaTypesContainSelectionSet(mergedTypeInfo, sourceSubschemaOrSourceSubschemas, selectionSet) + ) { + nonProxiableSubschemas.push(t); + } else { + if ( + fieldSelectionSets == null || + fieldNodes.every(fieldNode => { + const fieldName = fieldNode.name.value; + const fieldSelectionSet = fieldSelectionSets[fieldName]; + return ( + fieldSelectionSet == null || + subschemaTypesContainSelectionSet(mergedTypeInfo, sourceSubschemaOrSourceSubschemas, fieldSelectionSet) + ); + }) + ) { + proxiableSubschemas.push(t); + } else { + nonProxiableSubschemas.push(t); + } + } + }); + + return { + proxiableSubschemas, + nonProxiableSubschemas, + }; +}); + +const buildDelegationPlan = memoize3(function ( + mergedTypeInfo: MergedTypeInfo, + fieldNodes: Array, + proxiableSubschemas: Array +): { + delegationMap: Map>; + unproxiableFieldNodes: Array; +} { + const { uniqueFields, nonUniqueFields } = mergedTypeInfo; + const unproxiableFieldNodes: Array = []; + + // 2. for each selection: + + const delegationMap: Map> = new Map(); + fieldNodes.forEach(fieldNode => { + if (fieldNode.name.value === '__typename') { + return; + } + + // 2a. use uniqueFields map to assign fields to subschema if one of possible subschemas + + const uniqueSubschema: Subschema = uniqueFields[fieldNode.name.value]; + if (uniqueSubschema != null) { + if (!proxiableSubschemas.includes(uniqueSubschema)) { + unproxiableFieldNodes.push(fieldNode); + return; + } + + const existingSubschema = delegationMap.get(uniqueSubschema); + if (existingSubschema != null) { + existingSubschema.push(fieldNode); + } else { + delegationMap.set(uniqueSubschema, [fieldNode]); + } + + return; + } + + // 2b. use nonUniqueFields to assign to a possible subschema, + // preferring one of the subschemas already targets of delegation + + let nonUniqueSubschemas: Array = nonUniqueFields[fieldNode.name.value]; + if (nonUniqueSubschemas == null) { + unproxiableFieldNodes.push(fieldNode); + return; + } + + nonUniqueSubschemas = nonUniqueSubschemas.filter(s => proxiableSubschemas.includes(s)); + if (!nonUniqueSubschemas.length) { + unproxiableFieldNodes.push(fieldNode); + return; + } + + const existingSubschema = nonUniqueSubschemas.find(s => delegationMap.has(s)); + if (existingSubschema != null) { + delegationMap.get(existingSubschema).push(fieldNode); + } else { + delegationMap.set(nonUniqueSubschemas[0], [fieldNode]); + } + }); + + return { + delegationMap, + unproxiableFieldNodes, + }; +}); + +const combineSubschemas = memoize2(function ( + subschemaOrSubschemas: Subschema | Array, + additionalSubschemas: Array +): Array { + return Array.isArray(subschemaOrSubschemas) + ? subschemaOrSubschemas.concat(additionalSubschemas) + : [subschemaOrSubschemas].concat(additionalSubschemas); +}); + +const subschemaTypesContainSelectionSet = memoize3(function ( + mergedTypeInfo: MergedTypeInfo, + sourceSubschemaOrSourceSubschemas: Subschema | Array, + selectionSet: SelectionSetNode +) { + if (Array.isArray(sourceSubschemaOrSourceSubschemas)) { + return typesContainSelectionSet( + sourceSubschemaOrSourceSubschemas.map( + sourceSubschema => sourceSubschema.transformedSchema.getType(mergedTypeInfo.typeName) as GraphQLObjectType + ), + selectionSet + ); + } + + return typesContainSelectionSet( + [sourceSubschemaOrSourceSubschemas.transformedSchema.getType(mergedTypeInfo.typeName) as GraphQLObjectType], + selectionSet + ); +}); + +function typesContainSelectionSet(types: Array, selectionSet: SelectionSetNode): boolean { + const fieldMaps = types.map(type => type.getFields()); + + for (const selection of selectionSet.selections) { + if (selection.kind === Kind.FIELD) { + const fields = fieldMaps.map(fieldMap => fieldMap[selection.name.value]).filter(field => field != null); + if (!fields.length) { + return false; + } + + if (selection.selectionSet != null) { + return typesContainSelectionSet( + fields.map(field => getNamedType(field.type)) as Array, + selection.selectionSet + ); + } + } else if (selection.kind === Kind.INLINE_FRAGMENT && selection.typeCondition.name.value === types[0].name) { + return typesContainSelectionSet(types, selection.selectionSet); + } + } + + return true; +} + +function addFieldNodesToMap( + map: Map, + fields: GraphQLFieldMap, + fieldNodes: Array, +): void { + fieldNodes.forEach(fieldNode => { + const fieldName = fieldNode.name.value; + if (!(fieldName in fields)) { + const key = print(fieldNode); + if (!map.has(key)) { + map.set(key, fieldNode); + } + } + }); +} + +async function slowRace(promises: Array>): Promise { + let last: T; + for await (const result of Repeater.merge(promises)) { + last = result; + } + return last; +} diff --git a/packages/delegate/src/index.ts b/packages/delegate/src/index.ts index 93b3a21b408..248a2062f4e 100644 --- a/packages/delegate/src/index.ts +++ b/packages/delegate/src/index.ts @@ -1,11 +1,15 @@ +export * from './Receiver'; export * from './Subschema'; +export * from './Transformer'; export * from './applySchemaTransforms'; export * from './createRequest'; export * from './defaultMergedResolver'; export * from './delegateToSchema'; export * from './delegationBindings'; export * from './externalObjects'; -export * from './resolveExternalValue'; +export * from './externalValues'; +export * from './mergeDataAndErrors'; +export * from './getMergedParent'; export * from './subschemaConfig'; export * from './transforms'; export * from './types'; diff --git a/packages/delegate/src/mergeDataAndErrors.ts b/packages/delegate/src/mergeDataAndErrors.ts new file mode 100644 index 00000000000..aefb34dfd77 --- /dev/null +++ b/packages/delegate/src/mergeDataAndErrors.ts @@ -0,0 +1,70 @@ +import AggregateError from '@ardatan/aggregate-error'; + +import { GraphQLError, locatedError } from 'graphql'; +import { relocatedError } from '@graphql-tools/utils'; + +export function mergeDataAndErrors( + data: any, + errors: ReadonlyArray = [], + onLocatedError = (originalError: GraphQLError) => originalError, + index = 1 +): { data: any; unpathedErrors: Array } { + if (data == null) { + if (!errors.length) { + return { data: null, unpathedErrors: [] }; + } + + if (errors.length === 1) { + const error = onLocatedError(errors[0]); + const newPath = error.path === undefined ? [] : error.path.slice(1); + const newError = relocatedError(error, newPath); + return { data: newError, unpathedErrors: [] }; + } + + const newErrors = errors.map(error => onLocatedError(error)); + const firstError = newErrors[0]; + const newPath = firstError.path === undefined ? [] : firstError.path.slice(1); + + const aggregateError = locatedError(new AggregateError(newErrors), undefined, newPath); + + return { data: aggregateError, unpathedErrors: [] }; + } + + if (!errors.length) { + return { data, unpathedErrors: [] }; + } + + let unpathedErrors: Array = []; + + const errorMap: Record> = Object.create(null); + errors.forEach(error => { + const pathSegment = error.path?.[index]; + if (pathSegment != null) { + const pathSegmentErrors = errorMap[pathSegment]; + if (pathSegmentErrors === undefined) { + errorMap[pathSegment] = [error]; + } else { + pathSegmentErrors.push(error); + } + } else { + unpathedErrors.push(error); + } + }); + + Object.keys(errorMap).forEach(pathSegment => { + if (data[pathSegment] !== undefined) { + const { data: newData, unpathedErrors: newErrors } = mergeDataAndErrors( + data[pathSegment], + errorMap[pathSegment], + onLocatedError, + index + 1 + ); + data[pathSegment] = newData; + unpathedErrors = unpathedErrors.concat(newErrors); + } else { + unpathedErrors = unpathedErrors.concat(errorMap[pathSegment]); + } + }); + + return { data, unpathedErrors }; +} diff --git a/packages/delegate/src/mergeFields.ts b/packages/delegate/src/mergeFields.ts deleted file mode 100644 index 3b44f3fb867..00000000000 --- a/packages/delegate/src/mergeFields.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { - FieldNode, - SelectionNode, - Kind, - GraphQLResolveInfo, - SelectionSetNode, - GraphQLObjectType, - responsePathAsArray, - getNamedType, -} from 'graphql'; - -import { ValueOrPromise } from 'value-or-promise'; - -import { MergedTypeInfo } from './types'; -import { memoize4, memoize3, memoize2 } from './memoize'; -import { mergeExternalObjects } from './externalObjects'; -import { Subschema } from './Subschema'; - -const sortSubschemasByProxiability = memoize4(function ( - mergedTypeInfo: MergedTypeInfo, - sourceSubschemaOrSourceSubschemas: Subschema | Array, - targetSubschemas: Array, - fieldNodes: Array -): { - proxiableSubschemas: Array; - nonProxiableSubschemas: Array; -} { - // 1. calculate if possible to delegate to given subschema - - const proxiableSubschemas: Array = []; - const nonProxiableSubschemas: Array = []; - - targetSubschemas.forEach(t => { - const selectionSet = mergedTypeInfo.selectionSets.get(t); - const fieldSelectionSets = mergedTypeInfo.fieldSelectionSets.get(t); - if ( - selectionSet != null && - !subschemaTypesContainSelectionSet(mergedTypeInfo, sourceSubschemaOrSourceSubschemas, selectionSet) - ) { - nonProxiableSubschemas.push(t); - } else { - if ( - fieldSelectionSets == null || - fieldNodes.every(fieldNode => { - const fieldName = fieldNode.name.value; - const fieldSelectionSet = fieldSelectionSets[fieldName]; - return ( - fieldSelectionSet == null || - subschemaTypesContainSelectionSet(mergedTypeInfo, sourceSubschemaOrSourceSubschemas, fieldSelectionSet) - ); - }) - ) { - proxiableSubschemas.push(t); - } else { - nonProxiableSubschemas.push(t); - } - } - }); - - return { - proxiableSubschemas, - nonProxiableSubschemas, - }; -}); - -const buildDelegationPlan = memoize3(function ( - mergedTypeInfo: MergedTypeInfo, - fieldNodes: Array, - proxiableSubschemas: Array -): { - delegationMap: Map; - unproxiableFieldNodes: Array; -} { - const { uniqueFields, nonUniqueFields } = mergedTypeInfo; - const unproxiableFieldNodes: Array = []; - - // 2. for each selection: - - const delegationMap: Map> = new Map(); - fieldNodes.forEach(fieldNode => { - if (fieldNode.name.value === '__typename') { - return; - } - - // 2a. use uniqueFields map to assign fields to subschema if one of possible subschemas - - const uniqueSubschema: Subschema = uniqueFields[fieldNode.name.value]; - if (uniqueSubschema != null) { - if (!proxiableSubschemas.includes(uniqueSubschema)) { - unproxiableFieldNodes.push(fieldNode); - return; - } - - const existingSubschema = delegationMap.get(uniqueSubschema); - if (existingSubschema != null) { - existingSubschema.push(fieldNode); - } else { - delegationMap.set(uniqueSubschema, [fieldNode]); - } - - return; - } - - // 2b. use nonUniqueFields to assign to a possible subschema, - // preferring one of the subschemas already targets of delegation - - let nonUniqueSubschemas: Array = nonUniqueFields[fieldNode.name.value]; - if (nonUniqueSubschemas == null) { - unproxiableFieldNodes.push(fieldNode); - return; - } - - nonUniqueSubschemas = nonUniqueSubschemas.filter(s => proxiableSubschemas.includes(s)); - if (!nonUniqueSubschemas.length) { - unproxiableFieldNodes.push(fieldNode); - return; - } - - const existingSubschema = nonUniqueSubschemas.find(s => delegationMap.has(s)); - if (existingSubschema != null) { - delegationMap.get(existingSubschema).push(fieldNode); - } else { - delegationMap.set(nonUniqueSubschemas[0], [fieldNode]); - } - }); - - const finalDelegationMap: Map = new Map(); - - delegationMap.forEach((selections, subschema) => { - finalDelegationMap.set(subschema, { - kind: Kind.SELECTION_SET, - selections, - }); - }); - - return { - delegationMap: finalDelegationMap, - unproxiableFieldNodes, - }; -}); - -const combineSubschemas = memoize2(function ( - subschemaOrSubschemas: Subschema | Array, - additionalSubschemas: Array -): Array { - return Array.isArray(subschemaOrSubschemas) - ? subschemaOrSubschemas.concat(additionalSubschemas) - : [subschemaOrSubschemas].concat(additionalSubschemas); -}); - -export function mergeFields( - mergedTypeInfo: MergedTypeInfo, - typeName: string, - object: any, - fieldNodes: Array, - sourceSubschemaOrSourceSubschemas: Subschema | Array, - targetSubschemas: Array, - context: Record, - info: GraphQLResolveInfo -): any { - if (!fieldNodes.length) { - return object; - } - - const { proxiableSubschemas, nonProxiableSubschemas } = sortSubschemasByProxiability( - mergedTypeInfo, - sourceSubschemaOrSourceSubschemas, - targetSubschemas, - fieldNodes - ); - - const { delegationMap, unproxiableFieldNodes } = buildDelegationPlan(mergedTypeInfo, fieldNodes, proxiableSubschemas); - - if (!delegationMap.size) { - return object; - } - - const resultMap: Map, SelectionSetNode> = new Map(); - delegationMap.forEach((selectionSet: SelectionSetNode, s: Subschema) => { - const resolver = mergedTypeInfo.resolvers.get(s); - const valueOrPromise = new ValueOrPromise(() => resolver(object, context, info, s, selectionSet)).catch(error => error); - resultMap.set(valueOrPromise, selectionSet); - }); - - return ValueOrPromise.all(Array.from(resultMap.keys())).then(results => - mergeFields( - mergedTypeInfo, - typeName, - mergeExternalObjects( - info.schema, - responsePathAsArray(info.path), - object.__typename, - object, - results, - Array.from(resultMap.values()) - ), - unproxiableFieldNodes, - combineSubschemas(sourceSubschemaOrSourceSubschemas, proxiableSubschemas), - nonProxiableSubschemas, - context, - info - ) - ).resolve(); -} - -const subschemaTypesContainSelectionSet = memoize3(function ( - mergedTypeInfo: MergedTypeInfo, - sourceSubschemaOrSourceSubschemas: Subschema | Array, - selectionSet: SelectionSetNode -) { - if (Array.isArray(sourceSubschemaOrSourceSubschemas)) { - return typesContainSelectionSet( - sourceSubschemaOrSourceSubschemas.map( - sourceSubschema => sourceSubschema.transformedSchema.getType(mergedTypeInfo.typeName) as GraphQLObjectType - ), - selectionSet - ); - } - - return typesContainSelectionSet( - [sourceSubschemaOrSourceSubschemas.transformedSchema.getType(mergedTypeInfo.typeName) as GraphQLObjectType], - selectionSet - ); -}); - -function typesContainSelectionSet(types: Array, selectionSet: SelectionSetNode): boolean { - const fieldMaps = types.map(type => type.getFields()); - - for (const selection of selectionSet.selections) { - if (selection.kind === Kind.FIELD) { - const fields = fieldMaps.map(fieldMap => fieldMap[selection.name.value]).filter(field => field != null); - if (!fields.length) { - return false; - } - - if (selection.selectionSet != null) { - return typesContainSelectionSet( - fields.map(field => getNamedType(field.type)) as Array, - selection.selectionSet - ); - } - } else if (selection.kind === Kind.INLINE_FRAGMENT && selection.typeCondition.name.value === types[0].name) { - return typesContainSelectionSet(types, selection.selectionSet); - } - } - - return true; -} diff --git a/packages/delegate/src/resolveExternalValue.ts b/packages/delegate/src/resolveExternalValue.ts deleted file mode 100644 index 73495d174ce..00000000000 --- a/packages/delegate/src/resolveExternalValue.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { - GraphQLResolveInfo, - getNullableType, - isCompositeType, - isLeafType, - isListType, - GraphQLError, - GraphQLSchema, - GraphQLCompositeType, - isAbstractType, - GraphQLList, - GraphQLType, - locatedError, -} from 'graphql'; - -import AggregateError from '@ardatan/aggregate-error'; - -import { StitchingInfo, SubschemaConfig } from './types'; -import { annotateExternalObject, isExternalObject } from './externalObjects'; -import { getFieldsNotInSubschema } from './getFieldsNotInSubschema'; -import { mergeFields } from './mergeFields'; -import { Subschema } from './Subschema'; - -export function resolveExternalValue( - result: any, - unpathedErrors: Array, - subschema: GraphQLSchema | SubschemaConfig, - context: Record, - info: GraphQLResolveInfo, - returnType = info.returnType, - skipTypeMerging?: boolean -): any { - const type = getNullableType(returnType); - - if (result instanceof Error) { - return result; - } - - if (result == null) { - return reportUnpathedErrorsViaNull(unpathedErrors); - } - - if (isLeafType(type)) { - return type.parseValue(result); - } else if (isCompositeType(type)) { - return resolveExternalObject(type, result, unpathedErrors, subschema, context, info, skipTypeMerging); - } else if (isListType(type)) { - return resolveExternalList(type, result, unpathedErrors, subschema, context, info, skipTypeMerging); - } -} - -function resolveExternalObject( - type: GraphQLCompositeType, - object: any, - unpathedErrors: Array, - subschema: GraphQLSchema | SubschemaConfig, - context: Record, - info: GraphQLResolveInfo, - skipTypeMerging?: boolean -) { - // if we have already resolved this object, for example, when the identical object appears twice - // in a list, see https://github.com/ardatan/graphql-tools/issues/2304 - if (isExternalObject(object)) { - return object; - } - - annotateExternalObject(object, unpathedErrors, subschema); - - const stitchingInfo: StitchingInfo = info?.schema.extensions?.stitchingInfo; - if (skipTypeMerging || !stitchingInfo) { - return object; - } - - let typeName: string; - - if (isAbstractType(type)) { - const resolvedType = info.schema.getTypeMap()[object.__typename]; - if (resolvedType == null) { - throw new Error( - `Unable to resolve type '${object.__typename}'. Did you forget to include a transform that renames types? Did you delegate to the original subschema rather that the subschema config object containing the transform?` - ); - } - typeName = resolvedType.name; - } else { - typeName = type.name; - } - - const mergedTypeInfo = stitchingInfo.mergedTypes[typeName]; - let targetSubschemas: Array; - - // Within the stitching context, delegation to a stitched GraphQLSchema or SubschemaConfig - // will be redirected to the appropriate Subschema object, from which merge targets can be queried. - if (mergedTypeInfo != null) { - targetSubschemas = mergedTypeInfo.targetSubschemas.get(subschema as Subschema); - } - - // If there are no merge targets from the subschema, return. - if (!targetSubschemas) { - return object; - } - - const fieldNodes = getFieldsNotInSubschema(info, subschema, mergedTypeInfo); - - return mergeFields( - mergedTypeInfo, - typeName, - object, - fieldNodes, - subschema as Subschema, - targetSubschemas, - context, - info - ); -} - -function resolveExternalList( - type: GraphQLList, - list: Array, - unpathedErrors: Array, - subschema: GraphQLSchema | SubschemaConfig, - context: Record, - info: GraphQLResolveInfo, - skipTypeMerging?: boolean -) { - return list.map(listMember => - resolveExternalListMember( - getNullableType(type.ofType), - listMember, - unpathedErrors, - subschema, - context, - info, - skipTypeMerging - ) - ); -} - -function resolveExternalListMember( - type: GraphQLType, - listMember: any, - unpathedErrors: Array, - subschema: GraphQLSchema | SubschemaConfig, - context: Record, - info: GraphQLResolveInfo, - skipTypeMerging?: boolean -): any { - if (listMember instanceof Error) { - return listMember; - } - - if (listMember == null) { - return reportUnpathedErrorsViaNull(unpathedErrors); - } - - if (isLeafType(type)) { - return type.parseValue(listMember); - } else if (isCompositeType(type)) { - return resolveExternalObject(type, listMember, unpathedErrors, subschema, context, info, skipTypeMerging); - } else if (isListType(type)) { - return resolveExternalList(type, listMember, unpathedErrors, subschema, context, info, skipTypeMerging); - } -} - -const reportedErrors: WeakMap = new Map(); - -function reportUnpathedErrorsViaNull(unpathedErrors: Array) { - if (unpathedErrors.length) { - const unreportedErrors: Array = []; - unpathedErrors.forEach(error => { - if (!reportedErrors.has(error)) { - unreportedErrors.push(error); - reportedErrors.set(error, true); - } - }); - - if (unreportedErrors.length) { - if (unreportedErrors.length === 1) { - return unreportedErrors[0]; - } - - const combinedError = new AggregateError(unreportedErrors); - return locatedError(combinedError, undefined, unreportedErrors[0].path); - } - } - - return null; -} diff --git a/packages/delegate/src/symbols.ts b/packages/delegate/src/symbols.ts index dad8b7b958a..df467cf7cee 100644 --- a/packages/delegate/src/symbols.ts +++ b/packages/delegate/src/symbols.ts @@ -1,3 +1,7 @@ +export const INITIAL_PATH_SYMBOL = Symbol('initialPath'); export const UNPATHED_ERRORS_SYMBOL = Symbol('subschemaErrors'); export const OBJECT_SUBSCHEMA_SYMBOL = Symbol('initialSubschema'); +export const INITIAL_POSSIBLE_FIELDS = Symbol('initialPossibleFields'); +export const INFO_SYMBOL = Symbol('info'); export const FIELD_SUBSCHEMA_MAP_SYMBOL = Symbol('subschemaMap'); +export const RECEIVER_MAP_SYMBOL = Symbol('receiverMap'); diff --git a/packages/delegate/src/transforms/AddFieldNodes.ts b/packages/delegate/src/transforms/AddFieldNodes.ts new file mode 100644 index 00000000000..24e34e5d421 --- /dev/null +++ b/packages/delegate/src/transforms/AddFieldNodes.ts @@ -0,0 +1,85 @@ +import { SelectionSetNode, TypeInfo, Kind, FieldNode, SelectionNode, print } from 'graphql'; + +import { Request } from '@graphql-tools/utils'; + +import { Transform, DelegationContext } from '../types'; +import { memoize2 } from '../memoize'; + +import VisitSelectionSets from './VisitSelectionSets'; + +export default class AddFieldNodes implements Transform { + private readonly transformer: VisitSelectionSets; + + constructor( + fieldNodesByField: Record>>, + dynamicFieldNodesByField: Record Array>>> + ) { + this.transformer = new VisitSelectionSets((node, typeInfo) => + visitSelectionSet(node, typeInfo, fieldNodesByField, dynamicFieldNodesByField) + ); + } + + public transformRequest( + originalRequest: Request, + delegationContext: DelegationContext, + transformationContext: Record + ): Request { + return this.transformer.transformRequest(originalRequest, delegationContext, transformationContext); + } +} + +function visitSelectionSet( + node: SelectionSetNode, + typeInfo: TypeInfo, + fieldNodesByField: Record>>, + dynamicFieldNodesByField: Record Array>>> +): SelectionSetNode { + const parentType = typeInfo.getParentType(); + + const newSelections: Map = new Map(); + + if (parentType != null) { + const parentTypeName = parentType.name; + addSelectionsToMap(newSelections, node.selections); + + if (parentTypeName in fieldNodesByField) { + node.selections.forEach(selection => { + if (selection.kind === Kind.FIELD) { + const name = selection.name.value; + const fieldNodes = fieldNodesByField[parentTypeName][name]; + if (fieldNodes != null) { + addSelectionsToMap(newSelections, fieldNodes); + } + } + }); + } + + if (parentTypeName in dynamicFieldNodesByField) { + node.selections.forEach(selection => { + if (selection.kind === Kind.FIELD) { + const name = selection.name.value; + const dynamicFieldNodes = dynamicFieldNodesByField[parentTypeName][name]; + if (dynamicFieldNodes != null) { + dynamicFieldNodes.forEach(fieldNodeFn => { + const fieldNodes = fieldNodeFn(selection); + if (fieldNodes != null) { + addSelectionsToMap(newSelections, fieldNodes); + } + }); + } + } + }); + } + + return { + ...node, + selections: Array.from(newSelections.values()), + }; + } +} + +const addSelectionsToMap = memoize2(function (map: Map, selections: ReadonlyArray): void { + selections.forEach(selection => { + map.set(print(selection), selection); + }); +}); diff --git a/packages/delegate/src/transforms/AddSelectionSets.ts b/packages/delegate/src/transforms/AddSelectionSets.ts deleted file mode 100644 index d725fd1c364..00000000000 --- a/packages/delegate/src/transforms/AddSelectionSets.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { SelectionSetNode, TypeInfo, Kind, FieldNode, SelectionNode, print } from 'graphql'; - -import { Request } from '@graphql-tools/utils'; - -import { Transform, DelegationContext } from '../types'; -import { memoize2 } from '../memoize'; - -import VisitSelectionSets from './VisitSelectionSets'; - -export default class AddSelectionSets implements Transform { - private readonly transformer: VisitSelectionSets; - - constructor( - selectionSetsByType: Record, - selectionSetsByField: Record>, - dynamicSelectionSetsByField: Record SelectionSetNode>>> - ) { - this.transformer = new VisitSelectionSets((node, typeInfo) => - visitSelectionSet(node, typeInfo, selectionSetsByType, selectionSetsByField, dynamicSelectionSetsByField) - ); - } - - public transformRequest( - originalRequest: Request, - delegationContext: DelegationContext, - transformationContext: Record - ): Request { - return this.transformer.transformRequest(originalRequest, delegationContext, transformationContext); - } -} - -function visitSelectionSet( - node: SelectionSetNode, - typeInfo: TypeInfo, - selectionSetsByType: Record, - selectionSetsByField: Record>, - dynamicSelectionSetsByField: Record SelectionSetNode>>> -): SelectionSetNode { - const parentType = typeInfo.getParentType(); - - const newSelections: Map = new Map(); - - if (parentType != null) { - const parentTypeName = parentType.name; - addSelectionsToMap(newSelections, node); - - if (parentTypeName in selectionSetsByType) { - const selectionSet = selectionSetsByType[parentTypeName]; - addSelectionsToMap(newSelections, selectionSet); - } - - if (parentTypeName in selectionSetsByField) { - node.selections.forEach(selection => { - if (selection.kind === Kind.FIELD) { - const name = selection.name.value; - const selectionSet = selectionSetsByField[parentTypeName][name]; - if (selectionSet != null) { - addSelectionsToMap(newSelections, selectionSet); - } - } - }); - } - - if (parentTypeName in dynamicSelectionSetsByField) { - node.selections.forEach(selection => { - if (selection.kind === Kind.FIELD) { - const name = selection.name.value; - const dynamicSelectionSets = dynamicSelectionSetsByField[parentTypeName][name]; - if (dynamicSelectionSets != null) { - dynamicSelectionSets.forEach(selectionSetFn => { - const selectionSet = selectionSetFn(selection); - if (selectionSet != null) { - addSelectionsToMap(newSelections, selectionSet); - } - }); - } - } - }); - } - - return { - ...node, - selections: Array.from(newSelections.values()), - }; - } -} - -const addSelectionsToMap = memoize2(function (map: Map, selectionSet: SelectionSetNode): void { - selectionSet.selections.forEach(selection => { - map.set(print(selection), selection); - }); -}); diff --git a/packages/delegate/src/transforms/AddTypenameToAbstract.ts b/packages/delegate/src/transforms/AddTypename.ts similarity index 76% rename from packages/delegate/src/transforms/AddTypenameToAbstract.ts rename to packages/delegate/src/transforms/AddTypename.ts index 4ffb31f57a7..0a14cac6828 100644 --- a/packages/delegate/src/transforms/AddTypenameToAbstract.ts +++ b/packages/delegate/src/transforms/AddTypename.ts @@ -7,20 +7,19 @@ import { SelectionSetNode, Kind, GraphQLSchema, - isAbstractType, } from 'graphql'; import { Request } from '@graphql-tools/utils'; import { Transform, DelegationContext } from '../types'; -export default class AddTypenameToAbstract implements Transform { +export default class AddTypename implements Transform { public transformRequest( originalRequest: Request, delegationContext: DelegationContext, _transformationContext: Record ): Request { - const document = addTypenameToAbstract(delegationContext.targetSchema, originalRequest.document); + const document = addTypename(delegationContext.targetSchema, originalRequest.document); return { ...originalRequest, document, @@ -28,15 +27,16 @@ export default class AddTypenameToAbstract implements Transform { } } -function addTypenameToAbstract(targetSchema: GraphQLSchema, document: DocumentNode): DocumentNode { +function addTypename(targetSchema: GraphQLSchema, document: DocumentNode): DocumentNode { const typeInfo = new TypeInfo(targetSchema); + const subscriptionType = targetSchema.getSubscriptionType(); return visit( document, visitWithTypeInfo(typeInfo, { [Kind.SELECTION_SET](node: SelectionSetNode): SelectionSetNode | null | undefined { const parentType: GraphQLType = typeInfo.getParentType(); let selections = node.selections; - if (parentType != null && isAbstractType(parentType)) { + if (parentType !== subscriptionType) { selections = selections.concat({ kind: Kind.FIELD, name: { diff --git a/packages/delegate/src/transforms/CheckResultAndHandleErrors.ts b/packages/delegate/src/transforms/CheckResultAndHandleErrors.ts deleted file mode 100644 index 264541de177..00000000000 --- a/packages/delegate/src/transforms/CheckResultAndHandleErrors.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - GraphQLResolveInfo, - GraphQLOutputType, - GraphQLSchema, - GraphQLError, - responsePathAsArray, - locatedError, -} from 'graphql'; - -import AggregateError from '@ardatan/aggregate-error'; - -import { getResponseKeyFromInfo, ExecutionResult, relocatedError } from '@graphql-tools/utils'; - -import { SubschemaConfig, Transform, DelegationContext } from '../types'; -import { resolveExternalValue } from '../resolveExternalValue'; - -export default class CheckResultAndHandleErrors implements Transform { - public transformResult( - originalResult: ExecutionResult, - delegationContext: DelegationContext, - _transformationContext: Record - ): ExecutionResult { - return checkResultAndHandleErrors( - originalResult, - delegationContext.context != null ? delegationContext.context : {}, - delegationContext.info, - delegationContext.fieldName, - delegationContext.subschema, - delegationContext.returnType, - delegationContext.skipTypeMerging, - delegationContext.onLocatedError - ); - } -} - -export function checkResultAndHandleErrors( - result: ExecutionResult, - context: Record, - info: GraphQLResolveInfo, - responseKey: string = getResponseKeyFromInfo(info), - subschema?: GraphQLSchema | SubschemaConfig, - returnType: GraphQLOutputType = info.returnType, - skipTypeMerging?: boolean, - onLocatedError?: (originalError: GraphQLError) => GraphQLError -): any { - const { data, unpathedErrors } = mergeDataAndErrors( - result.data == null ? undefined : result.data[responseKey], - result.errors == null ? [] : result.errors, - info ? responsePathAsArray(info.path) : undefined, - onLocatedError - ); - - return resolveExternalValue(data, unpathedErrors, subschema, context, info, returnType, skipTypeMerging); -} - -export function mergeDataAndErrors( - data: any, - errors: ReadonlyArray, - path: Array, - onLocatedError: (originalError: GraphQLError) => GraphQLError, - index = 1 -): { data: any; unpathedErrors: Array } { - if (data == null) { - if (!errors.length) { - return { data: null, unpathedErrors: [] }; - } - - if (errors.length === 1) { - const error = onLocatedError ? onLocatedError(errors[0]) : errors[0]; - const newPath = - path === undefined ? error.path : error.path === undefined ? path : path.concat(error.path.slice(1)); - - return { data: relocatedError(errors[0], newPath), unpathedErrors: [] }; - } - - const newError = locatedError(new AggregateError(errors), undefined, path); - - return { data: newError, unpathedErrors: [] }; - } - - if (!errors.length) { - return { data, unpathedErrors: [] }; - } - - let unpathedErrors: Array = []; - - const errorMap: Record> = Object.create(null); - errors.forEach(error => { - const pathSegment = error.path?.[index]; - if (pathSegment != null) { - const pathSegmentErrors = errorMap[pathSegment]; - if (pathSegmentErrors === undefined) { - errorMap[pathSegment] = [error]; - } else { - pathSegmentErrors.push(error); - } - } else { - unpathedErrors.push(error); - } - }); - - Object.keys(errorMap).forEach(pathSegment => { - if (data[pathSegment] !== undefined) { - const { data: newData, unpathedErrors: newErrors } = mergeDataAndErrors( - data[pathSegment], - errorMap[pathSegment], - path, - onLocatedError, - index + 1 - ); - data[pathSegment] = newData; - unpathedErrors = unpathedErrors.concat(newErrors); - } else { - unpathedErrors = unpathedErrors.concat(errorMap[pathSegment]); - } - }); - - return { data, unpathedErrors }; -} diff --git a/packages/delegate/src/transforms/ExpandAbstractTypes.ts b/packages/delegate/src/transforms/ExpandAbstractTypes.ts index a2ba8d2abfa..a80a9f88f80 100644 --- a/packages/delegate/src/transforms/ExpandAbstractTypes.ts +++ b/packages/delegate/src/transforms/ExpandAbstractTypes.ts @@ -30,11 +30,9 @@ export default class ExpandAbstractTypes implements Transform { delegationContext.info.schema, targetSchema ); - const reversePossibleTypesMap = flipMapping(possibleTypesMap); const document = expandAbstractTypes( targetSchema, possibleTypesMap, - reversePossibleTypesMap, interfaceExtensionsMap, originalRequest.document ); @@ -79,24 +77,9 @@ function extractPossibleTypes(sourceSchema: GraphQLSchema, targetSchema: GraphQL return { possibleTypesMap, interfaceExtensionsMap }; } -function flipMapping(mapping: Record>): Record> { - const result: Record> = Object.create(null); - Object.keys(mapping).forEach(typeName => { - const toTypeNames = mapping[typeName]; - toTypeNames.forEach(toTypeName => { - if (!(toTypeName in result)) { - result[toTypeName] = []; - } - result[toTypeName].push(typeName); - }); - }); - return result; -} - function expandAbstractTypes( targetSchema: GraphQLSchema, possibleTypesMap: Record>, - reversePossibleTypesMap: Record>, interfaceExtensionsMap: Record>, document: DocumentNode ): DocumentNode { @@ -178,7 +161,7 @@ function expandAbstractTypes( visitWithTypeInfo(typeInfo, { [Kind.SELECTION_SET](node: SelectionSetNode) { let newSelections = node.selections; - const addedSelections = []; + const addedSelections: Array = []; const maybeType = typeInfo.getParentType(); if (maybeType != null) { const parentType: GraphQLNamedType = getNamedType(maybeType); @@ -226,16 +209,6 @@ function expandAbstractTypes( } }); - if (parentType.name in reversePossibleTypesMap) { - addedSelections.push({ - kind: Kind.FIELD, - name: { - kind: Kind.NAME, - value: '__typename', - }, - }); - } - if (interfaceExtensionFields.length) { const possibleTypes = possibleTypesMap[parentType.name]; if (possibleTypes != null) { diff --git a/packages/delegate/src/transforms/StoreAsyncSelectionSets.ts b/packages/delegate/src/transforms/StoreAsyncSelectionSets.ts new file mode 100644 index 00000000000..b03266e0d87 --- /dev/null +++ b/packages/delegate/src/transforms/StoreAsyncSelectionSets.ts @@ -0,0 +1,205 @@ +import { + DirectiveNode, + DocumentNode, + FieldNode, + FragmentSpreadNode, + InlineFragmentNode, + Kind, + SelectionSetNode, + visit, +} from 'graphql'; + +import { Request } from '@graphql-tools/utils'; + +import { Transform, DelegationContext } from '../types'; + +export default class StoreAsyncSelectionSets implements Transform { + private labelNumber: number; + + constructor() { + this.labelNumber = 0; + } + + public transformRequest( + originalRequest: Request, + delegationContext: DelegationContext, + _transformationContext: Record + ): Request { + const { asyncSelectionSets } = delegationContext; + return { + ...originalRequest, + document: this.storeAsyncSelectionSets(originalRequest.document, asyncSelectionSets), + }; + } + + private storeAsyncSelectionSets( + document: DocumentNode, + asyncSelectionSets: Record + ): DocumentNode { + const fragmentSelectionSets: Record = Object.create(null); + + document.definitions.forEach(def => { + if (def.kind === Kind.FRAGMENT_DEFINITION) { + fragmentSelectionSets[def.name.value] = filterSelectionSet(def.selectionSet); + } + }); + + return visit(document, { + [Kind.FIELD]: node => { + const newNode = transformFieldNode(node, this.labelNumber); + + if (newNode === undefined) { + return; + } + + if (node.selectionSet !== undefined) { + asyncSelectionSets[`label_${this.labelNumber}`] = filterSelectionSet(node.selectionSet); + } + + this.labelNumber++; + + return newNode; + }, + [Kind.INLINE_FRAGMENT]: node => { + const newNode = transformFragmentNode(node, this.labelNumber); + + if (newNode === undefined) { + return; + } + + asyncSelectionSets[`label_${this.labelNumber}`] = filterSelectionSet(node.selectionSet); + + this.labelNumber++; + + return newNode; + }, + [Kind.FRAGMENT_SPREAD]: node => { + const newNode = transformFragmentNode(node, this.labelNumber); + + if (newNode === undefined) { + return; + } + + asyncSelectionSets[this.labelNumber] = fragmentSelectionSets[node.name.value]; + + this.labelNumber++; + + return newNode; + }, + }); + } +} + +function transformFragmentNode(node: T, labelNumber: number): T { + const deferIndex = node.directives?.findIndex(directive => directive.name.value === 'defer'); + if (deferIndex === undefined || deferIndex === -1) { + return; + } + + const defer = node.directives[deferIndex]; + + let newDefer: DirectiveNode; + + const args = defer.arguments; + const labelIndex = args?.findIndex(arg => arg.name.value === 'label'); + const newLabel = { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'label', + }, + value: { + kind: Kind.STRING, + value: `label_${labelNumber}`, + }, + }; + + if (labelIndex === undefined) { + newDefer = { + ...defer, + arguments: [newLabel], + }; + } else if (labelIndex === -1) { + newDefer = { + ...defer, + arguments: [...args, newLabel], + }; + } else { + const newArgs = args.slice(); + newArgs.splice(labelIndex, 1, newLabel); + newDefer = { + ...defer, + arguments: newArgs, + }; + } + + const newDirectives = node.directives.slice(); + newDirectives.splice(deferIndex, 1, newDefer); + + return { + ...node, + directives: newDirectives, + }; +} + +function transformFieldNode(node: FieldNode, labelNumber: number): FieldNode { + const streamIndex = node.directives?.findIndex(directive => directive.name.value === 'stream'); + if (streamIndex === undefined || streamIndex === -1) { + return; + } + + const stream = node.directives[streamIndex]; + + let newStream: DirectiveNode; + + const args = stream.arguments; + const labelIndex = args?.findIndex(arg => arg.name.value === 'label'); + const newLabel = { + kind: Kind.ARGUMENT, + name: { + kind: Kind.NAME, + value: 'label', + }, + value: { + kind: Kind.STRING, + value: `label_${labelNumber}`, + }, + }; + + if (labelIndex === undefined) { + newStream = { + ...stream, + arguments: [newLabel], + }; + } else if (labelIndex === -1) { + newStream = { + ...stream, + arguments: [...args, newLabel], + }; + } else { + const newArgs = args.slice(); + newArgs.splice(labelIndex, 1, newLabel); + newStream = { + ...stream, + arguments: newArgs, + }; + } + + const newDirectives = node.directives.slice(); + newDirectives.splice(streamIndex, 1, newStream); + + return { + ...node, + directives: newDirectives, + }; +} + +function filterSelectionSet(selectionSet: SelectionSetNode): SelectionSetNode { + return { + ...selectionSet, + selections: selectionSet.selections.filter( + selection => + selection.directives === undefined || !selection.directives.some(directive => directive.name.value === 'defer') + ), + }; +} diff --git a/packages/delegate/src/transforms/VisitSelectionSets.ts b/packages/delegate/src/transforms/VisitSelectionSets.ts index 3f807241b0c..690fad35466 100644 --- a/packages/delegate/src/transforms/VisitSelectionSets.ts +++ b/packages/delegate/src/transforms/VisitSelectionSets.ts @@ -7,13 +7,13 @@ import { visit, visitWithTypeInfo, GraphQLOutputType, - OperationDefinitionNode, FragmentDefinitionNode, SelectionNode, DefinitionNode, + InlineFragmentNode, } from 'graphql'; -import { Request, collectFields, GraphQLExecutionContext } from '@graphql-tools/utils'; +import { Request } from '@graphql-tools/utils'; import { Transform, DelegationContext } from '../types'; @@ -48,89 +48,55 @@ function visitSelectionSets( initialType: GraphQLOutputType, visitor: (node: SelectionSetNode, typeInfo: TypeInfo) => SelectionSetNode ): DocumentNode { - const { document, variables } = request; + const { document } = request; - const operations: Array = []; - const fragments: Record = Object.create(null); + const typeInfo = new TypeInfo(schema, undefined, initialType); + + const newDefinitions: Array = []; document.definitions.forEach(def => { - if (def.kind === Kind.OPERATION_DEFINITION) { - operations.push(def); - } else if (def.kind === Kind.FRAGMENT_DEFINITION) { - fragments[def.name.value] = def; - } - }); + if (def.kind === Kind.FRAGMENT_DEFINITION) { + newDefinitions.push(visitNode(def, typeInfo, visitor)); + } else if (def.kind === Kind.OPERATION_DEFINITION) { + const newSelections: Array = []; - const partialExecutionContext = { - schema, - variableValues: variables, - fragments, - } as GraphQLExecutionContext; + def.selectionSet.selections.forEach(selection => { + if (selection.kind === Kind.FRAGMENT_SPREAD) { + return; + } - const typeInfo = new TypeInfo(schema, undefined, initialType); - const newDefinitions: Array = operations.map(operation => { - const type = - operation.operation === 'query' - ? schema.getQueryType() - : operation.operation === 'mutation' - ? schema.getMutationType() - : schema.getSubscriptionType(); - - const fields = collectFields( - partialExecutionContext, - type, - operation.selectionSet, - Object.create(null), - Object.create(null) - ); + if (selection.kind === Kind.INLINE_FRAGMENT) { + newSelections.push(visitNode(selection, typeInfo, visitor)); + return; + } - const newSelections: Array = []; - Object.keys(fields).forEach(responseKey => { - const fieldNodes = fields[responseKey]; - fieldNodes.forEach(fieldNode => { - const selectionSet = fieldNode.selectionSet; + const selectionSet = selection.selectionSet; if (selectionSet == null) { - newSelections.push(fieldNode); + newSelections.push(selection); return; } - const newSelectionSet = visit( - selectionSet, - visitWithTypeInfo(typeInfo, { - [Kind.SELECTION_SET]: node => visitor(node, typeInfo), - }) - ); + const newSelectionSet = visitNode(selectionSet, typeInfo, visitor); if (newSelectionSet === selectionSet) { - newSelections.push(fieldNode); + newSelections.push(selection); return; } newSelections.push({ - ...fieldNode, + ...selection, selectionSet: newSelectionSet, }); }); - }); - return { - ...operation, - selectionSet: { - kind: Kind.SELECTION_SET, - selections: newSelections, - }, - }; - }); - - Object.values(fragments).forEach(fragment => { - newDefinitions.push( - visit( - fragment, - visitWithTypeInfo(typeInfo, { - [Kind.SELECTION_SET]: node => visitor(node, typeInfo), - }) - ) - ); + newDefinitions.push({ + ...def, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: newSelections, + }, + }); + } }); return { @@ -138,3 +104,16 @@ function visitSelectionSets( definitions: newDefinitions, }; } + +function visitNode( + node: T, + typeInfo: TypeInfo, + visitor: (node: SelectionSetNode, typeInfo: TypeInfo) => SelectionSetNode +): T { + return visit( + node, + visitWithTypeInfo(typeInfo, { + [Kind.SELECTION_SET]: node => visitor(node, typeInfo), + }) + ); +} diff --git a/packages/delegate/src/transforms/index.ts b/packages/delegate/src/transforms/index.ts index 624bc89e3fa..9b59ef1bb27 100644 --- a/packages/delegate/src/transforms/index.ts +++ b/packages/delegate/src/transforms/index.ts @@ -1,7 +1,7 @@ -export { default as CheckResultAndHandleErrors, checkResultAndHandleErrors } from './CheckResultAndHandleErrors'; export { default as ExpandAbstractTypes } from './ExpandAbstractTypes'; export { default as VisitSelectionSets } from './VisitSelectionSets'; -export { default as AddSelectionSets } from './AddSelectionSets'; +export { default as AddFieldNodes } from './AddFieldNodes'; export { default as AddArgumentsAsVariables } from './AddArgumentsAsVariables'; export { default as FilterToSchema } from './FilterToSchema'; -export { default as AddTypenameToAbstract } from './AddTypenameToAbstract'; +export { default as AddTypename } from './AddTypename'; +export { default as StoreAsyncSelectionSets } from './StoreAsyncSelectionSets'; diff --git a/packages/delegate/src/types.ts b/packages/delegate/src/types.ts index 419f11bd075..5287e6b901f 100644 --- a/packages/delegate/src/types.ts +++ b/packages/delegate/src/types.ts @@ -10,14 +10,25 @@ import { VariableDefinitionNode, OperationTypeNode, GraphQLError, + GraphQLFieldMap, } from 'graphql'; import DataLoader from 'dataloader'; import { ExecutionParams, ExecutionResult, Executor, Request, Subscriber, TypeMap } from '@graphql-tools/utils'; +import { + INITIAL_PATH_SYMBOL, + OBJECT_SUBSCHEMA_SYMBOL, + FIELD_SUBSCHEMA_MAP_SYMBOL, + UNPATHED_ERRORS_SYMBOL, + RECEIVER_MAP_SYMBOL, + INITIAL_POSSIBLE_FIELDS, + INFO_SYMBOL, +} from './symbols'; + +import { Receiver } from './Receiver'; import { Subschema } from './Subschema'; -import { OBJECT_SUBSCHEMA_SYMBOL, FIELD_SUBSCHEMA_MAP_SYMBOL, UNPATHED_ERRORS_SYMBOL } from './symbols'; export type SchemaTransform = ( originalWrappingSchema: GraphQLSchema, @@ -50,12 +61,12 @@ export interface DelegationContext { args: Record; context: Record; info: GraphQLResolveInfo; - rootValue?: Record, + rootValue?: Record; returnType: GraphQLOutputType; onLocatedError?: (originalError: GraphQLError) => GraphQLError; transforms: Array; transformedSchema: GraphQLSchema; - skipTypeMerging: boolean; + asyncSelectionSets: Record; } export type DelegationBinding = (delegationContext: DelegationContext) => Array; @@ -76,7 +87,6 @@ export interface IDelegateToSchemaOptions, TArgs transforms?: Array; transformedSchema?: GraphQLSchema; skipValidation?: boolean; - skipTypeMerging?: boolean; binding?: DelegationBinding; } @@ -115,6 +125,7 @@ export interface MergedTypeInfo> { targetSubschemas: Map>; uniqueFields: Record; nonUniqueFields: Record>; + subschemaFields: Record; typeMaps: Map; selectionSets: Map; fieldSelectionSets: Map>; @@ -141,7 +152,7 @@ export interface SubschemaConfig; transforms?: Array; - merge?: Record>; + merge?: Record>; rootValue?: Record; executor?: Executor; subscriber?: Subscriber; @@ -149,24 +160,23 @@ export interface SubschemaConfig; } -export interface MergedTypeConfig> extends MergedTypeEntryPoint { +export interface MergedTypeConfig> extends MergedTypeEntryPoint { entryPoints?: Array; fields?: Record; computedFields?: Record; canonical?: boolean; } -export interface MergedTypeEntryPoint> extends MergedTypeResolverOptions { +export interface MergedTypeEntryPoint> extends MergedTypeResolverOptions { selectionSet?: string; key?: (originalResult: any) => K; resolve?: MergedTypeResolver; } -export interface MergedTypeResolverOptions { +export interface MergedTypeResolverOptions { fieldName?: string; args?: (originalResult: any) => Record; argsFromKeys?: (keys: ReadonlyArray) => Record; - valuesFromResults?: (results: any, keys: ReadonlyArray) => Array; } export interface MergedFieldConfig { @@ -186,15 +196,24 @@ export type MergedTypeResolver> = ( export interface StitchingInfo> { subschemaMap: Map, Subschema>; - selectionSetsByType: Record; - selectionSetsByField: Record>; - dynamicSelectionSetsByField: Record SelectionSetNode>>>; + fieldNodesByField: Record>>; + dynamicFieldNodesByField: Record Array>>>; mergedTypes: Record>; } +export interface MergedExecutionResult> { + unpathedErrors: Array; + data: TData; +} + export interface ExternalObject> { - key: any; + __typename: string; + [key: string]: any; + [INITIAL_PATH_SYMBOL]: Array; [OBJECT_SUBSCHEMA_SYMBOL]: GraphQLSchema | SubschemaConfig; + [INITIAL_POSSIBLE_FIELDS]: GraphQLFieldMap; + [INFO_SYMBOL]: GraphQLResolveInfo; [FIELD_SUBSCHEMA_MAP_SYMBOL]: Record>; [UNPATHED_ERRORS_SYMBOL]: Array; + [RECEIVER_MAP_SYMBOL]: Map; } diff --git a/packages/delegate/tests/defer.test.ts b/packages/delegate/tests/defer.test.ts new file mode 100644 index 00000000000..40b380e75f5 --- /dev/null +++ b/packages/delegate/tests/defer.test.ts @@ -0,0 +1,329 @@ +import { graphql } from 'graphql'; + +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { stitchSchemas } from '@graphql-tools/stitch'; +import { ExecutionResult, isAsyncIterable } from '@graphql-tools/utils'; + +describe('defer support', () => { + test('should work for root fields', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + test: String + } + `, + resolvers: { + Query: { + test: () => 'test', + } + }, + }); + + const stitchedSchema = stitchSchemas({ + subschemas: [schema] + }); + + const result = await graphql( + stitchedSchema, + ` + query { + ... on Query @defer { + test + } + } + `, + ); + + const results = []; + if (isAsyncIterable(result)) { + for await (const patch of result) { + results.push(patch); + } + } + + expect(results[0]).toEqual({ + data: {}, + hasNext: true, + }); + expect(results[1]).toEqual({ + data: { + test: 'test', + }, + hasNext: false, + path: [], + }); + }); + + test('should work for proxied fields', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Object { + test: String + } + type Query { + object: Object + } + `, + resolvers: { + Object: { + test: () => 'test', + }, + Query: { + object: () => ({}), + } + }, + }); + + const stitchedSchema = stitchSchemas({ + subschemas: [schema] + }); + + const result = await graphql( + stitchedSchema, + ` + query { + object { + ... on Object @defer { + test + } + } + } + `, + ); + + const results = []; + if (isAsyncIterable(result)) { + for await (const patch of result) { + results.push(patch); + } + } + + expect(results[0]).toEqual({ + data: { object: {} }, + hasNext: true, + }); + expect(results[1]).toEqual({ + data: { + test: 'test', + }, + hasNext: false, + path: ['object'], + }); + }); + + test('should work for proxied fields from multiple schemas', async () => { + const schema1 = makeExecutableSchema({ + typeDefs: ` + type Object { + id: ID + field1: String + } + type Query { + object(id: ID): Object + } + `, + resolvers: { + Query: { + object: () => ({ id: '1', field1: 'field1' }), + } + }, + }); + + const schema2 = makeExecutableSchema({ + typeDefs: ` + type Object { + id: ID + field2: String + } + type Query { + object(id: ID): Object + } + `, + resolvers: { + Query: { + object: () => ({ id: '1', field2: 'field2' }), + } + }, + }); + + const stitchedSchema = stitchSchemas({ + subschemas: [{ + schema: schema1, + merge: { + Object: { + selectionSet: '{ id }', + fieldName: 'object', + args: ({ id }) => ({ id }), + }, + }, + }, { + schema: schema2, + merge: { + Object: { + selectionSet: '{ id }', + fieldName: 'object', + args: ({ id }) => ({ id }), + }, + }, + }], + }); + + const result = await graphql( + stitchedSchema, + ` + query { + object(id: "1") { + ... on Object @defer { + field1 + field2 + } + } + } + `, + ); + + expect((result as ExecutionResult).errors).toBeUndefined(); + + const results = []; + + if (isAsyncIterable(result)) { + for await (const patch of result) { + results.push(patch); + } + } + + expect(results[0]).toEqual({ + data: { object: {} }, + hasNext: true, + }); + expect(results[1]).toEqual({ + data: { + field1: 'field1', + field2: 'field2', + }, + hasNext: false, + path: ['object'], + }); + }); + + test('should work for nested proxied fields from multiple schemas', async () => { + const schema1 = makeExecutableSchema({ + typeDefs: ` + type Object { + field: Subtype + } + type Subtype { + id: ID + subfield1: String + } + type Query { + object: Object + field(id: ID): Subtype + } + `, + resolvers: { + Query: { + object: () => ({ field: { id: '1', subfield1: 'subfield1'} }), + field: () => ({ id: '1', subfield1: 'subfield1'}), + }, + }, + }); + + const schema2 = makeExecutableSchema({ + typeDefs: ` + type Object { + field: Subtype + } + type Subtype { + id: ID + subfield2: String + } + type Query { + object: Object + field(id: ID): Subtype + } + `, + resolvers: { + Query: { + object: () => ({ field: { id: '1', subfield2: 'subfield2'} }), + field: () => ({ id: '1', subfield2: 'subfield2'}), + }, + }, + }); + + const stitchedSchema = stitchSchemas({ + subschemas: [{ + schema: schema1, + merge: { + Subtype: { + selectionSet: '{ id }', + fieldName: 'field', + args: ({ id }) => ({ id }), + }, + }, + }, { + schema: schema2, + merge: { + Subtype: { + selectionSet: '{ id }', + fieldName: 'field', + args: ({ id }) => ({ id }), + }, + }, + }], + }); + + const result = await graphql( + stitchedSchema, + ` + query { + object { + ... on Object @defer { + field { + subfield1 + } + } + ... on Object @defer { + field { + subfield2 + } + } + } + } + `, + ); + + expect((result as ExecutionResult).errors).toBeUndefined(); + + const results = []; + + if (isAsyncIterable(result)) { + for await (const patch of result) { + results.push(patch); + } + } + + expect(results[0]).toEqual({ + data: { object: {} }, + hasNext: true, + }); + expect(results[1]).toEqual({ + data: { + field: { + subfield2: 'subfield2', + }, + }, + hasNext: true, + path: ['object'], + }); + expect(results[2]).toEqual({ + data: { + field: { + subfield1: 'subfield1', + }, + }, + hasNext: false, + path: ['object'], + }); + }); +}); diff --git a/packages/delegate/tests/errors.test.ts b/packages/delegate/tests/errors.test.ts index faf8d5b2ff0..55f2eb80882 100644 --- a/packages/delegate/tests/errors.test.ts +++ b/packages/delegate/tests/errors.test.ts @@ -1,10 +1,10 @@ -import { GraphQLError, GraphQLResolveInfo, locatedError, graphql } from 'graphql'; +import { GraphQLError, locatedError, graphql } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { ExecutionResult } from '@graphql-tools/utils'; import { stitchSchemas } from '@graphql-tools/stitch'; +import { DelegationContext, externalValueFromResult } from '@graphql-tools/delegate'; -import { checkResultAndHandleErrors } from '../src/transforms/CheckResultAndHandleErrors'; import { UNPATHED_ERRORS_SYMBOL } from '../src/symbols'; import { getUnpathedErrors } from '../src/externalObjects'; import { delegateToSchema, defaultMergedResolver } from '../src'; @@ -32,17 +32,15 @@ describe('Errors', () => { }); }); - describe('checkResultAndHandleErrors', () => { + describe('CheckResultAndHandleErrors', () => { test('persists single error', () => { const result = { errors: [new GraphQLError('Test error')], }; try { - checkResultAndHandleErrors( + externalValueFromResult( result, - {}, - ({} as unknown) as GraphQLResolveInfo, - 'responseKey', + { fieldName: 'responseKey' } as unknown as DelegationContext, ); } catch (e) { expect(e.message).toEqual('Test error'); @@ -55,11 +53,9 @@ describe('Errors', () => { errors: [new ErrorWithExtensions('Test error', 'UNAUTHENTICATED')], }; try { - checkResultAndHandleErrors( + externalValueFromResult( result, - {}, - ({} as unknown) as GraphQLResolveInfo, - 'responseKey', + { fieldName: 'responseKey '} as unknown as DelegationContext, ); } catch (e) { expect(e.message).toEqual('Test error'); @@ -73,11 +69,9 @@ describe('Errors', () => { errors: [new GraphQLError('Error1'), new GraphQLError('Error2')], }; try { - checkResultAndHandleErrors( + externalValueFromResult( result, - {}, - ({} as unknown) as GraphQLResolveInfo, - 'responseKey', + { fieldName: 'reponseKey' } as unknown as DelegationContext, ); } catch (e) { expect(e.message).toEqual('Error1\nError2'); diff --git a/packages/delegate/tests/stream.test.ts b/packages/delegate/tests/stream.test.ts new file mode 100644 index 00000000000..83f1967c16b --- /dev/null +++ b/packages/delegate/tests/stream.test.ts @@ -0,0 +1,107 @@ +import { graphql } from 'graphql'; + +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { stitchSchemas } from '@graphql-tools/stitch'; +import { isAsyncIterable } from '@graphql-tools/utils'; + +describe('stream support', () => { + test('should work for root fields', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + test: [String] + } + `, + resolvers: { + Query: { + test: () => ['test1', 'test2'], + } + }, + }); + + const stitchedSchema = stitchSchemas({ + subschemas: [schema] + }); + + const result = await graphql( + stitchedSchema, + ` + query { + test @stream(initialCount: 1) + } + `, + ); + + const results = []; + if (isAsyncIterable(result)) { + for await (const patch of result) { + results.push(patch); + } + } + + expect(results[0]).toEqual({ + data: { + test: ['test1'], + }, + hasNext: true, + }); + expect(results[1]).toEqual({ + data: 'test2', + hasNext: false, + path: ['test', 1], + }); + }); + + test('should work for proxied fields', async () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Object { + test: [String] + } + type Query { + object: Object + } + `, + resolvers: { + Object: { + test: () => ['test1', 'test2'], + }, + Query: { + object: () => ({}), + } + }, + }); + + const stitchedSchema = stitchSchemas({ + subschemas: [schema] + }); + + const result = await graphql( + stitchedSchema, + ` + query { + object { + test @stream(initialCount: 1) + } + } + `, + ); + + const results = []; + if (isAsyncIterable(result)) { + for await (const patch of result) { + results.push(patch); + } + } + + expect(results[0]).toEqual({ + data: { object: { test: ['test1'] } }, + hasNext: true, + }); + expect(results[1]).toEqual({ + data: 'test2', + hasNext: true, + path: ['object', 'test', 1], + }); + }); +}); diff --git a/packages/links/src/linkToSubscriber.ts b/packages/links/src/linkToSubscriber.ts index 0b66c661b1c..e1d3b7f47fa 100644 --- a/packages/links/src/linkToSubscriber.ts +++ b/packages/links/src/linkToSubscriber.ts @@ -5,7 +5,7 @@ import { Subscriber, ExecutionParams, ExecutionResult, observableToAsyncIterable export const linkToSubscriber = (link: ApolloLink): Subscriber => async ( params: ExecutionParams -): Promise | AsyncIterator>> => { +): Promise | AsyncIterableIterator>> => { const { document, variables, extensions, context, info } = params; return observableToAsyncIterable>( execute(link, { diff --git a/packages/loaders/url/src/index.ts b/packages/loaders/url/src/index.ts index 0596dceb172..0b3e64d896e 100644 --- a/packages/loaders/url/src/index.ts +++ b/packages/loaders/url/src/index.ts @@ -414,7 +414,7 @@ export class UrlLoader implements DocumentLoader { query: document, variables, }) - ) as AsyncIterator>; + ) as AsyncIterableIterator>; }; } diff --git a/packages/mock/src/types.ts b/packages/mock/src/types.ts index e9307bdaa55..cb5d76c12de 100644 --- a/packages/mock/src/types.ts +++ b/packages/mock/src/types.ts @@ -1,4 +1,6 @@ -import { ExecutionResult, GraphQLSchema } from 'graphql'; +import { GraphQLSchema } from 'graphql'; + +import { ExecutionResult, AsyncExecutionResult } from '@graphql-tools/utils'; export type IMockFn = () => unknown; export type IScalarMock = unknown | IMockFn; @@ -212,5 +214,8 @@ export interface IMockServer { * @param query GraphQL query to execute * @param vars Variables */ - query: (query: string, vars?: Record) => Promise; + query: ( + query: string, + vars?: Record + ) => Promise>; } diff --git a/packages/stitch/src/createMergedTypeResolver.ts b/packages/stitch/src/createMergedTypeResolver.ts index 21bb0dbc1ed..278d52d0d25 100644 --- a/packages/stitch/src/createMergedTypeResolver.ts +++ b/packages/stitch/src/createMergedTypeResolver.ts @@ -1,9 +1,9 @@ -import { getNamedType, GraphQLOutputType, GraphQLList } from 'graphql'; +import { getNamedType, GraphQLOutputType } from 'graphql'; import { delegateToSchema, MergedTypeResolver, MergedTypeResolverOptions } from '@graphql-tools/delegate'; import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; export function createMergedTypeResolver(mergedTypeResolverOptions: MergedTypeResolverOptions): MergedTypeResolver { - const { fieldName, argsFromKeys, valuesFromResults, args } = mergedTypeResolverOptions; + const { fieldName, argsFromKeys, args } = mergedTypeResolverOptions; if (argsFromKeys != null) { return (originalResult, context, info, subschema, selectionSet, key) => @@ -11,16 +11,14 @@ export function createMergedTypeResolver(mergedTypeResolverOptions: MergedTypeRe schema: subschema, operation: 'query', fieldName, - returnType: new GraphQLList( - getNamedType(info.schema.getType(originalResult.__typename) ?? info.returnType) as GraphQLOutputType - ), + returnType: getNamedType( + info.schema.getType(originalResult.__typename) ?? info.returnType + ) as GraphQLOutputType, key, argsFromKeys, - valuesFromResults, selectionSet, context, info, - skipTypeMerging: true, }); } @@ -37,7 +35,6 @@ export function createMergedTypeResolver(mergedTypeResolverOptions: MergedTypeRe selectionSet, context, info, - skipTypeMerging: true, }); } diff --git a/packages/stitch/src/selectionSetArgs.ts b/packages/stitch/src/selectionSetArgs.ts index 4dbc164d5b2..bc8b1f4326b 100644 --- a/packages/stitch/src/selectionSetArgs.ts +++ b/packages/stitch/src/selectionSetArgs.ts @@ -1,29 +1,50 @@ -import { parseSelectionSet } from '@graphql-tools/utils'; -import { SelectionSetNode, SelectionNode, FieldNode, Kind } from 'graphql'; +import { collectFields, GraphQLExecutionContext, parseSelectionSet } from '@graphql-tools/utils'; +import { FieldNode, GraphQLSchema, GraphQLObjectType, GraphQLField, getNamedType } from 'graphql'; -export const forwardArgsToSelectionSet: ( +export function forwardArgsToSelectionSet( selectionSet: string, - mapping?: Record -) => (field: FieldNode) => SelectionSetNode = (selectionSet: string, mapping?: Record) => { + mapping?: Record> +): (schema: GraphQLSchema, field: GraphQLField) => (originalFieldNode: FieldNode) => Array { const selectionSetDef = parseSelectionSet(selectionSet, { noLocation: true }); - return (field: FieldNode): SelectionSetNode => { - const selections = selectionSetDef.selections.map( - (selectionNode): SelectionNode => { - if (selectionNode.kind === Kind.FIELD) { + + return (schema: GraphQLSchema, field: GraphQLField) => { + const partialExecutionContext = ({ + schema, + variableValues: Object.create(null), + fragments: Object.create(null), + } as unknown) as GraphQLExecutionContext; + + const responseKeys = collectFields( + partialExecutionContext, + getNamedType(field.type) as GraphQLObjectType, + selectionSetDef, + Object.create(null), + Object.create(null) + ); + + return (originalFieldNode: FieldNode): Array => { + const newFieldNodes: Array = []; + + Object.values(responseKeys).forEach(fieldNodes => { + fieldNodes.forEach(fieldNode => { if (!mapping) { - return { ...selectionNode, arguments: field.arguments.slice() }; - } else if (selectionNode.name.value in mapping) { - const selectionArgs = mapping[selectionNode.name.value]; - return { - ...selectionNode, - arguments: field.arguments.filter((arg): boolean => selectionArgs.includes(arg.name.value)), - }; + newFieldNodes.push({ + ...fieldNode, + arguments: originalFieldNode.arguments.slice(), + }); + } else if (fieldNode.name.value in mapping) { + const newArgs = mapping[fieldNode.name.value]; + newFieldNodes.push({ + ...fieldNode, + arguments: originalFieldNode.arguments.filter((arg): boolean => newArgs.includes(arg.name.value)), + }); + } else { + newFieldNodes.push(fieldNode); } - } - return selectionNode; - } - ); + }); + }); - return { ...selectionSetDef, selections }; + return newFieldNodes; + }; }; -}; +} diff --git a/packages/stitch/src/stitchSchemas.ts b/packages/stitch/src/stitchSchemas.ts index 40d63a88da6..411d7fe3f38 100644 --- a/packages/stitch/src/stitchSchemas.ts +++ b/packages/stitch/src/stitchSchemas.ts @@ -5,9 +5,10 @@ import { GraphQLDirective, specifiedDirectives, extendSchema, + isObjectType, } from 'graphql'; -import { SchemaDirectiveVisitor, mergeDeep, IResolvers, pruneSchema } from '@graphql-tools/utils'; +import { SchemaDirectiveVisitor, mergeDeep, IResolvers, pruneSchema, IObjectTypeResolver } from '@graphql-tools/utils'; import { addResolversToSchema, @@ -19,7 +20,14 @@ import { extendResolversFromInterfaces, } from '@graphql-tools/schema'; -import { SubschemaConfig, isSubschemaConfig, Subschema, defaultMergedResolver } from '@graphql-tools/delegate'; +import { + Subschema, + SubschemaConfig, + defaultMergedResolver, + getMergedParent, + isExternalObject, + isSubschemaConfig, +} from '@graphql-tools/delegate'; import { IStitchSchemasOptions, SubschemaConfigTransform } from './types'; @@ -141,11 +149,13 @@ export function stitchSchemas>({ // We allow passing in an array of resolver maps, in which case we merge them const resolverMap: IResolvers = Array.isArray(resolvers) ? resolvers.reduce(mergeDeep, {}) : resolvers; - const finalResolvers = inheritResolversFromInterfaces + const extendedResolvers = inheritResolversFromInterfaces ? extendResolversFromInterfaces(schema, resolverMap) : resolverMap; - stitchingInfo = completeStitchingInfo(stitchingInfo, finalResolvers, schema); + stitchingInfo = completeStitchingInfo(stitchingInfo, extendedResolvers, schema); + + const finalResolvers = wrapResolvers(extendedResolvers, schema); schema = addResolversToSchema({ schema, @@ -235,3 +245,42 @@ function applySubschemaConfigTransforms>( return transformedSubschemas; } + +function wrapResolvers(originalResolvers: IResolvers, schema: GraphQLSchema): IResolvers { + const wrappedResolvers: IResolvers = Object.create(null); + + Object.keys(originalResolvers).forEach(typeName => { + const typeEntry = originalResolvers[typeName]; + const type = schema.getType(typeName); + if (!isObjectType(type)) { + wrappedResolvers[typeName] = originalResolvers[typeName]; + return; + } + + const newTypeEntry: IObjectTypeResolver = Object.create(null); + Object.keys(typeEntry).forEach(fieldName => { + const field = typeEntry[fieldName]; + const originalResolver = field?.resolve; + if (originalResolver === undefined) { + newTypeEntry[fieldName] = field; + return; + } + + newTypeEntry[fieldName] = { + ...field, + resolve: (parent, args, context, info) => { + if (!isExternalObject(parent)) { + return originalResolver(parent, args, context, info); + } + + return getMergedParent(parent, context, info).then(mergedParent => + originalResolver(mergedParent, args, context, info) + ); + }, + }; + }); + wrappedResolvers[typeName] = newTypeEntry; + }); + + return wrappedResolvers; +} diff --git a/packages/stitch/src/stitchingInfo.ts b/packages/stitch/src/stitchingInfo.ts index d34ebcd4e46..0dc57b13f42 100644 --- a/packages/stitch/src/stitchingInfo.ts +++ b/packages/stitch/src/stitchingInfo.ts @@ -4,16 +4,19 @@ import { Kind, SelectionSetNode, isObjectType, - isScalarType, getNamedType, GraphQLInterfaceType, SelectionNode, print, isInterfaceType, isLeafType, + isUnionType, + isInputObjectType, + FieldNode, + GraphQLField, } from 'graphql'; -import { parseSelectionSet, TypeMap, IResolvers, IFieldResolverOptions } from '@graphql-tools/utils'; +import { parseSelectionSet, TypeMap, IResolvers, IFieldResolverOptions, collectFields, GraphQLExecutionContext } from '@graphql-tools/utils'; import { MergedTypeResolver, Subschema, SubschemaConfig, MergedTypeInfo, StitchingInfo } from '@graphql-tools/delegate'; @@ -27,57 +30,11 @@ export function createStitchingInfo( mergeTypes?: boolean | Array | MergeTypeFilter ): StitchingInfo { const mergedTypes = createMergedTypes(typeCandidates, mergeTypes); - const selectionSetsByField: Record> = Object.create(null); - - Object.entries(mergedTypes).forEach(([typeName, mergedTypeInfo]) => { - if (mergedTypeInfo.selectionSets == null && mergedTypeInfo.fieldSelectionSets == null) { - return; - } - - selectionSetsByField[typeName] = Object.create(null); - - mergedTypeInfo.selectionSets.forEach((selectionSet, subschemaConfig) => { - const schema = subschemaConfig.transformedSchema; - const type = schema.getType(typeName) as GraphQLObjectType; - const fields = type.getFields(); - Object.keys(fields).forEach(fieldName => { - const field = fields[fieldName]; - const fieldType = getNamedType(field.type); - if (selectionSet && isLeafType(fieldType) && selectionSetContainsTopLevelField(selectionSet, fieldName)) { - return; - } - if (selectionSetsByField[typeName][fieldName] == null) { - selectionSetsByField[typeName][fieldName] = { - kind: Kind.SELECTION_SET, - selections: [parseSelectionSet('{ __typename }', { noLocation: true }).selections[0]], - }; - } - selectionSetsByField[typeName][fieldName].selections = selectionSetsByField[typeName][ - fieldName - ].selections.concat(selectionSet.selections); - }); - }); - - mergedTypeInfo.fieldSelectionSets.forEach(selectionSetFieldMap => { - Object.keys(selectionSetFieldMap).forEach(fieldName => { - if (selectionSetsByField[typeName][fieldName] == null) { - selectionSetsByField[typeName][fieldName] = { - kind: Kind.SELECTION_SET, - selections: [parseSelectionSet('{ __typename }', { noLocation: true }).selections[0]], - }; - } - selectionSetsByField[typeName][fieldName].selections = selectionSetsByField[typeName][ - fieldName - ].selections.concat(selectionSetFieldMap[fieldName].selections); - }); - }); - }); return { subschemaMap, - selectionSetsByType: undefined, - selectionSetsByField, - dynamicSelectionSetsByField: undefined, + fieldNodesByField: undefined, + dynamicFieldNodesByField: undefined, mergedTypes, }; } @@ -131,6 +88,7 @@ function createMergedTypes( if (mergedTypeConfig.selectionSet) { const selectionSet = parseSelectionSet(mergedTypeConfig.selectionSet, { noLocation: true }); + selectionSets.set(subschema, selectionSet); } @@ -199,6 +157,7 @@ function createMergedTypes( fieldSelectionSets, uniqueFields: Object.create({}), nonUniqueFields: Object.create({}), + subschemaFields: Object.create({}), resolvers, }; @@ -208,6 +167,7 @@ function createMergedTypes( } else { mergedTypes[typeName].nonUniqueFields[fieldName] = supportedBySubschemas[fieldName]; } + mergedTypes[typeName].subschemaFields[fieldName] = true; }); } } @@ -221,69 +181,138 @@ export function completeStitchingInfo( resolvers: IResolvers, schema: GraphQLSchema ): StitchingInfo { - const selectionSetsByType = Object.create(null); - [schema.getQueryType(), schema.getMutationType()].forEach(rootType => { - if (rootType) { - selectionSetsByType[rootType.name] = parseSelectionSet('{ __typename }', { noLocation: true }); + const selectionSetsByField: Record> = Object.create(null); + Object.entries(stitchingInfo.mergedTypes).forEach(([typeName, mergedTypeInfo]) => { + if (mergedTypeInfo.selectionSets == null && mergedTypeInfo.fieldSelectionSets == null) { + return; } + + selectionSetsByField[typeName] = Object.create(null); + + mergedTypeInfo.selectionSets.forEach((selectionSet, subschemaConfig) => { + const schema = subschemaConfig.transformedSchema; + const type = schema.getType(typeName) as GraphQLObjectType; + const fields = type.getFields(); + Object.keys(fields).forEach(fieldName => { + const field = fields[fieldName]; + const fieldType = getNamedType(field.type); + if (selectionSet && isLeafType(fieldType) && selectionSetContainsTopLevelField(selectionSet, fieldName)) { + return; + } + + const typeSelectionSets = selectionSetsByField[typeName]; + if (typeSelectionSets[fieldName] == null) { + typeSelectionSets[fieldName] = { + kind: Kind.SELECTION_SET, + selections: [], + }; + } + + const fieldSelectionSet = selectionSetsByField[typeName][fieldName]; + + fieldSelectionSet.selections = fieldSelectionSet.selections.concat(selectionSet.selections); + }); + }); + + mergedTypeInfo.fieldSelectionSets.forEach(selectionSetFieldMap => { + Object.keys(selectionSetFieldMap).forEach(fieldName => { + const typeSelectionSets = selectionSetsByField[typeName]; + if (typeSelectionSets[fieldName] == null) { + typeSelectionSets[fieldName] = { + kind: Kind.SELECTION_SET, + selections: [], + }; + } + + const fieldSelectionSet = selectionSetsByField[typeName][fieldName]; + fieldSelectionSet.selections = fieldSelectionSet.selections.concat(selectionSetFieldMap[fieldName].selections); + }); + }); }); - const selectionSetsByField = stitchingInfo.selectionSetsByField; - const dynamicSelectionSetsByField = Object.create(null); + const dynamicFieldNodesByField:Record Array>>> = Object.create(null); Object.keys(resolvers).forEach(typeName => { - const type = resolvers[typeName]; - if (isScalarType(type)) { + const typeEntry = resolvers[typeName]; + const type = schema.getType(typeName); + if (isLeafType(type) || isUnionType(type) || isInputObjectType(type)) { return; } - Object.keys(type).forEach(fieldName => { - const field = type[fieldName] as IFieldResolverOptions; + + Object.keys(typeEntry).forEach(fieldName => { + const field = typeEntry[fieldName] as IFieldResolverOptions; if (field.selectionSet) { if (typeof field.selectionSet === 'function') { - if (!(typeName in dynamicSelectionSetsByField)) { - dynamicSelectionSetsByField[typeName] = Object.create(null); + if (!(typeName in dynamicFieldNodesByField)) { + dynamicFieldNodesByField[typeName] = Object.create(null); } - if (!(fieldName in dynamicSelectionSetsByField[typeName])) { - dynamicSelectionSetsByField[typeName][fieldName] = []; + if (!(fieldName in dynamicFieldNodesByField[typeName])) { + dynamicFieldNodesByField[typeName][fieldName] = []; } - dynamicSelectionSetsByField[typeName][fieldName].push(field.selectionSet); + const buildFieldNodeFn = field.selectionSet as ((schema: GraphQLSchema, field: GraphQLField) => (originalFieldNode: FieldNode) => Array); + const fieldNodeFn = buildFieldNodeFn(schema, type.getFields()[fieldName]); + dynamicFieldNodesByField[typeName][fieldName].push((fieldNode: FieldNode) => fieldNodeFn(fieldNode)); } else { const selectionSet = parseSelectionSet(field.selectionSet, { noLocation: true }); if (!(typeName in selectionSetsByField)) { selectionSetsByField[typeName] = Object.create(null); } - if (!(fieldName in selectionSetsByField[typeName])) { - selectionSetsByField[typeName][fieldName] = { + const typeSelectionSets = selectionSetsByField[typeName]; + if (!(fieldName in typeSelectionSets)) { + typeSelectionSets[fieldName] = { kind: Kind.SELECTION_SET, selections: [], }; } - selectionSetsByField[typeName][fieldName].selections = selectionSetsByField[typeName][ - fieldName - ].selections.concat(selectionSet.selections); + + const fieldSelectionSet = typeSelectionSets[fieldName]; + fieldSelectionSet.selections = fieldSelectionSet.selections.concat(selectionSet.selections); } } }); }); + const partialExecutionContext = ({ + schema, + variableValues: Object.create(null), + fragments: Object.create(null), + } as unknown) as GraphQLExecutionContext; + + const fieldNodesByField: Record>> = Object.create(null); Object.keys(selectionSetsByField).forEach(typeName => { + const typeFieldNodes: Record> = Object.create(null); + fieldNodesByField[typeName] = typeFieldNodes; + + const type = schema.getType(typeName) as GraphQLObjectType; const typeSelectionSets = selectionSetsByField[typeName]; Object.keys(typeSelectionSets).forEach(fieldName => { + const consolidatedSelections: Map = new Map(); const selectionSet = typeSelectionSets[fieldName]; - selectionSet.selections.forEach(selection => { - consolidatedSelections.set(print(selection), selection); - }); - selectionSet.selections = Array.from(consolidatedSelections.values()); + selectionSet.selections.forEach(selection => consolidatedSelections.set(print(selection), selection)); + + const responseKeys = collectFields( + partialExecutionContext, + type, + { + kind: Kind.SELECTION_SET, + selections: Array.from(consolidatedSelections.values()) + }, + Object.create(null), + Object.create(null) + ); + + const fieldNodes: Array = []; + typeFieldNodes[fieldName] = fieldNodes; + Object.values(responseKeys).forEach(nodes => fieldNodes.push(...nodes)); }); }); - stitchingInfo.selectionSetsByType = selectionSetsByType; - stitchingInfo.selectionSetsByField = selectionSetsByField; - stitchingInfo.dynamicSelectionSetsByField = dynamicSelectionSetsByField; + stitchingInfo.fieldNodesByField = fieldNodesByField; + stitchingInfo.dynamicFieldNodesByField = dynamicFieldNodesByField; return stitchingInfo; } diff --git a/packages/stitch/src/types.ts b/packages/stitch/src/types.ts index 1251afdd843..85d40959093 100644 --- a/packages/stitch/src/types.ts +++ b/packages/stitch/src/types.ts @@ -1,7 +1,6 @@ import { GraphQLNamedType, GraphQLSchema, - SelectionSetNode, FieldNode, GraphQLFieldConfig, GraphQLObjectType, @@ -10,6 +9,7 @@ import { GraphQLInputObjectType, GraphQLEnumValueConfig, GraphQLEnumType, + GraphQLField, } from 'graphql'; import { ITypeDefinitions } from '@graphql-tools/utils'; import { Subschema, SubschemaConfig } from '@graphql-tools/delegate'; @@ -108,6 +108,6 @@ export type OnTypeConflict> = ( declare module '@graphql-tools/utils' { interface IFieldResolverOptions { fragment?: string; - selectionSet?: string | ((node: FieldNode) => SelectionSetNode); + selectionSet?: string | ((schema: GraphQLSchema, field: GraphQLField) => (originalFieldNode: FieldNode) => Array); } } diff --git a/packages/stitch/tests/alternateStitchSchemas.test.ts b/packages/stitch/tests/alternateStitchSchemas.test.ts index 3ae5cb889f1..f2a62a11a01 100644 --- a/packages/stitch/tests/alternateStitchSchemas.test.ts +++ b/packages/stitch/tests/alternateStitchSchemas.test.ts @@ -6,7 +6,6 @@ import { GraphQLScalarType, FieldNode, printSchema, - graphqlSync, assertValidSchema, GraphQLFieldConfig, isSpecifiedScalarType, @@ -210,16 +209,13 @@ describe('merge schemas through transforms', () => { }, Bookings_Booking: { property: { - selectionSet: () => ({ - kind: Kind.SELECTION_SET, - selections: [{ - kind: Kind.FIELD, - name: { - kind: Kind.NAME, - value: 'propertyId', - } - }] - }), + selectionSet: () => () => [{ + kind: Kind.FIELD, + name: { + kind: Kind.NAME, + value: 'propertyId', + } + }], resolve: (parent, _args, context, info) => delegateToSchema({ schema: propertySubschema, @@ -540,7 +536,7 @@ describe('default values', () => { }); describe('rename fields that implement interface fields', () => { - test('should work', () => { + test('should work', async () => { const originalItem = { id: '123', camel: "I'm a camel!", @@ -612,10 +608,10 @@ describe('rename fields that implement interface fields', () => { } `; - const originalResult = graphqlSync(originalSchema, originalQuery); + const originalResult = await graphql(originalSchema, originalQuery); expect(originalResult).toEqual({ data: { node: originalItem } }); - const newResult = graphqlSync(wrappedSchema, newQuery); + const newResult = await graphql(wrappedSchema, newQuery); expect(newResult).toEqual({ data: { _node: originalItem } }); }); }); @@ -874,7 +870,7 @@ type Query { }); describe('rename nested object fields with interfaces', () => { - test('should work', () => { + test('should work', async () => { const originalNode = { aList: [ { @@ -975,8 +971,8 @@ describe('rename nested object fields with interfaces', () => { } `; - const originalResult = graphqlSync(originalSchema, originalQuery); - const transformedResult = graphqlSync(transformedSchema, transformedQuery); + const originalResult = await graphql(originalSchema, originalQuery); + const transformedResult = await graphql(transformedSchema, transformedQuery); expect(originalResult).toEqual({ data: { node: originalNode } }); expect(transformedResult).toEqual({ @@ -2004,7 +2000,6 @@ describe('basic type merging', () => { selectionSet, context, info, - skipTypeMerging: true, }), }, }, @@ -2024,7 +2019,6 @@ describe('basic type merging', () => { selectionSet, context, info, - skipTypeMerging: true, }), }, }, @@ -2148,7 +2142,6 @@ describe('unidirectional type merging', () => { selectionSet, context, info, - skipTypeMerging: true, }), }, }, diff --git a/packages/stitch/tests/fixtures/schemas.ts b/packages/stitch/tests/fixtures/schemas.ts index 191fb01c2ae..2d12cc65bdf 100644 --- a/packages/stitch/tests/fixtures/schemas.ts +++ b/packages/stitch/tests/fixtures/schemas.ts @@ -21,10 +21,11 @@ import { IResolvers, ExecutionResult, mapAsyncIterator, + isAsyncIterable, } from '@graphql-tools/utils'; import { makeExecutableSchema } from '@graphql-tools/schema'; -import { ExecutionParams, SubschemaConfig } from '@graphql-tools/delegate'; +import { ExecutionParams, Executor, SubschemaConfig } from '@graphql-tools/delegate'; export class CustomError extends GraphQLError { constructor(message: string, extensions: Record) { @@ -684,17 +685,20 @@ export const subscriptionSchema: GraphQLSchema = makeExecutableSchema({ resolvers: subscriptionResolvers, }); -function makeExecutorFromSchema(schema: GraphQLSchema) { - return async ({ document, variables, context }: ExecutionParams) => { - return (new ValueOrPromise(() => graphql( +function makeExecutorFromSchema(schema: GraphQLSchema): Executor { + return ({ document, variables, context }) => { + return new ValueOrPromise(() => graphql( schema, print(document), null, context, variables, - )) - .then(originalResult => JSON.parse(JSON.stringify(originalResult))) - .resolve()) as Promise> | ExecutionResult; + )).then(resultOrIterable => { + if (isAsyncIterable(resultOrIterable)) { + return mapAsyncIterator(resultOrIterable, originalResult => JSON.parse(JSON.stringify(originalResult))); + } + return JSON.parse(JSON.stringify(resultOrIterable)); + }).resolve(); }; } diff --git a/packages/stitch/tests/selectionSetArgs.test.ts b/packages/stitch/tests/selectionSetArgs.test.ts index 0397a21c0dc..c6dfbbc2d04 100644 --- a/packages/stitch/tests/selectionSetArgs.test.ts +++ b/packages/stitch/tests/selectionSetArgs.test.ts @@ -1,13 +1,37 @@ +import { makeExecutableSchema } from '@graphql-tools/schema'; import { parseSelectionSet } from '@graphql-tools/utils'; +import { FieldNode, GraphQLObjectType } from 'graphql'; import { forwardArgsToSelectionSet } from '../src'; describe('forwardArgsToSelectionSet', () => { + const schema = makeExecutableSchema({ + typeDefs: ` + type Query { + users: [User] + } - const GATEWAY_FIELD = parseSelectionSet('{ posts(pageNumber: 1, perPage: 7) }').selections[0]; + type User { + id: ID + posts(pageNumber: Int, perPage: Int): [Post] + postIds(pageNumber: Int, perPage: Int): [ID] + } + + type Post { + id: ID + } + `, + }); + + const type = schema.getType('User') as GraphQLObjectType; + + const field = type.getFields()['posts']; + + const fieldNode = parseSelectionSet('{ posts(pageNumber: 1, perPage: 7) { id } }').selections[0] as FieldNode; test('passes all arguments to a hint selection set', () => { - const buildSelectionSet = forwardArgsToSelectionSet('{ postIds }'); - const result = buildSelectionSet(GATEWAY_FIELD).selections[0]; + const buildFieldNodeFn = forwardArgsToSelectionSet('{ postIds }'); + const fieldNodeFn = buildFieldNodeFn(schema, type, field); + const result = fieldNodeFn(fieldNode)[0]; expect(result.name.value).toEqual('postIds'); expect(result.arguments.length).toEqual(2); @@ -18,16 +42,17 @@ describe('forwardArgsToSelectionSet', () => { }); test('passes mapped arguments to a hint selection set', () => { - const buildSelectionSet = forwardArgsToSelectionSet('{ id postIds }', { postIds: ['pageNumber'] }); - const result = buildSelectionSet(GATEWAY_FIELD); + const buildFieldNodeFn = forwardArgsToSelectionSet('{ id postIds }', { postIds: ['pageNumber'] }); + const fieldNodeFn = buildFieldNodeFn(schema, type, field); + const result = fieldNodeFn(fieldNode); - expect(result.selections.length).toEqual(2); - expect(result.selections[0].name.value).toEqual('id'); - expect(result.selections[0].arguments.length).toEqual(0); + expect(result.length).toEqual(2); + expect(result[0].name.value).toEqual('id'); + expect(result[0].arguments.length).toEqual(0); - expect(result.selections[1].name.value).toEqual('postIds'); - expect(result.selections[1].arguments.length).toEqual(1); - expect(result.selections[1].arguments[0].name.value).toEqual('pageNumber'); - expect(result.selections[1].arguments[0].value.value).toEqual('1'); + expect(result[1].name.value).toEqual('postIds'); + expect(result[1].arguments.length).toEqual(1); + expect(result[1].arguments[0].name.value).toEqual('pageNumber'); + expect(result[1].arguments[0].value.value).toEqual('1'); }); }); diff --git a/packages/utils/package.json b/packages/utils/package.json index 17e7217ef73..7e60483f3e3 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@ardatan/aggregate-error": "0.0.6", + "@repeaterjs/repeater": "^3.0.4", "camel-case": "4.1.2", "tslib": "~2.2.0" }, diff --git a/packages/utils/src/Interfaces.ts b/packages/utils/src/Interfaces.ts index e8f0aade45b..144e3a3b209 100644 --- a/packages/utils/src/Interfaces.ts +++ b/packages/utils/src/Interfaces.ts @@ -22,6 +22,7 @@ import { OperationDefinitionNode, GraphQLError, ExecutionResult as GraphQLExecutionResult, + ExecutionPatchResult as GraphQLExecutionPatchResult, GraphQLOutputType, FieldDefinitionNode, GraphQLFieldConfig, @@ -54,10 +55,21 @@ import { SchemaVisitor } from './SchemaVisitor'; // See: https://github.com/graphql/graphql-js/pull/2490 export interface ExecutionResult> extends GraphQLExecutionResult { + errors?: ReadonlyArray; data?: TData | null; extensions?: Record; } +export interface ExecutionPatchResult> extends GraphQLExecutionPatchResult { + errors?: ReadonlyArray; + data?: TData | null; + path?: ReadonlyArray; + label?: string; + hasNext: boolean; + extensions?: Record; +} + +export type AsyncExecutionResult> = ExecutionResult | ExecutionPatchResult; // graphql-js non-exported typings export type TypeMap = Record; diff --git a/packages/utils/src/collectFields.ts b/packages/utils/src/collectFields.ts index aef6ab5d9b9..22e201e8454 100644 --- a/packages/utils/src/collectFields.ts +++ b/packages/utils/src/collectFields.ts @@ -122,6 +122,5 @@ function doesFragmentConditionMatch( * Implements the logic to compute the key of a given field's entry */ function getFieldEntryKey(node: FieldNode): string { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return node.alias ? node.alias.value : node.name.value; } diff --git a/packages/utils/src/executor.ts b/packages/utils/src/executor.ts index 7ff1d6422a8..311fdda6083 100644 --- a/packages/utils/src/executor.ts +++ b/packages/utils/src/executor.ts @@ -1,5 +1,5 @@ import { DocumentNode, GraphQLResolveInfo } from 'graphql'; -import { ExecutionResult } from './Interfaces'; +import { AsyncExecutionResult, ExecutionResult } from './Interfaces'; export interface ExecutionParams, TContext = any> { document: DocumentNode; @@ -15,7 +15,8 @@ export type AsyncExecutor> = < TContext extends TBaseContext = TBaseContext >( params: ExecutionParams -) => Promise>; +) => Promise> | ExecutionResult>; + export type SyncExecutor> = < TReturn = Record, TArgs = Record, @@ -23,17 +24,19 @@ export type SyncExecutor> = < >( params: ExecutionParams ) => ExecutionResult; + export type Executor> = < TReturn = Record, TArgs = Record, TContext extends TBaseContext = TBaseContext >( params: ExecutionParams -) => ExecutionResult | Promise>; +) => ExecutionResult | AsyncIterableIterator> | Promise> | ExecutionResult>; + export type Subscriber> = < TReturn = Record, TArgs = Record, TContext extends TBaseContext = TBaseContext >( params: ExecutionParams -) => Promise> | ExecutionResult>; +) => Promise> | ExecutionResult>; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 531c3a96ea9..d6e95e4a6ea 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -39,6 +39,7 @@ export * from './renameType'; export * from './collectFields'; export * from './transformInputValue'; export * from './mapAsyncIterator'; +export * from './splitAsyncIterator'; export * from './updateArgument'; export * from './implementsAbstractType'; export * from './errors'; diff --git a/packages/utils/src/mapAsyncIterator.ts b/packages/utils/src/mapAsyncIterator.ts index 67adfa2ffda..c70854c5a40 100644 --- a/packages/utils/src/mapAsyncIterator.ts +++ b/packages/utils/src/mapAsyncIterator.ts @@ -1,59 +1,39 @@ /** - * Given an AsyncIterable and a callback function, return an AsyncIterator + * Given an AsyncIterator and a callback function, return an AsyncIterator * which produces values mapped via calling the callback function. + * + * Implementation adapted from: + * https://github.com/repeaterjs/repeater/issues/48#issuecomment-569134039 + * so that all payloads will be delivered in the original order */ + +import { Repeater } from '@repeaterjs/repeater'; + export function mapAsyncIterator( iterator: AsyncIterator, - callback: (value: T) => Promise | U, - rejectCallback?: any + mapValue: (value: T) => Promise | U ): AsyncIterableIterator { - let $return: any; - let abruptClose: any; - - if (typeof iterator.return === 'function') { - $return = iterator.return; - abruptClose = (error: any) => { - const rethrow = () => Promise.reject(error); - return $return.call(iterator).then(rethrow, rethrow); - }; - } + const returner = iterator.return?.bind(iterator) ?? (() => true); - function mapResult(result: any) { - return result.done ? result : asyncMapValue(result.value, callback).then(iteratorResult, abruptClose); - } + return new Repeater(async (push, stop) => { + let earlyReturn: any; + stop.then(() => { + earlyReturn = returner(); + }); - let mapReject: any; - if (rejectCallback) { - // Capture rejectCallback to ensure it cannot be null. - const reject = rejectCallback; - mapReject = (error: any) => asyncMapValue(error, reject).then(iteratorResult, abruptClose); - } + /* eslint-disable no-unmodified-loop-condition */ + while (!earlyReturn) { + const iteration = await iterator.next(); - return { - next() { - return iterator.next().then(mapResult, mapReject); - }, - return() { - return $return - ? $return.call(iterator).then(mapResult, mapReject) - : Promise.resolve({ value: undefined, done: true }); - }, - throw(error: any) { - if (typeof iterator.throw === 'function') { - return iterator.throw(error).then(mapResult, mapReject); + if (iteration.done) { + stop(); + return iteration.value; } - return Promise.reject(error).catch(abruptClose); - }, - [Symbol.asyncIterator]() { - return this; - }, - }; -} -function asyncMapValue(value: T, callback: (value: T) => Promise | U): Promise { - return new Promise(resolve => resolve(callback(value))); -} + await push(mapValue(iteration.value)); + } + /* eslint-enable no-unmodified-loop-condition */ -function iteratorResult(value: T): IteratorResult { - return { value, done: false }; + await earlyReturn; + }); } diff --git a/packages/utils/src/splitAsyncIterator.ts b/packages/utils/src/splitAsyncIterator.ts new file mode 100644 index 00000000000..8d11a7d66ee --- /dev/null +++ b/packages/utils/src/splitAsyncIterator.ts @@ -0,0 +1,88 @@ +// adapted from: https://stackoverflow.com/questions/63543455/how-to-multicast-an-async-iterable +// and: https://gist.github.com/jed/cc1e949419d42e2cb26d7f2e1645864d +// and also: https://github.com/repeaterjs/repeater/issues/48#issuecomment-569134039 + +import { Repeater } from '@repeaterjs/repeater'; + +type Splitter = (item: T) => [[number | undefined, T]]; + +export function splitAsyncIterator(iterator: AsyncIterator, n: number, splitter: Splitter) { + const returner = iterator.return?.bind(iterator) ?? (() => true); + + const buffers: Array>> = Array(n); + for (let i = 0; i < n; i++) { + buffers[i] = []; + } + + const set: Set = new Set(); + return buffers.map((buffer, index) => { + set.add(index); + return new Repeater(async (push, stop) => { + let earlyReturn: any; + stop.then(() => { + set.delete(index); + if (!set.size) { + earlyReturn = returner(); + } + }); + + /* eslint-disable no-unmodified-loop-condition */ + while (!earlyReturn) { + const iteration = await next(buffer, buffers, iterator, splitter); + + if (iteration === undefined) { + continue; + } + + if (iteration.done) { + stop(); + return iteration.value; + } + + await push(iteration.value); + } + /* eslint-enable no-unmodified-loop-condition */ + + await earlyReturn; + }); + }); +} + +async function next( + buffer: Array>, + buffers: Array>>, + iterator: AsyncIterator, + splitter: Splitter +): Promise | undefined> { + const existingIteration = buffer.shift(); + + if (existingIteration !== undefined) { + return existingIteration; + } + + const iterationCandidate = await iterator.next(); + + const value = iterationCandidate.value; + if (value !== undefined) { + const assignments = splitter(value); + + for (const [iterationIndex, newValue] of assignments) { + if (iterationIndex !== undefined) { + buffers[iterationIndex].push({ + ...iterationCandidate, + value: newValue, + }); + } else { + for (const b of buffers) { + b.push(iterationCandidate); + } + } + } + } else { + for (const b of buffers) { + b.push(iterationCandidate); + } + } + + return buffer.shift(); +} diff --git a/packages/utils/tests/splitAsyncIterator.spec.ts b/packages/utils/tests/splitAsyncIterator.spec.ts new file mode 100644 index 00000000000..4274720b783 --- /dev/null +++ b/packages/utils/tests/splitAsyncIterator.spec.ts @@ -0,0 +1,94 @@ +import { splitAsyncIterator } from '../src/splitAsyncIterator'; +import { mapAsyncIterator } from '../src/mapAsyncIterator'; + +describe('splitAsyncIterator', () => { + test('it works sequentially', async () => { + const gen3 = async function* () { + for (let i = 0; i < 3; i++) { + yield i; + } + }(); + + const [one, two] = splitAsyncIterator(gen3, 2, (x) => [[0, x + 5]]); + + let results = []; + for await (const result of one) { + results.push(result); + } + expect(results).toEqual([5, 6, 7]); + + results = []; + for await (const result of two) { + results.push(result); + } + expect(results).toEqual([]); + }); + + test('it works in parallel', async () => { + const gen3 = async function* () { + for (let i = 0; i < 3; i++) { + yield i; + } + }(); + + const [one, two] = splitAsyncIterator(gen3, 2, (x) => [[0, x + 5]]); + + const oneResults = []; + const twoResults = []; + for (let i = 0; i < 3; i++) { + const results = await Promise.all([one.next(), two.next()]); + oneResults.push(results[0].value); + twoResults.push(results[1].value); + } + + expect(oneResults).toEqual([5, 6, 7]); + expect(twoResults).toEqual([undefined, undefined, undefined]); + }); +}); + +describe('splitAsyncIterator with mapAsyncIterator', () => { + test('it works sequentially', async () => { + const gen3 = async function* () { + for (let i = 0; i < 3; i++) { + yield i; + } + }(); + + const mappedGen3 = mapAsyncIterator(gen3, value => value); + const [one, two] = splitAsyncIterator(mappedGen3, 2, (x) => [[0, x + 5]]); + + let results = []; + for await (const result of one) { + results.push(result); + } + expect(results).toEqual([5, 6, 7]); + + results = []; + for await (const result of two) { + results.push(result); + } + expect(results).toEqual([]); + }); + + test('it works in parallel', async () => { + const gen3 = async function* () { + for (let i = 0; i < 3; i++) { + yield i; + } + }(); + + const mappedGen3 = mapAsyncIterator(gen3, value => value); + const [one, two] = splitAsyncIterator(mappedGen3, 2, (x) => [[0, x + 5]]); + + const oneResults = []; + const twoResults = []; + for (let i = 0; i < 3; i++) { + const results = await Promise.all([one.next(), two.next()]); + oneResults.push(results[0].value); + twoResults.push(results[1].value); + } + + expect(oneResults).toEqual([5, 6, 7]); + expect(twoResults).toEqual([undefined, undefined, undefined]); + }); +}); diff --git a/packages/wrap/src/generateProxyingResolvers.ts b/packages/wrap/src/generateProxyingResolvers.ts index c2e07c0d6f7..feda3bccb5b 100644 --- a/packages/wrap/src/generateProxyingResolvers.ts +++ b/packages/wrap/src/generateProxyingResolvers.ts @@ -1,24 +1,29 @@ -import { GraphQLFieldResolver, GraphQLObjectType, GraphQLResolveInfo, OperationTypeNode } from 'graphql'; +import { GraphQLFieldResolver, GraphQLObjectType, GraphQLResolveInfo, GraphQLSchema, OperationTypeNode } from 'graphql'; import { getResponseKeyFromInfo } from '@graphql-tools/utils'; import { - delegateToSchema, - getSubschema, - resolveExternalValue, - SubschemaConfig, ICreateProxyingResolverOptions, + SubschemaConfig, applySchemaTransforms, - isExternalObject, + createExternalValue, + delegateToSchema, + getInitialPath, + getReceiver, + getSubschema, getUnpathedErrors, + isExternalObject, } from '@graphql-tools/delegate'; export function generateProxyingResolvers( - subschemaConfig: SubschemaConfig + subschemaConfig: SubschemaConfig, + transformedSchema?: GraphQLSchema, ): Record>> { const targetSchema = subschemaConfig.schema; const createProxyingResolver = subschemaConfig.createProxyingResolver ?? defaultCreateProxyingResolver; - const transformedSchema = applySchemaTransforms(targetSchema, subschemaConfig); + if (transformedSchema === undefined) { + transformedSchema = applySchemaTransforms(targetSchema, subschemaConfig); + } const operationTypes: Record = { query: targetSchema.getQueryType(), @@ -72,14 +77,16 @@ function createPossiblyNestedProxyingResolver( // Check to see if the parent contains a proxied result if (isExternalObject(parent)) { - const unpathedErrors = getUnpathedErrors(parent); const subschema = getSubschema(parent, responseKey); // If there is a proxied result from this subschema, return it // This can happen even for a root field when the root type ia // also nested as a field within a different type. if (subschemaConfig === subschema && parent[responseKey] !== undefined) { - return resolveExternalValue(parent[responseKey], unpathedErrors, subschema, context, info); + const unpathedErrors = getUnpathedErrors(parent); + const initialPath = getInitialPath(parent); + const receiver = getReceiver(parent, subschema); + return createExternalValue(parent[responseKey], unpathedErrors, initialPath, subschema, context, info, receiver); } } } diff --git a/packages/wrap/src/introspect.ts b/packages/wrap/src/introspect.ts index 08ef51fdf9c..38703111e6c 100644 --- a/packages/wrap/src/introspect.ts +++ b/packages/wrap/src/introspect.ts @@ -28,16 +28,18 @@ function getSchemaFromIntrospection(introspectionResult: ExecutionResult( +export function introspectSchema( executor: TExecutor, context?: Record, options?: IntrospectionOptions -): TExecutor extends AsyncExecutor ? Promise : GraphQLSchema { +): TExecutor extends SyncExecutor ? GraphQLSchema : TExecutor extends AsyncExecutor ? Promise : Promise | GraphQLSchema { const parsedIntrospectionQuery: DocumentNode = parse(getIntrospectionQuery(options)); return new ValueOrPromise(() => (executor as Executor)({ document: parsedIntrospectionQuery, context, - })).then(introspection => getSchemaFromIntrospection(introspection)).resolve() as any; + })).then( + (introspection: ExecutionResult) => getSchemaFromIntrospection(introspection) + ).resolve() as any; } // Keep for backwards compatibility. Will be removed on next release. diff --git a/packages/wrap/src/transforms/TransformCompositeFields.ts b/packages/wrap/src/transforms/TransformCompositeFields.ts index 537b3e20d92..2333bed5bb6 100644 --- a/packages/wrap/src/transforms/TransformCompositeFields.ts +++ b/packages/wrap/src/transforms/TransformCompositeFields.ts @@ -25,7 +25,6 @@ export default class TransformCompositeFields implements Transform { private transformedSchema: GraphQLSchema; private typeInfo: TypeInfo; private mapping: Record>; - private subscriptionTypeName: string; constructor( fieldTransformer: FieldTransformer, @@ -62,7 +61,6 @@ export default class TransformCompositeFields implements Transform { }, }); this.typeInfo = new TypeInfo(this.transformedSchema); - this.subscriptionTypeName = originalWrappingSchema.getSubscriptionType()?.name; return this.transformedSchema; } @@ -137,20 +135,6 @@ export default class TransformCompositeFields implements Transform { const newName = selection.name.value; - // See https://github.com/ardatan/graphql-tools/issues/2282 - if ( - (this.dataTransformer != null || this.errorsTransformer != null) && - (this.subscriptionTypeName == null || parentTypeName !== this.subscriptionTypeName) - ) { - newSelections.push({ - kind: Kind.FIELD, - name: { - kind: Kind.NAME, - value: '__typename', - }, - }); - } - let transformedSelection: SelectionNode | Array; if (this.fieldNodeTransformer == null) { transformedSelection = selection; diff --git a/packages/wrap/src/transforms/TransformInputObjectFields.ts b/packages/wrap/src/transforms/TransformInputObjectFields.ts index f823a511a33..a30e8ba29f6 100644 --- a/packages/wrap/src/transforms/TransformInputObjectFields.ts +++ b/packages/wrap/src/transforms/TransformInputObjectFields.ts @@ -7,7 +7,6 @@ import { visit, visitWithTypeInfo, Kind, - FragmentDefinitionNode, GraphQLInputObjectType, GraphQLInputType, ObjectValueNode, @@ -71,15 +70,12 @@ export default class TransformInputObjectFields implements Transform { _transformationContext: Record ): Request { const variableValues = originalRequest.variables; - const fragments = Object.create(null); const operations: Array = []; originalRequest.document.definitions.forEach(def => { if ((def as OperationDefinitionNode).kind === Kind.OPERATION_DEFINITION) { operations.push(def as OperationDefinitionNode); - } else { - fragments[(def as FragmentDefinitionNode).name.value] = def; } }); @@ -118,11 +114,6 @@ export default class TransformInputObjectFields implements Transform { } }); - originalRequest.document.definitions - .filter(def => def.kind === Kind.FRAGMENT_DEFINITION) - .forEach(def => { - fragments[(def as FragmentDefinitionNode).name.value] = def; - }); const document = this.transformDocument( originalRequest.document, this.mapping, diff --git a/packages/wrap/src/transforms/TransformQuery.ts b/packages/wrap/src/transforms/TransformQuery.ts index 06221726df6..717e2ec1033 100644 --- a/packages/wrap/src/transforms/TransformQuery.ts +++ b/packages/wrap/src/transforms/TransformQuery.ts @@ -8,10 +8,14 @@ export type QueryTransformer = ( selectionSet: SelectionSetNode, fragments: Record, delegationContext: DelegationContext, - transformationContext: Record, + transformationContext: Record ) => SelectionSetNode; -export type ResultTransformer = (result: any, delegationContext: DelegationContext, transformationContext: Record) => any; +export type ResultTransformer = ( + result: any, + delegationContext: DelegationContext, + transformationContext: Record +) => any; export type ErrorPathTransformer = (path: ReadonlyArray) => Array; @@ -59,7 +63,12 @@ export default class TransformQuery implements Transform { index++; if (index === pathLength) { - const selectionSet = this.queryTransformer(node.selectionSet, this.fragments, delegationContext, transformationContext); + const selectionSet = this.queryTransformer( + node.selectionSet, + this.fragments, + delegationContext, + transformationContext + ); return { ...node, @@ -87,12 +96,17 @@ export default class TransformQuery implements Transform { const data = this.transformData(originalResult.data, delegationContext, transformationContext); const errors = originalResult.errors; return { + ...originalResult, data, errors: errors != null ? this.transformErrors(errors) : undefined, }; } - private transformData(data: any, delegationContext: DelegationContext, transformationContext: Record): any { + private transformData( + data: any, + delegationContext: DelegationContext, + transformationContext: Record + ): any { const leafIndex = this.path.length - 1; let index = 0; let newData = data; diff --git a/packages/wrap/src/transforms/WrapFields.ts b/packages/wrap/src/transforms/WrapFields.ts index ee989ae9b9d..6b7458178f4 100644 --- a/packages/wrap/src/transforms/WrapFields.ts +++ b/packages/wrap/src/transforms/WrapFields.ts @@ -51,7 +51,7 @@ export default class WrapFields implements Transform dehoistValue(value, context), + [outerTypeName]: (value, context: WrapFieldsTransformationContext) => + dehoistValue(value, wrappingTypeNames, context), }, (errors, context: WrapFieldsTransformationContext) => dehoistErrors(errors, context) ); @@ -279,28 +280,31 @@ function hoistFieldNodes({ return newFieldNodes; } -export function dehoistValue(originalValue: any, context: WrapFieldsTransformationContext): any { +export function dehoistValue( + originalValue: any, + wrappingTypeNames: Array, + context: WrapFieldsTransformationContext +): any { if (originalValue == null) { return originalValue; } const newValue = Object.create(null); - Object.keys(originalValue).forEach(alias => { + Object.keys(originalValue).forEach(responseKey => { let obj = newValue; - const path = context.paths[alias]; + const path = context.paths[responseKey]; if (path == null) { - newValue[alias] = originalValue[alias]; + newValue[responseKey] = originalValue[responseKey]; return; } const pathToField = path.pathToField; - const fieldAlias = path.alias; - pathToField.forEach(key => { - obj = obj[key] = obj[key] || Object.create(null); + pathToField.forEach((key, index) => { + obj = obj[key] = obj[key] ?? { __typename: wrappingTypeNames[index] }; }); - obj[fieldAlias] = originalValue[alias]; + obj[path.alias] = originalValue[responseKey]; }); return newValue; diff --git a/packages/wrap/src/transforms/WrapQuery.ts b/packages/wrap/src/transforms/WrapQuery.ts index 04b7dbc84ba..3055910a0f9 100644 --- a/packages/wrap/src/transforms/WrapQuery.ts +++ b/packages/wrap/src/transforms/WrapQuery.ts @@ -77,6 +77,7 @@ export default class WrapQuery implements Transform { } return { + ...originalResult, data: rootData, errors: originalResult.errors, }; diff --git a/packages/wrap/src/wrapSchema.ts b/packages/wrap/src/wrapSchema.ts index 12f55f7bcde..6fb4b31ead8 100644 --- a/packages/wrap/src/wrapSchema.ts +++ b/packages/wrap/src/wrapSchema.ts @@ -13,12 +13,11 @@ import { generateProxyingResolvers } from './generateProxyingResolvers'; export function wrapSchema(subschemaConfig: SubschemaConfig): GraphQLSchema { const targetSchema = subschemaConfig.schema; + const transformedSchema = applySchemaTransforms(targetSchema, subschemaConfig); - const proxyingResolvers = generateProxyingResolvers(subschemaConfig); + const proxyingResolvers = generateProxyingResolvers(subschemaConfig, transformedSchema); const schema = createWrappingSchema(targetSchema, proxyingResolvers); - const transformedSchema = applySchemaTransforms(schema, subschemaConfig); - return applySchemaTransforms(schema, subschemaConfig, transformedSchema); } diff --git a/packages/wrap/tests/makeRemoteExecutableSchema.test.ts b/packages/wrap/tests/makeRemoteExecutableSchema.test.ts index 554d656108b..152ddae6e27 100644 --- a/packages/wrap/tests/makeRemoteExecutableSchema.test.ts +++ b/packages/wrap/tests/makeRemoteExecutableSchema.test.ts @@ -211,16 +211,19 @@ describe('respects buildSchema options', () => { expect(print(calls[0].document)).toEqual(`\ { fieldA + __typename } `); expect(print(calls[1].document)).toEqual(`\ { fieldB + __typename } `); expect(print(calls[2].document)).toEqual(`\ { field3 + __typename } `); }); diff --git a/patches/graphql+15.4.0-experimental-stream-defer.1.patch b/patches/graphql+15.4.0-experimental-stream-defer.1.patch new file mode 100644 index 00000000000..7508e678639 --- /dev/null +++ b/patches/graphql+15.4.0-experimental-stream-defer.1.patch @@ -0,0 +1,143 @@ +diff --git a/node_modules/graphql/execution/execute.js b/node_modules/graphql/execution/execute.js +index e711fb5..e1dc791 100644 +--- a/node_modules/graphql/execution/execute.js ++++ b/node_modules/graphql/execution/execute.js +@@ -724,7 +724,13 @@ function completeAsyncIteratorValue(exeContext, itemType, fieldNodes, info, path + var containsPromise = false; + var stream = getStreamValues(exeContext, fieldNodes); + return new Promise(function (resolve) { +- function next(index, completedResults) { ++ function advance(index, completedResults) { ++ if (stream && typeof stream.initialCount === 'number' && index >= stream.initialCount) { ++ exeContext.dispatcher.addAsyncIteratorValue(stream.label, index, path, iterator, exeContext, fieldNodes, info, itemType); ++ resolve(completedResults); ++ return; ++ } ++ + var fieldPath = (0, _Path.addPath)(path, index, undefined); + iterator.next().then(function (_ref) { + var value = _ref.value, +@@ -748,19 +754,9 @@ function completeAsyncIteratorValue(exeContext, itemType, fieldNodes, info, path + completedResults.push(null); + var error = (0, _locatedError.locatedError)(rawError, fieldNodes, (0, _Path.pathToArray)(fieldPath)); + handleFieldError(error, itemType, errors); +- resolve(completedResults); +- return; + } + +- var newIndex = index + 1; +- +- if (stream && typeof stream.initialCount === 'number' && newIndex >= stream.initialCount) { +- exeContext.dispatcher.addAsyncIteratorValue(stream.label, newIndex, path, iterator, exeContext, fieldNodes, info, itemType); +- resolve(completedResults); +- return; +- } +- +- next(newIndex, completedResults); ++ advance(index + 1, completedResults); + }, function (rawError) { + completedResults.push(null); + var error = (0, _locatedError.locatedError)(rawError, fieldNodes, (0, _Path.pathToArray)(fieldPath)); +@@ -769,7 +765,7 @@ function completeAsyncIteratorValue(exeContext, itemType, fieldNodes, info, path + }); + } + +- next(0, []); ++ advance(0, []); + }).then(function (completedResults) { + return containsPromise ? Promise.all(completedResults) : completedResults; + }); +@@ -1187,46 +1183,59 @@ var Dispatcher = /*#__PURE__*/function () { + var _this = this; + + return new Promise(function (resolve) { ++ var resolved = false; + _this._subsequentPayloads.forEach(function (promise) { +- promise.then(function () { +- // resolve with actual promise, not resolved value of promise so we can remove it from this._subsequentPayloads +- resolve({ +- promise: promise +- }); +- }); +- }); +- }).then(function (_ref3) { +- var promise = _ref3.promise; ++ promise.then(function (payload) { ++ if (resolved) { ++ return; ++ } ++ resolved = true; + +- _this._subsequentPayloads.splice(_this._subsequentPayloads.indexOf(promise), 1); ++ if (_this._subsequentPayloads.length === 0) { ++ // a different call to next has exhausted all payloads ++ resolve({ value: undefined, done: true }); ++ return; ++ } + +- return promise; +- }).then(function (_ref4) { +- var value = _ref4.value, +- done = _ref4.done; ++ var index = _this._subsequentPayloads.indexOf(promise); + +- if (done && _this._subsequentPayloads.length === 0) { +- // async iterable resolver just finished and no more pending payloads +- return { +- value: { +- hasNext: false +- }, +- done: false +- }; +- } else if (done) { +- // async iterable resolver just finished but there are pending payloads +- // return the next one +- return _this._race(); +- } ++ if (index === -1) { ++ // a different call to next has consumed this payload ++ resolve(_this._race()); ++ return; ++ } + +- var returnValue = _objectSpread(_objectSpread({}, value), {}, { +- hasNext: _this._subsequentPayloads.length > 0 +- }); ++ _this._subsequentPayloads.splice(index, 1); + +- return { +- value: returnValue, +- done: false +- }; ++ var value = payload.value, ++ done = payload.done; ++ ++ if (done && _this._subsequentPayloads.length === 0) { ++ // async iterable resolver just finished and no more pending payloads ++ resolve({ ++ value: { ++ hasNext: false, ++ }, ++ done: false, ++ }); ++ return; ++ } else if (done) { ++ // async iterable resolver just finished but there are pending payloads ++ // return the next one ++ resolve(_this._race()); ++ return; ++ } ++ ++ var returnValue = _objectSpread(_objectSpread({}, value), {}, { ++ hasNext: _this._subsequentPayloads.length > 0 ++ }); ++ ++ resolve({ ++ value: returnValue, ++ done: false, ++ }); ++ }); ++ }); + }); + }; + diff --git a/scripts/match-graphql.js b/scripts/match-graphql.js index 04be8f948af..fafcfe63fa2 100644 --- a/scripts/match-graphql.js +++ b/scripts/match-graphql.js @@ -6,13 +6,13 @@ const pkgPath = resolve(cwd(), './package.json'); const pkg = require(pkgPath); -const version = argv[2]; +const versionOrTag = argv[2]; pkg.resolutions = pkg.resolutions || {}; -if (pkg.resolutions.graphql.startsWith(version)){ - console.info(`GraphQL v${version} already installed! Skipping.`) +if (pkg.resolutions.graphql.startsWith(versionOrTag)){ + console.info(`GraphQL v${versionOrTag} already installed! Skipping.`) } -pkg.resolutions.graphql = `^${version}`; +pkg.resolutions.graphql = typeof versionOrTag === 'number' ? `^${versionOrTag}`: versionOrTag; writeFileSync(pkgPath, JSON.stringify(pkg, null, 2), 'utf8'); diff --git a/website/docs/stitch-api.md b/website/docs/stitch-api.md index 47b7f1f090c..4dc98e258a7 100644 --- a/website/docs/stitch-api.md +++ b/website/docs/stitch-api.md @@ -78,5 +78,5 @@ import { forwardArgsToSelectionSet } from '@graphql-tools/stitch'; forwardArgsToSelectionSet( selectionSet: string, mapping?: Record -) => (field: FieldNode) => SelectionSetNode +) => (schema: GraphQLSchema, field: GraphQLField) => (originalFieldNode: FieldNode) => Array ``` diff --git a/yarn.lock b/yarn.lock index 76a579c58fd..3b64b80ec25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2143,6 +2143,11 @@ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.11.tgz#aeb16f50649a91af79dbe36574b66d0f9e4d9f71" integrity sha512-3NsZsJIA/22P3QUyrEDNA2D133H4j224twJrdipXN38dpnIOzAbUDtOwkcJ5pXmn75w7LSQDjA4tO9dm1XlqlA== +"@repeaterjs/repeater@^3.0.4": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@repeaterjs/repeater/-/repeater-3.0.4.tgz#a04d63f4d1bf5540a41b01a921c9a7fddc3bd1ca" + integrity sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA== + "@rollup/plugin-node-resolve@7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-node-resolve/-/plugin-node-resolve-7.1.1.tgz#8c6e59c4b28baf9d223028d0e450e06a485bb2b7" @@ -6622,10 +6627,10 @@ graphql-ws@^4.4.1: resolved "https://registry.yarnpkg.com/graphql-ws/-/graphql-ws-4.4.1.tgz#69f472f362b57366af23265c6c6b967077b9d1dc" integrity sha512-kHgDohfRQFDdzXzLqsV4wZM141sO1ukaXW/RSLlmIUsxT4N3r/4eQYTbkeLd4yRXaDkmv/rYf1EHL09Y5KO+Uw== -graphql@15.5.0, graphql@^14.5.3, graphql@^15.3.0: - version "15.5.0" - resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.5.0.tgz#39d19494dbe69d1ea719915b578bf920344a69d5" - integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== +graphql@15.4.0-experimental-stream-defer.1, graphql@^14.5.3, graphql@^15.3.0: + version "15.4.0-experimental-stream-defer.1" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-15.4.0-experimental-stream-defer.1.tgz#46ae3fd2b532284575e7ddcf6c4f08aa7fe53fa3" + integrity sha512-zlGgY7aLlIofjO0CfTpCYK/tMccnj+5jvjnkTnW5qOxYhgEltuCvpMNYOJ67gz6L1flTIigt5BVEM8JExgtW3w== gray-matter@^4.0.3: version "4.0.3"