UNPKG

hapiest-mysql

Version:

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

454 lines (393 loc) 17.2 kB
'use strict'; const _ = require('lodash'); const Squel = require('squel'); const VO = require('hapiest-vo'); const Moment = require('moment'); class MysqlDaoQueryHelper { /** * @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 */ constructor(tableName, cleanFunction) { this._tableName = tableName; this._clean = cleanFunction; this._queryOperators = { gt: '>', gte: '>=', like: 'LIKE', lt: '<', lte: '<=', eq: '=', ne: '!=', in: 'IN', nin: 'NOT IN', not_in: 'NOT IN', // supporting a more readable version of nin raw: '' }; this._squel = Squel.useFlavour('mysql'); } /** * @param {object} createArgs * @param {object} [opts] additional insert options * @param {boolean} [opts.ignoreOnDuplicateKey] if true, insert will ignore duplicate keys * @returns {string} */ create(createArgs, opts) { return this.createBulk([createArgs], opts); } /** * @param {Array.<object>} createArgsArray * @param {object} [opts] additional insert options * @param {boolean} [opts.ignoreOnDuplicateKey=false] if true, insert will ignore duplicate keys * @param {boolean} [opts.dontCleanMysqlFunctions=true] allow some mysql expressions in your VALUES. * Disable for a small performance improvenment. * @returns {string} */ createBulk(createArgsArray, opts = {}) { const cleanValues = this._cleanAndMapValues(createArgsArray, opts.dontCleanMysqlFunctions); const sqlBuilder = this._squel.insert({autoQuoteFieldNames: true}).into(this._tableName) .setFieldsRows(cleanValues, {dontQuote: true}); if (opts.ignoreOnDuplicateKey) { //INSERT ... IGNORE actually ignores ALL errors, not just duplicate key, so best practice is to "update" // a duplicate setting a column to itself const firstField = Object.keys(cleanValues[0])[0]; sqlBuilder.onDupUpdate(`\`${firstField}\``, `\`${firstField}\``, {dontQuote: true}) } return sqlBuilder.toString(); } /** * @param {object} insertArgs * @param {object} onDupUpdateArgs * @returns {string} */ upsert(insertArgs, onDupUpdateArgs) { const cleanInsertValues = this._cleanAndMapValues(insertArgs); const cleanOnDupUpdateValues = this._cleanAndMapValues(onDupUpdateArgs); let upsert = this._squel.insert({autoQuoteFieldNames: true}).into(this._tableName) .setFields(cleanInsertValues, {dontQuote: true}); Object.keys(cleanOnDupUpdateValues).forEach(property => { const cleanUpsertValue = cleanOnDupUpdateValues[property]; upsert.onDupUpdate(`\`${property}\``, cleanUpsertValue, {dontQuote: true}); }); return upsert.toString(); } /** * @param {Array.<object>} insertArgsArray * @param {object|Array} onDupUpdateArgs If an object, update columns with values defined by the object keys. If * an array, update the listed columns with the insert value. * @param {object} [opts] additional insert options * @param {boolean} [opts.dontCleanMysqlFunctions=true] allow some mysql expressions in your VALUES * Disable for a small performance improvenment. * @returns {string} */ upsertBulk(insertArgsArray, onDupUpdateArgs, opts = {}) { const cleanValues = this._cleanAndMapValues(insertArgsArray, opts.dontCleanMysqlFunctions); const upsert = this._squel.insert({autoQuoteFieldNames: true}).into(this._tableName) .setFieldsRows(cleanValues, {dontQuote: true}); if (_.isArray(onDupUpdateArgs)) { onDupUpdateArgs.forEach(propName => { const snakeCaseProp = _.snakeCase(propName); upsert.onDupUpdate(`\`${snakeCaseProp}\``, "VALUES(`" + snakeCaseProp + "`)", {dontQuote: true}); }); } else { const cleanOnDupUpdateValues = this._cleanAndMapValues(onDupUpdateArgs); Object.keys(cleanOnDupUpdateValues).forEach(property => { const cleanUpsertValue = cleanOnDupUpdateValues[property]; upsert.onDupUpdate(`\`${property}\``, cleanUpsertValue, {dontQuote: true}); }); } return upsert.toString(); } /** * @param {object} whereClause - key/value pairs that are "and"ed together */ getOne(whereClause) { const sqlObject = this._getBase(whereClause); const sql = sqlObject.limit(1).toString(); return sql; } getAll(whereClause, options) { const sqlObject = this._getBase(whereClause, options); const sql = sqlObject.toString(); return sql; } getCount(whereClause, options) { const sqlObject = this._getCountBase(whereClause, options); const sql = sqlObject.toString(); return sql; } updateOne(whereClause, updateArgs) { const sqlObject = this._updateBase(whereClause, updateArgs); const sql = sqlObject.limit(1).toString(); return sql; } update(whereClause, updateArgs) { const sqlObject = this._updateBase(whereClause, updateArgs); return sqlObject.toString(); } deleteOne(whereClause) { const sqlObject = this._deleteBase(whereClause); const sql = sqlObject.limit(1).toString(); return sql; } delete(whereClause) { const sqlObject = this._deleteBase(whereClause); return sqlObject.toString(); } /** * @returns {Squel} */ squel() { return Squel; } /** * Raw MySQL clean function * @param uncleanValue * @returns {string|number} */ clean(uncleanValue) { return this._clean(uncleanValue); } /** * Allows special MySQL functions to pass through without getting cleaned * @param uncleanValue * @returns {string|number} */ cleanSpecial(uncleanValue) { let cleanValue = null; if (['current_timestamp','now()','is null', 'is not null'].indexOf(_.toLower(uncleanValue)) !== -1) { cleanValue = uncleanValue; } else { const valueToClean = typeof(uncleanValue) === 'undefined' ? null : uncleanValue; cleanValue = this._clean(valueToClean); } return cleanValue; } /** * @param {object} whereClause * @param {MysqlDaoGetAllOptions} * @returns {Squel} * @private */ _getBase(whereClause, options) { options = options || {}; // Note, this is a really hacky way of telling Squel to ignore all ? characters // Take advantage that it uses str.substr(idx, paramChar.length) === paramChar to determine whether or not to // replace characters. Before this, a URL with query parameter such as youtube.com/?q=abc would cause the // ? to be replaced with 'undefined'. Stupidly annoying! let sqlObject = this._squel.select({parameterCharacter: '!!@@##$$%%', autoQuoteFieldNames: true}) .from(this._tableName); sqlObject = this._appendWhereClause(sqlObject, whereClause); if (options.sort) { Object.keys(options.sort).forEach(key => { sqlObject = sqlObject.order(`\`${_.snakeCase(String(key))}\``, String(options.sort[key]).toUpperCase() !== 'DESC'); }) } if (options.limit) sqlObject = sqlObject.limit(Number(options.limit)); if (options.offset) sqlObject = sqlObject.offset(Number(options.offset)); return sqlObject; } /** * @param {object} whereClause * @param {MysqlDaoGetAllOptions} * @returns {Squel} * @private */ _getCountBase(whereClause, options) { options = options || {}; let sqlObject = this._squel.select({parameterCharacter: '!!@@##$$%%'}) .field('COUNT(*)', "count") .from(this._tableName); sqlObject = this._appendWhereClause(sqlObject, whereClause); return sqlObject; } /** * @param whereClause * @param updateArgs * @returns {Squel} * @private */ _updateBase(whereClause, updateArgs) { const cleanUpdateValues = this._cleanAndMapValues(updateArgs); let sqlObject = this._squel.update({parameterCharacter: '!!@@##$$%%', autoQuoteFieldNames: true}) // See comment in _getBase for explanation of paramChar - using sequence likely never to be inserted .table(this._tableName) .setFields(cleanUpdateValues, {dontQuote: true}); sqlObject = this._appendWhereClause(sqlObject, whereClause); return sqlObject; } _deleteBase(whereClause) { let sqlObject = this._squel.delete({parameterCharacter: '!!@@##$$%%', autoQuoteFieldNames: true}) // See comment in _getBase for explanation of paramChar .from(this._tableName); sqlObject = this._appendWhereClause(sqlObject, whereClause); return sqlObject; } /** * @private * @param {Object|Object[]} obj * @param {Boolean} [dontCleanMysqlFunctions] whether or not to allow MySQL functions to exist in values * @returns {Object} - cleans the values and returns an object with snake_case props --> clean values */ _cleanAndMapValues(obj, dontCleanMysqlFunctions = true) { if (Array.isArray(obj)) { const propertyMappings = MysqlDaoQueryHelper._getPropertyMappings(obj[0]); return obj.map(obj => this._cleanAndMapObjectProperties(obj, propertyMappings, dontCleanMysqlFunctions)); } else { const propertyMappings = MysqlDaoQueryHelper._getPropertyMappings(obj); return this._cleanAndMapObjectProperties(obj, propertyMappings, dontCleanMysqlFunctions); } } /** * @private * @param {Object} obj * @returns {Object} - a lookup object mapping property names to their snake_cased equivalents */ static _getPropertyMappings(obj) { if (typeof(obj.toJsObj) === 'function') { obj = obj.toJsObj(); } else if (typeof(obj.toJSON) === 'function') { obj = obj.toJSON(); } const mappings = {}; Object.keys(obj).forEach(key => { mappings[key] = key.includes('->') ? key : _.snakeCase(key); }); return mappings; } /** * Cleans values and maps them to snake_cased property names. * This method should only be called by _cleanAndMapValues() * @private * @param {object} obj * @param {Object} propertyMappings maps property names to their snake_cased equivalents * @param {Boolean} dontCleanMysqlFunctions whether or not to allow MySQL functions to exist in values * @returns {object} - cleans the values and returns an object with snake_case props --> clean values */ _cleanAndMapObjectProperties(obj, propertyMappings, dontCleanMysqlFunctions) { if (typeof(obj.toJsObj) === 'function') { obj = obj.toJsObj(); } else if (typeof(obj.toJSON) === 'function') { obj = obj.toJSON(); } const cleanValues = {}; const clean = dontCleanMysqlFunctions ? this.cleanSpecial.bind(this) : this.clean.bind(this); Object.keys(obj).forEach(property => { const uncleanValue = obj[property]; const mappedProp = propertyMappings[property]; if (uncleanValue === null || typeof uncleanValue === 'string' || typeof uncleanValue === 'number' || typeof uncleanValue === 'boolean' || typeof uncleanValue === 'undefined') { // Escaping primitive values cleanValues[mappedProp] = clean(uncleanValue); } else if (_.has(uncleanValue, 'raw')) { cleanValues[mappedProp] = uncleanValue.raw; } else if (_.isDate(uncleanValue) || uncleanValue instanceof Moment) { cleanValues[mappedProp] = clean(uncleanValue); } else if (_.isArray(uncleanValue)) { // Escaping array values const cleanValueArray = []; uncleanValue.forEach(uncleanVal => { if (['string', 'number', 'boolean'].indexOf(typeof(uncleanVal)) !== -1) { cleanValueArray.push(clean(uncleanVal)) } }); if (cleanValueArray.length > 0) { cleanValues[mappedProp] = cleanValueArray; } } else if (_.isObject(uncleanValue)) { this._validateQueryObject(uncleanValue); // Escape the values in the operator object const cleanQueryObj = this._cleanAndMapValues(uncleanValue, dontCleanMysqlFunctions); if (_.size(cleanQueryObj) > 0) { cleanValues[mappedProp] = cleanQueryObj; } } }); return cleanValues; } _appendWhereClause(sqlObject, whereClause) { const cleanValues = this._cleanAndMapValues(whereClause); Object.keys(cleanValues).forEach(columnName => { const whereClauseKey = columnName.includes('->') ? columnName : _.camelCase(columnName); const uncleanValue = whereClause[whereClauseKey]; const cleanValue = cleanValues[columnName]; if (uncleanValue === null || typeof(uncleanValue) === 'undefined') { sqlObject = sqlObject.where(`\`${columnName}\` IS NULL`); } else if (cleanValue === 'IS NULL' || cleanValue === 'IS NOT NULL') { sqlObject = sqlObject.where(`\`${columnName}\` ${cleanValue}`); } else if (_.isArray(cleanValue)) { const cleanValueList = cleanValue.join(','); sqlObject = sqlObject.where(`\`${columnName}\` IN (${cleanValueList})`) } else if (_.isObject(cleanValue)) { sqlObject = sqlObject.where(this._queryObjectToSql(columnName, cleanValue)); } else if (columnName.includes('->')) { let jsonPathWithQuotedColumn = columnName .split('->') .map((path, index) => { const isFieldName = index === 0; const isQuoted = path[0] === '`' && path[path.length - 1] === '`'; return isFieldName && !isQuoted ? `\`${path}\`` : path; }) .join('->'); sqlObject = sqlObject.where(`${jsonPathWithQuotedColumn} = ${cleanValue}`); } else { sqlObject = sqlObject.where(`\`${columnName}\` = ${cleanValue}`); } }); return sqlObject; } _validateQueryObject(queryObj) { // If the unclean value is a query object, it should be flat and be have only accepted operation keys (gt, lt, eq, ne) const possibleKeys = _.keys(this._queryOperators); const hasValidKeys = _.difference(_.keys(queryObj), possibleKeys).length === 0; // const hasNestedQueryObjects = !!_.find(queryObj, (val, key) => _.isObject(val)); if (!hasValidKeys) { throw new Error('Query objects must be comprised of the accepted operator keys: (' + possibleKeys.join(',') + ').') } } //TODO: Handle OR case. _queryObjectToSql(columnName, queryObj) { let sql = ''; let i = 0; _.each(queryObj, (value, operator) => { if (i++ > 0) { sql += ' AND '; } const cleanQueryOperator = this._cleanQueryOperator(operator, value); const cleanedValue = this._cleanValueForOperator(operator, value); sql += `\`${columnName}\` ${cleanQueryOperator} ${cleanedValue}`; }); return sql; } /** * Performs any additional cleaning necessary for the specified operation. This function assumes that the value * has already been cleaned and transformed into a safe SQL value. * @param operator * @param value * @returns {*} * @private */ _cleanValueForOperator(operator, value) { if (_.includes(['in', 'nin', 'not_in'], operator)) { value = _.castArray(value); const joinedValues = value.join(','); return `(${joinedValues})`; } return value; } _cleanQueryOperator(operator, value) { let cleanOperator = this._queryOperators[operator]; if (operator === 'ne' && value === 'NULL') { cleanOperator = 'IS NOT'; } return cleanOperator; } } module.exports = MysqlDaoQueryHelper;