Repository: icebob/fastest-validator
Pull request #300 (2022-07-24 22:14:51)

Add Record rule
I added a new `record` rule. I decided to use `record` instead of `map` because it can be confused with an native `Map` object.
If everything is ok, I'll improve tests and add docs.

Related issue https://github.com/icebob/fastest-validator/issues/276


Patch:
diff --git a/README.md b/README.md
index 8ceec6b..7c886cf 100644
--- a/README.md
+++ b/README.md
@@ -928,6 +928,30 @@ check(obj); // Fail
 // ]
 ```
 
+## `record`
+This validator allows to check an object with arbitrary keys.
+
+```js
+const schema = {
+    surnameGroups: {
+        type: 'record',
+        key: { type: 'string', alpha: true },
+        value: { type: 'array', items: 'string' }
+    }
+};
+const check = v.compile(schema);
+
+check({ surnameGroups: { Doe: ['Jane', 'John'], Williams: ['Bill'] } }); // Valid
+check({ surnameGroups: { Doe1: ['Jane', 'John'] } }); // Fail
+check({ surnameGroups: { Doe: [1, 'Jane'] } }); // Fail
+```
+
+### Properties
+Property | Default  | Description
+-------- |----------| -----------
+`key`    | `string` | Key validation rule (It is reasonable to use only the `string` rule).
+`value`  | `any`    | Value validation rule.
+
 ## `string`
 This is a `String` validator.
 
diff --git a/index.d.ts b/index.d.ts
index 1550140..d51daa2 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -17,6 +17,7 @@ export type ValidationRuleName =
 	| "multi"
 	| "number"
 	| "object"
+	| "record"
 	| "string"
 	| "url"
 	| "uuid"
@@ -385,6 +386,21 @@ export interface RuleObjectID extends RuleCustom {
 	convert?: boolean | "hexString";
 }
 
+export interface RuleRecord extends RuleCustom {
+	/**
+	 * Name of built-in validator
+	 */
+	type: "record";
+	/**
+	 * Key validation rule
+	 */
+	key?: RuleString;
+	/**
+	 * Value validation rule
+	 */
+	value?: ValidationRuleObject;
+}
+
 /**
  * Validation schema definition for "string" built-in validator
  * @see https://github.com/icebob/fastest-validator#string
@@ -804,6 +820,7 @@ export type ValidationRuleObject =
 	| RuleNumber
 	| RuleObject
 	| RuleObjectID
+	| RuleRecord
 	| RuleString
 	| RuleTuple
 	| RuleURL
diff --git a/lib/messages.js b/lib/messages.js
index 7128085..bb2d672 100644
--- a/lib/messages.js
+++ b/lib/messages.js
@@ -81,4 +81,6 @@ module.exports = {
 	classInstanceOf: "The '{field}' field must be an instance of the '{expected}' class.",
 
 	objectID: "The '{field}' field must be an valid ObjectID",
+
+	record: "The '{field}' must be an Object."
 };
diff --git a/lib/rules/record.js b/lib/rules/record.js
new file mode 100644
index 0000000..544d69e
--- /dev/null
+++ b/lib/rules/record.js
@@ -0,0 +1,45 @@
+function patchKeyRuleMessages(rule) {
+	for (const type in rule.messages) {
+		if (type.startsWith("string")) {
+			rule.messages[type] = rule.messages[type].replace(" field ", " key ");
+		}
+	}
+}
+
+module.exports = function compileRecordRule({ schema, messages }, path, context) {
+	const sourceCode = [];
+	sourceCode.push(`
+		if (typeof value !== "object" || value === null || Array.isArray(value)) {
+			${this.makeError({ type: "record", actual: "value", messages })}
+			return value;
+		}
+	`);
+
+	const keyRuleName = schema.key || "string";
+	const valueRuleName = schema.value || "any";
+
+	sourceCode.push(`
+		let tmp;
+		for (let key in value) {
+	`);
+
+	const keyRule = this.getRuleFromSchema(keyRuleName);
+	patchKeyRuleMessages(keyRule);
+	const keyInnerSource = `
+		tmp = ${context.async ? "await " : ""}context.fn[%%INDEX%%](key, field ? field + "." + key : key, value, errors, context);
+	`;
+	sourceCode.push(this.compileRule(keyRule, context, null, keyInnerSource, "tmp"));
+
+	const valueRule = this.getRuleFromSchema(valueRuleName);
+	const valueInnerSource = `
+		tmp = ${context.async ? "await " : ""}context.fn[%%INDEX%%](value[key], field ? field + "." + key : key, value, errors, context);
+	`;
+	sourceCode.push(this.compileRule(valueRule, context, `${path}[key]`, valueInnerSource, "tmp"));
+	sourceCode.push(`
+		}
+	`);
+
+	return {
+		source: sourceCode.join("\n")
+	};
+};
diff --git a/lib/validator.js b/lib/validator.js
index f0c5c44..2c1b8cc 100644
--- a/lib/validator.js
+++ b/lib/validator.js
@@ -30,6 +30,7 @@ function loadRules() {
 		number: require("./rules/number"),
 		object: require("./rules/object"),
 		objectID: require("./rules/objectID"),
+		record: require("./rules/record"),
 		string: require("./rules/string"),
 		tuple: require("./rules/tuple"),
 		url: require("./rules/url"),


Test patch:
diff --git a/test/rules/record.spec.js b/test/rules/record.spec.js
new file mode 100644
index 0000000..53e8454
--- /dev/null
+++ b/test/rules/record.spec.js
@@ -0,0 +1,93 @@
+"use strict";
+
+const Validator = require("../../lib/validator");
+const v = new Validator({ debug: false });
+
+describe("Test rule: record", () => {
+
+	it("should check values", () => {
+		const check = v.compile({ $$root: true, type: "record" });
+		const message = "The '' must be an Object.";
+
+		expect(check(0)).toEqual([{ type: "record", actual: 0, message }]);
+		expect(check(1)).toEqual([{ type: "record", actual: 1, message }]);
+		expect(check("")).toEqual([{ type: "record", actual: "", message }]);
+		expect(check(false)).toEqual([{ type: "record", actual: false, message }]);
+		expect(check(true)).toEqual([{ type: "record", actual: true, message }]);
+		expect(check([])).toEqual([{ type: "record", actual: [], message }]);
+		expect(check({})).toEqual(true);
+		expect(check({ a: "John" })).toEqual(true);
+	});
+
+	it("should return key validation error when record has invalid key", () => {
+		const check = v.compile({ $$root: true, type: "record", key: { type: "string", numeric: true } });
+
+		expect(check({ nonNumeric: 3 })).toEqual([
+			{ type: "stringNumeric", actual: "nonNumeric", field: "nonNumeric", message: "The 'nonNumeric' key must be a numeric string." }
+		]);
+	});
+
+
+	it("should return value validation error when record has invalid value", () => {
+		const check = v.compile({ $$root: true, type: "record", value: { type: "number" }});
+
+		expect(check({ John: "Doe", Jane: 33 })).toEqual([
+			{ type: "number", actual: "Doe", field: "John", message: "The 'John' field must be a number." }
+		]);
+	});
+
+	it("should return value and key validation errors when record has invalid value and key", () => {
+		const check = v.compile({
+			$$root: true,
+			type: "record",
+			key: { type: "string", alpha: true },
+			value: { type: "string" }
+		});
+
+		expect(check({ John: "Doe", 1: 2 })).toEqual([
+			{ type: "stringAlpha", actual: "1", field: "1", message: "The '1' key must be an alphabetic string." },
+			{ type: "string", actual: 2, field: "1", message: "The '1' field must be a string." }
+		]);
+	});
+
+	it("should pass validation when schema has only key rule", () => {
+		const check = v.compile({
+			$$root: true,
+			type: "record",
+			key: { type: "string", alpha: true }
+		});
+
+		expect(check({ John: "Doe", Jane: "Doe" })).toEqual(true);
+	});
+
+	it.each([
+		{ rule: { type: "any" }, value: {} },
+		{ rule: { type: "array" }, value: [1, 2] },
+		{ rule: { type: "boolean" }, value: true },
+		{ rule: { type: "class", instanceOf: Number }, value: new Number() },
+		{ rule: { type: "currency", currencySymbol: "$" }, value: "$11.11" },
+		{ rule: { type: "date" }, value: new Date() },
+		{ rule: { type: "email" }, value: "user@example.com" },
+		{ rule: { type: "enum", values: ["John", "Jane"] }, value: "John" },
+		{ rule: { type: "forbidden" }, value: undefined },
+		{ rule: { type: "function" }, value: () => {} },
+		{ rule: { type: "luhn" }, value: "452373989901198" },
+		{ rule: { type: "mac" }, value: "01:C8:95:4B:65:FE" },
+		{ rule: { type: "multi", rules: ["number", "boolean"] }, value: 4 },
+		{ rule: { type: "number" }, value: 3 },
+		{ rule: { type: "object" }, value: {} },
+		{ rule: { type: "record" }, value: { test: "test" } },
+		{ rule: { type: "string" }, value: "example" },
+		{ rule: { type: "url" }, value: "https://example.com" },
+		{ rule: { type: "uuid" }, value: "10ba038e-48da-487b-96e8-8d3b99b6d18a" }
+	])(
+		"should pass validation when schema has '$rule.type' rule as value", ({ rule, value }) => {
+			const check = v.compile({
+				$$root: true,
+				type: "record",
+				value: rule
+			});
+
+			expect(check({ John: value })).toEqual(true);
+		});
+});
diff --git a/test/typescript/validator.spec.ts b/test/typescript/validator.spec.ts
index f22a52c..c056da9 100644
--- a/test/typescript/validator.spec.ts
+++ b/test/typescript/validator.spec.ts
@@ -12,7 +12,7 @@ describe('TypeScript Definitions', () => {
 			expect(v.validate).toBeInstanceOf(Function);
 			expect(v.add).toBeInstanceOf(Function);
 
-			expect(Object.keys(v.rules)).toHaveProperty('length', 22);
+			expect(Object.keys(v.rules)).toHaveProperty('length', 23);
 		});
 
 		it('should create instance with custom messages', () => {
diff --git a/test/validator.spec.js b/test/validator.spec.js
index 9ff5d59..6f1d137 100644
--- a/test/validator.spec.js
+++ b/test/validator.spec.js
@@ -12,7 +12,7 @@ describe("Test constructor", () => {
 		expect(v.validate).toBeInstanceOf(Function);
 		expect(v.add).toBeInstanceOf(Function);
 
-		expect(Object.keys(v.rules).length).toBe(22);
+		expect(Object.keys(v.rules).length).toBe(23);
 	});
 
 	it("should create instance with custom messages", () => {