Skip to content

Commit

Permalink
Merge pull request #345 from RateGravity/facts-in-events
Browse files Browse the repository at this point in the history
Support Facts In Events
  • Loading branch information
chris-pardy authored Aug 23, 2023
2 parents 4f622e3 + 9a2beac commit e7263f4
Show file tree
Hide file tree
Showing 10 changed files with 436 additions and 31 deletions.
2 changes: 2 additions & 0 deletions docs/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ condition reference that cannot be resolved an exception is thrown. Turning
this option on will cause the engine to treat unresolvable condition references
as failed conditions. (default: false)

`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)

`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.

### engine.addFact(String id, Function [definitionFunc], Object [options])
Expand Down
23 changes: 23 additions & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,29 @@ engine.on('failure', function(event, almanac, ruleResult) {
})
```
### Referencing Facts In Events
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.
```js
const engine = new Engine([], { replaceFactsInEventParams: true });
engine.addRule({
conditions: { /* ... */ },
event: {
type: "gameover",
params: {
initials: {
fact: "currentHighScore",
path: "$.initials",
params: { foo: 'bar' }
}
}
}
})
```
See [11-using-facts-in-events.js](../examples/11-using-facts-in-events.js) for a complete example.
## Operators
Each rule condition must begin with a boolean operator(```all```, ```any```, or ```not```) at its root.
Expand Down
148 changes: 148 additions & 0 deletions examples/11-using-facts-in-events.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
'use strict'
/*
* This is an advanced example demonstrating an event that emits the value
* of a fact in it's parameters.
*
* Usage:
* node ./examples/11-using-facts-in-events.js
*
* For detailed output:
* DEBUG=json-rules-engine node ./examples/11-using-facts-in-events.js
*/

require('colors')
const { Engine, Fact } = require('json-rules-engine')

async function start () {
/**
* Setup a new engine
*/
const engine = new Engine([], { replaceFactsInEventParams: true })

// in-memory "database"
let currentHighScore = null
const currentHighScoreFact = new Fact('currentHighScore', () => currentHighScore)

/**
* Rule for when you've gotten the high score
* event will include your score and initials.
*/
const highScoreRule = {
conditions: {
any: [
{
fact: 'currentHighScore',
operator: 'equal',
value: null
},
{
fact: 'score',
operator: 'greaterThan',
value: {
fact: 'currentHighScore',
path: '$.score'
}
}
]
},
event: {
type: 'highscore',
params: {
initials: { fact: 'initials' },
score: { fact: 'score' }
}
}
}

/**
* Rule for when the game is over and you don't have the high score
* event will include the previous high score
*/
const gameOverRule = {
conditions: {
all: [
{
fact: 'score',
operator: 'lessThanInclusive',
value: {
fact: 'currentHighScore',
path: '$.score'
}
}
]
},
event: {
type: 'gameover',
params: {
initials: {
fact: 'currentHighScore',
path: '$.initials'
},
score: {
fact: 'currentHighScore',
path: '$.score'
}
}
}
}
engine.addRule(highScoreRule)
engine.addRule(gameOverRule)
engine.addFact(currentHighScoreFact)

/**
* Register listeners with the engine for rule success
*/
engine
.on('success', async ({ params: { initials, score } }) => {
console.log(`HIGH SCORE\n${initials} - ${score}`)
})
.on('success', ({ type, params }) => {
if (type === 'highscore') {
currentHighScore = params
}
})

let facts = {
initials: 'DOG',
score: 968
}

// first run, without a high score
await engine.run(facts)

console.log('\n')

// new player
facts = {
initials: 'AAA',
score: 500
}

// new player hasn't gotten the high score yet
await engine.run(facts)

console.log('\n')

facts = {
initials: 'AAA',
score: 1000
}

// second run, with a high score
await engine.run(facts)
}

start()

/*
* OUTPUT:
*
* NEW SCORE:
* DOG - 968
*
* HIGH SCORE:
* DOG - 968
*
* HIGH SCORE:
* AAA - 1000
*/
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "json-rules-engine",
"version": "6.3.1",
"version": "6.4.0",
"description": "Rules Engine expressed in simple json",
"main": "dist/index.js",
"types": "types/index.d.ts",
Expand Down
10 changes: 10 additions & 0 deletions src/almanac.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,4 +162,14 @@ export default class Almanac {

return factValuePromise
}

/**
* Interprets value as either a primitive, or if a fact, retrieves the fact value
*/
getValue (value) {
if (isObjectLike(value) && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value = { fact: 'xyz' }
return this.factValue(value.fact, value.params, value.path)
}
return Promise.resolve(value)
}
}
52 changes: 24 additions & 28 deletions src/condition.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use strict'

import debug from './debug'
import isObjectLike from 'lodash.isobjectlike'

export default class Condition {
constructor (properties) {
Expand All @@ -11,8 +10,8 @@ export default class Condition {
if (booleanOperator) {
const subConditions = properties[booleanOperator]
const subConditionsIsArray = Array.isArray(subConditions)
if (booleanOperator !== 'not' && !subConditionsIsArray) throw new Error(`"${booleanOperator}" must be an array`)
if (booleanOperator === 'not' && subConditionsIsArray) throw new Error(`"${booleanOperator}" cannot be an array`)
if (booleanOperator !== 'not' && !subConditionsIsArray) { throw new Error(`"${booleanOperator}" must be an array`) }
if (booleanOperator === 'not' && subConditionsIsArray) { throw new Error(`"${booleanOperator}" cannot be an array`) }
this.operator = booleanOperator
// boolean conditions always have a priority; default 1
this.priority = parseInt(properties.priority, 10) || 1
Expand All @@ -22,9 +21,9 @@ export default class Condition {
this[booleanOperator] = new Condition(subConditions)
}
} else if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) {
if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) throw new Error('Condition: constructor "fact" property required')
if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) throw new Error('Condition: constructor "operator" property required')
if (!Object.prototype.hasOwnProperty.call(properties, 'value')) throw new Error('Condition: constructor "value" property required')
if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) { throw new Error('Condition: constructor "fact" property required') }
if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) { throw new Error('Condition: constructor "operator" property required') }
if (!Object.prototype.hasOwnProperty.call(properties, 'value')) { throw new Error('Condition: constructor "value" property required') }

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

/**
* Interprets .value as either a primitive, or if a fact, retrieves the fact value
*/
_getValue (almanac) {
const value = this.value
if (isObjectLike(value) && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value: { fact: 'xyz' }
return almanac.factValue(value.fact, value.params, value.path)
}
return Promise.resolve(value)
}

/**
* Takes the fact result and compares it to the condition 'value', using the operator
* LHS OPER RHS
Expand All @@ -102,20 +90,28 @@ export default class Condition {
evaluate (almanac, operatorMap) {
if (!almanac) return Promise.reject(new Error('almanac required'))
if (!operatorMap) return Promise.reject(new Error('operatorMap required'))
if (this.isBooleanOperator()) return Promise.reject(new Error('Cannot evaluate() a boolean condition'))
if (this.isBooleanOperator()) { return Promise.reject(new Error('Cannot evaluate() a boolean condition')) }

const op = operatorMap.get(this.operator)
if (!op) return Promise.reject(new Error(`Unknown operator: ${this.operator}`))
if (!op) { return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) }

return this._getValue(almanac) // todo - parallelize
.then(rightHandSideValue => {
return almanac.factValue(this.fact, this.params, this.path)
.then(leftHandSideValue => {
const result = op.evaluate(leftHandSideValue, rightHandSideValue)
debug(`condition::evaluate <${JSON.stringify(leftHandSideValue)} ${this.operator} ${JSON.stringify(rightHandSideValue)}?> (${result})`)
return { result, leftHandSideValue, rightHandSideValue, operator: this.operator }
})
})
return Promise.all([
almanac.getValue(this.value),
almanac.factValue(this.fact, this.params, this.path)
]).then(([rightHandSideValue, leftHandSideValue]) => {
const result = op.evaluate(leftHandSideValue, rightHandSideValue)
debug(
`condition::evaluate <${JSON.stringify(leftHandSideValue)} ${
this.operator
} ${JSON.stringify(rightHandSideValue)}?> (${result})`
)
return {
result,
leftHandSideValue,
rightHandSideValue,
operator: this.operator
}
})
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class Engine extends EventEmitter {
this.rules = []
this.allowUndefinedFacts = options.allowUndefinedFacts || false
this.allowUndefinedConditions = options.allowUndefinedConditions || false
this.replaceFactsInEventParams = options.replaceFactsInEventParams || false
this.pathResolver = options.pathResolver
this.operators = new Map()
this.facts = new Map()
Expand Down
18 changes: 18 additions & 0 deletions src/rule-result.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict'

import deepClone from 'clone'
import { isObject } from 'lodash'

export default class RuleResult {
constructor (conditions, event, priority, name) {
Expand All @@ -15,6 +16,23 @@ export default class RuleResult {
this.result = result
}

resolveEventParams (almanac) {
if (isObject(this.event.params)) {
const updates = []
for (const key in this.event.params) {
if (Object.prototype.hasOwnProperty.call(this.event.params, key)) {
updates.push(
almanac
.getValue(this.event.params[key])
.then((val) => (this.event.params[key] = val))
)
}
}
return Promise.all(updates)
}
return Promise.resolve()
}

toJSON (stringify = true) {
const props = {
conditions: this.conditions.toJSON(false),
Expand Down
6 changes: 5 additions & 1 deletion src/rule.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,8 +367,12 @@ class Rule extends EventEmitter {
*/
const processResult = (result) => {
ruleResult.setResult(result)
let processEvent = Promise.resolve()
if (this.engine.replaceFactsInEventParams) {
processEvent = ruleResult.resolveEventParams(almanac)
}
const event = result ? 'success' : 'failure'
return this.emitAsync(event, ruleResult.event, almanac, ruleResult).then(
return processEvent.then(() => this.emitAsync(event, ruleResult.event, almanac, ruleResult)).then(
() => ruleResult
)
}
Expand Down
Loading

0 comments on commit e7263f4

Please sign in to comment.