From 8e3386b01a6473be5ad593327738a26950ca0867 Mon Sep 17 00:00:00 2001 From: Evyatar Date: Fri, 13 Nov 2020 01:57:59 +0200 Subject: [PATCH] Added: Chaining support in lazy enforcement (#495) --- jsconfig.json | 3 + packages/__shared/src/passArgs | 0 packages/n4s/docs/shape.md | 8 +- packages/n4s/package.json | 2 +- .../n4s/src/enforce/__tests__/enforce.test.js | 48 ++++--- .../enforce/compounds/__tests__/shape.test.js | 16 +-- .../n4s/src/enforce/compounds/optional.js | 8 +- packages/n4s/src/enforce/compounds/shape.js | 8 +- packages/n4s/src/enforce/enforce.js | 123 +++++++++--------- packages/n4s/src/enforce/enforceKeywords.js | 1 + packages/n4s/src/enforce/enforceRunner.js | 5 +- packages/vest/package.json | 2 +- packages/vest/src/typings/vest.d.ts | 30 ++--- 13 files changed, 133 insertions(+), 121 deletions(-) create mode 100644 packages/__shared/src/passArgs create mode 100644 packages/n4s/src/enforce/enforceKeywords.js diff --git a/jsconfig.json b/jsconfig.json index d5f0c6a88..a38b1f69d 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -29,6 +29,9 @@ "enforce": [ "./packages/n4s/src/enforce/enforce.js" ], + "enforceKeywords": [ + "./packages/n4s/src/enforce/enforceKeywords.js" + ], "enforceRunner": [ "./packages/n4s/src/enforce/enforceRunner.js" ], diff --git a/packages/__shared/src/passArgs b/packages/__shared/src/passArgs new file mode 100644 index 000000000..e69de29bb diff --git a/packages/n4s/docs/shape.md b/packages/n4s/docs/shape.md index e0d58ca28..3ecda92ac 100644 --- a/packages/n4s/docs/shape.md +++ b/packages/n4s/docs/shape.md @@ -1,8 +1,8 @@ # Shape validations -Enforce (only, not ensure) comes with a built-in lean schema validator rule called `shape`. It allows you to use all the existing and custom rules of enforce to validate the shape of an object. +Enforce comes with a built-in lean schema validator rule called `shape`. It allows you to use all the existing and custom rules of enforce to validate the shape of an object. -When using enforce rules inside your shape, use the rules that exist as properties on enforce itself (`enforce.isString()`). For rules used like this, rule chaining is not possible. +When using enforce rules inside your shape, use the rules that exist as properties on enforce itself (`enforce.isString()`). ## Example @@ -20,13 +20,13 @@ enforce({ ## Testing multiple rules for the same key -To test multiple rules with the same key use an array of rules: +To test multiple rules with the same key you can chain them the same way you chain regular enforce rules: ```js enforce({ age: 22, }).shape({ - age: [enforce.isNumber(), enforce.isBetween(0, 150)], + age: enforce.isNumber().isBetween(0, 150), }); ``` diff --git a/packages/n4s/package.json b/packages/n4s/package.json index ffa6b342d..9a8ffb9fc 100644 --- a/packages/n4s/package.json +++ b/packages/n4s/package.json @@ -67,4 +67,4 @@ "build": "rollup -c ./config/rollup/rollup.config.js", "test": "jest" } -} +} \ No newline at end of file diff --git a/packages/n4s/src/enforce/__tests__/enforce.test.js b/packages/n4s/src/enforce/__tests__/enforce.test.js index 0874f657e..59fea6aad 100644 --- a/packages/n4s/src/enforce/__tests__/enforce.test.js +++ b/packages/n4s/src/enforce/__tests__/enforce.test.js @@ -1,3 +1,4 @@ +import { RUN_RULE } from 'enforceKeywords'; import rules from 'rules'; const allRules = Object.keys(rules()); @@ -89,32 +90,43 @@ const suite = ({ withProxy, requirePath }) => expect(typeof enforce.isAbc).toBe('function'); }); - test('Each rule returns a function', () => { - allRules.forEach(rule => - expect(typeof enforce[rule]()).toBe('function') - ); + it('Should retain all lazy functions in an array as a property of the returned object', () => { + expect(enforce.isEmpty()[RUN_RULE]).toBeInstanceOf(Array); + expect(enforce.isEmpty().isArray()[RUN_RULE]).toBeInstanceOf(Array); }); - test('Returned function returns a boolean value', () => { - expect(enforce.isArray()([])).toBe(true); - expect(enforce.isNumber()('not_a_number')).toBe(false); + it('Should store all the provided rules in the returned array', () => { + const res = enforce.isEmpty().isArray().equals()[RUN_RULE]; + expect(res).toHaveLength(3); + expect(res[0].name).toBe('isEmpty'); + expect(res[1].name).toBe('isArray'); + expect(res[2].name).toBe('equals'); + expect(typeof res[0]).toBe('function'); + expect(typeof res[1]).toBe('function'); + expect(typeof res[2]).toBe('function'); }); - it("Should use the second function's argument as the enforce value, and the first function's arguments as the ...rest", () => { - expect(enforce.isEmpty()([])).toBe(true); - expect(enforce.isEmpty()([1, 2, 3])).toBe(false); - expect(enforce.isNumeric()('555')).toBe(true); - expect(enforce.greaterThan(10)(20)).toBe(true); - expect(enforce.greaterThan(10)(4)).toBe(false); - + it('Should produce correct result when run', () => { + expect(enforce.isEmpty()[RUN_RULE].every(fn => fn([]))).toBe(true); + expect(enforce.isEmpty()[RUN_RULE].every(fn => fn([1, 2, 3]))).toBe( + false + ); + expect(enforce.isNumeric()[RUN_RULE].every(fn => fn('555'))).toBe(true); + expect(enforce.greaterThan(10)[RUN_RULE].every(fn => fn(20))).toBe( + true + ); + expect(enforce.greaterThan(20)[RUN_RULE].every(fn => fn(10))).toBe( + false + ); + expect(enforce.greaterThan(10)[RUN_RULE].every(fn => fn(4))).toBe( + false + ); const fn = jest.fn(() => true); - enforce.extend({ getArgs: fn, }); - - enforce.getArgs(2, 3, 4, 5, 6, 7)(1); - // One should be first + enforce.getArgs(2, 3, 4, 5, 6, 7)[RUN_RULE].every(fn => fn(1)); + // // One should be first expect(fn).toHaveBeenCalledWith(1, 2, 3, 4, 5, 6, 7); }); }); diff --git a/packages/n4s/src/enforce/compounds/__tests__/shape.test.js b/packages/n4s/src/enforce/compounds/__tests__/shape.test.js index 11e86513b..3f0d4bd52 100644 --- a/packages/n4s/src/enforce/compounds/__tests__/shape.test.js +++ b/packages/n4s/src/enforce/compounds/__tests__/shape.test.js @@ -61,18 +61,14 @@ describe('Shape validation', () => { ).toBe(true); }); - it('Allows array of enforcements per field', () => { + it('Allows multiple enforcements per field', () => { expect( shape( { friendCount: 200, }, { - friendCount: [ - enforce.isNumber(), - enforce.greaterThan(150), - enforce.equals(200), - ], + friendCount: enforce.isNumber().greaterThan(150).equals(200), } ) ).toBe(true); @@ -82,11 +78,7 @@ describe('Shape validation', () => { friendCount: 200, }, { - friendCount: [ - enforce.isNumber(), - enforce.greaterThan(150), - enforce.equals(300), - ], + friendCount: enforce.isNumber().greaterThan(150).equals(300), } ) ).toBe(false); @@ -308,7 +300,7 @@ describe('Shape validation', () => { const shapeRules = () => ({ user: enforce.shape({ - age: [enforce.isNumber(), enforce.isBetween(0, 10)], + age: enforce.isNumber().isBetween(0, 10), friends: enforce.optional(enforce.isArray()), id: enforce.isString(), name: enforce.shape({ diff --git a/packages/n4s/src/enforce/compounds/optional.js b/packages/n4s/src/enforce/compounds/optional.js index 3333982a2..3b94984e9 100644 --- a/packages/n4s/src/enforce/compounds/optional.js +++ b/packages/n4s/src/enforce/compounds/optional.js @@ -1,3 +1,5 @@ +import asArray from 'asArray'; +import { RUN_RULE } from 'enforceKeywords'; import { isNull } from 'isNull'; import { isUndefined } from 'isUndefined'; @@ -5,7 +7,7 @@ import { isUndefined } from 'isUndefined'; * @param {Array} ObjectEntry Object and key leading to current value * @param {Function[]} rules Rules to validate the value with */ -export default function optional([obj, key], ...rules) { +export default function optional([obj, key], ...ruleGroups) { if ( !Object.prototype.hasOwnProperty.call(obj, key) || isUndefined(obj[key] || isNull(obj[key])) @@ -13,5 +15,7 @@ export default function optional([obj, key], ...rules) { return true; } - return rules.every(fn => fn(obj[key])); + return asArray(ruleGroups).every(ruleGroup => { + return ruleGroup[RUN_RULE].every(fn => fn(obj[key])); + }); } diff --git a/packages/n4s/src/enforce/compounds/shape.js b/packages/n4s/src/enforce/compounds/shape.js index f12c0c00d..bec4a6553 100644 --- a/packages/n4s/src/enforce/compounds/shape.js +++ b/packages/n4s/src/enforce/compounds/shape.js @@ -1,6 +1,6 @@ import asArray from 'asArray'; +import { RUN_RULE } from 'enforceKeywords'; import optional from 'optional'; - /** * @param {Object} obj Data object that gets validated * @param {*} shapeObj Shape definition @@ -11,9 +11,9 @@ export default function shape(obj, shapeObj) { const value = obj[key]; if ( - !asArray(current).every(fn => - fn(fn.name === optional.name ? [obj, key] : value) - ) + !asArray(current[RUN_RULE]).every(fn => { + return fn(fn.name === optional.name ? [obj, key] : value); + }) ) { return false; } diff --git a/packages/n4s/src/enforce/enforce.js b/packages/n4s/src/enforce/enforce.js index 28fd86d39..d1e8b3c20 100644 --- a/packages/n4s/src/enforce/enforce.js +++ b/packages/n4s/src/enforce/enforce.js @@ -1,4 +1,5 @@ import compounds from 'compounds'; +import { RUN_RULE } from 'enforceKeywords'; import runner from 'enforceRunner'; import isRule from 'isRule'; import proxySupported from 'proxySupported'; @@ -6,81 +7,79 @@ import rules from 'rules'; const rulesObject = Object.assign(rules(), compounds); -let enforce, rulesList; +let rulesList = proxySupported() ? null : Object.keys(rulesObject); -const bindLazyRule = ruleName => (...args) => { - return Object.defineProperty( - value => rulesObject[ruleName](value, ...args), - 'name', - { value: ruleName } - ); -}; - -const bindLazyRules = rules => - rules.reduce( - (enforce, ruleName) => - Object.assign(enforce, { - [ruleName]: bindLazyRule(ruleName), - }), - enforce - ); - -if (proxySupported()) { - const enforceMain = value => { - const proxy = new Proxy(rulesObject, { - get: (rules, fnName) => { - if (!isRule(rules, fnName)) { - return enforce[fnName]; - } - - return (...args) => { - runner(rules[fnName], value, ...args); - return proxy; - }; - }, - }); +const Enforce = value => { + const target = proxySupported() ? enforce : {}; + const proxy = genRuleProxy(target, ruleName => (...args) => { + runner(rulesObject[ruleName], value, args); return proxy; - }; - - // This is for lazy enforcement: enforce.isArray()([]) // true - enforce = new Proxy(enforceMain, { - get: (enforce, fnName) => { - if (!isRule(rulesObject, fnName)) { - return enforce[fnName]; - } - - return bindLazyRule(fnName); - }, }); -} else { - rulesList = Object.keys(rulesObject); - - // This is for lazy enforcement: enforce.isArray()([]) // true - enforce = value => - rulesList.reduce((allRules, fnName) => { - if (!isRule(rulesObject, fnName)) { - return enforce[fnName]; - } - return Object.assign(allRules, { - [fnName]: (...args) => { - runner(rulesObject[fnName], value, ...args); - return allRules; - }, - }); - }, {}); + return proxy; +}; - bindLazyRules(rulesList); -} +const enforce = genRuleProxy(Enforce, bindLazyRule); enforce.extend = customRules => { Object.assign(rulesObject, customRules); if (!proxySupported()) { rulesList = Object.keys(rulesObject); - bindLazyRules(Object.keys(customRules)); + genRuleProxy(Enforce, bindLazyRule); } return enforce; }; export default enforce; + +// Creates a proxy object that has access to all the rules +function genRuleProxy(target, output) { + if (proxySupported()) { + return new Proxy(target, { + get: (target, fnName) => { + if (!isRule(rulesObject, fnName)) { + return target[fnName]; + } + + return output(fnName); + }, + }); + } else { + /** + * This method is REALLY not recommended as it is slow and iterates over + * all the rules for each direct enforce reference. We only use it as a + * lightweight alternative for the much faster proxy interface + */ + return rulesList.reduce((target, fnName) => { + return Object.defineProperties(target, { + [fnName]: { get: () => output(fnName), configurable: true }, + }); + }, target); + } +} + +// Initiates a chain of functions directly from the `enforce` +// function - that's even though we do not have any closure +// there to store that data. +function bindLazyRule(ruleName) { + const registeredRules = []; + + const addFn = fnName => (...args) => { + registeredRules.push( + Object.defineProperty( + value => rulesObject[fnName](value, ...args), + 'name', + { value: fnName } + ) + ); + + const returnvalue = genRuleProxy({}, addFn); + + return Object.assign(returnvalue, { + [RUN_RULE]: registeredRules, + }); + }; + + return addFn(ruleName); +} diff --git a/packages/n4s/src/enforce/enforceKeywords.js b/packages/n4s/src/enforce/enforceKeywords.js new file mode 100644 index 000000000..0376337ce --- /dev/null +++ b/packages/n4s/src/enforce/enforceKeywords.js @@ -0,0 +1 @@ +export const RUN_RULE = 'test'; diff --git a/packages/n4s/src/enforce/enforceRunner.js b/packages/n4s/src/enforce/enforceRunner.js index f093395d1..66cc87f8c 100644 --- a/packages/n4s/src/enforce/enforceRunner.js +++ b/packages/n4s/src/enforce/enforceRunner.js @@ -8,8 +8,9 @@ import { transformResult } from 'transformResult'; * @param {Array} args list of arguments sent from consumer * @throws */ -function runner(rule, value, ...args) { - const ruleResult = rule(value, ...args); +function runner(rule, value, args) { + // spreading here instead of passing rest params because of significant bundle size impact + const ruleResult = rule(value, ...(args || [])); const result = transformResult(ruleResult, { rule, value }); if (!result.pass) { throw new Error(result.message); diff --git a/packages/vest/package.json b/packages/vest/package.json index c97665580..8c8daa428 100644 --- a/packages/vest/package.json +++ b/packages/vest/package.json @@ -48,4 +48,4 @@ "context": "^1.1.0", "n4s": "^2.1.1" } -} +} \ No newline at end of file diff --git a/packages/vest/src/typings/vest.d.ts b/packages/vest/src/typings/vest.d.ts index f79b48deb..4a1d1b52b 100644 --- a/packages/vest/src/typings/vest.d.ts +++ b/packages/vest/src/typings/vest.d.ts @@ -164,7 +164,7 @@ export interface IEnforceRules { isNegative: RuleNumeral; isPositive: RuleNumeral; shape: (shape: { - [key: string]: LazyEnforceValue | LazyEnforceValue[]; + [key: string]: TEnforceLazy | TEnforceLazy[]; }) => RuleReturn; } @@ -192,19 +192,19 @@ interface IEnforce { ): (value: any) => IEnforceRules & EnforceExtendMap; } -type LazyEnforceValue = (enforceValue?: any) => boolean; -type LazyNumeral = (expected: TNumeral) => LazyEnforceValue; -type JustLazy = () => LazyEnforceValue; -type LazyString = (str: string) => LazyEnforceValue; -type LazyRange = (start: number, end: number) => LazyEnforceValue; -type LazyMatches = (expected: string | RegExp) => LazyEnforceValue; -type LazyAny = (expected: any) => LazyEnforceValue; +type LazyNumeral = (expected: TNumeral) => TEnforceLazy; +type JustLazy = () => TEnforceLazy; +type LazyString = (str: string) => TEnforceLazy; +type LazyRange = (start: number, end: number) => TEnforceLazy; +type LazyMatches = (expected: string | RegExp) => TEnforceLazy; +type LazyAny = (expected: any) => TEnforceLazy; type LazyInside = ( expected: Array | string -) => LazyEnforceValue; +) => TEnforceLazy; type TEnforceLazy = { - [key: string]: (...args: any[]) => LazyEnforceValue; + [key: string]: (...args: any[]) => TEnforceLazy | boolean; + run: (...args: any[]) => boolean; equals: LazyAny; notEquals: LazyAny; numberEquals: LazyNumeral; @@ -250,14 +250,14 @@ type TEnforceLazy = { notInside: LazyInside; lengthEquals: LazyNumeral; lengthNotEquals: LazyNumeral; - isNegative: LazyNumeral; - isPositive: LazyNumeral; + isNegative: JustLazy; + isPositive: JustLazy; isBoolean: JustLazy; isNotBoolean: JustLazy; shape: (shape: { - [key: string]: LazyEnforceValue | LazyEnforceValue[]; - }) => LazyEnforceValue; - optional: (...rules: LazyEnforceValue[]) => LazyEnforceValue; + [key: string]: TEnforceLazy | TEnforceLazy[]; + }) => TEnforceLazy; + optional: (...rules: TEnforceLazy[]) => TEnforceLazy; }; declare module 'vest' {