UNPKG

knex-automigrate

Version:

Table schema based database migration tool, built on top of the knex.js

426 lines (335 loc) 13.4 kB
/* eslint-disable no-param-reassign */ const fs = require('fs'); const helper = require('./index/helper'); module.exports = function Automigrate(opts) { // eslint-disable-next-line no-async-promise-executor return new Promise(async (resolve, reject) => { opts = opts || {}; const knex = require('knex')(opts.config); // eslint-disable-line global-require // TODO: Migration PK (Y,N) // TODO: Migration Indexes (Y,N) // TODO: Migration Unique Attr. (Y,N) // TODO: Migration Reference Attr. (Y,N) const tabler = function tabler() { const appended = []; return new Proxy({}, { get(target, method) { if (method === '__appended__') { return appended; } if (typeof (method) !== 'string' || ['constructor'].indexOf(method) !== -1) { return undefined; } return function tablerProxy(a, b, c) { const proxy = tabler(); appended.push([proxy, method, [a, b, c]]); return proxy; }; }, }); }; const migrateTable = async function migrator(tableName, fn, initRows) { const exists = await knex.schema.hasTable(tableName); if (!exists) { const fulltextIndexesWithParser = []; await knex.schema.createTable(tableName, (table) => { const customTable = Object.create(table); customTable.index = function indexWithParser(columns, name, options) { if (options && options.indexType === 'FULLTEXT' && options.parser) { fulltextIndexesWithParser.push({ columns, name, options }); return this; } return table.index.call(this, columns, name, options); }; fn(customTable); }); await Promise.all(fulltextIndexesWithParser.map(async (index) => { const bindings = [tableName, index.name, ...index.columns]; const columns = index.columns.map(() => '??').join(', '); let sql = `ALTER TABLE ?? ADD FULLTEXT INDEX ?? (${columns})`; if (index.options.parser) { sql += ' WITH PARSER ??'; bindings.push(index.options.parser); } await knex.raw(sql, bindings); })); } else { const existColumns = await knex.from(tableName).columnInfo(); const existIndexes = await (require('./index')(knex, tableName)); // eslint-disable-line global-require const schemaColumns = {}; const schemaIndexes = { pk: [], uk: [], key: [], fk: [], }; const fulltextIndexesWithParser = []; await knex.schema.alterTable(tableName, (table) => { let prevColumnName; let columnName; const proxy = tabler(); fn(proxy); const column = function column(t, e, depth) { if (!e || !e[0]) return null; const method = e[1]; const args = e[2]; if (depth === 0) { columnName = e[2][0]; schemaColumns[columnName] = true; } // If exists primary key already. if ((method === 'increments' || method === 'bigIncrements') && existIndexes.pk.length > 0) { return t; } if (method === 'index') { schemaIndexes.key.push(typeof (args[0]) === 'string' ? [args[0]] : args[0]); // If exists index already. if (existIndexes.isIndexExists(args[0], args[1])) { return t; } if (args.length > 2 && args[2] && args[2].indexType === 'FULLTEXT' && args[2].parser) { fulltextIndexesWithParser.push({ columns: args[0], name: args[1], options: args[2], }); return t; } } let isAppliable = true; if (method === 'references') { schemaIndexes.fk.push([columnName, typeof (args[0]) === 'string' ? [args[0]] : args[0]]); // If exists foreign key already. if (existIndexes.isForeignKeyExists(columnName, args[0])) { isAppliable = false; } } if (method === 'unique') { const keys = args[0] || columnName; schemaIndexes.uk.push(typeof (keys) === 'string' ? [keys] : keys); // If exists unique index already. if (existIndexes.isUniqueExists(args[0] || columnName)) { isAppliable = false; if (depth === 0) { return t; } } } if (method === 'primary') { const keys = args[0] || columnName; schemaIndexes.pk.push(typeof (keys) === 'string' ? [keys] : keys); // If exists primary key already. if (existIndexes.isPrimaryKeyExists(args[0] || columnName)) { isAppliable = false; if (depth === 0) { return t; } } } if (isAppliable) { const methodFn = t[method]; t = methodFn.apply(t, args); } e[0].__appended__.forEach((ee) => { // eslint-disable-line no-underscore-dangle t = column(t, ee, depth + 1); }); if (depth === 0) { if (['foreign'].indexOf(method) !== -1) { /* eslint-disable no-empty */ } else if (['index', 'unique'].indexOf(method) === -1) { if (existColumns[columnName]) { t.alter(); } else if (prevColumnName) { t = t.after(prevColumnName); } else { t = t.first(); } } prevColumnName = columnName; } return t; }; proxy.__appended__.forEach((e) => { // eslint-disable-line no-underscore-dangle column(table, e, 0); }); // Drop unused columns. const dropColumns = []; Object.keys(existColumns).forEach((e) => { if (!schemaColumns[e]) { Object.keys(existIndexes.fk).forEach((key) => { if (helper.isArrayEqual([e], existIndexes.fk[key].key)) { table.dropForeign(undefined, key); } }); dropColumns.push(e); } }); if (dropColumns.length > 0) { if (opts.config.safe) { if (opts.verbose !== false) { /* eslint-disable no-console */ console.warn(`* [Skip Drop Column${dropColumns.length > 1 ? 's' : ''}] \`${tableName}\``); console.warn(` ${'-'.repeat(20)}`); console.warn(` ALTER TABLE \`${tableName}\` \n ${dropColumns.map((c) => `DROP COLUMN \`${c}\``).join(',\n ')};`); console.warn(` ${'-'.repeat(20)}`); } } else { table.dropColumns(dropColumns); } } // Drop unused indexes. Object.keys(existIndexes.key).forEach((key) => { let found = false; schemaIndexes.key.forEach((sKey) => { if (helper.isArrayEqual(existIndexes.key[key], sKey)) found = true; }); schemaIndexes.fk.forEach((sKey) => { sKey = typeof (sKey[0]) === 'string' ? [sKey[0]] : sKey[0]; if (helper.isArrayEqual(existIndexes.key[key], sKey)) found = true; }); if (!found) { table.dropIndex(undefined, key); } }); Object.keys(existIndexes.uk).forEach((key) => { let found = false; schemaIndexes.uk.forEach((sKey) => { if (helper.isArrayEqual(existIndexes.uk[key], sKey)) found = true; }); if (!found) { table.dropUnique(undefined, key); } }); }); await Promise.all(fulltextIndexesWithParser.map(async (index) => { const bindings = [tableName, index.name, ...index.columns]; const columns = index.columns.map(() => '??').join(', '); let sql = `ALTER TABLE ?? ADD FULLTEXT INDEX ?? (${columns})`; if (index.options.parser) { sql += ' WITH PARSER ??'; bindings.push(index.options.parser); } await knex.raw(sql, bindings); })); } if (typeof initRows === 'function') { const existRows = (await knex.count('* AS cnt').from(tableName))[0].cnt; if (!existRows) { const rows = initRows(opts.config); if (rows && rows.length) { await knex.batchInsert(tableName, rows); } } } return tableName; }; const migrateView = async function migrator(viewName, fn) { const exists = (await knex('INFORMATION_SCHEMA.TABLES').select('TABLE_SCHEMA', 'TABLE_TYPE').where({ TABLE_SCHEMA: opts.config.connection.database, TABLE_NAME: viewName, }))[0]; if (exists && exists.TABLE_TYPE === 'BASE TABLE') { throw new Error(`* ${viewName} must be a view, but a table already exists with this name.`); } const proxy = tabler(); // eslint-disable-next-line no-underscore-dangle const { schemaColumns } = proxy.__appended__.reduce((acc, cur) => { if (cur[1] === 'columns') { if (acc.schemaColumns) throw new Error('Only one "columns" can be used.'); acc.schemaColumns = cur[2][0]; } if (cur[1] === 'as') { if (acc.schemaAs) throw new Error('Only one "as" can be used.'); acc.schemaAs = cur[2][0]; } return acc; }, { schemaColumns: null, schemaAs: null, }); if (!exists) { await knex.schema.createView(viewName, (view) => { fn(view); }); } else { fn(proxy); const existColumns = await knex.from(viewName).columnInfo(); const dropColumns = Object.keys(existColumns) .filter((existColumn) => schemaColumns && !schemaColumns.includes(existColumn)); if (dropColumns.length > 0) { if (opts.verbose !== false) { /* eslint-disable no-console */ console.warn(`* [Drop Column${dropColumns.length > 1 ? 's' : ''}] \`${viewName}\``); console.warn(` ${'-'.repeat(20)}`); console.warn(` ALTER VIEW \`${viewName}\` \n ${dropColumns.map((c) => `DROP COLUMN \`${c}\``).join(',\n ')};`); console.warn(` ${'-'.repeat(20)}`); } } await knex.schema.createViewOrReplace(viewName, (view) => { fn(view); }); } return viewName; }; const promises = { tables: [], views: [], }; opts.path = opts.cwd || process.cwd(); const dummyMigrator = (fn) => (a, b, c) => fn(a, b, c); const tableMigrator = (a, b, c) => ({ call: async () => { const res = await migrateTable(a, b, c); return res; }, }); const viewMigrator = (a, b) => ({ call: async () => { const res = await migrateView(a, b); return res; }, }); fs.readdirSync(opts.path).forEach((name) => { if (name.slice(0, 6) !== 'table_' && name.slice(0, 5) !== 'view_') return; const isTable = name.slice(0, 6) === 'table_'; // eslint-disable-next-line global-require, import/no-dynamic-require require(`${opts.path}/${name}`).auto(dummyMigrator(isTable ? tableMigrator : viewMigrator), knex).forEach((e) => { if (isTable) { promises.tables.push([name, e]); } else { promises.views.push([name, e]); } }); }); if (opts.tables) { promises.tables.push(...opts.tables(dummyMigrator(tableMigrator), knex).map((table) => ['table_on_demand', table])); } if (opts.views) { promises.views.push(...opts.views(dummyMigrator(viewMigrator), knex).map((view) => ['view_on_demand', view])); } const convert = (type) => type.charAt(0).toUpperCase() + type.slice(1, -1); const execute = async function execute(i, type) { try { const res = await promises[type][i][1].call(); if (opts.verbose !== false) { console.info(`* ${convert(type)} \`${res}\` has been migrated.`); // eslint-disable-line no-console } if (i < promises[type].length - 1) { await execute(i + 1, type); } } catch (err) { if (opts.verbose !== false) { console.error(`* ${convert(type)} \`${promises[type][i][0]}\` migration failed.`); // eslint-disable-line no-console } reject(err); } }; // eslint-disable-next-line no-restricted-syntax for (const type of Object.keys(promises)) { if (promises[type].length > 0) { // eslint-disable-next-line no-await-in-loop await execute(0, type); } else { console.info(`* No ${convert(type)} schema exist.`); // eslint-disable-line no-console } } resolve(); }); };