Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-pumas-jam.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<Project['createSourceFile']>;
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();
}
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down