UNPKG

@megaorm/cli

Version:

This package allows you to communicate with MegaORM via commands directly from the command line interface (CLI).

751 lines (750 loc) 34.4 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.GeneratorHandler = exports.GeneratorHandlerError = void 0; const promises_1 = __importDefault(require("fs/promises")); const path_1 = require("path"); const errors_1 = require("@megaorm/errors"); const gen_1 = require("@megaorm/gen"); const builder_1 = require("@megaorm/builder"); const test_1 = require("@megaorm/test"); /** * Custom error class for handling errors related to the generator operations. * * This error is thrown when there are issues with generator entries, * such as invalid paths or batch numbers, ensuring that the errors can be * distinguished from other types of errors in the application. */ class GeneratorHandlerError extends Error { } exports.GeneratorHandlerError = GeneratorHandlerError; /** * Represents the generator class responsible for handling the creation * and management of the 'generators' table in the database. * * This class extends `MegaGenerator` and provides functionality to create * and drop the 'generators' table, which is used to store the paths and * batch numbers of generator files that have been executed. The batch * numbers are required to group tables created together for rollback. */ class Generator extends gen_1.MegaGenerator { /** * Creates the 'generators' table with specified columns and constraints. * * The table includes the following columns: * - `id`: The primary key of the table. * - `path`: A string representing the path of the generator file, which * cannot be null. * - `batch`: A small integer representing the batch number, which is * unsigned and cannot be null. * * @returns A promise that resolves when the table schema is successfully created. */ create() { return this.schema(this.column('id').pk(), this.column('path').text().notNull(), this.column('batch').smallInt().unsigned().notNull()); } } /** * Checks if the provided name matches the pattern for a valid generator file. * * @param name - The name to check if it matches a generator file pattern. * @returns `true` if the name matches the generator file pattern (e.g., `01_generate_users_table.js`), otherwise `false`. */ function isFile(name) { // Pattern example: 01_generate_users_table.js return /^[0-9]+_generate_[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*_table\.(ts|js)$/.test(name); } /** * Checks if the provided name matches the pattern for a valid `typescript` generator file. * * @param name - The name to check if it matches a `typescript` generator file pattern. * @returns `true` if the name matches a `typescript` generator file pattern (e.g., `01_generate_users_table.ts`), otherwise `false`. */ function isTsFile(name) { // Pattern example: 01_generate_users_table.ts return /^[0-9]+_generate_[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*_table\.ts$/.test(name); } /** * Checks if the provided name matches the pattern for a valid TypeScript output file (either a map or declaration file). * * @param name - The name to check if it matches a TypeScript output file pattern. * @returns `true` if the name matches the pattern for a `.js.map` or `.d.ts` file, otherwise `false`. */ function isMappedFile(name) { // Pattern example: 01_generate_users_table.js.map or .d.ts return /^[0-9]+_generate_[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*_table(\.js\.map|\.d\.ts)$/.test(name); } /** * Retrieves the last generator file number in the specified directory. * * This function scans for existing generator files in the given path and extracts * the highest generator file number. If no files are found, it defaults to returning `0`. * * @param path The directory path where the generator files are located. * @returns Promise that resolves with the next available file number, if no file found resolves with `0`. */ function lastNumber(path) { return new Promise((resolve, reject) => { promises_1.default.readdir(path) .then((result) => { // resolve with 0 if ((0, test_1.isEmptyArr)(result)) return resolve(0); // filter map files const files = result.filter((name) => !isMappedFile(name)); // validate each file for (const file of files) { if (isFile(file)) continue; return reject(new GeneratorHandlerError(`Invalid generator file: ${String(file)}`)); } // Find the highest existing file number const match = files .pop() .match(/^([0-9]+)_generate_[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*_table\.(ts|js)$/); return resolve(Number(match[1])); }) .catch((e) => reject(new GeneratorHandlerError(e.message))); }); } /** * Returns the highest batch number from an array of batch numbers. * * @param batches - An array of batch numbers to evaluate. * @returns The highest batch number in the array, or `0` if the array is empty. */ function lastBatch(batches) { if ((0, test_1.isEmptyArr)(batches)) return 0; return batches.reduce((lastBatch, batch) => { return batch > lastBatch ? batch : lastBatch; }, 0); } /** * Inserts an array of file paths and a batch number into the 'generators' table. * * @param paths - An array of file paths to be inserted. * @param batch - The batch number associated with these paths. * @param builder - The database builder instance used for constructing the query. * @returns A promise that resolves when the paths have been successfully inserted into the table. */ function add(paths, batch, builder) { return new Promise((resolve, reject) => { const insert = builder.insert().into('generators'); const rows = paths.map((path) => ({ path, batch })); if (rows.length === 1) insert.row(rows[0]); else insert.rows(rows); insert.exec().then(resolve).catch(reject); }); } /** * Removes specified file paths from the 'generators' table. * * @param paths - An array of file paths to be removed from the table. * @param builder - The database builder instance used for constructing the query. * @returns A promise that resolves when the paths have been successfully removed from the table. */ function remove(paths, builder) { return new Promise((resolve, reject) => { builder .delete() .from('generators') .where((col) => col('path').in(...paths)) .exec() .then(resolve) .catch(reject); }); } /** * Renames generator files by updating their numbering sequence after a deletion. * * This function is used internally to ensure that the numbering of generator files * remains consistent after a user deletes one or more files. Each group of paths * may include related files, such as: * - JavaScript files (.js) * - TypeScript files (.ts) * - Source map files (.js.map) * * @param groups An array of arrays, where each inner array contains paths to generator files that need to be renamed. * @returns Promise that resolves when all files have been successfully renamed. */ function rename(groups) { return new Promise((resolve, reject) => { if ((0, test_1.isEmptyArr)(groups)) return resolve(); const promises = []; groups.forEach((group, index) => { group.forEach((path) => { const newNumber = (index + 1).toString().padStart(2, '0'); const newName = `${newNumber}${(0, path_1.basename)(path).replace(/^[0-9]+/, '')}`; const newPath = (0, path_1.dirname)(path).concat('/', newName); promises.push(promises_1.default.rename(path, newPath)); }); }); return Promise.all(promises) .then(() => resolve()) .catch((error) => reject(new GeneratorHandlerError(error.message))); }); } /** * Groups generator file paths by their numeric prefix. This function organizes generator * files that share the same numeric prefix into arrays * * @param paths - An array of file paths representing generator files. * @returns An array of arrays, where each inner array represents a group of files that share the same numeric prefix. */ function group(paths) { const storage = {}; paths.forEach((path) => { // prettier-ignore const regex = /([0-9]+)_generate_[a-z][a-z0-9]*(?:_[a-z][a-z0-9]*)*_table/; const number = path.match(regex)[1]; if (!storage[number]) storage[number] = []; storage[number].push(path); }); // Return paths in the correct order return Object.keys(storage) .sort((a, b) => parseInt(a) - parseInt(b)) .map((key) => storage[key]); } /** * Safely deletes multiple generator files at the specified paths. * * This function calls `unlink` for each path provided in the array, attempting to * remove each corresponding file. * * @param paths An array of strings representing the absolute paths of the generator files to be deleted. * @returns A promise that resolves when files are deleted. */ function unlink(paths) { return new Promise((resolve, reject) => { return Promise.all(paths.map((path) => promises_1.default.unlink(path))) .then(resolve) .catch(reject); }); } /** * Creates a generator class name based on the table name. * * @param name The snake_case table name. * @returns A generator class name. */ function toClassName(name) { return (name .split('_') .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .join('') + 'TableGenerator'); } /** * Creates a generator file name based on the provided name, number, and extension. * * @param name The snake_case table name. * @param number The file number. * @param ext The file extension. * @returns A generator file name. */ function toFileName(name, number, ext) { return `${(number + 1) .toString() .padStart(2, '0')}_generate_${name}_table.${ext}`; } /** * Returns an `s` if the array length is not 1, for pluralization purposes. * * @param paths - An array whose length determines the suffix. * @returns - Returns an empty string if the array length is 1, otherwise `s`. */ function sfx(paths) { return paths.length === 1 ? '' : 's'; } /** * The `GeneratorHandler` class is responsible for managing operations related to database * generators. It provides functionality to add, remove, execute, and roll back generator files * that create or drop tables, ensuring consistent and reliable execution of generation tasks. * * This class encapsulates the logic required for handling file-based database generation, * ensuring consistent operations across tables and generator files. * */ class GeneratorHandler { /** * Creates a new handler instance with the specified database builder. * * @param builder The database builder instance used for constructing queries. * @throws `GeneratorHandlerError` if the provided builder is not a valid instance of `MegaBuilder`. */ constructor(builder) { if (!(0, test_1.isChildOf)(builder, builder_1.MegaBuilder)) { throw new GeneratorHandlerError(`Invalid builder: ${String(builder)}`); } this.rows = new Array(); this.builder = builder; this.assets = (0, path_1.resolve)(__dirname, '../../assets'); } /** * Collects the absolute paths of generator files from the specified directory. * * @param path The directory path from which to collect generator files. * @param map A boolean indicating whether to include mapped files (e.g., `.js.map`) in the resulting paths. * @returns A promise that resolves with an array of absolute paths to valid generator files. * @throws GeneratorHandlerError if there is an issue collecting the files or if an invalid generator file is encountered. */ collectPaths(path, map) { return new Promise((resolve, reject) => { promises_1.default.readdir(path) .then((result) => { // check if the directory is empty if ((0, test_1.isEmptyArr)(result)) return resolve([]); // filter map files const files = map ? result : result.filter((name) => !isMappedFile(name)); // validate each file for (const file of files) { if (!isFile(file)) { if (map && isMappedFile(file)) continue; return reject(new GeneratorHandlerError(`Invalid generator file: ${String(file)}`)); } } return resolve(files.map((file) => (0, path_1.resolve)(path, file))); }) .catch((e) => reject(new GeneratorHandlerError(e.message))); }); } /** * Collects exported generator instances from the specified file paths. * * This method attempts to require each file listed in the provided array of * paths. It expects each file to export either a default instance of `MegaGenerator` * or a named generator instance. If any of the files do not conform to these * expectations, an error will be thrown. * * ### Behavior * - If no paths are provided or if no valid generators are found, a * `GeneratorHandlerError` is thrown with a relevant message. * - For each path, the method tries to: * - Import the module using `require`. * - Check if the imported module is a valid `MegaGenerator` instance. * - If the module exports a named generator, it will check for the first * property and validate it as a `MegaGenerator`. * * @param paths An array absolute paths to your generator files. * @returns An array of `MegaGenerator` instances collected from the specified file paths. * @throws `GeneratorHandlerError` if no generators are found or if an invalid generator is encountered in any of the specified paths. */ collectGenerators(paths) { if ((0, test_1.isEmptyArr)(paths)) { throw new GeneratorHandlerError(`Ops! No generator found`); } const generators = []; for (const path of paths) { let module; try { module = require(path); } catch (error) { throw new GeneratorHandlerError(error.message); } // Check if module exports a valid default generator if ((0, test_1.isChildOf)(module, gen_1.MegaGenerator)) { generators.push(module); continue; } // Check if module exports named generators if ((0, test_1.isObj)(module) && (0, test_1.hasLength)(module, 1)) { const key = Object.keys(module)[0]; const namedGenerator = module[key]; if ((0, test_1.isChildOf)(namedGenerator, gen_1.MegaGenerator)) { generators.push(namedGenerator); continue; } } throw new GeneratorHandlerError(`Invalid generator in: ${path}`); } return generators; } /** * Loads data from the `generators` table or creates the table if it does not exist. * * This method attempts to retrieve all rows from the `generators` table, storing * them in the `this.rows` property. If the `generators` table does not exist, * it catches the resulting error and initializes a new `Generator` instance to * create the table. This method is useful for ensuring that the necessary * table structure is available before performing further actions. * * @returns Promise resolves once the table data is loaded into `this.rows` or the table is created. */ load() { return new Promise((resolve, reject) => { this.builder .raw('SELECT * FROM generators;') .then((rows) => { this.rows = rows; return resolve(); }) .catch((error) => { if (!(0, test_1.isChildOf)(error, errors_1.QueryError)) return reject(error); const generator = new Generator(); generator.set.table('generators'); generator.set.builder(this.builder); generator.create().then(resolve).catch(reject); }); }); } /** * Creates tables based on the provided generator files and records their creation in the database. * * This method sequentially invokes the `create` method on each generator instance in the `paths` array. * The method also maintains a record of successfully created tables in the `created` array. * If any table fails to be created, the remaining tables in the sequence are not created. * * @param paths An array of paths to generator files that define the tables to be created. * @param batch A batch number i use to group the created tables togother for future rollback. * @returns A promise that resolves with a message indicating the number of tables created successfully. * @throws `GeneratorHandlerError` If any generator does not contain a valid `create` method or if an * error occurs during table creation or batch recording. */ createTables(paths, batch) { return new Promise((resolve, reject) => { const generators = this.collectGenerators(paths); // Collect generators from paths const created = []; const create = (index = 0) => { return new Promise((resolve, reject) => { if (index >= generators.length) return resolve(); const promise = generators[index].set.builder(this.builder).create(); if (!(0, test_1.isPromise)(promise)) { return reject(new GeneratorHandlerError(`Invalid create method in: ${generators[index].constructor.name}`)); } return promise .then(() => { created.push(paths[index]); return create(index + 1); }) .then(resolve) .catch(reject); }); }; create() .then(() => { // prettier-ignore const message = `${created.length}/${paths.length} table${sfx(paths)} created`; return add(created, batch, this.builder) .then(() => resolve(message)) .catch(reject); }) .catch((e) => { // prettier-ignore const message = `${created.length}/${paths.length} table${sfx(paths)} created: ${e.message}`; if (created.length > 0) { return add(created, batch, this.builder) .then(() => reject(new GeneratorHandlerError(message))) .catch(reject); } return reject(new GeneratorHandlerError(message)); }); }); } /** * Drops tables based on the provided generator files and records their removal from the database. * * This method sequentially invokes the `drop` method on each generator instance in the `paths` array. * Successfully dropped tables are recorded in the `dropped` array. If any table fails to be * dropped, the remaining tables in the sequence are not dropped. * * @param paths An array of paths to generator files that define the tables to be dropped. * @returns A promise that resolves with a message indicating the number of tables dropped successfully. * @throws `GeneratorHandlerError` If any generator does not contain a valid `drop` method or if an * error occurs during table removal or record deletion. */ dropTables(paths) { return new Promise((resolve, reject) => { const generators = this.collectGenerators(paths); // Collect generators from paths const dropped = []; const drop = (index = 0) => { return new Promise((resolve, reject) => { if (index >= generators.length) return resolve(); const promise = generators[index].set.builder(this.builder).drop(); if (!(0, test_1.isPromise)(promise)) { return reject(new GeneratorHandlerError(`Invalid drop method in: ${generators[index].constructor.name}`)); } promise .then(() => { dropped.push(paths[index]); return drop(index + 1); // drop next }) .then(resolve) .catch(reject); }); }; drop() .then(() => { // prettier-ignore const message = `${dropped.length}/${paths.length} table${sfx(paths)} dropped`; return remove(dropped, this.builder) .then(() => resolve(message)) .catch(reject); }) .catch((e) => { // prettier-ignore const message = `${dropped.length}/${paths.length} table${sfx(paths)} dropped: ${e.message}`; if (dropped.length > 0) { return remove(dropped, this.builder) .then(() => reject(new GeneratorHandlerError(message))) .catch(reject); } return reject(new GeneratorHandlerError(message)); }); }); } /** * Prepares the handler to create new tables by: * - Finding paths to all generator files * - Filtering out files that have already been run, so only unexecuted generators left * - Assigning a new batch number for tracking * * @param path The main folder where generator files are located. * @returns A promise that resolves with the new batch number and paths to pending generators. * @throws `GeneratorHandlerError` If there are no new generators to run or if loading data or paths fails. */ beReadyToGenerate(path) { return new Promise((resolve, reject) => { this.load() .then(() => this.collectPaths(path, false)) .then((allPaths) => { const batches = this.rows.map((r) => r.batch); const batch = lastBatch(batches) + 1; const DBPaths = this.rows.map((r) => r.path); const paths = allPaths.filter((p) => !DBPaths.includes(p)); if ((0, test_1.isEmptyArr)(paths)) { return reject(new GeneratorHandlerError('Nothing to generate')); } resolve({ batch, paths }); }) .catch(reject); }); } /** * Prepares the handler to drop tables by: * - Retrieving the highest batch number * - Collecting paths of all generators in that batch * * @note Paths are reversed to ensure tables are dropped in the correct order. * @returns A promise that resolves with paths to the generator files in the latest batch. * @throws `GeneratorHandlerError` If there are no files to rollback or if loading data fails. */ beReadyToRollback() { return new Promise((resolve, reject) => { this.load() .then(() => { const batch = lastBatch(this.rows.map((g) => g.batch)); const paths = this.rows .filter((g) => g.batch === batch) .map((g) => g.path) .reverse(); if ((0, test_1.isEmptyArr)(paths)) { return reject(new GeneratorHandlerError('Nothing to rollback')); } resolve(paths); }) .catch(reject); }); } /** * Executes the `create` method for each unexecuted generator to create tables. * * @param path The main folder where generator files are located. * @returns A promise that resolves with a success message if tables are created successfully. * @throws `GeneratorHandlerError` If any error occurs during preparation or table creation. */ generate(path) { return new Promise((resolve, reject) => { if (!(0, test_1.isFullStr)(path)) { return reject(new GeneratorHandlerError(`Invalid path: ${String(path)}`)); } this.beReadyToGenerate(path) .then(({ paths, batch }) => this.createTables(paths, batch)) .then(resolve) // Resolve if successful .catch(reject); // Handle any errors }); } /** * Executes the `drop` method for each generator associated with the highest batch number. * * This method ensures that only the tables created in the most recent batch are dropped, * preserving any tables created in earlier batches. This allows for targeted rollback of * the last set of generator executions without affecting previously created tables. * * @returns A promise that resolves with a success message if tables are dropped successfully. * @throws `GeneratorHandlerError` If any error occurs during preparation or table dropping. */ rollback() { return new Promise((resolve, reject) => { this.beReadyToRollback() .then((paths) => this.dropTables(paths)) .then(resolve) // Resolve if successful .catch(reject); // Handle any errors }); } /** * Creates a new generator file in the specified path. * * This method generates a new file for the specified table name in the given directory path. * You can choose to create a TypeScript file or a JavaScript file based on the provided boolean flag. * * @param name The snake_case table name for which the generator is created. * @param path The directory path where the generator file will be created. * @param ts A boolean indicating whether to create a TypeScript file (`true`) or a JavaScript file (`false`). * @returns A promise that resolves with a success message indicating the path of the created generator file. * @throws `GeneratorHandlerError` If the table name is invalid, the path is empty, or if any errors occur during file operations. * @note The table name must be a snake_case table name, e.g., users, category_product. */ add(name, path, ts) { return new Promise((resolve, reject) => { if (!(0, test_1.isSnakeCase)(name)) { return reject(new GeneratorHandlerError(`Invalid table name: ${String(name)}`)); } if (!(0, test_1.isFullStr)(path)) { return reject(new GeneratorHandlerError(`Invalid path: ${String(path)}`)); } if (!(0, test_1.isBool)(ts)) ts = false; const ext = ts ? 'ts' : 'js'; const layout = ts ? 'ts/generator.txt' : 'js/generator.txt'; const content = (0, path_1.resolve)(this.assets, layout); promises_1.default.readFile(content, 'utf-8') .then((template) => { lastNumber(path) .then((number) => { const className = toClassName(name); const fileName = toFileName(name, number, ext); const filePath = (0, path_1.resolve)(path, fileName); const fileContent = template .replace(/\[className\]/g, className) .replace(/\[tableName\]/g, name) .replace(/\[fileName\]/g, fileName); // Create generator file promises_1.default.writeFile(filePath, fileContent, 'utf-8') .then(() => resolve(`Generator added in: ${filePath}`)) .catch((e) => reject(new GeneratorHandlerError(e.message))); }) .catch((e) => reject(new GeneratorHandlerError(e.message))); }) .catch((e) => reject(new GeneratorHandlerError(e.message))); }); } /** * Removes the generator associated with the given table name from the specified folder path. * * This method first drops all tables in the database using the `reset` method, then removes * the generator file for the specified table, and finally updates the numbering of each remaining * generator file in the folder. * * @param name The snake_case table name whose associated generator is to be removed. * @param path The directory path where the generator files are located. * @returns A promise that resolves with a success message indicating how many generator files were removed. * @throws `GeneratorHandlerError` If the table name is invalid, the path is empty. * @note The table name must be a snake_case table name, e.g., users, category_product. */ remove(name, path) { return new Promise((resolve, reject) => { if (!(0, test_1.isSnakeCase)(name)) { return reject(new GeneratorHandlerError(`Invalid table name: ${String(name)}`)); } if (!(0, test_1.isFullStr)(path)) { return reject(new GeneratorHandlerError(`Invalid path: ${String(path)}`)); } return this.collectPaths(path, true) .then((paths) => { const regex = new RegExp(`[0-9]+_generate_${name}_table\.(ts|js|js\.map|d\.ts)$`); const unlinkPaths = paths.filter((path) => regex.test(path)); const renamePaths = group(paths.filter((path) => !regex.test(path))); if (unlinkPaths.length === 0) { return Promise.reject(new GeneratorHandlerError(`No generator found for table: ${name}`)); } // prettier-ignore const message = `${unlinkPaths.length} generator file${sfx(unlinkPaths)} removed in:\n${unlinkPaths.join('\n')}`; // Reset .js generators const reset = () => { return new Promise((resolve, reject) => { if (isTsFile((0, path_1.basename)(paths[0]))) return resolve(null); this.reset(path).then(resolve).catch(reject); }); }; return reset() .then(() => unlink(unlinkPaths)) .then(() => rename(renamePaths)) .then(() => resolve(message)) .catch(reject); }) .catch(reject); }); } /** * Resets the database by dropping all tables associated with the generator files * in the specified directory. * * @param path The directory path where the generator files are located. * @returns A promise that resolves with a success message indicating how many tables were dropped. * @throws `GeneratorHandlerError` If there are no tables to reset. * @note This method will continue dropping tables even if some errors occur (e.g., if a table does not exist). */ reset(path) { return new Promise((resolve, reject) => { if (!(0, test_1.isFullStr)(path)) { return reject(new GeneratorHandlerError(`Invalid path: ${String(path)}`)); } const dropTables = (paths) => { return new Promise((resolve, reject) => { if ((0, test_1.isEmptyArr)(paths)) { return reject(new GeneratorHandlerError('Nothing to reset')); } const generators = this.collectGenerators(paths); let dropped = 0; const exec = (index = 0) => { return new Promise((resolve) => { if (index >= generators.length) { return this.builder .raw(`DROP TABLE generators;`) // Drop the table .then(resolve) .catch(resolve); } const name = generators[index].get.table(); this.builder .raw(`DROP TABLE ${name};`) // Drop the table .then(() => { dropped++; // Track dropped tables if successful exec(index + 1).then(resolve); // Proceed to the next table }) .catch(() => { // Continue even if there's an error (e.g., table doesn't exist) exec(index + 1).then(resolve); // Proceed to the next table }); }); }; exec() .then(() => { resolve(`${dropped}/${paths.length} table${sfx(paths)} dropped`); }) .catch(reject); }); }; this.collectPaths(path, false) .then((paths) => dropTables(paths.reverse())) .then(resolve) .catch(reject); }); } } exports.GeneratorHandler = GeneratorHandler; //# sourceMappingURL=GeneratorHandler.js.map