imago-sql
Version:
Small library to work with Azure SQL and MySQL via a single interface.
439 lines (392 loc) • 13.7 kB
JavaScript
/**
* @fileOverview Implementation of the SQL interface for the MYSQL DB.
* @author Eric 2018-01-12
*/
const mysql = require('mysql');
const CONN_STRING_PREFIX_LENGTH = 'mysql://'.length;
const SQL_CONNECTION_TIMEOUT = 60 * 60 * 1000;
const SQL_ACQUIRE_TIMEOUT = 60 * 60 * 1000;
const SQL_TIMEOUT = 60 * 60 * 1000;
const SQL_CONNECTION_LIMIT = 10;
const SQL_CHARSET = 'utf8mb4';
const DEFAULT_OPTIONS = {
/** Show debug messages */
debug: false,
/** Return null instead of throwing an exception */
swallowExceptions: false,
};
/** Implementation of the SQL interface for the MYSQL DB. */
class MySql {
/**
*
* @param {string} connectionString
* @param {object} options
*/
constructor(connectionString, options) {
this.connectionString = connectionString;
this.options = Object.assign({}, DEFAULT_OPTIONS, options);
}
/**
* Selects all items from a table.
* @param {string} tableName - The name of the SQL table to return all items
* from. Must be SQL-safe.
* @returns {object[]} - An array of all rows from the SQL table.
*/
async selectAll(tableName) {
return this.query(`SELECT * FROM ${tableName}`);
}
/**
* Executes the specified SQL command.
* @param {string} statement - An SQL statement, e.g. "SELECT * FROM ...".
*/
async query(statement) {
return this.runSqlQuery(statement);
}
/**
* Executes the SQL command with row data.
* @param {string} statement - An SQL statement, e.g. "INSERT INTO ...".
* @param {string} rows - Data Insert with Array Format".
*/
async queryInsertRows(statement, rows) {
return this.runArrayDataQuery(statement, rows);
}
/**
* Executes the specified SQL command, returns raw results, which may
* include multiple record sets.
* @param {string} statement - An SQL statement, e.g. "SELECT * FROM ...".
*/
async runSqlQuery(statement) {
try {
// TODO FIXME this is wasteful, we need to maintain a global
// connection pool to avoid connecting to DB each time.
let pool;
try {
const {
user, password, host, database,
} = parseMySqlConnectionString(this.connectionString);
pool = mysql.createPool({
connectionLimit: SQL_CONNECTION_LIMIT,
connectTimeout: SQL_CONNECTION_TIMEOUT,
acquireTimeout: SQL_ACQUIRE_TIMEOUT,
timezone: 'UTC',
timeout: SQL_TIMEOUT,
user,
password,
host,
database,
charset: SQL_CHARSET,
});
} catch (error) {
console.log('Error connecting to MySQL:');
throw error;
}
const dataList = await this.queryMysql(pool, statement);
pool.end();
return dataList;
} catch (error) {
console.log(`SQL error when running this statement: ${statement}`);
console.log(error);
if (this.options.swallowExceptions) {
return null;
}
throw error;
}
}
/**
* Runs a query with SQL parameters.
* @param {string} statement - A statement that includes parameters, e.g.
* "SELECT * FROM table WHER ID = @user_id".
* @param {object[]} parameters - An array of objects, each of which includes
* `name`, `type` (like sql.Int) and `value`.
* @returns {object}
*/
async runParameterizedQuery(statement, parameters) {
try {
// TODO save pool globally, avoid connecting each time
let pool;
try {
const {
user, password, host, database,
} = parseMySqlConnectionString(this.connectionString);
pool = mysql.createPool({
connectionLimit: SQL_CONNECTION_LIMIT,
connectTimeout: SQL_CONNECTION_TIMEOUT,
acquireTimeout: SQL_ACQUIRE_TIMEOUT,
timezone: 'UTC',
timeout: SQL_TIMEOUT,
user,
password,
host,
database,
charset: SQL_CHARSET,
});
} catch (error) {
console.log('Error connecting to SQL: ', error);
throw error;
}
const dataList = await this.queryMysql(pool, statement, parameters);
pool.end();
return dataList;
} catch (error) {
console.log('SQL error when running this statement: ', statement);
console.log(error);
if (this.options.swallowExceptions) {
return null;
}
throw error;
}
}
/**
* Runs a query with SQL parameters.
* @param {string} statement - A statement that includes parameters, e.g.
* "SELECT * FROM table WHER ID = @user_id".
* @param {object[]} rows - An array of objects, each of which includes
* `name`, `type` (like sql.Int) and `value`.
* @returns {object}
*/
async runArrayDataQuery(statement, rows) {
try {
// TODO FIXME reuse connection, do not create each time
let pool;
try {
const {
user, password, host, database,
} = parseMySqlConnectionString(this.connectionString);
pool = mysql.createPool({
connectionLimit: SQL_CONNECTION_LIMIT,
connectTimeout: SQL_CONNECTION_TIMEOUT,
acquireTimeout: SQL_ACQUIRE_TIMEOUT,
timezone: 'UTC',
timeout: SQL_TIMEOUT,
user,
password,
host,
database,
charset: SQL_CHARSET,
});
} catch (error) {
console.log('Error connecting to SQL: ', error);
throw error;
}
const dataList = await this.queryMysql(pool, statement, [rows]);
return dataList;
} catch (error) {
console.log(`SQL error when running this statement: ${statement}`, error);
if (this.options.swallowExceptions) {
return null;
}
throw error;
}
}
/**
* Inserts multiple rows into a table simultaneously.
* @param {string} table - The name of the table to insert the data into.
* @param {string[]} columns - Names of columns to insert the data into.
* The order of columns in `rows` must be the same as here.
* @param {Array.<string[]>} - An array of rows, where each row is an array
* of values in columns. The columns are the same as described in `columns`.
* @returns {Promise}
*/
async insertMultipleRows(table, columns, rows) {
if (!table || !columns || !rows) {
throw new Error('Empty parameters provided to insertMultipleRows().');
}
if (typeof table !== 'string' || !(columns instanceof Array) || !(rows instanceof Array)) {
throw new Error('Invalid parameter types for insertMultipleRows().');
}
const statement = `INSERT INTO ${table} (${columns.join(', ')}) VALUES ? `;
// Execute statement, return "OK" if success
try {
return await this.queryInsertRows(statement, rows);
} catch (error) {
console.log('Failed to insert rows into database: ', error);
if (this.options.swallowExceptions) {
return null;
}
throw error;
}
}
/**
* Truncates the specified table (removes all rows, resets the auto-increment
* counter).
* @param {string} table - The name of the table to be truncated.
* @returns {string} 'OK' if truncated successfully.
*/
async truncate(table) {
if (!table) {
throw new Error('Empty table name when trying to truncate.');
}
return this.query(`TRUNCATE TABLE ${table}`);
}
/**
* Saves the entire object as a row into the database. The object's property
* names must match the column names.
* @param {string} table - The table name.
* @param {object} data - The object to be saved as a row.
*/
async save(table, data) {
const keys = Object.keys(data);
const statement = `INSERT INTO ${table} (${keys.join(', ')}) `
+ 'VALUES ? ';
const values = [Object.values(data)];
return this.queryInsertRows(statement, values);
}
/**
* Bulk import huge of dataset to database
* @param {*} table - The table object contains the dataset
*/
async bulkInsert(table) {
// To be implemented in MySQL
throw new Error('TODO FIXME: bulkInsert() not implemented for MySQL');
}
/**
* Executes a stored procedure.
* @param {string} procedure - The name of the procedure to execute
* @param {object} data - Procedure parameters. Types guessed automatically.
*/
async execute(procedure, data) {
// Generate SQL statement and as many question marks as properties in `data`:
const questionMarks = Object.keys(data).map(() => '?').join(', ');
const statement = `CALL ${procedure}(${questionMarks})`;
// TODO FIXME - need to implement conversion from object into array:
const convertedParameters = [];
throw new Error('TODO FIXME: execute() not implemented for MySQL');
/* eslint-disable-next-line */ // TODO FIXME
return this.runParameterizedQuery(statement, convertedParameters);
}
/**
* Escapes a string to make it SQL-safe.
* @param {string} s - A string to be saved in SQL, potentially unsafe.
* @returns {string} The escaped SQL-safe string.
*/
static escapeString(s) {
if (typeof s !== 'string') {
return s;
}
/* eslint no-control-regex:1 */
return s.replace(/[\0\x08\x09\x1a\n\r"'\\%]/g, (char) => {
switch (char) {
case '\0':
return '\\0';
case '\x08':
return '\\b';
case '\x09':
return '\\t';
case '\x1a':
return '\\z';
case '\n':
return '\\n';
case '\r':
return '\\r';
case "'":
return "''"; // added by Anton, seems can't use backslash to escape
case '"':
case '\\':
case '%':
// Prepends a backslash to backslash, percent, and double/single
// quotes:
return `\\${char}`;
default:
throw new Error('Code problem in imago-sql: unexpected character in regex.');
}
});
}
/**
* Connect with mysql server package, execute sql & return status
* @param {Pool} pool - Connection Pool, contains connection details.
* @param {sql} sql - Statment queries to execute.
* @param {Array.<string[]>} rows - Allows null, to execute the data follow
* sql to server. System auto escape data in this format.
* @returns {object[]} - An array of result / execute result of sql.
*/
queryMysql(pool, sql, rows) {
return new Promise((resolve, reject) => {
pool.getConnection((error, connection) => {
if (error) {
console.log('MySQL connection failed: ', error, connection);
reject(error);
} else {
if (this.options.debug) {
console.log(`Running query: ${sql}`);
}
if (rows !== undefined) {
// To allow TSQL-style parameters like "@parameterName", we change
// that syntax into MySQL syntax (just simply a question mark '?').
({ rows, sql } = convertTsqlSyntaxToMysql(rows, sql));
connection.query(sql, rows, (innerError, receivedRows) => {
connection.destroy();
if (innerError) {
reject(innerError);
} else {
resolve(receivedRows);
}
});
} else {
connection.query(sql, (innerError, returnedRows) => {
connection.destroy();
if (innerError) {
reject(innerError);
} else {
resolve(returnedRows);
}
});
}
}
});
});
}
}
/**
* To allow TSQL-style parameters like "@parameterName", we change
* that syntax into MySQL syntax (just simply a question mark '?').
* @param {object[]} rows
* @param {string} sql - The SQL statement.
*/
function convertTsqlSyntaxToMysql(rows, sql) {
if (typeof (rows[0]) === 'object' && !Array.isArray(rows[0])) {
try {
const mysqlStyleRows = [];
const rowsObject = {};
for (let i = 0; i < rows.length; i++) {
if (typeof (rows[i]) === 'object') {
const { name, value } = rows[i];
rowsObject[name] = value;
}
}
const newSql = sql.replace(/@[\w]*/gi, (x) => {
const newItem = x.replace('@', '');
mysqlStyleRows.push(rowsObject[newItem]);
return '?';
});
// Replace TSQL's GETDATE with MySQL's NOW() function:
sql = newSql.replace(/GETDATE\(\)/gi, 'NOW()');
return {
rows: mysqlStyleRows,
sql,
};
} catch (e) {
console.log(e);
return { rows, sql };
}
} else {
return { rows, sql };
}
}
/**
* Convers a connection string like "mysql://user:pass@host/db" into separate
* variables.
* @param {string} connString
*/
function parseMySqlConnectionString(connString) {
// Cut off "mysql://":
connString = connString.substr(CONN_STRING_PREFIX_LENGTH);
const userAndPassword = connString.substr(0, connString.lastIndexOf('@'));
const [user, password] = userAndPassword.split(':');
// Host and database name:
const hostAndDb = connString.substr(connString.lastIndexOf('@') + 1).split('?')[0];
const [host, database] = hostAndDb.split('/');
return {
user, password, host, database,
};
}
module.exports = MySql;