diff --git a/.changeset/breezy-weeks-camp.md b/.changeset/breezy-weeks-camp.md new file mode 100644 index 000000000..7e2384ddf --- /dev/null +++ b/.changeset/breezy-weeks-camp.md @@ -0,0 +1,6 @@ +--- +'@graphql-tools/batch-delegate': patch +'@graphql-tools/delegate': patch +--- + +Correct error paths in case of batch delegation with the same error diff --git a/packages/batch-delegate/src/batchDelegateToSchema.ts b/packages/batch-delegate/src/batchDelegateToSchema.ts index 71bd6f921..50807e2b9 100644 --- a/packages/batch-delegate/src/batchDelegateToSchema.ts +++ b/packages/batch-delegate/src/batchDelegateToSchema.ts @@ -1,3 +1,5 @@ +import { pathToArray, relocatedError } from '@graphql-tools/utils'; +import { GraphQLError } from 'graphql'; import { getLoader } from './getLoader.js'; import { BatchDelegateOptions } from './types.js'; @@ -11,5 +13,11 @@ export function batchDelegateToSchema( return []; } const loader = getLoader(options); - return Array.isArray(key) ? loader.loadMany(key) : loader.load(key); + const res = Array.isArray(key) ? loader.loadMany(key) : loader.load(key); + return res.catch((error) => { + if (options.info?.path && error instanceof GraphQLError) { + return relocatedError(error, pathToArray(options.info.path)); + } + return error; + }); } diff --git a/packages/batch-delegate/tests/errorPaths.test.ts b/packages/batch-delegate/tests/errorPaths.test.ts new file mode 100644 index 000000000..78f065ce5 --- /dev/null +++ b/packages/batch-delegate/tests/errorPaths.test.ts @@ -0,0 +1,162 @@ +import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; +import { delegateToSchema } from '@graphql-tools/delegate'; +import { normalizedExecutor } from '@graphql-tools/executor'; +import { makeExecutableSchema } from '@graphql-tools/schema'; +import { stitchSchemas } from '@graphql-tools/stitch'; +import { GraphQLError, parse } from 'graphql'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +class NotFoundError extends GraphQLError { + constructor(id: unknown) { + super('Not Found', { + extensions: { id }, + }); + } +} + +describe('preserves error path indices', () => { + const getProperty = vi.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 normalizedExecutor({ + schema, + document: parse(query), + }); + + expect(getProperty).toHaveBeenCalledTimes(2); + expect(result).toMatchObject(expected); + }); + + test('using batchDelegateToSchema', async () => { + const schema = stitchSchemas({ + subschemas, + typeDefs, + resolvers: { + Object: { + property: { + selectionSet: '{ propertyId }', + resolve: (source, _, context, info) => + batchDelegateToSchema({ + schema: subschema, + fieldName: 'propertiesByIds', + key: source.propertyId, + context, + info, + }), + }, + }, + }, + }); + + const result = await normalizedExecutor({ + schema, + document: parse(query), + }); + + expect(getProperty).toHaveBeenCalledTimes(1); + expect(result).toMatchObject(expected); + }); +}); diff --git a/packages/delegate/src/resolveExternalValue.ts b/packages/delegate/src/resolveExternalValue.ts index 8aa6958c5..9402afaa4 100644 --- a/packages/delegate/src/resolveExternalValue.ts +++ b/packages/delegate/src/resolveExternalValue.ts @@ -182,7 +182,7 @@ function resolveExternalList>( ); } -const reportedErrors = new WeakMap(); +const reportedErrors = new WeakSet(); function reportUnpathedErrorsViaNull(unpathedErrors: Array) { if (unpathedErrors.length) { @@ -190,7 +190,7 @@ function reportUnpathedErrorsViaNull(unpathedErrors: Array) { for (const error of unpathedErrors) { if (!reportedErrors.has(error)) { unreportedErrors.push(error); - reportedErrors.set(error, true); + reportedErrors.add(error); } }