UNPKG

imago-sql

Version:

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

350 lines (304 loc) 11 kB
/** * @fileoverview Implementation of the SQL interface for the Azure SQL DB. * @author Anton 2017-09-12 */ const azure = require('mssql'); const SQL_CONNECTION_RESTART_TIMEOUT = 2000; const DEFAULT_OPTIONS = { /** Show debug messages */ debug: false, /** Return null instead of throwing an exception */ swallowExceptions: false, }; /** Implementation of the SQL interface for the Azure SQL DB. */ class AzureSql { constructor(connectionString, options) { /** * SQL connection string in the URL format. */ this.connectionString = connectionString; this.pool = null; this.options = Object.assign({}, DEFAULT_OPTIONS, options); // Store an async promise in the `connection`. Then before each query we // make sure that this promise is resolved, otherwise we might try to // query SQL server before the connection is established. this.connection = this.createPool(); } async createPool() { this.pool = new azure.ConnectionPool(this.connectionString); // Try to restart connection when it fails: this.pool.on('error', async (err) => { console.log(err, 'Waiting for 2 seconds and restarting SQL connection...'); await new Promise(resolve => setTimeout(resolve, SQL_CONNECTION_RESTART_TIMEOUT)); this.pool = new azure.ConnectionPool(this.connectionString); }); return this.pool.connect(); } /** * 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) { if (this.options.debug) { console.log(`Running query: ${statement}`); } try { await this.connection; const request = new azure.Request(this.pool); const modifiedStatement = returnInsertId(statement); return await request.query(modifiedStatement); } catch (error) { console.log(`SQL error when running this statement: ${statement}`, error); if (this.options.swallowExceptions) { return null; } throw error; } } /** * Executes the specified SQL command. * @param {string} statement - An SQL statement, e.g. "SELECT * FROM ...". */ async query(statement) { const serverResponse = await this.runSqlQuery(statement); return serverResponse ? serverResponse.recordset : serverResponse; } /** * 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) { const keys = Object.keys(data); const parameters = keys.map(key => ({ name: key, value: data[key], type: getSqlDataType(data[key]), })); await this.connection; const request = new azure.Request(this.pool); try { if (this.options.debug) { console.log(`Executing stored procedure ${procedure}...`); } // Add SQL parameters. They are automatically sanitized. for (const parameter of parameters) { request.input(parameter.name, parameter.type, parameter.value); } return await request.execute(procedure); } catch (error) { console.log(`SQL error when running this procedure: ${procedure}`, 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) { if (this.options.debug) { console.log(`Running query: ${statement}`); } await this.connection; const request = new azure.Request(this.pool); try { // Add SQL parameters. They are automatically sanitized. for (const parameter of parameters) { request.input(parameter.name, parameter.type, parameter.value); } const modifiedStatement = returnInsertId(statement); // This must be awaited in order to catch exceptions. return await request.query(modifiedStatement); } catch (error) { console.log(`SQL error when running this statement: ${statement}`, error); if (this.options.swallowExceptions) { return null; } throw error; } } /** * 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}`); } /** * 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.'); } }); } /** * 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<string>} 'OK' if successful. */ 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().'); } let statement = ''; rows.forEach((row) => { statement += `INSERT INTO ${table} (${columns.join(', ')}) VALUES `; // Escape all columns, add quotes and 'N': const columnValues = row.map(column => `N'${AzureSql.escapeString(column)}'`); statement += `(${columnValues.join(', ')}); \n`; }); statement = returnInsertId(statement); // Execute statement: return this.query(statement); } /** * 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. We will try to guess the data type. * @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 (${(keys.map(key => `@${key}`)).join(', ')});`; const parameters = keys.map(key => ({ name: key, value: data[key], type: getSqlDataType(data[key]), })); return this.runParameterizedQuery(statement, parameters); } /** * Bulk import huge of dataset to database * @param {*} table - The table object contains the dataset */ async bulkInsert(table) { await this.connection; const request = new azure.Request(this.pool); try { return await request.bulk(table) .then(data => { console.log(data); }) .catch(err => { console.log(err); }); } catch (error) { console.log(`SQL error when running bulk insert`, error); if (this.options.swallowExceptions) { return null; } throw error; } } } /** * Selects the data type automatically. * @param {any} value * @returns The SQL data type. */ function getSqlDataType(value) { if (typeof value === 'number') { if (Number.isInteger(value)) { return azure.Int; } return azure.Float; } if (typeof value === 'boolean') { return azure.Bit; } if (typeof value === 'string') { return azure.NVarChar; } if (typeof value === 'undefined' || value === null) { return azure.NVarChar; // there is no "null" type } throw new Error( `Unknown data type when trying to determine data type for ${value}`, ); } /** * If the statement is an INSERT statement, append code that selects the * last inserted row's ID and returns that ID. * Warning: this may interfere with statements that insert and select at the * same time. To avoid using this feature, add any comment at the beginning * of the statement. * TODO FIXME - needs to be fixed to avoid interfering with queries when not * intended. * @param {string} statement - SQL statment * @returns {string} SQL Statment. */ function returnInsertId(statement) { if (statement.toLowerCase().startsWith('insert')) { return `${statement} -- Automatically appended to select the last added row ID: SELECT SCOPE_IDENTITY() AS id;`; } return statement; } module.exports = AzureSql; // Make the mssql package available to the outside code so that we can // use the types, such as azure.NVarChar when calling the .runParameterizedQuery(): // Adding the mssql package twice into the code using require('mssql') causes // unexpected errors described in the package's issue tracker. module.exports.mssql = azure; module.exports.jest = { returnInsertId, };