Repository: icebob/fastest-validator
Pull request #31 (2018-09-21 19:21:03)

Reorganized
This reorganizes the internals somewhat, compiling to more explicit validators with fewer special cases.

Pseudo BNF for the schema compiler after this PR:

```
RootSchema   ::= SchemaType[] | SchemaObject
SchemaObject ::= map<string, SchemaType>
SchemaType   ::= SchemaType[] | SchemaRule
SchemaRule   ::= string 
	| {type: "object", props?: SchemaObject}
	| {type: "array", items?: SchemaType}
	| {type: string}
```

Compare to the current schema compiler before this PR: 

```
RootSchema   ::= SchemaType[] | SchemaObject
SchemaObject ::= map<string, SchemaType>
SchemaType   ::= SchemaType[] | SchemaRule
SchemaRule   ::= string
	| {type: "object", props?: RootSchema}
	| {type: "array", items?: SchemaType}
	| {type: string}
```

Main difference is the new BNF object props compile from SchemaObject instead of RootSchema. This makes the new compiler stricter, since it throws errors for certain invalid schemas which compiled before.


Patch:
diff --git a/lib/validator.js b/lib/validator.js
index 7be7ff2..a6a511c 100644
--- a/lib/validator.js
+++ b/lib/validator.js
@@ -59,190 +59,196 @@ Validator.prototype.validate = function(obj, schema) {
  * @throws {Error} Invalid schema
  */
 Validator.prototype.compile = function(schema) {
+	const self = this;
 	if (Array.isArray(schema)) {
 		// Multiple schemas
 		if (schema.length == 0) {
 			throw new Error("If the schema is an Array, must contain at least one element!");
 		}
 
-		const rules = flatten(schema.map(r => this._processRule(r, null, false)));
-		return this._checkWrapper(rules, true);
+		var rules = this.compileSchemaType(schema);
+		return function(value) {
+			return self.checkSchemaType(value, rules, undefined, null);
+		}
+	} 
 
-	} else if (schema != null && typeof schema === "object") {
-		const rules = flatten(Object.keys(schema).map(name => this._processRule(schema[name], name, false)));
-		return this._checkWrapper(rules);
+	var rule = this.compileSchemaObject(schema);
+	return function(value) {
+		return self.checkSchemaObject(value, rule, undefined, null);
+	}
+};
 
+Validator.prototype.compileSchemaObject = function(schemaObject) {
+	if (schemaObject === null || typeof schemaObject !== "object" || Array.isArray(schemaObject)) {
+		throw new Error("Invalid schema!");
 	}
 
-	throw new Error("Invalid schema!");
+	var compiledObject = Object.keys(schemaObject).map(name => {
+		const compiledType = this.compileSchemaType(schemaObject[name]);
+		return {name: name, compiledType: compiledType};
+	});
+
+	// Uncomment this line to use compiled object validator:
+	// return compiledObject;
+
+	const sourceCode = [];
+	sourceCode.push(`let res;`);
+	sourceCode.push(`let propertyPath;`);
+	sourceCode.push(`const errors = [];`);
+	for (let i = 0; i < compiledObject.length; i++) {
+		const property = compiledObject[i];
+		const name = property.name;
+		sourceCode.push(`propertyPath = (path !== undefined ? path + ".${name}" : "${name}");`);
+		if (Array.isArray(property.compiledType)) {
+			sourceCode.push(`res = this.checkSchemaType(value.${name}, compiledObject[${i}].compiledType, propertyPath, value);`);
+		} else {
+			sourceCode.push(`res = this.checkSchemaRule(value.${name}, compiledObject[${i}].compiledType, propertyPath, value);`);
+		}
+		sourceCode.push(`if (res !== true) {`);
+		sourceCode.push(`\tthis.handleResult(errors, propertyPath, res);`);
+		sourceCode.push(`}`);
+	}
+
+	sourceCode.push(`return errors.length === 0 ? true : errors;`);
+
+	var compiledObjectFunction = new Function("value", "compiledObject", "path", "parent", sourceCode.join("\n"));
+
+	var self = this;
+	return function(value, _unused, path, parent) {
+		return compiledObjectFunction.call(self, value, compiledObject, path, parent);
+	};
 };
 
-/**
- * Process a rule item & return checker functions
- *
- * @param {Object} rule
- * @param {String} name
- * @param {Boolean} iterate
- */
-Validator.prototype._processRule = function(rule, name, iterate) {
-	const checks = [];
-
-	if (Array.isArray(rule)) {
-		// Compile the multiple schemas
-		checks.push({
-			fn: this.compile(rule),
-			type: "_multi",
-			name: name,
-			schema: rule,
-			iterate: iterate
-		});
-
-		return checks;
+Validator.prototype.compileSchemaType = function(schemaType) {
+	if (Array.isArray(schemaType)) {
+		// Multiple rules, flatten to array of compiled SchemaRule
+		const rules = flatten(schemaType.map(r => this.compileSchemaType(r)));
+		if (rules.length == 1) {
+			return rules[0];
+		}
+
+		return rules;
 	}
 
-	if (typeof rule === "string") {
-		rule = {
-			type: rule
+	return this.compileSchemaRule(schemaType);
+};
+
+Validator.prototype.compileSchemaRule = function(schemaRule) {
+	if (typeof schemaRule === "string") {
+		schemaRule = {
+			type: schemaRule
 		};
 	}
 
-	if (!this.rules[rule.type]) {
-		throw new Error("Invalid '" + rule.type + "' type in validator schema!");
+	const ruleFunction = this.rules[schemaRule.type];
+	if (!ruleFunction) {
+		throw new Error("Invalid '" + schemaRule.type + "' type in validator schema!");
 	}
 
+	let dataParameter = null;
+	let dataFunction = null;
 
-	/**
-	 * !IMPORTANT!: For the functioning of multiRule cases it is important that
-	 * pushing of object and array special rules is done in directly after this
-	 * simple rule. 
-	 * If you which to push other checks, do it before the simple ones or after
-	 * the array special case.
-	 * See the comments in _checkWrapper for further explanation.
-	 */
-	checks.push({
-		fn: this.rules[rule.type],
-		type: rule.type,
-		name: name,
-		schema: rule,
-		iterate: iterate
-	});
+	if (schemaRule.type === "object" && schemaRule.props) {
+		dataParameter = this.compileSchemaObject(schemaRule.props);
+		dataFunction = this.checkSchemaObject;
+	} else if (schemaRule.type === "array" && schemaRule.items) {
+		dataParameter = this.compileSchemaType(schemaRule.items);
+		dataFunction = this.checkSchemaArray;
+	}
+
+	return {
+		schemaRule: schemaRule,
+		ruleFunction: ruleFunction,
+		dataFunction: dataFunction,
+		dataParameter: dataParameter
+	};
+};
 
-	// Nested schema
-	if (rule.type === "object" && rule.props) {
-		// Compile the child schema
-		checks.push({
-			fn: this.compile(rule.props),
-			type: rule.type,
-			name: name,
-			schema: rule,
-			iterate: iterate,
-			secondPart: true //first part is the "primitive" typeof check above
-		});
+Validator.prototype.checkSchemaObject = function(value, compiledObject, path, parent) {
+	if (compiledObject instanceof Function) {
+		return compiledObject(value, undefined, path, parent);
 	}
 
-	// Array schema
-	if (rule.type === "array" && rule.items) {
-		// Compile the array schema
-		checks.push({
-			fn: this._checkWrapper(this._processRule(rule.items, null, false)),
-			type: rule.type,
-			name: name,
-			schema: rule,
-			iterate: true,
-			secondPart: true //first part is the "primitive" typeof check above
-		});
+	const errors = [];
+	const checksLength = compiledObject.length;
+	for (let i = 0; i < checksLength; i++) {
+		const check = compiledObject[i];
+		const propertyPath = (path !== undefined ? path + "." : "") + check.name;
+		const res = this.checkSchemaType(value[check.name], check.compiledType, propertyPath, value);
+
+		if (res !== true) {
+			this.handleResult(errors, propertyPath, res);
+		}
 	}
 
-	return checks;
+	return errors.length === 0 ? true : errors;
 };
 
-/**
- * Create a wrapper function for compiled schema.
- *
- * @param {Array} rules
- * @param {Boolean} isMultipleRules
- */
-Validator.prototype._checkWrapper = function(rules, isMultipleRules) {
-	const self = this;
+Validator.prototype.checkSchemaType = function(value, compiledType, path, parent) {
+	if (Array.isArray(compiledType)) {
+		const errors = [];
+		const checksLength = compiledType.length;
+		for (let i = 0; i < checksLength; i++) {
+			// Always compiled to list of rules
+			const res = this.checkSchemaRule(value, compiledType[i], path, parent);
 
-	// Compiled validator function
-	return function(obj, _schema, pathStack) {
-		let errors = [];
-		const count = rules.length;
-		for (let i = 0; i < count; i++) {
-			const check = rules[i];
-			const schema = check.schema;
-
-			let value;
-			let stack;
-			if (check.name) {
-				value = obj[check.name];
-				stack = (pathStack ? pathStack + "." : "") + check.name;
+			if (res !== true) {
+				this.handleResult(errors, path, res);
 			} else {
-				value = obj;
-				stack = pathStack ? pathStack : "";
+				// Jump out after first success and clear previous errors
+				return true;
 			}
+		}
 
-			// Check required fields
-			if ((value === undefined || value === null)) {
-				if (check.type === "forbidden")
-					continue;
-
-				if (schema.optional === true)
-					continue;
-
-				if (!Array.isArray(schema)) {
-					self.handleResult(errors, stack, self.makeError("required"));
-					continue;
-				}
-
-			} // else {
-			// Call the checker function
-			if (check.iterate) {
-				let errorInCurrentArray = false;
-				const l = value.length;
-				for (let i = 0; i < l; i++) {
-					let _stack = stack + "[" + i + "]";
-					let res = check.fn.call(self, value[i], schema, _stack, obj);
-					if (res !== true) {
-						errorInCurrentArray = true;
-						self.handleResult(errors, _stack, res);
-					}
-				}
-				/**
-					 * If this is second part of a multiRule array check and the array
-					 * is valid, then the rule is valid.
-					 */
-				if (!errorInCurrentArray && isMultipleRules && check.secondPart) {
-					return true;
-				}
-			} else {
-				let res = check.fn.call(self, value, schema, stack, obj);
-
-				if (isMultipleRules) {
-					if (res === true) {
-						/**
-							 * Object and array checks are divided into two internal checks. In case of a multiRule
-							 * we have to make sure to check both parts. Thus we we continue to also do the second
-							 * check if their is one.
-							 */
-						const nextRule = rules[i + 1];
-						if (nextRule && nextRule.secondPart){
-							continue;
-						}
-						// Jump out after first success and clear previous errors
-						return true;
-					}
-				}
-
-				if (res !== true)
-					self.handleResult(errors, stack, res);
-			}
-			//}
+		return errors;
+	} 
+
+	return this.checkSchemaRule(value, compiledType, path, parent);
+};
+
+Validator.prototype.checkSchemaArray = function(value, compiledArray, path, parent) {
+	const errors = [];
+	const valueLength = value.length;
+
+	for (let i = 0; i < valueLength; i++) {
+		const itemPath = (path !== undefined ? path : "") + "[" + i + "]";
+		const res = this.checkSchemaType(value[i], compiledArray, itemPath, value, parent);
+
+		if (res !== true) {
+			this.handleResult(errors, itemPath, res);
 		}
+	}
 
-		return errors.length === 0 ? true : errors;
-	};
+	return errors.length === 0 ? true : errors;
+};
+
+Validator.prototype.checkSchemaRule = function(value, compiledRule, path, parent) {
+	const schemaRule = compiledRule.schemaRule;
+
+	if (value === undefined || value === null) {
+		if (schemaRule.type === "forbidden")
+			return true;
+
+		if (schemaRule.optional === true)
+			return true;
+
+		const errors = [];
+		this.handleResult(errors, path, this.makeError("required"));
+		return errors;
+	}
+
+	const res = compiledRule.ruleFunction.call(this, value, schemaRule, path, parent);
+	if (res !== true) {
+		const errors = [];
+		this.handleResult(errors, path, res);
+		return errors;
+	}
+
+	if (compiledRule.dataFunction !== null) {
+		return compiledRule.dataFunction.call(this, value, compiledRule.dataParameter, path, parent);
+	}
+
+	return true;
 };
 
 /**


Test patch:
diff --git a/test/validator.spec.js b/test/validator.spec.js
index a31111e..d7e0167 100644
--- a/test/validator.spec.js
+++ b/test/validator.spec.js
@@ -103,37 +103,42 @@ describe("Test resolveMessage", () => {
 		expect(res).toBe("Field country and again country. Expected: London, actual: 350.");
 	});
 
+	it("should not resolve unknown errors", () => {
+		let res = v.resolveMessage({ type: "XXX"});
+		expect(res).toBeUndefined();
+	});
 });
 
 describe("Test compile (unit test)", () => {
 
 	const v = new Validator();
-	v._processRule = jest.fn();
+	v.compileSchemaRule = jest.fn(v.compileSchemaRule.bind(v));
 
-	it("should call processRule", () => {
+	it("should call compileSchemaRule", () => {
 		v.compile({
 			id: { type: "number" },
 			name: { type: "string", min: 5},
 			status: "boolean"
 		});
 
-		expect(v._processRule).toHaveBeenCalledTimes(3);
-		expect(v._processRule).toHaveBeenCalledWith({"type": "number"}, "id", false);
-		expect(v._processRule).toHaveBeenCalledWith({"type": "string", "min": 5}, "name", false);
-		expect(v._processRule).toHaveBeenCalledWith("boolean", "status", false);
+		expect(v.compileSchemaRule).toHaveBeenCalledTimes(3);
+		expect(v.compileSchemaRule).toHaveBeenCalledWith({"type": "number"});
+		expect(v.compileSchemaRule).toHaveBeenCalledWith({"type": "string", "min": 5});
+		expect(v.compileSchemaRule).toHaveBeenCalledWith("boolean");
 	});
 
-	it("should call processRule for root-level array", () => {
-		v._processRule.mockClear();
+	it("should call compileSchemaRule for root-level array", () => {
+		v.compileSchemaRule.mockClear();
 
 		v.compile([
 			{ type: "array", items: "number" },
 			{ type: "string", min: 2 }
 		]);
 
-		expect(v._processRule).toHaveBeenCalledTimes(2);
-		expect(v._processRule).toHaveBeenCalledWith({"type": "array", items: "number"}, null, false);
-		expect(v._processRule).toHaveBeenCalledWith({"type": "string", min: 2 }, null, false);
+		expect(v.compileSchemaRule).toHaveBeenCalledTimes(3);
+		expect(v.compileSchemaRule).toHaveBeenCalledWith({"type": "array", items: "number"});
+		expect(v.compileSchemaRule).toHaveBeenCalledWith("number");
+		expect(v.compileSchemaRule).toHaveBeenCalledWith({"type": "string", min: 2 });
 	});
 
 	it("should throw error is the schema is null", () => {
@@ -163,9 +168,36 @@ describe("Test compile (unit test)", () => {
 			v.compile([], []);
 		}).toThrowError();
 	});
+
+	it("should throw error if the type is invalid", () => {
+		expect(() => {
+			v.compile({ id: { type: "unknow" } });
+		}).toThrowError("Invalid 'unknow' type in validator schema!");
+	});
+
+	it("should throw error if object has array props", () => {
+		const schema = {
+			invalid: { type: "object", props: [ { type: "string" }, { type: "number" } ] }
+		};
+
+		expect(() => {
+			v.compile(schema);
+		}).toThrowError();
+	});
+
+	it("should throw error if object has string props", () => {
+		const schema = {
+			invalid: { type: "object", props: "string" }
+		};
+
+		expect(() => {
+			v.compile(schema);
+		}).toThrowError();
+	});
 });
 
-describe("Test _processRule", () => {
+// Skip tests for earlier compiler internals
+describe.skip("Test _processRule", () => {
 
 	const v = new Validator();
 	v.compile = jest.fn();
@@ -195,12 +227,6 @@ describe("Test _processRule", () => {
 		expect(res[0].iterate).toBe(false);
 	});
 
-	it("should throw error if the type is invalid", () => {
-		expect(() => {
-			v._processRule({ type: "unknow" }, "id", false);
-		}).toThrowError("Invalid 'unknow' type in validator schema!");
-	});
-
 	it("should call compile if type is object", () => {
 		let res = v._processRule({ type: "object", props: {
 			id: "number"
@@ -956,4 +982,98 @@ describe("Test multiple rules with arrays", () => {
 
 	});
 
-});
\ No newline at end of file
+});
+
+describe("Test multiple array in root", () => {
+	const v = new Validator();
+
+	let schema = [
+		{ 
+			type: "array",
+			items: "string" 
+		},
+		{ 
+			type: "array",
+			items: "number" 
+		}
+	];
+
+	let check = v.compile(schema);
+
+	it("should give true if first array is given", () => {
+		let obj = ["hello", "there", "this", "is", "a", "test"];
+
+		let res = check(obj);
+
+		expect(res).toBe(true);
+	});
+
+	it("should give true if second array is given", () => {
+		let obj = [1, 3, 3, 7];
+
+		let res = check(obj);
+
+		expect(res).toBe(true);
+	});
+
+	it("should give error if the array is broken", () => {
+		let obj = ["hello", 3];
+
+		let res = check(obj);
+
+		expect(res).toBeInstanceOf(Array);
+		expect(res.length).toBe(2);
+		expect(res[0].type).toBe("string");
+		expect(res[0].field).toBe("[1]");
+
+		expect(res[1].type).toBe("number");
+		expect(res[1].field).toBe("[0]");
+	});
+
+	it("should give error if the array is broken", () => {
+		let obj = [true, false];
+		let res = check(obj);
+
+		expect(res).toBeInstanceOf(Array);
+		expect(res.length).toBe(4);
+		expect(res[0].type).toBe("string");
+		expect(res[0].field).toBe("[0]");
+
+		expect(res[1].type).toBe("string");
+		expect(res[1].field).toBe("[1]");
+
+	});
+
+});
+
+describe("Test object without props", () => {
+	const v = new Validator();
+
+	it("should compile and validate", () => {
+		const schema = {
+			valid: { type: "object" }
+		};
+
+		const check = v.compile(schema);
+		expect(check).toBeInstanceOf(Function);
+
+		const res = check({ valid: { a: "b" } });
+		expect(res).toBe(true);
+	});
+});
+
+describe("Test array without items", () => {
+	const v = new Validator();
+
+	it("should compile and validate", () => {
+		const schema = {
+			valid: { type: "array" }
+		};
+
+		const check = v.compile(schema);
+		expect(check).toBeInstanceOf(Function);
+
+		const res = check({ valid: [1, 2, 3] });
+		expect(res).toBe(true);
+	});
+});