Skip to content

Commit 7def45a

Browse files
author
Cache Hamm
authored
Merge pull request #24 from CacheControl/allow-undefined-facts
Allow undefined facts
2 parents a6a8130 + 9465ad1 commit 7def45a

10 files changed

Lines changed: 118 additions & 35 deletions

docs/engine.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ The Engine stores and executes rules, emits events, and maintains state.
44

55
## Methods
66

7-
### constructor([Array rules])
7+
### constructor([Array rules], Object [options])
88

99
```js
1010
let Engine = require('json-rules-engine').Engine
@@ -13,8 +13,20 @@ let engine = new Engine()
1313

1414
// initialize with rules
1515
let engine = new Engine([Array rules])
16+
17+
// initialize with options
18+
let options = {
19+
allowUndefinedFacts: false
20+
};
21+
let engine = new Engine([Array rules], options)
1622
```
1723

24+
#### Options
25+
26+
`allowUndefinedFacts` - By default, when a running engine encounters an undefined fact,
27+
an exception is thrown. Turning this option on will cause the engine to treat
28+
undefined facts as falsey conditions. (default: false)
29+
1830
### engine.addFact(String id, Function [definitionFunc], Object [options])
1931

2032
```js

docs/walkthrough.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ More on rules can be found [here](./docs/rules.md)
6161

6262
### Step 3: Define Facts
6363

64-
Facts are constant values or pure functions. Using the current example, if the engine were to be run, it would throw an error: "Undefined fact: 'age'". So let's define some facts!
64+
Facts are constant values or pure functions. Using the current example, if the engine were to be run, it would throw an exception: `Undefined fact:'age'` (note: this behavior can be disable via [engine options](./engine.md#Options)).
65+
66+
Let's define some facts:
6567

6668
```js
6769

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
"/dist"
2525
],
2626
"globals": [
27+
"context",
28+
"xcontext",
2729
"describe",
2830
"xdescribe",
2931
"it",

src/almanac.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ let debug = require('debug')('json-rules-engine')
44
let verbose = require('debug')('json-rules-engine-verbose')
55

66
import Fact from './fact'
7+
import { UndefinedFactError } from './errors'
78

89
/**
910
* Fact results lookup
@@ -36,7 +37,7 @@ export default class Almanac {
3637
_getFact (factId) {
3738
let fact = this.factMap.get(factId)
3839
if (fact === undefined) {
39-
throw new Error(`Undefined fact: ${factId}`)
40+
throw new UndefinedFactError(`Undefined fact: ${factId}`)
4041
}
4142
return fact
4243
}

src/engine.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ class Engine extends EventEmitter {
2020
* Returns a new Engine instance
2121
* @param {Rule[]} rules - array of rules to initialize with
2222
*/
23-
constructor (rules = []) {
23+
constructor (rules = [], options = {}) {
2424
super()
2525
this.rules = []
26+
this.allowUndefinedFacts = options.allowUndefinedFacts || false
2627
this.operators = new Map()
2728
this.facts = new Map()
2829
this.status = READY

src/errors.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use strict'
2+
3+
export class UndefinedFactError extends Error {
4+
constructor (...props) {
5+
super(...props)
6+
this.code = 'UNDEFINED_FACT'
7+
}
8+
}

src/json-rules-engine.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@ import Rule from './rule'
44
import Operator from './operator'
55

66
export { Fact, Rule, Operator, Engine }
7-
export default function (rules) {
8-
return new Engine(rules)
7+
export default function (rules, options) {
8+
return new Engine(rules, options)
99
}

src/rule.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ class Rule extends EventEmitter {
134134
*/
135135
let evaluateCondition = async (condition) => {
136136
let comparisonValue
137+
let passes
137138
if (condition.isBooleanOperator()) {
138139
let subConditions = condition[condition.operator]
139140
if (condition.operator === 'all') {
@@ -142,9 +143,16 @@ class Rule extends EventEmitter {
142143
comparisonValue = await any(subConditions)
143144
}
144145
} else {
145-
comparisonValue = await almanac.factValue(condition.fact, condition.params)
146+
try {
147+
comparisonValue = await almanac.factValue(condition.fact, condition.params)
148+
} catch (err) {
149+
if (this.engine.allowUndefinedFacts && err.code === 'UNDEFINED_FACT') passes = false
150+
else throw err
151+
}
152+
}
153+
if (passes === undefined) {
154+
passes = await condition.evaluate(comparisonValue, this.engine.operators)
146155
}
147-
let passes = await condition.evaluate(comparisonValue, this.engine.operators)
148156
if (passes) {
149157
this.emit('success', this.event, almanac)
150158
} else {

test/engine-fact.test.js

Lines changed: 76 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -42,41 +42,90 @@ describe('Engine: fact evaluation', () => {
4242
demographic: 'under50'
4343
}
4444
}
45-
let baseConditions = {
46-
any: [{
47-
fact: 'eligibilityField',
48-
operator: 'lessThan',
49-
params: {
50-
eligibilityId: 1,
51-
field: 'age'
52-
},
53-
value: 50
54-
}]
45+
function baseConditions () {
46+
return {
47+
any: [{
48+
fact: 'eligibilityField',
49+
operator: 'lessThan',
50+
params: {
51+
eligibilityId: 1,
52+
field: 'age'
53+
},
54+
value: 50
55+
}]
56+
}
5557
}
56-
let eventSpy = sinon.spy()
57-
function setup (conditions = baseConditions) {
58-
eventSpy.reset()
59-
engine = engineFactory()
58+
let successSpy = sinon.spy()
59+
let failureSpy = sinon.spy()
60+
function setup (conditions = baseConditions(), engineOptions = {}) {
61+
successSpy.reset()
62+
failureSpy.reset()
63+
engine = engineFactory([], engineOptions)
6064
let rule = factories.rule({ conditions, event })
6165
engine.addRule(rule)
6266
engine.addFact('eligibilityField', eligibilityField)
6367
engine.addFact('eligibilityData', eligibilityData)
64-
engine.on('success', eventSpy)
68+
engine.on('success', successSpy)
69+
engine.on('failure', failureSpy)
6570
}
6671

72+
describe('options', () => {
73+
describe('options.allowUndefinedFacts', () => {
74+
it('throws when fact is undefined by default', async () => {
75+
let conditions = Object.assign({}, baseConditions())
76+
conditions.any.push({
77+
fact: 'undefined-fact',
78+
operator: 'equal',
79+
value: true
80+
})
81+
setup(conditions)
82+
return expect(engine.run()).to.be.rejectedWith(/Undefined fact: undefined-fact/)
83+
})
84+
85+
context('treats undefined facts as falsey when allowUndefinedFacts is set', () => {
86+
it('emits "success" when the condition succeeds', async () => {
87+
let conditions = Object.assign({}, baseConditions())
88+
conditions.any.push({
89+
fact: 'undefined-fact',
90+
operator: 'equal',
91+
value: true
92+
})
93+
setup(conditions, { allowUndefinedFacts: true })
94+
await engine.run()
95+
expect(successSpy).to.have.been.called
96+
expect(failureSpy).to.not.have.been.called
97+
})
98+
99+
it('emits "failure" when the condition fails', async () => {
100+
let conditions = Object.assign({}, baseConditions())
101+
conditions.any.push({
102+
fact: 'undefined-fact',
103+
operator: 'equal',
104+
value: true
105+
})
106+
conditions.any[0].params.eligibilityId = 2
107+
setup(conditions, { allowUndefinedFacts: true })
108+
await engine.run()
109+
expect(successSpy).to.not.have.been.called
110+
expect(failureSpy).to.have.been.called
111+
})
112+
})
113+
})
114+
})
115+
67116
describe('params', () => {
68117
it('emits when the condition is met', async () => {
69118
setup()
70119
await engine.run()
71-
expect(eventSpy).to.have.been.calledWith(event)
120+
expect(successSpy).to.have.been.calledWith(event)
72121
})
73122

74123
it('does not emit when the condition fails', async () => {
75-
let conditions = Object.assign({}, baseConditions)
124+
let conditions = Object.assign({}, baseConditions())
76125
conditions.any[0].params.eligibilityId = 2
77126
setup(conditions)
78127
await engine.run()
79-
expect(eventSpy).to.not.have.been.called
128+
expect(successSpy).to.not.have.been.called
80129
})
81130
})
82131

@@ -97,15 +146,15 @@ describe('Engine: fact evaluation', () => {
97146
it('emits when the condition is met', async () => {
98147
setup(conditions())
99148
await engine.run()
100-
expect(eventSpy).to.have.been.calledWith(event)
149+
expect(successSpy).to.have.been.calledWith(event)
101150
})
102151

103152
it('does not emit when the condition fails', async () => {
104153
let failureCondition = conditions()
105154
failureCondition.any[0].params.eligibilityId = 2
106155
setup(failureCondition)
107156
await engine.run()
108-
expect(eventSpy).to.not.have.been.called
157+
expect(successSpy).to.not.have.been.called
109158
})
110159

111160
it('emits when complex object paths meet the conditions', async () => {
@@ -115,7 +164,7 @@ describe('Engine: fact evaluation', () => {
115164
complexCondition.any[0].operator = 'equal'
116165
setup(complexCondition)
117166
await engine.run()
118-
expect(eventSpy).to.have.been.calledWith(event)
167+
expect(successSpy).to.have.been.calledWith(event)
119168
})
120169

121170
it('does not emit when complex object paths fail the condition', async () => {
@@ -125,7 +174,7 @@ describe('Engine: fact evaluation', () => {
125174
complexCondition.any[0].operator = 'equal'
126175
setup(complexCondition)
127176
await engine.run()
128-
expect(eventSpy).to.not.have.been.calledWith(event)
177+
expect(successSpy).to.not.have.been.calledWith(event)
129178
})
130179

131180
it('treats invalid object paths as undefined', async () => {
@@ -135,7 +184,7 @@ describe('Engine: fact evaluation', () => {
135184
complexCondition.any[0].operator = 'equal'
136185
setup(complexCondition)
137186
await engine.run()
138-
expect(eventSpy).to.have.been.calledWith(event)
187+
expect(successSpy).to.have.been.calledWith(event)
139188
})
140189

141190
it('ignores "path" when facts return non-objects', async () => {
@@ -145,7 +194,7 @@ describe('Engine: fact evaluation', () => {
145194
}
146195
engine.addFact('eligibilityData', eligibilityData)
147196
await engine.run()
148-
expect(eventSpy).to.have.been.calledWith(event)
197+
expect(successSpy).to.have.been.calledWith(event)
149198
})
150199
})
151200

@@ -161,7 +210,7 @@ describe('Engine: fact evaluation', () => {
161210
}
162211
engine.addFact('eligibilityField', eligibilityField)
163212
await engine.run()
164-
expect(eventSpy).to.have.been.called
213+
expect(successSpy).to.have.been.called
165214
})
166215
})
167216

@@ -173,7 +222,7 @@ describe('Engine: fact evaluation', () => {
173222
}
174223
engine.addFact('eligibilityField', eligibilityField)
175224
await engine.run()
176-
expect(eventSpy).to.have.been.called
225+
expect(successSpy).to.have.been.called
177226
})
178227

179228
it('works with synchronous, non-promise evaluations that are falsey', async () => {
@@ -183,7 +232,7 @@ describe('Engine: fact evaluation', () => {
183232
}
184233
engine.addFact('eligibilityField', eligibilityField)
185234
await engine.run()
186-
expect(eventSpy).to.not.have.been.called
235+
expect(successSpy).to.not.have.been.called
187236
})
188237
})
189238
})

0 commit comments

Comments
 (0)