Skip to content

Commit

Permalink
Added: Chaining support in lazy enforcement (#495)
Browse files Browse the repository at this point in the history
  • Loading branch information
ealush committed Nov 12, 2020
1 parent b8c8b6c commit 8e3386b
Show file tree
Hide file tree
Showing 13 changed files with 133 additions and 121 deletions.
3 changes: 3 additions & 0 deletions jsconfig.json
Expand Up @@ -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"
],
Expand Down
Empty file added packages/__shared/src/passArgs
Empty file.
8 changes: 4 additions & 4 deletions 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

Expand All @@ -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),
});
```

Expand Down
2 changes: 1 addition & 1 deletion packages/n4s/package.json
Expand Up @@ -67,4 +67,4 @@
"build": "rollup -c ./config/rollup/rollup.config.js",
"test": "jest"
}
}
}
48 changes: 30 additions & 18 deletions 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());
Expand Down Expand Up @@ -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);
});
});
Expand Down
16 changes: 4 additions & 12 deletions packages/n4s/src/enforce/compounds/__tests__/shape.test.js
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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({
Expand Down
8 changes: 6 additions & 2 deletions packages/n4s/src/enforce/compounds/optional.js
@@ -1,17 +1,21 @@
import asArray from 'asArray';
import { RUN_RULE } from 'enforceKeywords';
import { isNull } from 'isNull';
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]))
) {
return true;
}

return rules.every(fn => fn(obj[key]));
return asArray(ruleGroups).every(ruleGroup => {
return ruleGroup[RUN_RULE].every(fn => fn(obj[key]));
});
}
8 changes: 4 additions & 4 deletions 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
Expand All @@ -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;
}
Expand Down
123 changes: 61 additions & 62 deletions packages/n4s/src/enforce/enforce.js
@@ -1,86 +1,85 @@
import compounds from 'compounds';
import { RUN_RULE } from 'enforceKeywords';
import runner from 'enforceRunner';
import isRule from 'isRule';
import proxySupported from 'proxySupported';
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);
}
1 change: 1 addition & 0 deletions packages/n4s/src/enforce/enforceKeywords.js
@@ -0,0 +1 @@
export const RUN_RULE = 'test';
5 changes: 3 additions & 2 deletions packages/n4s/src/enforce/enforceRunner.js
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/vest/package.json
Expand Up @@ -48,4 +48,4 @@
"context": "^1.1.0",
"n4s": "^2.1.1"
}
}
}

0 comments on commit 8e3386b

Please sign in to comment.