UNPKG

@kwaeri/mysql-migration-generator

Version:

The @kwaeri/mysql-migration-generator component module of the @kwaeri/node-kit platform.

635 lines 30.5 kB
/** * 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 */ 'use strict'; 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