diff --git a/README.md b/README.md index 86f4f7f..bfce158 100644 --- a/README.md +++ b/README.md @@ -31,3 +31,41 @@ ARGUMENTS file The source file to inspect and generate ``` + +## Configure VSCode + +Install [Save and Run](https://marketplace.visualstudio.com/items?itemName=wk-j.save-and-run) +extension and add the following to `/.vscode/settings.json` + +```json +{ + "saveAndRun": { + "commands": [ + { + "match": ".model.ts", + "cmd": "gql-assist generate '${file}'", + "useShortcut": false, + "silent": true + }, + { + "match": ".resolver.ts", + "cmd": "gql-assist generate '${file}'", + "useShortcut": false, + "silent": true + }, + { + "match": ".enum.ts", + "cmd": "gql-assist generate '${file}'", + "useShortcut": false, + "silent": true + }, + { + "match": ".input.ts", + "cmd": "gql-assist generate '${file}'", + "useShortcut": false, + "silent": true + } + ] + } +} +``` diff --git a/package.json b/package.json index 1e0a4bd..0ba34b3 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "bin": { - "gqlassist": ".bin/cli" + "gql-assist": ".bin/cli" }, "private": false, "scripts": { @@ -31,15 +31,19 @@ }, "license": "ISC", "dependencies": { + "chalk": "4.0.0", "clifer": "^1.2.1", "fs-extra": "^11.2.0", "name-util": "^1.3.0", + "shelljs": "^0.8.5", "tsds-tools": "^1.2.1" }, "devDependencies": { + "@types/chalk": "^2.2.0", "@types/fs-extra": "^11.0.4", "@types/jest": "^29.5.12", "@types/node": "^20.14.0", + "@types/shelljs": "^0.8.15", "eslint": "^8.23.0", "eslint-config-prettier": "^8.5.0", "eslint-plugin-jest": "^27.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dfe7ac..7c28ea3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + chalk: + specifier: 4.0.0 + version: 4.0.0 clifer: specifier: ^1.2.1 version: 1.2.1 @@ -17,10 +20,16 @@ importers: name-util: specifier: ^1.3.0 version: 1.3.0 + shelljs: + specifier: ^0.8.5 + version: 0.8.5 tsds-tools: specifier: ^1.2.1 version: 1.2.1 devDependencies: + '@types/chalk': + specifier: ^2.2.0 + version: 2.2.0 '@types/fs-extra': specifier: ^11.0.4 version: 11.0.4 @@ -30,6 +39,9 @@ importers: '@types/node': specifier: ^20.14.0 version: 20.14.0 + '@types/shelljs': + specifier: ^0.8.15 + version: 0.8.15 eslint: specifier: ^8.23.0 version: 8.57.0 @@ -525,9 +537,16 @@ packages: '@types/babel__traverse@7.20.6': resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + '@types/chalk@2.2.0': + resolution: {integrity: sha512-1zzPV9FDe1I/WHhRkf9SNgqtRJWZqrBWgu7JGveuHmmyR9CnAPCie2N/x+iHrgnpYBIcCJWHBoMRv2TRWktsvw==} + deprecated: This is a stub types definition for chalk (https://github.com/chalk/chalk). chalk provides its own type definitions, so you don't need @types/chalk installed! + '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/glob@7.2.0': + resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -549,6 +568,9 @@ packages: '@types/jsonfile@6.1.4': resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + '@types/minimatch@5.1.2': + resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + '@types/node@20.14.0': resolution: {integrity: sha512-5cHBxFGJx6L4s56Bubp4fglrEpmyJypsqI6RgzMfBHWUJQGWAAi8cWcgetEbZXHYXo9C2Fa4EEds/uSyS4cxmA==} @@ -558,6 +580,9 @@ packages: '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + '@types/shelljs@0.8.15': + resolution: {integrity: sha512-vzmnCHl6hViPu9GNLQJ+DZFd6BQI2DBTUeOvYHqkWQLMfKAAQYMb/xAmZkTogZI/vqXHCWkqDRymDI5p0QTi5Q==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -778,6 +803,10 @@ packages: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + chalk@4.0.0: + resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==} + engines: {node: '>=10'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1455,6 +1484,10 @@ packages: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} + interpret@1.4.0: + resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} + engines: {node: '>= 0.10'} + into-stream@7.0.0: resolution: {integrity: sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==} engines: {node: '>=12'} @@ -2267,6 +2300,10 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + rechoir@0.6.2: + resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} + engines: {node: '>= 0.10'} + regexp.prototype.flags@1.5.2: resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} engines: {node: '>= 0.4'} @@ -2365,6 +2402,11 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shelljs@0.8.5: + resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==} + engines: {node: '>=4'} + hasBin: true + side-channel@1.0.6: resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} engines: {node: '>= 0.4'} @@ -3064,7 +3106,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/node': 20.14.0 - chalk: 4.1.2 + chalk: 4.0.0 jest-message-util: 29.7.0 jest-util: 29.7.0 slash: 3.0.0 @@ -3078,7 +3120,7 @@ snapshots: '@jest/types': 29.6.3 '@types/node': 20.14.0 ansi-escapes: 4.3.2 - chalk: 4.1.2 + chalk: 4.0.0 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 @@ -3149,7 +3191,7 @@ snapshots: '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 '@types/node': 20.14.0 - chalk: 4.1.2 + chalk: 4.0.0 collect-v8-coverage: 1.0.2 exit: 0.1.2 glob: 7.2.3 @@ -3199,7 +3241,7 @@ snapshots: '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.25 babel-plugin-istanbul: 6.1.1 - chalk: 4.1.2 + chalk: 4.0.0 convert-source-map: 2.0.0 fast-json-stable-stringify: 2.1.0 graceful-fs: 4.2.11 @@ -3220,7 +3262,7 @@ snapshots: '@types/istanbul-reports': 3.0.4 '@types/node': 20.14.0 '@types/yargs': 17.0.32 - chalk: 4.1.2 + chalk: 4.0.0 '@jridgewell/gen-mapping@0.3.5': dependencies: @@ -3447,11 +3489,20 @@ snapshots: dependencies: '@babel/types': 7.24.6 + '@types/chalk@2.2.0': + dependencies: + chalk: 4.0.0 + '@types/fs-extra@11.0.4': dependencies: '@types/jsonfile': 6.1.4 '@types/node': 20.14.0 + '@types/glob@7.2.0': + dependencies: + '@types/minimatch': 5.1.2 + '@types/node': 20.14.0 + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 20.14.0 @@ -3477,6 +3528,8 @@ snapshots: dependencies: '@types/node': 20.14.0 + '@types/minimatch@5.1.2': {} + '@types/node@20.14.0': dependencies: undici-types: 5.26.5 @@ -3485,6 +3538,11 @@ snapshots: '@types/semver@7.5.8': {} + '@types/shelljs@0.8.15': + dependencies: + '@types/glob': 7.2.0 + '@types/node': 20.14.0 + '@types/stack-utils@2.0.3': {} '@types/yargs-parser@21.0.3': {} @@ -3634,7 +3692,7 @@ snapshots: '@types/babel__core': 7.20.5 babel-plugin-istanbul: 6.1.1 babel-preset-jest: 29.6.3(@babel/core@7.24.6) - chalk: 4.1.2 + chalk: 4.0.0 graceful-fs: 4.2.11 slash: 3.0.0 transitivePeerDependencies: @@ -3737,6 +3795,11 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 + chalk@4.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3756,7 +3819,7 @@ snapshots: cli-highlight@2.1.11: dependencies: - chalk: 4.1.2 + chalk: 4.0.0 highlight.js: 10.7.3 mz: 2.7.0 parse5: 5.1.1 @@ -3851,7 +3914,7 @@ snapshots: create-jest@29.7.0(@types/node@20.14.0)(ts-node@10.9.2(@types/node@20.14.0)(typescript@5.4.5)): dependencies: '@jest/types': 29.6.3 - chalk: 4.1.2 + chalk: 4.0.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-config: 29.7.0(@types/node@20.14.0)(ts-node@10.9.2(@types/node@20.14.0)(typescript@5.4.5)) @@ -4093,7 +4156,7 @@ snapshots: '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.2.0 ajv: 6.12.6 - chalk: 4.1.2 + chalk: 4.0.0 cross-spawn: 7.0.3 debug: 4.3.5 doctrine: 3.0.0 @@ -4510,6 +4573,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.0.6 + interpret@1.4.0: {} + into-stream@7.0.0: dependencies: from2: 2.3.0 @@ -4678,7 +4743,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.14.0 - chalk: 4.1.2 + chalk: 4.0.0 co: 4.6.0 dedent: 1.5.3 is-generator-fn: 2.1.0 @@ -4702,7 +4767,7 @@ snapshots: '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.14.0)(typescript@5.4.5)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - chalk: 4.1.2 + chalk: 4.0.0 create-jest: 29.7.0(@types/node@20.14.0)(ts-node@10.9.2(@types/node@20.14.0)(typescript@5.4.5)) exit: 0.1.2 import-local: 3.1.0 @@ -4722,7 +4787,7 @@ snapshots: '@jest/test-sequencer': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.24.6) - chalk: 4.1.2 + chalk: 4.0.0 ci-info: 3.9.0 deepmerge: 4.3.1 glob: 7.2.3 @@ -4749,7 +4814,7 @@ snapshots: jest-diff@29.7.0: dependencies: - chalk: 4.1.2 + chalk: 4.0.0 diff-sequences: 29.6.3 jest-get-type: 29.6.3 pretty-format: 29.7.0 @@ -4761,7 +4826,7 @@ snapshots: jest-each@29.7.0: dependencies: '@jest/types': 29.6.3 - chalk: 4.1.2 + chalk: 4.0.0 jest-get-type: 29.6.3 jest-util: 29.7.0 pretty-format: 29.7.0 @@ -4800,7 +4865,7 @@ snapshots: jest-matcher-utils@29.7.0: dependencies: - chalk: 4.1.2 + chalk: 4.0.0 jest-diff: 29.7.0 jest-get-type: 29.6.3 pretty-format: 29.7.0 @@ -4810,7 +4875,7 @@ snapshots: '@babel/code-frame': 7.24.6 '@jest/types': 29.6.3 '@types/stack-utils': 2.0.3 - chalk: 4.1.2 + chalk: 4.0.0 graceful-fs: 4.2.11 micromatch: 4.0.7 pretty-format: 29.7.0 @@ -4838,7 +4903,7 @@ snapshots: jest-resolve@29.7.0: dependencies: - chalk: 4.1.2 + chalk: 4.0.0 graceful-fs: 4.2.11 jest-haste-map: 29.7.0 jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) @@ -4856,7 +4921,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.14.0 - chalk: 4.1.2 + chalk: 4.0.0 emittery: 0.13.1 graceful-fs: 4.2.11 jest-docblock: 29.7.0 @@ -4884,7 +4949,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@types/node': 20.14.0 - chalk: 4.1.2 + chalk: 4.0.0 cjs-module-lexer: 1.3.1 collect-v8-coverage: 1.0.2 glob: 7.2.3 @@ -4912,7 +4977,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.6) - chalk: 4.1.2 + chalk: 4.0.0 expect: 29.7.0 graceful-fs: 4.2.11 jest-diff: 29.7.0 @@ -4930,7 +4995,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/node': 20.14.0 - chalk: 4.1.2 + chalk: 4.0.0 ci-info: 3.9.0 graceful-fs: 4.2.11 picomatch: 2.3.1 @@ -4939,7 +5004,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 camelcase: 6.3.0 - chalk: 4.1.2 + chalk: 4.0.0 jest-get-type: 29.6.3 leven: 3.1.0 pretty-format: 29.7.0 @@ -4950,7 +5015,7 @@ snapshots: '@jest/types': 29.6.3 '@types/node': 20.14.0 ansi-escapes: 4.3.2 - chalk: 4.1.2 + chalk: 4.0.0 emittery: 0.13.1 jest-util: 29.7.0 string-length: 4.0.2 @@ -5379,6 +5444,10 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 + rechoir@0.6.2: + dependencies: + resolve: 1.22.8 + regexp.prototype.flags@1.5.2: dependencies: call-bind: 1.0.7 @@ -5504,6 +5573,12 @@ snapshots: shebang-regex@3.0.0: {} + shelljs@0.8.5: + dependencies: + glob: 7.2.3 + interpret: 1.4.0 + rechoir: 0.6.2 + side-channel@1.0.6: dependencies: call-bind: 1.0.7 diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f2734a5 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,38 @@ +import { resolve } from 'path' +import * as fs from 'fs-extra' + +export interface GQLAssistConfig { + nullableByDefault: boolean +} + +const DEFAULT_CONFIG: GQLAssistConfig = { + nullableByDefault: true, +} + +const configNames = ['.gql-assist', '.gql-assist.json', 'gql-assist.json'] + +export function getConfig(): GQLAssistConfig { + let index = 0 + let configFileName = configNames[index] + while (configFileName) { + try { + const file = resolve(process.cwd(), configFileName) + if (fs.existsSync(file)) { + return { ...DEFAULT_CONFIG, ...JSON.parse(fs.readFileSync(file, 'utf8')) } + } + } catch (e) { + console.error(e) + // do nothing + } + configFileName = configNames[++index] + } + try { + const packageJSON = JSON.parse(fs.readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) + return { ...DEFAULT_CONFIG, ...packageJSON['gql-assist'] } + } catch (e) { + // do nothing + } + return DEFAULT_CONFIG +} + +export const config = getConfig() diff --git a/src/create/create-command.ts b/src/create/create-command.ts new file mode 100644 index 0000000..ef4a03c --- /dev/null +++ b/src/create/create-command.ts @@ -0,0 +1,21 @@ +import { command, input } from 'clifer' +import { ifValidCommand, runCommand } from '../util/run-command' + +interface Props { + name: string +} + +async function run({ name }: Props) { + if (!ifValidCommand('nest')) throw new Error('Install @nest/cli to continue!') + await runCommand(`nest g module ${name}`) + await runCommand(`nest g resolver ${name}`) + await runCommand(`nest g service ${name}`) + await runCommand(`nest g class ${name}`) + await runCommand(`mv src/${name}/${name}.ts src/${name}/${name}.model.ts`) + await runCommand(`find src -type f -name "*.spec.ts" -exec rm {} \\;`) +} + +export default command('create') + .description('Create a module ') + .argument(input('name').description('Name of the module').string().required()) + .handle(run) diff --git a/src/generate/generate-command.ts b/src/generate/generate-command.ts index b2b0083..4422cb7 100644 --- a/src/generate/generate-command.ts +++ b/src/generate/generate-command.ts @@ -2,26 +2,30 @@ import { command, input } from 'clifer' import { writeFile } from 'fs-extra' import { reduceAsync } from 'tsds-tools' import ts from 'typescript' +import { generateEnum } from '../gql/enum/enum-generator' +import { generateInput } from '../gql/input/input-generator' import { generateModel } from '../gql/model/model-generator' +import { generateResolver } from '../gql/resolver/resolver-generator' import { prettify, printTS, readAndParseTSFile } from '../util/ts-util' interface GenerateProps { file: string } -const plugins = [generateModel] +const plugins = [generateModel, generateInput, generateResolver, generateEnum] -async function generate(sourceFile: ts.SourceFile) { +export async function generate(sourceFile: ts.SourceFile) { return await reduceAsync(plugins, (sourceFile, runPlugin) => runPlugin(sourceFile), sourceFile) } async function run({ file }: GenerateProps) { const sourceFile = readAndParseTSFile(file) - const output = await prettify(printTS(await generate(sourceFile))) + const output = await prettify(printTS(await generate(sourceFile), undefined)) await writeFile(file, output) } export default command('generate') + .description('Generate models and resolvers') .argument( input('file').description('The source file to inspect and generate').string().required(), ) diff --git a/src/gql/enum/enum-generator.test.ts b/src/gql/enum/enum-generator.test.ts new file mode 100644 index 0000000..1cd9619 --- /dev/null +++ b/src/gql/enum/enum-generator.test.ts @@ -0,0 +1,83 @@ +import { toParsedOutput } from '../../util/test-util' +import { parseTSFile, prettify, printTS } from '../../util/ts-util' +import { generateEnum } from './enum-generator' + +async function generate(fileName: string, content: string) { + const sourceFile = parseTSFile(fileName, content) + const output = await generateEnum(sourceFile) + return prettify(printTS(output, undefined, { removeComments: true })) +} + +describe('generateModel', () => { + test('should register an enum', async () => { + const output = await generate( + 'user.enum.ts', + ` + enum Status { + ACTIVE = 'ACTIVE', + DELETED = 'DELETED', + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { registerEnumType } from '@nestjs/graphql' + + enum Status { + ACTIVE = 'ACTIVE', + DELETED = 'DELETED', + } + registerEnumType(Status, { name: 'Status' }) + `), + ) + }) + + test('should convert type to string type', async () => { + const output = await generate( + 'user.enum.ts', + ` + enum Status { + ACTIVE, + DELETED, + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { registerEnumType } from '@nestjs/graphql' + + enum Status { + ACTIVE = 'ACTIVE', + DELETED = 'DELETED', + } + registerEnumType(Status, { name: 'Status' }) + `), + ) + }) + + test('should not register twice', async () => { + const output = await generate( + 'user.enum.ts', + ` + import { registerEnumType } from '@nestjs/graphql' + + enum Status { + ACTIVE = 'ACTIVE', + DELETED = 'DELETED', + } + registerEnumType(Status, { name: 'Status' }) + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { registerEnumType } from '@nestjs/graphql' + + enum Status { + ACTIVE = 'ACTIVE', + DELETED = 'DELETED', + } + registerEnumType(Status, { name: 'Status' }) + `), + ) + }) +}) diff --git a/src/gql/enum/enum-generator.ts b/src/gql/enum/enum-generator.ts new file mode 100644 index 0000000..8b16440 --- /dev/null +++ b/src/gql/enum/enum-generator.ts @@ -0,0 +1,70 @@ +import ts, { factory } from 'typescript' +import { Context, createContext } from '../context' +import { addImports, createImport, getName, organizeImports } from '../gql-util' + +function createRegisterEnum(node: ts.EnumDeclaration, context: Context) { + const name = getName(node) + context.imports.push(createImport('@nestjs/graphql', 'registerEnumType')) + return factory.createExpressionStatement( + factory.createCallExpression(factory.createIdentifier('registerEnumType'), undefined, [ + factory.createIdentifier(name), + factory.createObjectLiteralExpression( + [ + factory.createPropertyAssignment( + factory.createIdentifier('name'), + factory.createStringLiteral(name), + ), + ], + false, + ), + ]), + ) +} + +function processEnumDeclaration(node: ts.EnumDeclaration, context: Context) { + return [ + ts.visitEachChild( + node, + node => { + if (ts.isEnumMember(node)) { + const name = getName(node) + return { ...node, initializer: factory.createStringLiteral(name) } + } + return node + }, + undefined, + ), + createRegisterEnum(node, context), + ] +} + +export function isEnum(sourceFile: ts.SourceFile) { + const { fileName } = sourceFile + return ( + fileName.endsWith('.enum.ts') || + fileName.endsWith('.input.ts') || + fileName.endsWith('.response.ts') + ) +} + +export async function generateEnum(sourceFile: ts.SourceFile): Promise { + if (!isEnum(sourceFile)) return sourceFile + const context = createContext() + const updatedSourcefile = ts.visitEachChild( + sourceFile, + node => { + if (ts.isEnumDeclaration(node)) return processEnumDeclaration(node, context) + if ( + ts.isExpressionStatement(node) && + ts.isCallExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'registerEnumType' + ) { + return + } + return node + }, + undefined, + ) + return organizeImports(addImports(updatedSourcefile, context.imports)) +} diff --git a/src/gql/gql-util.ts b/src/gql/gql-util.ts index 5d06e85..111cf65 100644 --- a/src/gql/gql-util.ts +++ b/src/gql/gql-util.ts @@ -1,20 +1,132 @@ -import ts, { ModifierLike, factory, isCallExpression, isDecorator, isIdentifier } from 'typescript' +import { toClassName } from 'name-util' +import { toNonNullArray } from 'tsds-tools' +import ts, { ModifierLike, SyntaxKind, factory, isCallExpression, isIdentifier } from 'typescript' +import { config } from '../config' import { Context } from './context' +export function getComment(node: ts.Node) { + return (node as any)?.jsDoc + ?.map((doc: { comment?: string }) => doc.comment) + .filter((comment: string | undefined) => !!comment) + .join('\n') +} + +export function getCommentFromDecorator(node: ts.Node, name: string) { + const decorator = getDecorator(node, name) + if (!decorator || !ts.isDecorator(decorator)) return + if (isCallExpression(decorator.expression)) { + const option = decorator.expression.arguments.find(ts.isObjectLiteralExpression) + if (option) { + const description = option.properties + .filter(ts.isPropertyAssignment) + .find(prop => (ts.isIdentifier(prop.name) ? prop.name.text === 'description' : false)) + return description?.initializer && ts.isStringLiteral(description?.initializer) + ? description?.initializer.text + : undefined + } + } +} + +export function conditional( + value: boolean | undefined, + ifTrue: T | ((...args: any[]) => T), + ifFalse: T | ((...args: any[]) => T), +) { + if (value === true) return typeof ifTrue === 'function' ? (ifTrue as any)() : ifTrue + return typeof ifFalse === 'function' ? (ifFalse as any)() : ifFalse +} + +export function hasImplementationByName(node: ts.ClassDeclaration, name: string) { + return !!getImplementationByName(node, name) +} + +export function getImplementationByName(node: ts.ClassDeclaration, name: string) { + const heritageClause = node.heritageClauses?.find( + clause => + !!clause.types.find( + type => + ts.isExpressionWithTypeArguments(type) && + ts.isIdentifier(type.expression) && + type.expression.text === name, + ), + ) + return heritageClause?.types.find( + type => + ts.isExpressionWithTypeArguments(type) && + ts.isIdentifier(type.expression) && + type.expression.text === name, + ) +} + +export function getTypeFromDecorator(node: ts.Node, name: string) { + const decorator = getDecorator(node, name) + if (!decorator || !ts.isDecorator(decorator)) return + if (isCallExpression(decorator.expression)) { + const arrowFunction = decorator.expression.arguments.find(ts.isArrowFunction) + if (arrowFunction && ts.isIdentifier(arrowFunction.body)) { + return arrowFunction.body.text + } else if ( + arrowFunction && + ts.isArrayLiteralExpression(arrowFunction.body) && + ts.isIdentifier(arrowFunction.body.elements?.[0]) + ) { + return `[${arrowFunction.body.elements[0].text}]` + } + } +} + +export function isAsync(node: ts.Node) { + return (node as any)?.modifiers?.some((i: any) => i.kind === SyntaxKind.AsyncKeyword) +} + export function hasDecorator(node: ts.Node, name: string) { + return getDecorator(node, name) !== undefined +} + +export function getDecorator(node: ts.Node, name: string) { const modifiers = (node as any).modifiers as ModifierLike[] - if (!modifiers?.length) return false - return modifiers.filter(isDecorator).some(decorator => { - if (isCallExpression(decorator.expression) && isIdentifier(decorator.expression.expression)) { - return decorator.expression.expression.text === name + if (!modifiers?.length) return + for (const decorator of modifiers) { + if ( + ts.isDecorator(decorator) && + isCallExpression(decorator.expression) && + isIdentifier(decorator.expression.expression) && + decorator.expression.expression.text === name + ) { + return decorator } - }) + } } -export function addDecorator( - node: ts.ClassDeclaration | ts.PropertyDeclaration, - ...decorators: ts.Decorator[] -) { +export function getName(node: ts.Node) { + return (node as any)?.name && ts.isIdentifier((node as any).name) + ? (node as any).name.text + : undefined +} + +export function getPropertyDeclaration(node: ts.ClassDeclaration, name: string) { + return node.members.find( + member => + ts.isPropertyDeclaration(member) && ts.isIdentifier(member.name) && member.name.text === name, + ) +} + +export function getMethodDeclaration(node: ts.ClassDeclaration, name: string) { + return node.members.find( + member => + ts.isMethodDeclaration(member) && ts.isIdentifier(member.name) && member.name.text === name, + ) +} + +export function addDecorator< + T extends + | ts.ClassDeclaration + | ts.PropertyDeclaration + | ts.MethodDeclaration + | ts.ParameterDeclaration + | undefined, +>(node: T, ...decorators: ts.Decorator[]): T { + if (!node) return node const names = decorators.map((d: any) => d.expression.expression.text) return { ...node, @@ -28,33 +140,395 @@ export function addDecorator( } } -export function createObjectTypeDecorator(context: Context) { - context.imports.push(createImport('@nestjs/graphql', 'ObjectType')) +export function convertToMethod( + node: ts.PropertyDeclaration | ts.MethodDeclaration, + removeType?: boolean, +) { + const name = getName(node) + if (ts.isMethodDeclaration(node)) return node + if (ts.isPropertyDeclaration(node) && node.type && ts.isFunctionTypeNode(node.type)) { + return factory.createMethodDeclaration( + undefined, + undefined, + factory.createIdentifier(name), + undefined, + undefined, + node.type.parameters, + // removeType ? undefined : node.type.type, + undefined, + factory.createBlock([], true), + ) + } +} + +export function addExport( + node: T, +): T { + if (!node) return node + return { + ...node, + modifiers: [...(node.modifiers ?? []), factory.createToken(ts.SyntaxKind.ExportKeyword)], + } +} + +export function createObjectTypeDecorator( + node: ts.ClassDeclaration, + name: 'ObjectType' | 'InputType', + context: Context, +) { + const comment = getComment(node) ?? getCommentFromDecorator(node, name) + const argumentsArray: ts.Expression[] = [] + if (!!comment) { + argumentsArray.push( + factory.createObjectLiteralExpression( + [ + factory.createPropertyAssignment( + factory.createIdentifier('description'), + factory.createStringLiteral(comment), + ), + ], + false, + ), + ) + } + context.imports.push(createImport('@nestjs/graphql', name)) return factory.createDecorator( - factory.createCallExpression(factory.createIdentifier('ObjectType'), undefined, undefined), + factory.createCallExpression(factory.createIdentifier(name), undefined, argumentsArray), ) } -export function createFieldDecorator(node: ts.PropertyDeclaration, context: Context) { +export function hasParameter(node: ts.Node, name: string) { + return !!getParameterByName(node, name) +} + +export function getParameterByName(node: ts.Node, name: string) { + const parameters = getParameters(node) + return parameters.find(parameter => getName(parameter) === name) +} + +export function getParameters(node: ts.Node) { + if (ts.isPropertyDeclaration(node) && node.type && ts.isFunctionTypeNode(node.type)) { + return node.type.parameters + } else if (ts.isMethodDeclaration(node)) { + return node.parameters + } + return [] +} + +export function getAllParameters(node: ts.Node) { + let params: ts.ParameterDeclaration[] = [] + ts.visitEachChild( + node, + node => { + params = params.concat(getParameters(node)) + return node + }, + undefined, + ) + return params.flatMap(node => node) +} + +export function getParameterType(parameter: ts.ParameterDeclaration) { + if ( + parameter.type && + ts.isTypeReferenceNode(parameter.type) && + ts.isIdentifier(parameter.type.typeName) && + parameter.type.typeName.text !== '' + ) { + return parameter.type.typeName.text + } +} + +export function createParentDecorator(context: Context) { + context.imports.push(createImport('@nestjs/graphql', 'Parent')) + return factory.createDecorator( + factory.createCallExpression(factory.createIdentifier('Parent'), undefined, undefined), + ) +} + +export function createArgsDecorator(node: ts.ParameterDeclaration, context: Context) { + context.imports.push(createImport('@nestjs/graphql', 'Args')) + const name = getName(node) + const type = getType(node) ?? getTypeFromDecorator(node, 'Args') const argumentsArray: ts.Expression[] = [] - context.imports.push(createImport('@nestjs/graphql', 'Field')) - if (node && ts.isIdentifier(node?.name) && node.name.text === 'id') { + if (type && !['string', 'boolean'].includes(type)) { argumentsArray.push( - factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - factory.createIdentifier('ID'), + factory.createObjectLiteralExpression( + toNonNullArray([ + factory.createPropertyAssignment( + factory.createIdentifier('name'), + factory.createStringLiteral(name), + ), + factory.createPropertyAssignment( + factory.createIdentifier('type'), + factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + factory.createIdentifier(type), + ), + ), + isNullable(node) + ? factory.createPropertyAssignment( + factory.createIdentifier('nullable'), + factory.createTrue(), + ) + : (undefined as any), + ]), ), ) - context.imports.push(createImport('@nestjs/graphql', 'ID')) - } else if ( - node?.type && - ts.isTypeReferenceNode(node?.type) && - ts.isIdentifier(node?.type.typeName) + } else { + argumentsArray.push(factory.createStringLiteral(name)) + if (isNullable(node)) { + argumentsArray.push( + factory.createObjectLiteralExpression( + [ + factory.createPropertyAssignment( + factory.createIdentifier('nullable'), + factory.createTrue(), + ), + ], + false, + ), + ) + } + } + if (type && ['ID', 'INT'].includes(type)) { + context.imports.push(createImport('@nestjs/graphql', type)) + } + return factory.createDecorator( + factory.createCallExpression(factory.createIdentifier('Args'), undefined, argumentsArray), + ) +} + +export function createContextDecorator() { + return factory.createDecorator( + factory.createCallExpression(factory.createIdentifier('Context'), undefined, undefined), + ) +} + +export function createResolverDecorator(type: string, addType: boolean, context: Context) { + context.imports.push(createImport('@nestjs/graphql', 'Resolver')) + return factory.createDecorator( + factory.createCallExpression( + factory.createIdentifier('Resolver'), + undefined, + addType + ? [ + factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + factory.createIdentifier(type), + ), + ] + : undefined, + ), + ) +} + +export function createScalarDecorator(node: ts.ClassDeclaration, context: Context) { + const name = node.name && ts.isIdentifier(node.name) ? node.name.text : '' + const argumentsArray: ts.Expression[] = [ + factory.createStringLiteral(name), + factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + factory.createIdentifier('String'), + ), + ] + context.imports.push(createImport('@nestjs/graphql', 'Scalar')) + return factory.createDecorator( + factory.createCallExpression(factory.createIdentifier('SCalar'), undefined, argumentsArray), + ) +} + +export function isNullable( + node: ts.PropertyDeclaration | ts.MethodDeclaration | ts.ParameterDeclaration, +): boolean { + if (ts.isParameter(node)) { + return !!node.questionToken + } + if (ts.isMethodDeclaration(node)) { + return !!getAllTypes(node.type).find(i => i === 'null' || i === 'undefined') + } + return config.nullableByDefault ? !node.exclamationToken : !!node.questionToken +} + +export function isNullableFromDecorator( + node: ts.PropertyDeclaration | ts.MethodDeclaration | ts.ParameterDeclaration, +): boolean { + for (const decorator of node.modifiers ?? []) { + if ( + ts.isDecorator(decorator) && + isCallExpression(decorator.expression) && + isIdentifier(decorator.expression.expression) + ) { + const object = decorator.expression.arguments?.find(i => ts.isObjectLiteralExpression(i)) + if (object && ts.isObjectLiteralExpression(object)) { + const nullable = object.properties.find( + prop => + ts.isPropertyAssignment(prop) && + ts.isIdentifier(prop.name) && + prop.name.text === 'nullable', + ) + return nullable && ts.isPropertyAssignment(nullable) + ? nullable.initializer.kind === SyntaxKind.TrueKeyword + : false + } + } + } + return false +} + +export function getAllTypes( + node: + | ts.TypeNode + | ts.LiteralExpression + | ts.NullLiteral + | ts.BooleanLiteral + | ts.PrefixUnaryExpression + | undefined, +): string[] { + if (!node) return [] + if (node.kind === ts.SyntaxKind.StringKeyword) return ['string'] + if (node.kind === ts.SyntaxKind.NumberKeyword) return ['number'] + if (node.kind === ts.SyntaxKind.BooleanKeyword) return ['boolean'] + if (node.kind === ts.SyntaxKind.UndefinedKeyword) return ['undefined'] + if (node.kind === ts.SyntaxKind.NullKeyword) return ['null'] + if ( + ts.isTypeReferenceNode(node) && + ts.isIdentifier(node.typeName) && + node.typeName.text === 'Promise' && + node.typeArguments?.[0] ) { + return ['Promise', ...getAllTypes(node.typeArguments[0])] + } + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { + return [node.typeName.text] + } + if (ts.isUnionTypeNode(node)) { + return node.types.flatMap(getAllTypes) + } + if (ts.isLiteralTypeNode(node)) { + return getAllTypes(node.literal) + } + if (ts.isFunctionTypeNode(node)) { + return getAllTypes(node.type) + } + if (ts.isArrayTypeNode(node)) { + return getAllTypes(node.elementType).map(i => `[${toClassName(i)}]`) + } + throw new Error(`parseType: Failed to process ${ts.SyntaxKind[node.kind]}`) +} + +export function createType(...types: string[]): ts.TypeNode | undefined { + if (types.length > 1) { + return factory.createUnionTypeNode(toNonNullArray(types.map(type => createType(type)))) + } + const [type] = types + switch (type) { + case 'string': + return factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) + case 'number': + return factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword) + case 'boolean': + return factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword) + case 'undefined': + return factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword) + case 'null': + return factory.createLiteralTypeNode(factory.createNull()) + } + if (type) return factory.createTypeReferenceNode(factory.createIdentifier(type), undefined) +} + +export function createPromiseType(...types: string[]) { + const innerType = createType(...types) + if (!innerType) return + return factory.createTypeReferenceNode(factory.createIdentifier('Promise'), [innerType]) +} + +export function getType( + node: ts.PropertyDeclaration | ts.ParameterDeclaration | ts.MethodDeclaration, +) { + const typeFromDecorator = getTypeFromDecorator(node, 'Field') + if (typeFromDecorator) return typeFromDecorator + if (getName(node) === 'id') return 'ID' + const [type, secondType] = Array.from(new Set(getAllTypes(node.type))).filter( + i => !['null', 'undefined', 'Promise'].includes(i), + ) + if (secondType) throw new Error('Return type can not be a union type.') + if (type === 'string') return + if (type === 'number') return 'Int' + if (type) return type +} + +export function addNullability( + node: T, +): T { + return isNullable(node) + ? { + ...node, + questionToken: ts.factory.createToken(ts.SyntaxKind.QuestionToken), + } + : { + ...node, + exclamationToken: ts.factory.createToken(ts.SyntaxKind.ExclamationToken), + } +} + +export function removeNullability( + node: T, +): T { + return { + ...node, + questionToken: undefined, + exclamationToken: undefined, + } +} + +export function transformName< + T extends ts.PropertyDeclaration | ts.ParameterDeclaration | ts.MethodDeclaration | undefined, +>(node: T, transform: (name: string) => string): T { + if (!node) return undefined as T + if (node.name && ts.isIdentifier(node.name)) { + return { ...node, name: ts.factory.createIdentifier(transform(node.name.text)) } as T + } + return node +} + +export function withDefaultType( + node: ts.ParameterDeclaration | ts.MethodDeclaration | ts.PropertyDeclaration, + type: ts.TypeNode, +) { + return { ...node, type: node.type ?? type } +} + +export function createReferenceType(name: string) { + return factory.createTypeReferenceNode(factory.createIdentifier(name), undefined) +} + +export function createStringType() { + return factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) +} + +export function createFieldDecorator( + node: ts.PropertyDeclaration | ts.MethodDeclaration, + decoratorName: 'Query' | 'Mutation' | 'Field' | 'ResolveField', + context: Context, +) { + const argumentsArray: ts.Expression[] = [] + context.imports.push(createImport('@nestjs/graphql', decoratorName)) + const comment = getComment(node) ?? getCommentFromDecorator(node, decoratorName) + const type = getType(node) ?? getTypeFromDecorator(node, decoratorName) + if (type && !['string', 'boolean'].includes(type)) { argumentsArray.push( factory.createArrowFunction( undefined, @@ -62,26 +536,43 @@ export function createFieldDecorator(node: ts.PropertyDeclaration, context: Cont [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - factory.createIdentifier(node.type.typeName.text), + factory.createIdentifier(type), ), ) + if (['ID', 'INT'].includes(type)) context.imports.push(createImport('@nestjs/graphql', type)) } - if (!node?.exclamationToken) { + const isNull = + decoratorName === 'Field' ? isNullable(node) : isNullableFromDecorator(node) || isNullable(node) + + if (isNull || !!comment) { argumentsArray.push( factory.createObjectLiteralExpression( [ - factory.createPropertyAssignment( - factory.createIdentifier('nullable'), - factory.createTrue(), - ), - ], + isNull + ? factory.createPropertyAssignment( + factory.createIdentifier('nullable'), + factory.createTrue(), + ) + : (undefined as any), + comment + ? factory.createPropertyAssignment( + factory.createIdentifier('description'), + factory.createStringLiteral(comment), + ) + : (undefined as any), + ].filter(i => !!i), false, ), ) } + return factory.createDecorator( - factory.createCallExpression(factory.createIdentifier('Field'), undefined, argumentsArray), + factory.createCallExpression( + factory.createIdentifier(decoratorName), + undefined, + argumentsArray, + ), ) } @@ -136,6 +627,26 @@ export function organizeImports(sourceFile: ts.SourceFile): ts.SourceFile { return statement }) + const sortedFileNameMap = Object.keys(importStatements) + .filter(fromFile => !!importStatements[fromFile].size) + .map(from => ({ from, firstImport: Array.from(importStatements[from]).sort()[0] })) + + const sortImport = (a: any, b: any) => { + const value = a.from.localeCompare(b.from) + if (value === 0) return a.firstImport.localeCompare(b.firstImport) + return value + } + + const sortedFileNames = [ + sortedFileNameMap.filter(item => item.from.startsWith('@')).sort(sortImport), + sortedFileNameMap + .filter(item => !item.from.startsWith('@') && !item.from.startsWith('.')) + .sort(sortImport), + sortedFileNameMap.filter(item => item.from.startsWith('.')).sort(sortImport), + ] + .flat() + .map(item => item.from) + return { ...sourceFile, statements: [ @@ -143,10 +654,9 @@ export function organizeImports(sourceFile: ts.SourceFile): ts.SourceFile { .filter(fromFile => !importStatements[fromFile].size) .sort() .map(fromFile => createImport(fromFile, ...Array.from(importStatements[fromFile]).sort())), - ...Object.keys(importStatements) - .filter(fromFile => !!importStatements[fromFile].size) - .sort() - .map(fromFile => createImport(fromFile, ...Array.from(importStatements[fromFile]).sort())), + ...sortedFileNames.map(fromFile => + createImport(fromFile, ...Array.from(importStatements[fromFile]).sort()), + ), ...sourceFile.statements.filter(statement => !ts.isImportDeclaration(statement)), ] as any, } diff --git a/src/gql/input/input-generator.test.ts b/src/gql/input/input-generator.test.ts new file mode 100644 index 0000000..11c5ba2 --- /dev/null +++ b/src/gql/input/input-generator.test.ts @@ -0,0 +1,357 @@ +import { config } from '../../config' +import { toParsedOutput } from '../../util/test-util' +import { parseTSFile, prettify, printTS } from '../../util/ts-util' +import { generateInput } from './input-generator' + +async function generate(fileName: string, content: string) { + const sourceFile = parseTSFile(fileName, content) + const output = await generateInput(sourceFile) + return prettify(printTS(output, undefined, { removeComments: true })) +} + +describe('generateInput', () => { + test('should generate a model', async () => { + const output = await generate( + 'user.input.ts', + ` + class User { + id!: string + name?: string + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ID, InputType } from '@nestjs/graphql' + + @InputType() + class User { + @Field(() => ID) + id!: string + + @Field({ nullable: true }) + name?: string + } + `), + ) + }) + + test('should generate a model with array property', async () => { + const output = await generate( + 'user.input.ts', + ` + class User { + id!: string + name?: string[] + addresses?: Address[] + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ID, InputType } from '@nestjs/graphql' + + @InputType() + class User { + @Field(() => ID) + id!: string + + @Field(() => [String], { nullable: true }) + name?: string[] + + @Field(() => [Address], { nullable: true }) + addresses?: Address[] + } + `), + ) + }) + + test('should generate a model with boolean property', async () => { + const output = await generate( + 'user.input.ts', + ` + class User { + id!: string + name?: string[] + isActive?: boolean + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ID, InputType } from '@nestjs/graphql' + + @InputType() + class User { + @Field(() => ID) + id!: string + + @Field(() => [String], { nullable: true }) + name?: string[] + + @Field({ nullable: true }) + isActive?: boolean + } + `), + ) + }) + + test('should generate a model if has @InputType decorator', async () => { + const output = await generate( + 'user.ts', + ` + @InputType() + class User { + id!: string + name?: string + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ID, InputType } from '@nestjs/graphql' + + @InputType() + class User { + @Field(() => ID) + id!: string + + @Field({ nullable: true }) + name?: string + } + `), + ) + }) + + test('should generate fields with camel case', async () => { + const output = await generate( + 'user.ts', + ` + @InputType() + class User { + archivedOn?: Date + joined_on?: Date + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, InputType } from '@nestjs/graphql' + + @InputType() + class User { + @Field(() => Date, { nullable: true }) + archivedOn?: Date + + @Field(() => Date, { nullable: true }) + joinedOn?: Date + } + `), + ) + }) + + test('should infer nullability by exclamation', async () => { + const output = await generate( + 'user.ts', + ` + @InputType() + class User { + id!: string + name: string + bio?: string + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ID, InputType } from '@nestjs/graphql' + + @InputType() + class User { + @Field(() => ID) + id!: string + + @Field({ nullable: true }) + name?: string + + @Field({ nullable: true }) + bio?: string + } + `), + ) + }) + + test('should infer nullability by question mark', async () => { + config.nullableByDefault = false + const output = await generate( + 'user.ts', + ` + @InputType() + class User { + id!: string + name: string + bio?: string + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ID, InputType } from '@nestjs/graphql' + + @InputType() + class User { + @Field(() => ID) + id!: string + + @Field() + name!: string + + @Field({ nullable: true }) + bio?: string + } + `), + ) + }) + + test('should replace nullability always', async () => { + config.nullableByDefault = false + const output = await generate( + 'user.ts', + ` + @InputType() + class User { + @Field({ nullable: true }) + bio!: string + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, InputType } from '@nestjs/graphql' + + @InputType() + class User { + @Field() + bio!: string + } + `), + ) + }) + + test('should organize imports', async () => { + const output = await generate( + 'user.ts', + ` + import 'reflect-metadata' + + @InputType() + class User { + id!: string + name?: string + org?: Organization + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import 'reflect-metadata' + import { Field, ID, InputType } from '@nestjs/graphql' + + @InputType() + class User { + @Field(() => ID) + id!: string + + @Field({ nullable: true }) + name?: string + + @Field(() => Organization, { nullable: true }) + org?: Organization + } + `), + ) + }) + + test('should generate description from comments', async () => { + const output = await generate( + 'user.ts', + ` + import 'reflect-metadata' + + /** + * Defines a user + */ + @InputType() + class User { + /** + * Unique identifier for the User + */ + id!: string + + /** + * Name of the user. + * + * Expect this to be null + */ + name?: string + org?: Organization + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import 'reflect-metadata' + import { Field, ID, InputType } from '@nestjs/graphql' + + @InputType({ description: 'Defines a user' }) + class User { + @Field(() => ID, { description: 'Unique identifier for the User' }) + id!: string + + @Field({ nullable: true, description: 'Name of the user.\\n\\nExpect this to be null' }) + name?: string + + @Field(() => Organization, { nullable: true }) + org?: Organization + } + `), + ) + }) + + test('should generate description from existing decorator', async () => { + const output = await generate( + 'user.ts', + ` + import 'reflect-metadata' + + @InputType({ description: "Defines a user" }) + class User { + @Field(() => ID, { description: "Unique identifier for the User" }) + id!: string + + @Field({ nullable: true, description: "Name of the user.\\n\\nExpect this to be null" }) + name?: string + org?: Organization + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import 'reflect-metadata' + import { Field, ID, InputType } from '@nestjs/graphql' + + @InputType({ description: 'Defines a user' }) + class User { + @Field(() => ID, { description: 'Unique identifier for the User' }) + id!: string + + @Field({ nullable: true, description: 'Name of the user.\\n\\nExpect this to be null' }) + name?: string + + @Field(() => Organization, { nullable: true }) + org?: Organization + } + `), + ) + }) +}) diff --git a/src/gql/input/input-generator.ts b/src/gql/input/input-generator.ts new file mode 100644 index 0000000..778730d --- /dev/null +++ b/src/gql/input/input-generator.ts @@ -0,0 +1,54 @@ +import { toCamelCase } from 'name-util' +import ts, { isClassDeclaration } from 'typescript' +import { Context, createContext } from '../context' +import { + addDecorator, + addImports, + addNullability, + createFieldDecorator, + createObjectTypeDecorator, + hasDecorator, + organizeImports, + transformName, +} from '../gql-util' + +function processClassDeclaration(classDeclaration: ts.ClassDeclaration, context: Context) { + return ts.visitEachChild( + addDecorator( + classDeclaration, + createObjectTypeDecorator(classDeclaration, 'InputType', context), + ), + node => { + if (ts.isPropertyDeclaration(node) && ts.isIdentifier(node.name)) { + return addDecorator( + addNullability(transformName(node, toCamelCase)), + createFieldDecorator(node, 'Field', context), + ) + } + return node + }, + undefined, + ) +} + +export function isInput(sourceFile: ts.SourceFile) { + const { fileName } = sourceFile + return ( + fileName.endsWith('.input.ts') || + sourceFile.statements.some(statement => hasDecorator(statement, 'InputType')) + ) +} + +export async function generateInput(sourceFile: ts.SourceFile): Promise { + if (!isInput(sourceFile)) return sourceFile + const context = createContext() + const updatedSourcefile = ts.visitEachChild( + sourceFile, + node => { + if (isClassDeclaration(node)) return processClassDeclaration(node, context) + return node + }, + undefined, + ) + return organizeImports(addImports(updatedSourcefile, context.imports)) +} diff --git a/src/gql/model/model-generator.test.ts b/src/gql/model/model-generator.test.ts index 9f92093..a5ef545 100644 --- a/src/gql/model/model-generator.test.ts +++ b/src/gql/model/model-generator.test.ts @@ -1,3 +1,4 @@ +import { config } from '../../config' import { toParsedOutput } from '../../util/test-util' import { parseTSFile, prettify, printTS } from '../../util/ts-util' import { generateModel } from './model-generator' @@ -5,7 +6,7 @@ import { generateModel } from './model-generator' async function generate(fileName: string, content: string) { const sourceFile = parseTSFile(fileName, content) const output = await generateModel(sourceFile) - return prettify(printTS(output)) + return prettify(printTS(output, undefined, { removeComments: true })) } describe('generateModel', () => { @@ -35,6 +36,66 @@ describe('generateModel', () => { ) }) + test('should generate a model with array property', async () => { + const output = await generate( + 'user.model.ts', + ` + class User { + id!: string + name?: string[] + addresses?: Address[] + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ID, ObjectType } from '@nestjs/graphql' + + @ObjectType() + class User { + @Field(() => ID) + id!: string + + @Field(() => [String], { nullable: true }) + name?: string[] + + @Field(() => [Address], { nullable: true }) + addresses?: Address[] + } + `), + ) + }) + + test('should generate a model with boolean property', async () => { + const output = await generate( + 'user.model.ts', + ` + class User { + id!: string + name?: string[] + isActive?: boolean + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ID, ObjectType } from '@nestjs/graphql' + + @ObjectType() + class User { + @Field(() => ID) + id!: string + + @Field(() => [String], { nullable: true }) + name?: string[] + + @Field({ nullable: true }) + isActive?: boolean + } + `), + ) + }) + test('should generate a model if has @ObjectType decorator', async () => { const output = await generate( 'user.ts', @@ -120,6 +181,63 @@ describe('generateModel', () => { ) }) + test('should infer nullability by question mark', async () => { + config.nullableByDefault = false + const output = await generate( + 'user.ts', + ` + @ObjectType() + class User { + id!: string + name: string + bio?: string + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ID, ObjectType } from '@nestjs/graphql' + + @ObjectType() + class User { + @Field(() => ID) + id!: string + + @Field() + name!: string + + @Field({ nullable: true }) + bio?: string + } + `), + ) + }) + + test('should replace nullability always', async () => { + config.nullableByDefault = false + const output = await generate( + 'user.ts', + ` + @ObjectType() + class User { + @Field({ nullable: true }) + bio!: string + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Field, ObjectType } from '@nestjs/graphql' + + @ObjectType() + class User { + @Field() + bio!: string + } + `), + ) + }) + test('should organize imports', async () => { const output = await generate( 'user.ts', @@ -153,4 +271,87 @@ describe('generateModel', () => { `), ) }) + + test('should generate description from comments', async () => { + const output = await generate( + 'user.ts', + ` + import 'reflect-metadata' + + /** + * Defines a user + */ + @ObjectType() + class User { + /** + * Unique identifier for the User + */ + id!: string + + /** + * Name of the user. + * + * Expect this to be null + */ + name?: string + org?: Organization + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import 'reflect-metadata' + import { Field, ID, ObjectType } from '@nestjs/graphql' + + @ObjectType({ description: 'Defines a user' }) + class User { + @Field(() => ID, { description: 'Unique identifier for the User' }) + id!: string + + @Field({ nullable: true, description: 'Name of the user.\\n\\nExpect this to be null' }) + name?: string + + @Field(() => Organization, { nullable: true }) + org?: Organization + } + `), + ) + }) + + test('should generate description from existing decorator', async () => { + const output = await generate( + 'user.ts', + ` + import 'reflect-metadata' + + @ObjectType({ description: "Defines a user" }) + class User { + @Field(() => ID, { description: "Unique identifier for the User" }) + id!: string + + @Field({ nullable: true, description: "Name of the user.\\n\\nExpect this to be null" }) + name?: string + org?: Organization + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import 'reflect-metadata' + import { Field, ID, ObjectType } from '@nestjs/graphql' + + @ObjectType({ description: 'Defines a user' }) + class User { + @Field(() => ID, { description: 'Unique identifier for the User' }) + id!: string + + @Field({ nullable: true, description: 'Name of the user.\\n\\nExpect this to be null' }) + name?: string + + @Field(() => Organization, { nullable: true }) + org?: Organization + } + `), + ) + }) }) diff --git a/src/gql/model/model-generator.ts b/src/gql/model/model-generator.ts index a3e63b0..b2e940e 100644 --- a/src/gql/model/model-generator.ts +++ b/src/gql/model/model-generator.ts @@ -1,34 +1,28 @@ +import { toCamelCase } from 'name-util' import ts, { isClassDeclaration } from 'typescript' import { Context, createContext } from '../context' -import { toCamelCase } from 'name-util' import { addDecorator, addImports, + addNullability, createFieldDecorator, createObjectTypeDecorator, hasDecorator, organizeImports, + transformName, } from '../gql-util' function processClassDeclaration(classDeclaration: ts.ClassDeclaration, context: Context) { return ts.visitEachChild( - addDecorator(classDeclaration, createObjectTypeDecorator(context)), + addDecorator( + classDeclaration, + createObjectTypeDecorator(classDeclaration, 'ObjectType', context), + ), node => { if (ts.isPropertyDeclaration(node) && ts.isIdentifier(node.name)) { - const isNullable = !node.exclamationToken return addDecorator( - { - ...node, - ...(isNullable - ? { - questionToken: ts.factory.createToken(ts.SyntaxKind.QuestionToken), - } - : { - exclamationToken: ts.factory.createToken(ts.SyntaxKind.ExclamationToken), - }), - name: ts.factory.createIdentifier(toCamelCase(node.name.text)), - }, - createFieldDecorator(node, context), + addNullability(transformName(node, toCamelCase)), + createFieldDecorator(node, 'Field', context), ) } return node diff --git a/src/gql/organize-import.test.ts b/src/gql/organize-import.test.ts new file mode 100644 index 0000000..d826911 --- /dev/null +++ b/src/gql/organize-import.test.ts @@ -0,0 +1,74 @@ +import { toParsedOutput } from '../util/test-util' +import { parseTS, printTS, prettify } from '../util/ts-util' +import { organizeImports } from './gql-util' + +describe('organizeImport', () => { + test('to organize imports', async () => { + const sourceFile = parseTS(` + import { FieldResolver } from '../../common/field-resolver-type' + import { GQLContext } from '../../context' + import { ReturnService } from '../return/return.service' + import { Shipment } from './shipment.model' + import { ShipmentType, TransactionStatus } from './shipment.type' + import { Context, Parent, ResolveField, Resolver } from '@nestjs/graphql' + `) + const code = await prettify(printTS(organizeImports(sourceFile))) + expect(toParsedOutput(code)).toEqual( + toParsedOutput(` + import { Context, Parent, ResolveField, Resolver } from '@nestjs/graphql' + import { FieldResolver } from '../../common/field-resolver-type' + import { GQLContext } from '../../context' + import { ReturnService } from '../return/return.service' + import { Shipment } from './shipment.model' + import { ShipmentType, TransactionStatus } from './shipment.type' + `), + ) + }) + + test('to organize imports', async () => { + const sourceFile = parseTS(` + import { Field, ObjectType } from '@nestjs/graphql' + import { Model } from '../../common/model' + import { RecoveryMethod } from '../../recovery-method/recovery-method.model' + import { RecoveryMethodType } from '../../recovery-method/recovery-method.type' + import { ReturnStatus } from './return-status.enum' + import { Shipment } from '../shipment/shipment.model' + import { ShipmentType } from '../shipment/shipment.type' + import { User } from '../../user/user.model' + `) + const code = await prettify(printTS(organizeImports(sourceFile))) + expect(toParsedOutput(code)).toEqual( + toParsedOutput(` + import { Field, ObjectType } from '@nestjs/graphql' + import { Model } from '../../common/model' + import { RecoveryMethod } from '../../recovery-method/recovery-method.model' + import { RecoveryMethodType } from '../../recovery-method/recovery-method.type' + import { User } from '../../user/user.model' + import { Shipment } from '../shipment/shipment.model' + import { ShipmentType } from '../shipment/shipment.type' + import { ReturnStatus } from './return-status.enum' + `), + ) + }) + + test('to organize imports', async () => { + const sourceFile = parseTS(` + import { Context, Parent, ResolveField, Resolver } from '@nestjs/graphql' + import { GQLContext } from '../../context' + import { LocationService } from '../location/location.service' + import { StorageLocation } from './storage-location.model' + import { StorageLocationType } from './storage-location.type' + import { FieldResolver } from 'src/common/field-resolver-type'`) + const code = await prettify(printTS(organizeImports(sourceFile))) + expect(toParsedOutput(code)).toEqual( + toParsedOutput(` + import { Context, Parent, ResolveField, Resolver } from '@nestjs/graphql' + import { FieldResolver } from 'src/common/field-resolver-type' + import { GQLContext } from '../../context' + import { LocationService } from '../location/location.service' + import { StorageLocation } from './storage-location.model' + import { StorageLocationType } from './storage-location.type' + `), + ) + }) +}) diff --git a/src/gql/resolver/resolver-generator.test.ts b/src/gql/resolver/resolver-generator.test.ts new file mode 100644 index 0000000..268b4e5 --- /dev/null +++ b/src/gql/resolver/resolver-generator.test.ts @@ -0,0 +1,475 @@ +import { toParsedOutput } from '../../util/test-util' +import { parseTSFile, prettify, printTS } from '../../util/ts-util' +import { generateResolver } from './resolver-generator' + +async function generate(fileName: string, content: string) { + const sourceFile = parseTSFile(fileName, content) + const output = await generateResolver(sourceFile) + return prettify(printTS(output, undefined, { removeComments: true })) +} + +describe('generateResolver', () => { + test('should convert a variable with return type as function to a method ', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver { } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Resolver } from '@nestjs/graphql' + + @Resolver() + export class UserResolver {} + `), + ) + }) + + test('should remove ...args', async () => { + const output = await generate( + 'user.resolver.ts', + ` + @Resolver(() => UserModel) + class UserResolver { + createdBy: (...args: any[]) => User | Promise + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Parent, ResolveField, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver { + @ResolveField() + createdBy( + @Parent() + parent: unknown, + ) {} + } + `), + ) + }) + + test('should generate paginated model', async () => { + const output = await generate( + 'user.resolver.ts', + ` + @Resolver(() => UserModel) + class UserResolver { + @ResolveField() + findAll(context, page?: Page): Users { } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Context, ResolveField, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver { + @ResolveField(() => Users) + findAll( + @Context() + context: GQLContext, + + @Args({ name: 'page', type: () => Page, nullable: true }) + page?: Page, + ): Users {} + } + `), + ) + }) + + test('should use the correct model name', async () => { + const output = await generate( + 'user.resolver.ts', + ` + @Resolver(() => UserModel) + class UserResolver { + createdAt(context): Date | null { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Context, Query, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver { + @Query(() => Date, { nullable: true }) + createdAt( + @Context() + context: GQLContext, + ): Date | null {} + } + `), + ) + }) + + test('should use the correct typename name', async () => { + const output = await generate( + 'user.resolver.ts', + ` + @Resolver(() => UserModel) + class UserResolver { + createdAt(parent: UserAPIType, context): Date | null { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Context, Parent, ResolveField, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @ResolveField(() => Date, { nullable: true }) + createdAt( + @Parent() + parent: UserAPIType, + + @Context() + context: GQLContext, + ): Date | null {} + } + `), + ) + }) + + test('should resolve types from FieldResolver', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @ResolveField() + createdAt(parent): Date { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Parent, ResolveField, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @ResolveField(() => Date) + createdAt( + @Parent() + parent: UserAPIType, + ): Date {} + } + `), + ) + }) + + test('should resolve types from FieldResolver', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @Query() + user(context): User { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Context, Query, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @Query(() => User) + user( + @Context() + context: GQLContext, + ): User {} + } + `), + ) + }) + + test('should add @Args type to each parameter', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @Query() + user(context, id: string): User | null { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Context, ID, Query, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @Query(() => User, { nullable: true }) + user( + @Context() + context: GQLContext, + + @Args({ name: 'id', type: () => ID }) + id: string, + ): User | null {} + } + `), + ) + }) + + test('should add @Parent to query or mutation', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @Query(() => User) + user(id, parent, context): User { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Context, ID, Parent, Query, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @Query(() => User) + user( + @Args({ name: 'id', type: () => ID }) + id: string, + + @Parent() + parent: UserAPIType, + + @Context() + context: GQLContext, + ): User {} + } + `), + ) + }) + + test('should add @Args type with nullability', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @Query() + user(context, id?: string): User | null { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Context, ID, Query, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @Query(() => User, { nullable: true }) + user( + @Context() + context: GQLContext, + + @Args({ name: 'id', type: () => ID, nullable: true }) + id?: string, + ): User | null {} + } + `), + ) + }) + + test('should preserve the existing type the @ResolveField() if type is unknown', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @ResolveField(() => UserModel, { nullable: true }) + user(context, id?: string) { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Context, ID, ResolveField, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @ResolveField(() => UserModel, { nullable: true }) + user( + @Context() + context: GQLContext, + + @Args({ name: 'id', type: () => ID, nullable: true }) + id?: string, + ) {} + } + `), + ) + }) + + test('should replace the existing type the @ResolveField() if type exists', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @ResolveField(() => UserModel, { nullable: true }) + user(context, id?: string): User | null { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Context, ID, ResolveField, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @ResolveField(() => User, { nullable: true }) + user( + @Context() + context: GQLContext, + + @Args({ name: 'id', type: () => ID, nullable: true }) + id?: string, + ): User | null {} + } + `), + ) + }) + + test('should create mutation function', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @Mutation(() => User) + user(context, userId, parent): User { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Context, Mutation, Parent, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @Mutation(() => User) + user( + @Context() + context: GQLContext, + + @Args('userId') + userId: string, + + @Parent() + parent: UserAPIType, + ): User {} + } + `), + ) + }) + + test('should create mutation with nullability', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @Mutation() + createUser(userId?: string): User | null { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Mutation, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @Mutation(() => User, { nullable: true }) + createUser( + @Args('userId', { nullable: true }) + userId?: string, + ): User | null {} + } + `), + ) + }) + + test('should create query without Promise if not async', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @Query() + user(userId?: string): Promise { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Query, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @Query(() => User, { nullable: true }) + user( + @Args('userId', { nullable: true }) + userId?: string, + ): User | null {} + } + `), + ) + }) + + test('should create query with Promise if async', async () => { + const output = await generate( + 'user.resolver.ts', + ` + class UserResolver implements FieldResolver { + @Query() + async user(userId?: string): Promise { + + } + } + `, + ) + expect(toParsedOutput(output)).toBe( + toParsedOutput(` + import { Args, Query, Resolver } from '@nestjs/graphql' + + @Resolver(() => UserModel) + export class UserResolver implements FieldResolver { + @Query(() => User, { nullable: true }) + async user( + @Args('userId', { nullable: true }) + userId?: string, + ): Promise {} + } + `), + ) + }) +}) diff --git a/src/gql/resolver/resolver-generator.ts b/src/gql/resolver/resolver-generator.ts new file mode 100644 index 0000000..c114b56 --- /dev/null +++ b/src/gql/resolver/resolver-generator.ts @@ -0,0 +1,251 @@ +import { toCamelCase } from 'name-util' +import { toNonNullArray } from 'tsds-tools' +import ts, { SyntaxKind, factory, isClassDeclaration } from 'typescript' +import { Context, createContext } from '../context' +import { + addDecorator, + addExport, + addImports, + conditional, + convertToMethod, + createArgsDecorator, + createContextDecorator, + createFieldDecorator, + createImport, + createParentDecorator, + createPromiseType, + createReferenceType, + createResolverDecorator, + createStringType, + createType, + getAllParameters, + getAllTypes, + getName, + getParameterType, + getType, + getTypeFromDecorator, + hasDecorator, + hasImplementationByName, + hasParameter, + isAsync, + organizeImports, + removeNullability, + transformName, + withDefaultType, +} from '../gql-util' + +function processParameters( + node: ts.MethodDeclaration, + parentType: string, + context: Context, +): ts.MethodDeclaration { + return { + ...node, + parameters: toNonNullArray( + node.parameters.map(parameter => { + if (!!parameter.dotDotDotToken) { + const type = getType(node) + return addDecorator( + factory.createParameterDeclaration( + undefined, + undefined, + factory.createIdentifier('parent'), + undefined, + factory.createTypeReferenceNode( + factory.createIdentifier(parentType ?? type ?? 'unknown'), + undefined, + ), + undefined, + ), + createParentDecorator(context), + ) + } + const name = getName(parameter) + if (name === 'parent') { + return addDecorator( + withDefaultType(parameter, createReferenceType(parentType)), + createParentDecorator(context), + ) + } else if (name === 'context') { + context.imports.push(createImport('@nestjs/graphql', 'Context')) + return addDecorator( + withDefaultType(parameter, createReferenceType('GQLContext')), + createContextDecorator(), + ) + } + return addDecorator( + withDefaultType(parameter, createStringType()), + createArgsDecorator(parameter, context), + ) + }), + ) as any, + } +} + +function processReturnType(node: ts.MethodDeclaration, context: Context): ts.MethodDeclaration { + if (!node.type) return node + const types: Array = [] + ts.visitEachChild( + node.type, + node => { + if (ts.isIdentifier(node)) { + types.push(node.text) + } + if (ts.isTypeNode(node)) { + types.push(getAllTypes(node)) + } + return node + }, + undefined, + ) + const uniqueTypes = toNonNullArray(Array.from(new Set(types.flat()))) + const typesWithoutPromise = uniqueTypes.filter(i => i !== 'Promise') + const hasPromise = isAsync(node) + return { + ...node, + type: hasPromise + ? createPromiseType(...typesWithoutPromise) + : createType(...typesWithoutPromise), + } +} + +function addImplementsFieldResolver( + node: T, + fromType: string, + toType: string, +): T { + return { + ...node, + heritageClauses: [ + factory.createHeritageClause(ts.SyntaxKind.ImplementsKeyword, [ + factory.createExpressionWithTypeArguments(factory.createIdentifier('FieldResolver'), [ + factory.createTypeReferenceNode(factory.createIdentifier(fromType), undefined), + factory.createTypeReferenceNode(factory.createIdentifier(toType), undefined), + ]), + ]), + ], + } +} + +function getTypesFromFieldResolverImplementation(node: ts.ClassDeclaration) { + const heritageClause = node.heritageClauses?.find( + clause => + !!clause.types.find( + type => + ts.isExpressionWithTypeArguments(type) && + ts.isIdentifier(type.expression) && + type.expression.text === 'FieldResolver', + ), + ) + const [modelType, originType] = + heritageClause?.types.find( + type => + ts.isExpressionWithTypeArguments(type) && + ts.isIdentifier(type.expression) && + type.expression.text === 'FieldResolver', + )?.typeArguments ?? [] + return { + modelType: + modelType && ts.isTypeReferenceNode(modelType) && ts.isIdentifier(modelType.typeName) + ? modelType.typeName.text + : undefined, + parentType: + originType && ts.isTypeReferenceNode(originType) && ts.isIdentifier(originType.typeName) + ? originType.typeName.text + : undefined, + } +} + +function getTypes(node: ts.ClassDeclaration) { + const { modelType, parentType } = getTypesFromFieldResolverImplementation(node) + const name = getName(node) + ?.trim() + ?.replace(/Resolver$/, '') + return [ + modelType ?? getTypeFromDecorator(node, 'Resolver') ?? name, + parentType ?? getTypeNameFromParameters(node), + ] +} + +function getTypeNameFromParameters(node: ts.ClassDeclaration) { + return toNonNullArray( + getAllParameters(node).map(parameter => + getName(parameter) === 'parent' ? getParameterType(parameter) : undefined, + ), + )[0] +} + +function getFieldDecoratorType(node: ts.Node) { + if ( + hasDecorator(node, 'ResolveField') || + ((hasParameter(ts.isFunctionTypeNode(node) ? node.type : node, 'parent') || + hasParameter(ts.isFunctionTypeNode(node) ? node.type : node, 'args')) && + !hasDecorator(node, 'Query') && + !hasDecorator(node, 'Mutation')) + ) { + return 'ResolveField' + } + if (hasDecorator(node, 'Mutation')) return 'Mutation' + return 'Query' +} + +function processClassDeclaration(classDeclaration: ts.ClassDeclaration, context: Context) { + const [modelType, parentType] = getTypes(classDeclaration) + const typeFromDecorator = getTypeFromDecorator(classDeclaration, 'Resolver') + const hasFieldResolver = hasImplementationByName(classDeclaration, 'FieldResolver') + return ts.visitEachChild( + addExport( + addDecorator( + conditional( + (hasFieldResolver || !!typeFromDecorator) && !!parentType, + () => addImplementsFieldResolver(classDeclaration, modelType, parentType), + classDeclaration, + ), + createResolverDecorator(modelType, hasFieldResolver || !!typeFromDecorator, context), + ), + ), + node => { + if ( + ts.isMethodDeclaration(node) || + (ts.isPropertyDeclaration(node) && node.type && ts.isFunctionTypeNode(node.type)) + ) { + const method = convertToMethod(node as any, ts.isPropertyDeclaration(node)) + if (method) { + const fieldDecoratorType = getFieldDecoratorType(method) + return addDecorator( + processParameters( + processReturnType(removeNullability(transformName(method, toCamelCase)), context), + parentType, + context, + ), + createFieldDecorator(method, fieldDecoratorType, context), + ) + } + } + return node + }, + undefined, + ) +} + +export function isResolver(sourceFile: ts.SourceFile) { + const { fileName } = sourceFile + return ( + fileName.endsWith('.resolver.ts') || + sourceFile.statements.some(statement => hasDecorator(statement, 'Resolver')) + ) +} + +export async function generateResolver(sourceFile: ts.SourceFile): Promise { + if (!isResolver(sourceFile)) return sourceFile + const context = createContext() + const updatedSourcefile = ts.visitEachChild( + sourceFile, + node => { + if (isClassDeclaration(node)) return processClassDeclaration(node, context) + return node + }, + undefined, + ) + return organizeImports(addImports(updatedSourcefile, context.imports)) +} diff --git a/src/index.ts b/src/index.ts index 0e014d8..8d6f804 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { cli, runCli } from 'clifer' import generate from './generate/generate-command' +import create from './create/create-command' const command = cli('gql-assist') .description( @@ -7,5 +8,6 @@ const command = cli('gql-assist') ) .version('1.0') .command(generate) + .command(create) runCli(command) diff --git a/src/util/prettier-util.ts b/src/util/prettier-util.ts new file mode 100644 index 0000000..b96e8e5 --- /dev/null +++ b/src/util/prettier-util.ts @@ -0,0 +1,55 @@ +import * as fs from 'fs-extra' +import { resolve } from 'path' + +function sanitizePetterOptions(options: Record) { + return Object.keys(options) + .filter(key => !['plugins'].includes(key)) + .reduce((a, b) => ({ ...a, [b]: options[b] }), {}) +} + +export function getPrettierConfig() { + const prettierConfigs = ['.prettierrc', '.prettierrc.json'] + let index = 0 + let prettierConfigFile = prettierConfigs[index] + while (prettierConfigFile) { + try { + const file = resolve(process.cwd(), prettierConfigFile) + if (fs.existsSync(file)) { + return sanitizePetterOptions(JSON.parse(fs.readFileSync(file, 'utf8'))) + } + } catch (e) { + console.error(e) + // do nothing + } + prettierConfigFile = prettierConfigs[++index] + } + try { + const packageJSON = JSON.parse(fs.readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) + return sanitizePetterOptions(packageJSON.prettier) + } catch (e) { + // do nothing + } + return { + arrowParens: 'avoid', + bracketSpacing: true, + endOfLine: 'lf', + htmlWhitespaceSensitivity: 'css', + jsxBracketSameLine: false, + jsxSingleQuote: true, + printWidth: 100, + proseWrap: 'always', + requirePragma: false, + semi: false, + singleQuote: true, + tabWidth: 2, + trailingComma: 'all', + useTabs: false, + } +} + +export function getPrettierOptions() { + return { + ...getPrettierConfig(), + parser: 'typescript', + } +} diff --git a/src/util/run-command.ts b/src/util/run-command.ts new file mode 100644 index 0000000..287b06c --- /dev/null +++ b/src/util/run-command.ts @@ -0,0 +1,47 @@ +import { yellow } from 'chalk' +import { exec } from 'shelljs' + +interface Options { + silent?: boolean + fatal?: boolean + cwd?: string +} + +function showCommand(command: string) { + console.log( + yellow( + `\n$ ${command + .split(/\n|\s/) + .map(i => i.trim()) + .filter(i => !!i) + .join(' ')}`, + ), + ) +} + +export async function isValidCommand(command: string) { + const [response] = await runCommand(`which ${command}`, { silent: true }) + if (response.includes('not found')) return false + return true +} + +export async function ifValidCommand(command: string) { + if (!(await isValidCommand(command))) return + return command +} + +export function runCommand( + command: string, + { fatal = true, silent = false, cwd = process.cwd() }: Options = {}, +) { + if (!silent) showCommand(command) + return new Promise((resolve, reject) => { + exec(command, { silent, fatal, cwd }, async (code, stdout, stderr) => { + if (code !== 0) { + reject((stderr ?? stdout ?? '').split('\n')) + } else { + resolve((stdout ?? stderr ?? '').split('\n')) + } + }) + }) +} diff --git a/src/util/ts-util.ts b/src/util/ts-util.ts index f2f6017..cdf96a0 100644 --- a/src/util/ts-util.ts +++ b/src/util/ts-util.ts @@ -22,13 +22,16 @@ import { } from 'typescript' import { format } from 'prettier' +import { getPrettierOptions } from './prettier-util' + +const prettierOptions = getPrettierOptions() export function readAndParseTSFile(filePath: string) { return parseTSFile(filePath, readFileSync(filePath, 'utf8')) } export function parseTSFile(filePath: string, content = '') { - return createSourceFile(basename(filePath), content, ScriptTarget.Latest, false, ScriptKind.TSX) + return createSourceFile(basename(filePath), content, ScriptTarget.Latest, true, ScriptKind.TSX) } export function parseTS(content = '') { @@ -87,9 +90,14 @@ const printerOptions: PrinterOptions = { export function printTS( node: Node | undefined, sourceFile: SourceFile = parseTSFile('./test.ts', ''), + options?: PrinterOptions, ) { if (node == undefined) return '' - return createPrinter(printerOptions, {}).printNode(EmitHint.Unspecified, node, sourceFile) + return createPrinter({ ...printerOptions, ...options }, {}).printNode( + EmitHint.Unspecified, + node, + sourceFile, + ) } export function prettify(code: string) { @@ -114,21 +122,5 @@ export function prettify(code: string) { .replace(new RegExp(`\\/\\/${COMMENT}`, 'g'), '') .replace(new RegExp(`^function`, 'g'), '\nfunction') .replace(new RegExp(`^declare function`, 'g'), '\ndeclare function') - return format(formattedCode, { - arrowParens: 'avoid', - bracketSpacing: true, - endOfLine: 'lf', - htmlWhitespaceSensitivity: 'css', - bracketSameLine: false, - jsxSingleQuote: true, - printWidth: 100, - proseWrap: 'always', - requirePragma: false, - semi: false, - singleQuote: true, - tabWidth: 2, - trailingComma: 'all', - useTabs: false, - parser: 'typescript', - } as any) + return format(formattedCode, prettierOptions) }