imago-sql
Version:
Small library to work with Azure SQL and MySQL via a single interface.
350 lines (304 loc) • 11 kB
JavaScript
/**
* @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,
};