UNPKG

imago-sql

Version:

Small library to work with Azure SQL and MySQL via a single interface.

439 lines (392 loc) 13.7 kB
/** * @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;