Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/tricky-mappers-warn.md
Original file line number Diff line number Diff line change
@@ -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<void>` 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.
Original file line number Diff line number Diff line change
@@ -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<Project['createSourceFile']>;
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();
}
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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,
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';
import { getNodePropertyMap, isNodeTypeUnresolved } from './getNodePropertyMap';

describe('getNodePropertyMap', () => {
it('correctly resolves property map of a typical types.generated.ts', () => {
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down