UNPKG

hapiest-mysql

Version:

A wrapper around mysql that provides a very descriptive way of running queries.

723 lines (634 loc) 23.3 kB
'use strict'; const Promise = require('bluebird'); const _ = require('lodash'); const MysqlDaoQueryHelper = require('./mysqlDaoQueryHelper'); const TransformStream = require('stream').Transform; class MysqlDao { /** * @name {MysqlDaoGetAllOptions} * @type {Object} * @property {Object} [sort] - properties should be column names, values are 'asc'/'desc' * @property {int} [limit] * @property {int} [offset] - you have to provide a limit if you provide an offset */ /** * @name {MysqlDaoGetAllAndCountResult} * @type {Object} * @property {Array[dbRows]} [results] * @property {int} [count] */ /** * @param {MysqlDaoArgs} args */ constructor(args) { this._mysqlService = args.mysqlService; this._queryHelper = new MysqlDaoQueryHelper(this.tableName, args.mysqlService.clean.bind(args.mysqlService)); this._createVoFromDbRow = args.createVoFromDbRowFunction; this._logger = args.logger; } /** * @returns {string} */ get tableName() { throw new Error('Extending class must override tableName() with actual table') } /** * @returns {MysqlService} */ get mysqlService() { return this._mysqlService; } /** * @param {object} createArgs column values to be inserted * @param {object} [opts] additional insert options * @param {boolean} [opts.ignoreOnDuplicateKey] if true, insert will ignore duplicate keys * @returns {Promise.<int,Error>} - ID of last inserted item; or 0 if ignoreOnDuplicateKey is true and nothing new was inserted */ create(createArgs, opts) { const sql = this._queryHelper.create(createArgs, opts); return this.createFromSql(sql); } /** * @param {string} sql * @returns {Promise.<int,Error>} - ID of last inserted item */ createFromSql(sql) { return this._mysqlService.insert(sql) .then(result => result.insertId) .catch(err => { this._logger.error(err.message, {sql:sql, err:err}); throw new Error(`MysqlDao.createFromSql() for ${this.tableName} failed`); }); } /** * @param {Array.<object>} createArgsArr * @param {object} [opts] additional insert options * @param {boolean} [opts.ignoreOnDuplicateKey] if true, insert will ignore duplicate keys * @returns {Promise.<int,Error>} - number of processed rows (including ignored duplicates) */ createBulk(createArgsArr, opts) { const sql = this._queryHelper.createBulk(createArgsArr, opts); return this.createBulkFromSql(sql); } createBulkFromSql(sql) { return this._mysqlService.insert(sql) .then(result => result.affectedRows) .catch(err => { this._logger.error(err.message, {sql:sql, err:err}); throw new Error(`MysqlDao.createBulkFromSql() for ${this.tableName} failed`); }); } /** * @param {object} insertArgs * @param {object} onDupUpdateArgs * @returns {Promise.<int,Error>} - ID of last inserted item */ upsert(insertArgs, onDupUpdateArgs) { const sql = this._queryHelper.upsert(insertArgs, onDupUpdateArgs); return this.upsertFromSql(sql); } /** * @param {string} sql * @returns {Promise.<int,Error>} */ upsertFromSql(sql) { return this._mysqlService.upsert(sql) .then(results => results.affectedRows ) .catch(err => { this._logger.error(err.message, {sql: sql, err:err}); throw new Error('MysqlDao.upsertFromSql() failed'); }); } /** * @param {object} insertArgs * @param {object|Array} onDupUpdateArgs If an object, the columns specified by the object keys will be * updated with the corresponding values. (This uses the column = value syntax.) If an array, the columns * listed in the array will be updated with the insert values. (This uses the column = VALUES(column) syntax.) * @returns {Promise.<int|Error>} The number of affected rows */ upsertBulk(insertArgs, onDupUpdateArgs) { const sql = this._queryHelper.upsertBulk(insertArgs, onDupUpdateArgs); return this.upsertBulkFromSql(sql); } /** * @param {string} sql * @returns {Promise.<int|Error>} The number of affected rows */ upsertBulkFromSql(sql) { return this._mysqlService.upsert(sql) .then(results => results.affectedRows ) .catch(err => { this._logger.error(err.message, {sql: sql, err:err}); throw new Error('MysqlDao.upsertFromSql() failed'); }); } /** * @param id * @returns {Promise.<object,Error>} */ getOneById(id) { return this._getOneById(id); } /** * @param id * @returns {Promise.<object,Error>} */ getOneByIdFromMaster(id) { return this._getOneById(id, {forceReadFromMaster: true}); } /** * @param id * @param {MysqlGetFromSqlOptions} options * @private */ _getOneById(id, options) { return this._getOne({id:id}, options); } /** * @param {object} whereClause * @returns {Promise.<object|null,Error>} */ getOne(whereClause) { return this._getOne(whereClause); } /** * @param {object} whereClause * @returns {Promise.<object|null,Error>} */ getOneFromMaster(whereClause) { return this._getOne(whereClause, {forceReadFromMaster: true}); } /** * @param {object} whereClause * @param {MysqlGetFromSqlOptions} [options] * @returns {Promise.<Object|null, Error>} * @private */ _getOne(whereClause, options) { const sql = this._queryHelper.getOne(whereClause); return this._getOneFromSql(sql, options); } /** * @param {string} sql * @returns {Promise.<Object|null, Error>} */ getOneFromSql(sql) { return this._getOneFromSql(sql); } /** * @param {string} sql * @returns {Promise.<Object|null, Error>} */ getOneFromSqlRaw(sql) { return this._getOneFromSql(sql, {rawResults: true}); } /** * @param {string} sql * @returns {Promise.<Object|null, Error>} */ getOneFromSqlFromMaster(sql) { return this._getOneFromSql(sql, {forceReadFromMaster: true}); } /** * @param {string} sql * @returns {Promise.<Object|null, Error>} */ getOneFromSqlFromMasterRaw(sql) { return this._getOneFromSql(sql, {rawResults: true, forceReadFromMaster: true}); } /** * @name MysqlGetFromSqlOptions * @type {Object} * @property {boolean} [rawResults=false] * @property {boolean} [forceReadFromMaster=false] */ /** * @param {string} sql * @param {MysqlGetFromSqlOptions} [options] * @returns {Promise.<object|null,Error>} */ _getOneFromSql(sql, options) { const defaultOptions = { rawResults: false, forceReadFromMaster: false }; /** @type {MysqlGetFromSqlOptions} */ const optionsToUse = _.defaults(options, defaultOptions); const selectFunction = optionsToUse.forceReadFromMaster ? this._mysqlService.selectOneFromMaster.bind(this._mysqlService) : this._mysqlService.selectOne.bind(this._mysqlService); return selectFunction(sql) .then(dbRow => { if (!dbRow) { return null; } if (optionsToUse.rawResults) { return dbRow; } return this._createVoFromDbRow(dbRow); }) .catch(err => { this._logger.error(err.message, {sql: sql, err:err}); throw new Error('MysqlDao._getOneFromSql() failed'); }); } /** * @param {object} whereClause * @param {MysqlDaoGetAllOptions} * @returns {Promise.<object[],Error>} */ getAll(whereClause, options) { options = options || {}; return this._getAll(whereClause, options); } /** * @param {object} whereClause * @param {MysqlDaoGetAllOptions} * @returns {Promise.<MysqlDaoGetAllAndCountResult,Error>} */ getAllAndCount(whereClause, options) { options = options || {}; return this._getAllAndCount(whereClause, options); } /** * @param {object} whereClause * @param {MysqlDaoGetAllOptions} * @returns {Promise.<object[],Error>} */ getAllFromMaster(whereClause, options) { options = options || {}; return this._getAll(whereClause, options, {forceReadFromMaster: true}); } /** * @param {object} whereClause * @param {MysqlDaoGetAllOptions} * @returns {Promise.<MysqlDaoGetAllAndCountResult,Error>} */ getAllAndCountFromMaster(whereClause, options) { options = options || {}; return this._getAllAndCount(whereClause, options, {forceReadFromMaster: true}); } /** * @param whereClause * @param {MysqlDaoGetAllOptions} * @param {MysqlGetFromSqlOptions} [config] * @returns {Promise.<Object[], Error>} * @private */ _getAll(whereClause, options, config) { if (options && options.offset && !options.limit) throw new Error('Missing limit in getAll options, must include with offset option.'); const sql = this._queryHelper.getAll(whereClause, options); return this._getAllFromSql(sql, config); } /** * @param whereClause * @param {MysqlDaoGetAllOptions} * @param {MysqlGetFromSqlOptions} [config] * @returns {Promise.<MysqlDaoGetAllAndCountResult, Error>} * @private */ _getAllAndCount(whereClause, options, config) { config = config || {}; if (options && options.offset && !options.limit) throw new Error('Missing limit in getAll options, must include with offset option.'); const sql = this._queryHelper.getAll(whereClause, options); const countSql = this._queryHelper.getCount(whereClause, options); const countConfig = _.clone(config); countConfig.rawResults = true; let results; return this._getAllFromSql(sql, config) .tap(_results_ => results = _results_) .then(_results_ => this._getOneFromSql(countSql, countConfig)) .then(_count_ => { return {count: _count_.count, results: results} }) } /** * @param {string} sql * @returns {Promise.<Object[], Error>} */ getAllFromSql(sql) { return this._getAllFromSql(sql); } /** * @param {string} sql * @returns {Promise.<Object[], Error>} */ getCountFromSql(sql) { return this._getCountFromSql(sql); } /** * @param {string} sql * @returns {Promise.<Object[], Error>} */ getAllFromSqlRaw(sql) { return this._getAllFromSql(sql, {rawResults: true}); } /** * @param {string} sql * @returns {Promise.<Object|null, Error>} */ getAllFromSqlFromMaster(sql) { return this._getAllFromSql(sql, {forceReadFromMaster: true}); } /** * @param {string} sql * @returns {Promise.<Object|null, Error>} */ getAllFromSqlFromMasterRaw(sql) { return this._getAllFromSql(sql, {rawResults: true, forceReadFromMaster: true}); } /** * @param {string} sql * @param {object} [options] * @param {boolean} [options.rawResults=false] * @returns {Promise.<object[],Error>} */ _getAllFromSql(sql, options) { const defaultOptions = { rawResults: false, forceReadFromMaster: false }; /** @type {MysqlGetFromSqlOptions} */ const optionsToUse = _.defaults(options, defaultOptions); const selectAllFunction = optionsToUse.forceReadFromMaster ? this._mysqlService.selectAllFromMaster.bind(this._mysqlService) : this._mysqlService.selectAll.bind(this._mysqlService); return selectAllFunction(sql) .then(dbRows => { if (optionsToUse.rawResults) { return dbRows; } else { const results = []; dbRows.forEach(dbRow => results.push(this._createVoFromDbRow(dbRow))); return results; } }) .catch(err => { this._logger.error(err.message, {sql: sql, err:err}); throw new Error('MysqlDao._getAllFromSql() failed'); }); } /** * @param {object} whereClause * @param {MysqlDaoGetAllOptions} [options={}] * @param {ReadableOptions} [streamOptions={}] * @returns {stream.Readable} */ stream(whereClause, options, streamOptions) { options = options || {}; streamOptions = streamOptions || {}; return this._stream(whereClause, options, {}, streamOptions); } /** * @param {object} whereClause * @param {MysqlDaoGetAllOptions} [options={}] * @param {ReadableOptions} [streamOptions={}] * @returns {stream.Readable} */ streamFromMaster(whereClause, options, streamOptions) { options = options || {}; streamOptions = streamOptions || {}; return this._stream(whereClause, options, {forceReadFromMaster: true}, streamOptions); } /** * @param {string} sql * @param {ReadableOptions} [streamOptions={}] * @returns {stream.Readable} */ streamFromSql(sql, streamOptions) { streamOptions = streamOptions || {}; return this._streamFromSql(sql, {}, streamOptions); } /** * @param {string} sql * @param {ReadableOptions} [streamOptions={}] * @returns {stream.Readable} */ streamFromSqlRaw(sql, streamOptions) { streamOptions = streamOptions || {}; return this._streamFromSql(sql, {rawResults: true}, streamOptions); } /** * @param {string} sql * @param {ReadableOptions} [streamOptions={}] * @returns {stream.Readable} */ streamFromSqlFromMaster(sql, streamOptions) { streamOptions = streamOptions || {}; return this._streamFromSql(sql, {forceReadFromMaster: true}, streamOptions); } /** * @param {string} sql * @param {ReadableOptions} [streamOptions={}] * @returns {stream.Readable} */ streamFromSqlFromMasterRaw(sql, streamOptions) { streamOptions = streamOptions || {}; return this._streamFromSql(sql, {rawResults: true, forceReadFromMaster: true}, streamOptions); } /** * @param whereClause * @param {MysqlDaoGetAllOptions} options * @param {MysqlGetFromSqlOptions} [config] * @returns {Promise.<Object[], Error>} * @private */ _stream(whereClause, options, config) { if (options && options.offset && !options.limit) throw new Error('Missing limit in streamAll options, must include with offset option.'); const sql = this._queryHelper.getAll(whereClause, options); return this._streamFromSql(sql, config); } /** * @param {string} sql * @param {object} [options] * @param {boolean} [options.rawResults=false] * @param {ReadableOptions} [streamOptions] * @returns {stream.Readable} */ _streamFromSql(sql, options, streamOptions) { /** @type {MysqlGetFromSqlOptions} */ const optionsToUse = _.defaults(options, { rawResults: false, forceReadFromMaster: false }); /** @type {ReadableOptions} **/ const streamOptionsToUse = _.defaults(streamOptions, { objectMode: true }); const streamFunction = optionsToUse.forceReadFromMaster ? this._mysqlService.streamQueryFromMaster.bind(this._mysqlService) : this._mysqlService.streamQuery.bind(this._mysqlService); /** @type {stream.Readable} **/ const queryStream = streamFunction(sql, streamOptionsToUse) .on('error', (err) => { this._logger.error(err.message, { sql: sql, err:err }); }); if (!optionsToUse.rawResults) { const transform = new this._TransformResult(streamOptionsToUse); queryStream.on('error', (err) => { transform.emit('error', err); transform.end(); }); return queryStream.pipe(transform); } return queryStream; } get _TransformResult() { const dao = this; class TransformResult extends TransformStream { _transform(dbRow, encoding, cb) { cb(null, dao._createVoFromDbRow(dbRow)); } } return TransformResult; } /** * @param {int} id * @param {object} updateArgs * @returns {Promise.<int>} - number of changed rows (should be 0 or 1) */ updateById(id, updateArgs) { const whereClause = {id: id}; return this.updateOne(whereClause, updateArgs); } /** * @param {object} whereClause * @param {object} updateArgs * @returns {Promise.<int>} - returns number of changed rows (should be 0 or 1) */ updateOne(whereClause, updateArgs) { const sql = this._queryHelper.updateOne(whereClause, updateArgs); return this.updateFromSql(sql); } /** * @param {object} whereClause * @param {object} updateArgs * @returns {Promise.<int>} */ updateMultiple(whereClause, updateArgs) { const sql = this._queryHelper.update(whereClause, updateArgs); return this.updateFromSql(sql); } /** * @param {string} sql * @returns {Promise.<int>} - returns number of changed rows (should be 0 or 1) */ updateFromSql(sql) { return this._mysqlService.update(sql) .then(results => results.changedRows) .catch(err => { this._logger.error(err.message, {sql: sql, err:err}); throw new Error('MysqlDao.updateFromSql() failed'); }); } /** * @param {int} id * @returns {Promise.<int>} - number of affected rows (should be 0 or 1) */ deleteById(id) { return this.deleteOne({id: id}); } /** * @param {object} whereClause * @returns {Promise.<int>} - number of affected rows (should be 0 or 1) */ deleteOne(whereClause) { const sql = this._queryHelper.deleteOne(whereClause); return this.deleteFromSql(sql); } /** * @param {object} whereClause * @returns {Promise.<int>} */ deleteMultiple(whereClause) { const sql = this._queryHelper.delete(whereClause); return this.deleteFromSql(sql); } /** * @param {string} sql * @returns {Promise.<int>} - number of affected rows (should be 0 or 1) */ deleteFromSql(sql) { return this._mysqlService.delete(sql) .then(results => results.affectedRows) .catch(err => { this._logger.error(err.message, {sql: sql, err:err}); throw new Error('MysqlDao.deleteFromSql() failed'); }); } /** * @param uncleanValue * @returns {string|number} */ clean(uncleanValue) { return this._queryHelper.clean(uncleanValue); } /** * * @param uncleanValue * @returns {string|number} */ cleanSpecial(uncleanValue) { return this._queryHelper.cleanSpecial(uncleanValue); } /** * Similar to join(), but will divide `objectsToJoin` into batches of size `maxBatchSize` * @param {object[]|object} objectsToJoin * @param {string} lookupKey * @param {object} options * @param {int} [options.maxBatchSize = 100] - The max batch size for querying this Dao's table. * @param {string} [options.joinKey = "id"] * @param {string} [options.resultsKey] * @return {Promise.<object[]|object>} */ batchJoin(objectsToJoin, lookupKey, options) { if (!objectsToJoin) { return Promise.resolve(); } const shouldReturnArray = _.isArray(objectsToJoin); objectsToJoin = _.castArray(objectsToJoin); options = _.defaults(options, { joinKey: 'id', resultsKey: _.camelCase(this.tableName), maxBatchSize: 100 }); // Split into batches based on batch size. const objectBatches = _.chunk(objectsToJoin, options.maxBatchSize); // Process each batch, then recombine. return Promise.map(objectBatches, objectBatch => this.join(objectBatch, lookupKey, options) ) .then(_.flatten) .then(objectsWithJoins => { return shouldReturnArray ? objectsWithJoins : _.head(objectsWithJoins); }); } /** * Takes an array of objects and joins entries from this Dao onto each of them. It will take "joinKey" from each object * (defaulting to "id") and query this Dao using "lookupKey" for all rows matching that array of values (`SELECT * FROM table_name WHERE id IN (x, y, z, ...)`), * and then join the matching VOs and add them under a new property ("resultsKey") on the original object. * @param {object[]|object} objectsToJoin - Object or objects which should be joined-onto. * @param {string} lookupKey - The lookup key to use for selecting entities from this Dao. * @param {object} options * @param {string} [options.joinKey = "id"] - The join key to use for the objects in objectsToJoin. * @param {string} [options.resultsKey] - The key onto which the joined entities will be added. Defaults to the camelCase value of `this.tableName`. * @return {Promise.<object[]|object>} - Returns original input object(s) decorated with joined VOs under "resultsKey" */ join(objectsToJoin, lookupKey, options) { if (!objectsToJoin) { return Promise.resolve(); } const shouldReturnArray = _.isArray(objectsToJoin); objectsToJoin = _.castArray(objectsToJoin); options = _.defaults(options, { joinKey: 'id', resultsKey: _.camelCase(this.tableName) }); const joinValues = objectsToJoin.map(object => object[options.joinKey]); return this.getAll({[lookupKey]: joinValues}) .then(vos => { // For each object in the join, find all matching VOs from this Dao to join. return Promise.map(objectsToJoin, object => { // Pull all vos which match this object. const vosToJoin = _.filter(vos, vo => object[options.joinKey] === vo[lookupKey]); // Add the matching vos for the join under `resultsKey`. object[options.resultsKey] = vosToJoin; return object; }); }) .then(objectsWithJoins => { return shouldReturnArray ? objectsWithJoins : _.head(objectsWithJoins); }) } } module.exports = MysqlDao;