Source: queryvalidator.js

"use strict";

/**
 * QueryValidator parses given restrictions on user input and manages user input comparison and validation.
 */
class QueryValidator {
	/**
	 * Create QueryValidator. 
	 * @param {(string|boolean|RegExp|string[]|boolean[]|RegExp[])} validationData - Defines basis for user input comparison.
	 */
	constructor(validationData) {
		this.regExps = [];
		this.strings = [];
		this.booleans = [];
		this.mixture = [];
		
		this.parseValidationData(validationData);
	}
	
	/**
	 * Chceck whether such rule has been registered.
	 * @param {(string|boolean|RegExp)} rule - a rule to be searched for in the QueryValidator.
	 * @return {boolean} - If a rule was found or not.
	 */
	hasRule(rule) {
		for(let i = 0; i < this.mixture.length; i++) {
			if(rule instanceof RegExp) {
				if(String(this.mixture[i]) === String(rule)) {
					return true;
				}
			}
			else {
				if(this.mixture[i] === rule) {
					return true;
				}
			}
		}
		return false;
	}
	
	/**
	 * Adds a regular expression to the list of regular expressions as well as to the mixture array.
	 * @param {RegExp} regExp - A regular espression to be registered for later comparison.
	 * @return {QueryValidator} - This instance.
	 */
	appendRegExp(regExp) {
		this.regExps.push(regExp);
		this.mixture.push(regExp);
		return this;
	}
	
	/**
	 * Adds a string to the list of strings as well as to the mixture array.
	 * @param {string} string - A string to be registered for later comparison.
	 * @return {QueryValidator} - This instance.
	 */
	appendString(string) {
		this.strings.push(string);
		this.mixture.push(string);
		return this;
	}
	
	/**
	 * Adds a boolean to the list of booleans as well as to the mixture array.
	 * @param {boolean} bool - A boolean value to be registered for later comparison.
	 * @return {QueryValidator} - This instance.
	 */
	appendBoolean(bool) {
		this.booleans.push(bool);
		this.mixture.push(bool);
		return this;
	}
	
	/**
	 * Takes a single expression or an array of expressions designed for a specific QueryItem
	 * and pushes them to be registered for later validation comparison.
	 * @param {(string|boolean|RegExp|string[]|boolean[]|RegExp[])} validationData - Defines basis for user input comparison.
	 * @return {QueryValidator} - This instance.
	 */
	parseValidationData(validationData) {
		if(typeof validationData !== typeof undefined && validationData !== null) {
			if(validationData.constructor === Array) {
				for(var i = 0; i < validationData.length; i++) {
					this.parseSingleValidationItem(validationData[i]);
				}
			}
			else {
				this.parseSingleValidationItem(validationData);
			}
		}
		else {
			this.off = true;
		}
		return this;
	}
	
	/**
	 * Sets match type for this QueryValidator to take passed arrays as AND or OR. AND: Each input entry must have a match, OR: At least on given entry must have a match.
	 * @param {string} [type] - Defines given array match type to AND or OR.
	 * @return {string} - Set validation match option.
	 */
	setMatch(type) {
		type = type || "and";
		switch(type) {
			case "and":
				this.and = true;
				this.or = false;
				return "and";
			case "or":
				this.or = true;
				this.and = false;
				return "or";
			default:
				this.and = true;
				this.or = false;
				return "default";
		}
	}
	
	/**
	 * Convert some known words into booleans
	 * @param {(string|boolean)} value - A word to be potentionally converted into boolean.
	 * @return {(boolean|null)} - A converted word, passed boolean or null if conversion was not possible/successful.
	 */
	booleanStringConversion(value) {
		if(value === "true" || value === "false") {
			return value === "true";
		}
		else if(value === "yes" || value === "no") {
			return value === "yes";
		}
		else if(value === true) {
			return true;
		}
		else if(value === false) {
			return false;
		}
		return null;
	}
	
	/**
	 * Parses a signle validation item designed for a specific QueryItem and assigns it to the apropriete category (String, RegExp, Bool).
	 * In addition allows for complex string values separed by commas - dividing them by commas and parsing each item separately.
	 * @param {(string|boolean|RegExp)} itemData - Takes one element for later user input comparison.
	 * @param {number} [depth=1] - Tells the method how deep in the recursion it is. Default is 1, max depth allowed is 2.
	 * @return {boolean} - State of the parse operation, false on error, otherwise true.
	 */
	parseSingleValidationItem(itemData, depth) {
		depth = depth || 1;
		let maxDepth = 2;
		
		if(itemData instanceof RegExp) {
			this.appendRegExp(itemData);
		}
		else if(typeof itemData === "boolean") {
			this.appendBoolean(itemData);
		}
		else {
			var parsedData = this.parseRegExpRule(itemData);
			if(!parsedData) {
				console.error("Unknow check rule expression", parsedData);
				return false;
			}
			else if(parsedData instanceof RegExp) {
				this.appendRegExp(parsedData);
			}
			else {
				// Convert some known words into booleans
				let stringConversion = this.booleanStringConversion(parsedData);
				if(stringConversion !== null) {
					this.appendBoolean(stringConversion);
				}
				else {
					// Parse string ... subdivide a string if values in it are separated by comma.
					let separateValues = parsedData.split(", ");
					if(separateValues.length > 1 && depth < maxDepth) {
						for(let i = 0; i < separateValues.length; i++) {
							if(!this.parseSingleValidationItem(separateValues[i], depth++)) {
								return false;
							}
						}
					}
					else {
						this.appendString(parsedData);
					}
				}
			}
		}
		return true;
	}
	
	/**
	 * Parses a given string into RegExp.
	 * @param {string} rule - Takes a string which is in the form of a regular expression like this: "/^[a-zA-Z ]+$/".
	 * @return {(RegExp|string|boolean)} - If it could parse the given string returns the final RegExp,
	 * otherwise a given string is returned back. In case string was not passed to the method false is returned.
	 */
	parseRegExpRule(rule) {
		if(typeof rule === "string" || rule instanceof String) {
			try {
				// Source: http://stackoverflow.com/a/874742
				var flags = rule.replace(/.*\/([gimyu]*)$/, "$1");
				var pattern = rule.replace(new RegExp("^/(.*?)/" + flags + "$"), "$1");
				return new RegExp(pattern, flags);
			}
			catch(e) {
				return rule;
			}
		}
		return false;
	}
	
	/**
	 * Compares two given values, where the first given value is universal for String and RegExp - won't work with Booleans.
	 * If a RegExp is passed the comparison is done in the form of a RegExp test on the second value.
	 * @param {(string|RegExp)} first - A first value for comparison.
	 * @param {string} second - A second value for comparison.
	 * @return {boolean} - Returns whether the two given values equal or not.
	 */
	compareValues(first, second) {
		if(typeof first !== typeof undefined && second !== typeof undefined) {
			if(typeof second === "string" || second instanceof String) {
				if(first instanceof RegExp) {
					return first.test(second);
				}
				if(typeof first === "string" || first instanceof String) {
					return first === second;
				}
			}
		}
		return false;
	}
	
	/**
	 * Compares given value only to the registered booleans.
	 * @param {boolean} value - A boolean value for comparison.
	 * @param {number} [index] - If index is given the value is compared only with an item on the specified index.
	 * @return {boolean} - Returns whether the given values mathes any of the registered boolean rules or not.
	 */
	testValueToAllBooleans(value, index) {
		if(typeof index !== undefined && !isNaN(index) && index >= 0) {
			return this.booleans.length > index && value === this.booleans[index];
		}
		
		var atLeastOneCorrect = false;
		if(typeof value !== typeof undefined) {
			for(let i = 0; i < this.booleans.length; i++) {
				if(value === this.booleans[i]) {
					atLeastOneCorrect = true;
				}
			}
		}
		return atLeastOneCorrect;
	}
	
	/**
	 * Compares given value only to the registered strings.
	 * @param {string} value - A string value for comparison.
	 * @param {number} [index] - If index is given the value is compared only with an item on the specified index.
	 * @return {boolean} - Returns whether the given values mathes any of the registered string rules or not.
	 */
	testValueToAllStrings(value, index) {
		if(typeof index !== undefined && !isNaN(index) && index >= 0) {
			return this.strings.length > index && value === this.strings[index];
		}
		
		var atLeastOneCorrect = false;
		if(typeof value !== typeof undefined) {
			for(let i = 0; i < this.strings.length; i++) {
				if(this.strings[i] === value) {
					atLeastOneCorrect = true;
				}
			}
		}
		return atLeastOneCorrect;
	}
	
	/**
	 * Compares given value only to the registered RegExps.
	 * @param {RegExp} value - A RegExp value for comparison.
	 * @param {number} [index] - If index is given the value is compared only with an item on the specified index.
	 * @return {boolean} - Returns whether the given values mathes any of the registered RegExp rules or not.
	 */
	testValueToAllRegExps(value, index) {
		if(typeof index !== undefined && !isNaN(index) && index >= 0) {
			return this.regExps.length > index && this.regExps[index].test(value);
		}
		
		var atLeastOneCorrect = false;
		if(typeof value !== typeof undefined) {
			for(let i = 0; i < this.regExps.length; i++) {
				if(this.regExps[i].test(value)) {
					atLeastOneCorrect = true;
				}
			}
		}
		return atLeastOneCorrect;
	}
	
	/**
	 * Get the itemToValidate value.
	 * @return {InputItem} - The itemToValidate value.
	 */
	get itemToValidate() {
		return this.item || null;
	}
	
	/**
	 * Set the itemToValidate value.
	 * @param {InputItem} item - The itemToValidate value.
	 */
	set itemToValidate(item) {
		this.item = item || null;
	}
	
	/**
	 * Validates any mixture content (array of strings or booleans and any mixture of those) to all inner values.
	 * @param {(boolean[]|string[]|mixed)} values - The itemToValidate value.
	 * @return {boolean} - Return true if all values match (AND) or at least one matches (OR).
	 */
	validateAnyMixture(values) {
		values = values || [];
		let valid = false;
				
		// If each must have a match and the number of present rules is not the same as the number of input values than this is invalid right away.
		if(this.and && values.length !== this.mixture.length) {
			valid = false;
		}
		else {
			for(let i = 0; i < values.length; i++) {
				let stringConversion = this.booleanStringConversion(values[i]);
				if(stringConversion !== null && typeof stringConversion === "boolean") {
					// Test to all booleans
					valid = this.testValueToAllBooleans(stringConversion);
				}
				else {
					if(this.strings.length > 0) {
						// Test to all strings
						valid = this.testValueToAllStrings(values[i]);
					}
					if(!valid && this.regExps.length > 0) {
						// Test to all RegExps
						valid = this.testValueToAllRegExps(values[i]);
					}
				}
								
				if(this.and && valid === false) {
					break;
				}
			}
		}
		return valid;
	}
	
	/**
	 * Validates any mixture content (array of strings or booleans and any mixture of those) to all inner values in rule order.
	 * @param {(boolean[]|string[]|mixed)} values - The itemToValidate value.
	 * @return {boolean} - Return true if all values match (AND) or at least one matches (OR).
	 */
	validateAnyMixtureInRuleOrder(values) {
		values = values || [];
		let valid = false;
				
		if(values.length !== this.mixture.length) {
			valid = false;
		}
		else {
			for(let i = 0; i < values.length; i++) {
				let stringConversion = this.booleanStringConversion(values[i]);
				if(stringConversion !== null && typeof stringConversion === "boolean") {
					// Test on booleans
					valid = this.mixture[i] === stringConversion;
				}
				else {
					// Test on strings and RegExps
					valid = this.compareValues(this.mixture[i], values[i]);
				}
				if(!valid) {
					break;
				}
			}
		}
		return valid;
	}
	
	/**
	 * Validates a preset itemToValidate (InputItem) using the registered rules.
	 * @param {(boolean[]|string[]|mixed)} [values] - An optional array of booleans, strings or mixed parameter overwiting a value given by InputItem.
	 * @return {boolean} - Returns whether the assigned InputItem matches the preset rules.
	 */
	validate(values) {
		if(this.off) {
			// If Validation is not requested for this item always return true;
			return true;
		}
		
		// True if the default input item value should be overwritten.
		let overwrite = typeof values === typeof undefined ? false : true;
		
		// If there is no overwrite value to validate set and input item to be validated present.
		var valid = true;
		
		// Validate specifix input items - IGNORES CUSTOM VALUES, THOUGH THEY ARE TAKEN CARE IN A DIFFERENT SECTION.
		if(this.itemToValidate) {
			// Validate input type checkbox and radio
			// In case more booleans registered here at least one has to match - Works as OR because AND would not make sense for boolean values.
			if(this.itemToValidate.queryData.type === "radio" || this.itemToValidate.queryData.type === "checkbox") {
				valid = this.testValueToAllBooleans(this.itemToValidate.dom.checked);
			}
			// Validate input type order: Will keep order for items validation.
			// The only way to make this a bit variable is to use RegExp on relevant option match.
			else if(this.itemToValidate.queryData.type === "order") {
				let items = this.itemToValidate.value;
				valid = this.validateAnyMixtureInRuleOrder(items);
			}
			else {
				valid = this.validateAnyMixture(this.itemToValidate.value);
			}
		}
		else {
			// Validation any value
			valid = this.validateAnyMixture(overwrite ? values : this.itemToValidate.value);
		}
		this.lastValidationResult = valid;
		return valid;
	}
}