Repository: icebob/fastest-validator
Pull request #142 (2020-05-22 10:29:00)

[Feature] Add support for minProps and maxProps in object validation
Hi, thanks for the good job you've been doing with `fastest-validator`!

Would you be interested in two new rules `minProps` and `maxProps` in the object validation? These rules would be the minimum required props in an object (and maximum allowed props).

These features exist in JSON Schema, under the names `minProperties` and `maxProperties`, but since `fastest-validator` seems to use the name `props`, I figured I'd use `minProps` and `maxProps` instead.

What do you think about that?

BR,

Alex

coveralls: 
[![Coverage Status](https://coveralls.io/builds/30967273/badge)](https://coveralls.io/builds/30967273)

Coverage remained the same at 100.0% when pulling **c01ffeb12b6c0401adaeda75b1759ecca0ff8eeb on alexjab:object_props_count** into **906b36696332c5977c0b894317df29f5180a58af on icebob:master**.


Patch:
diff --git a/README.md b/README.md
index 3f7333a..cd7340b 100644
--- a/README.md
+++ b/README.md
@@ -769,10 +769,12 @@ v.validate({
 ### Properties
 Property | Default  | Description
 -------- | -------- | -----------
-`strict`  | `false`| if `true` any properties which are not defined on the schema will throw an error. If `remove` all additional properties will be removed from the original object. _It's a sanitizer, it will change the original object._
+`strict`  | `false`| If `true` any properties which are not defined on the schema will throw an error. If `remove` all additional properties will be removed from the original object. _It's a sanitizer, it will change the original object._
+`minProps` | `null` | If set to a number N, will throw an error if the object has fewer than N properties.
+`maxProps` | `null` | If set to a number N, will throw an error if the object has more than N properties.
 
 ```js
-const schema = {
+let schema = {
     address: { type: "object", strict: "remove", props: {
         country: { type: "string" },
         city: "string", // short-hand
@@ -780,7 +782,7 @@ const schema = {
     } }
 }
 
-const obj = {
+let obj = {
     address: {
         country: "Italy",
         city: "Rome",
@@ -800,6 +802,46 @@ console.log(obj);
     }   
 }
 */
+
+schema = {
+  address: {
+    type: "object",
+    minProps: 2,
+    props: {
+      country: { type: "string" },
+      city: { type: "string", optional: true },
+      zip: { type: "number", optional: true }
+    }
+  }
+}
+
+obj = {
+    address: {
+        country: "Italy",
+        city: "Rome",
+        zip: 12345,
+        state: "IT"
+    }
+}
+
+v.validate(obj, schema); // Valid
+
+obj = {
+    address: {
+        country: "Italy",
+    }
+}
+
+v.validate(obj, schema); // Fail
+// [
+//   {
+//     type: 'objectMinProps',
+//     message: "The object 'address' must contain at least 2 properties.",
+//     field: 'address',
+//     expected: 2,
+//     actual: 1
+//   }
+// ]
 ```
 
 ## `string`
@@ -1130,6 +1172,8 @@ Name                | Default text
 `equalField`	| The '{field}' field value must be equal to '{expected}' field value.
 `object`	| The '{field}' must be an Object.
 `objectStrict`	| The object '{field}' contains forbidden keys: '{actual}'.
+`objectMinProps` | "The object '{field}' must contain at least {expected} properties.
+`objectMaxProps` | "The object '{field}' must contain {expected} properties at most.
 `uuid`	| The '{field}' field must be a valid UUID.
 `uuidVersion`	| The '{field}' field must be a valid UUID version provided.
 `mac`	| The '{field}' field must be a valid MAC address.
diff --git a/index.d.ts b/index.d.ts
index f55e1ba..b7e0d9e 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -313,6 +313,14 @@ declare module "fastest-validator" {
 		 */
 		properties?: ValidationSchema;
 		props?: ValidationSchema;
+		/**
+		 * If set to a number, will throw if the number of props is less than that number.
+		 */
+		minProps?: number;
+		/**
+		 * If set to a number, will throw if the number of props is greater than that number.
+		 */
+		maxProps?: number;
 	}
 
 	/**
@@ -801,10 +809,16 @@ declare module "fastest-validator" {
 		type: string;
 		expected?: unknown;
 		actual?: unknown;
-		field?: string
+		field?: string;
 	}
 
-	type CheckerFunctionV1<T = unknown> = (value: T, schema: ValidationSchema, path: string, parent: object | null, context: Context) => true | ValidationError[];
+	type CheckerFunctionV1<T = unknown> = (
+		value: T,
+		schema: ValidationSchema,
+		path: string,
+		parent: object | null,
+		context: Context
+	) => true | ValidationError[];
 	type CheckerFunctionV2<T = unknown> = (
 		value: T,
 		errors: CheckerFunctionError[],
@@ -814,7 +828,9 @@ declare module "fastest-validator" {
 		context: Context
 	) => T;
 
-	type CheckerFunction<T = unknown> = CheckerFunctionV1<T> | CheckerFunctionV2<T>
+	type CheckerFunction<T = unknown> =
+		| CheckerFunctionV1<T>
+		| CheckerFunctionV2<T>;
 
 	type CompilationFunction = (
 		rule: CompilationRule,
diff --git a/lib/messages.js b/lib/messages.js
index 3b529d2..a543d7c 100644
--- a/lib/messages.js
+++ b/lib/messages.js
@@ -58,6 +58,8 @@ module.exports = {
 
 	object: "The '{field}' must be an Object.",
 	objectStrict: "The object '{field}' contains forbidden keys: '{actual}'.",
+	objectMinProps: "The object '{field}' must contain at least {expected} properties.",
+	objectMaxProps: "The object '{field}' must contain {expected} properties at most.",
 
 	url: "The '{field}' field must be a valid URL.",
 
diff --git a/lib/rules/object.js b/lib/rules/object.js
index 88661a6..d8026f6 100644
--- a/lib/rules/object.js
+++ b/lib/rules/object.js
@@ -99,7 +99,42 @@ module.exports = function ({ schema, messages }, path, context) {
 				}
 			`);
 		}
+	}
+
+	if (schema.minProps != null || schema.maxProps != null) {
+		// We recalculate props, because:
+		//	- if strict equals 'remove', we want to work on
+		//	the payload with the extra keys removed,
+		//	- if no strict is set, we need them anyway.
+		if (schema.strict) {
+			sourceCode.push(`
+				props = Object.keys(${subSchema ? 'parentObj' : 'value'});
+			`);
+		} else {
+			sourceCode.push(`
+				var props = Object.keys(${subSchema ? 'parentObj' : 'value'});
+				${subSchema ? 'field = parentField;' : ''}
+			`);
+		}
+	}
+
+	if (schema.minProps != null) {
+		sourceCode.push(`
+			if (props.length < ${schema.minProps}) {
+				${this.makeError({ type: "objectMinProps", expected: schema.minProps, actual: "props.length", messages })}
+			}
+		`);
+	}
+
+	if (schema.maxProps != null) {
+		sourceCode.push(`
+			if (props.length > ${schema.maxProps}) {
+				${this.makeError({ type: "objectMaxProps", expected: schema.maxProps, actual: "props.length", messages })}
+			}
+		`);
+	}
 
+	if (subSchema) {
 		sourceCode.push(`
 			return parentObj;
 		`);
diff --git a/lib/validator.js b/lib/validator.js
index b863806..6bf8049 100644
--- a/lib/validator.js
+++ b/lib/validator.js
@@ -331,8 +331,8 @@ class Validator {
 		};
 		if (field) o.field = `"${field}"`;
 		else o.field = "field";
-		if (expected) o.expected = expected;
-		if (actual) o.actual = actual;
+		if (expected != null) o.expected = expected;
+		if (actual != null) o.actual = actual;
 
 		const s = Object.keys(o)
 			.map(key => `${key}: ${o[key]}`)


Test patch:
diff --git a/test/rules/number.spec.js b/test/rules/number.spec.js
index 9a88ab8..c1af714 100644
--- a/test/rules/number.spec.js
+++ b/test/rules/number.spec.js
@@ -35,6 +35,8 @@ describe("Test rule: number", () => {
 		expect(check(-20)).toEqual([{ type: "numberMin", expected: 5, actual: -20, message }]);
 		expect(check(5)).toEqual(true);
 		expect(check(8)).toEqual(true);
+
+		expect(v.validate(-1, { $$root: true, type: "number", min: 0})).toEqual([{actual: -1, expected: 0, field: undefined, message: "The '' field must be greater than or equal to 0.", type: 'numberMin'}]);
 	});
 
 	it("check max", () => {
diff --git a/test/rules/object.spec.js b/test/rules/object.spec.js
index c792037..f8d273c 100644
--- a/test/rules/object.spec.js
+++ b/test/rules/object.spec.js
@@ -57,6 +57,82 @@ describe("Test rule: object", () => {
 			.toEqual([{ type: "required", field: "user.address.city", actual: undefined, message: "The 'user.address.city' field is required." }]);
 	});
 
+  it('should check min props', () => {
+      const check = v.compile({ $$root: true, type: 'object', props: {
+          optional_key_1: { type: 'string', optional: true },
+          optional_key_2: { type: 'number', optional: true },
+          optional_key_3: { type: 'boolean', optional: true },
+          optional_key_4: { type: 'array', optional: true },
+      }, minProps: 2 });
+
+      expect(check({})).toEqual([{ type: 'objectMinProps', actual: 0, expected: 2, message: "The object '' must contain at least 2 properties.", field: undefined }]);
+      expect(check({ optional_key_1: 'foobar', optional_key_2: 9 })).toEqual(true);
+      expect(check({ optional_key_1: 'foobar', optional_key_2: 9, optional_key_3: false })).toEqual(true);
+
+      const checkNested = v.compile({
+        key: { type: 'object',
+          props: {
+            nested: {
+              type: 'object',
+              props: {
+                optional_key_1: { type: 'string', optional: true },
+                optional_key_2: { type: 'number', optional: true },
+              },
+              minProps: 1
+            }
+          }
+        },
+      });
+
+      expect(checkNested({ key: { nested: {} } })).toEqual([{ type: 'objectMinProps', actual: 0, expected: 1, message: "The object 'key.nested' must contain at least 1 properties.", field: 'key.nested' }]);
+      expect(checkNested({ key: { nested: { optional_key_1: 'foobar' } } })).toEqual(true);
+      expect(v.validate({}, {$$root: true, type: 'object', minProps: 0})).toEqual(true);
+  });
+
+  it('should check max props', () => {
+      const check = v.compile({ $$root: true, type: 'object', props: {
+          optional_key_1: { type: 'string', optional: true },
+          optional_key_2: { type: 'number', optional: true },
+          optional_key_3: { type: 'boolean', optional: true },
+          optional_key_4: { type: 'array', optional: true },
+      }, maxProps: 2 });
+
+      expect(check({ optional_key_1: 'foobar', optional_key_2: 9, optional_key_3: true })).toEqual([{ type: 'objectMaxProps', actual: 3, expected: 2, message: "The object '' must contain 2 properties at most.", field: undefined }]);
+      expect(check({ optional_key_1: 'foobar', optional_key_2: 9 })).toEqual(true);
+      expect(check({ optional_key_2: 9 })).toEqual(true);
+      expect(check({})).toEqual(true);
+
+      const checkWithStrict = v.compile({ $$root: true, type: 'object', strict: 'remove', props: {
+          optional_key_1: { type: 'string' },
+          optional_key_2: { type: 'number' },
+      }, maxProps: 2 });
+
+      expect(checkWithStrict({ optional_key_1: 'foobar', optional_key_2: 9, optional_key_3: true })).toEqual(true);
+
+      const checkNested = v.compile({
+        key: { type: 'object',
+          props: {
+            nested: {
+              type: 'object',
+              props: {
+                optional_key_1: { type: 'string', optional: true },
+                optional_key_2: { type: 'number', optional: true },
+              },
+              maxProps: 1
+            }
+          }
+        },
+      });
+
+      expect(checkNested({ key: { nested: {} } })).toEqual(true);
+      expect(checkNested({ key: { nested: { optional_key_1: 'foobar' } } })).toEqual(true);
+      expect(checkNested({ key: { nested: { optional_key_1: 'foobar', optional_key_3: 99 } } })).toEqual([{ type: 'objectMaxProps', actual: 2, expected: 1, message: "The object 'key.nested' must contain 1 properties at most.", field: 'key.nested' }]);
+
+      expect(v.validate({}, {$$root: true, type: 'object', maxProps: 0})).toEqual(true);
+      expect(v.validate({foo:'bar'}, {$$root: true, type: 'object', maxProps: 0})).toEqual([{actual: 1, field: undefined, message: "The object '' must contain 0 properties at most.", type: 'objectMaxProps', expected: 0}]);
+      expect(v.validate({foo:'bar'}, {$$root: true, type: 'object', maxProps: 0, strict: 'remove'})).toEqual([{actual: 1, field: undefined, message: "The object '' must contain 0 properties at most.", type: 'objectMaxProps', expected: 0}]);
+  });
+
 
 	describe("Test sanitization", () => {