Skip to content

Commit e7263f4

Browse files
author
chris-pardy
authored
Merge pull request #345 from RateGravity/facts-in-events
Support Facts In Events
2 parents 4f622e3 + 9a2beac commit e7263f4

10 files changed

+436
-31
lines changed

Diff for: docs/engine.md

+2
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+
`replaceFactsInEventParams` - 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])

Diff for: docs/rules.md

+23
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,29 @@ engine.on('failure', function(event, almanac, ruleResult) {
349349
})
350350
```
351351
352+
### Referencing Facts In Events
353+
354+
With the engine option [`replaceFactsInEventParams`](./engine.md#options) the parameters of the event may include references to facts in the same form as [Comparing Facts](#comparing-facts). These references will be replaced with the value of the fact before the event is emitted.
355+
356+
```js
357+
const engine = new Engine([], { replaceFactsInEventParams: true });
358+
engine.addRule({
359+
conditions: { /* ... */ },
360+
event: {
361+
type: "gameover",
362+
params: {
363+
initials: {
364+
fact: "currentHighScore",
365+
path: "$.initials",
366+
params: { foo: 'bar' }
367+
}
368+
}
369+
}
370+
})
371+
```
372+
373+
See [11-using-facts-in-events.js](../examples/11-using-facts-in-events.js) for a complete example.
374+
352375
## Operators
353376
354377
Each rule condition must begin with a boolean operator(```all```, ```any```, or ```not```) at its root.

Diff for: examples/11-using-facts-in-events.js

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
'use strict'
2+
/*
3+
* This is an advanced example demonstrating an event that emits the value
4+
* of a fact in it's parameters.
5+
*
6+
* Usage:
7+
* node ./examples/11-using-facts-in-events.js
8+
*
9+
* For detailed output:
10+
* DEBUG=json-rules-engine node ./examples/11-using-facts-in-events.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([], { replaceFactsInEventParams: 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 your score and initials.
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+
57+
/**
58+
* Rule for when the game is over and you don't have the high score
59+
* event will include the previous high score
60+
*/
61+
const gameOverRule = {
62+
conditions: {
63+
all: [
64+
{
65+
fact: 'score',
66+
operator: 'lessThanInclusive',
67+
value: {
68+
fact: 'currentHighScore',
69+
path: '$.score'
70+
}
71+
}
72+
]
73+
},
74+
event: {
75+
type: 'gameover',
76+
params: {
77+
initials: {
78+
fact: 'currentHighScore',
79+
path: '$.initials'
80+
},
81+
score: {
82+
fact: 'currentHighScore',
83+
path: '$.score'
84+
}
85+
}
86+
}
87+
}
88+
engine.addRule(highScoreRule)
89+
engine.addRule(gameOverRule)
90+
engine.addFact(currentHighScoreFact)
91+
92+
/**
93+
* Register listeners with the engine for rule success
94+
*/
95+
engine
96+
.on('success', async ({ params: { initials, score } }) => {
97+
console.log(`HIGH SCORE\n${initials} - ${score}`)
98+
})
99+
.on('success', ({ type, params }) => {
100+
if (type === 'highscore') {
101+
currentHighScore = params
102+
}
103+
})
104+
105+
let facts = {
106+
initials: 'DOG',
107+
score: 968
108+
}
109+
110+
// first run, without a high score
111+
await engine.run(facts)
112+
113+
console.log('\n')
114+
115+
// new player
116+
facts = {
117+
initials: 'AAA',
118+
score: 500
119+
}
120+
121+
// new player hasn't gotten the high score yet
122+
await engine.run(facts)
123+
124+
console.log('\n')
125+
126+
facts = {
127+
initials: 'AAA',
128+
score: 1000
129+
}
130+
131+
// second run, with a high score
132+
await engine.run(facts)
133+
}
134+
135+
start()
136+
137+
/*
138+
* OUTPUT:
139+
*
140+
* NEW SCORE:
141+
* DOG - 968
142+
*
143+
* HIGH SCORE:
144+
* DOG - 968
145+
*
146+
* HIGH SCORE:
147+
* AAA - 1000
148+
*/

Diff for: package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "json-rules-engine",
3-
"version": "6.3.1",
3+
"version": "6.4.0",
44
"description": "Rules Engine expressed in simple json",
55
"main": "dist/index.js",
66
"types": "types/index.d.ts",

Diff for: src/almanac.js

+10
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
}

Diff for: src/condition.js

+24-28
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use strict'
22

33
import debug from './debug'
4-
import isObjectLike from 'lodash.isobjectlike'
54

65
export default class Condition {
76
constructor (properties) {
@@ -11,8 +10,8 @@ export default class Condition {
1110
if (booleanOperator) {
1211
const subConditions = properties[booleanOperator]
1312
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`)
13+
if (booleanOperator !== 'not' && !subConditionsIsArray) { throw new Error(`"${booleanOperator}" must be an array`) }
14+
if (booleanOperator === 'not' && subConditionsIsArray) { throw new Error(`"${booleanOperator}" cannot be an array`) }
1615
this.operator = booleanOperator
1716
// boolean conditions always have a priority; default 1
1817
this.priority = parseInt(properties.priority, 10) || 1
@@ -22,9 +21,9 @@ export default class Condition {
2221
this[booleanOperator] = new Condition(subConditions)
2322
}
2423
} 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')
24+
if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) { throw new Error('Condition: constructor "fact" property required') }
25+
if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) { throw new Error('Condition: constructor "operator" property required') }
26+
if (!Object.prototype.hasOwnProperty.call(properties, 'value')) { throw new Error('Condition: constructor "value" property required') }
2827

2928
// a non-boolean condition does not have a priority by default. this allows
3029
// priority to be dictated by the fact definition
@@ -79,17 +78,6 @@ export default class Condition {
7978
return props
8079
}
8180

82-
/**
83-
* Interprets .value as either a primitive, or if a fact, retrieves the fact value
84-
*/
85-
_getValue (almanac) {
86-
const value = this.value
87-
if (isObjectLike(value) && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value: { fact: 'xyz' }
88-
return almanac.factValue(value.fact, value.params, value.path)
89-
}
90-
return Promise.resolve(value)
91-
}
92-
9381
/**
9482
* Takes the fact result and compares it to the condition 'value', using the operator
9583
* LHS OPER RHS
@@ -102,20 +90,28 @@ export default class Condition {
10290
evaluate (almanac, operatorMap) {
10391
if (!almanac) return Promise.reject(new Error('almanac required'))
10492
if (!operatorMap) return Promise.reject(new Error('operatorMap required'))
105-
if (this.isBooleanOperator()) return Promise.reject(new Error('Cannot evaluate() a boolean condition'))
93+
if (this.isBooleanOperator()) { return Promise.reject(new Error('Cannot evaluate() a boolean condition')) }
10694

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

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-
})
98+
return Promise.all([
99+
almanac.getValue(this.value),
100+
almanac.factValue(this.fact, this.params, this.path)
101+
]).then(([rightHandSideValue, leftHandSideValue]) => {
102+
const result = op.evaluate(leftHandSideValue, rightHandSideValue)
103+
debug(
104+
`condition::evaluate <${JSON.stringify(leftHandSideValue)} ${
105+
this.operator
106+
} ${JSON.stringify(rightHandSideValue)}?> (${result})`
107+
)
108+
return {
109+
result,
110+
leftHandSideValue,
111+
rightHandSideValue,
112+
operator: this.operator
113+
}
114+
})
119115
}
120116

121117
/**

Diff for: src/engine.js

+1
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.replaceFactsInEventParams = options.replaceFactsInEventParams || false
2627
this.pathResolver = options.pathResolver
2728
this.operators = new Map()
2829
this.facts = new Map()

Diff for: src/rule-result.js

+18
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),

Diff for: src/rule.js

+5-1
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.replaceFactsInEventParams) {
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)