diff --git a/package-lock.json b/package-lock.json index 410c3d4a..ede9a181 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@faker-js/faker": "^7.6.0", "locter": "^1.0.9", + "pascal-case": "^3.1.2", "rapiq": "^0.8.0", "reflect-metadata": "^0.1.13", "yargs": "^17.7.1" @@ -8305,6 +8306,14 @@ "dev": true, "peer": true }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -8684,6 +8693,15 @@ "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", "dev": true }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-abi": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.15.0.tgz", @@ -11737,6 +11755,15 @@ "parse5": "^6.0.1" } }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -13677,8 +13704,7 @@ "node_modules/tslib": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", - "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", - "dev": true + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" }, "node_modules/tsutils": { "version": "3.21.0", @@ -20656,6 +20682,14 @@ "dev": true, "peer": true }, + "lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "requires": { + "tslib": "^2.0.3" + } + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -20931,6 +20965,15 @@ "integrity": "sha512-EZSPZB70jiVsivaBLYDCyntd5eH8NTSMOn3rB+HxwdmKThGELLdYv8qVIMWvZEFy9w8ZZpW9h9OB32l1rGtj7g==", "dev": true }, + "no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "requires": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node-abi": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.15.0.tgz", @@ -23080,6 +23123,15 @@ "parse5": "^6.0.1" } }, + "pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "requires": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -24529,8 +24581,7 @@ "tslib": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", - "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", - "dev": true + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" }, "tsutils": { "version": "3.21.0", diff --git a/package.json b/package.json index ace9e0f9..8b2d1b5e 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dependencies": { "@faker-js/faker": "^7.6.0", "locter": "^1.0.9", + "pascal-case": "^3.1.2", "rapiq": "^0.8.0", "reflect-metadata": "^0.1.13", "yargs": "^17.7.1" diff --git a/src/database/utils/index.ts b/src/database/utils/index.ts index 4398666d..a75ba901 100644 --- a/src/database/utils/index.ts +++ b/src/database/utils/index.ts @@ -1,3 +1,5 @@ export * from './context'; +export * from './migration'; export * from './query'; export * from './schema'; +export * from './type'; diff --git a/src/database/utils/migration.ts b/src/database/utils/migration.ts new file mode 100644 index 00000000..ec7bb601 --- /dev/null +++ b/src/database/utils/migration.ts @@ -0,0 +1,129 @@ +import { pascalCase } from 'pascal-case'; +import path from 'node:path'; +import fs from 'node:fs'; +import process from 'node:process'; +import { MigrationGenerateCommand } from 'typeorm/commands/MigrationGenerateCommand'; +import type { MigrationGenerateCommandContext, MigrationGenerateResult } from './type'; + +class GenerateCommand extends MigrationGenerateCommand { + static prettify(query: string) { + return this.prettifyQuery(query); + } +} + +function queryParams(parameters: any[] | undefined): string { + if (!parameters || !parameters.length) { + return ''; + } + + return `, ${JSON.stringify(parameters)}`; +} + +function buildTemplate( + name: string, + timestamp: number, + upStatements: string[], + downStatements: string[], +): string { + const migrationName = `${pascalCase(name)}${timestamp}`; + + const up = upStatements.map((statement) => ` ${statement}`); + const down = downStatements.map((statement) => ` ${statement}`); + + return `import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ${migrationName} implements MigrationInterface { + name = '${migrationName}'; + + public async up(queryRunner: QueryRunner): Promise { +${up.join(` +`)} + } + public async down(queryRunner: QueryRunner): Promise { +${down.join(` +`)} + } +} +`; +} + +export async function generateMigration( + context: MigrationGenerateCommandContext, +) : Promise { + context.name = context.name || 'Default'; + + const timestamp = context.timestamp || new Date().getTime(); + const fileName = `${timestamp}-${context.name}.ts`; + + const { dataSource } = context; + + const up: string[] = []; const + down: string[] = []; + + if (!dataSource.isInitialized) { + await dataSource.initialize(); + } + + const sqlInMemory = await dataSource.driver.createSchemaBuilder().log(); + + if (context.prettify) { + sqlInMemory.upQueries.forEach((upQuery) => { + upQuery.query = GenerateCommand.prettify( + upQuery.query, + ); + }); + sqlInMemory.downQueries.forEach((downQuery) => { + downQuery.query = GenerateCommand.prettify( + downQuery.query, + ); + }); + } + + sqlInMemory.upQueries.forEach((upQuery) => { + up.push(`await queryRunner.query(\`${upQuery.query.replace(/`/g, '\\`')}\`${queryParams(upQuery.parameters)});`); + }); + + sqlInMemory.downQueries.forEach((downQuery) => { + down.push(`await queryRunner.query(\`${downQuery.query.replace(/`/g, '\\`')}\`${queryParams(downQuery.parameters)});`); + }); + + await dataSource.destroy(); + + if ( + up.length === 0 && + down.length === 0 + ) { + return { up, down }; + } + + const content = buildTemplate(context.name, timestamp, up, down.reverse()); + + if (!context.preview) { + let directoryPath : string; + if (context.directoryPath) { + if (!path.isAbsolute(context.directoryPath)) { + directoryPath = path.join(process.cwd(), context.directoryPath); + } else { + directoryPath = context.directoryPath; + } + } else { + directoryPath = path.join(process.cwd(), 'migrations'); + } + + try { + await fs.promises.access(directoryPath, fs.constants.R_OK | fs.constants.W_OK); + } catch (e) { + await fs.promises.mkdir(directoryPath, { recursive: true }); + } + + const filePath = path.join(directoryPath, fileName); + + await fs.promises.writeFile(filePath, content, { encoding: 'utf-8' }); + } + + return { + up, + down, + content, + }; +} diff --git a/src/database/utils/type.ts b/src/database/utils/type.ts new file mode 100644 index 00000000..98a0df34 --- /dev/null +++ b/src/database/utils/type.ts @@ -0,0 +1,37 @@ +import type { DataSource } from 'typeorm'; + +export type MigrationGenerateResult = { + up: string[], + down: string[], + content?: string +}; + +export type MigrationGenerateCommandContext = { + /** + * Directory where the migration(s) should be stored. + */ + directoryPath?: string, + /** + * Name of the migration class. + */ + name?: string, + /** + * DataSource used for reference of existing schema. + */ + dataSource: DataSource, + + /** + * Timestamp in milliseconds. + */ + timestamp?: number, + + /** + * Prettify sql statements. + */ + prettify?: boolean, + + /** + * Only return up- & down-statements instead of backing up the migration to the file system. + */ + preview?: boolean +}; diff --git a/test/data/typeorm/data-source.ts b/test/data/typeorm/data-source.ts index 7523216b..428dd120 100644 --- a/test/data/typeorm/data-source.ts +++ b/test/data/typeorm/data-source.ts @@ -1,5 +1,5 @@ import {DataSource, DataSourceOptions} from "typeorm"; -import path from "path"; +import path from "node:path"; import {User} from "../entity/user"; import {SeederOptions} from "../../../src"; diff --git a/test/unit/database/migration.spec.ts b/test/unit/database/migration.spec.ts new file mode 100644 index 00000000..b704ee18 --- /dev/null +++ b/test/unit/database/migration.spec.ts @@ -0,0 +1,31 @@ +import {DataSource, DataSourceOptions} from "typeorm"; +import {generateMigration} from "../../../src"; +import {User} from "../../data/entity/user"; + +describe('src/database/migration', () => { + it('should generate migration file', async () => { + const options : DataSourceOptions = { + type: 'better-sqlite3', + entities: [User], + database: ':memory:', + extra: { + charset: "UTF8_GENERAL_CI" + } + } + const dataSource = new DataSource(options); + + const output = await generateMigration({ + dataSource, + preview: true + }); + + expect(output).toBeDefined(); + expect(output.up).toBeDefined(); + expect(output.up.length).toBeGreaterThanOrEqual(1); + expect(output.up[0]).toEqual('await queryRunner.query(`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "firstName" varchar NOT NULL, "lastName" varchar NOT NULL, "email" varchar NOT NULL, "foo" varchar NOT NULL)`);') + + expect(output.down).toBeDefined(); + expect(output.down.length).toBeGreaterThanOrEqual(1); + expect(output.down[0]).toEqual('await queryRunner.query(`DROP TABLE "user"`);') + }) +})