diff --git a/assets/templates/migration/create_down.sql b/assets/templates/migration/create_down.sql new file mode 100644 index 00000000..ae1cd5d2 --- /dev/null +++ b/assets/templates/migration/create_down.sql @@ -0,0 +1 @@ +DROP TABLE {{table}}; diff --git a/assets/templates/migration/create_up.sql b/assets/templates/migration/create_up.sql new file mode 100644 index 00000000..6951675e --- /dev/null +++ b/assets/templates/migration/create_up.sql @@ -0,0 +1,6 @@ +-- +-- Create {{table}} table. +-- +CREATE TABLE {{table}} ( + id INT PRIMARY KEY +); diff --git a/package.json b/package.json index bfb0be9a..63d12757 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "files": [ "/bin/run", "/bin/run.cmd", - "/lib" + "/lib", + "/assets" ], "bin": { "sync-db": "./bin/run" diff --git a/src/commands/make.ts b/src/commands/make.ts new file mode 100644 index 00000000..c81628c4 --- /dev/null +++ b/src/commands/make.ts @@ -0,0 +1,55 @@ +import { loadConfig } from '../config'; +import { printLine } from '../util/io'; +import { Command, flags } from '@oclif/command'; +import Configuration from '../domain/Configuration'; +import * as fileMakerService from '../service/fileMaker'; + +class Make extends Command { + static description = 'Make migration files from the template.'; + + static args = [{ name: 'name', description: 'Object or filename to generate.', required: true }]; + static flags = { + type: flags.string({ + char: 't', + helpValue: 'TYPE', + description: 'Type of file to generate.', + default: 'migration', + options: ['migration', 'view', 'procedure', 'function'] + }) + }; + + /** + * CLI command execution handler. + * + * @returns {Promise} + */ + async run(): Promise { + const { args, flags: parsedFlags } = this.parse(Make); + const config = await loadConfig(); + const list = await this.makeFiles(config, args.name, parsedFlags.type); + + for (const filename of list) { + await printLine(`Created ${filename}`); + } + } + + /** + * Make files based on the given name and type. + * + * @param {Configuration} config + * @param {string} name + * @param {string} [type] + * @returns {Promise} + */ + async makeFiles(config: Configuration, name: string, type?: string): Promise { + switch (type) { + case 'migration': + return fileMakerService.makeMigration(config, name); + + default: + throw new Error(`Unsupported file type ${type}.`); + } + } +} + +export default Make; diff --git a/src/migration/service/knexMigrator.ts b/src/migration/service/knexMigrator.ts index f9c02fa1..96290897 100644 --- a/src/migration/service/knexMigrator.ts +++ b/src/migration/service/knexMigrator.ts @@ -80,12 +80,7 @@ export async function resolveMigrationContext( log(`Initialize migration context [sourceType=${config.migration.sourceType}]`); - const { basePath, migration } = config; - - // Migration directory could be absolute OR could be relative to the basePath. - const migrationPath = path.isAbsolute(migration.directory) - ? migration.directory - : path.join(basePath, migration.directory); + const migrationPath = getMigrationPath(config); switch (config.migration.sourceType) { case 'sql': @@ -101,3 +96,19 @@ export async function resolveMigrationContext( throw new Error(`Unsupported migration.sourceType value "${config.migration.sourceType}".`); } } + +/** + * Get Migration directory path. + * + * @param {Configuration} config + * @returns {string} + */ +export function getMigrationPath(config: Configuration): string { + const { basePath, migration } = config; + // Migration directory could be absolute OR could be relative to the basePath. + const migrationPath = path.isAbsolute(migration.directory) + ? migration.directory + : path.join(basePath, migration.directory); + + return migrationPath; +} diff --git a/src/service/fileMaker.ts b/src/service/fileMaker.ts new file mode 100644 index 00000000..df12e544 --- /dev/null +++ b/src/service/fileMaker.ts @@ -0,0 +1,62 @@ +import * as path from 'path'; + +import * as fs from '../util/fs'; +import { log } from '../util/logger'; +import { interpolate } from '../util/string'; +import { getTimestampString } from '../util/ts'; +import Configuration from '../domain/Configuration'; +import { getMigrationPath } from '../migration/service/knexMigrator'; + +const MIGRATION_TEMPLATE_PATH = path.resolve(__dirname, '../../assets/templates/migration'); +const CREATE_TABLE_CONVENTION = /create_(\w+)_table/; + +/** + * Generate migration file(s). + * + * @param {string} filename + * @returns {Promise} + */ +export async function makeMigration(config: Configuration, filename: string): Promise { + if (config.migration.sourceType !== 'sql') { + // TODO: We'll need to support different types of migrations eg both sql & js + // For instance migrations in JS would have different context like JavaScriptMigrationContext. + throw new Error(`Unsupported migration.sourceType value "${config.migration.sourceType}".`); + } + + let createUpTemplate = ''; + let createDownTemplate = ''; + + const migrationPath = getMigrationPath(config); + const migrationPathExists = await fs.exists(migrationPath); + + if (!migrationPathExists) { + log(`Migration path does not exist, creating ${migrationPath}`); + + await fs.mkdir(migrationPath, { recursive: true }); + } + + const timestamp = getTimestampString(); + const upFilename = path.join(migrationPath, `${timestamp}_${filename}.up.sql`); + const downFilename = path.join(migrationPath, `${timestamp}_${filename}.down.sql`); + + // Use the create migration template if the filename follows the pattern: create__table.sql + const createTableMatched = filename.match(CREATE_TABLE_CONVENTION); + + if (createTableMatched) { + const table = createTableMatched[1]; + + log(`Create migration for table: ${table}`); + + createUpTemplate = await fs + .read(path.join(MIGRATION_TEMPLATE_PATH, 'create_up.sql')) + .then(template => interpolate(template, { table })); + createDownTemplate = await fs + .read(path.join(MIGRATION_TEMPLATE_PATH, 'create_down.sql')) + .then(template => interpolate(template, { table })); + } + + await fs.write(upFilename, createUpTemplate); + await fs.write(downFilename, createDownTemplate); + + return [upFilename, downFilename]; +} diff --git a/src/util/fs.ts b/src/util/fs.ts index 48326948..c347be41 100644 --- a/src/util/fs.ts +++ b/src/util/fs.ts @@ -3,6 +3,8 @@ import * as os from 'os'; import * as path from 'path'; import { promisify } from 'util'; +export const mkdir = promisify(fs.mkdir); + /** * Create a temporary directory and return it's path. * diff --git a/src/util/string.ts b/src/util/string.ts new file mode 100644 index 00000000..8536d3ab --- /dev/null +++ b/src/util/string.ts @@ -0,0 +1,31 @@ +/** + * Interpolate a given template string by filling the placeholders with the params. + * + * Placeholder syntax: + * {{name}} + * + * @example + * interpolate('
{{text}}
', {text: 'Hello World!'}) + * => '
Hello World!
' + * + * @param {string} template + * @param {*} [params={}] + * @returns {string} + */ +export function interpolate(template: string, params: any = {}): string { + if (!params || !Object.keys(params)) { + return template; + } + + let result = template; + + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined) { + continue; + } + + result = result.replace(new RegExp('{{' + key + '}}', 'g'), `${value}`); + } + + return result; +} diff --git a/src/util/ts.ts b/src/util/ts.ts index ae5cbef9..af132137 100644 --- a/src/util/ts.ts +++ b/src/util/ts.ts @@ -17,3 +17,36 @@ export function getElapsedTime(timeStart: [number, number], fixed: false | numbe return Number(timeElapsed.toFixed(fixed)); } + +/** + * Gets a timestamp string for the given date. + * + * @param {Date} [date=new Date()] + * @returns {string} + */ +export function getTimestampString(date: Date = new Date()): string { + const dtf = new Intl.DateTimeFormat('en', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }); + + const [ + { value: month }, + , + { value: day }, + , + { value: year }, + , + { value: hour }, + , + { value: minute }, + , + { value: second } + ] = dtf.formatToParts(date); + + return `${year}${month}${day}${hour}${minute}${second}`; +} diff --git a/test/cli/commands/common.test.ts b/test/cli/commands/common.test.ts index 79013dc6..79ab4257 100644 --- a/test/cli/commands/common.test.ts +++ b/test/cli/commands/common.test.ts @@ -11,7 +11,7 @@ const packageJson = fs.readFileSync(path.join(__dirname, '../../../package.json' const { version } = JSON.parse(packageJson.toString()); describe('CLI:', () => { - describe('default run', () => { + describe('with no args', () => { it('should display the usage information.', async () => { const { stdout } = await runCli([], { cwd }); diff --git a/test/cli/commands/make.test.ts b/test/cli/commands/make.test.ts new file mode 100644 index 00000000..17c626d8 --- /dev/null +++ b/test/cli/commands/make.test.ts @@ -0,0 +1,87 @@ +import * as path from 'path'; +import * as yaml from 'yamljs'; +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import { runCli, queryByPattern } from './util'; +import Configuration from '../../../src/domain/Configuration'; +import { mkdir, mkdtemp, write, exists, glob, read } from '../../../src/util/fs'; + +describe('CLI: make', () => { + describe('--help', () => { + it('should print help message.', async () => { + const { stdout } = await runCli(['make', '--help']); + + expect(stdout).contains('make'); + expect(stdout).contains(`USAGE\n $ sync-db make`); + }); + }); + + it('should create migration directory automatically if it does not exist.', async () => { + const cwd = await mkdtemp(); + await write(path.join(cwd, 'sync-db.yml'), yaml.stringify({})); + + await runCli(['make', 'something'], { cwd }); + + // Directory is created. + const pathExists = await exists(path.join(cwd, 'src/migration')); + + expect(pathExists).to.equal(true); + }); + + it('should create a migration file when name is supplied.', async () => { + // Write sync-db.yml file. + const cwd = await mkdtemp(); + const migrationPath = path.join(cwd, 'src/migration'); + await mkdir(migrationPath, { recursive: true }); + await write( + path.join(cwd, 'sync-db.yml'), + yaml.stringify({ + migration: { + directory: 'migration' + } + } as Configuration) + ); + + const { stdout } = await runCli(['make', 'alter_users_table_drop_column'], { cwd }); + + // Check the output. + expect(stdout).to.match(/Created.+\d{13}_alter_users_table_drop_column\.up\.sql/); + expect(stdout).to.match(/Created.+\d{13}_alter_users_table_drop_column\.down\.sql/); + + // Check files are created. + const files = await glob(migrationPath); + + expect(files.length).to.equal(2); + + const upFile = await read( + path.join(migrationPath, queryByPattern(files, /\d{13}_alter_users_table_drop_column\.up\.sql/)) + ); + const downFile = await read( + path.join(migrationPath, queryByPattern(files, /\d{13}_alter_users_table_drop_column\.down\.sql/)) + ); + + expect(upFile).to.equal(''); + expect(downFile).to.equal(''); + }); + + it('should create a migration file with create table template when filename convention is followed.', async () => { + const cwd = await mkdtemp(); + const migrationPath = path.join(cwd, 'src/migration'); + await write(path.join(cwd, 'sync-db.yml'), yaml.stringify({})); + + await runCli(['make', 'create_users_table'], { cwd }); + + const files = await glob(migrationPath); + + expect(files.length).to.equal(2); + + const upFile = await read(path.join(migrationPath, queryByPattern(files, /\d{13}_create_users_table\.up\.sql/))); + const downFile = await read( + path.join(migrationPath, queryByPattern(files, /\d{13}_create_users_table\.down\.sql/)) + ); + + expect(upFile).contains('CREATE TABLE users'); + expect(downFile).contains('DROP TABLE users'); + }); +}); diff --git a/test/cli/commands/util.ts b/test/cli/commands/util.ts index 6b01b743..66523677 100644 --- a/test/cli/commands/util.ts +++ b/test/cli/commands/util.ts @@ -13,3 +13,20 @@ const binPath = getBinPathSync(); export function runCli(args?: string[], options?: execa.Options): execa.ExecaChildProcess { return execa(binPath, args, options); } + +/** + * Query a list by regex pattern and return the found value. + * + * @param {string[]} list + * @param {RegExp} pattern + * @returns {string} + */ +export function queryByPattern(list: string[], pattern: RegExp): string { + const found = list.find(item => pattern.test(item)); + + if (!found) { + throw new Error(`Pattern ${pattern} not found in the list ${list}.`); + } + + return found; +} diff --git a/test/unit/util/string.test.ts b/test/unit/util/string.test.ts new file mode 100644 index 00000000..3bddf6da --- /dev/null +++ b/test/unit/util/string.test.ts @@ -0,0 +1,25 @@ +import { expect } from 'chai'; +import { describe, it } from 'mocha'; + +import { interpolate } from '../../../src/util/string'; + +describe('UTIL: string', () => { + describe('interpolate', () => { + it('should replace the template placeholders with params.', () => { + expect( + interpolate('
{{text}}
', { + className: 'test', + text: 'Hello World!' + }) + ).to.equal('
Hello World!
'); + }); + + it('should leave the unresolved placeholders as-is.', () => { + expect( + interpolate('Hello {{user}}! This is {{foo}}.', { + user: 'Kabir' + }) + ).to.equal('Hello Kabir! This is {{foo}}.'); + }); + }); +}); diff --git a/test/unit/util/ts.test.ts b/test/unit/util/ts.test.ts new file mode 100644 index 00000000..b6b359ec --- /dev/null +++ b/test/unit/util/ts.test.ts @@ -0,0 +1,28 @@ +import { expect } from 'chai'; +import { it, describe } from 'mocha'; + +import * as ts from '../../../src/util/ts'; + +describe('UTIL: ts', () => { + describe('getTimestampString', () => { + it('should return a timestamp string for the given date instance.', () => { + const result1 = ts.getTimestampString(new Date('December 17, 1995 03:24:00')); + const result2 = ts.getTimestampString(new Date('December 17, 1995 15:24:00')); + + expect(result1).to.equal('19951217032400'); + expect(result2).to.equal('19951217032400'); + }); + + it('should return a timestamp string with the current date if date is not provided.', () => { + const now = new Date(); + const result = ts.getTimestampString(); + + expect(result.startsWith(now.getFullYear().toString())); + expect(result.length).to.equal(14); + }); + + it('should return a value fully numeric.', () => { + expect(ts.getTimestampString()).to.match(/^\d{14}$/); + }); + }); +});