UNPKG

db-auto-migrate

Version:

Automatic database migration with 'CREATE TABLE' document.

606 lines (535 loc) 20.8 kB
'use strict' const fs = require('fs') const path = require('path') const assert = require('assert') const sqlParser = require('./sqlParser') const sqlFormat = require('./sqlFormat') const util = require('./util') const similarity = require('string-similarity').compareTwoStrings let ID = name => '`' + name + '`' // 非分组内的类型 认为是不同的列(非重命名) let GROUP_TO_DATATYPE = { 'int': ['tinyint', 'smallint', 'mediumint', 'int', 'integer', 'bigint', 'float', 'double', 'decimal'], 'str': ['char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext'], 'blob': ['tinyblob', 'blob', 'mediumblob', 'longblob'], 'date': ['date', 'time', 'year', 'datetime', 'timestamp'] } let DATATYPE_TO_GROUP = {} for (const group in GROUP_TO_DATATYPE) { for (const dataType of GROUP_TO_DATATYPE[group]) { DATATYPE_TO_GROUP[dataType] = group } } module.exports = class autoSync { constructor(options = {}) { this.simiThreshold = options.simiThreshold || 0.7 this.maxTry = options.maxTry || 10 this.tableFilter = new Set(options.tableFilter || []) } setSimiThreshold(value) { this.simiThreshold = value } setMaxTry(num = 10) { this.maxTry = num } setTableFilter(tables = []) { this.tableFilter = new Set(tables) } getTableGroup(dir, prefix = '') { let group = {} let files = fs.readdirSync(dir) for (const file of files) { if (!(file.endsWith('.sql') && file.startsWith(prefix))) continue let filePath = path.join(dir, file) let stat = fs.statSync(filePath) if (stat.isFile()) { let content = fs.readFileSync(filePath, 'utf8') let groupName = file.slice(prefix.length, -4) content = util.removeComment(content) group[groupName] = [] let createTables = util.splitOutQuote(content, ';') for (let sql of createTables) { sql = sql.trim() if (!sql) continue let tableName = sqlParser.parseTableNames(sql) if (tableName) group[groupName].push({ sql: sql + ';', tableName }) } } } return group } getCreateTablesByDir(dir, prefix = '') { let group = this.getTableGroup(dir, prefix) let createTables = {} for (const name in group) { for (const { sql, tableName } of group[name]) { createTables[tableName] = sql } } return createTables } async getTableSchemas(db, tables) { let isArray = Array.isArray(tables) if (!isArray) tables = [tables] let schemas = {} for (const tableName of tables) { //todo: } return isArray ? schemas : schemas[tables[0]] } async dataBaseDiff(curDb, tgtDb) { let cur = await this.getCreateTablesByDb(curDb) let tgt = await this.getCreateTablesByDb(tgtDb) return this.diffByCreateTables(cur, tgt) } // cur, tgt是通过 getCreateTablesByDb 获得的数据 diffByCreateTables(curTables, tgtTables) { let addTables = {} let delTables = {} let changeTables = [] for (const tableName in curTables) { if (!tgtTables.hasOwnProperty(tableName)) { delTables[tableName] = curTables[tableName] } else if (curTables[tableName] != tgtTables[tableName]) { changeTables.push({ cur: { name: tableName, sql: curTables[tableName] }, tgt: { name: tableName, sql: tgtTables[tableName] }, }) } } for (const tableName in tgtTables) { if (!curTables.hasOwnProperty(tableName)) addTables[tableName] = tgtTables[tableName] } this.adjustRenameTables(delTables, addTables, changeTables) let diffNum = Object.keys(addTables).length + Object.keys(delTables).length + changeTables.length return { addTables, delTables, changeTables, diffNum } } //两个参数都会被修改 removeSameTables(curTables, tgtTables) { let sameTable = [] for (const tableName in curTables) { if (curTables[tableName] == tgtTables[tableName]) { delete curTables[tableName] delete tgtTables[tableName] } } return sameTable } getMaybeRenameTable(sql, others) { let simiTable = null let maxSimi = this.simiThreshold for (const tableName in others) { let simi = similarity(sql, others[tableName]) if (simi > maxSimi) { simiTable = tableName maxSimi = simi } } return simiTable } //三个参数都惠改变 adjustRenameTables(delTables, addTables, changeTables) { for (const tableName in delTables) { let renameTo = this.getMaybeRenameTable(delTables[tableName], addTables) if (renameTo) { changeTables.push({ cur: { name: tableName, sql: delTables[tableName] }, tgt: { name: renameTo, sql: addTables[renameTo] } }) delete delTables[tableName] delete addTables[renameTo] } } } getMaybeRenameColumn(column, others) { let { dataType, defi, pre, next } = column let group = DATATYPE_TO_GROUP[dataType] if (group === undefined) return null let simiColumn = null let maxSimi = this.simiThreshold for (const colName in others) { let other = others[colName] if (group != DATATYPE_TO_GROUP[other.dataType]) continue let simiArray = [] simiArray.push({ weight: 1, value: similarity(defi, other.defi) }) if (pre && other.pre) { simiArray.push({ weight: 0.5, value: similarity(pre.defi, other.pre.defi) }) } else { simiArray.push({ weight: 0.4, value: pre === other.pre ? 1 : 0 }) } if (next && other.next) { simiArray.push({ weight: 0.5, value: similarity(next.defi, other.next.defi) }) } else { simiArray.push({ weight: 0.4, value: next === next ? 1 : 0 }) } let [sumWeight, sum] = simiArray.reduce((s, v) => [s[0] + v.weight, s[1] + v.weight * v.value], [0, 0]) let simi = sum / sumWeight if (simi > maxSimi) { simiColumn = colName maxSimi = simi } } return simiColumn } //三个参数都惠改变 adjustRenameColumn(delColumns, addColumns, changeFrom) { for (const colName in delColumns) { let renameTo = this.getMaybeRenameColumn(delColumns[colName], addColumns) if (renameTo) { changeFrom[renameTo] = colName delete delColumns[colName] delete addColumns[renameTo] } } } //分多个阶段构建迁移命令, 计算过程中 db 不会被改变, tempDb 会被清空重建多次 //tempDb 的初始状态就是需要的升级到的目标状态, 计算结束 tempDb 会恢复至初始状态 async createMigrationByDb(db, tempDb) { let curTables = await this.getCreateTablesByDb(db) let tgtTables = await this.getCreateTablesByDb(tempDb) return this.createMigrationByTables(tempDb, curTables, tgtTables) } async createMigrationByTables(tempDb, curTables, tgtTables) { await tempDb.checkTempDb() let sign = { begin: this.getTablesSign(curTables), end: this.getTablesSign(tgtTables) } let _curTables = Object.assign({}, curTables) let _tgtTables = Object.assign({}, tgtTables) this.removeSameTables(_curTables, _tgtTables) await this.initTempDbByTables(tempDb, _curTables) let migration = [] await this._sync(tempDb, _tgtTables, 'Table', migration) await this._sync(tempDb, _tgtTables, 'Option', migration) // Column 与 Key 相互依赖,为简化算法,采用是在维度采用试错算法 for (let i = 0; i < this.maxTry; i++) { let colSucceed = await this._sync(tempDb, _tgtTables, 'Column', migration, true) let keySucceed = await this._sync(tempDb, _tgtTables, 'Key', migration, true) if (colSucceed && keySucceed) break } let results = { migration, sign } let newTgtTables = await this.getCreateTablesByDb(tempDb) let diffData = this.diffByCreateTables(newTgtTables, _tgtTables) results.succeed = diffData.diffNum == 0 if (!results.succeed) { results.diffData = diffData } return results } async _sync(tempDb, tgtTables, type, outMigration, ignoreError = false) { let curTables = await this.getCreateTablesByDb(tempDb) let diffData = this.diffByCreateTables(curTables, tgtTables) let migration = this[`get${type}Migration`](diffData) let { succeed, failed, msg } = await this._doMigration(tempDb, migration, ignoreError) outMigration.push(...succeed) if (!ignoreError && failed.length > 0) throw new Error(msg[0]) return failed.length == 0 } getTableMigration(diffData) { let { addTables, delTables, changeTables } = diffData let migration = [] //先计算 up this.getMigrationSql_addTables(addTables, migration) this.getMigrationSql_delTables(delTables, migration) this.getMigrationSql_renameTables(changeTables, migration) return migration } getOptionMigration(diffData) { let migration = [] for (const { cur, tgt } of diffData.changeTables) { assert(cur.name == tgt.name) let curInfo = sqlParser.parseCreateSql(cur.sql, ['options']) let tgtInfo = sqlParser.parseCreateSql(tgt.sql, ['options']) if (curInfo.options.sql != tgtInfo.options.sql) { migration.push(`ALTER TABLE ${ID(cur.name)} ${tgtInfo.options.sql}`) } } return migration } getColumnMigration(diffData) { let migration = [] for (const { cur, tgt } of diffData.changeTables) { assert(cur.name == tgt.name) let curInfo = sqlParser.parseCreateSql(cur.sql, ['columns', 'keys']) let tgtInfo = sqlParser.parseCreateSql(tgt.sql, ['columns']) this.getMigrationSql_columns(tgt.name, curInfo, tgtInfo.columns, migration) } return migration } getKeyMigration(diffData) { let migration = [] for (const { cur, tgt } of diffData.changeTables) { assert(cur.name == tgt.name) let curInfo = sqlParser.parseCreateSql(cur.sql, ['keys']) let tgtInfo = sqlParser.parseCreateSql(tgt.sql, ['keys']) this.getMigrationSql_keys(tgt.name, curInfo.keys, tgtInfo.keys, migration) } return migration } getMigrationSql_delTables(delTables, outMigration) { for (const tableName in delTables) { outMigration.push(`DROP TABLE ${ID(tableName)}`) } } getMigrationSql_addTables(addTables, outMigration) { for (const tableName in addTables) { outMigration.push(addTables[tableName]) } } getMigrationSql_renameTables(changeTables, outMigration) { for (const { cur, tgt } of changeTables) { if (cur.name != tgt.name) { outMigration.push(`ALTER TABLE ${ID(cur.name)} RENAME TO ${ID(tgt.name)}`) } } } getMigrationSql_columns(tableName, curInfo, tgtColumns, outMigration) { let { columns: curColumns, keys: curKeys } = curInfo let addColumns = {} let delColumns = {} for (const colName in curColumns) { if (!tgtColumns.hasOwnProperty(colName)) delColumns[colName] = curColumns[colName] } for (const colName in tgtColumns) { if (!curColumns.hasOwnProperty(colName)) addColumns[colName] = tgtColumns[colName] } let changeFrom = {} this.adjustRenameColumn(delColumns, addColumns, changeFrom) let curOrder = Object.keys(curColumns).sort((a, b) => curColumns[a].pos - curColumns[b].pos) for (const colName in delColumns) { let node = delColumns[colName] //表内外键约束检测 (表外已经通过 'SET FOREIGN_KEY_CHECKS = 1' 忽略了) for (const key in curKeys) { if (key.type != 'foreignKey' || key.isDrop) continue if (key.columns.includes(key)) { outMigration.push(this.getAlterSql_delKey(tableName, key)) key.isDrop = true } } outMigration.push(this.getAlterSql_delColumn(tableName, node)) let index = curOrder.indexOf(colName) curOrder.splice(index, 1) } let tgtOrder = Object.keys(tgtColumns).sort((a, b) => tgtColumns[a].pos - tgtColumns[b].pos) for (const colName of tgtOrder) { let info = tgtColumns[colName] let oldName = changeFrom[colName] if (oldName) { info.oldName = oldName outMigration.push(this.getAlterSql_changeColumn(tableName, info, info.pre)) let index = curOrder.indexOf(oldName) if (index == info.pos) { curOrder[index] = colName } else { curOrder.splice(index, 1) curOrder.splice(info.pos, 0, oldName) } } else if (addColumns[colName]) { outMigration.push(this.getAlterSql_addColumn(tableName, info, info.pre)) curOrder.splice(info.pos, 0, colName) } else { let index = curOrder.indexOf(colName) if (index != info.pos) { outMigration.push(this.getAlterSql_changeColumn(tableName, info, info.pre)) curOrder.splice(index, 1) curOrder.splice(info.pos, 0, oldName) } else if (info.sql != curColumns[colName].sql) { outMigration.push(this.getAlterSql_changeColumn(tableName, info, info.pre)) } } } assert.deepStrictEqual(curOrder, tgtOrder) } getMigrationSql_keys(tableName, curKeys, tgtKeys, outMigration) { let addKeys = {} let delKeys = {} let changeKeys = [] let renameKeys = {} for (const keyName in curKeys) { let cur = curKeys[keyName] if (!tgtKeys.hasOwnProperty(keyName)) { delKeys[keyName] = cur } else if (cur.sql != tgtKeys[keyName].sql) { changeKeys.push({ cur, tgt: tgtKeys[keyName] }) } } for (const keyName in tgtKeys) { if (!curKeys.hasOwnProperty(keyName)) addKeys[keyName] = tgtKeys[keyName] } for (const oldKeyName in delKeys) { let oldInfo = delKeys[oldKeyName] if (oldInfo.type == 'foreignKey') continue for (const newKeyName in addKeys) { let newInfo = addKeys[newKeyName] if (oldInfo.sql.replace(oldKeyName, newKeyName) == newInfo.sql) { renameKeys[oldKeyName] = newKeyName delete delKeys[oldKeyName] delete addKeys[newKeyName] } } } //先处理 foreignKey 删除, 再删除普通 key this._dropKey(tableName, delKeys, changeKeys, type => type == 'foreignKey', outMigration) this._dropKey(tableName, delKeys, changeKeys, type => type != 'foreignKey', outMigration) let delayDel = [] //延迟删除, 删除key如何有外键依赖, 则会先重命名后延迟删除, for (const oldKeyName in renameKeys) { outMigration.push(this.getAlterSql_renameKey(tableName, oldKeyName, renameKeys[oldKeyName])) } for (const { cur, tgt } of changeKeys) { outMigration.push(this.getAlterSql_addKey(tableName, tgt)) } for (const keyName in addKeys) { outMigration.push(this.getAlterSql_addKey(tableName, addKeys[keyName])) } outMigration.push(...delayDel) } _dropKey(tableName, delKeys, changeKeys, check = () => true, outMigration) { for (const keyName in delKeys) { let info = delKeys[keyName] if (check(info.type)) outMigration.push(this.getAlterSql_delKey(tableName, info)) } for (const { cur, tgt } of changeKeys) { if (check(cur.type)) outMigration.push(this.getAlterSql_delKey(tableName, cur)) } } getAlterSql_delKey(tableName, info) { info.isDrop = true if (info.type == 'primaryKey') { return `ALTER TABLE ${ID(tableName)} DROP PRIMARY KEY` } else if (info.type == 'uniqueKey' || info.type == 'key') { return `ALTER TABLE ${ID(tableName)} DROP KEY ${ID(info.name)}` } else if (info.type == 'foreignKey') { return `ALTER TABLE ${ID(tableName)} DROP FOREIGN KEY ${ID(info.name)}` } else { throw new Error('目前不支持的 key 类型: ' + info.type) } } getAlterSql_addKey(tableName, info) { return `ALTER TABLE ${ID(tableName)} ADD ${info.sql}` } getAlterSql_renameKey(tableName, oldName, newName) { return `ALTER TABLE ${ID(tableName)} RENAME KEY ${ID(oldName)} TO ${ID(newName)}` } getAlterSql_delColumn(tableName, info) { return `ALTER TABLE ${ID(tableName)} DROP COLUMN ${ID(info.name)}` } getAlterSql_addColumn(tableName, info, afterCol) { if (afterCol) { return `ALTER TABLE ${ID(tableName)} ADD COLUMN ${info.sql} AFTER ${ID(afterCol.name)}` } else { return `ALTER TABLE ${ID(tableName)} ADD COLUMN ${info.sql} FIRST` } } getAlterSql_changeColumn(tableName, info, afterCol) { let sql = '' if (info.oldName && info.oldName != info.name) { sql = `ALTER TABLE ${ID(tableName)} CHANGE COLUMN ${ID(info.oldName)} ${info.sql}` } else { sql = `ALTER TABLE ${ID(tableName)} MODIFY COLUMN ${info.sql}` } return sql + (afterCol ? ` AFTER ${ID(afterCol.name)}` : ' FIRST') } async clearTempDataBase(tempDb) { await tempDb.checkTempDb() let tableNames = await this.getDbTables(tempDb, false) await tempDb.offForeignKey(async () => { await tempDb.queryM(tableNames.map(tableName => `DROP TABLE ${ID(tableName)}`)) }) } //通过文件夹内 sql 文件创建数据库 async initTempDbByDir(tempDb, dir, prefix = '') { await tempDb.checkTempDb() await this.clearTempDataBase(tempDb) let group = this.getTableGroup(dir, prefix) await tempDb.offForeignKey(async () => { for (const name in group) { for (let { sql } of group[name]) { await tempDb.query(sql) } } }) return group } async initTempDbByTables(tempDb, createTables) { await tempDb.checkTempDb() await this.clearTempDataBase(tempDb) await tempDb.offForeignKey(async () => { let tables = [] for (let tableName in createTables) { tables.push(createTables[tableName]) } await tempDb.queryM(tables) }) } //通过 clone 数据库结构到另一个数据库 async cloneStructToTempDb(db, tempDb) { let createTables = await this.getCreateTablesByDb(db) await this.initTempDbByTables(tempDb, createTables) } async getDbTables(db, filter = true) { let colName = 'Tables_in_' + db.database let rt = await db.query('SHOW TABLES') let tableNames = rt.map(row => row[colName]) if (filter) tableNames = tableNames.filter(name => !this.tableFilter.has(name)) return tableNames } async getCreateTablesByDb(db, tables) { if (!tables) tables = await this.getDbTables(db) let createTables = {} let args = tables.map(tableName => `SHOW CREATE TABLE ${ID(tableName)}`) let results = await db.queryM(args) for (const [data] of results) { let sql = data['Create Table'] + ';' sql = sqlFormat.formatOne(sql, sqlFormat.rules['noAutoIncrement']) createTables[data['Table']] = sql } return createTables } async getTablesSignByDb(db) { let createTables = await this.getCreateTablesByDb(db) return this.getTablesSign(createTables) } //通过 createTables 获取签名 getTablesSign(createTables, ) { let tableNames = Object.keys(createTables) tableNames.sort() let str = '' for (const tableName of tableNames) { str += createTables[tableName] } return util.sha1(str) } async _doMigration(db, migration, force = false) { let succeed = [] let failed = [] let msg = [] if (migration.length > 0) { await db.offForeignKey(async () => { for (const sql of migration) { try { let rt = await db.query(sql) succeed.push(sql) } catch (e) { failed.push(sql) msg.push(e.message) if (!force) break } } }) } let sign = await this.getTablesSignByDb(db) let unexecuted = migration.slice(succeed.length + 1) return { succeed, failed, unexecuted, msg, sign } } async doMigration(db, migration) { let { failed, msg, sign } = await this._doMigration(db, migration) if (failed.length > 0) throw new Error(msg[0]) return sign } async verifyMigration(tempDb, curTables, migration, sign) { await this.initTempDbByTables(tempDb, curTables) let newSign = await this.doMigration(tempDb, migration) return newSign == sign } }