501 lines
15 KiB
JavaScript
501 lines
15 KiB
JavaScript
|
|
const MyVC = require('./myvc');
|
|
const fs = require('fs-extra');
|
|
const nodegit = require('nodegit');
|
|
|
|
/**
|
|
* Pushes changes to remote.
|
|
*/
|
|
class Push {
|
|
get usage() {
|
|
return {
|
|
description: 'Apply changes into database',
|
|
params: {
|
|
force: 'Answer yes to all questions',
|
|
user: 'Update the user version instead, for shared databases'
|
|
},
|
|
operand: 'remote'
|
|
};
|
|
}
|
|
|
|
get localOpts() {
|
|
return {
|
|
boolean: {
|
|
force: 'f',
|
|
user: 'u'
|
|
}
|
|
};
|
|
}
|
|
|
|
async run(myvc, opts) {
|
|
const conn = await myvc.dbConnect();
|
|
this.conn = conn;
|
|
|
|
// Obtain exclusive lock
|
|
|
|
const [[row]] = await conn.query(
|
|
`SELECT GET_LOCK('myvc_push', 30) getLock`);
|
|
|
|
if (!row.getLock) {
|
|
let isUsed = 0;
|
|
|
|
if (row.getLock == 0) {
|
|
const [[row]] = await conn.query(
|
|
`SELECT IS_USED_LOCK('myvc_push') isUsed`);
|
|
isUsed = row.isUsed;
|
|
}
|
|
|
|
throw new Error(`Cannot obtain exclusive lock, used by connection ${isUsed}`);
|
|
}
|
|
|
|
// Get database version
|
|
|
|
const version = await myvc.fetchDbVersion() || {};
|
|
|
|
console.log(
|
|
`Database information:`
|
|
+ `\n -> Version: ${version.number}`
|
|
+ `\n -> Commit: ${version.gitCommit}`
|
|
);
|
|
|
|
if (!version.number)
|
|
version.number = String('0').padStart(opts.versionDigits, '0');
|
|
if (!/^[0-9]*$/.test(version.number))
|
|
throw new Error('Wrong database version');
|
|
|
|
if (opts.user) {
|
|
const user = await this.getDbUser();
|
|
let [[userVersion]] = await conn.query(
|
|
`SELECT number, gitCommit
|
|
FROM versionUser
|
|
WHERE code = ? AND user = ?`,
|
|
[opts.code, user]
|
|
);
|
|
userVersion = userVersion || {};
|
|
console.log(
|
|
`User information:`
|
|
+ `\n -> User: ${user}`
|
|
+ `\n -> Version: ${userVersion.number}`
|
|
+ `\n -> Commit: ${userVersion.gitCommit}`
|
|
);
|
|
|
|
if (userVersion.number > version.number)
|
|
version.number = userVersion.number;
|
|
if (userVersion.gitCommit && userVersion.gitCommit !== version.gitCommit)
|
|
version.gitCommit = userVersion.gitCommit;
|
|
}
|
|
|
|
// Prevent push to production by mistake
|
|
|
|
if (opts.remote == 'production') {
|
|
console.log(
|
|
'\n ( ( ) ( ( ) ) '
|
|
+ '\n )\\ ))\\ ) ( /( )\\ ) ( ))\\ ) ( /( ( /( '
|
|
+ '\n(()/(()/( )\\()|()/( ( )\\ ) /(()/( )\\()) )\\())'
|
|
+ '\n /(_))(_)|(_)\\ /(_)) )\\ (((_) ( )(_))(_)|(_)\\ ((_)\\ '
|
|
+ '\n(_))(_)) ((_|_))_ _ ((_))\\___(_(_()|__)) ((_) _((_)'
|
|
+ '\n| _ \\ _ \\ / _ \\| \\| | | ((/ __|_ _|_ _| / _ \\| \\| |'
|
|
+ '\n| _/ /| (_) | |) | |_| || (__ | | | | | (_) | . |'
|
|
+ '\n|_| |_|_\\ \\___/|___/ \\___/ \\___| |_| |___| \\___/|_|\\_|'
|
|
+ '\n'
|
|
);
|
|
|
|
if (!opts.force) {
|
|
const readline = require('readline');
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout
|
|
});
|
|
const answer = await new Promise(resolve => {
|
|
rl.question('Are you sure? (Default: no) [yes|no] ', resolve);
|
|
});
|
|
rl.close();
|
|
|
|
if (answer !== 'yes')
|
|
throw new Error('Changes aborted');
|
|
}
|
|
}
|
|
|
|
// Apply versions
|
|
|
|
console.log('Applying versions.');
|
|
const pushConn = await myvc.createConnection();
|
|
|
|
let nChanges = 0;
|
|
const versionsDir = `${opts.myvcDir}/versions`;
|
|
|
|
function logVersion(type, version, name) {
|
|
console.log('', type.bold, `[${version.bold}]`, name);
|
|
}
|
|
|
|
if (await fs.pathExists(versionsDir)) {
|
|
const versionDirs = await fs.readdir(versionsDir);
|
|
|
|
for (const versionDir of versionDirs) {
|
|
if (versionDir == 'README.md')
|
|
continue;
|
|
|
|
const match = versionDir.match(/^([0-9]+)-([a-zA-Z0-9]+)?$/);
|
|
if (!match) {
|
|
logVersion('[W]'.yellow, '?????', versionDir);
|
|
continue;
|
|
}
|
|
|
|
const versionNumber = match[1];
|
|
const versionName = match[2];
|
|
|
|
if (versionNumber.length != version.number.length) {
|
|
logVersion('[W]'.yellow, '*****', versionDir);
|
|
continue;
|
|
}
|
|
if (version.number >= versionNumber) {
|
|
logVersion('[I]'.blue, versionNumber, versionName);
|
|
continue;
|
|
}
|
|
|
|
logVersion('[+]'.green, versionNumber, versionName);
|
|
const scriptsDir = `${versionsDir}/${versionDir}`;
|
|
const scripts = await fs.readdir(scriptsDir);
|
|
|
|
for (const script of scripts) {
|
|
if (!/^[0-9]{2}-[a-zA-Z0-9_]+\.sql$/.test(script)) {
|
|
console.log(` - Ignoring wrong file name: ${script}`);
|
|
continue;
|
|
}
|
|
|
|
console.log(` - ${script}`);
|
|
await this.queryFromFile(pushConn, `${scriptsDir}/${script}`);
|
|
nChanges++;
|
|
}
|
|
|
|
await this.updateVersion(nChanges, 'number', versionNumber);
|
|
}
|
|
}
|
|
|
|
// Apply routines
|
|
|
|
console.log('Applying changed routines.');
|
|
|
|
let nRoutines = 0;
|
|
let changes = await fs.pathExists(`${opts.workspace}/.git`)
|
|
? await myvc.changedRoutines(version.gitCommit)
|
|
: await myvc.cachedChanges();
|
|
changes = this.parseChanges(changes);
|
|
|
|
const routines = [];
|
|
for (const change of changes)
|
|
if (change.isRoutine)
|
|
routines.push([
|
|
change.schema,
|
|
change.name,
|
|
change.type.name
|
|
]);
|
|
|
|
if (routines.length) {
|
|
await conn.query(
|
|
`DROP TEMPORARY TABLE IF EXISTS tProcsPriv`
|
|
);
|
|
await conn.query(
|
|
`CREATE TEMPORARY TABLE tProcsPriv
|
|
ENGINE = MEMORY
|
|
SELECT * FROM mysql.procs_priv
|
|
WHERE (Db, Routine_name, Routine_type) IN (?)`,
|
|
[routines]
|
|
);
|
|
}
|
|
|
|
for (const change of changes) {
|
|
const fullPath = `${opts.myvcDir}/routines/${change.path}.sql`;
|
|
const exists = await fs.pathExists(fullPath);
|
|
|
|
const actionMsg = exists ? '[+]'.green : '[-]'.red;
|
|
const typeMsg = `[${change.type.abbr}]`[change.type.color];
|
|
console.log('', actionMsg.bold, typeMsg.bold, change.fullName);
|
|
|
|
try {
|
|
const scapedSchema = pushConn.escapeId(change.schema, true);
|
|
|
|
if (exists) {
|
|
if (change.type.name === 'VIEW')
|
|
await pushConn.query(`USE ${scapedSchema}`);
|
|
|
|
await this.queryFromFile(pushConn, `routines/${change.path}.sql`);
|
|
|
|
if (change.isRoutine) {
|
|
await conn.query(
|
|
`INSERT IGNORE INTO mysql.procs_priv
|
|
SELECT * FROM tProcsPriv
|
|
WHERE Db = ?
|
|
AND Routine_name = ?
|
|
AND Routine_type = ?`,
|
|
[change.schema, change.name, change.type.name]
|
|
);
|
|
}
|
|
} else {
|
|
const escapedName =
|
|
scapedSchema + '.' +
|
|
pushConn.escapeId(change.name, true);
|
|
|
|
const query = `DROP ${change.type.name} IF EXISTS ${escapedName}`;
|
|
await pushConn.query(query);
|
|
}
|
|
} catch (err) {
|
|
if (err.sqlState)
|
|
console.warn('Warning:'.yellow, err.message);
|
|
else
|
|
throw err;
|
|
}
|
|
|
|
nRoutines++;
|
|
}
|
|
|
|
if (routines.length) {
|
|
await conn.query(`DROP TEMPORARY TABLE tProcsPriv`);
|
|
await conn.query('FLUSH PRIVILEGES');
|
|
}
|
|
|
|
// Update and release
|
|
|
|
await pushConn.end();
|
|
|
|
if (nRoutines > 0) {
|
|
console.log(` -> ${nRoutines} routines have changed.`);
|
|
} else
|
|
console.log(` -> No routines changed.`);
|
|
|
|
const repo = await nodegit.Repository.open(this.opts.workspace);
|
|
const head = await repo.getHeadCommit();
|
|
|
|
if (version.gitCommit !== head.sha())
|
|
await this.updateVersion(nRoutines, 'gitCommit', head.sha());
|
|
|
|
await conn.query(`DO RELEASE_LOCK('myvc_push')`);
|
|
}
|
|
|
|
parseChanges(changes) {
|
|
const routines = [];
|
|
if (changes)
|
|
for (const change of changes)
|
|
routines.push(new Routine(change));
|
|
return routines;
|
|
}
|
|
|
|
async getDbUser() {
|
|
const [[row]] = await this.conn.query('SELECT USER() `user`');
|
|
return row.user.substr(0, row.user.indexOf('@'));
|
|
}
|
|
|
|
async updateVersion(nChanges, column, value) {
|
|
if (nChanges == 0) return;
|
|
const {opts} = this;
|
|
|
|
column = this.conn.escapeId(column, true);
|
|
|
|
if (opts.user) {
|
|
const user = await this.getDbUser();
|
|
await this.conn.query(
|
|
`INSERT INTO versionUser
|
|
SET code = ?,
|
|
user = ?,
|
|
${column} = ?,
|
|
updated = NOW()
|
|
ON DUPLICATE KEY UPDATE
|
|
${column} = VALUES(${column}),
|
|
updated = VALUES(updated)`,
|
|
[
|
|
opts.code,
|
|
user,
|
|
value
|
|
]
|
|
);
|
|
} else {
|
|
await this.conn.query(
|
|
`INSERT INTO version
|
|
SET code = ?,
|
|
${column} = ?,
|
|
updated = NOW()
|
|
ON DUPLICATE KEY UPDATE
|
|
${column} = VALUES(${column}),
|
|
updated = VALUES(updated)`,
|
|
[
|
|
opts.code,
|
|
value
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes an SQL script.
|
|
*
|
|
* @param {String} file Path to the SQL script
|
|
* @returns {Array<Result>} The resultset
|
|
*/
|
|
async queryFromFile(conn, file) {
|
|
let results = [];
|
|
const stmts = this.querySplit(await fs.readFile(file, 'utf8'));
|
|
|
|
for (const stmt of stmts)
|
|
results = results.concat(await conn.query(stmt));
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Splits an SQL muti-query into a single-query array, it does an small
|
|
* parse to correctly handle the DELIMITER statement.
|
|
*
|
|
* @param {Array<String>} stmts The splitted SQL statements
|
|
*/
|
|
querySplit(sql) {
|
|
const stmts = [];
|
|
let i,
|
|
char,
|
|
token,
|
|
escaped,
|
|
stmtStart;
|
|
|
|
let delimiter = ';';
|
|
const delimiterRe = /\s*delimiter\s+(\S+)[^\S\r\n]*(?:\r?\n|\r)/yi;
|
|
|
|
function begins(str) {
|
|
let j;
|
|
for (j = 0; j < str.length; j++)
|
|
if (sql[i + j] != str[j])
|
|
return false;
|
|
i += j;
|
|
return true;
|
|
}
|
|
|
|
for (i = 0; i < sql.length;) {
|
|
stmtStart = i;
|
|
|
|
delimiterRe.lastIndex = i;
|
|
const match = sql.match(delimiterRe);
|
|
if (match) {
|
|
delimiter = match[1];
|
|
i += match[0].length;
|
|
continue;
|
|
}
|
|
|
|
while (i < sql.length) {
|
|
char = sql[i];
|
|
|
|
if (token) {
|
|
if (!escaped && begins(token.end))
|
|
token = null;
|
|
else {
|
|
escaped = !escaped && token.escape(char);
|
|
i++;
|
|
}
|
|
} else {
|
|
if (begins(delimiter)) break;
|
|
|
|
const tok = tokenIndex.get(char);
|
|
if (tok && begins(tok.start))
|
|
token = tok;
|
|
else
|
|
i++;
|
|
}
|
|
}
|
|
|
|
const len = i - stmtStart - delimiter.length;
|
|
stmts.push(sql.substr(stmtStart, len));
|
|
}
|
|
|
|
const len = stmts.length;
|
|
if (len > 1 && /^\s*$/.test(stmts[len - 1]))
|
|
stmts.pop();
|
|
|
|
return stmts;
|
|
}
|
|
}
|
|
|
|
const typeMap = {
|
|
events: {
|
|
name: 'EVENT',
|
|
abbr: 'EVNT',
|
|
color: 'cyan'
|
|
},
|
|
functions: {
|
|
name: 'FUNCTION',
|
|
abbr: 'FUNC',
|
|
color: 'cyan'
|
|
},
|
|
procedures: {
|
|
name: 'PROCEDURE',
|
|
abbr: 'PROC',
|
|
color: 'yellow'
|
|
},
|
|
triggers: {
|
|
name: 'TRIGGER',
|
|
abbr: 'TRIG',
|
|
color: 'blue'
|
|
},
|
|
views: {
|
|
name: 'VIEW',
|
|
abbr: 'VIEW',
|
|
color: 'magenta'
|
|
},
|
|
};
|
|
|
|
const routineTypes = new Set([
|
|
'FUNCTION',
|
|
'PROCEDURE'
|
|
]);
|
|
|
|
class Routine {
|
|
constructor(change) {
|
|
const path = change.path;
|
|
const split = path.split('/');
|
|
|
|
const schema = split[0];
|
|
const type = typeMap[split[1]];
|
|
const name = split[2];
|
|
|
|
Object.assign(this, {
|
|
path,
|
|
mark: change.mark,
|
|
type,
|
|
schema,
|
|
name,
|
|
fullName: `${schema}.${name}`,
|
|
isRoutine: routineTypes.has(type.name)
|
|
});
|
|
}
|
|
}
|
|
|
|
const tokens = {
|
|
string: {
|
|
start: '\'',
|
|
end: '\'',
|
|
escape: char => char == '\'' || char == '\\'
|
|
},
|
|
id: {
|
|
start: '`',
|
|
end: '`',
|
|
escape: char => char == '`'
|
|
},
|
|
multiComment: {
|
|
start: '/*',
|
|
end: '*/',
|
|
escape: () => false
|
|
},
|
|
singleComment: {
|
|
start: '-- ',
|
|
end: '\n',
|
|
escape: () => false
|
|
}
|
|
};
|
|
|
|
const tokenIndex = new Map();
|
|
for (const tokenId in tokens) {
|
|
const token = tokens[tokenId];
|
|
tokenIndex.set(token.start[0], token);
|
|
}
|
|
|
|
module.exports = Push;
|
|
|
|
if (require.main === module)
|
|
new MyVC().run(Push);
|