Skip to content

Commit b19cff2

Browse files
author
Christopher Pardy
committed
Add Shared Condition Support
Add Support for Shared Conditions which are added to the engine and then referenced in various rules.
1 parent 1d5d304 commit b19cff2

File tree

6 files changed

+179
-12
lines changed

6 files changed

+179
-12
lines changed

src/condition.js

Lines changed: 11 additions & 1 deletion
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, 'uses')) {
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.isSharedCondition()) {
58+
props.uses = this.uses
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 shared condition
155+
* @returns {Boolean}
156+
*/
157+
isSharedCondition () {
158+
return Object.prototype.hasOwnProperty.call(this, 'uses')
159+
}
150160
}

src/engine.js

Lines changed: 23 additions & 0 deletions
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'
@@ -24,6 +25,7 @@ class Engine extends EventEmitter {
2425
this.pathResolver = options.pathResolver
2526
this.operators = new Map()
2627
this.facts = new Map()
28+
this.sharedConditions = new Map()
2729
this.status = READY
2830
rules.map(r => this.addRule(r))
2931
defaultOperators.map(o => this.addOperator(o))
@@ -92,6 +94,27 @@ class Engine extends EventEmitter {
9294
return ruleRemoved
9395
}
9496

97+
/**
98+
* Adds or updates a shared condition
99+
* @param {string} name - the name of the shared condition to be referenced by rules.
100+
* @param {object} conditions - the conditions to use when the shared condition is referenced.
101+
*/
102+
addSharedCondition (name, conditions) {
103+
if (!name) throw new Error('Engine: addSharedCondition() requires name')
104+
if (!conditions) throw new Error('Engine: addSharedCondition() requires conditions')
105+
this.sharedConditions.set(name, new Condition(conditions))
106+
return this
107+
}
108+
109+
/**
110+
* Removes a shared condition that has previously been added to this engine
111+
* @param {string} name - the name of the shared condition to remove.
112+
* @returns true if the shared condition existed, otherwise false
113+
*/
114+
removeSharedCondition (name) {
115+
return this.sharedConditions.delete(name)
116+
}
117+
95118
/**
96119
* Add a custom operator definition
97120
* @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc

src/rule.js

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import Condition from './condition'
44
import RuleResult from './rule-result'
55
import debug from './debug'
6+
import deepClone from 'clone'
67
import EventEmitter from 'eventemitter2'
78

89
class Rule extends EventEmitter {
@@ -70,8 +71,8 @@ class Rule extends EventEmitter {
7071
* @param {object} conditions - conditions, root element must be a boolean operator
7172
*/
7273
setConditions (conditions) {
73-
if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not')) {
74-
throw new Error('"conditions" root must contain a single instance of "all", "any", or "not"')
74+
if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'uses')) {
75+
throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "uses"')
7576
}
7677
this.conditions = new Condition(conditions)
7778
return this
@@ -188,6 +189,10 @@ class Rule extends EventEmitter {
188189
* @return {Promise(true|false)} - resolves with the result of the condition evaluation
189190
*/
190191
const evaluateCondition = (condition) => {
192+
if (condition.isSharedCondition()) {
193+
realize(this.engine.sharedConditions.get(condition.uses), condition)
194+
}
195+
191196
if (condition.isBooleanOperator()) {
192197
const subConditions = condition[condition.operator]
193198
let comparisonPromise
@@ -309,6 +314,12 @@ class Rule extends EventEmitter {
309314
return prioritizeAndRun([condition], 'not').then(result => !result)
310315
}
311316

317+
const realize = (sharedCondition, targetCondition) => {
318+
if (!sharedCondition) throw new Error(`No shared condition ${targetCondition.uses} exists`)
319+
delete targetCondition.uses
320+
Object.assign(targetCondition, deepClone(sharedCondition))
321+
}
322+
312323
/**
313324
* Emits based on rule evaluation result, and decorates ruleResult with 'result' property
314325
* @param {RuleResult} ruleResult

test/engine-shared-conditions.test.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use strict'
2+
3+
import sinon from 'sinon'
4+
import engineFactory from '../src/index'
5+
6+
describe('Engine: shared conditions', () => {
7+
let engine
8+
let sandbox
9+
before(() => {
10+
sandbox = sinon.createSandbox()
11+
})
12+
afterEach(() => {
13+
sandbox.restore()
14+
})
15+
16+
describe('supports shared conditions across multiple rules', () => {
17+
const name = 'over60'
18+
const sharedCondition = {
19+
all: [{
20+
fact: 'age',
21+
operator: 'greaterThanInclusive',
22+
value: 60
23+
}]
24+
}
25+
26+
const sendEvent = {
27+
type: 'checkSending',
28+
params: {
29+
sendRetirementPayment: true
30+
}
31+
}
32+
33+
const sendConditions = {
34+
all: [
35+
{ uses: name },
36+
{
37+
fact: 'isRetired',
38+
operator: 'equal',
39+
value: true
40+
}
41+
]
42+
}
43+
44+
const outreachEvent = {
45+
type: 'triggerOutreach'
46+
}
47+
48+
const outreachConditions = {
49+
all: [
50+
{ uses: name },
51+
{
52+
fact: 'requestedOutreach',
53+
operator: 'equal',
54+
value: true
55+
}
56+
]
57+
}
58+
59+
let eventSpy
60+
let ageSpy
61+
let isRetiredSpy
62+
let requestedOutreachSpy
63+
beforeEach(() => {
64+
eventSpy = sandbox.spy()
65+
ageSpy = sandbox.stub()
66+
isRetiredSpy = sandbox.stub()
67+
requestedOutreachSpy = sandbox.stub()
68+
engine = engineFactory()
69+
70+
const sendRule = factories.rule({ conditions: sendConditions, event: sendEvent })
71+
engine.addRule(sendRule)
72+
73+
const outreachRule = factories.rule({ conditions: outreachConditions, event: outreachEvent })
74+
engine.addRule(outreachRule)
75+
76+
engine.addSharedCondition(name, sharedCondition)
77+
78+
engine.addFact('age', ageSpy)
79+
engine.addFact('isRetired', isRetiredSpy)
80+
engine.addFact('requestedOutreach', requestedOutreachSpy)
81+
engine.on('success', eventSpy)
82+
})
83+
84+
it('emits all events when all conditions are met', async () => {
85+
ageSpy.returns(65)
86+
isRetiredSpy.returns(true)
87+
requestedOutreachSpy.returns(true)
88+
await engine.run()
89+
expect(eventSpy).to.have.been.calledWith(sendEvent).and.to.have.been.calledWith(outreachEvent)
90+
})
91+
92+
it('expands shared conditions in rule results', async () => {
93+
ageSpy.returns(65)
94+
isRetiredSpy.returns(true)
95+
requestedOutreachSpy.returns(true)
96+
const { results } = await engine.run()
97+
const nestedSharedCondition = {
98+
'conditions.all[0].all[0].fact': 'age',
99+
'conditions.all[0].all[0].operator': 'greaterThanInclusive',
100+
'conditions.all[0].all[0].value': 60
101+
}
102+
expect(results[0]).to.nested.include(nestedSharedCondition)
103+
expect(results[1]).to.nested.include(nestedSharedCondition)
104+
})
105+
})
106+
})

test/rule.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe('Rule', () => {
109109
describe('setConditions()', () => {
110110
describe('validations', () => {
111111
it('throws an exception for invalid root conditions', () => {
112-
expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", or "not"/)
112+
expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", "not", or "uses"/)
113113
})
114114
})
115115
})

types/index.d.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export class Engine {
2323
removeRule(ruleOrName: Rule | string): boolean;
2424
updateRule(rule: Rule): void;
2525

26+
addSharedCondition(name: string, conditions: TopLevelCondition): this;
27+
removeSharedCondition(name: string): boolean;
28+
2629
addOperator(operator: Operator): Map<string, Operator>;
2730
addOperator<A, B>(
2831
operatorName: string,
@@ -98,10 +101,7 @@ export interface Event {
98101
params?: Record<string, any>;
99102
}
100103

101-
export type PathResolver = (
102-
value: object,
103-
path: string,
104-
) => any;
104+
export type PathResolver = (value: object, path: string) => any;
105105

106106
export type EventHandler = (
107107
event: Event,
@@ -156,7 +156,24 @@ interface ConditionProperties {
156156
}
157157

158158
type NestedCondition = ConditionProperties | TopLevelCondition;
159-
type AllConditions = { all: NestedCondition[]; name?: string; priority?: number; };
160-
type AnyConditions = { any: NestedCondition[]; name?: string; priority?: number; };
161-
type NotConditions = { not: NestedCondition; name?: string; priority?: number; };
162-
export type TopLevelCondition = AllConditions | AnyConditions | NotConditions;
159+
type AllConditions = {
160+
all: NestedCondition[];
161+
name?: string;
162+
priority?: number;
163+
};
164+
type AnyConditions = {
165+
any: NestedCondition[];
166+
name?: string;
167+
priority?: number;
168+
};
169+
type NotConditions = { not: NestedCondition; name?: string; priority?: number };
170+
type SharedConditionReference = {
171+
uses: string;
172+
priority?: number;
173+
name?: string;
174+
};
175+
export type TopLevelCondition =
176+
| AllConditions
177+
| AnyConditions
178+
| NotConditions
179+
| SharedConditionReference;

0 commit comments

Comments
 (0)