Source: query-builder.js

'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;
DocStrap Copyright © 2012-2014 The contributors to the JSDoc3 and DocStrap projects.
Documentation generated by JSDoc 3.3.0-alpha9 on Thu Oct 23rd 2014 using the DocStrap template.