Source: Mixin.Matching.js

"use strict";

/**
 * Provides object matching algorithm methods.
 * @mixin
 */
var Matching = {
	/**
	 * Internal method that checks a document against a test object.
	 * @param {*} source The source object or value to test against.
	 * @param {*} test The test object or value to test with.
	 * @param {Object} queryOptions The options the query was passed with.
	 * @param {String=} opToApply The special operation to apply to the test such
	 * as 'and' or an 'or' operator.
	 * @param {Object=} options An object containing options to apply to the
	 * operation such as limiting the fields returned etc.
	 * @returns {Boolean} True if the test was positive, false on negative.
	 * @private
	 */
	_match: function (source, test, queryOptions, opToApply, options) {
		// TODO: This method is quite long, break into smaller pieces
		var operation,
			applyOp = opToApply,
			recurseVal,
			tmpIndex,
			sourceType = typeof source,
			testType = typeof test,
			matchedAll = true,
			opResult,
			substringCache,
			i;

		if (sourceType === 'object' && source === null) {
			sourceType = 'null';
		}

		if (testType === 'object' && test === null) {
			testType = 'null';
		}

		options = options || {};
		queryOptions = queryOptions || {};

		// Check if options currently holds a root query object
		if (!options.$rootQuery) {
			// Root query not assigned, hold the root query
			options.$rootQuery = test;
		}

		// Check if options currently holds a root source object
		if (!options.$rootSource) {
			// Root query not assigned, hold the root query
			options.$rootSource = source;
		}

		// Assign current query data
		options.$currentQuery = test;

		options.$rootData = options.$rootData || {};

		// Check if the comparison data are both strings or numbers
		if ((sourceType === 'string' || sourceType === 'number' || sourceType === 'null') && (testType === 'string' || testType === 'number' || testType === 'null')) {
			// The source and test data are flat types that do not require recursive searches,
			// so just compare them and return the result
			if (sourceType === 'number' || sourceType === 'null' || testType === 'null') {
				// Number or null comparison
				if (source !== test) {
					matchedAll = false;
				}
			} else {
				// String comparison
				// TODO: We can probably use a queryOptions.$locale as a second parameter here
				// TODO: to satisfy https://github.com/Irrelon/ForerunnerDB/issues/35
				if (source.localeCompare(test)) {
					matchedAll = false;
				}
			}
		} else if ((sourceType === 'string' || sourceType === 'number') && (testType === 'object' && test instanceof RegExp)) {
			if (!test.test(source)) {
				matchedAll = false;
			}
		} else {
			for (i in test) {
				if (test.hasOwnProperty(i)) {
					// Assign previous query data
					options.$previousQuery = options.$parent;

					// Assign parent query data
					options.$parent = {
						query: test[i],
						key: i,
						parent: options.$previousQuery
					};

					// Reset operation flag
					operation = false;

					// Grab first two chars of the key name to check for $
					substringCache = i.substr(0, 2);

					// Check if the property is a comment (ignorable)
					if (substringCache === '//') {
						// Skip this property
						continue;
					}

					// Check if the property starts with a dollar (function)
					if (substringCache.indexOf('$') === 0) {
						// Ask the _matchOp method to handle the operation
						opResult = this._matchOp(i, source, test[i], queryOptions, options);

						// Check the result of the matchOp operation
						// If the result is -1 then no operation took place, otherwise the result
						// will be a boolean denoting a match (true) or no match (false)
						if (opResult > -1) {
							if (opResult) {
								if (opToApply === 'or') {
									return true;
								}
							} else {
								// Set the matchedAll flag to the result of the operation
								// because the operation did not return true
								matchedAll = opResult;
							}

							// Record that an operation was handled
							operation = true;
						}
					}

					// Check for regex
					if (!operation && test[i] instanceof RegExp) {
						operation = true;

						if (sourceType === 'object' && source[i] !== undefined && test[i].test(source[i])) {
							if (opToApply === 'or') {
								return true;
							}
						} else {
							matchedAll = false;
						}
					}

					if (!operation) {
						// Check if our query is an object
						if (typeof(test[i]) === 'object') {
							// Because test[i] is an object, source must also be an object

							// Check if our source data we are checking the test query against
							// is an object or an array
							if (source[i] !== undefined) {
								if (source[i] instanceof Array && !(test[i] instanceof Array)) {
									// The source data is an array, so check each item until a
									// match is found
									recurseVal = false;
									for (tmpIndex = 0; tmpIndex < source[i].length; tmpIndex++) {
										recurseVal = this._match(source[i][tmpIndex], test[i], queryOptions, applyOp, options);

										if (recurseVal) {
											// One of the array items matched the query so we can
											// include this item in the results, so break now
											break;
										}
									}

									if (recurseVal) {
										if (opToApply === 'or') {
											return true;
										}
									} else {
										matchedAll = false;
									}
								} else if (!(source[i] instanceof Array) && test[i] instanceof Array) {
									// The test key data is an array and the source key data is not so check
									// each item in the test key data to see if the source item matches one
									// of them. This is effectively an $in search.
									recurseVal = false;

									for (tmpIndex = 0; tmpIndex < test[i].length; tmpIndex++) {
										recurseVal = this._match(source[i], test[i][tmpIndex], queryOptions, applyOp, options);

										if (recurseVal) {
											// One of the array items matched the query so we can
											// include this item in the results, so break now
											break;
										}
									}

									if (recurseVal) {
										if (opToApply === 'or') {
											return true;
										}
									} else {
										matchedAll = false;
									}
								} else if (typeof(source) === 'object') {
									// Recurse down the object tree
									recurseVal = this._match(source[i], test[i], queryOptions, applyOp, options);

									if (recurseVal) {
										if (opToApply === 'or') {
											return true;
										}
									} else {
										matchedAll = false;
									}
								} else {
									recurseVal = this._match(undefined, test[i], queryOptions, applyOp, options);

									if (recurseVal) {
										if (opToApply === 'or') {
											return true;
										}
									} else {
										matchedAll = false;
									}
								}
							} else {
								// First check if the test match is an $exists
								if (test[i] && test[i].$exists !== undefined) {
									// Push the item through another match recurse
									recurseVal = this._match(undefined, test[i], queryOptions, applyOp, options);

									if (recurseVal) {
										if (opToApply === 'or') {
											return true;
										}
									} else {
										matchedAll = false;
									}
								} else {
									matchedAll = false;
								}
							}
						} else {
							// Check if the prop matches our test value
							if (source && source[i] === test[i]) {
								if (opToApply === 'or') {
									return true;
								}
							} else if (source && source[i] && source[i] instanceof Array && test[i] && typeof(test[i]) !== "object") {
								// We are looking for a value inside an array

								// The source data is an array, so check each item until a
								// match is found
								recurseVal = false;
								for (tmpIndex = 0; tmpIndex < source[i].length; tmpIndex++) {
									recurseVal = this._match(source[i][tmpIndex], test[i], queryOptions, applyOp, options);

									if (recurseVal) {
										// One of the array items matched the query so we can
										// include this item in the results, so break now
										break;
									}
								}

								if (recurseVal) {
									if (opToApply === 'or') {
										return true;
									}
								} else {
									matchedAll = false;
								}
							} else {
								matchedAll = false;
							}
						}
					}

					if (opToApply === 'and' && !matchedAll) {
						return false;
					}
				}
			}
		}

		return matchedAll;
	},

	/**
	 * Internal method, performs a matching process against a query operator such as $gt or $nin.
	 * @param {String} key The property name in the test that matches the operator to perform
	 * matching against.
	 * @param {*} source The source data to match the query against.
	 * @param {*} test The query to match the source against.
	 * @param {Object} queryOptions The options the query was passed with.
	 * @param {Object=} options An options object.
	 * @returns {*}
	 * @private
	 */
	_matchOp: function (key, source, test, queryOptions, options) {
		// Check for commands
		switch (key) {
			case '$gt':
				// Greater than
				return source > test;

			case '$gte':
				// Greater than or equal
				return source >= test;

			case '$lt':
				// Less than
				return source < test;

			case '$lte':
				// Less than or equal
				return source <= test;

			case '$exists':
				// Property exists
				return (source === undefined) !== test;

			case '$eq': // Equals
				return source == test; // jshint ignore:line

			case '$eeq': // Equals equals
				return source === test;

			case '$ne': // Not equals
				return source != test; // jshint ignore:line

			case '$nee': // Not equals equals
				return source !== test;
				
			case '$not': // Not operator
				return !this._match(source, test, queryOptions, 'and', options);

			case '$or':
				// Match true on ANY check to pass
				for (var orIndex = 0; orIndex < test.length; orIndex++) {
					if (this._match(source, test[orIndex], queryOptions, 'and', options)) {
						return true;
					}
				}

				return false;

			case '$and':
				// Match true on ALL checks to pass
				for (var andIndex = 0; andIndex < test.length; andIndex++) {
					if (!this._match(source, test[andIndex], queryOptions, 'and', options)) {
						return false;
					}
				}

				return true;

			case '$in': // In
				// Check that the in test is an array
				if (test instanceof Array) {
					var inArr = test,
						inArrCount = inArr.length,
						inArrIndex;

					for (inArrIndex = 0; inArrIndex < inArrCount; inArrIndex++) {
						if (this._match(source, inArr[inArrIndex], queryOptions, 'and', options)) {
							return true;
						}
					}

					return false;
				} else if (typeof test === 'object') {
					return this._match(source, test, queryOptions, 'and', options);
				} else {
					console.log(this.logIdentifier() + ' Cannot use an $in operator on a non-array key: ' + key, options.$rootQuery);
					return false;
				}
				break;

			case '$nin': // Not in
				// Check that the not-in test is an array
				if (test instanceof Array) {
					var notInArr = test,
						notInArrCount = notInArr.length,
						notInArrIndex;

					for (notInArrIndex = 0; notInArrIndex < notInArrCount; notInArrIndex++) {
						if (this._match(source, notInArr[notInArrIndex], queryOptions, 'and', options)) {
							return false;
						}
					}

					return true;
				} else if (typeof test === 'object') {
					return this._match(source, test, queryOptions, 'and', options);
				} else {
					console.log(this.logIdentifier() + ' Cannot use a $nin operator on a non-array key: ' + key, options.$rootQuery);
					return false;
				}
				break;

			case '$fastIn':
				if (test instanceof Array) {
					// Source is a string or number, use indexOf to identify match in array
					return test.indexOf(source) !== -1;
				} else {
					console.log(this.logIdentifier() + ' Cannot use an $fastIn operator on a non-array key: ' + key, options.$rootQuery);
					return false;
				}
				break;

			case '$distinct':
				var lookupPath,
					value,
					finalDistinctProp;
				
				// Ensure options holds a distinct lookup
				options.$rootData['//distinctLookup'] = options.$rootData['//distinctLookup'] || {};
				
				for (var distinctProp in test) {
					if (test.hasOwnProperty(distinctProp)) {
						if (typeof test[distinctProp] === 'object') {
							// Get the path string from the object
							lookupPath = this.sharedPathSolver.parse(test)[0].path;
							
							// Use the path string to find the lookup value from the source data
							value = this.sharedPathSolver.get(source, lookupPath);
							finalDistinctProp = lookupPath;
						} else {
							value = source[distinctProp];
							finalDistinctProp = distinctProp;
						}
						
						options.$rootData['//distinctLookup'][finalDistinctProp] = options.$rootData['//distinctLookup'][finalDistinctProp] || {};
						
						// Check if the options distinct lookup has this field's value
						if (options.$rootData['//distinctLookup'][finalDistinctProp][value]) {
							// Value is already in use
							return false;
						} else {
							// Set the value in the lookup
							options.$rootData['//distinctLookup'][finalDistinctProp][value] = true;

							// Allow the item in the results
							return true;
						}
					}
				}
				break;

			case '$count':
				var countKey,
					countArr,
					countVal;

				// Iterate the count object's keys
				for (countKey in test) {
					if (test.hasOwnProperty(countKey)) {
						// Check the property exists and is an array. If the property being counted is not
						// an array (or doesn't exist) then use a value of zero in any further count logic
						countArr = source[countKey];
						if (typeof countArr === 'object' && countArr instanceof Array) {
							countVal = countArr.length;
						} else {
							countVal = 0;
						}

						// Now recurse down the query chain further to satisfy the query for this key (countKey)
						if (!this._match(countVal, test[countKey], queryOptions, 'and', options)) {
							return false;
						}
					}
				}

				// Allow the item in the results
				return true;

			case '$find':
			case '$findOne':
			case '$findSub':
				var fromType = 'collection',
					findQuery,
					findOptions,
					subQuery,
					subOptions,
					subPath,
					result,
					operation = {};

				// Check all parts of the $find operation exist
				if (!test.$from) {
					throw(key + ' missing $from property!');
				}

				if (test.$fromType) {
					fromType = test.$fromType;

					// Check the fromType exists as a method
					if (!this.db()[fromType] || typeof this.db()[fromType] !== 'function') {
						throw(key + ' cannot operate against $fromType "' + fromType + '" because the database does not recognise this type of object!');
					}
				}

				// Perform the find operation
				findQuery = test.$query || {};
				findOptions = test.$options || {};

				if (key === '$findSub') {
					if (!test.$path) {
						throw(key + ' missing $path property!');
					}

					subPath = test.$path;
					subQuery = test.$subQuery || {};
					subOptions = test.$subOptions || {};

					if (options.$parent && options.$parent.parent && options.$parent.parent.key) {
						result = this.db()[fromType](test.$from).findSub(findQuery, subPath, subQuery, subOptions);
					} else {
						// This is a root $find* query
						// Test the source against the main findQuery
						if (this._match(source, findQuery, {}, 'and', options)) {
							result = this._findSub([source], subPath, subQuery, subOptions);
						}

						return result && result.length > 0;
					}
				} else {
					result = this.db()[fromType](test.$from)[key.substr(1)](findQuery, findOptions);
				}

				operation[options.$parent.parent.key] = result;
				return this._match(source, operation, queryOptions, 'and', options);
		}

		return -1;
	},

	/**
	 * Performs a join operation and returns the final joined data.
	 * @param {Array | Object} docArr An array of objects to run the join
	 * operation against or a single object.
	 * @param {Array} joinClause The join clause object array (the array in
	 * the $join key of a normal join options object).
	 * @param {Object} joinSource An object containing join source reference
	 * data or a blank object if you are doing a bespoke join operation.
	 * @param {Object} options An options object or blank object if no options.
	 * @returns {Array}
	 * @private
	 */
	applyJoin: function (docArr, joinClause, joinSource, options) {
		var self = this,
			joinSourceIndex,
			joinSourceKey,
			joinMatch,
			joinSourceType,
			joinSourceIdentifier,
			resultKeyName,
			joinSourceInstance,
			resultIndex,
			joinSearchQuery,
			joinMulti,
			joinRequire,
			joinPrefix,
			joinMatchIndex,
			joinMatchData,
			joinSearchOptions,
			joinFindResults,
			joinFindResult,
			joinItem,
			resultRemove = [],
			l;

		if (!(docArr instanceof Array)) {
			// Turn the document into an array
			docArr = [docArr];
		}

		for (joinSourceIndex = 0; joinSourceIndex < joinClause.length; joinSourceIndex++) {
			for (joinSourceKey in joinClause[joinSourceIndex]) {
				if (joinClause[joinSourceIndex].hasOwnProperty(joinSourceKey)) {
					// Get the match data for the join
					joinMatch = joinClause[joinSourceIndex][joinSourceKey];

					// Check if the join is to a collection (default) or a specified source type
					// e.g 'view' or 'collection'
					joinSourceType = joinMatch.$sourceType || 'collection';
					joinSourceIdentifier = '$' + joinSourceType + '.' + joinSourceKey;

					// Set the key to store the join result in to the collection name by default
					// can be overridden by the '$as' clause in the join object
					resultKeyName = joinSourceKey;

					// Get the join collection instance from the DB
					if (joinSource[joinSourceIdentifier]) {
						// We have a joinSource for this identifier already (given to us by
						// an index when we analysed the query earlier on) and we can use
						// that source instead.
						joinSourceInstance = joinSource[joinSourceIdentifier];
					} else {
						// We do not already have a joinSource so grab the instance from the db
						if (this._db[joinSourceType] && typeof this._db[joinSourceType] === 'function') {
							joinSourceInstance = this._db[joinSourceType](joinSourceKey);
						}
					}

					// Loop our result data array
					for (resultIndex = 0; resultIndex < docArr.length; resultIndex++) {
						// Loop the join conditions and build a search object from them
						joinSearchQuery = {};
						joinMulti = false;
						joinRequire = false;
						joinPrefix = '';

						for (joinMatchIndex in joinMatch) {
							if (joinMatch.hasOwnProperty(joinMatchIndex)) {
								joinMatchData = joinMatch[joinMatchIndex];

								// Check the join condition name for a special command operator
								if (joinMatchIndex.substr(0, 1) === '$') {
									// Special command
									switch (joinMatchIndex) {
										case '$where':
											if (joinMatchData.$query || joinMatchData.$options) {
												if (joinMatchData.$query) {
													// Commented old code here, new one does dynamic reverse lookups
													//joinSearchQuery = joinMatchData.query;
													joinSearchQuery = self.resolveDynamicQuery(joinMatchData.$query, docArr[resultIndex]);
												}
												if (joinMatchData.$options) {
													joinSearchOptions = joinMatchData.$options;
												}
											} else {
												throw('$join $where clause requires "$query" and / or "$options" keys to work!');
											}
											break;

										case '$as':
											// Rename the collection when stored in the result document
											resultKeyName = joinMatchData;
											break;

										case '$multi':
											// Return an array of documents instead of a single matching document
											joinMulti = joinMatchData;
											break;

										case '$require':
											// Remove the result item if no matching join data is found
											joinRequire = joinMatchData;
											break;

										case '$prefix':
											// Add a prefix to properties mixed in
											joinPrefix = joinMatchData;
											break;

										default:
											break;
									}
								} else {
									// Get the data to match against and store in the search object
									// Resolve complex referenced query
									joinSearchQuery[joinMatchIndex] = self.resolveDynamicQuery(joinMatchData, docArr[resultIndex]);
								}
							}
						}

						// Do a find on the target collection against the match data
						joinFindResults = joinSourceInstance.find(joinSearchQuery, joinSearchOptions);

						// Check if we require a joined row to allow the result item
						if (!joinRequire || (joinRequire && joinFindResults[0])) {
							// Join is not required or condition is met
							if (resultKeyName === '$root') {
								// The property name to store the join results in is $root
								// which means we need to mixin the results but this only
								// works if joinMulti is disabled
								if (joinMulti !== false) {
									// Throw an exception here as this join is not physically possible!
									throw(this.logIdentifier() + ' Cannot combine [$as: "$root"] with [$multi: true] in $join clause!');
								}

								// Mixin the result
								joinFindResult = joinFindResults[0];
								joinItem = docArr[resultIndex];

								for (l in joinFindResult) {
									if (joinFindResult.hasOwnProperty(l) && joinItem[joinPrefix + l] === undefined) {
										// Properties are only mixed in if they do not already exist
										// in the target item (are undefined). Using a prefix denoted via
										// $prefix is a good way to prevent property name conflicts
										joinItem[joinPrefix + l] = joinFindResult[l];
									}
								}
							} else {
								docArr[resultIndex][resultKeyName] = joinMulti === false ? joinFindResults[0] : joinFindResults;
							}
						} else {
							// Join required but condition not met, add item to removal queue
							resultRemove.push(resultIndex);
						}
					}
				}
			}
		}

		return resultRemove;
	},

	/**
	 * Takes a query object with dynamic references and converts the references
	 * into actual values from the references source.
	 * @param {Object} query The query object with dynamic references.
	 * @param {Object} item The document to apply the references to.
	 * @returns {*}
	 * @private
	 */
	resolveDynamicQuery: function (query, item) {
		var self = this,
			newQuery,
			propType,
			propVal,
			pathResult,
			i;

		// Check for early exit conditions
		if (typeof query === 'string') {
			// Check if the property name starts with a back-reference
			if (query.substr(0, 3) === '$$.') {
				// Fill the query with a back-referenced value
				pathResult = this.sharedPathSolver.value(item, query.substr(3, query.length - 3));
			} else {
				pathResult = this.sharedPathSolver.value(item, query);
			}

			if (pathResult.length > 1) {
				return {$in: pathResult};
			} else {
				return pathResult[0];
			}
		}

		newQuery = {};

		for (i in query) {
			if (query.hasOwnProperty(i)) {
				propType = typeof query[i];
				propVal = query[i];

				switch (propType) {
					case 'string':
						// Check if the property name starts with a back-reference
						if (propVal.substr(0, 3) === '$$.') {
							// Fill the query with a back-referenced value
							newQuery[i] = this.sharedPathSolver.value(item, propVal.substr(3, propVal.length - 3))[0];
						} else {
							newQuery[i] = propVal;
						}
						break;

					case 'object':
						newQuery[i] = self.resolveDynamicQuery(propVal, item);
						break;

					default:
						newQuery[i] = propVal;
						break;
				}
			}
		}

		return newQuery;
	},

	spliceArrayByIndexList: function (arr, list) {
		var i;

		for (i = list.length - 1; i >= 0; i--) {
			arr.splice(list[i], 1);
		}
	}
};

module.exports = Matching;