Skip to content

Commit e500c1d

Browse files
authored
Merge pull request #339 from RateGravity/shared-conditions
Condition Sharing
2 parents 351ffc5 + 371243e commit e500c1d

10 files changed

+608
-17
lines changed

docs/engine.md

+72
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ The Engine stores and executes rules, emits events, and maintains state.
1212
* [engine.removeRule(Rule instance | String ruleId)](#engineremoverulerule-instance)
1313
* [engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))](#engineaddoperatorstring-operatorname-function-evaluatefuncfactvalue-jsonvalue)
1414
* [engine.removeOperator(String operatorName)](#engineremoveoperatorstring-operatorname)
15+
* [engine.setCondition(String name, Object conditions)](#enginesetconditionstring-name-object-conditions)
16+
* [engine.removeCondition(String name)](#engineremovecondtionstring-name)
1517
* [engine.run([Object facts], [Object options]) -> Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})](#enginerunobject-facts-object-options---promise--events--failureevents--almanac-almanac-results--failureresults-)
1618
* [engine.stop() -> Engine](#enginestop---engine)
1719
* [engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult)
@@ -43,6 +45,11 @@ let engine = new Engine([Array rules], options)
4345
an exception is thrown. Turning this option on will cause the engine to treat
4446
undefined facts as `undefined`. (default: false)
4547

48+
`allowUndefinedConditions` - By default, when a running engine encounters a
49+
condition reference that cannot be resolved an exception is thrown. Turning
50+
this option on will cause the engine to treat unresolvable condition references
51+
as failed conditions. (default: false)
52+
4653
`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.
4754

4855
### engine.addFact(String id, Function [definitionFunc], Object [options])
@@ -172,6 +179,71 @@ engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
172179
engine.removeOperator('startsWithLetter');
173180
```
174181

182+
### engine.setCondition(String name, Object conditions)
183+
184+
Adds or updates a condition to the engine. Rules may include references to this condition. Conditions must start with `all`, `any`, `not`, or reference a condition.
185+
186+
```javascript
187+
engine.setCondition('validLogin', {
188+
all: [
189+
{
190+
operator: 'notEqual',
191+
fact: 'loginToken',
192+
value: null
193+
},
194+
{
195+
operator: 'greaterThan',
196+
fact: 'loginToken',
197+
path: '$.expirationTime',
198+
value: { fact: 'now' }
199+
}
200+
]
201+
});
202+
203+
engine.addRule({
204+
condtions: {
205+
all: [
206+
{
207+
condition: 'validLogin'
208+
},
209+
{
210+
operator: 'contains',
211+
fact: 'loginToken',
212+
path: '$.role',
213+
value: 'admin'
214+
}
215+
]
216+
},
217+
event: {
218+
type: 'AdminAccessAllowed'
219+
}
220+
})
221+
222+
```
223+
224+
### engine.removeCondition(String name)
225+
226+
Removes the condition that was previously added.
227+
228+
```javascript
229+
engine.setCondition('validLogin', {
230+
all: [
231+
{
232+
operator: 'notEqual',
233+
fact: 'loginToken',
234+
value: null
235+
},
236+
{
237+
operator: 'greaterThan',
238+
fact: 'loginToken',
239+
path: '$.expirationTime',
240+
value: { fact: 'now' }
241+
}
242+
]
243+
});
244+
245+
engine.removeCondition('validLogin');
246+
```
175247

176248

177249
### engine.run([Object facts], [Object options]) -> Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})

docs/rules.md

+27-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ Rules contain a set of _conditions_ and a single _event_. When the engine is ru
1414
* [toJSON(Boolean stringify = true)](#tojsonboolean-stringify--true)
1515
* [Conditions](#conditions)
1616
* [Basic conditions](#basic-conditions)
17-
* [Boolean expressions: all and any](#boolean-expressions-all-and-any)
17+
* [Boolean expressions: all, any, and not](#boolean-expressions-all-any-and-not)
18+
* [Condition Reference](#condition-reference)
1819
* [Condition helpers: params](#condition-helpers-params)
1920
* [Condition helpers: path](#condition-helpers-path)
2021
* [Condition helpers: custom path resolver](#condition-helpers-custom-path-resolver)
@@ -136,7 +137,7 @@ See the [hello-world](../examples/01-hello-world.js) example.
136137

137138
### Boolean expressions: `all`, `any`, and `not`
138139

139-
Each rule's conditions *must* have an `all` or `any` operator containing an array of conditions at its root or a `not` operator containing a single condition. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains.
140+
Each rule's conditions *must* have an `all` or `any` operator containing an array of conditions at its root, a `not` operator containing a single condition, or a condition reference. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains.
140141

141142
```js
142143
// all:
@@ -174,7 +175,30 @@ let rule = new Rule({
174175
})
175176
```
176177

177-
Notice in the second example how `all`, `any`, and 'not' can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example.
178+
Notice in the second example how `all`, `any`, and `not` can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example.
179+
180+
### Condition Reference
181+
182+
Rules may reference conditions based on their name.
183+
184+
```js
185+
let rule = new Rule({
186+
conditions: {
187+
all: [
188+
{ condition: 'conditionName' },
189+
{ /* additional condition */ }
190+
]
191+
}
192+
})
193+
```
194+
195+
Before running the rule the condition should be added to the engine.
196+
197+
```js
198+
engine.setCondition('conditionName', { /* conditions */ });
199+
```
200+
201+
Conditions must start with `all`, `any`, `not`, or reference a condition.
178202

179203
### Condition helpers: `params`
180204

examples/10-condition-sharing.js

+139
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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 } = require('json-rules-engine')
15+
16+
async function start () {
17+
/**
18+
* Setup a new engine
19+
*/
20+
const engine = new Engine()
21+
22+
/**
23+
* Condition that will be used to determine if a user likes screwdrivers
24+
*/
25+
engine.setCondition('screwdriverAficionado', {
26+
all: [
27+
{
28+
fact: 'drinksOrangeJuice',
29+
operator: 'equal',
30+
value: true
31+
},
32+
{
33+
fact: 'enjoysVodka',
34+
operator: 'equal',
35+
value: true
36+
}
37+
]
38+
})
39+
40+
/**
41+
* Rule for identifying people who should be invited to a screwdriver social
42+
* - Only invite people who enjoy screw drivers
43+
* - Only invite people who are sociable
44+
*/
45+
const inviteRule = {
46+
conditions: {
47+
all: [
48+
{
49+
condition: 'screwdriverAficionado'
50+
},
51+
{
52+
fact: 'isSociable',
53+
operator: 'equal',
54+
value: true
55+
}
56+
]
57+
},
58+
event: { type: 'invite-to-screwdriver-social' }
59+
}
60+
engine.addRule(inviteRule)
61+
62+
/**
63+
* Rule for identifying people who should be invited to the other social
64+
* - Only invite people who don't enjoy screw drivers
65+
* - Only invite people who are sociable
66+
*/
67+
const otherInviteRule = {
68+
conditions: {
69+
all: [
70+
{
71+
not: {
72+
condition: 'screwdriverAficionado'
73+
}
74+
},
75+
{
76+
fact: 'isSociable',
77+
operator: 'equal',
78+
value: true
79+
}
80+
]
81+
},
82+
event: { type: 'invite-to-other-social' }
83+
}
84+
engine.addRule(otherInviteRule)
85+
86+
/**
87+
* Register listeners with the engine for rule success and failure
88+
*/
89+
engine
90+
.on('success', async (event, almanac) => {
91+
const accountId = await almanac.factValue('accountId')
92+
console.log(
93+
`${accountId}` +
94+
'DID'.green +
95+
` meet conditions for the ${event.type.underline} rule.`
96+
)
97+
})
98+
.on('failure', async (event, almanac) => {
99+
const accountId = await almanac.factValue('accountId')
100+
console.log(
101+
`${accountId} did ` +
102+
'NOT'.red +
103+
` meet conditions for the ${event.type.underline} rule.`
104+
)
105+
})
106+
107+
// define fact(s) known at runtime
108+
let facts = {
109+
accountId: 'washington',
110+
drinksOrangeJuice: true,
111+
enjoysVodka: true,
112+
isSociable: true
113+
}
114+
115+
// first run, using washington's facts
116+
await engine.run(facts)
117+
118+
facts = {
119+
accountId: 'jefferson',
120+
drinksOrangeJuice: true,
121+
enjoysVodka: false,
122+
isSociable: true,
123+
accountInfo: {}
124+
}
125+
126+
// second run, using jefferson's facts; facts & evaluation are independent of the first run
127+
await engine.run(facts)
128+
}
129+
130+
start()
131+
132+
/*
133+
* OUTPUT:
134+
*
135+
* washington DID meet conditions for the invite-to-screwdriver-social rule.
136+
* washington did NOT meet conditions for the invite-to-other-social rule.
137+
* jefferson did NOT meet conditions for the invite-to-screwdriver-social rule.
138+
* jefferson DID meet conditions for the invite-to-other-social rule.
139+
*/

examples/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
"author": "Cache Hamm <[email protected]>",
1111
"license": "ISC",
1212
"dependencies": {
13-
"json-rules-engine": "6.0.0-alpha-3"
13+
"json-rules-engine": "../"
1414
}
1515
}

src/condition.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export default class Condition {
2121
} else {
2222
this[booleanOperator] = new Condition(subConditions)
2323
}
24-
} else {
24+
} else if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) {
2525
if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) throw new Error('Condition: constructor "fact" property required')
2626
if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) throw new Error('Condition: constructor "operator" property required')
2727
if (!Object.prototype.hasOwnProperty.call(properties, 'value')) throw new Error('Condition: constructor "value" property required')
@@ -54,6 +54,8 @@ export default class Condition {
5454
} else {
5555
props[oper] = this[oper].toJSON(false)
5656
}
57+
} else if (this.isConditionReference()) {
58+
props.condition = this.condition
5759
} else {
5860
props.operator = this.operator
5961
props.value = this.value
@@ -147,4 +149,12 @@ export default class Condition {
147149
isBooleanOperator () {
148150
return Condition.booleanOperator(this) !== undefined
149151
}
152+
153+
/**
154+
* Whether the condition represents a reference to a condition
155+
* @returns {Boolean}
156+
*/
157+
isConditionReference () {
158+
return Object.prototype.hasOwnProperty.call(this, 'condition')
159+
}
150160
}

src/engine.js

+28
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Almanac from './almanac'
77
import EventEmitter from 'eventemitter2'
88
import defaultOperators from './engine-default-operators'
99
import debug from './debug'
10+
import Condition from './condition'
1011

1112
export const READY = 'READY'
1213
export const RUNNING = 'RUNNING'
@@ -21,9 +22,11 @@ class Engine extends EventEmitter {
2122
super()
2223
this.rules = []
2324
this.allowUndefinedFacts = options.allowUndefinedFacts || false
25+
this.allowUndefinedConditions = options.allowUndefinedConditions || false
2426
this.pathResolver = options.pathResolver
2527
this.operators = new Map()
2628
this.facts = new Map()
29+
this.conditions = new Map()
2730
this.status = READY
2831
rules.map(r => this.addRule(r))
2932
defaultOperators.map(o => this.addOperator(o))
@@ -92,6 +95,31 @@ class Engine extends EventEmitter {
9295
return ruleRemoved
9396
}
9497

98+
/**
99+
* sets a condition that can be referenced by the given name.
100+
* If a condition with the given name has already been set this will replace it.
101+
* @param {string} name - the name of the condition to be referenced by rules.
102+
* @param {object} conditions - the conditions to use when the condition is referenced.
103+
*/
104+
setCondition (name, conditions) {
105+
if (!name) throw new Error('Engine: setCondition() requires name')
106+
if (!conditions) throw new Error('Engine: setCondition() requires conditions')
107+
if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'condition')) {
108+
throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"')
109+
}
110+
this.conditions.set(name, new Condition(conditions))
111+
return this
112+
}
113+
114+
/**
115+
* Removes a condition that has previously been added to this engine
116+
* @param {string} name - the name of the condition to remove.
117+
* @returns true if the condition existed, otherwise false
118+
*/
119+
removeCondition (name) {
120+
return this.conditions.delete(name)
121+
}
122+
95123
/**
96124
* Add a custom operator definition
97125
* @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc

0 commit comments

Comments
 (0)