Skip to content

Commit 9bba768

Browse files
committed
abstracting engine
1 parent 245c29c commit 9bba768

16 files changed

+231
-166
lines changed

README.md

+31-16
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
# json-rules-engine-simplified
22
A simple rules engine expressed in JSON
33

4+
The primary goal of this project was to be used
5+
as alternative to [json-rules-engine](https://github.com/CacheControl/json-rules-engine) in [react-jsonschema-form-conditionals](https://github.com/RxNT/react-jsonschema-form-conditionals),
6+
as such it has some functionality, that might be specific, but there is nothing preventing it from more generic use.
7+
48
## Features
59

6-
- Rules expressed in simple, easy to read JSON
7-
- Declarative conditional logic with [Predicates](https://github.com/landau/predicate)
8-
- Validation, based on JSON schema and available [predicates](https://github.com/landau/predicate)
9-
- Secure; no use of eval()
10-
- Support of nested structures
10+
- Optional schema and rules validation, to prevent runtime surprises
11+
- Basic boolean operations (`and` `or` and `not`) that allow to have any arbitrary complexity
12+
- Rules expressed in simple, easy to read JSON
13+
- Declarative conditional logic with [predicates](https://github.com/landau/predicate)
14+
- Support of nested structures with [selectn](https://github.com/wilmoore/selectn.js)
15+
including composite arrays
16+
- Secure - no use of eval()
1117

1218
## Installation
1319

@@ -24,36 +30,45 @@ The simplest example of using `json-rules-engine-simplified`
2430
```jsx
2531
import { Engine } from 'json-rules-engine-simplified'
2632

27-
/**
28-
* Setup a new engine
29-
*/
30-
let engine = new Engine()
31-
/**
32-
* Specify engine rules
33-
*/
34-
engine.addRule({
33+
let rules = [{
3534
conditions: {
3635
firstName: "empty"
3736
},
3837
event: {
3938
type: "remove",
4039
params: { fields: [ "password" ] },
4140
}
42-
});
41+
}];
4342

44-
let facts = {
43+
let schema = {
44+
properties: {
45+
firstName: { type: "string" },
46+
lastName: { type: "string" }
47+
}
48+
}
49+
50+
/**
51+
* Setup a new engine
52+
*/
53+
let engine = new Engine(rules, schema);
54+
55+
let formData = {
4556
lastName: "Smit"
4657
}
4758

4859
// Run the engine to evaluate
4960
engine
50-
.run(facts)
61+
.run(formData)
5162
.then(events => { // run() returns remove event
5263
events.map(event => console.log(event.type));
5364
})
5465

5566
```
5667

68+
## Validation
69+
70+
71+
5772
## Conditional logic
5873

5974
Conditional logic is based on public [predicate](https://github.com/landau/predicate) library

package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
},
1919
"jest": {
2020
"verbose": true,
21-
"collectCoverage": true
21+
"collectCoverage": true,
22+
"collectCoverageFrom" : ["src/**/*.{js,jsx}"]
2223
},
2324
"prettierOptions": "--jsx-bracket-same-line --trailing-comma es5 --semi",
2425
"lint-staged": {

src/Engine.js

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { validatePredicates, validateConditionFields } from "./validation";
2+
import applicableActions from "./applicableActions";
3+
import { isDevelopment, isObject, toError } from "./utils";
4+
5+
class Engine {
6+
constructor(rules, schema) {
7+
this.rules = rules;
8+
if (isDevelopment()) {
9+
let conditions = rules.map(rule => rule.conditions);
10+
validatePredicates(conditions);
11+
if (schema !== undefined && schema !== null) {
12+
if (isObject(schema)) {
13+
validateConditionFields(conditions, schema)
14+
} else {
15+
toError(`Expected valid schema object, but got - ${schema}`)
16+
}
17+
}
18+
}
19+
}
20+
run = (formData) => Promise.resolve(applicableActions(this.rules, formData));
21+
}
22+
23+
export default Engine;

src/PredicatesRuleEngine.js

-17
This file was deleted.

src/checkField.js

+22-14
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,45 @@
11
import predicate from "predicate";
22
import { isObject } from "./utils";
33

4+
export const OR = "or";
5+
export const AND = "and";
6+
export const NOT = "not";
7+
48
const POSITIVE_PREDICATE = predicate;
59
const NEGATIVE_PREDICATE = predicate.not;
610

711
export default function checkField(
812
fieldVal,
913
rule,
1014
predicator = predicate,
11-
condition = Array.prototype.every
1215
) {
13-
if (isObject(rule)) {
16+
if (Array.isArray(fieldVal)) {
17+
// Simple rule - like emptyString
18+
return fieldVal.some(val => checkField(val, rule, predicator));
19+
} else if (isObject(rule)) {
1420
// Complicated rule - like { greater then 10 }
15-
return condition.call(Object.keys(rule), p => {
21+
return Object.keys(rule).every(p => {
1622
let comparable = rule[p];
17-
if (isObject(comparable) || p === "not") {
18-
if (p === "or") {
19-
return comparable.some(condition =>
20-
checkField(fieldVal, condition, predicator, Array.prototype.every)
21-
);
22-
} else if (p === "not") {
23+
if (isObject(comparable) || p === NOT) {
24+
if (p === OR || p === AND) {
25+
if (Array.isArray(comparable)) {
26+
if ( p === OR ) {
27+
return comparable.some(rule => checkField(fieldVal, rule, predicator));
28+
} else {
29+
return comparable.every(rule => checkField(fieldVal, rule, predicator));
30+
}
31+
} else {
32+
return false;
33+
}
34+
} else if (p === NOT) {
2335
let oppositePredicator =
2436
predicator === NEGATIVE_PREDICATE
2537
? POSITIVE_PREDICATE
2638
: NEGATIVE_PREDICATE;
2739
return checkField(
2840
fieldVal,
2941
comparable,
30-
oppositePredicator,
31-
Array.prototype.every
42+
oppositePredicator
3243
);
3344
} else {
3445
return false;
@@ -37,9 +48,6 @@ export default function checkField(
3748
return predicator[p](fieldVal, comparable);
3849
}
3950
});
40-
} else if (Array.isArray(fieldVal)) {
41-
// Simple rule - like emptyString
42-
return fieldVal.some(val => predicator[rule](val));
4351
} else {
4452
return predicator[rule](fieldVal);
4553
}

src/conditionsMeet.js

+8-6
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import { isObject, toError } from "./utils";
2-
import checkField from "./checkField";
2+
import checkField, { OR, AND, NOT} from "./checkField";
33
import selectn from "selectn";
44

55
export default function conditionsMeet(conditions, formData) {
66
if (!isObject(conditions) || !isObject(formData)) {
77
toError(`Rule ${conditions} with ${formData} can't be processed`);
88
}
99
return Object.keys(conditions).every(conditionKey => {
10-
if (conditionKey === "or") {
11-
return conditions[conditionKey].some(sr => conditionsMeet(sr, formData));
12-
} else if (conditionKey === "and") {
13-
return conditions[conditionKey].every(sr => conditionsMeet(sr, formData));
10+
let refFieldRule = conditions[conditionKey];
11+
if (conditionKey === OR) {
12+
return refFieldRule.some(sr => conditionsMeet(sr, formData));
13+
} else if (conditionKey === AND) {
14+
return refFieldRule.every(sr => conditionsMeet(sr, formData));
15+
} else if (conditionKey === NOT) {
16+
return !conditionsMeet(refFieldRule, formData);
1417
} else {
1518
let refVal = selectn(conditionKey, formData);
16-
let refFieldRule = conditions[conditionKey];
1719
if (Array.isArray(refVal)) {
1820
return refVal.some(val => conditionsMeet(refFieldRule, val));
1921
} else {

src/index.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
import PredicatesRuleEngine from "./PredicatesRuleEngine";
1+
import Engine from './Engine';
22

3-
export default PredicatesRuleEngine;
3+
export default Engine;

src/validation.js

+12-10
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import predicate from "predicate";
22
import { flatMap, isObject, toError } from "./utils";
33

4+
import { NOT, AND, OR } from './checkField';
5+
46
export function predicatesFromRule(rule) {
57
if (isObject(rule)) {
68
return flatMap(Object.keys(rule), p => {
79
let comparable = rule[p];
8-
if (isObject(comparable) || p === "not") {
9-
if (p === "or") {
10+
if (isObject(comparable) || p === NOT) {
11+
if (p === OR || p === AND) {
1012
if (Array.isArray(comparable)) {
11-
return flatMap(comparable, condition =>
12-
predicatesFromRule(condition)
13-
);
13+
return flatMap(comparable, condition => predicatesFromRule(condition));
1414
} else {
15-
toError(`OR must be an array`);
15+
toError(`"${p}" must be an array`);
1616
return [];
1717
}
1818
} else {
@@ -31,7 +31,7 @@ export function predicatesFromRule(rule) {
3131

3232
export function predicatesFromCondition(condition) {
3333
return flatMap(Object.keys(condition), ref => {
34-
if (ref === "or" || ref === "and") {
34+
if (ref === OR || ref === AND) {
3535
return flatMap(condition[ref], w => predicatesFromRule(w));
3636
} else {
3737
return predicatesFromRule(condition[ref]);
@@ -54,7 +54,7 @@ export function listInvalidPredicates(rules) {
5454

5555
export function fieldsFromCondition(condition) {
5656
return flatMap(Object.keys(condition), ref => {
57-
if (ref === "or" || ref === "and") {
57+
if (ref === OR || ref === AND) {
5858
return flatMap(condition[ref], w => fieldsFromCondition(w));
5959
} else {
6060
return [ref];
@@ -75,14 +75,16 @@ export function listInvalidFields(conditions, schema) {
7575
return Array.from(ruleFields);
7676
}
7777

78-
export default function validateConditions(conditions, schema) {
78+
export function validateConditionFields(conditions, schema) {
7979
let invalidFields = listInvalidFields(conditions, schema);
8080
if (invalidFields.length !== 0) {
8181
toError(`Rule contains invalid fields ${invalidFields}`);
8282
}
83+
}
8384

85+
export function validatePredicates(conditions) {
8486
let invalidPredicates = listInvalidPredicates(conditions);
8587
if (invalidPredicates.length !== 0) {
8688
toError(`Rule contains invalid predicates ${invalidPredicates}`);
8789
}
88-
}
90+
}

test/Engine.invalid.test.js

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import Engine from "../src/Engine";
2+
import { testInProd } from "./utils";
3+
4+
let invalidRules = [
5+
{
6+
conditions: {
7+
age: {
8+
and: {
9+
greater: 5,
10+
less: 70,
11+
},
12+
},
13+
},
14+
event: {
15+
type: "remove",
16+
params: {
17+
fields: ["telephone"],
18+
},
19+
},
20+
},
21+
];
22+
23+
let schema = {
24+
properties: {
25+
age: { type: "number" },
26+
telephone: { type: "string" },
27+
},
28+
};
29+
30+
test("ignore invalid rules if no schema provided", () => {
31+
expect(() => new Engine(invalidRules)).not.toBeUndefined();
32+
});
33+
34+
test("ignore invalid rules with invalid schema", () => {
35+
expect(() => new Engine({}, [])).toThrow();
36+
expect(() => new Engine({}, "schema")).toThrow();
37+
});
38+
39+
test("initialize with invalid rules", () => {
40+
expect(() => new Engine(invalidRules, schema)).toThrow();
41+
expect(testInProd(() => new Engine(invalidRules, schema))).not.toBeUndefined();
42+
});

test/PredicateRuleEngine.test.js test/Engine.test.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import PredicatesRuleEngine from "../src/PredicatesRuleEngine";
1+
import Engine from "../src/index";
22

33
let rules = [
44
{
@@ -22,11 +22,11 @@ let schema = {
2222
},
2323
};
2424

25-
let engine = PredicatesRuleEngine;
25+
let engine = new Engine(rules, schema);
2626

2727
test("age greater 5", () => {
2828
return engine
29-
.run({ age: 10 }, rules, schema)
29+
.run({ age: 10 })
3030
.then(actions =>
3131
expect(actions).toEqual([
3232
{ type: "remove", params: { fields: ["telephone"] } },
@@ -36,13 +36,13 @@ test("age greater 5", () => {
3636

3737
test("age less 5", () => {
3838
return engine
39-
.run({ age: 4 }, rules, schema)
39+
.run({ age: 4 })
4040
.then(actions => expect(actions).toEqual([]));
4141
});
4242

4343
test("age less 70 ", () => {
4444
return engine
45-
.run({ age: 69 }, rules, schema)
45+
.run({ age: 69 })
4646
.then(actions =>
4747
expect(actions).toEqual([
4848
{ type: "remove", params: { fields: ["telephone"] } },
@@ -52,6 +52,6 @@ test("age less 70 ", () => {
5252

5353
test("age greater 70 ", () => {
5454
return engine
55-
.run({ age: 71 }, rules, schema)
55+
.run({ age: 71 })
5656
.then(actions => expect(actions).toEqual([]));
5757
});

0 commit comments

Comments
 (0)