diff --git a/.changeset/tricky-mappers-warn.md b/.changeset/tricky-mappers-warn.md new file mode 100644 index 00000000..acb379b1 --- /dev/null +++ b/.changeset/tricky-mappers-warn.md @@ -0,0 +1,9 @@ +--- +'@eddeee888/gcg-typescript-resolver-files': patch +--- + +Fix `fixObjectTypeResolvers` treating an unresolvable mapper type as an empty mapper and injecting a stub for every field + +When a mapper aliases a type whose import cannot be resolved (a TypeScript _error type_ — e.g. a generated client that has not been generated yet, or a module that is not installed on a fresh checkout), `fixObjectTypeResolvers` (both `smart` and `fast`) previously treated the mapper as having zero fields and injected a resolver stub for every field of the schema type, silently overwriting hand-maintained resolver files with broken `Promise` stubs. + +The generator now detects the unresolved type, skips stub generation for that mapper (leaving existing resolvers untouched), and emits a warning naming the mapper so the underlying unresolved import surfaces instead. Genuinely empty mappers (e.g. `type FooMapper = {}`) still generate stubs as before. diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.spec.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.spec.ts new file mode 100644 index 00000000..5377a013 --- /dev/null +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.spec.ts @@ -0,0 +1,139 @@ +import { Project, SyntaxKind } from 'ts-morph'; +import { getGraphQLObjectTypeResolversToGenerate } from './getGraphQLObjectTypeResolversToGenerate'; +import { logger } from '../utils'; +import type { TypeMappersMap } from '../parseTypeMappers'; +import type { GeneratedTypesFileMeta } from '../generateResolverFiles'; +import type { ParsedGraphQLSchemaMeta } from '../parseGraphQLSchema'; + +const MAPPERS_PATH = '/path/to/schema.mappers.ts'; +const TYPES_PATH = '/path/to/types.generated.ts'; + +const setup = ({ + mapperContent, +}: { + mapperContent: string; +}): { + tsMorphProject: Project; + typesSourceFile: ReturnType; + typeMappersMap: TypeMappersMap; + userDefinedSchemaObjectTypeMap: ParsedGraphQLSchemaMeta['userDefinedSchemaTypeMap']['object']; + generatedTypesFileMeta: GeneratedTypesFileMeta; +} => { + const tsMorphProject = new Project({ skipAddingFilesFromTsConfig: true }); + + // A stand-in for the generated `types.generated.ts`: the `User` type has two + // fields, so if `UserMapper` is treated as empty, both would be stubbed. + const typesSourceFile = tsMorphProject.createSourceFile( + TYPES_PATH, + `export type UserResolvers = { + id?: unknown; + name?: unknown; + };` + ); + + tsMorphProject.createSourceFile(MAPPERS_PATH, mapperContent); + + const typeMappersMap: TypeMappersMap = { + User: { + schemaType: 'User', + mapper: { + name: 'UserMapper', + filename: MAPPERS_PATH, + kind: SyntaxKind.TypeAliasDeclaration, + }, + configImportPath: '', + }, + }; + + const generatedTypesFileMeta = { + generatedResolverTypes: { + resolversMap: { name: 'Resolvers' }, + userDefined: { User: { name: 'UserResolvers' } }, + }, + } as unknown as GeneratedTypesFileMeta; + + const userDefinedSchemaObjectTypeMap = { + User: {}, + } as unknown as ParsedGraphQLSchemaMeta['userDefinedSchemaTypeMap']['object']; + + return { + tsMorphProject, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + }; +}; + +describe('getGraphQLObjectTypeResolversToGenerate', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + // Regression test for https://github.com/eddeee888/graphql-code-generator-plugins/issues/446 + it.each(['smart', 'fast'] as const)( + 'skips stub generation and warns when a mapper type is unresolvable (mode: %s)', + (mode) => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => { + // noop + }); + const { + tsMorphProject, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + } = setup({ + mapperContent: `import type { User } from './does-not-exist'; + export type UserMapper = User;`, + }); + + const result = getGraphQLObjectTypeResolversToGenerate({ + mode, + tsMorphProject, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + }); + + // No stubs injected: the hand-maintained resolver file is left untouched. + expect(result.User).toBeUndefined(); + + // The real problem (unresolved import) is surfaced via a warning. + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('UserMapper'); + } + ); + + it.each(['smart', 'fast'] as const)( + 'still stubs fields for a mapper that genuinely has no matching fields (mode: %s)', + (mode) => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => { + // noop + }); + const { + tsMorphProject, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + } = setup({ mapperContent: `export type UserMapper = {};` }); + + const result = getGraphQLObjectTypeResolversToGenerate({ + mode, + tsMorphProject, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + }); + + // A genuinely empty mapper still triggers stub generation (unchanged behavior). + expect(result.User).toBeDefined(); + expect(result.User.id).toBeDefined(); + expect(result.User.name).toBeDefined(); + expect(warnSpy).not.toHaveBeenCalled(); + } + ); +}); diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts index b22d5d4b..9ab77438 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts @@ -10,9 +10,14 @@ import { Node, } from 'ts-morph'; import type { TypeMapperDetails, TypeMappersMap } from '../parseTypeMappers'; -import { type NodePropertyMap, getNodePropertyMap } from './getNodePropertyMap'; +import { + type NodePropertyMap, + getNodePropertyMap, + isNodeTypeUnresolved, +} from './getNodePropertyMap'; import type { ParsedGraphQLSchemaMeta } from '../parseGraphQLSchema'; import type { GeneratedTypesFileMeta } from '../generateResolverFiles'; +import { logger } from '../utils'; export type GraphQLObjectTypeResolversToGenerate = Record< string, @@ -111,6 +116,17 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ tsMorphProject, mapper, }); + + // If the mapper's type cannot be resolved (e.g. it aliases a type from an + // unresolvable import), it reports zero properties, which is + // indistinguishable from an empty mapper. Generating stubs here would + // overwrite hand-maintained resolvers with broken ones, so we skip this + // mapper and warn instead. See issue #446. + if (isNodeTypeUnresolved({ node: mapperOriginalDeclarationNode })) { + logger.warn(getUnresolvedMapperWarning({ mapper, schemaType })); + return; + } + const mapperPropsMap = getNodePropertyMap({ node: mapperOriginalDeclarationNode, }); @@ -205,6 +221,17 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ tsMorphProject, mapper, }); + + // If the mapper's type cannot be resolved (e.g. it aliases a type from an + // unresolvable import), it reports zero properties, which is + // indistinguishable from an empty mapper. Generating stubs here would + // overwrite hand-maintained resolvers with broken ones, so we skip this + // mapper and warn instead. See issue #446. + if (isNodeTypeUnresolved({ node: originalDeclarationNode })) { + logger.warn(getUnresolvedMapperWarning({ mapper, schemaType })); + return; + } + const typeMapperPropertyMap = getNodePropertyMap({ node: originalDeclarationNode, }); @@ -267,6 +294,16 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ return result; }; +const getUnresolvedMapperWarning = ({ + mapper, + schemaType, +}: { + mapper: TypeMapperDetails['mapper']; + schemaType: string; +}): string => { + return `Skipping resolver generation for schema type "${schemaType}" because its mapper "${mapper.name}" (${mapper.filename}) could not be resolved. This usually means the mapper aliases a type from an import that does not exist yet (e.g. a generated client that has not been generated). Existing resolvers are left untouched. Fix the unresolved import and re-run codegen.`; +}; + const mustGetMapperOriginalDeclarationNode = ({ tsMorphProject, mapper, diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.spec.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.spec.ts index 019ff378..8c056ca7 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.spec.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.spec.ts @@ -1,5 +1,5 @@ import { Project, Node } from 'ts-morph'; -import { getNodePropertyMap } from './getNodePropertyMap'; +import { getNodePropertyMap, isNodeTypeUnresolved } from './getNodePropertyMap'; describe('getNodePropertyMap', () => { it('correctly resolves property map of a typical types.generated.ts', () => { @@ -113,3 +113,79 @@ describe('getNodePropertyMap', () => { expect(nodePropertyMap.role.name).toBe('role'); }); }); + +describe('isNodeTypeUnresolved', () => { + it('returns true when a type alias mapper aliases a type from an unresolvable import', () => { + const project = new Project(); + const sourceFile = project.createSourceFile( + '/path/to/mappers.ts', + `import type { User } from './does-not-exist'; + export type UserMapper = User;` + ); + + const node = sourceFile.getFirstDescendant( + (descendant) => + Node.isTypeAliasDeclaration(descendant) && + descendant.getName() === 'UserMapper' + ); + + expect(isNodeTypeUnresolved({ node })).toBe(true); + }); + + it('returns false for a mapper that genuinely has no properties (empty object type)', () => { + const project = new Project(); + const sourceFile = project.createSourceFile( + '/path/to/mappers.ts', + `export type EmptyMapper = {};` + ); + + const node = sourceFile.getFirstDescendant( + (descendant) => + Node.isTypeAliasDeclaration(descendant) && + descendant.getName() === 'EmptyMapper' + ); + + expect(isNodeTypeUnresolved({ node })).toBe(false); + }); + + it('returns false for an explicit `any` mapper (not an error type)', () => { + const project = new Project(); + const sourceFile = project.createSourceFile( + '/path/to/mappers.ts', + `export type AnyMapper = any;` + ); + + const node = sourceFile.getFirstDescendant( + (descendant) => + Node.isTypeAliasDeclaration(descendant) && + descendant.getName() === 'AnyMapper' + ); + + expect(isNodeTypeUnresolved({ node })).toBe(false); + }); + + it('returns false for a mapper whose imported type resolves', () => { + const project = new Project(); + project.createSourceFile( + '/path/to/user.ts', + `export type User = { id: string; name: string };` + ); + const sourceFile = project.createSourceFile( + '/path/to/mappers.ts', + `import type { User } from './user'; + export type UserMapper = User;` + ); + + const node = sourceFile.getFirstDescendant( + (descendant) => + Node.isTypeAliasDeclaration(descendant) && + descendant.getName() === 'UserMapper' + ); + + expect(isNodeTypeUnresolved({ node })).toBe(false); + }); + + it('returns false when node is undefined', () => { + expect(isNodeTypeUnresolved({ node: undefined })).toBe(false); + }); +}); diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts index 77defea1..7bd66322 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts @@ -56,6 +56,39 @@ export const getNodePropertyMap = ({ return nodePropertyMap; }; +/** + * Detects whether a node's type failed to resolve i.e. it is the TypeScript + * *error type*. This happens when a mapper aliases a type coming from an import + * that cannot be resolved (e.g. a client that has not been generated yet, or a + * module that is not installed on a fresh checkout). + * + * This must be distinguished from a mapper that genuinely has no properties + * (e.g. `type Mapper = {}`): both report zero properties via `getProperties()`, + * but only the unresolved one should stop resolver generation. If we treated an + * unresolved type as "empty", `getGraphQLObjectTypeResolversToGenerate` would + * inject a stub for every field and silently overwrite hand-maintained + * resolvers. See https://github.com/eddeee888/graphql-code-generator-plugins/issues/446 + * + * The error type is an intrinsic type named `error`. This is distinct from a + * real `any` (`intrinsicName === 'any'`) and from an empty object type (which + * is not an intrinsic type at all), so this check does not misfire on those. + */ +export const isNodeTypeUnresolved = ({ + node, +}: { + node: Node | undefined; +}): boolean => { + if (!node) { + return false; + } + + const { intrinsicName } = node.getType().compilerType as { + intrinsicName?: string; + }; + + return intrinsicName === 'error'; +}; + const collectClassNodeProperties = ( classNode: ClassDeclaration, result: NodePropertyMapValue[]