diff --git a/src/almanac.js b/src/almanac.js index 7a9a8e0..ccf684c 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -4,12 +4,8 @@ import Fact from './fact' import { UndefinedFactError } from './errors' import debug from './debug' -import { JSONPath } from 'jsonpath-plus' import isObjectLike from 'lodash.isobjectlike' - -function defaultPathResolver (value, path) { - return JSONPath({ path, json: value, wrap: false }) -} +import { defaultPathResolver } from './resolver'; /** * Fact results lookup diff --git a/src/engine.js b/src/engine.js index 3bd958e..6775113 100644 --- a/src/engine.js +++ b/src/engine.js @@ -7,6 +7,8 @@ import Almanac from './almanac' import EventEmitter from 'eventemitter2' import defaultOperators from './engine-default-operators' import debug from './debug' +import { interpolateDeep, needsInterpolation, defaultInterpolation } from './interpolate'; +import { defaultPathResolver } from './resolver'; export const READY = 'READY' export const RUNNING = 'RUNNING' @@ -21,12 +23,16 @@ class Engine extends EventEmitter { super() this.rules = [] this.allowUndefinedFacts = options.allowUndefinedFacts || false - this.pathResolver = options.pathResolver + this.pathResolver = options.pathResolver || defaultPathResolver + this.interpolation = options.interpolation || defaultInterpolation; + if(!(this.interpolation instanceof RegExp) || !this.interpolation.global) throw new Error('interpolation option must be a global regexp') this.operators = new Map() this.facts = new Map() this.status = READY rules.map(r => this.addRule(r)) defaultOperators.map(o => this.addOperator(o)) + + this.interpolatableRules = new Set(); } /** @@ -49,6 +55,7 @@ class Engine extends EventEmitter { if (!Object.prototype.hasOwnProperty.call(properties, 'conditions')) throw new Error('Engine: addRule() argument requires "conditions" property') rule = new Rule(properties) } + if(needsInterpolation(rule,this.interpolation)) this.interpolatableRules.add(rule); rule.setEngine(this) this.rules.push(rule) this.prioritizedRules = null @@ -62,7 +69,8 @@ class Engine extends EventEmitter { updateRule (rule) { const ruleIndex = this.rules.findIndex(ruleInEngine => ruleInEngine.name === rule.name) if (ruleIndex > -1) { - this.rules.splice(ruleIndex, 1) + const [old] = this.rules.splice(ruleIndex, 1) + this.interpolatableRules.delete(old); this.addRule(rule) this.prioritizedRules = null } else { @@ -75,21 +83,25 @@ class Engine extends EventEmitter { * @param {object|Rule|string} rule - rule definition. Must be a instance of Rule */ removeRule (rule) { - let ruleRemoved = false + let theRemovedRule; if (!(rule instanceof Rule)) { - const filteredRules = this.rules.filter(ruleInEngine => ruleInEngine.name !== rule) - ruleRemoved = filteredRules.length !== this.rules.length + const filteredRules = this.rules.filter(ruleInEngine => { + const isRule = ruleInEngine.name === rule; + if(!theRemovedRule && isRule) theRemovedRule = ruleInEngine; + return !isRule; + }); this.rules = filteredRules } else { const index = this.rules.indexOf(rule) if (index > -1) { - ruleRemoved = Boolean(this.rules.splice(index, 1).length) + theRemovedRule = this.rules.splice(index, 1)[0]; } } - if (ruleRemoved) { + if (theRemovedRule) { this.prioritizedRules = null + this.interpolatableRules.delete(theRemovedRule); } - return ruleRemoved + return Boolean(theRemovedRule); } /** @@ -211,6 +223,7 @@ class Engine extends EventEmitter { debug(`engine::run status:${this.status}; skipping remaining rules`) return Promise.resolve() } + return rule.evaluate(almanac).then((ruleResult) => { debug(`engine::run ruleResult:${ruleResult.result}`) almanac.addResult(ruleResult) @@ -240,12 +253,28 @@ class Engine extends EventEmitter { pathResolver: this.pathResolver } const almanac = new Almanac(this.facts, runtimeFacts, almanacOptions) - const orderedSets = this.prioritizeRules() + const orderedSets = this.prioritizeRules(); + let cursor = Promise.resolve() // for each rule set, evaluate in parallel, // before proceeding to the next priority set. return new Promise((resolve, reject) => { orderedSets.map((set) => { + set = set.map(rule => { + if(!this.interpolatableRules.has(rule)) return rule; + rule = new Rule( + interpolateDeep( + rule.toJSON(true), + runtimeFacts, + this.interpolation, + this.pathResolver + ) + ); + rule.setEngine(this); + return rule; + }); + + cursor = cursor.then(() => { return this.evaluateRules(set, almanac) }).catch(reject) diff --git a/src/interpolate.js b/src/interpolate.js new file mode 100644 index 0000000..93ffea7 --- /dev/null +++ b/src/interpolate.js @@ -0,0 +1,27 @@ +export const defaultInterpolation = /\{\{\s*(.+?)\s*\}\}/g + +export const needsInterpolation = (rule,regexp) => regexp.test(rule.toJSON(true)); + +const interpolate = (subject = '', params = {}, regexp, resolver) => { + let shouldReplaceFull, found; + + const replaced = subject.replace(regexp, (full, matched) => { + shouldReplaceFull = full === subject; + found = resolver(params, matched); + return shouldReplaceFull ? '' : found; + }); + + return shouldReplaceFull ? found : replaced; +}; + + +export const interpolateDeep = (o, params, regexp, resolver) => { + if (!o || typeof o === 'number' || typeof o === 'boolean') return o; + + if (typeof o === 'string') return interpolate(o,params,regexp,resolver) + + if (Array.isArray(o)) return o.map(t => interpolateDeep(t, params, regexp, resolver)); + + return Object.entries(o).reduce((acc, [k, v]) => ({...acc,[k]: interpolateDeep(v, params, regexp, resolver)}),{}); +}; + diff --git a/src/resolver.js b/src/resolver.js new file mode 100644 index 0000000..fd41455 --- /dev/null +++ b/src/resolver.js @@ -0,0 +1,5 @@ +import { JSONPath } from 'jsonpath-plus' + +export function defaultPathResolver (value, path) { + return JSONPath({ path, json: value, wrap: false }) +} \ No newline at end of file diff --git a/src/rule.js b/src/rule.js index 5db0191..afaf322 100644 --- a/src/rule.js +++ b/src/rule.js @@ -4,6 +4,7 @@ import Condition from './condition' import RuleResult from './rule-result' import debug from './debug' import EventEmitter from 'eventemitter2' +import { needsInterpolation } from './interpolate' class Rule extends EventEmitter { /** diff --git a/test/engine-fact.test.js b/test/engine-fact.test.js index 93d9b99..5822d10 100644 --- a/test/engine-fact.test.js +++ b/test/engine-fact.test.js @@ -61,12 +61,15 @@ describe('Engine: fact evaluation', () => { operator: 'lessThan', params: { eligibilityId: 1, - field: 'age' + field: '{{whichField}}' }, value: 50 }] } } + const runtimeFacts = { + whichField:'age' + } let successSpy let failureSpy beforeEach(() => { @@ -94,7 +97,7 @@ describe('Engine: fact evaluation', () => { value: true }) setup(conditions) - return expect(engine.run()).to.be.rejectedWith(/Undefined fact: undefined-fact/) + return expect(engine.run(runtimeFacts)).to.be.rejectedWith(/Undefined fact: undefined-fact/) }) context('treats undefined facts as falsey when allowUndefinedFacts is set', () => { @@ -106,7 +109,7 @@ describe('Engine: fact evaluation', () => { value: true }) setup(conditions, { allowUndefinedFacts: true }) - await engine.run() + await engine.run(runtimeFacts) expect(successSpy).to.have.been.called() expect(failureSpy).to.not.have.been.called() }) @@ -131,7 +134,7 @@ describe('Engine: fact evaluation', () => { describe('params', () => { it('emits when the condition is met', async () => { setup() - await engine.run() + await engine.run(runtimeFacts) expect(successSpy).to.have.been.calledWith(event) }) diff --git a/test/engine.test.js b/test/engine.test.js index 6030466..34b8b3b 100644 --- a/test/engine.test.js +++ b/test/engine.test.js @@ -3,6 +3,7 @@ import sinon from 'sinon' import engineFactory, { Fact, Rule, Operator } from '../src/index' import defaultOperators from '../src/engine-default-operators' +import Condition from '../src/condition' describe('Engine', () => { const operatorCount = defaultOperators.length @@ -92,8 +93,7 @@ describe('Engine', () => { engine.addRule(rule2) expect(engine.rules[0].conditions.all.length).to.equal(2) expect(engine.rules[1].conditions.all.length).to.equal(2) - - rule1.conditions = { all: [] } + rule1.conditions = new Condition({all:[]}) engine.updateRule(rule1) rule1 = engine.rules.find(rule => rule.name === 'rule1') diff --git a/types/index.d.ts b/types/index.d.ts index b3d39e2..25b1072 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,7 @@ export interface EngineOptions { allowUndefinedFacts?: boolean; pathResolver?: PathResolver; + interpolation?: RegExp; } export interface EngineResult {