Skip to content

Commit

Permalink
feat: use scope-eval and contextual-proxy to replace bcx-expression-e…
Browse files Browse the repository at this point in the history
…valuator

BREAKING CHANGE: the expression behaviour is now different from bcx-expression-evaluator,
noticably not silent exception on accessing property of undefined.
  • Loading branch information
3cp committed Jul 6, 2021
1 parent 6834d4f commit 5944614
Show file tree
Hide file tree
Showing 13 changed files with 145 additions and 95 deletions.
25 changes: 13 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,25 +16,25 @@
"license": "MIT",
"author": "Chunpeng Huo",
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-env": "^7.11.5",
"@babel/register": "^7.11.5",
"@babel/cli": "^7.11.6",
"@vercel/ncc": "^0.23.0",
"@babel/core": "^7.14.6",
"@babel/preset-env": "^7.14.7",
"@babel/register": "^7.14.5",
"@babel/cli": "^7.14.5",
"@vercel/ncc": "^0.28.6",
"babel-eslint": "^10.1.0",
"eslint": "^7.9.0",
"standard-changelog": "^2.0.24",
"eslint": "^7.30.0",
"standard-changelog": "^2.0.27",
"tap-nirvana": "^1.1.0",
"tape": "^5.0.1"
"tape": "^5.2.2"
},
"scripts": {
"prebuild": "ncc build src/index.js -m -e bcx-expression-evaluator -e lodash -o packed",
"prebuild": "ncc build src/index.js -m -e contextual-proxy -e scoped-eval -e lodash -o packed",
"build": "babel packed/index.js -o dist/index.js",
"lint": "eslint src",
"prepare": "npm run build",
"preversion": "npm test",
"version": "standard-changelog && git add CHANGELOG.md",
"postversion": "git push && git push --tags && npm publish",
"postversion": "git push && git push --tags && npm publish --tag next",
"pretest": "npm run lint",
"test": "tape -r @babel/register 'test/**/*.spec.js' | tap-nirvana"
},
Expand All @@ -45,7 +45,8 @@
"index.d.ts"
],
"dependencies": {
"bcx-expression-evaluator": "^1.2.1",
"lodash": "^4.17.20"
"contextual-proxy": "^0.2.0",
"lodash": "^4.17.21",
"scoped-eval": "^0.1.0"
}
}
5 changes: 5 additions & 0 deletions src/can-be-proxied.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

export default function canBeProxied(value) {
const type = typeof value;
return type === 'function' || (type === 'object' && value !== null);
}
13 changes: 6 additions & 7 deletions src/scope-variation.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import _ from 'lodash';

export function modifiedOverrideContext(overrideContext, variation) {
return {...overrideContext, ...variation};
}
import proxy from 'contextual-proxy';

export default function (scope, variation) {
if (_.isEmpty(variation)) return scope;
let {bindingContext, overrideContext} = scope;
return {bindingContext, overrideContext: modifiedOverrideContext(overrideContext, variation)};
}
let {$this, $parent, $contextual} = scope;
const contextual = Object.create($contextual);
Object.assign(contextual, variation);
return proxy($this, $parent, contextual);
}
2 changes: 1 addition & 1 deletion src/standard-validator-wrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function (validator, opts = {}) {
const overrideMessage = (!result.isValid && messageEvaluator) ?
messageEvaluator(scopeVariation(_scope, {
// use original scope $value, not overrided value
$value: scope.overrideContext.$value,
$value: scope.$value,
// pass original errors
$errors: result.errors
})) :
Expand Down
33 changes: 9 additions & 24 deletions src/standard-validators.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import _ from 'lodash';
import {createOverrideContext} from 'bcx-expression-evaluator';
import proxy from 'contextual-proxy';
import valueEvaluator from './value-evaluator';
import canBeProxied from './can-be-proxied';

export function isBlank(v) {
if (_.isNull(v) || _.isUndefined(v) || _.isNaN(v)) return true;
Expand Down Expand Up @@ -56,19 +57,12 @@ export const switchTransformer = function (rule, validate, inPropertyName) {

const validator = scope => {
// make a guess whether user try to use nested validation or plain validation
const value = scope.overrideContext.$value;
const value = scope.$value;
let precompiled, precompiledDefault;

if (_.isObjectLike(value)) {
// in nested object
const {overrideContext} = scope;
let newOverrideContext = createOverrideContext(value, overrideContext);
newOverrideContext.$value = value;
const newScope = {
bindingContext: value,
overrideContext: newOverrideContext
};

const newScope = proxy(value, scope, {$value: value});
precompiled = precompiledNested[switchEvaluator(newScope)];
precompiledDefault = precompiledNestedDefault;
} else {
Expand Down Expand Up @@ -107,23 +101,19 @@ export const forEachTransformer = function (rule, validate /*, inPropertyName*/)
}

// don't pass inPropertyName to underneath validators,
// they work in new overrideContext with propertyPath null.
// they work in new scope with propertyPath null.
const precompiled = !foreachRulesMapFunc && validate(foreachRulesMap);

const _key = _.get(rule, 'key', '$index');
const keyEvaluator = valueEvaluator(_key);

const validator = scope => {
let errors = {};
const enumerable = scope.overrideContext.$value;
const errors = {};
const enumerable = scope.$value;
const length = _.size(enumerable);
_.each(enumerable, (item, index) => {
const {overrideContext} = scope;
let newOverrideContext = createOverrideContext(item, overrideContext);

let neighbours = _.filter(enumerable, (v, i) => i !== index);

_.merge(newOverrideContext, {
const neighbours = _.filter(enumerable, (v, i) => i !== index);
const newScope = proxy(canBeProxied(item) ? item : {}, scope, {
$value: item,
$propertyPath: null, // initial propertyPath
$neighbours: neighbours,
Expand All @@ -133,11 +123,6 @@ export const forEachTransformer = function (rule, validate /*, inPropertyName*/)
$last: (index === length - 1),
});

const newScope = {
bindingContext: item,
overrideContext: newOverrideContext
};

const key = keyEvaluator(newScope);
const result = (precompiled || validate(foreachRulesMapFunc(newScope)))(newScope);

Expand Down
20 changes: 12 additions & 8 deletions src/validation.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {createSimpleScope} from 'bcx-expression-evaluator';
import proxy from 'contextual-proxy';
import valueEvaluator from './value-evaluator';
import scopeVariation from './scope-variation';
import validatorChain from './validator-chain';
import standardValidatorWrap from './standard-validator-wrap';
import {config} from './standard-validators';
import { config } from './standard-validators';
import canBeProxied from './can-be-proxied';

import _ from 'lodash';

const NAME_FORMAT = /^[a-z][a-z0-9_]+/i;
Expand Down Expand Up @@ -188,10 +190,12 @@ class Validation {
}

_buildScope(model, helper = {}) {
let scope = createSimpleScope(model, {...Validation.sharedHelpers, ...this.helpers, ...helper});
// initial $value and $propertyPath
_.merge(scope.overrideContext, {$value: model, $propertyPath: null});
return scope;
return proxy(
canBeProxied(model) ? model : {},
{ ...Validation.sharedHelpers, ...this.helpers, ...helper },
// initial $value and $propertyPath
{ $value: model, $propertyPath: null }
);
}

validate(model, rulesMap, helper = {}) {
Expand Down Expand Up @@ -226,8 +230,8 @@ class Validation {
_.each(rulesMap, (rules, propertyName) => {
const path = inPropertyName ? [...inPropertyName, propertyName] : [propertyName];

const value = _.get(valueEvaluator('$this')(scope), path);
const neighbourValues = _.map(valueEvaluator('$neighbours')(scope), _.property(path));
const value = _.get(scope.$this, path);
const neighbourValues = _.map(scope.$neighbours, _.property(path));
const localScope = scopeVariation(scope, {
$value: value,
$propertyPath: path,
Expand Down
49 changes: 38 additions & 11 deletions src/value-evaluator.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,56 @@
import _ from 'lodash';
import {Parser} from 'bcx-expression-evaluator';

const parser = new Parser();
import ScopedEval from 'scoped-eval';

const scopedEval = new ScopedEval();
const cache = Object.create(null);

function build(expression, opts) {
if (!cache[expression]) {
try {
if (opts && opts.stringInterpolationMode) {
expression = "`" + expression + "`";
}
cache[expression] = scopedEval.build(expression);
} catch (e) {
throw new Error(`Failed to parse expression: ${JSON.stringify(expression)}\n${e.message}`);
}
}
return cache[expression];
}

export default function (input, opts) {

if (_.isString(input) && _.trim(input).length) {
const expression = parser.parse(input, opts);
return scope => expression.evaluate(scope);
const func = build(input, opts);
return scope => {
try {
return func.call(scope);
} catch (e) {
throw new Error(`Failed to execute expression: ${JSON.stringify(input)}\n${e.message}`);
}
};
}

if (_.isRegExp(input)) {
return scope => {
return input.test(scope.overrideContext.$value);
return input.test(scope.$value);
};
}

if (_.isFunction(input)) {
const func = input;

return scope => {
const value = scope.overrideContext.$value;
const propertyPath = scope.overrideContext.$propertyPath;
const context = scope.overrideContext.bindingContext;
const get = expression => parser.parse(expression).evaluate(scope);
const value = scope.$value;
const propertyPath = scope.$propertyPath;
const context = scope.$this;
const get = expression => {
const func = build(expression);
try {
return func.call(scope);
} catch (e) {
throw new Error(`Failed to execute expression: ${JSON.stringify(input)}\n${e.message}`);
}
};

return func(value, propertyPath, context, get);
};
Expand Down
33 changes: 33 additions & 0 deletions test/can-be-proxied.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import test from 'tape';
import canBeProxied from '../src/can-be-proxied';

test('object can be proxied, but not null object', t => {
console.log('canBeProxied({})', canBeProxied({}));
t.ok(canBeProxied({}));
t.ok(canBeProxied(Object.create(null)));
t.ok(canBeProxied([]));
t.ok(canBeProxied({a: 1}));
t.ok(canBeProxied(/a/));
t.notOk(canBeProxied(null));
t.end();
});

test('function can be proxied', t => {
t.ok(canBeProxied(function() {}));
t.ok(canBeProxied(() => false));
t.end();
});

test('primitive values cannot be proxied', t => {
t.notOk(canBeProxied(1));
t.notOk(canBeProxied(0));
t.notOk(canBeProxied(""));
t.notOk(canBeProxied("lorem"));
t.notOk(canBeProxied(NaN));
t.notOk(canBeProxied(Infinity));
t.notOk(canBeProxied(undefined));
t.notOk(canBeProxied(false));
t.notOk(canBeProxied(true));
t.notOk(canBeProxied(Symbol('f')));
t.end();
});
35 changes: 18 additions & 17 deletions test/scope-variation.spec.js
Original file line number Diff line number Diff line change
@@ -1,38 +1,39 @@
import test from 'tape';
import _ from 'lodash';
import scopeVariation from '../src/scope-variation';
import {createSimpleScope, Parser} from 'bcx-expression-evaluator';
import proxy from 'contextual-proxy';
import ScopedEval from 'scoped-eval';

const parser = new Parser();
const scopedEval = new ScopedEval();

test('scopeVariation: creates scope variation', t => {
const model = {a: 1, b: 2};
const parent = {c: 3, d: 4};

const scope = createSimpleScope(model, parent);
const scope = proxy(model, parent);

const variation = scopeVariation(scope, {a: 'one', c: {new: 1}});

t.deepEqual(model, {a: 1, b: 2}, 'does not touch original bindingContext');
t.deepEqual(parent, {c: 3, d: 4}, 'does not touch original parentBindingContext');
t.deepEqual(model, {a: 1, b: 2}, 'does not touch original object');
t.deepEqual(parent, {c: 3, d: 4}, 'does not touch original parent object');

t.equal(parser.parse('a').evaluate(scope), 1);
t.equal(parser.parse('a').evaluate(variation), 'one');
t.equal(scopedEval.eval('a', scope), 1);
t.equal(scopedEval.eval('a', variation), 'one');

t.equal(parser.parse('b').evaluate(scope), 2);
t.equal(parser.parse('b').evaluate(variation), 2);
t.equal(scopedEval.eval('b', scope), 2);
t.equal(scopedEval.eval('b', variation), 2);

t.equal(parser.parse('c').evaluate(scope), 3);
t.deepEqual(parser.parse('c').evaluate(variation), {new: 1});
t.equal(scopedEval.eval('c', scope), 3);
t.deepEqual(scopedEval.eval('c', variation), {new: 1});

t.equal(parser.parse('$parent.a').evaluate(scope), undefined);
t.equal(parser.parse('$parent.a').evaluate(variation), undefined);
t.equal(scopedEval.eval('$parent.a', scope), undefined);
t.equal(scopedEval.eval('$parent.a', variation), undefined);

t.equal(parser.parse('$parent.b').evaluate(scope), undefined);
t.equal(parser.parse('$parent.b').evaluate(variation), undefined);
t.equal(scopedEval.eval('$parent.b', scope), undefined);
t.equal(scopedEval.eval('$parent.b', variation), undefined);

t.equal(parser.parse('$parent.c').evaluate(scope), 3);
t.deepEqual(parser.parse('$parent.c').evaluate(variation), 3);
t.equal(scopedEval.eval('$parent.c', scope), 3);
t.deepEqual(scopedEval.eval('$parent.c', variation), 3);

t.end();
});
2 changes: 1 addition & 1 deletion test/standard-transformers-and-validators/is-true.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ test('Validation: can use helper', t => {
t.equal(v.validate({a: ['bar', 'foo']}, rule, {myjoin}), undefined);

// if helper is missing
t.deepEqual(v.validate({a: ['bar', 'foo']}, rule), {a: ['lorem']}, 'missing helper yields undefined');
t.throws(() => v.validate({a: ['bar', 'foo']}, rule));
t.end();
});

Expand Down
Loading

0 comments on commit 5944614

Please sign in to comment.