Skip to content

Commit 6f00a6f

Browse files
author
Christopher Pardy
committed
Support Facts In Events
Support event parameters to include facts this allows the events to reference values that are being stored in a persistent storage mechanism or otherwise unknown when the rules are written.
1 parent 4f622e3 commit 6f00a6f

File tree

9 files changed

+336
-18
lines changed

9 files changed

+336
-18
lines changed

docs/engine.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ condition reference that cannot be resolved an exception is thrown. Turning
5050
this option on will cause the engine to treat unresolvable condition references
5151
as failed conditions. (default: false)
5252

53+
`allowFactsInEventParams` - By default when rules succeed or fail the events emitted are clones of the event in the rule declaration. When setting this option to true the parameters on the events will be have any fact references resolved. (default: false)
54+
5355
`pathResolver` - Allows a custom object path resolution library to be used. (default: `json-path` syntax). See [custom path resolver](./rules.md#condition-helpers-custom-path-resolver) docs.
5456

5557
### engine.addFact(String id, Function [definitionFunc], Object [options])

docs/rules.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,12 @@ engine.on('failure', function(event, almanac, ruleResult) {
349349
})
350350
```
351351
352+
### Referencing Facts In Events
353+
354+
With the engine option [`allowFactsInEventParams`](./engine.md#options) the parameters of the event may include references to facts in the same form as [Comparing Facts](#comparing-facts).
355+
356+
See [11-using-facts-in-events.js](../examples/11-using-facts-in-events.js) for an example.
357+
352358
## Operators
353359
354360
Each rule condition must begin with a boolean operator(```all```, ```any```, or ```not```) at its root.

examples/11-using-facts-in-events.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"use strict";
2+
/*
3+
* This is an advanced example demonstrating rules that re-use a condition defined
4+
* in the engine.
5+
*
6+
* Usage:
7+
* node ./examples/10-condition-sharing.js
8+
*
9+
* For detailed output:
10+
* DEBUG=json-rules-engine node ./examples/10-condition-sharing.js
11+
*/
12+
13+
require("colors");
14+
const { Engine, Fact } = require("json-rules-engine");
15+
16+
async function start() {
17+
/**
18+
* Setup a new engine
19+
*/
20+
const engine = new Engine([], { allowFactsInEventParams: true });
21+
22+
// in-memory "database"
23+
let currentHighScore = null;
24+
const currentHighScoreFact = new Fact('currentHighScore', () => currentHighScore)
25+
26+
/**
27+
* Rule for when you've gotten the high score
28+
* - Event will include the previous high score
29+
*/
30+
const highScoreRule = {
31+
conditions: {
32+
any: [
33+
{
34+
fact: "currentHighScore",
35+
operator: "equal",
36+
value: null
37+
},
38+
{
39+
fact: "score",
40+
operator: "greaterThan",
41+
value: {
42+
fact: "currentHighScore",
43+
path: "$.score",
44+
},
45+
}
46+
],
47+
},
48+
event: {
49+
type: "highscore",
50+
params: {
51+
initials: { fact: 'initials' },
52+
score: { fact: "score" }
53+
},
54+
},
55+
};
56+
const gameOverRule = {
57+
conditions: {
58+
all: [
59+
{
60+
fact: "score",
61+
operator: "lessThanInclusive",
62+
value: {
63+
fact: "currentHighScore",
64+
path: "$.score"
65+
}
66+
}
67+
]
68+
},
69+
event: {
70+
type: "gameover",
71+
params: {
72+
initials: {
73+
fact: "currentHighScore",
74+
path: "$.initials"
75+
},
76+
score: {
77+
fact: "currentHighScore",
78+
path: "$.score"
79+
}
80+
}
81+
}
82+
}
83+
engine.addRule(highScoreRule);
84+
engine.addRule(gameOverRule);
85+
engine.addFact(currentHighScoreFact);
86+
87+
/**
88+
* Register listeners with the engine for rule success and failure
89+
*/
90+
engine
91+
.on("success", async ({ params: { initials, score }}) => {
92+
console.log(`HIGH SCORE\n${initials} - ${score}`);
93+
})
94+
.on("success", ({ type, params }) => {
95+
if(type === 'highscore') {
96+
currentHighScore = params;
97+
}
98+
});
99+
100+
let facts = {
101+
initials: 'DOG',
102+
score: 968
103+
}
104+
105+
// first run, without a high score
106+
await engine.run(facts);
107+
108+
console.log('\n');
109+
110+
// new player
111+
facts = {
112+
initials: 'AAA',
113+
score: 500
114+
};
115+
116+
// new player hasn't gotten the high score yet
117+
await engine.run(facts);
118+
119+
console.log('\n');
120+
121+
facts = {
122+
initials: 'AAA',
123+
score: 1000
124+
};
125+
126+
// second run, with a high score
127+
await engine.run(facts);
128+
}
129+
130+
start();
131+
132+
/*
133+
* OUTPUT:
134+
*
135+
* NEW SCORE:
136+
* DOG - 968
137+
*
138+
* HIGH SCORE:
139+
* DOG - 968
140+
*
141+
* HIGH SCORE:
142+
* AAA - 1000
143+
*/

src/almanac.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,14 @@ export default class Almanac {
162162

163163
return factValuePromise
164164
}
165+
166+
/**
167+
* Interprets .value as either a primitive, or if a fact, retrieves the fact value
168+
*/
169+
getValue (value) {
170+
if (isObjectLike(value) && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value: { fact: 'xyz' }
171+
return this.factValue(value.fact, value.params, value.path)
172+
}
173+
return Promise.resolve(value)
174+
}
165175
}

src/condition.js

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ export default class Condition {
1111
if (booleanOperator) {
1212
const subConditions = properties[booleanOperator]
1313
const subConditionsIsArray = Array.isArray(subConditions)
14-
if (booleanOperator !== 'not' && !subConditionsIsArray) throw new Error(`"${booleanOperator}" must be an array`)
15-
if (booleanOperator === 'not' && subConditionsIsArray) throw new Error(`"${booleanOperator}" cannot be an array`)
14+
if (booleanOperator !== 'not' && !subConditionsIsArray) { throw new Error(`"${booleanOperator}" must be an array`) }
15+
if (booleanOperator === 'not' && subConditionsIsArray) { throw new Error(`"${booleanOperator}" cannot be an array`) }
1616
this.operator = booleanOperator
1717
// boolean conditions always have a priority; default 1
1818
this.priority = parseInt(properties.priority, 10) || 1
@@ -22,9 +22,9 @@ export default class Condition {
2222
this[booleanOperator] = new Condition(subConditions)
2323
}
2424
} else if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) {
25-
if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) throw new Error('Condition: constructor "fact" property required')
26-
if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) throw new Error('Condition: constructor "operator" property required')
27-
if (!Object.prototype.hasOwnProperty.call(properties, 'value')) throw new Error('Condition: constructor "value" property required')
25+
if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) { throw new Error('Condition: constructor "fact" property required') }
26+
if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) { throw new Error('Condition: constructor "operator" property required') }
27+
if (!Object.prototype.hasOwnProperty.call(properties, 'value')) { throw new Error('Condition: constructor "value" property required') }
2828

2929
// a non-boolean condition does not have a priority by default. this allows
3030
// priority to be dictated by the fact definition
@@ -84,7 +84,11 @@ export default class Condition {
8484
*/
8585
_getValue (almanac) {
8686
const value = this.value
87-
if (isObjectLike(value) && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value: { fact: 'xyz' }
87+
if (
88+
isObjectLike(value) &&
89+
Object.prototype.hasOwnProperty.call(value, 'fact')
90+
) {
91+
// value: { fact: 'xyz' }
8892
return almanac.factValue(value.fact, value.params, value.path)
8993
}
9094
return Promise.resolve(value)
@@ -102,20 +106,28 @@ export default class Condition {
102106
evaluate (almanac, operatorMap) {
103107
if (!almanac) return Promise.reject(new Error('almanac required'))
104108
if (!operatorMap) return Promise.reject(new Error('operatorMap required'))
105-
if (this.isBooleanOperator()) return Promise.reject(new Error('Cannot evaluate() a boolean condition'))
109+
if (this.isBooleanOperator()) { return Promise.reject(new Error('Cannot evaluate() a boolean condition')) }
106110

107111
const op = operatorMap.get(this.operator)
108-
if (!op) return Promise.reject(new Error(`Unknown operator: ${this.operator}`))
112+
if (!op) { return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) }
109113

110-
return this._getValue(almanac) // todo - parallelize
111-
.then(rightHandSideValue => {
112-
return almanac.factValue(this.fact, this.params, this.path)
113-
.then(leftHandSideValue => {
114-
const result = op.evaluate(leftHandSideValue, rightHandSideValue)
115-
debug(`condition::evaluate <${JSON.stringify(leftHandSideValue)} ${this.operator} ${JSON.stringify(rightHandSideValue)}?> (${result})`)
116-
return { result, leftHandSideValue, rightHandSideValue, operator: this.operator }
117-
})
118-
})
114+
return Promise.all([
115+
almanac.getValue(this.value),
116+
almanac.factValue(this.fact, this.params, this.path)
117+
]).then(([rightHandSideValue, leftHandSideValue]) => {
118+
const result = op.evaluate(leftHandSideValue, rightHandSideValue)
119+
debug(
120+
`condition::evaluate <${JSON.stringify(leftHandSideValue)} ${
121+
this.operator
122+
} ${JSON.stringify(rightHandSideValue)}?> (${result})`
123+
)
124+
return {
125+
result,
126+
leftHandSideValue,
127+
rightHandSideValue,
128+
operator: this.operator
129+
}
130+
})
119131
}
120132

121133
/**

src/engine.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class Engine extends EventEmitter {
2323
this.rules = []
2424
this.allowUndefinedFacts = options.allowUndefinedFacts || false
2525
this.allowUndefinedConditions = options.allowUndefinedConditions || false
26+
this.allowFactsInEventParams = options.allowFactsInEventParams || false
2627
this.pathResolver = options.pathResolver
2728
this.operators = new Map()
2829
this.facts = new Map()

src/rule-result.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
import deepClone from 'clone'
4+
import { isObject } from 'lodash'
45

56
export default class RuleResult {
67
constructor (conditions, event, priority, name) {
@@ -15,6 +16,23 @@ export default class RuleResult {
1516
this.result = result
1617
}
1718

19+
resolveEventParams (almanac) {
20+
if (isObject(this.event.params)) {
21+
const updates = []
22+
for (const key in this.event.params) {
23+
if (Object.prototype.hasOwnProperty.call(this.event.params, key)) {
24+
updates.push(
25+
almanac
26+
.getValue(this.event.params[key])
27+
.then((val) => (this.event.params[key] = val))
28+
)
29+
}
30+
}
31+
return Promise.all(updates)
32+
}
33+
return Promise.resolve()
34+
}
35+
1836
toJSON (stringify = true) {
1937
const props = {
2038
conditions: this.conditions.toJSON(false),

src/rule.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -367,8 +367,12 @@ class Rule extends EventEmitter {
367367
*/
368368
const processResult = (result) => {
369369
ruleResult.setResult(result)
370+
let processEvent = Promise.resolve()
371+
if (this.engine.allowFactsInEventParams) {
372+
processEvent = ruleResult.resolveEventParams(almanac)
373+
}
370374
const event = result ? 'success' : 'failure'
371-
return this.emitAsync(event, ruleResult.event, almanac, ruleResult).then(
375+
return processEvent.then(() => this.emitAsync(event, ruleResult.event, almanac, ruleResult)).then(
372376
() => ruleResult
373377
)
374378
}

0 commit comments

Comments
 (0)