Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/interpolation #276

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions src/almanac.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 38 additions & 9 deletions src/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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();
}

/**
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions src/interpolate.js
Original file line number Diff line number Diff line change
@@ -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) => {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a weird looking interpolation function, but it's been in my toolbelt for a while now. It allows you to preserve types when interpolating. e.g.

const subject = {
   myField: '{{a}}',
   myArray: '{{b}}'
}

interpolateDeep(
  subject,
  {
    a: 1,
    b: ['some','array']
  }
) 

// results in { myField: 1, myArray: ['some','array'] }

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)}),{});
};

5 changes: 5 additions & 0 deletions src/resolver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { JSONPath } from 'jsonpath-plus'

export function defaultPathResolver (value, path) {
return JSONPath({ path, json: value, wrap: false })
}
1 change: 1 addition & 0 deletions src/rule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
11 changes: 7 additions & 4 deletions test/engine-fact.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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()
})
Expand All @@ -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)
})

Expand Down
4 changes: 2 additions & 2 deletions test/engine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down
1 change: 1 addition & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export interface EngineOptions {
allowUndefinedFacts?: boolean;
pathResolver?: PathResolver;
interpolation?: RegExp;
}

export interface EngineResult {
Expand Down