hapiest-mysql
Version:
A wrapper around mysql that provides a very descriptive way of running queries.
364 lines (317 loc) • 11.8 kB
JavaScript
'use strict';
const Mysql = require('mysql');
const Promise = require('bluebird');
const Chance = require('chance');
const _ = require('lodash');
const MysqlQuery = require('./mysqlQuery');
const MysqlInsertResultFactory = require('./mysqlModificationResultFactory');
const TransformStream = require('stream').Transform;
class MysqlService {
/**
* @param {MysqlPoolConnectionConfig} writeConnectionConfig
* @param {MysqlPoolConnectionConfig} [readConnectionConfig] - note, if you leave this NULL then reads will use the write connection
* @param {Logger} [logger] - instance of hapiest-logger
*/
constructor(writeConnectionConfig, readConnectionConfig, logger) {
this._timezone = _.get(writeConnectionConfig, 'timezone');
this._readConnectionConfig = readConnectionConfig;
this._writeConnectionConfig = writeConnectionConfig;
this._createConnectionPools();
this._logger = logger; // @TODO: throw an error if logger is not of correct type
this._chance = new Chance();
}
_createConnectionPools() {
this._writePool = Promise.promisifyAll(Mysql.createPool(this._writeConnectionConfig));
if (this._readConnectionConfig) {
const readConnectionConfigs = this._expandMysqlPoolConnectionConfig(this._readConnectionConfig);
this._readPools = readConnectionConfigs.map(readConfig => Promise.promisifyAll(Mysql.createPool(readConfig)));
this._allPools = this._readPools.concat(this._writePool);
} else {
// Read from the write DB if a separate connection isn't provided for reading
this._readPools = [this._writePool];
this._allPools = [this._writePool];
}
}
/**
* Converts input to an array, creating one output for every host input (facilitates multiple read slaves)
* @param {MysqlPoolConnectionConfig} mysqlPoolConnectionConfig
* @returns {MysqlPoolConnectionConfig[]}
* @private
*/
_expandMysqlPoolConnectionConfig(mysqlPoolConnectionConfig) {
const expandedConnectionConfigs = [];
if (Array.isArray(mysqlPoolConnectionConfig.host)) {
const baseConfig = mysqlPoolConnectionConfig.toJSON();
mysqlPoolConnectionConfig.host.forEach(host => {
const config = _.clone(baseConfig);
config.host = host;
expandedConnectionConfigs.push(config);
})
} else {
expandedConnectionConfigs.push(mysqlPoolConnectionConfig);
}
return expandedConnectionConfigs;
}
_getReadPool() {
const slaveIndex = this._chance.integer({min:0, max:this._readPools.length-1});
return this._readPools[slaveIndex];
}
/**
* @param {string} selectQuery
* @returns {Promise.<object|null,Error>}
*/
selectOne(selectQuery) {
const selectOneResult = true;
return this._selectBase(this._getReadPool(), selectQuery, selectOneResult);
}
/**
* @param {string} selectQuery
* @returns {Promise.<object|null,Error>}
*/
selectOneFromMaster(selectQuery) {
const selectOneResult = true;
return this._selectBase(this._writePool, selectQuery, selectOneResult);
}
/**
* @param {string} selectQuery
* @returns {Promise.<object[]|null,Error>}
*/
selectAll(selectQuery) {
const selectOneResult = false;
return this._selectBase(this._getReadPool(), selectQuery, selectOneResult);
}
/**
* @param {string} selectQuery
* @returns {Promise.<object[]|null,Error>}
*/
selectAllFromMaster(selectQuery) {
const selectOneResult = false;
return this._selectBase(this._writePool, selectQuery, selectOneResult);
}
/**
* @param pool
* @param selectQuery
* @param returnOneRow
* @returns {Promise.<object|null>}
*/
_selectBase(pool, selectQuery, returnOneRow) {
return Promise.resolve()
.tap(() => this._logSqlQuery(selectQuery))
.then(() => MysqlQuery.validateSelect(selectQuery))
.then(() => pool.queryAsync(selectQuery))
.then((rows) => {
if (rows.length === 0) {
const data = returnOneRow ? null : [];
return Promise.resolve(data);
} else {
// convert RowDataPacket to a standard JS object
const jsRows = rows.map(MysqlService._convertToJsObject);
return Promise.resolve(returnOneRow ? jsRows[0] : jsRows);
}
})
.catch(err => {
this._logSqlError(err, selectQuery);
throw err;
});
}
/**
* @param {string} insertQuery
* @returns {Promise.<MysqlModificationResult|null,Error>}
*/
insert(insertQuery) {
return Promise.resolve()
.tap(() => this._logSqlQuery(insertQuery))
.then(() => MysqlQuery.validateInsert(insertQuery))
.then(() => this._writePool.queryAsync(insertQuery))
.then((result) => MysqlInsertResultFactory.createFromResult(result))
.catch(err => {
this._logSqlError(err, insertQuery);
throw err;
})
;
}
/**
* @param {string} updateQuery
* @returns {Promise.<MysqlModificationResult|null,Error>}
*/
update(updateQuery) {
return Promise.resolve()
.tap(() => this._logSqlQuery(updateQuery))
.then(() => MysqlQuery.validateUpdate(updateQuery))
.then(() => this._writePool.queryAsync(updateQuery))
.then((result) => MysqlInsertResultFactory.createFromResult(result))
.catch(err => {
this._logSqlError(err, updateQuery);
throw err;
})
;
}
/**
* @param {string} upsertQuery
* @returns {Promise.<MysqlModificationResult|null,Error>}
*/
upsert(upsertQuery) {
return Promise.resolve()
.tap(() => this._logSqlQuery(upsertQuery))
.then(() => MysqlQuery.validateUpsert(upsertQuery))
.then(() => this._writePool.queryAsync(upsertQuery))
.then((result) => MysqlInsertResultFactory.createFromResult(result))
.catch(err => {
this._logSqlError(err, upsertQuery);
throw err;
})
;
}
/**
* @param {string} deleteQuery
* @returns {Promise.<MysqlModificationResult,Error>}
*/
delete(deleteQuery) {
return Promise.resolve()
.tap(() => this._logSqlQuery(deleteQuery))
.then(() => MysqlQuery.validateDelete(deleteQuery))
.then(() => this._writePool.queryAsync(deleteQuery))
.then((result) => MysqlInsertResultFactory.createFromResult(result))
.catch(err => {
this._logSqlError(err, deleteQuery);
throw err;
})
;
}
/**
* @param {string} query
* @returns {Promise.<*>}
*/
executeGenericQuery(query) {
return Promise.resolve()
.tap(() => this._logSqlQuery(query))
.then(() => {return this._writePool.queryAsync(query)}) // We have to execute generic queries against "master");
.catch(err => {
this._logSqlError(err, query);
throw err;
})
;
}
/**
* Executes all queries using executeGenericQuery
* @param {String[]} queries
* @returns {Promise}
*/
executeQueries(queries) {
return Promise.mapSeries(queries, (query) => this.executeGenericQuery(query));
}
clean(input) {
return Mysql.escape(input, false, this._timezone);
}
/** @returns {Promise} */
endPools() {
return Promise.each(this._allPools, pool => pool.endAsync());
}
/**
* Closes the existing pools and recreates them.
* This can be useful if the existing pools encounter an acceptable failure that breaks the connection,
* such as during a long-running script with retry logic.
* @param {int} [delayMs] - Optional delay in milliseconds after closing the pools and recreating them.
* @return {Promise} - Returns after delayMs with the new connection pools.
*/
resetPools(delayMs) {
delayMs = delayMs || 0;
// End the pools asynchronously to ensure this function will return after delayMs.
// Any lingering connections will have to be dealt-with by the implementing code.
this.endPools();
return new Promise(resolve => {
setTimeout(() => {
this._createConnectionPools();
resolve();
}, delayMs);
});
}
/**
* Ping a connection from all pools.
* @return {Promise.<null,Error>}
*/
ping() {
return Promise.each(this._allPools, pool => pingPoolConnection(pool, this._logger));
function pingPoolConnection(pool, logger) {
return pool.getConnectionAsync().then(connection => {
if (logger) logger.debug('Pinging connection from pool.');
connection.release()
});
}
}
/**
* Stream the results of a query.
* @param {String} sql
* @param {ReadableOptions} [streamOptions] Options to be passed to the stream constructor.
* @return {stream.Readable}
*/
streamQuery(sql, streamOptions) {
streamOptions = streamOptions || {};
return this._streamQuery(this._getReadPool(), sql, streamOptions);
}
/**
* Stream the results of a query from the master pool
* @param {String} sql
* @param {ReadableOptions} [streamOptions] Options to be passed to the stream constructor.
* @return {stream.Readable}
*/
streamQueryFromMaster(sql, streamOptions) {
streamOptions = streamOptions || {};
return this._streamQuery(this._writePool, sql, streamOptions);
}
/**
* @param pool
* @param {String} sql
* @param {ReadableOptions} streamOptions
* @return {stream.Readable}
*/
_streamQuery(pool, sql, streamOptions) {
this._logSqlQuery(sql);
const defaults = { objectMode: true };
const opts = _.defaults({
objectMode: true
}, streamOptions);
/** @type {stream.Readable} **/
const queryStream = pool.query(sql)
.on('error', err => this._logSqlError(err, sql))
.stream(opts);
const transform = new MysqlService._TransformResult(opts);
queryStream.on('error', (err) => {
transform.emit('error', err);
transform.end();
});
return queryStream.pipe(transform);
}
static get _TransformResult() {
class TransformResult extends TransformStream {
_transform(result, encoding, cb) {
cb(null, MysqlService._convertToJsObject(result));
}
}
return TransformResult;
}
/**
* @param {Error} err
* @param {string} sql
* @private
*/
_logSqlError(err, sql) {
if (this._logger) {
this._logger.error('Error executing SQL', {sql: sql, err: err});
}
}
_logSqlQuery(sql) {
if (this._logger) {
this._logger.debug('Executing SQL', {sql: sql})
}
}
/**
* Converts a RowDataPacket into a plain JS object.
* @param {RowDataPacket} resultObject description
* @return {object}
*/
static _convertToJsObject(resultObject) {
return Object.assign({}, resultObject);
}
}
module.exports = MysqlService;