diff --git a/.changeset/gentle-students-invent.md b/.changeset/gentle-students-invent.md new file mode 100644 index 00000000..bec6369a --- /dev/null +++ b/.changeset/gentle-students-invent.md @@ -0,0 +1,5 @@ +--- +'@eddeee888/gcg-typescript-resolver-files': patch +--- + +Update internals to use faster approach to run static analysis diff --git a/packages/typescript-resolver-files-e2e/project.json b/packages/typescript-resolver-files-e2e/project.json index 58d44a30..3f5222a6 100644 --- a/packages/typescript-resolver-files-e2e/project.json +++ b/packages/typescript-resolver-files-e2e/project.json @@ -24,7 +24,8 @@ "rimraf -g \"packages/typescript-resolver-files-e2e/src/**/rslvrs/\"", "rimraf -g \"packages/typescript-resolver-files-e2e/src/**/*.generated.*\"", "rimraf -g \"packages/typescript-resolver-files-e2e/src/**/*.gen.*\"", - "node packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/test-setup.js" + "node packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/testSetup.js", + "node packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/testSetup.js" ], "parallel": false }, @@ -74,7 +75,8 @@ "test-mappers-vs-schema-types": { "commands": [ "rimraf -g \"packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/**/resolvers/\"", - "rimraf -g \"packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/**/*.generated.*\"" + "rimraf -g \"packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/**/*.generated.*\"", + "node packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/testSetup.js" ], "parallel": false }, @@ -138,7 +140,7 @@ "commands": [ "rimraf -g \"packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/**/resolvers/\"", "rimraf -g \"packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/**/*.generated.*\"", - "node packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/test-setup.js" + "node packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/testSetup.js" ], "parallel": false }, diff --git a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/schema.generated.graphqls b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/schema.generated.graphqls index d8d10e2d..5e0d64a3 100644 --- a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/schema.generated.graphqls +++ b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/schema.generated.graphqls @@ -68,7 +68,11 @@ type Topic { createdAt: DateTime! creator: User! id: ID! + likedBy: [User!]! + likedByNullable: [User] + mostRelatedTopic: Topic name: String! + relatedTopics: [Topic!]! url: String } diff --git a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/resolvers/Topic.ts b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/resolvers/Topic.ts index 37e8c114..89dfd13d 100644 --- a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/resolvers/Topic.ts +++ b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/resolvers/Topic.ts @@ -1,4 +1,18 @@ import type { TopicResolvers } from './../../types.generated'; export const Topic: TopicResolvers = { - /* Implement Topic resolver logic here */ + id: ({ id }) => id, + createdAt: async (_parent, _arg, _ctx) => { + /* existing implementation, must keep */ + return '2024-01-01T00:00:00.000Z'; + }, + creator: ({ creator }, _arg, _ctx) => { + /* Topic.creator resolver is required because Topic.creator and TopicMapper.creator are not compatible */ + return creator; + }, + name: async (_parent, _arg, _ctx) => { + /* Topic.name resolver is required because Topic.name exists but TopicMapper.name does not */ + }, + url: async (_parent, _arg, _ctx) => { + /* Topic.url resolver is required because Topic.url exists but TopicMapper.url does not */ + }, }; diff --git a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/topic.graphqls b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/topic.graphqls index 69e06e91..61a9b00c 100644 --- a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/topic.graphqls +++ b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/topic.graphqls @@ -12,10 +12,14 @@ extend type Mutation { type Topic { id: ID! + createdAt: DateTime! + creator: User! + likedBy: [User!]! + likedByNullable: [User] + mostRelatedTopic: Topic name: String! + relatedTopics: [Topic!]! url: String - creator: User! - createdAt: DateTime! } type TopicByIdResult { diff --git a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/topic.mappers.ts b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/topic.mappers.ts new file mode 100644 index 00000000..e9184085 --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/topic/topic.mappers.ts @@ -0,0 +1,10 @@ +import type { UserMapper } from '../user/user.graphqls.mappers'; + +export type TopicMapper = { + id: string; + creator: string; + mostRelatedTopic?: TopicMapper; + relatedTopics: TopicMapper[]; + likedBy: UserMapper[]; + likedByNullable: null; +}; diff --git a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/typeDefs.generated.ts b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/typeDefs.generated.ts index 468c8957..e4b40dcf 100644 --- a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/typeDefs.generated.ts +++ b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/typeDefs.generated.ts @@ -388,47 +388,102 @@ export const typeDefs = { }, { kind: 'FieldDefinition', - name: { kind: 'Name', value: 'name' }, + name: { kind: 'Name', value: 'createdAt' }, arguments: [], type: { kind: 'NonNullType', type: { kind: 'NamedType', - name: { kind: 'Name', value: 'String' }, + name: { kind: 'Name', value: 'DateTime' }, }, }, directives: [], }, { kind: 'FieldDefinition', - name: { kind: 'Name', value: 'url' }, + name: { kind: 'Name', value: 'creator' }, arguments: [], - type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'User' } }, + }, directives: [], }, { kind: 'FieldDefinition', - name: { kind: 'Name', value: 'creator' }, + name: { kind: 'Name', value: 'likedBy' }, arguments: [], type: { kind: 'NonNullType', + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'User' }, + }, + }, + }, + }, + directives: [], + }, + { + kind: 'FieldDefinition', + name: { kind: 'Name', value: 'likedByNullable' }, + arguments: [], + type: { + kind: 'ListType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'User' } }, }, directives: [], }, { kind: 'FieldDefinition', - name: { kind: 'Name', value: 'createdAt' }, + name: { kind: 'Name', value: 'mostRelatedTopic' }, + arguments: [], + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Topic' } }, + directives: [], + }, + { + kind: 'FieldDefinition', + name: { kind: 'Name', value: 'name' }, arguments: [], type: { kind: 'NonNullType', type: { kind: 'NamedType', - name: { kind: 'Name', value: 'DateTime' }, + name: { kind: 'Name', value: 'String' }, }, }, directives: [], }, + { + kind: 'FieldDefinition', + name: { kind: 'Name', value: 'relatedTopics' }, + arguments: [], + type: { + kind: 'NonNullType', + type: { + kind: 'ListType', + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Topic' }, + }, + }, + }, + }, + directives: [], + }, + { + kind: 'FieldDefinition', + name: { kind: 'Name', value: 'url' }, + arguments: [], + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + directives: [], + }, ], }, { diff --git a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/types.generated.ts b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/types.generated.ts index 227ac870..cb2ad79d 100644 --- a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/types.generated.ts +++ b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/modules/types.generated.ts @@ -10,6 +10,7 @@ import { ProfileMetaMapper, } from './user/profile.mappers'; import { CatMapper, DogMapper } from './pet/schema.mappers'; +import { TopicMapper } from './topic/topic.mappers'; import { UserMapper } from './user/user.graphqls.mappers'; export type Maybe = T | null | undefined; export type InputMaybe = T | null | undefined; @@ -145,7 +146,11 @@ export type Topic = { createdAt: Scalars['DateTime']['output']; creator: User; id: Scalars['ID']['output']; + likedBy: Array; + likedByNullable?: Maybe>>; + mostRelatedTopic?: Maybe; name: Scalars['String']['output']; + relatedTopics: Array; url?: Maybe; }; @@ -371,9 +376,7 @@ export type ResolversTypes = { ProfileMeta: ResolverTypeWrapper; Query: ResolverTypeWrapper<{}>; Subscription: ResolverTypeWrapper<{}>; - Topic: ResolverTypeWrapper< - Omit & { creator: ResolversTypes['User'] } - >; + Topic: ResolverTypeWrapper; TopicByIdPayload: ResolverTypeWrapper< ResolversUnionTypes['TopicByIdPayload'] >; @@ -433,7 +436,7 @@ export type ResolversParentTypes = { ProfileMeta: ProfileMetaMapper; Query: {}; Subscription: {}; - Topic: Omit & { creator: ResolversParentTypes['User'] }; + Topic: TopicMapper; TopicByIdPayload: ResolversUnionTypes['TopicByIdPayload']; TopicByIdResult: Omit & { result?: Maybe; @@ -605,7 +608,23 @@ export type TopicResolvers< createdAt?: Resolver; creator?: Resolver; id?: Resolver; + likedBy?: Resolver, ParentType, ContextType>; + likedByNullable?: Resolver< + Maybe>>, + ParentType, + ContextType + >; + mostRelatedTopic?: Resolver< + Maybe, + ParentType, + ContextType + >; name?: Resolver; + relatedTopics?: Resolver< + Array, + ParentType, + ContextType + >; url?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; }; diff --git a/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/testSetup.js b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/testSetup.js new file mode 100644 index 00000000..89970762 --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/testSetup.js @@ -0,0 +1,19 @@ +const { createTestSetup } = require('../utils/createTestSetup'); + +createTestSetup({ + baseDir: + 'packages/typescript-resolver-files-e2e/src/test-mappers-vs-schema-types/', + files: [ + { + file: 'modules/topic/resolvers/Topic.ts', + content: `import type { TopicResolvers } from './../../types.generated'; + export const Topic: TopicResolvers = { + id: ({ id }) => id, + createdAt: async (_parent, _arg, _ctx) => { + /* existing implementation, must keep */ + return '2024-01-01T00:00:00.000Z'; + }, + };`, + }, + ], +}); diff --git a/packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/test-setup.js b/packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/test-setup.js deleted file mode 100644 index 0e0003b3..00000000 --- a/packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/test-setup.js +++ /dev/null @@ -1,116 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -try { - const files = [ - // Exists in the schema, should be filled with content - 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/base/resolvers/Error.ts', - 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/topic/resolvers/TopicCreatePayload.ts', - 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/topic/resolvers/TopicCreateResult.ts', - 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/pet/resolvers/User.ts', - - // Exisiting empty object type file and mapper causing comment + variable to be added, should successfully fill with content - // https://github.com/eddeee888/graphql-code-generator-plugins/pull/297 - 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/topic/resolvers/TopicEditResult.ts', - - // Object types with `Pick` type with a correct type but differently formatted should not be re-generated - { - file: 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/pet/resolvers/Zoo.ts', - content: `import type { ZooResolvers } from './../../types.generated'; -export const Zoo: Pick< - ZooResolvers, - | 'favouritePet' - | 'pets' - | 'rating' - | '__isTypeOf' -> = { /* Custom */ }; -`, - }, - - // Existing object type file with wrong type and no type import, should import type and replace wrong type - { - file: 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/pet/resolvers/Pet.ts', - content: `export const Pet: Record = {};`, - }, - - // Existing object type file with no type and no type import, should import type and add type - { - file: 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/pet/resolvers/PetToy.ts', - content: `export const PetToy = {};`, - }, - - // Existing Scalar file, must not re-import GraphQLScalarType - { - file: 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/base/resolvers/CustomLogicScalar.ts', - content: `import { DateResolver} from 'graphql-scalars' - DateResolver.description = undefined; - export const CustomLogicScalar = DateResolver; - `, - }, - // If there's custom Scalar definition, use it instead of importing from scalars module (e.g. graphql-scalars) - { - file: 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/base/resolvers/JSON.ts', - content: `import { JSONResolver} from 'graphql-scalars' - export const JSON = JSONResolver; - `, - }, - - // Empty Enum file should be filled with content - 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/base/resolvers/ErrorType.ts', - - // Existing Enum File should be edited: - // - Add missing import line - // - Add missing export - // - Do not add missing enum allowed values - { - file: 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/base/resolvers/SortOrder.ts', - content: `const SortOrder = { - ASC: 'ASCENDING', - } - `, - }, - - // `externalOverrides` gives full control to user - { - file: 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/pet/resolvers/PetHouse.ts', - content: `export const PetHouseResolvers = {};`, - }, - - // `scalarOverrides` must give full control to user - // https://github.com/eddeee888/graphql-code-generator-plugins/issues/306 - { - file: 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/base/resolvers/Date.ts', - content: `export const DateResolver = {}`, - }, - - // Files in blacklisted modules should not be filled or added to resolvers.generated.ts - 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/user/resolvers/User.ts', - - // Should handle `extend type` correctly if the extension happens _after_ the initial type definition in a blacklisted module - 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/zoo/resolvers/Profile.ts', - - // Random file should not be filled - 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/schema/topic/resolvers/RandomFile_ShouldHaveEmptyContent.ts', - ]; - - files.forEach((item) => { - let filename; - let content = ''; - - if (typeof item === 'string') { - filename = item; - } else { - filename = item.file; - content = item.content; - } - - const dir = path.dirname(filename); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - filename, - `/* This file has been created on filesystem by src/test-resolvers/auto-wireup/test-setup.js */\n\n${content}` - ); - }); -} catch (err) { - console.error(err); -} diff --git a/packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/testSetup.js b/packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/testSetup.js new file mode 100644 index 00000000..4ebf322b --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/testSetup.js @@ -0,0 +1,96 @@ +const { createTestSetup } = require('../utils/createTestSetup'); + +createTestSetup({ + baseDir: + 'packages/typescript-resolver-files-e2e/src/test-resolvers-auto-wireup/', + files: [ + // Exists in the schema, should be filled with content + 'schema/base/resolvers/Error.ts', + 'schema/topic/resolvers/TopicCreatePayload.ts', + 'schema/topic/resolvers/TopicCreateResult.ts', + 'schema/pet/resolvers/User.ts', + + // Exisiting empty object type file and mapper causing comment + variable to be added, should successfully fill with content + // https://github.com/eddeee888/graphql-code-generator-plugins/pull/297 + 'schema/topic/resolvers/TopicEditResult.ts', + + // Object types with `Pick` type with a correct type but differently formatted should not be re-generated + { + file: 'schema/pet/resolvers/Zoo.ts', + content: `import type { ZooResolvers } from './../../types.generated'; +export const Zoo: Pick< + ZooResolvers, + | 'favouritePet' + | 'pets' + | 'rating' + | '__isTypeOf' +> = { /* Custom */ }; +`, + }, + + // Existing object type file with wrong type and no type import, should import type and replace wrong type + { + file: 'schema/pet/resolvers/Pet.ts', + content: `export const Pet: Record = {};`, + }, + + // Existing object type file with no type and no type import, should import type and add type + { + file: 'schema/pet/resolvers/PetToy.ts', + content: `export const PetToy = {};`, + }, + + // Existing Scalar file, must not re-import GraphQLScalarType + { + file: 'schema/base/resolvers/CustomLogicScalar.ts', + content: `import { DateResolver} from 'graphql-scalars' + DateResolver.description = undefined; + export const CustomLogicScalar = DateResolver; + `, + }, + // If there's custom Scalar definition, use it instead of importing from scalars module (e.g. graphql-scalars) + { + file: 'schema/base/resolvers/JSON.ts', + content: `import { JSONResolver} from 'graphql-scalars' + export const JSON = JSONResolver; + `, + }, + + // Empty Enum file should be filled with content + 'schema/base/resolvers/ErrorType.ts', + + // Existing Enum File should be edited: + // - Add missing import line + // - Add missing export + // - Do not add missing enum allowed values + { + file: 'schema/base/resolvers/SortOrder.ts', + content: `const SortOrder = { + ASC: 'ASCENDING', + } + `, + }, + + // `externalOverrides` gives full control to user + { + file: 'schema/pet/resolvers/PetHouse.ts', + content: `export const PetHouseResolvers = {};`, + }, + + // `scalarOverrides` must give full control to user + // https://github.com/eddeee888/graphql-code-generator-plugins/issues/306 + { + file: 'schema/base/resolvers/Date.ts', + content: `export const DateResolver = {}`, + }, + + // Files in blacklisted modules should not be filled or added to resolvers.generated.ts + 'schema/user/resolvers/User.ts', + + // Should handle `extend type` correctly if the extension happens _after_ the initial type definition in a blacklisted module + 'schema/zoo/resolvers/Profile.ts', + + // Random file should not be filled + 'schema/topic/resolvers/RandomFile_ShouldHaveEmptyContent.ts', + ], +}); diff --git a/packages/typescript-resolver-files-e2e/src/utils/createTestSetup.js b/packages/typescript-resolver-files-e2e/src/utils/createTestSetup.js new file mode 100644 index 00000000..05e44980 --- /dev/null +++ b/packages/typescript-resolver-files-e2e/src/utils/createTestSetup.js @@ -0,0 +1,31 @@ +const fs = require('fs'); +const path = require('path'); + +const createTestSetup = ({ baseDir, files }) => { + try { + files.forEach((item) => { + let filename; + let content = ''; + + if (typeof item === 'string') { + filename = path.join(baseDir, item); + } else { + filename = path.join(baseDir, item.file); + content = item.content; + } + + const dir = path.dirname(filename); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + filename, + `/* This file has been created on filesystem by src/test-resolvers/auto-wireup/test-setup.js */\n\n${content}` + ); + }); + } catch (err) { + console.error(err); + } +}; + +module.exports = { + createTestSetup, +}; diff --git a/packages/typescript-resolver-files/src/addVirtualTypesFileToTsMorphProject/addVirtualTypesFileToTsMorphProject.ts b/packages/typescript-resolver-files/src/addVirtualTypesFileToTsMorphProject/addVirtualTypesFileToTsMorphProject.ts index 2306f189..c05d4404 100644 --- a/packages/typescript-resolver-files/src/addVirtualTypesFileToTsMorphProject/addVirtualTypesFileToTsMorphProject.ts +++ b/packages/typescript-resolver-files/src/addVirtualTypesFileToTsMorphProject/addVirtualTypesFileToTsMorphProject.ts @@ -69,16 +69,19 @@ const generateVirtualTypesFile = async ({ const addResultAsComplextOutput = convertPluginOutputToComplextPluginOutput(addResult); + const print = (value: string[] | undefined): string => + value ? value.join('\n') : ''; + return { filePath: resolverTypesPath, content: ` - ${addResultAsComplextOutput.prepend?.join('\n')} - ${typescriptResult.prepend?.join('\n')} - ${typescriptResolversResult.prepend?.join('\n')} + ${print(addResultAsComplextOutput.prepend)} + ${print(typescriptResult.prepend)} + ${print(typescriptResolversResult.prepend)} ${typescriptResult.content} ${typescriptResolversResult.content} ${addResultAsComplextOutput.content} - ${addResultAsComplextOutput.append?.join('\n')} + ${print(addResultAsComplextOutput.append)} `, meta: { generatedResolverTypes: typescriptResolversResult.meta diff --git a/packages/typescript-resolver-files/src/generateResolverFiles/ensureObjectTypeResolversAreGenerated.spec.ts b/packages/typescript-resolver-files/src/generateResolverFiles/addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented.spec.ts similarity index 59% rename from packages/typescript-resolver-files/src/generateResolverFiles/ensureObjectTypeResolversAreGenerated.spec.ts rename to packages/typescript-resolver-files/src/generateResolverFiles/addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented.spec.ts index 2b6b2a0e..07cfc1db 100644 --- a/packages/typescript-resolver-files/src/generateResolverFiles/ensureObjectTypeResolversAreGenerated.spec.ts +++ b/packages/typescript-resolver-files/src/generateResolverFiles/addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented.spec.ts @@ -1,11 +1,15 @@ import * as path from 'path'; import { Project } from 'ts-morph'; -import { ensureObjectTypeResolversAreGenerated } from './ensureObjectTypeResolversAreGenerated'; +import { + type AddedPropertyAssignmentNodes, + addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented, +} from './addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented'; +import type { ObjectTypeFile } from './types'; const createFilePath = (filePath: string): string => path.join('/path/', filePath); -describe('ensureObjectTypeResolversAreGenerated()', () => { +describe('addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented()', () => { it('adds missing field resolvers if needed', () => { const project = new Project(); project.createSourceFile( @@ -120,6 +124,7 @@ describe('ensureObjectTypeResolversAreGenerated()', () => { ` ); + const addedPropertyAssignmentNodes: AddedPropertyAssignmentNodes = {}; const sourceFile = project.createSourceFile( createFilePath('User.ts'), `import type { UserResolvers } from './types.generated'; @@ -127,9 +132,7 @@ describe('ensureObjectTypeResolversAreGenerated()', () => { /* Implement logic here */ };` ); - const resolverFile: Parameters< - typeof ensureObjectTypeResolversAreGenerated - >[1] = { + const resolverFile: ObjectTypeFile = { __filetype: 'objectType', content: '', mainImportIdentifier: 'User', @@ -156,10 +159,6 @@ describe('ensureObjectTypeResolversAreGenerated()', () => { resolverName: 'id', resolverDeclaration: `({ id }) => id`, }, - createdAt: { - resolverName: 'createdAt', - resolverDeclaration: `({ createdAt }) => createdAt`, - }, accountGitHub: { resolverName: 'accountGitHub', resolverDeclaration: `({ accountGitHub }) => accountGitHub`, @@ -172,14 +171,39 @@ describe('ensureObjectTypeResolversAreGenerated()', () => { resolverName: 'fullName', resolverDeclaration: `({ fullName }) => fullName`, }, - role: { - resolverName: 'role', - resolverDeclaration: `({ role }) => role`, - }, }, }, }; - ensureObjectTypeResolversAreGenerated(sourceFile, resolverFile); + addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented({ + addedPropertyAssignmentNodes, + sourceFile, + resolverFile, + }); + + // This is `/path/User.ts` file. We target it this way because the path on windows is different from linux + const file = Object.values(addedPropertyAssignmentNodes)[0]; + + const addedNode1 = file[4]; + expect(addedNode1.__toBeRemoved).toBe(true); + expect(addedNode1.node.getText()).toBe('id: ({ id }) => id'); + + const addedNode2 = file[5]; + expect(addedNode2.__toBeRemoved).toBe(true); + expect(addedNode2.node.getText()).toBe( + 'accountGitHub: ({ accountGitHub }) => accountGitHub' + ); + + const addedNode3 = file[6]; + expect(addedNode3.__toBeRemoved).toBe(true); + expect(addedNode3.node.getText()).toBe( + 'accountGoogle: ({ accountGoogle }) => accountGoogle' + ); + + const addedNode4 = file[7]; + expect(addedNode4.__toBeRemoved).toBe(true); + expect(addedNode4.node.getText()).toBe( + 'fullName: ({ fullName }) => fullName' + ); expect(sourceFile.getText()).toMatchInlineSnapshot(` "import type { UserResolvers } from './types.generated'; @@ -191,7 +215,166 @@ describe('ensureObjectTypeResolversAreGenerated()', () => { fullName: ({ fullName }) => fullName };" `); - expect(resolverFile.filesystem.contentUpdated).toBe(true); + }); + + it('adds does not add missing field resolvers if not needed i.e. resolversToGenerate is {}', () => { + const project = new Project(); + project.createSourceFile( + createFilePath('user.mappers.ts'), + `export interface UserMapper { + id: number; + fullName: string; + role: 'ADMIN' | 'USER'; + createdAt: Date; + }` + ); + project.createSourceFile( + createFilePath('types.generated.ts'), + ` + import { UserMapper } from './user.mappers'; + export type Maybe = T | null; + export type InputMaybe = Maybe; + export type Exact = { + [K in keyof T]: T[K]; + }; + export type MakeOptional = Omit & { + [SubKey in K]?: Maybe; + }; + export type MakeMaybe = Omit & { + [SubKey in K]: Maybe; + }; + /** All built-in and custom scalars, mapped to their actual values */ + export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; + DateTime: Date | string; + }; + + export type Query = { + __typename: 'Query'; + me?: Maybe; + }; + + export type User = { + __typename: 'User'; + accountGitHub?: Maybe; + accountGoogle?: Maybe; + createdAt: Scalars['DateTime']; + fullName: Scalars['String']; + id: Scalars['ID']; + role: UserRole; + }; + + export type UserRole = 'ADMIN' | 'USER'; + + export type ResolverTypeWrapper = Promise | T; + + export type ResolverWithResolve = { + resolve: ResolverFn; + }; + export type Resolver = + | ResolverFn + | ResolverWithResolve; + + export type ResolverFn = ( + parent: TParent, + args: TArgs, + context: TContext, + info: any + ) => Promise | TResult; + + /** Mapping between all available schema types and the resolvers types */ + export type ResolversTypes = { + DateTime: ResolverTypeWrapper; + Query: ResolverTypeWrapper<{}>; + User: ResolverTypeWrapper; + String: ResolverTypeWrapper; + ID: ResolverTypeWrapper; + UserRole: UserRole; + Boolean: ResolverTypeWrapper; + }; + + /** Mapping between all available schema types and the resolvers parents */ + export type ResolversParentTypes = { + DateTime: Scalars['DateTime']; + Query: {}; + User: UserMapper; + String: Scalars['String']; + ID: Scalars['ID']; + Boolean: Scalars['Boolean']; + }; + + export type UserResolvers< + ContextType = any, + ParentType extends ResolversParentTypes['User'] = ResolversParentTypes['User'] + > = { + accountGitHub?: Resolver< + Maybe, + ParentType, + ContextType + >; + accountGoogle?: Resolver< + Maybe, + ParentType, + ContextType + >; + createdAt?: Resolver; + fullName?: Resolver; + id?: Resolver; + role?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; + }; + ` + ); + + const addedPropertyAssignmentNodes: AddedPropertyAssignmentNodes = {}; + const sourceFile = project.createSourceFile( + createFilePath('User.ts'), + `import type { UserResolvers } from './types.generated'; + export const User: UserResolvers = {};` + ); + const resolverFile: ObjectTypeFile = { + __filetype: 'objectType', + content: '', + mainImportIdentifier: 'User', + filesystem: { + type: 'filesystem', + contentUpdated: false, + }, + meta: { + moduleName: 'user', + relativePathFromBaseToModule: ['user'], + normalizedResolverName: { + base: 'User', + withModule: 'user.User', + }, + resolverTypeImportDeclaration: '', + variableStatement: '', + resolverType: { + baseImport: 'UserResolvers', + final: 'UserResolvers', + otherVariants: [], + }, + resolversToGenerate: {}, + }, + }; + addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented({ + addedPropertyAssignmentNodes, + sourceFile, + resolverFile, + }); + + // This is `/path/User.ts` file. We target it this way because the path on windows is different from linux + const file = Object.values(addedPropertyAssignmentNodes)[0]; + + expect(file).toEqual({}); + expect(sourceFile.getText()).toMatchInlineSnapshot(` + "import type { UserResolvers } from './types.generated'; + export const User: UserResolvers = {};" + `); }); it('adds missing field resolvers, if necessary, when Mapper is a Class', () => { @@ -316,8 +499,8 @@ describe('ensureObjectTypeResolversAreGenerated()', () => { };` ); const resolverFile: Parameters< - typeof ensureObjectTypeResolversAreGenerated - >[1] = { + typeof addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented + >[0]['resolverFile'] = { __filetype: 'objectType', content: '', filesystem: { @@ -344,10 +527,6 @@ describe('ensureObjectTypeResolversAreGenerated()', () => { resolverName: 'id', resolverDeclaration: `({ id }) => id`, }, - createdAt: { - resolverName: 'createdAt', - resolverDeclaration: `({ createdAt }) => createdAt`, - }, accountGitHub: { resolverName: 'accountGitHub', resolverDeclaration: `({ accountGitHub }) => accountGitHub`, @@ -360,15 +539,41 @@ describe('ensureObjectTypeResolversAreGenerated()', () => { resolverName: 'fullName', resolverDeclaration: `({ fullName }) => fullName`, }, - role: { - resolverName: 'role', - resolverDeclaration: `({ role }) => role`, - }, }, }, }; - ensureObjectTypeResolversAreGenerated(sourceFile, resolverFile); + const addedPropertyAssignmentNodes: AddedPropertyAssignmentNodes = {}; + addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented({ + addedPropertyAssignmentNodes, + sourceFile, + resolverFile, + }); + + // This is `/path/User.ts` file. We target it this way because the path on windows is different from linux + const file = Object.values(addedPropertyAssignmentNodes)[0]; + + const addedNode1 = file[4]; + expect(addedNode1.__toBeRemoved).toBe(true); + expect(addedNode1.node.getText()).toBe('id: ({ id }) => id'); + + const addedNode2 = file[5]; + expect(addedNode2.__toBeRemoved).toBe(true); + expect(addedNode2.node.getText()).toBe( + 'accountGitHub: ({ accountGitHub }) => accountGitHub' + ); + + const addedNode3 = file[6]; + expect(addedNode3.__toBeRemoved).toBe(true); + expect(addedNode3.node.getText()).toBe( + 'accountGoogle: ({ accountGoogle }) => accountGoogle' + ); + + const addedNode4 = file[7]; + expect(addedNode4.__toBeRemoved).toBe(true); + expect(addedNode4.node.getText()).toBe( + 'fullName: ({ fullName }) => fullName' + ); expect(sourceFile.getText()).toMatchInlineSnapshot(` "import type { UserResolvers } from './types.generated'; @@ -380,6 +585,5 @@ describe('ensureObjectTypeResolversAreGenerated()', () => { fullName: ({ fullName }) => fullName };" `); - expect(resolverFile.filesystem.contentUpdated).toBe(true); }); }); diff --git a/packages/typescript-resolver-files/src/generateResolverFiles/addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented.ts b/packages/typescript-resolver-files/src/generateResolverFiles/addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented.ts new file mode 100644 index 00000000..6b1b3f28 --- /dev/null +++ b/packages/typescript-resolver-files/src/generateResolverFiles/addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented.ts @@ -0,0 +1,100 @@ +import { type PropertyAssignment, type SourceFile, SyntaxKind } from 'ts-morph'; +import type { ObjectTypeFile } from './types'; +import { getVariableStatementWithExpectedIdentifier } from './getVariableStatementWithExpectedIdentifier'; + +export type AddedPropertyAssignmentNodes = Record< + string, // SourceFile's filename + Record< + number, // Line number + { + node: PropertyAssignment; + resolverFile: ObjectTypeFile; + __toBeRemoved: boolean; + } + > +>; + +/** + * Ensure objectTypeResolver files have all the resolvers due to mismatched types + */ +export const addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented = ({ + addedPropertyAssignmentNodes, + sourceFile, + resolverFile, +}: { + addedPropertyAssignmentNodes: AddedPropertyAssignmentNodes; + sourceFile: SourceFile; + resolverFile: ObjectTypeFile; +}): void => { + const sourceFilePath = sourceFile.getFilePath().toString(); + addedPropertyAssignmentNodes[sourceFilePath] = + addedPropertyAssignmentNodes[sourceFilePath] || {}; + + const resolversToGenerate = resolverFile.meta.resolversToGenerate || {}; + if (!Object.keys(resolversToGenerate).length) { + return; + } + + const { variableStatement } = getVariableStatementWithExpectedIdentifier( + sourceFile, + resolverFile + ); + + if (!variableStatement) { + throw new Error( + 'Missing variableStatement in addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented.' + ); + } + + // 1. Check to see which generated to-be-generated resolvers are implemented + const resolversData: Record< + string, + { + resolverName: string; + resolverDeclaration: string; + implemented?: true; + } + > = { ...resolversToGenerate }; + + variableStatement + .getDescendantsOfKind(SyntaxKind.PropertyAssignment) + .forEach((propertyAssignment) => { + const resolverName = propertyAssignment.getName(); + if (resolversData[resolverName]) { + resolversData[resolverName].implemented = true; + } + }); + + variableStatement + .getDescendantsOfKind(SyntaxKind.MethodDeclaration) + .forEach((methodDeclaration) => { + const resolverName = methodDeclaration.getName(); + if (resolversData[resolverName]) { + resolversData[resolverName].implemented = true; + } + }); + + // 2. Add missing resolver properties if they haven't been implemented + Object.values(resolversData).forEach( + ({ resolverName, resolverDeclaration, implemented }) => { + if (implemented) { + return; + } + + const addedNode = variableStatement + .getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)[0] + .addPropertyAssignment({ + name: resolverName, + initializer: resolverDeclaration, + }); + + addedPropertyAssignmentNodes[sourceFilePath][ + addedNode.getStartLineNumber() + ] = { + node: addedNode, + resolverFile, + __toBeRemoved: true, + }; + } + ); +}; diff --git a/packages/typescript-resolver-files/src/generateResolverFiles/ensureObjectTypeResolversAreGenerated.ts b/packages/typescript-resolver-files/src/generateResolverFiles/ensureObjectTypeResolversAreGenerated.ts deleted file mode 100644 index 25efe5f1..00000000 --- a/packages/typescript-resolver-files/src/generateResolverFiles/ensureObjectTypeResolversAreGenerated.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { type SourceFile, type PropertyAssignment, SyntaxKind } from 'ts-morph'; -import type { ObjectTypeFile } from './types'; -import { getVariableStatementWithExpectedIdentifier } from './getVariableStatementWithExpectedIdentifier'; - -/** - * Ensure objectTypeResolver files have all the resolvers due to mismatched types - */ -export const ensureObjectTypeResolversAreGenerated = ( - sourceFile: SourceFile, - resolverFile: ObjectTypeFile -): void => { - const resolversToGenerate = resolverFile.meta.resolversToGenerate || {}; - if (!Object.keys(resolversToGenerate).length) { - return; - } - - const { variableStatement } = getVariableStatementWithExpectedIdentifier( - sourceFile, - resolverFile - ); - - if (!variableStatement) { - throw new Error( - 'Missing variableStatement in ensureObjectTypeResolversAreGenerated.' - ); - } - - /** - * FIXME: TS API does not expose `.isAssignable(Type, Type)` so we cannot use it to compare a field's type in schema type vs mapper type - * - * In this workaround, we try to put the type together and use TS's diagnostic tool to see if there's error: - * 1. If there's error, it means the mapper field cannot be safely mapped to schema field using GraphQL's default behaviour i.e. there will be runtime error - * In this case, we leave the resolver function as-is. There should be an error message from the parsing phrase explaining the types cannot be mapped. - * 2. If there's no error, it means we can safely remove the resolver and let GraphQL handle the mapping. - * - * Note: this only looks at fields that's not declared by the user. If it's already declared, we assume TS will report naturally if there's an error. - */ - - // 1. Check to see which generated to-be-generated resolvers are implemented - const resolversData: Record< - string, - { - resolverName: string; - resolverDeclaration: string; - implemented?: true; - } - > = { ...resolversToGenerate }; - - variableStatement - .getDescendantsOfKind(SyntaxKind.PropertyAssignment) - .forEach((propertyAssignment) => { - const resolverName = propertyAssignment.getName(); - if (resolversData[resolverName]) { - resolversData[resolverName].implemented = true; - } - }); - - variableStatement - .getDescendantsOfKind(SyntaxKind.MethodDeclaration) - .forEach((methodDeclaration) => { - const resolverName = methodDeclaration.getName(); - if (resolversData[resolverName]) { - resolversData[resolverName].implemented = true; - } - }); - - // 2. Add missing resolver properties if they haven't been implemented - const addedPropertyAssignmentNodes: Record< - number, - { node: PropertyAssignment; __toBeRemoved: boolean } - > = []; - Object.values(resolversData).forEach( - ({ resolverName, resolverDeclaration, implemented }) => { - if (implemented) { - return; - } - - const addedNode = variableStatement - .getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)[0] - .addPropertyAssignment({ - name: resolverName, - initializer: resolverDeclaration, - }); - - addedPropertyAssignmentNodes[addedNode.getStartLineNumber()] = { - node: addedNode, - __toBeRemoved: true, - }; - } - ); - - // 3. Check to see if there's error at the newly added properties: - // a. If there's error, leave it as user needs to manually map mapper type to schema type - // b. If there's no error, remove the node because GraphQL default mapping behaviour should work - sourceFile.getPreEmitDiagnostics().forEach((d) => { - const lineNumberWithError = d.getLineNumber(); - // If erroring on a recently added line, do not remove as user needs to implement it - if ( - lineNumberWithError && - addedPropertyAssignmentNodes[lineNumberWithError] - ) { - addedPropertyAssignmentNodes[lineNumberWithError].__toBeRemoved = false; - } - }); - Object.values(addedPropertyAssignmentNodes).forEach( - ({ node, __toBeRemoved }) => { - if (__toBeRemoved) { - node.remove(); - } else { - // If found a property assignment that cannot be removed i.e. incompatible types between mapper vs schema types - // Then we must mark the content as updated to be added to generate list - resolverFile.filesystem.contentUpdated = true; - } - } - ); -}; diff --git a/packages/typescript-resolver-files/src/generateResolverFiles/handleGraphQLEnumType.ts b/packages/typescript-resolver-files/src/generateResolverFiles/handleGraphQLEnumType.ts index 22055d05..950ab3f0 100644 --- a/packages/typescript-resolver-files/src/generateResolverFiles/handleGraphQLEnumType.ts +++ b/packages/typescript-resolver-files/src/generateResolverFiles/handleGraphQLEnumType.ts @@ -40,10 +40,10 @@ export const handleGraphQLEnumType: GraphQLTypeHandler< const forcedGenerationWarning = (() => { if (!matchedPatternToGenerate && mapperDetails) { logger.debug( - `Enum resolver generation was NOT skipped because there is a associated mapper: "${normalizedResolverName.withModule}". "Pattern: ${resolverGeneration.enum}". Mapper: ${mapperDetails.typeMapperName}` + `Enum resolver generation was NOT skipped because there is a associated mapper: "${normalizedResolverName.withModule}". "Pattern: ${resolverGeneration.enum}". Mapper: ${mapperDetails.mapper.name}` ); return `/* - * Note: This enum file is generated because "${mapperDetails.typeMapperName}" is declared. This is to ensure runtime safety. + * Note: This enum file is generated because "${mapperDetails.mapper.name}" is declared. This is to ensure runtime safety. * If you want to skip this file generation, remove the mapper or update the pattern in the \`resolverGeneration.object\` config. */\n`; } diff --git a/packages/typescript-resolver-files/src/generateResolverFiles/handleGraphQLObjectType.ts b/packages/typescript-resolver-files/src/generateResolverFiles/handleGraphQLObjectType.ts index 5beefc5a..1013c938 100644 --- a/packages/typescript-resolver-files/src/generateResolverFiles/handleGraphQLObjectType.ts +++ b/packages/typescript-resolver-files/src/generateResolverFiles/handleGraphQLObjectType.ts @@ -48,10 +48,10 @@ export const handleGraphQLObjectType: GraphQLTypeHandler< const forcedGenerationWarning = (() => { if (!matchedPatternToGenerate && mapperDetails) { logger.debug( - `Object resolver generation was NOT skipped because there is a associated mapper: "${normalizedResolverName.withModule}". "Pattern: ${resolverGeneration.object}". Mapper: ${mapperDetails.typeMapperName}` + `Object resolver generation was NOT skipped because there is a associated mapper: "${normalizedResolverName.withModule}". "Pattern: ${resolverGeneration.object}". Mapper: ${mapperDetails.mapper.name}` ); return `/* - * Note: This object type is generated because "${mapperDetails.typeMapperName}" is declared. This is to ensure runtime safety. + * Note: This object type is generated because "${mapperDetails.mapper.name}" is declared. This is to ensure runtime safety. * * When a mapper is used, it is possible to hit runtime errors in some scenarios: * - given a field name, the schema type's field type does not match mapper's field type diff --git a/packages/typescript-resolver-files/src/generateResolverFiles/postProcessFiles.ts b/packages/typescript-resolver-files/src/generateResolverFiles/postProcessFiles.ts index 931f2173..34ffbb4e 100644 --- a/packages/typescript-resolver-files/src/generateResolverFiles/postProcessFiles.ts +++ b/packages/typescript-resolver-files/src/generateResolverFiles/postProcessFiles.ts @@ -3,7 +3,10 @@ import * as path from 'path'; import { cwd } from '../utils'; import type { ResolverFile, GenerateResolverFilesContext } from './types'; import { getVariableStatementWithExpectedIdentifier } from './getVariableStatementWithExpectedIdentifier'; -import { ensureObjectTypeResolversAreGenerated } from './ensureObjectTypeResolversAreGenerated'; +import { + type AddedPropertyAssignmentNodes, + addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented, +} from './addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented'; import { ensureEnumTypeResolversAreGenerated } from './ensureEnumTypeResolversAreGenerated'; import { getImportStatementWithExpectedNamedImport } from './getImportStatementWithExpectedNamedImport'; @@ -24,6 +27,8 @@ export const postProcessFiles = ({ sourceFile: SourceFile; resolverFile: ResolverFile; }[] = []; + + // 1. Load resolver files into ts-morph project so we can run static analysis Object.entries(result.files).forEach(([filePath, file]) => { if (file.__filetype === 'file') { return; @@ -55,6 +60,10 @@ export const postProcessFiles = ({ } }); + // `addedPropertyAssignmentNodes` is used to store added property assignments in object types + // these property assigments are to be removed if there's no TypeScript error + const addedPropertyAssignmentNodes: AddedPropertyAssignmentNodes = {}; + sourceFilesToProcess.forEach(({ sourceFile, resolverFile }) => { const normalizedRelativePath = path.posix.relative( cwd(), @@ -79,7 +88,11 @@ export const postProcessFiles = ({ fixObjectTypeResolvers.object === 'smart' && resolverFile.__filetype === 'objectType' ) { - ensureObjectTypeResolversAreGenerated(sourceFile, resolverFile); + addObjectTypeResolversPropertyAssignmentNodesIfNotImplemented({ + addedPropertyAssignmentNodes, + sourceFile, + resolverFile, + }); } if ( @@ -95,6 +108,55 @@ export const postProcessFiles = ({ content: sourceFile.getText(), }; }); + + // 3. ensure object type's added property assignments are removed if there's no related TypeScript error + // We do this only once at the project level instead of sourceFile level to speed up the process + if (fixObjectTypeResolvers.object === 'smart') { + project.getPreEmitDiagnostics().forEach((d) => { + const filename = d.getSourceFile()?.getFilePath().toString(); + if (!filename || !addedPropertyAssignmentNodes[filename]) { + return; + } + const lineNumberWithError = d.getLineNumber(); + + // If erroring on a recently added line, do not remove as user needs to implement it + if ( + lineNumberWithError && + addedPropertyAssignmentNodes[filename][lineNumberWithError] + ) { + addedPropertyAssignmentNodes[filename][ + lineNumberWithError + ].__toBeRemoved = false; + } + }); + Object.values(addedPropertyAssignmentNodes).forEach((addedNodes) => { + Object.values(addedNodes).forEach( + ({ node, resolverFile, __toBeRemoved }) => { + if (__toBeRemoved) { + node.remove(); + } else { + // If found a property assignment that cannot be removed i.e. incompatible types between mapper vs schema types + // Then we must mark the content as updated to be added to generate list + resolverFile.filesystem.contentUpdated = true; + } + } + ); + }); + } + + // 4. Apply to result files the updated content done in step 2. and 3. above + sourceFilesToProcess.forEach(({ sourceFile, resolverFile }) => { + const normalizedRelativePath = path.posix.relative( + cwd(), + sourceFile.getFilePath() + ); + + // Overwrite existing files with fixes + result.files[normalizedRelativePath] = { + ...resolverFile, + content: sourceFile.getText(), + }; + }); }; /** diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts index 5cc70e0a..79e2dc3b 100644 --- a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getGraphQLObjectTypeResolversToGenerate.ts @@ -1,12 +1,16 @@ import { type InterfaceDeclaration, type TypeAliasDeclaration, + type ClassDeclaration, + type ExportSpecifier, type SourceFile, type Project, + type Identifier, SyntaxKind, + Node, } from 'ts-morph'; -import type { TypeMappersMap } from '../parseTypeMappers'; -import { type NodePropertyMap, getNodePropertyMap } from '../utils'; +import type { TypeMapperDetails, TypeMappersMap } from '../parseTypeMappers'; +import { type NodePropertyMap, getNodePropertyMap } from './getNodePropertyMap'; import type { ParsedGraphQLSchemaMeta } from '../parseGraphQLSchema'; export type GraphQLObjectTypeResolversToGenerate = Record< @@ -55,55 +59,152 @@ export const getGraphQLObjectTypeResolversToGenerate = ({ // 3. Find resolvers to generate and add reason const result: GraphQLObjectTypeResolversToGenerate = {}; - typeMappersEntries.forEach( - ([_, { schemaType, typeMapperName, typeMapperPropertyMap }]) => { - const matchedSchemaTypePropertyMap = schemaTypePropertyMap[schemaType]; - if (matchedSchemaTypePropertyMap) { - Object.values(matchedSchemaTypePropertyMap).forEach( - (schemaTypeProperty) => { - const typeMapperProperty = - typeMapperPropertyMap[schemaTypeProperty.name]; - const typeMapperPropertyIdentifier = `${typeMapperName}.${schemaTypeProperty.name}`; - const schemaTypePropertyIdentifier = `${schemaType}.${schemaTypeProperty.name}`; - - if (schemaTypeProperty.name === '__typename') { - return; - } - - result[schemaType] = result[schemaType] || {}; - - // If mapper does not have a field in schema type, report - if (!typeMapperProperty) { - result[schemaType][schemaTypeProperty.name] = { - resolverName: schemaTypeProperty.name, - resolverDeclaration: `async (_parent, _arg, _ctx) => { /* ${schemaTypePropertyIdentifier} resolver is required because ${schemaTypePropertyIdentifier} exists but ${typeMapperPropertyIdentifier} does not */ }`, - }; - return; - } - - /** - * FIXME: there's currently no way to check if a type is assignable to another type - * https://github.com/dsherret/ts-morph/issues/357 - * https://github.com/microsoft/TypeScript/issues/9879 - * - * Therefore, the workaround now is to generate all resolvers with matching names, then use TS diagnostics to see if there's error when trying to merge the two keys - * - * Note: this happens only when mappers are used - */ + typeMappersEntries.forEach(([_, { schemaType, mapper }]) => { + const matchedSchemaTypePropertyMap = schemaTypePropertyMap[schemaType]; + if (matchedSchemaTypePropertyMap) { + const originalDeclarationNode = mustGetMapperOriginalDeclarationNode({ + tsMorphProject, + mapper, + }); + const typeMapperPropertyMap = getNodePropertyMap({ + tsMorphProject, + node: originalDeclarationNode, + }); + + Object.values(matchedSchemaTypePropertyMap).forEach( + (schemaTypeProperty) => { + const typeMapperProperty = + typeMapperPropertyMap[schemaTypeProperty.name]; + + const typeMapperPropertyIdentifier = `${mapper.name}.${schemaTypeProperty.name}`; + const schemaTypePropertyIdentifier = `${schemaType}.${schemaTypeProperty.name}`; + + if (schemaTypeProperty.name === '__typename') { + return; + } + + result[schemaType] = result[schemaType] || {}; + + // If mapper does not have a field in schema type, add missing resolver + if (!typeMapperProperty) { result[schemaType][schemaTypeProperty.name] = { resolverName: schemaTypeProperty.name, - resolverDeclaration: `({ ${schemaTypeProperty.name} }, _arg, _ctx) => { + resolverDeclaration: `async (_parent, _arg, _ctx) => { /* ${schemaTypePropertyIdentifier} resolver is required because ${schemaTypePropertyIdentifier} exists but ${typeMapperPropertyIdentifier} does not */ }`, + }; + return; + } + + /** + * FIXME: TypeScript's `isTypeAssignableTo` should be used to check if the mapper type vs resolver return type is compatible. + * The current challenge is to: + * - Switch from using the schema type to resolver return type e.g. `User` -> `UserResolver` + * - Take the ReturnType of the resolver function type e.g. `Resolver` + * + * For now, the workaround now is to generate all resolvers with matching names, + * then use TS diagnostics to see if there's error when trying to merge the two keys. + * + * Note: this happens only when mappers are used + */ + result[schemaType][schemaTypeProperty.name] = { + resolverName: schemaTypeProperty.name, + resolverDeclaration: `({ ${schemaTypeProperty.name} }, _arg, _ctx) => { /* ${schemaTypePropertyIdentifier} resolver is required because ${schemaTypePropertyIdentifier} and ${typeMapperPropertyIdentifier} are not compatible */ return ${schemaTypeProperty.name} }`, - }; + }; - return; + return; + } + ); + } + }); + + return result; +}; + +const mustGetMapperOriginalDeclarationNode = ({ + tsMorphProject, + mapper, +}: { + tsMorphProject: Project; + mapper: TypeMapperDetails['mapper']; +}): Node => { + const typeMapperFile = tsMorphProject.getSourceFile(mapper.filename); + if (!typeMapperFile) { + throw new Error( + `Unable to find ${typeMapperFile} file after parsing. This shouldn't happen.` + ); + } + + /** + * Finding `firstDescendantThatIsMapper` here is a bit of the duplicated traversing logic in `collectTypeMappersFromSourceFile`. + * However, in `collectTypeMappersFromSourceFile`, we find the mappers details. + * And here, we actually do look for the mapper nodes and run analysis on it. + * + * Previously, we were parsing the node property map in `collectTypeMappersFromSourceFile` + * but for some reason `isAssignableTo` has issue comparing types, so we have to move the static analysis here for now. + */ + const firstDescendantThatIsMapper = ((): + | GetOriginalDeclarationNodeParams + | undefined => { + for (const descendant of typeMapperFile.getDescendants()) { + const typedNode = descendant.isKind(mapper.kind); + if (typedNode) { + let identifierNode = descendant.getNameNode(); + if (descendant.isKind(SyntaxKind.ExportSpecifier)) { + const aliasNode = descendant.getAliasNode(); + if (aliasNode) { + identifierNode = aliasNode; } - ); + } + + if (identifierNode?.getText() === mapper.name) { + return { + declarationNode: descendant, + identifierNode, + }; + } } } - ); + return; + })(); - return result; + if (!firstDescendantThatIsMapper) { + throw new Error( + `Unable to find ${mapper.name} node after parsing. This shouldn't happen.` + ); + } + + return getOriginalDeclarationNode(firstDescendantThatIsMapper); +}; + +interface GetOriginalDeclarationNodeParams { + declarationNode: + | InterfaceDeclaration + | TypeAliasDeclaration + | ExportSpecifier + | ClassDeclaration; + identifierNode: Identifier; +} +const getOriginalDeclarationNode = ({ + declarationNode, + identifierNode, +}: GetOriginalDeclarationNodeParams): Node => { + if ( + declarationNode.isKind(SyntaxKind.ExportSpecifier) || + declarationNode.isKind(SyntaxKind.ClassDeclaration) + ) { + return identifierNode.getDefinitionNodes()[0]; + } + + // InterfaceDeclaration + if (declarationNode.isKind(SyntaxKind.InterfaceDeclaration)) { + return declarationNode; + } + + // TypeAliasDeclaration + const typeNode = declarationNode.getTypeNodeOrThrow(); + return Node.isTypeReference(typeNode) + ? identifierNode.getDefinitionNodes()[0] // If type alias is a reference, go to definition using `getDefinitionNodes` + : declarationNode; }; diff --git a/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.spec.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.spec.ts new file mode 100644 index 00000000..9e3acaac --- /dev/null +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.spec.ts @@ -0,0 +1,142 @@ +import { Project, Node } from 'ts-morph'; +import { getNodePropertyMap } from './getNodePropertyMap'; + +describe('getNodePropertyMap', () => { + it('correctly resolves property map of a typical types.generated.ts', () => { + const project = new Project(); + const sourceFile = project.createSourceFile( + '/path/to/types.generated.ts', + ` + export type Maybe = T | null; + export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + DateTime: { input: Date; output: Date | string | number; } + }; + export type User = { + __typename: 'User'; + accountGitHub?: Maybe; + accountGoogle?: Maybe; + createdAt: Scalars['DateTime']['output']; + fullName: Scalars['String']['output']; + id: Scalars['ID']['output']; + role: UserRole; + }; + export type UserRole = 'ADMIN' | 'USER';` + ); + + const userDeclarationNode = sourceFile.getFirstDescendant( + (node) => Node.isTypeAliasDeclaration(node) && node.getName() === 'User' + ); + + const nodePropertyMap = getNodePropertyMap({ + tsMorphProject: project, + node: userDeclarationNode, + }); + + expect(nodePropertyMap.__typename.name).toBe('__typename'); + expect(nodePropertyMap.__typename.type.getText()).toBe('"User"'); + + expect(nodePropertyMap.accountGitHub.name).toBe('accountGitHub'); + expect(nodePropertyMap.accountGitHub.type.getText()).toBe('string'); + + expect(nodePropertyMap.accountGoogle.name).toBe('accountGoogle'); + expect(nodePropertyMap.accountGoogle.type.getText()).toBe('string'); + + expect(nodePropertyMap.createdAt.name).toBe('createdAt'); + expect(nodePropertyMap.createdAt.type.getText()).toBe( + 'string | number | Date' + ); + + expect(nodePropertyMap.fullName.name).toBe('fullName'); + expect(nodePropertyMap.fullName.type.getText()).toBe('string'); + + expect(nodePropertyMap.id.name).toBe('id'); + expect(nodePropertyMap.id.type.getText()).toBe('string'); + + expect(nodePropertyMap.role.name).toBe('role'); + expect(nodePropertyMap.role.type.getText()).toBe( + 'import("/path/to/types.generated").UserRole' + ); + }); + + it('correctly resolves property map a class', () => { + const project = new Project(); + const sourceFile = project.createSourceFile( + '/path/to/types.ts', + ` + export type UserRole = 'ADMIN' | 'USER'; + export class User { + #_id: string; + private word: string; + protected word2: string; + + name: string; + role: UserRole; + + get id(): string { + return this.#_id; + } + }` + ); + + const userDeclarationNode = sourceFile.getFirstDescendant( + (node) => Node.isClassDeclaration(node) && node.getName() === 'User' + ); + + const nodePropertyMap = getNodePropertyMap({ + tsMorphProject: project, + node: userDeclarationNode, + }); + + expect(nodePropertyMap._id).toBe(undefined); + expect(nodePropertyMap.id).toBe(undefined); + expect(nodePropertyMap.word).toBe(undefined); + expect(nodePropertyMap.word2).toBe(undefined); + + expect(nodePropertyMap.name.name).toBe('name'); + expect(nodePropertyMap.name.type.getText()).toBe('string'); + + expect(nodePropertyMap.role.name).toBe('role'); + expect(nodePropertyMap.role.type.getText()).toBe( + 'import("/path/to/types").UserRole' + ); + }); + + it('correctly resolves property map a type alias mapper', () => { + const project = new Project(); + const sourceFile = project.createSourceFile( + '/path/to/mappers.ts', + ` + type UserRole = 'Admin' | 'User'; + export type UserMapper = { + id: number; + name: string; + role: UserRole; + }` + ); + + const node = sourceFile.getFirstDescendant( + (descendant) => + Node.isTypeAliasDeclaration(descendant) && + descendant.getName() === 'UserMapper' + ); + + const nodePropertyMap = getNodePropertyMap({ + tsMorphProject: project, + node, + }); + + expect(nodePropertyMap.id.name).toBe('id'); + expect(nodePropertyMap.id.type.getText()).toBe('number'); + + expect(nodePropertyMap.name.name).toBe('name'); + expect(nodePropertyMap.name.type.getText()).toBe('string'); + + expect(nodePropertyMap.role.name).toBe('role'); + expect(nodePropertyMap.role.type.getText()).toBe('UserRole'); // Non-exported types will have result in the name, not the `import("/path/to/mappers")` path + }); +}); diff --git a/packages/typescript-resolver-files/src/utils/getNodePropertyMap.ts b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts similarity index 70% rename from packages/typescript-resolver-files/src/utils/getNodePropertyMap.ts rename to packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts index 4fba666c..f7972c6f 100644 --- a/packages/typescript-resolver-files/src/utils/getNodePropertyMap.ts +++ b/packages/typescript-resolver-files/src/getGraphQLObjectTypeResolversToGenerate/getNodePropertyMap.ts @@ -2,10 +2,12 @@ import { type Project, type ClassDeclaration, type Node, + type Type, SyntaxKind, } from 'ts-morph'; -export type NodePropertyMap = Record; +type NodePropertyMapValue = { type: Type; name: string }; +export type NodePropertyMap = Record; /** * Function to get properties of a Node in a map @@ -24,9 +26,9 @@ export const getNodePropertyMap = ({ const typeChecker = tsMorphProject.getTypeChecker(); - const properties = ((): string[] => { + const properties = ((): NodePropertyMapValue[] => { if (node.isKind(SyntaxKind.ClassDeclaration)) { - const result: string[] = []; + const result: NodePropertyMapValue[] = []; collectClassNodeProperties(node, result); return result; } @@ -34,13 +36,19 @@ export const getNodePropertyMap = ({ return typeChecker .getTypeAtLocation(node) .getProperties() - .map((property) => property.getName()); + .map((prop) => { + return { + type: prop.getDeclarations()[0].getType(), + name: prop.getName(), + }; + }); })(); const nodePropertyMap = properties.reduce( - (res, propertyName) => { - res[propertyName] = { - name: propertyName, + (res, { type, name }) => { + res[name] = { + type, + name, }; return res; }, @@ -52,7 +60,7 @@ export const getNodePropertyMap = ({ const collectClassNodeProperties = ( classNode: ClassDeclaration, - result: string[] + result: NodePropertyMapValue[] ): void => { const baseClass = classNode.getBaseClass(); if (baseClass) { @@ -74,6 +82,9 @@ const collectClassNodeProperties = ( // getter is skipped return; } - result.push(prop.getName()); + result.push({ + type: prop.getType(), + name: prop.getName(), + }); }); }; diff --git a/packages/typescript-resolver-files/src/parseTypeMappers/collectTypeMappersFromSourceFile.spec.ts b/packages/typescript-resolver-files/src/parseTypeMappers/collectTypeMappersFromSourceFile.spec.ts index e87a6ce3..b2112642 100644 --- a/packages/typescript-resolver-files/src/parseTypeMappers/collectTypeMappersFromSourceFile.spec.ts +++ b/packages/typescript-resolver-files/src/parseTypeMappers/collectTypeMappersFromSourceFile.spec.ts @@ -1,4 +1,4 @@ -import { Project } from 'ts-morph'; +import { Project, SyntaxKind } from 'ts-morph'; import { collectTypeMappersFromSourceFile } from './collectTypeMappersFromSourceFile'; describe('collectTypeMappersFromSourceFile', () => { @@ -82,11 +82,9 @@ describe('collectTypeMappersFromSourceFile', () => { collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: mapperFile, typeMappersSuffix: 'TypeMapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: true, emitLegacyCommonJSImports: true, }, result @@ -95,184 +93,57 @@ describe('collectTypeMappersFromSourceFile', () => { expect(result).toEqual({ Billing: { schemaType: 'Billing', - typeMapperName: 'BillingTypeMapper', - configImportPath: './module1/schema.mappers#BillingTypeMapper', - typeMapperPropertyMap: { - address: { name: 'address' }, - id: { name: 'id' }, - }, - }, - Payment: { - schemaType: 'Payment', - typeMapperName: 'PaymentTypeMapper', - configImportPath: './module1/schema.mappers#PaymentTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, - type: { name: 'type' }, - typeCode: { name: 'typeCode' }, - }, - }, - Address: { - schemaType: 'Address', - typeMapperName: 'AddressTypeMapper', - configImportPath: './module1/schema.mappers#AddressTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, - }, - }, - Geo: { - schemaType: 'Geo', - typeMapperName: 'GeoTypeMapper', - configImportPath: './module1/schema.mappers#GeoTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, - }, - }, - Preference: { - schemaType: 'Preference', - typeMapperName: 'PreferenceTypeMapper', - configImportPath: './module1/schema.mappers#PreferenceTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, + mapper: { + name: 'BillingTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, - }, - Flag: { - schemaType: 'Flag', - typeMapperName: 'FlagTypeMapper', - configImportPath: './module1/schema.mappers#FlagTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, - }, - }, - }); - }); - - it('mutates the result based on typeMappersSuffix for exports from other modules but not propert map if shouldCollectPropertyMap=false', () => { - const project = new Project({ - compilerOptions: { - paths: { - '@external/module1': ['/path/to/external/modules1/index.ts'], - '@external/module2': ['/path/to/external/modules2/index.ts'], - }, - }, - }); - project.createSourceFile( - `/path/to/external/modules1/index.ts`, - ` - export interface Billing { - id: number; - address: string; - } - - export type PaymentTypeMapper = { - id: string; - type: 'creditcard' | 'cash' | 'bitcoin'; - typeCode: 'payment'; - } - - export type SomethingTypeMapper = { - id: number; - } - ` - ); - project.createSourceFile( - `/path/to/external/modules2/index.ts`, - ` - export type Address = { - id: string; - } - export interface GeoTypeMapper { - id: string; - } - export type NotAliasMapper2 = { - id: number; - } - ` - ); - project.createSourceFile( - '/path/to/schemas/module1/localModule1.ts', - ` - export type Preference = { - id: number; - } - export interface FlagTypeMapper { - id: number; - } - export type NotAliasMapper3 = { - id: number; - } - ` - ); - const mapperFile = project.createSourceFile( - '/path/to/schemas/module1/schema.mappers.ts', - ` - export type { - Billing as BillingTypeMapper, - PaymentTypeMapper, - SomethingTypeMapper as NotAliasMapper1 - } from '@external/module1'; - export { - Address as AddressTypeMapper, - GeoTypeMapper, - NotAliasMapper2 - } from '@external/module2'; - export { - Preference as PreferenceTypeMapper, - FlagTypeMapper, - NotAliasMapper3 - } from './localModule1';` - ); - - const result = {}; - - collectTypeMappersFromSourceFile( - { - tsMorphProject: project, - typeMappersSourceFile: mapperFile, - typeMappersSuffix: 'TypeMapper', - resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: false, - emitLegacyCommonJSImports: true, - }, - result - ); - - expect(result).toEqual({ - Billing: { - schemaType: 'Billing', - typeMapperName: 'BillingTypeMapper', configImportPath: './module1/schema.mappers#BillingTypeMapper', - typeMapperPropertyMap: {}, }, Payment: { schemaType: 'Payment', - typeMapperName: 'PaymentTypeMapper', + mapper: { + name: 'PaymentTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', + }, configImportPath: './module1/schema.mappers#PaymentTypeMapper', - typeMapperPropertyMap: {}, }, Address: { schemaType: 'Address', - typeMapperName: 'AddressTypeMapper', + mapper: { + name: 'AddressTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', + }, configImportPath: './module1/schema.mappers#AddressTypeMapper', - typeMapperPropertyMap: {}, }, Geo: { schemaType: 'Geo', - typeMapperName: 'GeoTypeMapper', + mapper: { + filename: '/path/to/schemas/module1/schema.mappers.ts', + kind: SyntaxKind.ExportSpecifier, + name: 'GeoTypeMapper', + }, configImportPath: './module1/schema.mappers#GeoTypeMapper', - typeMapperPropertyMap: {}, }, Preference: { schemaType: 'Preference', - typeMapperName: 'PreferenceTypeMapper', + mapper: { + name: 'PreferenceTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', + }, configImportPath: './module1/schema.mappers#PreferenceTypeMapper', - typeMapperPropertyMap: {}, }, Flag: { schemaType: 'Flag', - typeMapperName: 'FlagTypeMapper', + mapper: { + name: 'FlagTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', + }, configImportPath: './module1/schema.mappers#FlagTypeMapper', - typeMapperPropertyMap: {}, }, }); }); @@ -367,11 +238,9 @@ describe('collectTypeMappersFromSourceFile', () => { collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: mapperFile, typeMappersSuffix: 'TypeMapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: true, emitLegacyCommonJSImports: true, }, result @@ -380,51 +249,57 @@ describe('collectTypeMappersFromSourceFile', () => { expect(result).toEqual({ Billing: { schemaType: 'Billing', - typeMapperName: 'BillingTypeMapper', - configImportPath: './module1/schema.mappers#BillingTypeMapper', - typeMapperPropertyMap: { - __type: { name: '__type' }, + mapper: { + name: 'BillingTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#BillingTypeMapper', }, Payment: { schemaType: 'Payment', - typeMapperName: 'PaymentTypeMapper', - configImportPath: './module1/schema.mappers#PaymentTypeMapper', - typeMapperPropertyMap: { - __type: { name: '__type' }, + mapper: { + name: 'PaymentTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#PaymentTypeMapper', }, Address: { schemaType: 'Address', - typeMapperName: 'AddressTypeMapper', - configImportPath: './module1/schema.mappers#AddressTypeMapper', - typeMapperPropertyMap: { - __type: { name: '__type' }, + mapper: { + name: 'AddressTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#AddressTypeMapper', }, Geo: { schemaType: 'Geo', - typeMapperName: 'GeoTypeMapper', - configImportPath: './module1/schema.mappers#GeoTypeMapper', - typeMapperPropertyMap: { - __type: { name: '__type' }, + mapper: { + name: 'GeoTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#GeoTypeMapper', }, Preference: { schemaType: 'Preference', - typeMapperName: 'PreferenceTypeMapper', - configImportPath: './module1/schema.mappers#PreferenceTypeMapper', - typeMapperPropertyMap: { - __type: { name: '__type' }, + mapper: { + name: 'PreferenceTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#PreferenceTypeMapper', }, Flag: { schemaType: 'Flag', - typeMapperName: 'FlagTypeMapper', - configImportPath: './module1/schema.mappers#FlagTypeMapper', - typeMapperPropertyMap: { - __type: { name: '__type' }, + mapper: { + name: 'FlagTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#FlagTypeMapper', }, }); }); @@ -480,11 +355,9 @@ describe('collectTypeMappersFromSourceFile', () => { collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: mapperFile, typeMappersSuffix: 'TypeMapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: true, emitLegacyCommonJSImports: true, }, result @@ -492,37 +365,40 @@ describe('collectTypeMappersFromSourceFile', () => { expect(result).toEqual({ Profile: { - configImportPath: './module1/schema.mappers#ProfileTypeMapper', schemaType: 'Profile', - typeMapperName: 'ProfileTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, - account: { name: 'account' }, + mapper: { + name: 'ProfileTypeMapper', + kind: SyntaxKind.TypeAliasDeclaration, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#ProfileTypeMapper', }, User: { - configImportPath: './module1/schema.mappers#UserTypeMapper', schemaType: 'User', - typeMapperName: 'UserTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, + mapper: { + name: 'UserTypeMapper', + kind: SyntaxKind.InterfaceDeclaration, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#UserTypeMapper', }, Like: { - configImportPath: './module1/schema.mappers#LikeTypeMapper', schemaType: 'Like', - typeMapperName: 'LikeTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, + mapper: { + name: 'LikeTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#LikeTypeMapper', }, Password: { - configImportPath: './module1/schema.mappers#PasswordTypeMapper', schemaType: 'Password', - typeMapperName: 'PasswordTypeMapper', - typeMapperPropertyMap: { - isValid: { name: 'isValid' }, + mapper: { + name: 'PasswordTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#PasswordTypeMapper', }, }); }); @@ -575,11 +451,9 @@ describe('collectTypeMappersFromSourceFile', () => { collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: mapperFile, typeMappersSuffix: 'TypeMapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: true, emitLegacyCommonJSImports: true, }, result @@ -587,33 +461,31 @@ describe('collectTypeMappersFromSourceFile', () => { expect(result).toEqual({ Like: { - configImportPath: './module1/schema.mappers#LikeTypeMapper', schemaType: 'Like', - typeMapperName: 'LikeTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, - createdAt: { name: 'createdAt' }, + mapper: { + name: 'LikeTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#LikeTypeMapper', }, Post: { - configImportPath: './module1/schema.mappers#PostTypeMapper', schemaType: 'Post', - typeMapperName: 'PostTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, + mapper: { + name: 'PostTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#PostTypeMapper', }, User: { - configImportPath: './module1/schema.mappers#UserTypeMapper', schemaType: 'User', - typeMapperName: 'UserTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, - firstName: { name: 'firstName' }, - lastName: { name: 'lastName' }, - createdAt: { name: 'createdAt' }, - updatedAt: { name: 'updatedAt' }, + mapper: { + name: 'UserTypeMapper', + kind: SyntaxKind.ClassDeclaration, + filename: '/path/to/schemas/module1/schema.mappers.ts', }, + configImportPath: './module1/schema.mappers#UserTypeMapper', }, }); }); @@ -679,65 +551,66 @@ describe('collectTypeMappersFromSourceFile', () => { const expectedBilling = { schemaType: 'Billing', - typeMapperName: 'BillingTypeMapper', - configImportPath: './billing/schema.mappers#BillingTypeMapper', - typeMapperPropertyMap: { - address: { name: 'address' }, - id: { name: 'id' }, + mapper: { + name: 'BillingTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/billing/schema.mappers.ts', }, + configImportPath: './billing/schema.mappers#BillingTypeMapper', }; const expectedPayment = { schemaType: 'Payment', - typeMapperName: 'PaymentTypeMapper', - configImportPath: './billing/schema.mappers#PaymentTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, - type: { name: 'type' }, - typeCode: { name: 'typeCode' }, + mapper: { + name: 'PaymentTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/billing/schema.mappers.ts', }, + configImportPath: './billing/schema.mappers#PaymentTypeMapper', }; const expectedAddress = { schemaType: 'Address', - typeMapperName: 'AddressTypeMapper', - configImportPath: './address/schema.mappers#AddressTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, + mapper: { + name: 'AddressTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/address/schema.mappers.ts', }, + configImportPath: './address/schema.mappers#AddressTypeMapper', }; const expectedGeo = { schemaType: 'Geo', - typeMapperName: 'GeoTypeMapper', - configImportPath: './address/schema.mappers#GeoTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, + mapper: { + name: 'GeoTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/address/schema.mappers.ts', }, + configImportPath: './address/schema.mappers#GeoTypeMapper', }; const expectedPreference = { schemaType: 'Preference', - typeMapperName: 'PreferenceTypeMapper', - configImportPath: './preference/schema.mappers#PreferenceTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, + mapper: { + name: 'PreferenceTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/preference/schema.mappers.ts', }, + configImportPath: './preference/schema.mappers#PreferenceTypeMapper', }; const expectedFlag = { schemaType: 'Flag', - typeMapperName: 'FlagTypeMapper', - configImportPath: './preference/schema.mappers#FlagTypeMapper', - typeMapperPropertyMap: { - id: { name: 'id' }, + mapper: { + name: 'FlagTypeMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/preference/schema.mappers.ts', }, + configImportPath: './preference/schema.mappers#FlagTypeMapper', }; const result = {}; collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: billingMapperFile, typeMappersSuffix: 'TypeMapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: true, emitLegacyCommonJSImports: true, }, result @@ -749,11 +622,9 @@ describe('collectTypeMappersFromSourceFile', () => { collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: addressMapperFile, typeMappersSuffix: 'TypeMapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: true, emitLegacyCommonJSImports: true, }, result @@ -767,11 +638,9 @@ describe('collectTypeMappersFromSourceFile', () => { collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: preferenceMapperFile, typeMappersSuffix: 'TypeMapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: true, emitLegacyCommonJSImports: true, }, result @@ -832,11 +701,9 @@ describe('collectTypeMappersFromSourceFile', () => { collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: mapperFile, typeMappersSuffix: 'Mapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: false, emitLegacyCommonJSImports: false, }, result @@ -845,21 +712,30 @@ describe('collectTypeMappersFromSourceFile', () => { expect(result).toEqual({ Billing: { schemaType: 'Billing', - typeMapperName: 'BillingMapper', + mapper: { + name: 'BillingMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', + }, configImportPath: './module1/schema.mappers.js#BillingMapper', - typeMapperPropertyMap: {}, }, Address: { schemaType: 'Address', - typeMapperName: 'AddressMapper', + mapper: { + name: 'AddressMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', + }, configImportPath: './module1/schema.mappers.js#AddressMapper', - typeMapperPropertyMap: {}, }, Preference: { schemaType: 'Preference', - typeMapperName: 'PreferenceMapper', + mapper: { + name: 'PreferenceMapper', + kind: SyntaxKind.ExportSpecifier, + filename: '/path/to/schemas/module1/schema.mappers.ts', + }, configImportPath: './module1/schema.mappers.js#PreferenceMapper', - typeMapperPropertyMap: {}, }, }); }); @@ -886,11 +762,9 @@ describe('collectTypeMappersFromSourceFile', () => { collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: project.getSourceFiles()[0], typeMappersSuffix: 'TypeMapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: true, emitLegacyCommonJSImports: true, }, result @@ -899,11 +773,9 @@ describe('collectTypeMappersFromSourceFile', () => { expect(() => collectTypeMappersFromSourceFile( { - tsMorphProject: project, typeMappersSourceFile: project.getSourceFiles()[1], typeMappersSuffix: 'TypeMapper', resolverTypesPath: '/path/to/schemas/types.generated.ts', - shouldCollectPropertyMap: true, emitLegacyCommonJSImports: true, }, result diff --git a/packages/typescript-resolver-files/src/parseTypeMappers/collectTypeMappersFromSourceFile.ts b/packages/typescript-resolver-files/src/parseTypeMappers/collectTypeMappersFromSourceFile.ts index e19da244..0624a3e3 100644 --- a/packages/typescript-resolver-files/src/parseTypeMappers/collectTypeMappersFromSourceFile.ts +++ b/packages/typescript-resolver-files/src/parseTypeMappers/collectTypeMappersFromSourceFile.ts @@ -1,30 +1,18 @@ import * as path from 'path'; -import { - type SourceFile, - type Identifier, - type TypeAliasDeclaration, - type InterfaceDeclaration, - type Project, - Node, - SyntaxKind, -} from 'ts-morph'; -import { normalizeRelativePath, getNodePropertyMap } from '../utils'; +import { type SourceFile, type Identifier, SyntaxKind } from 'ts-morph'; +import { normalizeRelativePath } from '../utils'; import type { TypeMappersMap } from './parseTypeMappers'; export const collectTypeMappersFromSourceFile = ( { - tsMorphProject, typeMappersSourceFile, typeMappersSuffix, resolverTypesPath, - shouldCollectPropertyMap, emitLegacyCommonJSImports, }: { - tsMorphProject: Project; typeMappersSourceFile: SourceFile; typeMappersSuffix: string; resolverTypesPath: string; - shouldCollectPropertyMap: boolean; emitLegacyCommonJSImports: boolean; }, result: TypeMappersMap @@ -37,13 +25,11 @@ export const collectTypeMappersFromSourceFile = ( addTypeMapperDetailsIfValid( { - tsMorphProject, - declarationNode: interfaceDeclaration, + kind: SyntaxKind.InterfaceDeclaration, identifierNode: interfaceDeclaration.getNameNode(), typeMappersSuffix, typeMappersFilePath: typeMappersSourceFile.getFilePath(), resolverTypesPath, - shouldCollectPropertyMap, emitLegacyCommonJSImports, }, result @@ -60,13 +46,11 @@ export const collectTypeMappersFromSourceFile = ( addTypeMapperDetailsIfValid( { - tsMorphProject, - declarationNode: typeAlias, + kind: SyntaxKind.TypeAliasDeclaration, identifierNode, typeMappersSuffix, typeMappersFilePath: typeMappersSourceFile.getFilePath(), resolverTypesPath, - shouldCollectPropertyMap, emitLegacyCommonJSImports, }, result @@ -88,13 +72,11 @@ export const collectTypeMappersFromSourceFile = ( addTypeMapperDetailsIfValid( { - tsMorphProject, - declarationNode: null, + kind: SyntaxKind.ExportSpecifier, identifierNode, typeMappersSuffix, typeMappersFilePath: typeMappersSourceFile.getFilePath(), resolverTypesPath, - shouldCollectPropertyMap, emitLegacyCommonJSImports, }, result @@ -114,13 +96,11 @@ export const collectTypeMappersFromSourceFile = ( addTypeMapperDetailsIfValid( { - tsMorphProject, - declarationNode: null, + kind: SyntaxKind.ClassDeclaration, identifierNode, typeMappersSuffix, typeMappersFilePath: typeMappersSourceFile.getFilePath(), resolverTypesPath, - shouldCollectPropertyMap, emitLegacyCommonJSImports, }, result @@ -130,22 +110,22 @@ export const collectTypeMappersFromSourceFile = ( const addTypeMapperDetailsIfValid = ( { - tsMorphProject, - declarationNode, + kind, identifierNode, typeMappersSuffix, typeMappersFilePath, resolverTypesPath, - shouldCollectPropertyMap, emitLegacyCommonJSImports, }: { - tsMorphProject: Project; - declarationNode: InterfaceDeclaration | TypeAliasDeclaration | null; + kind: + | SyntaxKind.InterfaceDeclaration + | SyntaxKind.TypeAliasDeclaration + | SyntaxKind.ExportSpecifier + | SyntaxKind.ClassDeclaration; identifierNode: Identifier; typeMappersSuffix: string; typeMappersFilePath: string; resolverTypesPath: string; - shouldCollectPropertyMap: boolean; emitLegacyCommonJSImports: boolean; }, result: TypeMappersMap @@ -190,43 +170,13 @@ const addTypeMapperDetailsIfValid = ( ); } - let typeMapperPropertyMap = {}; - if (shouldCollectPropertyMap) { - const originalDeclarationNode = getOriginalDeclarationNode( - declarationNode, - identifierNode - ); - typeMapperPropertyMap = getNodePropertyMap({ - node: originalDeclarationNode, - tsMorphProject, - }); - } - result[schemaType] = { schemaType, - typeMapperName: identifierName, - typeMapperPropertyMap, + mapper: { + name: identifierName, + filename: typeMappersFilePath, + kind, + }, configImportPath, }; }; - -const getOriginalDeclarationNode = ( - declarationNode: InterfaceDeclaration | TypeAliasDeclaration | null, - identifierNode: Identifier -): Node => { - if (!declarationNode) { - return identifierNode.getDefinitionNodes()[0]; - } - - // InterfaceDeclaration - if (declarationNode.isKind(SyntaxKind.InterfaceDeclaration)) { - return declarationNode; - } - - // TypeAliasDeclaration - const typeNode = declarationNode.getTypeNodeOrThrow(); - const node = Node.isTypeReference(typeNode) // If type alias is a reference, go to definition using `getDefinitionNodes` - ? identifierNode.getDefinitionNodes()[0] - : declarationNode; - return node; -}; diff --git a/packages/typescript-resolver-files/src/parseTypeMappers/parseTypeMappers.ts b/packages/typescript-resolver-files/src/parseTypeMappers/parseTypeMappers.ts index d6d9c966..976b6bb7 100644 --- a/packages/typescript-resolver-files/src/parseTypeMappers/parseTypeMappers.ts +++ b/packages/typescript-resolver-files/src/parseTypeMappers/parseTypeMappers.ts @@ -1,7 +1,6 @@ import * as path from 'path'; -import type { Project } from 'ts-morph'; +import type { Project, SyntaxKind } from 'ts-morph'; import type { ParseSourcesResult } from '../parseSources'; -import type { NodePropertyMap } from '../utils'; import { collectTypeMappersFromSourceFile } from './collectTypeMappersFromSourceFile'; export interface ParseTypeMappersParams { @@ -10,14 +9,20 @@ export interface ParseTypeMappersParams { typeMappersFileExtension: string; typeMappersSuffix: string; tsMorphProject: Project; - shouldCollectPropertyMap: boolean; emitLegacyCommonJSImports: boolean; } export interface TypeMapperDetails { schemaType: string; - typeMapperName: string; - typeMapperPropertyMap: NodePropertyMap; + mapper: { + name: string; + filename: string; // e.g. /path/to/schema.mappers.ts + kind: + | SyntaxKind.InterfaceDeclaration + | SyntaxKind.TypeAliasDeclaration + | SyntaxKind.ExportSpecifier + | SyntaxKind.ClassDeclaration; + }; configImportPath: string; } @@ -29,7 +34,6 @@ export const parseTypeMappers = ({ typeMappersFileExtension, typeMappersSuffix, tsMorphProject, - shouldCollectPropertyMap, emitLegacyCommonJSImports, }: ParseTypeMappersParams): TypeMappersMap => { const result = Object.entries(sourceMap).reduce( @@ -48,11 +52,9 @@ export const parseTypeMappers = ({ collectTypeMappersFromSourceFile( { - tsMorphProject, typeMappersSourceFile, typeMappersSuffix, resolverTypesPath, - shouldCollectPropertyMap, emitLegacyCommonJSImports, }, res diff --git a/packages/typescript-resolver-files/src/preset.ts b/packages/typescript-resolver-files/src/preset.ts index 30f1effd..06a0e2bf 100644 --- a/packages/typescript-resolver-files/src/preset.ts +++ b/packages/typescript-resolver-files/src/preset.ts @@ -93,8 +93,6 @@ export const preset: Types.OutputPreset = { typeMappersFileExtension, typeMappersSuffix, tsMorphProject, - shouldCollectPropertyMap: - fixObjectTypeResolvers.object !== 'disabled', emitLegacyCommonJSImports, }), createProfilerRunName('parseTypeMappers') @@ -163,14 +161,16 @@ export const preset: Types.OutputPreset = { const graphQLObjectTypeResolversToGenerate = await profiler.run( async () => - getGraphQLObjectTypeResolversToGenerate({ - tsMorphProject, - typesSourceFile, - userDefinedSchemaObjectTypeMap: - mergedConfig.userDefinedSchemaTypeMap.object, - typeMappersMap, - }), - createProfilerRunName('graphQLObjectTypeResolversToGenerate') + fixObjectTypeResolvers.object === 'smart' + ? getGraphQLObjectTypeResolversToGenerate({ + tsMorphProject, + typesSourceFile, + userDefinedSchemaObjectTypeMap: + mergedConfig.userDefinedSchemaTypeMap.object, + typeMappersMap, + }) + : {}, + createProfilerRunName('getGraphQLObjectTypeResolversToGenerate') ); const resolverTypesFilePlugins: Types.PluginConfig[] = [ diff --git a/packages/typescript-resolver-files/src/utils/getNodePropertyMap.spec.ts b/packages/typescript-resolver-files/src/utils/getNodePropertyMap.spec.ts deleted file mode 100644 index 0bc00a6e..00000000 --- a/packages/typescript-resolver-files/src/utils/getNodePropertyMap.spec.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Project, Node } from 'ts-morph'; -import { getNodePropertyMap } from './getNodePropertyMap'; - -describe('getNodePropertyMap', () => { - it('correctly resolves property map of a typical types.generated.ts', () => { - const project = new Project(); - const sourceFile = project.createSourceFile( - '/path/to/types.generated.ts', - ` - export type Maybe = T | null; - export type Scalars = { - ID: string; - String: string; - Boolean: boolean; - Int: number; - Float: number; - DateTime: Date | string; - }; - export type User = { - __typename: 'User'; - accountGitHub?: Maybe; - accountGoogle?: Maybe; - createdAt: Scalars['DateTime']; - fullName: Scalars['String']; - id: Scalars['ID']; - role: UserRole; - }; - export type UserRole = 'ADMIN' | 'USER';` - ); - - const userDeclarationNode = sourceFile.getFirstDescendant( - (node) => Node.isTypeAliasDeclaration(node) && node.getName() === 'User' - ); - - expect( - getNodePropertyMap({ tsMorphProject: project, node: userDeclarationNode }) - ).toEqual({ - __typename: { - name: '__typename', - }, - accountGitHub: { - name: 'accountGitHub', - }, - accountGoogle: { - name: 'accountGoogle', - }, - createdAt: { - name: 'createdAt', - }, - fullName: { - name: 'fullName', - }, - id: { - name: 'id', - }, - role: { - name: 'role', - }, - }); - }); -}); diff --git a/packages/typescript-resolver-files/src/utils/index.ts b/packages/typescript-resolver-files/src/utils/index.ts index 87beaeb5..6e998c4b 100644 --- a/packages/typescript-resolver-files/src/utils/index.ts +++ b/packages/typescript-resolver-files/src/utils/index.ts @@ -6,7 +6,6 @@ export * from './isNativeNamedType'; export * from './isRootObjectType'; export * from './isWhitelistedModule'; export * from './printImportLine'; -export * from './getNodePropertyMap'; export * from './normalizeRelativePath'; export * from './relativeModulePath'; export * from './isMatchResolverNamePattern';