'use strict'; /** @module query-builder */ var getArgs = require('getargs'), helpers = require('./helpers'); /** * Variables controlling the sql building * * @private */ var state = {}; /* * SQL generation object * * @param {driver} - The syntax driver for the database * @param {adapter} - The database module adapter for running queries * @constructor */ var QueryBuilder = function(driver, adapter) { // That 'new' keyword is annoying if ( ! (this instanceof QueryBuilder)) return new QueryBuilder(driver, adapter); // Keep these properties as object members so they can be mocked/substituted this.driver = driver; this.adapter = adapter; /** * "Private" methods * * @private */ var _p = { /** * Complete the sql building based on the type provided * * @param {String} type * @param {String} table * @private * @return {String} */ compile: function (type, table) { // Put together the basic query var sql = _p.compileType(type, table); // Set each subClause ['queryMap', 'groupString', 'orderString', 'havingMap'].forEach(function(clause) { var param = state[clause]; if ( ! helpers.isScalar(param)) { Object.keys(param).forEach(function(part) { sql += param[part].conjunction + param[part].string; }); } else { sql += param; } }); // Append the limit, if it exists if (helpers.isNumber(state.limit)) { sql = this.driver.limit(sql, state.limit, state.offset); } return sql; }, compileType: function (type, table) { var sql = ''; switch(type) { case "insert": var paramCount = state.setArrayKeys.length; var params = []; params.fill('?', 0, paramCount); sql = "INSERT INTO " + table + " ("; sql += state.setArrayKeys.join(','); sql += ") VALUES ("; sql += params.join(',') + ')'; break; case "update": sql = "UPDATE " + table + " SET " + state.setString; break; case "delete": sql = "DELETE FROM " + table; break; default: sql = "SELECT * FROM " + state.fromString; // Set the select string if (state.selectString.length > 0) { // Replace the star with the selected fields sql = sql.replace('*', state.selectString); } break; } return sql; }, like: function (field, val, pos, like, conj) { field = this.driver.quoteIdentifiers(field); like = field + " " + like + " ?"; if (pos == 'before') { val = "%" + val; } else if (pos == 'after') { val = val + "%"; } else { val = "%" + val + "%"; } conj = (state.queryMap.length < 1) ? ' WHERE ' : ' ' + conj + ''; _p.appendMap(conj, like, 'like'); state.whereValues.push(val); }, /** * Append a clause to the query map * * @param {String} conjunction * @param {String} string * @param {String} type * @return void */ appendMap: function(conjunction, string, type) { state.queryMap.push({ type: type, conjunction: conjunction, string: string }); }, /** * Handle key/value pairs in an object the same way as individual arguments, * when appending to state * * @private */ mixedSet: function(/* varName, key, [val], valType */) { var args = getArgs('varName:string, key:string|array, [val]:string, valType:string', arguments); var obj = {}; if (helpers.isString(args.key) && helpers.isString(args.val)) { obj[args.key] = args.val; } else { obj = args.key; } Object.keys(obj).forEach(function(k) { if (args.valType != 'both') { state[args.varName].push( (args.valType === 'key')? k: obj[k] ); } else { state[args.varName][k] = obj[k]; } }); return state[args.varName]; }, whereMixedSet: function(key, val) { _p.mixedSet('whereValues', key, val, 'value'); _p.mixedSet('whereMap', key, val, 'both'); }, where: function(key, val, conj) { conj = conj || 'AND'; // Get an object to iterate over _p.whereMixedSet(key, val); Object.keys(state.whereMap).forEach(function(field) { // Split each key by spaces, in case there // is an operator such as >, <, !=, etc. var fieldArray = field.split(' '); var item = this.driver.quoteIdentifiers(fieldArray[0]); // Simple key value, or an operator? item += (fieldArray.length === 1) ? '=?' : " " + fieldArray[1] + " ?"; var firstItem = state.queryMap[0], lastItem = state.queryMap[state.queryMap.length - 1]; // Determine the correct conjunction if (state.queryMap.length < 1 || firstItem.contains('JOIN')) { conj = " WHERE "; } else if (lastItem.type === 'groupStart') { conj = ''; } else { conj = ' ' + conj + ' '; } _p.appendMap(conj, item, 'where'); }); }, having: function(key, val, conj) { conj = conj || 'AND'; _p.whereMixedSet(key, val); Object.keys(state.whereMap).forEach(function(field) { // Split each key by spaces, in case there // is an operator such as >, <, !=, etc. var fieldArray = field.split(' ').map(helpers.stringTrim); var item = this.driver.quoteIdentifiers(fieldArray[0]); // Simple key value, or an operator? item += (fieldArray.length === 1) ? '=?' : " " + fieldArray[1] + " ?"; // Put in the having map state.havingMap.push({ conjunction: (state.havingMap.length > 0) ? " " + conj + " " : ' HAVING ', string: item }); }); }, whereIn: function(key, val, inClause, conj) { key = this.driver.quoteIdentifiers(key); var params = []; params.fill('?', 0, val.length); val.forEach(function(value) { state.whereValues.push(value); }); conj = (state.queryMap.length > 0) ? " " + conj + " " : ' WHERE '; var str = key + " " + inClause + " (" + params.join(',') + ") "; _p.appendMap(conj, str, 'whereIn'); }, run: function(type, table, callback, sql, vals) { if ( ! sql) { sql = _p.compile(type, table); } if ( ! vals) { vals = state.values.concat(state.whereValues); } // Reset the state so another query can be built _p.resetState(); // Pass the sql and values to the adapter to run on the database adapter.execute(sql, vals, callback); }, getCompile: function(type, table, reset) { reset = reset || false; var sql = _p.compile(type, table); if (reset) _p.resetState(); return sql; }, resetState: function() { state = { // Arrays/Maps queryMap: {}, values: [], whereValues: [], setArrayKeys: [], orderArray: [], groupArray: [], havingMap: [], whereMap: [], // Partials selectString: '', fromString: '', setString: '', orderString: '', groupString: '', // Other various values limit: null, offset: null }; } }; // ---------------------------------------------------------------------------- // ! Miscellaneous Methods // ---------------------------------------------------------------------------- /** * Reset the object state for a new query * * @memberOf query-builder * @return void */ this.resetQuery = function() { _p.resetState(); }; /** * Returns the current class state for testing or other purposes * * @private * @return {Object} */ this.getState = function() { return state; }; // ------------------------------------------------------------------------ // Set up state object this.resetQuery(); // ------------------------------------------------------------------------ // ! Query Builder Methods // ------------------------------------------------------------------------ /** * Specify rows to select in the query * * @param {String|Array} fields - The fields to select from the current table * @return this */ this.select = function(fields) { // Split/trim fields by comma fields = (Array.isArray(fields)) ? fields : fields.split(",").map(helpers.stringTrim); // Split on 'As' fields.forEach(function (field, index) { if (field.match(/as/i)) { fields[index] = field.split(/ as /i).map(helpers.stringTrim); } }); var safeArray = this.driver.quoteIdentifiers(fields); // Join the strings back together safeArray.forEach(function (field, index) { if (Array.isArray(field)) { safeArray[index] = safeArray[index].join(' AS '); } }); state.selectString += safeArray.join(', '); return this; }; /** * Specify the database table to select from * * @param {String} tableName - The table to use for the current query * @return this */ this.from = function(tableName) { // Split identifiers on spaces var identArray = tableName.trim().split(' ').map(helpers.stringTrim); // Quote/prefix identifiers identArray[0] = this.driver.quoteTable(identArray[0]); identArray = this.driver.quoteIdentifiers(identArray); // Put it back together state.fromString = identArray.join(' '); return this; }; /** * Add a 'like/ and like' clause to the query * * @param {String} field - The name of the field to compare to * @param {String} val - The value to compare to * @param {String} [pos=both] - The placement of the wildcard character(s): before, after, or both * @return this */ this.like = function(field, val, pos) { _p.like(field, val, pos, ' LIKE ', 'AND'); return this; }; /** * Add a 'not like/ and not like' clause to the query * * @param {String} field - The name of the field to compare to * @param {String} val - The value to compare to * @param {String} [pos=both] - The placement of the wildcard character(s): before, after, or both * @return this */ this.notLike = function(field, val, pos) { _p.like(field, val, pos, ' NOT LIKE ', 'AND'); return this; }; /** * Add an 'or like' clause to the query * * @param {String} field - The name of the field to compare to * @param {String} val - The value to compare to * @param {String} [pos=both] - The placement of the wildcard character(s): before, after, or both * @return this */ this.orLike = function(field, val, pos) { _p.like(field, val, pos, ' LIKE ', 'OR'); return this; }; /** * Add an 'or not like' clause to the query * * @param {String} field - The name of the field to compare to * @param {String} val - The value to compare to * @param {String} [pos=both] - The placement of the wildcard character(s): before, after, or both * @return this */ this.orNotLike = function(field, val, pos) { _p.like(field, val, pos, ' NOT LIKE ', 'OR'); return this; }; /** * Add a 'having' clause * * @param {String|Object} key - The name of the field and the comparision operator, or an object * @param {String|Number} [val] - The value to compare if the value of key is a string * @return this */ this.having = function(key, val) { _p.having(key, val, 'AND'); return this; }; /** * Add an 'or having' clause * * @param {String|Object} key - The name of the field and the comparision operator, or an object * @param {String|Number} [val] - The value to compare if the value of key is a string * @return this */ this.orHaving = function(key, val) { _p.having(key, val, 'OR'); return this; }; /** * Set a 'where' clause * * @param {String|Object} key - The name of the field and the comparision operator, or an object * @param {String|Number} [val] - The value to compare if the value of key is a string * @return this */ this.where = function(key, val) { _p.where(key, val, 'AND'); return this; }; /** * Set a 'or where' clause * * @param {String|Object} key - The name of the field and the comparision operator, or an object * @param {String|Number} [val] - The value to compare if the value of key is a string * @return this */ this.orWhere = function(key, val) { _p.where(key, val, 'OR'); return this; }; /** * Set a 'where in' clause * * @param {String} key - the field to search * @param {Array} val - the array of items to search in * @return this */ this.whereIn = function(key, val) { _p.whereIn(key, val, 'IN', 'AND'); return this; }; /** * Set a 'or where in' clause * * @param {String} key - the field to search * @param {Array} val - the array of items to search in * @return this */ this.orWhereIn = function(key, val) { _p.whereIn(key, val, 'IN', 'OR'); return this; }; /** * Set a 'where not in' clause * * @param {String} key - the field to search * @param {Array} val - the array of items to search in * @return this */ this.whereNotIn = function(key, val) { _p.whereIn(key, val, 'NOT IN', 'AND'); return this; }; /** * Set a 'or where not in' clause * * @param {String} key - the field to search * @param {Array} val - the array of items to search in * @return this */ this.orWhereNotIn = function(key, val) { _p.whereIn(key, val, 'NOT IN', 'OR'); return this; }; /** * Set values for insertion or updating * * @param {String|Object} key - The key or object to use * @param {String} [val] - The value if using a scalar key * @return this */ this.set = function(/* key, [val] */) { var args = getArgs('key:string|object, [val]:string', arguments); // Set the appropriate state variables _p.mixedSet('setArrayKeys', args.key, args.val, 'key'); _p.mixedSet('values', args.key, args.val, 'value'); // Use the keys of the array to make the insert/update string // and escape the field names state.setArrayKeys = state.setArrayKeys.map(this.driver._quote); // Generate the "set" string state.setString = state.setArrayKeys.join('=?,'); state.setString += '=?'; return this; }; /** * Add a join clause to the query * * @param {String} joinOn - The table you are joining * @param {String} [cond='='] - The join condition, eg. =,<,>,<>,!=,etc. * @param {String} joinTo - The value of the condition you are joining on, whether another table's field, or a literal value * @param {String} [type='inner'] - The type of join, which defaults to inner * @return this */ this.join = function(/* joinOn, [cond='='], joinTo, [type='inner']*/) { var args = getArgs('joinOn:string, [cond]:string, joinTo:string, [type]:string', arguments); args.cond = args.cond || '='; args.type = args.type || "inner"; return this; }; /** * Group the results by the selected field(s) * * @param {String|Array} field * @return this */ this.groupBy = function(field) { if (Array.isArray(field)) { var newGroupArray = field.map(this.driver.quoteIdentifiers); state.groupArray.concat(newGroupArray); } else { state.groupArray.push(this.driver.quoteIdentifiers(field)); } state.groupString = ' GROUP BY ' + state.groupArray.join(','); return this; }; /** * Order the results by the selected field(s) * * @param {String} field - The field to order by * @param {String} [type='ASC'] - The order direction, ASC or DESC * @return this */ this.orderBy = function(field, type) { type = type || 'ASC'; // Set the fields for later manipulation field = this.driver.quoteIdentifiers(field); state.orderArray[field] = type; var orderClauses = []; // Flatten key/val pairs into an array of space-separated pairs Object.keys(state.orderArray).forEach(function(key) { orderClauses.push(key + ' ' + state.orderArray[key].toUpperCase()); }); // Set the final string state.orderString = ' ORDER BY ' + orderClauses.join(', '); return this; }; /** * Put a limit on the query * * @param {Number} limit - The maximum number of rows to fetch * @param {Number} [offset] - The row number to start from * @return this */ this.limit = function(limit, offset) { state.limit = limit; state.offset = offset || null; return this; }; /** * Adds an open paren to the current query for logical grouping * * @return this */ this.groupStart = function() { var conj = (state.queryMap.length < 1) ? ' WHERE ' : ' '; _p.appendMap(conj, '(', 'groupStart'); return this; }; /** * Adds an open paren to the current query for logical grouping, * prefixed with 'OR' * * @return this */ this.orGroupStart = function() { _p.appendMap('', ' OR (', 'groupStart'); return this; }; /** * Adds an open paren to the current query for logical grouping, * prefixed with 'OR NOT' * * @return this */ this.orNotGroupStart = function() { _p.appendMap('', ' OR NOT (', 'groupStart'); return this; }; /** * Ends a logical grouping started with one of the groupStart methods * * @return this */ this.groupEnd = function() { _p.appendMap('', ')', 'groupEnd'); return this; }; // ------------------------------------------------------------------------ // ! Result Methods // ------------------------------------------------------------------------ /** * Get the results of the compiled query * * @param {String} [table] - The table to select from * @param {Number} [limit] - A limit for the query * @param {Number} [offset] - An offset for the query * @param {Function} callback - A callback for receiving the result * @return void */ this.get = function(/* [table], [limit], [offset], callback */) { var args = getArgs('[table]:string, [limit]:number, [offset]:number, callback:function', arguments); if (args.table) { this.from(args.table); } if (args.limit) { this.limit(args.limit, args.offset); } // Run the query _p.run('get', args.table, args.callback); }; /** * Run the generated insert query * * @param {String} table - The table to insert into * @param {Object} [data] - Data to insert, if not already added with the 'set' method * @param {Function} callback - Callback for handling response from the database * @return void */ this.insert = function(table, data, callback) { if (data) { this.set(data); } // Run the query _p.run('insert', table, callback); }; /** * Run the generated update query * * @param {String} table - The table to insert into * @param {Object} [data] - Data to insert, if not already added with the 'set' method * @param {Function} callback - Callback for handling response from the database * @return void */ this.update = function(table, data, callback) { if (data) { this.set(data); } // Run the query _p.run('update', table, callback); }; /** * Run the generated delete query * * @param {String} table - The table to insert into * @param {Function} callback - Callback for handling response from the database * @return void */ this['delete'] = function (table, callback) { // Run the query _p.run('delete', table, callback); }; // ------------------------------------------------------------------------ // ! Methods returning SQL // ------------------------------------------------------------------------ /** * Return generated select query SQL * * @param {String} [table] - the name of the table to retrieve from * @param {Boolean} [reset=true] - Whether to reset the query builder so another query can be built * @return String */ this.getCompiledSelect = function(table, reset) { if (table) { this.from(table); } return _p.getCompile('get', table, reset); }; /** * Return generated insert query SQL * * @param {String} table - the name of the table to insert into * @param {Boolean} [reset=true] - Whether to reset the query builder so another query can be built * @return {String} */ this.getCompiledInsert = function(table, reset) { return _p.getCompile('insert', table, reset); }; /** * Return generated update query SQL * * @param {String} table - the name of the table to update * @param {Boolean} [reset=true] - Whether to reset the query builder so another query can be built * @return {String} */ this.getCompiledUpdate = function(table, reset) { return _p.getCompile('update', table, reset); }; /** * Return generated delete query SQL * * @param {String} table - the name of the table to delete from * @param {Boolean} [reset=true] - Whether to reset the query builder so another query can be built * @return {String} */ this.getCompiledDelete = function(table, reset) { return _p.getCompile('delete', table, reset); }; return this; }; module.exports = QueryBuilder;