Skip to content

Commit

Permalink
add math capabilities (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
regevbr authored Aug 30, 2022
1 parent 476edef commit 559bd68
Show file tree
Hide file tree
Showing 9 changed files with 303 additions and 41 deletions.
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@ const expression: IExampleExpression = {
{
userId: '[email protected]',
},
{
times: {
lte: {
op: '+',
lhs: {
ref: 'nested.value4'
},
rhs: 2,
},
},
},
{
and: [
{
Expand Down Expand Up @@ -129,7 +140,7 @@ There are 4 types of operators you can use (evaluated in that order of precedenc
- `and` - accepts a non-empty list of expressions
- `or` - accepts a non-empty list of expressions
- `not` - accepts another expressions
- `<user defined funcs>` - accepts any type of argument and evaluated by the user defined functions and the given context. Can be async.
- `<user defined funcs>` - accepts any type of argument and evaluated by the user defined functions, and the given context (can be async).
- `<compare funcs>` - operates on one of the context properties and compares it to a given value.
- `{property: {op: value}}`
- available ops:
Expand All @@ -145,13 +156,36 @@ There are 4 types of operators you can use (evaluated in that order of precedenc
- `inq: any[]` - True if in an array of values. Comparison is done using the `===` operator
- `between: readonly [number, number] (as const)` - True if the value is between the two specified values: greater than or equal to first value and less than or equal to second value.
- `{property: value}`
- compares the property to that value (shorthand to the `eq` op)
- compares the property to that value (shorthand to the `eq` op, without the option to user math or refs to other properties)

> Nested properties in the context can also be accessed using a dot notation (see example above)
> In each expression level, you can only define 1 operator, and 1 only
> You can reference values (and nested values) from the context using the {"ref":"<dot notation path>"}
> (see example above) on the right-hand side of expressions (not in parameters to user defined functions though)
The right-hand side of compare (not user defined) functions can be a:
- literal - number/string/boolean (depending on the left-hand side of the function)
- reference to a property (or nested property) in the context.
This can be achieved by using `{"ref":"<dot notation path>"}`
- A math operation that can reference properties in the context.
The valid operations are `+,-,*,/,%,pow`.
This can be achieved by using
```json
{
"op": "<+,-,*,/,%,pow>",
"lhs": {"ref": "<dot notation path>"}, // or a number literal
"rhs": {"ref": "<dot notation path>"} // or a number literal
}
```
which will be computed as `<lhs> <op> <rhs>` where lhs is left-hand-side and rhs is right-hand-side. So for example
```json
{
"op": "/",
"lhs": 10,
"rhs": 2
}
```
will equal `10 / 2 = 5`


Example expressions, assuming we have the `user` and `maxCount` user defined functions in place can be:
```json
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "json-expression-eval",
"version": "5.0.0",
"version": "5.1.0",
"description": "json serializable rule engine / boolean expression evaluator",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand All @@ -14,7 +14,8 @@
"build": "yarn lint && yarn compile",
"compile": "./node_modules/.bin/tsc",
"test:cover": "nyc --reporter=lcov --reporter=text-summary mocha --opts src/test/mocha.opts",
"lint": "tslint -c tslint.json 'src/**/*.ts' 'test/**/*.ts'"
"lint": "tslint -c tslint.json 'src/**/*.ts' 'test/**/*.ts'",
"ci": "yarn lint && yarn compile && yarn test:tsd && yarn test:cover"
},
"repository": {
"type": "git",
Expand Down
1 change: 1 addition & 0 deletions src/examples/engine/example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ rules = [
{user: '[email protected]'},
{maxCount: 5},
{times: {eq:{ref:'nested.value'}}},
{times: {lte:{op:'+', lhs: {ref:'nested.value'}, rhs: 1}}},
],
},
consequence: {
Expand Down
9 changes: 9 additions & 0 deletions src/examples/evaluator/example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ expression = {

run(expression, context);

expression = {
and: [
{user: '[email protected]'},
{times: {eq:{op:'+', lhs: {ref:'nested.value'}, rhs: 1}}},
],
};

run(expression, context);

expression = {
and: [
{user: '[email protected]'},
Expand Down
75 changes: 50 additions & 25 deletions src/lib/evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,17 @@ import {
isInqCompareOp,
isNinCompareOp,
isRegexCompareOp,
isRegexiCompareOp
isRegexiCompareOp,
isWithRef, isMathOp,
WithRef
} from './typeGuards';
import {
Context,
Expression,
FunctionsTable,
ExtendedCompareOp,
ValidationContext,
PropertyCompareOps, Primitive
PropertyCompareOps, Primitive, MathOp
} from '../types';
import {
assertUnreachable,
Expand All @@ -34,13 +36,8 @@ import {
expressionNumberAssertion
} from './helpers';

type WithRef = {
ref: string
}

const isWithRef = (x: unknown): x is WithRef => Boolean((x as WithRef).ref);

const extractValueOrRef = <C extends Context>(context: C, validation: boolean, valueOrRef: Primitive | WithRef) => {
const extractValueOrRef = <C extends Context>(context: C, validation: boolean, valueOrRef: Primitive | WithRef)
: Primitive => {
if (isWithRef(valueOrRef)) {
const {value, exists} = getFromPath(context, valueOrRef.ref);
if (validation && !exists) {
Expand All @@ -52,9 +49,37 @@ const extractValueOrRef = <C extends Context>(context: C, validation: boolean, v
}
}

async function evaluateCompareOp<C extends Context>(expressionValue: ExtendedCompareOp<any, any, any>,
expressionKey: string, contextValue: any,
context: C, validation: boolean)
const computeValue = <C extends Context>(context: C, validation: boolean,
value: Primitive | WithRef | MathOp<any, any>,
expressionKey: string): Primitive => {
if (isMathOp(value)) {
const lhs = extractValueOrRef<C>(context, validation, value.lhs);
const rhs = extractValueOrRef<C>(context, validation, value.rhs);
expressionNumberAssertion(expressionKey, lhs);
expressionNumberAssertion(expressionKey, rhs);
switch (value.op) {
case '+':
return lhs + rhs;
case '-':
return lhs - rhs;
case '*':
return lhs * rhs;
case '/':
return lhs / rhs;
case '%':
return lhs % rhs;
case 'pow':
return Math.pow(lhs, rhs);
default:
throw new Error(`Invalid expression - ${expressionKey} has invalid math operand ${value.op}`);
}
}
return extractValueOrRef(context, validation, value);
}

async function evaluateCompareOp<C extends Context, Ignore>(expressionValue: ExtendedCompareOp<any, any, any>,
expressionKey: string, contextValue: any,
context: C, validation: boolean)
: Promise<boolean> {
if (!_isObject(expressionValue)) {
return contextValue === expressionValue;
Expand All @@ -64,43 +89,43 @@ async function evaluateCompareOp<C extends Context>(expressionValue: ExtendedCom
throw new Error('Invalid expression - too may keys');
}
if (isEqualCompareOp(expressionValue)) {
return contextValue === extractValueOrRef(context, validation, expressionValue.eq);
return contextValue === computeValue(context, validation, expressionValue.eq, expressionKey);
} else if (isNotEqualCompareOp(expressionValue)) {
return contextValue !== extractValueOrRef(context, validation, expressionValue.neq);
return contextValue !== computeValue(context, validation, expressionValue.neq, expressionKey);
} else if (isInqCompareOp(expressionValue)) {
return expressionValue.inq.map((value) => extractValueOrRef(context, validation, value))
return expressionValue.inq.map((value) => computeValue(context, validation, value, expressionKey))
.indexOf(contextValue) >= 0;
} else if (isNinCompareOp(expressionValue)) {
return expressionValue.nin.map((value) => extractValueOrRef(context, validation, value))
return expressionValue.nin.map((value) => computeValue(context, validation, value, expressionKey))
.indexOf(contextValue) < 0;
} else if (isRegexCompareOp(expressionValue)) {
contextStringAssertion(expressionKey, contextValue);
const regexpValue = extractValueOrRef(context, validation, expressionValue.regexp);
const regexpValue = computeValue(context, validation, expressionValue.regexp, expressionKey);
expressionStringAssertion(expressionKey, regexpValue);
return Boolean(contextValue.match(new RegExp(regexpValue)));
} else if (isRegexiCompareOp(expressionValue)) {
contextStringAssertion(expressionKey, contextValue);
const regexpiValue = extractValueOrRef(context, validation, expressionValue.regexpi);
const regexpiValue = computeValue(context, validation, expressionValue.regexpi, expressionKey);
expressionStringAssertion(expressionKey, regexpiValue);
return Boolean(contextValue.match(new RegExp(regexpiValue, `i`)));
} else if (isGtCompareOp(expressionValue)) {
contextNumberAssertion(expressionKey, contextValue);
const gtValue = extractValueOrRef(context, validation, expressionValue.gt);
const gtValue = computeValue(context, validation, expressionValue.gt, expressionKey);
expressionNumberAssertion(expressionKey, gtValue);
return contextValue > gtValue;
} else if (isGteCompareOp(expressionValue)) {
contextNumberAssertion(expressionKey, contextValue);
const gteValue = extractValueOrRef(context, validation, expressionValue.gte);
const gteValue = computeValue(context, validation, expressionValue.gte, expressionKey);
expressionNumberAssertion(expressionKey, gteValue);
return contextValue >= gteValue;
} else if (isLteCompareOp(expressionValue)) {
contextNumberAssertion(expressionKey, contextValue);
const lteValue = extractValueOrRef(context, validation, expressionValue.lte);
const lteValue = computeValue(context, validation, expressionValue.lte, expressionKey);
expressionNumberAssertion(expressionKey, lteValue);
return contextValue <= lteValue;
} else if (isLtCompareOp(expressionValue)) {
contextNumberAssertion(expressionKey, contextValue);
const ltValue = extractValueOrRef(context, validation, expressionValue.lt);
const ltValue = computeValue(context, validation, expressionValue.lt, expressionKey);
expressionNumberAssertion(expressionKey, ltValue);
return contextValue < ltValue;
} else if (isBetweenCompareOp(expressionValue)) {
Expand All @@ -109,8 +134,8 @@ async function evaluateCompareOp<C extends Context>(expressionValue: ExtendedCom
throw new Error(`Invalid expression - ${expressionKey}.length must be 2`);
}
const [lowRaw, highRaw] = expressionValue.between;
const low = extractValueOrRef(context, validation, lowRaw);
const high = extractValueOrRef(context, validation, highRaw);
const low = computeValue(context, validation, lowRaw, expressionKey);
const high = computeValue(context, validation, highRaw, expressionKey);
expressionNumberAssertion(`${expressionKey}[0]`, low);
expressionNumberAssertion(`${expressionKey}[1]`, high);
if (low > high) {
Expand Down Expand Up @@ -170,7 +195,7 @@ async function run<C extends Context, F extends FunctionsTable<C>, Ignore>
if (validation && !exists) {
throw new Error(`Invalid expression - unknown context key ${expressionKey}`);
}
return evaluateCompareOp<C>(
return evaluateCompareOp<C, Ignore>(
(expression as PropertyCompareOps<C, Ignore>)
[expressionKey as any as keyof PropertyCompareOps<C, Ignore>] as
unknown as ExtendedCompareOp<any, any, any>,
Expand Down
9 changes: 8 additions & 1 deletion src/lib/typeGuards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
RegexiCompareOp,
NotCompareOp,
NotEqualCompareOp,
OrCompareOp, InqCompareOp, NinCompareOp, RuleFunctionsTable, RuleFunctionsParams, Primitive
OrCompareOp, InqCompareOp, NinCompareOp, RuleFunctionsTable, RuleFunctionsParams, Primitive, MathOp
} from '../types';

export const _isObject = (obj: unknown): boolean => {
Expand Down Expand Up @@ -103,3 +103,10 @@ export const isNinCompareOp = (op: ExtendedCompareOp<any, any, any>)
: op is NinCompareOp<any, any, any> => {
return Array.isArray((op as NinCompareOp<any, any, any>).nin);
}

export type WithRef = {
ref: string
}

export const isWithRef = (x: unknown): x is WithRef => Boolean((x as WithRef).ref);
export const isMathOp = (x: unknown): x is MathOp<any, any> => Boolean((x as MathOp<any, any>).op);
Loading

0 comments on commit 559bd68

Please sign in to comment.