@kwaeri/mysql-migration-generator
Version:
The @kwaeri/mysql-migration-generator component module of the @kwaeri/node-kit platform.
635 lines • 30.5 kB
JavaScript
/**
* SPDX-PackageName: kwaeri/mysql-migration-generator
* SPDX-PackageVersion: 0.6.0
* SPDX-FileCopyrightText: © 2014 - 2022 Richard Winters <kirvedx@gmail.com> and contributors
* SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception OR MIT
*/
;
import { GeneratorServiceProvider } from '@kwaeri/generator';
import { MysqlMigrator } from '@kwaeri/mysql-migrator';
import { Configuration } from '@kwaeri/configuration';
import { kdt } from '@kwaeri/developer-tools';
import debug from 'debug';
// DEFINES
const _ = new kdt();
/* Configure Debug module support */
const DEBUG = debug(`kue:mysql-migration-generator`);
/* constants */
export const MIGRATION_TYPES = {
MYSQL_MIGRATION: "mysql",
PG_MIGRATION: 'pg',
MONGO_MIGRATION: 'mongodb'
};
/* Paramaterize */
const PARAMATERIZATION = {
TYPE: {
'mysql': true, // We support MySQL Migrations initially
'pg': false,
'mongodb': false
},
LANG: {
'typescript': false,
'javascript': true // JavaScript migrations are the only option, migrations don't get "built"
},
EXT: {
'typescript': 'mts',
'javascript': 'cjs'
},
SYMBOL: {
'mysql': "MySQL",
'pg': "PG",
'mongodb': 'MongoDB'
}
};
const DEFAULT_GENERATOR_OPTIONS = {
quest: "add",
specification: "migration",
version: "",
args: {
type: 'mysql', // just a default of sorts
lang: 'javascript'
},
subCommands: [
'MyNewMySQLMigration'
],
name: undefined,
fileSafeName: undefined,
type: "mysql",
ext: "cjs",
configuration: {
project: {
name: "",
type: "",
tech: "",
root: ".",
author: {
first: "",
last: "",
fullName: "",
email: ""
},
copyright: "",
copyrightEmail: "",
license: {
identifier: ""
},
repository: ""
}
}
};
/**
* MigrationGenerator
*
* Extends the { Filesystem } class, which implements the { BaseFilesystem }
* Interface.
*
* The { MigrationGenerator } facilitates generating of migration files..
*/
export class MysqlMigrationGenerator extends GeneratorServiceProvider {
/**
* @var { migrator }
*/
migrator;
/**
* @var { configuration }
*/
configuration;
/**
* @var { any }
*/
conf;
/**
* @var { Configuration }
*/
migrationConfiguration;
/**
* @var { any }
*/
migrationConf;
/**
* Class Constructor
*
* @param { (data: ServiceEventBits) => void } handler Method for handling ServiceEvents
* @param { NodeKitOptions } configuration A {@link NodeKitOptions} object.
*
* @returns { void }
*/
constructor(handler, configuration) {
super(handler);
this.migrator = new MysqlMigrator(undefined, configuration);
// Safely discern the environment
const environment = (configuration?.environment && ((configuration.environment == "production") ||
(configuration.environment == "default") ||
(configuration.environment == "test"))) ?
configuration.environment :
"default";
this.configuration = new Configuration('conf', `kwaeri.${environment}.json`);
}
getServiceProviderSubscriptions(options) {
return {
commands: {
"add": {
"migration": true // The project specification has a required flag (type)
}
},
required: {
"add": {
"migration": { // for the project specifications of the 'add' command:
//"type": [ // The flag's possible acceptable values:
// "mysql",
// "pg",
// "mongodb"
//]
}
}
},
optional: {
"add": {
"migration": {
"skip-wizard": {
"for": false, // Or false, if it's not related to an option/value, rather only to the specification.
"flag": true // True insists that no value is given. Its existance equates to <option>=1, the lack of its
}, // existence is similar to <option>=0.
"lang": {
"for": false,
"flag": false,
"values": [
"typescript",
"javascript"
]
}
}
}
}
};
}
getServiceProviderSubscriptionHelpText(options) {
return {
helpText: {
"commands": {
"add": {
"description": "The 'add' command automates content creation for an existing project.",
"specifications": {
"migration": {
"description": "Adds a new empty migration of the type specified to the existing project, and according to options provided.",
"options": {
"required": {
"type": {
"description": "Specify the template type to use when generating a migration for a project developed with @kwaeri/node-kit. Possible types include 'mysql', 'pg', 'mongodb'.",
"values": {
"mysql": {
"desccription": "A mysql based migration."
},
"pg": {
"description": "A postgreSQL based migration.",
},
"mongodb": {
"description": "A mongodb based migration.",
}
}
}
},
"optional": {
"specification": {
"language": {
"description": "Denotes the programming language for the migration being generated.",
"values": [
"typescript",
"javascript"
]
}
}, // ⇦ The various required options that allow optional flags
//"required-option": { // ⇦ For the required option
// "required-value": { // ⇦ For the required options value, can be 'any'
// "example-option": { // ⇦ List options
// "description": "",
// "values": false
// }
// },
//}
}
}
}
},
"options": {
"optional": {
//"command": { // ⇦ For the command itself
// "example-option": { // ⇦ List options
// "description": "",
// "values": []
// }
//},
//"optional-option": { // ⇦ For the optional options of the command
// "optional-value": { // ⇦ For the optional options value, can be 'any'
// "example-option": { // ⇦ List options
// "description": "",
// "values": []
// }
// }
//}
}
}
}
}
}
};
}
/**
* Method to resettle the { MySQLMigrationGeneratorOptions }. Essentially we
* merge NodeKitOptions with FilesystemDescriptor by combining provided
* command options with either a stored configuration or sane default.
*
* @param { NodeKitOptions } options
*
* @returns { MySQLMigrationGeneratorOptions } The options object, with the configuration partially populated with user-provided information
*/
async assembleOptions(options) {
// Setting some defaults
let returnable;
// Ensure a senibly complete set of options
returnable = _.extend(options, DEFAULT_GENERATOR_OPTIONS);
DEBUG(`[ASSEMBLE_OPTIONS] Sanitize settings`);
// Ensure the proper project type was provided - else fail gracefully. We can use the
// "in" operator to check if properties or indices exist within an object or array, and
// it is far more compact then try/catch. We don't ensure a type exists because we
// fallback to default - we only care if a provided type is not supported
if (returnable.args.type && !(returnable.args.type in PARAMATERIZATION.TYPE))
Promise.reject(new Error(`[ASSEMBLE_OPTIONS] Provided migration type '${returnable.args.type}' not supported.`));
// Name the migration
returnable.name = (returnable.subCommands[0] && returnable.subCommands[0] !== "") ? returnable.subCommands[0] : "MyNewMySQLMigration";
// Make a file safe name, if it wasn't already (hint, it should have been Upper Camel Case)
returnable.fileSafeName = MysqlMigrationGenerator.getFileSafeName(returnable.name);
// Default migration type to mysql if not provided (if it is provided, but not supported, its caught above!)
returnable.type = (returnable.args.type && returnable.args.type in PARAMATERIZATION.TYPE) ? returnable.args.type : 'mysql';
if (returnable.type && (returnable.type == 'mysql' || returnable.type == 'pg'))
returnable.table = 'nodekit_migrations';
// Attempt to allow some dynamic setting of the configuration (conf path, migration path, etc)
DEBUG(`[ASSEMBLE_OPTIONS] Get configuration`);
const conf = await this.getConfiguration();
if (conf)
returnable.configuration.project = conf.project;
else {
DEBUG(`ASSEMBLE_OPTIONS] Set project configuration 'tech' to a sane default.`);
returnable.configuration.project.tech = (returnable.args.lang in PARAMATERIZATION.LANG &&
PARAMATERIZATION.LANG[returnable.args.lang]) ?
Object.keys(PARAMATERIZATION.LANG)[returnable.args.lang] : "javascript";
}
// For now we'll hardcode this to be JS ⇨ since we aren't supporting migrations from Typescript
returnable.ext = PARAMATERIZATION.EXT["javascript"]; // ["returnable.configuration!.project.tech"];
returnable.path = `${returnable.configuration.project.root}/conf`;
return Promise.resolve(returnable);
}
/**
* Returns a configuraion from disk or a default fall back
*
* @returns Promise<T>
*/
async getConfiguration() {
// Check that the controller directory doesn't already exist::;
let conf, loadDefault = false;
DEBUG(`[GET_CONFIGURATION] Check 'conf' directory exists`);
if (await this.exists(`${MysqlMigrationGenerator.getPathToCWD()}/conf`)) {
DEBUG(`[GET_CONFIGURATION] Directory 'conf' exists, check for configuration 'kwaeri.default.json'`);
if (await this.exists(`${MysqlMigrationGenerator.getPathToCWD()}/conf/kwaeri.default.json`)) {
DEBUG(`[GET_CONFIGURATION] 'Configuration exists, read configuration`);
conf = await new Configuration(`conf`).get();
if (conf) {
DEBUG(`GET_CONFIGURATION] Configuration read: `);
DEBUG(conf);
}
}
else {
DEBUG(`[ASSEMBLE_OPTIONS] Configuration not found. Falling back to defaults.`);
loadDefault = true;
}
}
else {
DEBUG(`[ASSEMBLE_OPTIONS] Configuration directory not found. Falling back to defaults.`);
loadDefault = true;
}
if (loadDefault) {
conf = {
version: null,
project: DEFAULT_GENERATOR_OPTIONS.configuration.project
};
}
return Promise.resolve(conf);
}
/**
* A method which asynchronously executes the necessary steps for creating
* a migration within a project file structure
*
* @param { MigrationGeneratorOptions } options An object which specifies parameters for this method
*
* @return { Promise<any> }
*/
async renderService(options) {
try {
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 0, notice: `Preparing to generate '${options.args.type}' migration '${options.subCommands[0]}'` });
DEBUG(`[RENDER_MIGRATION_GENERATOR_SERVICE] Resettle options`);
options = await this.assembleOptions(options);
DEBUG(`[RENDER_MIGRATION_GENERATOR_SERVICE] Resolve 'runCreateMySQLMigrationRoutine()'`);
const result = await this.runCreateMigrationRoutine(options);
this.updateProgress('MySQLMigrationGenerator', { progressLevel: -1 });
return Promise.resolve({ ...result });
}
catch (error) {
return Promise.reject(new Error(`[RENDER_MIGRATION_GENERATOR_SERVICE]: ${error}`));
}
}
/**
* A method which asynchronously executes the necessary steps for creating
* a endpoints for a Kwaeri API Application.
*
* @param { MigrationGeneratorOptions } options An object which specifies parameters for this method
*
* @return { Promise<FilesystemPromise> }
*/
async runCreateMigrationRoutine(options) {
DEBUG(`Create migration directory structure`);
// We're gonna use a timestamp in the file name for the migrations, so
// we'll need to prepare some variables:
const dateOptions = { day: "2-digit", month: "2-digit", year: "numeric" }, timeOptions = { hour: "2-digit", minute: "2-digit", hour12: false }, dateObject = new Date(), dateSegments = dateObject.toLocaleDateString("en-US", dateOptions).split("/"),
//dateSegment = dateObject.toLocaleDateString( "en-US", dateOptions ).replace( /\//g, "" ),
timeSegments = dateObject.toLocaleTimeString("en-US", timeOptions).split(":"),
//timeSegment = dateObject.toLocaleTimeString( "en-US", timeOptions ).replace( /:/g, "" ),
dateTimeSegment = `${dateSegments.join("")}${timeSegments.join("")}`;
// Prefix the migration name with a timestamp to help ensure uniquity..
// It shall have an underscore separate the component from the name component:
options.name = `${dateTimeSegment}_${options.name}`;
// Define the path for the directory we'll create for the component:
const rootPath = options.configuration.project.root, sourcePath = `${rootPath}/src`, dataPath = `${rootPath}/data`, migrationsPath = `${dataPath}/migrations`, currentYearPath = `${migrationsPath}/${dateSegments[2].toString()}`, currentMonthPath = `${currentYearPath}/${dateSegments[0].toString()}`;
let firstMigration = false;
DEBUG(`Call 'migrator.checkInstall'`);
// Check for an existing migrations installation & configuration:
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 0, notice: `Checking '${options.args.type}' migrations installation'` });
try {
if (await this.migrator.checkInstall() !== undefined) {
this.updateProgress('MySQLMigrationGenerator', { log: `Flagging installation for '${options.args.type}' migrations'`, logType: 2 });
DEBUG(`Set 'firstMigration' to 'true', migrations need to be installed.`);
firstMigration = true;
}
}
catch (error) {
// First 33: ENOENT: no such file or directory
// Last 15: migrations.json
const first33 = (error.message).substring(0, 33);
DEBUG(`First 33: '${first33}'`);
const last16 = (error.message).slice(-16);
DEBUG(`Last 16: '${last16}'`);
if (first33 == "ENOENT: no such file or directory" && last16 == "migrations.json'") {
this.updateProgress('MySQLMigrationGenerator', { log: `Flagging installation for '${options.args.type}' migrations'`, logType: 2 });
DEBUG(`Set 'firstMigration' to 'true', 'migrations.json' did not exist.`);
firstMigration = true;
}
else {
DEBUG(`Rejecting promise: ${error.name} - ${error.message}.`);
return Promise.reject(error);
}
}
DEBUG(`Create ${options.type} migration system directory structure`);
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 25, notice: `Checking migration infrastructure...` });
// Check that the data path doesn't already exist
if (!((await this.exists(dataPath)))) {
DEBUG(`Create data path '${dataPath}'`);
await this.createDirectory(dataPath);
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 30, log: `Created missing directory '${dataPath}'`, logType: 2 });
}
// Check that the migrations path doesn't already exist:
if (!((await this.exists(migrationsPath)))) {
DEBUG(`Create migrations path '${migrationsPath}'`);
await this.createDirectory(migrationsPath);
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 50, log: `Created missing directory '${migrationsPath}'`, logType: 2 });
}
// Check that the current year path doesn't already exist:
if (!((await this.exists(currentYearPath)))) {
DEBUG(`Create year path '${currentYearPath}'`);
await this.createDirectory(currentYearPath);
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 70, log: `Created missing directory '${currentYearPath}'`, logType: 2 });
}
// Check that the current month path doesn't already exist:
if (!((await this.exists(currentMonthPath)))) {
DEBUG(`Create month path '${currentMonthPath}'`);
await this.createDirectory(currentMonthPath);
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 90, log: `Created missing directory '${currentMonthPath}'`, logType: 2 });
}
// Make the requested migration:
const MIGRATION_TYPE = PARAMATERIZATION.SYMBOL[options.type], GET_MIGRATION_CONF_CONTENT_FUNC = `get${MIGRATION_TYPE}MigrationsCfgFileContents`, GET_MIGRATION_CONTENT_FUNC = `get${MIGRATION_TYPE}MigrationFileContentsES6`;
if (firstMigration) {
// Pass the configuration and install migrations into the project:
DEBUG(`First migration detected; Call 'migrator.install'`);
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 98, notice: `Installing '${options.type}' migrations...` });
if (await this.migrator.install(this[GET_MIGRATION_CONF_CONTENT_FUNC](options)) !== undefined)
return Promise.reject(new Error(`There was an issue installing migrations.`));
}
DEBUG(`Generate ${options.type} migration file`);
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 99, notice: `Generating '${options.type}' migration '${options.name}'...`, log: `Installed '${options.type}' migrations`, logType: 2 });
const result = await this.createFile(currentMonthPath, options.name + "." + options.ext, this[GET_MIGRATION_CONTENT_FUNC](options));
this.updateProgress('MySQLMigrationGenerator', { progressLevel: 100, notice: `Finished generating '${options.args.type}' migration '${options.subCommands[0]}'` });
//this.updateProgress( 'MySQLMigrationGenerator', { progressLevel: 100, notice: `Finishing up'...`, log: `Generated '${options.type}' migration '${options.subCommands[0]}'` } );
return Promise.resolve({
result,
type: "add_mysql_migration"
});
}
/**
* Generates a migrations configuration for node-kit usage
*
* @param { MigrationGeneratorOptions } options The project options
*
* @return { string }
*/
getMySQLMigrationsCfgFileContents(options) {
let content = `` +
`{\n` +
` "version": "${((options.version && options.version !== "") ? options.version : '0.1.12')}",\n` +
` "type": "${((options.type && options.type !== "") ? options.type : 'mysql')}",\n` +
` "table": "${((options.table && options.table !== "") ? options.table : 'nodekit_migrations')}"\n` +
`}\n` +
`\n`;
return content;
}
/**
* Generates a migration's file contents in es6
*
* @param { MigrationGeneratorOptions } options The migration options
*
* @return { string }
*/
getMySQLMigrationFileContentsES6(options) {
let content = `` +
`/**\n` +
` * SPDX-PackageName: ${options.configuration.project.name}\n` +
` * SPDX-PackageVersion: 0.1.0\n` +
` * SPDX-FileCopyrightText: © ${new Date().getFullYear()} ${options.configuration.project.copyright} <${options.configuration.project.copyrightEmail}> and contributors\n` +
` * SPDX-License-Identifier: ${options.configuration.project.license.identifier}\n` +
` */\n` +
`\n` +
`\n` +
`'use strict'\n` +
`\n` +
`\n` +
`// INCLUDES\n` +
`\n` +
`\n` +
`// DEFINES\n` +
`\n` +
`\n` +
`class ${options.name?.split('_')[1].replace('-', '_') + 'Migration'} {\n` +
` /**\n` +
` * Class constructor\n` +
` */\n` +
` constructor() {\n` +
` }\n` +
`\n` +
`\n` +
` /**\n` +
` * Defines the process or query which will apply a 'migration' - or\n` +
` * set of changes - to a database.\n` +
` *\n` +
` * @returns { Promise<MigrationPromise> }\n` +
` */\n` +
` up() {\n` +
` const ci = this;\n` +
` return new Promise(\n` +
` ( resolve, reject ) => {\n` +
` ci.dbo\n` +
` .query(\n` +
` // Supply/Update your query for 'up' here:\n` +
` \`create table if not exists tasks \` +\n` +
` \`( id int(11) not null auto_increment,\` +\n` +
` \`date varchar(255), \` +\n` +
` \`name varchar(255), \` +\n` +
` \`description text, \` +\n` +
` \`complete int(11), \` +\n` +
` \`primary key (id) );\` \n` +
` )\n` +
` .then(\n` +
` ( migrated ) => {\n` +
` if( !migrated || !migrated.rows )\n` +
` reject( new Error( \`[MIGRATION][${options.name}]: There was an issue applying the migration: \` ) );\n` +
`\n` +
` resolve( { result: true, ...migrated } );\n` +
` }\n` +
` );\n` +
` }\n` +
` );\n` +
` }\n` +
`\n` +
`\n` +
` /**\n` +
` * Defines the process or query which will revert a 'migration' - or\n` +
` * set of changes - from a database.\n` +
` *\n` +
` * @returns { Promise<MigrationPromise> }\n` +
` */\n` +
` down() {\n` +
` const ci = this;\n` +
` return new Promise(\n` +
` ( resolve, reject ) => {\n` +
` ci.dbo\n` +
` .query(\n` +
` // Supply/Update your query for 'down' here:\n` +
` \`drop table tasks;\` \n` +
` )\n` +
` .then(\n` +
` ( reverted ) => {\n` +
` if( !reverted || !reverted.rows )\n` +
` reject( new Error( \`[MIGRATION][${options.name}]: There was an issue applying the migration: \` ) );\n` +
`\n` +
` resolve( { result: true, ...reverted } );\n` +
` }\n` +
` );\n` +
` }\n` +
` );\n` +
` }\n` +
`}\n` +
`\n` +
`\n` +
`module.exports = exports = ${options.name?.split('_')[1].replace('-', '_') + 'Migration'};\n` +
`\n` +
`\n`;
return content;
}
/**
* Generates a migration's file contents
*
* @param { MigrationGeneratorOptions } options The migration options
*
* @return { string }
*/
getMySQLMigrationFileContents(options) {
let content = `` +
`/**\n` +
` * SPDX-PackageName: ${options.configuration.project.name}\n` +
` * SPDX-PackageVersion: 0.1.0\n` +
` * SPDX-FileCopyrightText: © ${new Date().getFullYear()} ${options.configuration.project.copyright} <${options.configuration.project.copyrightEmail}> and contributors\n` +
` * SPDX-License-Identifier: ${options.configuration.project.license.identifier}\n` +
` */\n` +
`\n` +
`\n` +
`'use strict'\n` +
`\n` +
`\n` +
`// INCLUDES\n` +
`//import { Migration, MigrationPromise } from '@kwaeri/node-kit/build/node-kit/core/migrations/migration';\n` +
`\n` +
`\n` +
`// DEFINES\n` +
`\n` +
`\n` +
`export class ${options.name?.split('_')[1].replace('-', '_') + 'Migration'} {\n` +
` /**\n` +
` * Class constructor\n` +
` */\n` +
` constructor() {\n` +
` //super();\n` +
` }\n` +
`\n` +
`\n` +
` /**\n` +
` * Defines the process or query which will apply a 'migration' - or\n` +
` * set of changes - to a database.\n` +
` *\n` +
` * @returns { Promise<MigrationPromise> } The promise of a {@link MigrationPromise}.\n` +
` */\n` +
` async up<T extends MigrationPromise>(): Promise<T> {\n` +
` const migrated = await this.dbo\n` +
` .query( \`create table if not exists tasks \` +\n` +
` \`( id int(11) not null auto_increment,\` +\n` +
` \`date varchar(255), \` +\n` +
` \`name varchar(255), \` +\n` +
` \`description text, \` +\n` +
` \`complete int(11), \` +\n` +
` \`primary key (id) );\`\n` +
` );\n` +
`\n` +
` if( !migrated || !migrated.rows )\n` +
` return Promise.reject( new Error( \`[MIGRATION][${options.name}]: There was an issue applying the migration: \` ) );\n` +
`\n` +
` return Promise.resolve( <T>{ result: true, ...migrated } );\n` +
` }\n` +
`\n` +
`\n` +
` /**\n` +
` * Defines the process or query which will revert a 'migration' - or\n` +
` * set of changes - from a database.\n` +
` *\n` +
` * @returns { Promise<MigrationPromise> } The promise of a {@link MigrationPromise}.\n` +
` */\n` +
` async down<T extends MigrationPromise>(): Promise<T> {\n` +
` const reverted = await this.dbo\n` +
` .query( \`drop table tasks;\` );\n` +
`\n` +
` if( !reverted || !reverted.rows )\n` +
` return Promise.reject( new Error( \`[MIGRATION][${options.name}]: There was an issue reverting the migration: \` ) );\n` +
`\n` +
` return Promise.resolve( <T>{ result: true, ...reverted } );\n` +
` }\n` +
`}\n` +
`\n` +
`\n`;
return content;
}
}
//# sourceMappingURL=mysql-migration-generator.mjs.map