diff --git a/.changeset/quiet-pumas-jam.md b/.changeset/quiet-pumas-jam.md new file mode 100644 index 00000000..f755a2bb --- /dev/null +++ b/.changeset/quiet-pumas-jam.md @@ -0,0 +1,5 @@ +--- +'@eddeee888/gcg-typescript-resolver-files': patch +--- + +Fix `fixObjectTypeResolvers` treating a mapper with an unresolvable type as an empty mapper, which injected a stub for every field of the schema type and could overwrite hand-maintained resolvers. This happened whenever a mapper aliased a type from an import that couldn't be resolved yet (e.g. a Prisma client not generated on a fresh install). The generator now detects the unresolved type, skips resolver generation for that mapper, and logs a warning naming it instead. Fixes #446. 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..7f9c93fd --- /dev/null +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.spec.ts @@ -0,0 +1,145 @@ +import { Project, SyntaxKind } from 'ts-morph'; +import { getGraphQLObjectTypeResolversToGenerate } from './getGraphQLObjectTypeResolversToGenerate.js'; +import { logger } from '../utils/index.js'; +import type { TypeMappersMap } from '../parseTypeMappers/index.js'; +import type { ParsedGraphQLSchemaMeta } from '../parseGraphQLSchema/index.js'; +import type { GeneratedTypesFileMeta } from '../generateResolverFiles/index.js'; + +const setup = ({ + mapperContent, +}: { + mapperContent: string; +}): { + project: Project; + typesSourceFile: ReturnType; + typeMappersMap: TypeMappersMap; + userDefinedSchemaObjectTypeMap: ParsedGraphQLSchemaMeta['userDefinedSchemaTypeMap']['object']; + generatedTypesFileMeta: GeneratedTypesFileMeta; +} => { + const project = new Project({ skipAddingFilesFromTsConfig: true }); + + const typesSourceFile = project.createSourceFile( + '/path/to/types.generated.ts', + ` + export type UserResolvers = { + id: string; + name: string; + };` + ); + + project.createSourceFile('/path/to/mappers.ts', mapperContent); + + const typeMappersMap: TypeMappersMap = { + User: { + schemaType: 'User', + mapper: { + name: 'UserTypeMapper', + filename: '/path/to/mappers.ts', + kind: SyntaxKind.TypeAliasDeclaration, + }, + configImportPath: './mappers#UserTypeMapper', + }, + }; + + const userDefinedSchemaObjectTypeMap = { + User: {}, + } as unknown as ParsedGraphQLSchemaMeta['userDefinedSchemaTypeMap']['object']; + + const generatedTypesFileMeta = { + generatedResolverTypes: { + userDefined: { + User: { name: 'UserResolvers' }, + }, + }, + } as unknown as GeneratedTypesFileMeta; + + return { + project, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + }; +}; + +describe('getGraphQLObjectTypeResolversToGenerate', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + // https://github.com/eddeee888/graphql-code-generator-plugins/issues/446 + it.each(['smart', 'fast'] as const)( + 'does not treat a mapper with an unresolvable type as having zero fields, and warns instead (mode: %s)', + (mode) => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => { + // noop + }); + + // `Foo` is imported from a module that doesn't exist (e.g. not generated + // yet), so `UserTypeMapper`'s type resolves to a TS error type. + const { + project, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + } = setup({ + mapperContent: ` + import type { Foo } from './does-not-exist'; + export type UserTypeMapper = Foo;`, + }); + + const result = getGraphQLObjectTypeResolversToGenerate({ + mode, + tsMorphProject: project, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + }); + + // The mapper's real fields are unknown because `Foo` couldn't be + // resolved - this must NOT be treated the same as "the mapper genuinely + // has no fields", which used to generate a "resolver is required + // because ... does not [exist on the mapper]" stub for every field, + // overwriting hand-maintained resolvers. + expect(result).toEqual({}); + + // The real problem - the unresolved import - is surfaced instead. + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0][0]).toContain('UserTypeMapper'); + } + ); + + it.each(['smart', 'fast'] as const)( + 'still generates stubs for a mapper that is genuinely empty, without warning (mode: %s)', + (mode) => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => { + // noop + }); + + const { + project, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + } = setup({ + mapperContent: `export type UserTypeMapper = {};`, + }); + + const result = getGraphQLObjectTypeResolversToGenerate({ + mode, + tsMorphProject: project, + typesSourceFile, + typeMappersMap, + userDefinedSchemaObjectTypeMap, + generatedTypesFileMeta, + }); + + 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 c6dc6ce4..bba76208 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts @@ -16,10 +16,12 @@ import type { import { type NodePropertyMap, getNodePropertyMap, + isNodeTypeUnresolved, } from './getNodePropertyMap.js'; import type { ParsedGraphQLSchemaMeta } from '../parseGraphQLSchema/index.js'; import type { GeneratedTypesFileMeta } from '../generateResolverFiles/index.js'; import { createCache } from './cache.js'; +import { logger } from '../utils/index.js'; export type GraphQLObjectTypeResolversToGenerate = Record< string, @@ -133,6 +135,17 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ tsMorphProject, mapper, }); + + // If the mapper's type can't be resolved (e.g. it aliases a type from an + // import that doesn't exist yet), it reports zero properties - the same + // as a mapper that's genuinely empty. Generating stubs here would + // overwrite hand-maintained resolvers with broken ones, so skip this + // mapper and warn instead. See issue #446. + if (isNodeTypeUnresolved({ node: mapperOriginalDeclarationNode })) { + logger.warn(warnUnresolvedMapper({ schemaType, mapper })); + return; + } + const mapperPropsMap = getNodePropertyMap({ node: mapperOriginalDeclarationNode, }); @@ -229,6 +242,17 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ tsMorphProject, mapper, }); + + // If the mapper's type can't be resolved (e.g. it aliases a type from an + // import that doesn't exist yet), it reports zero properties - the same + // as a mapper that's genuinely empty. Generating stubs here would + // overwrite hand-maintained resolvers with broken ones, so skip this + // mapper and warn instead. See issue #446. + if (isNodeTypeUnresolved({ node: originalDeclarationNode })) { + logger.warn(warnUnresolvedMapper({ schemaType, mapper })); + return; + } + const typeMapperPropertyMap = getNodePropertyMap({ node: originalDeclarationNode, }); @@ -291,6 +315,15 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ return newResult; }; +const warnUnresolvedMapper = ({ + schemaType, + mapper, +}: { + schemaType: string; + mapper: TypeMapperDetails['mapper']; +}): string => + `Mapper "${mapper.name}" for GraphQL type "${schemaType}" (${mapper.filename}) could not be resolved, so resolver generation is being skipped for "${schemaType}". This usually means the mapper aliases a type from an import that doesn't exist yet (e.g. a generated client, like Prisma's, that hasn't 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 b980bee2..fa5cc764 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.js'; +import { getNodePropertyMap, isNodeTypeUnresolved } from './getNodePropertyMap.js'; describe('getNodePropertyMap', () => { it('correctly resolves property map of a typical types.generated.ts', () => { @@ -113,3 +113,81 @@ describe('getNodePropertyMap', () => { expect(nodePropertyMap.role.name).toBe('role'); }); }); + +describe('isNodeTypeUnresolved', () => { + it('returns true for a mapper aliasing a type from an unresolvable import', () => { + const project = new Project(); + const sourceFile = project.createSourceFile( + '/path/to/mappers.ts', + ` + import type { Foo } from './does-not-exist'; + export type FooMapper = Foo;` + ); + + const node = sourceFile.getFirstDescendant( + (descendant) => + Node.isTypeAliasDeclaration(descendant) && + descendant.getName() === 'FooMapper' + ); + + expect(isNodeTypeUnresolved({ node })).toBe(true); + }); + + it('returns false for a mapper that genuinely has no properties', () => { + 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', () => { + 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/foo.ts', + `export type Foo = { id: string };` + ); + const sourceFile = project.createSourceFile( + '/path/to/mappers.ts', + ` + import type { Foo } from './foo'; + export type FooMapper = Foo;` + ); + + const node = sourceFile.getFirstDescendant( + (descendant) => + Node.isTypeAliasDeclaration(descendant) && + descendant.getName() === 'FooMapper' + ); + + 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..d279bb4d 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts @@ -56,6 +56,41 @@ export const getNodePropertyMap = ({ return nodePropertyMap; }; +/** + * Detects whether a node's type is the TypeScript *error type* i.e. its type + * could not be resolved. This happens e.g. when a mapper aliases a type + * imported from a module that doesn't exist yet (a generated client that + * hasn't been generated, a dependency not installed on a fresh checkout, etc.) + * + * `getNodePropertyMap` reports zero properties for such a node, which is + * indistinguishable from a mapper that genuinely has no properties (e.g. + * `type FooMapper = {}`). Callers that need to tell these two cases apart - + * treating "unresolved" as "unknown" rather than "confirmed empty" - should + * check this first. + * + * The error type is an intrinsic type whose `intrinsicName` is `"error"`. + * This is distinct from a real `any` (`intrinsicName` is `"any"`) and from an + * empty object type (not an intrinsic type at all), so it won't misfire on + * either of those. + * + * See https://github.com/eddeee888/graphql-code-generator-plugins/issues/446 + */ +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[]