Skip to content

Commit

Permalink
Merge pull request #357 from chris-pardy/custom-almanac
Browse files Browse the repository at this point in the history
Custom almanac
  • Loading branch information
chris-pardy authored Nov 10, 2023
2 parents 0de797c + 3c1975e commit fff169d
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 52 deletions.
21 changes: 21 additions & 0 deletions docs/almanac.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* [Overview](#overview)
* [Methods](#methods)
* [almanac.factValue(Fact fact, Object params, String path) -> Promise](#almanacfactvaluefact-fact-object-params-string-path---promise)
* [almanac.addFact(String id, Function [definitionFunc], Object [options])](#almanacaddfactstring-id-function-definitionfunc-object-options)
* [almanac.addRuntimeFact(String factId, Mixed value)](#almanacaddruntimefactstring-factid-mixed-value)
* [almanac.getEvents(String outcome) -> Events[]](#almanacgeteventsstring-outcome---events)
* [almanac.getResults() -> RuleResults[]](#almanacgetresults---ruleresults)
Expand Down Expand Up @@ -33,8 +34,28 @@ almanac
.then( value => console.log(value))
```

### almanac.addFact(String id, Function [definitionFunc], Object [options])

Sets a fact in the almanac. Used in conjunction with rule and engine event emissions.

```js
// constant facts:
engine.addFact('speed-of-light', 299792458)

// facts computed via function
engine.addFact('account-type', function getAccountType(params, almanac) {
// ...
})

// facts with options:
engine.addFact('account-type', function getAccountType(params, almanac) {
// ...
}, { cache: false, priority: 500 })
```

### almanac.addRuntimeFact(String factId, Mixed value)

**Deprecated** Use `almanac.addFact` instead
Sets a constant fact mid-run. Often used in conjunction with rule and engine event emissions.

```js
Expand Down
10 changes: 10 additions & 0 deletions docs/engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,16 @@ const {
```
Link to the [Almanac documentation](./almanac.md)

Optionally, you may specify a specific almanac instance via the almanac property.

```js
// create a custom Almanac
const myCustomAlmanac = new CustomAlmanac();

// run the engine with the custom almanac
await engine.run({}, { almanac: myCustomAlmanac })
```

### engine.stop() -> Engine

Stops the rules engine from running the next priority set of Rules. All remaining rules will be resolved as undefined,
Expand Down
6 changes: 3 additions & 3 deletions examples/07-rule-chaining.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@ async function start () {
event: { type: 'drinks-screwdrivers' },
priority: 10, // IMPORTANT! Set a higher priority for the drinkRule, so it runs first
onSuccess: async function (event, almanac) {
almanac.addRuntimeFact('screwdriverAficionado', true)
almanac.addFact('screwdriverAficionado', true)

// asychronous operations can be performed within callbacks
// engine execution will not proceed until the returned promises is resolved
const accountId = await almanac.factValue('accountId')
const accountInfo = await getAccountInformation(accountId)
almanac.addRuntimeFact('accountInfo', accountInfo)
almanac.addFact('accountInfo', accountInfo)
},
onFailure: function (event, almanac) {
almanac.addRuntimeFact('screwdriverAficionado', false)
almanac.addFact('screwdriverAficionado', false)
}
}
engine.addRule(drinkRule)
Expand Down
94 changes: 94 additions & 0 deletions examples/12-using-custom-almanac.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use strict'

require('colors')
const { Almanac, Engine } = require('json-rules-engine')

/**
* Almanac that support piping values through named functions
*/
class PipedAlmanac extends Almanac {
constructor (options) {
super(options)
this.pipes = new Map()
}

addPipe (name, pipe) {
this.pipes.set(name, pipe)
}

factValue (factId, params, path) {
let pipes = []
if (params && 'pipes' in params && Array.isArray(params.pipes)) {
pipes = params.pipes
delete params.pipes
}
return super.factValue(factId, params, path).then(value => {
return pipes.reduce((value, pipeName) => {
const pipe = this.pipes.get(pipeName)
if (pipe) {
return pipe(value)
}
return value
}, value)
})
}
}

async function start () {
const engine = new Engine()
.addRule({
conditions: {
all: [
{
fact: 'age',
params: {
// the addOne pipe adds one to the value
pipes: ['addOne']
},
operator: 'greaterThanInclusive',
value: 21
}
]
},
event: {
type: 'Over 21(ish)'
}
})

engine.on('success', async (event, almanac) => {
const name = await almanac.factValue('name')
const age = await almanac.factValue('age')
console.log(`${name} is ${age} years old and ${'is'.green} ${event.type}`)
})

engine.on('failure', async (event, almanac) => {
const name = await almanac.factValue('name')
const age = await almanac.factValue('age')
console.log(`${name} is ${age} years old and ${'is not'.red} ${event.type}`)
})

const createAlmanacWithPipes = () => {
const almanac = new PipedAlmanac()
almanac.addPipe('addOne', (v) => v + 1)
return almanac
}

// first run Bob who is less than 20
await engine.run({ name: 'Bob', age: 19 }, { almanac: createAlmanacWithPipes() })

// second run Alice who is 21
await engine.run({ name: 'Alice', age: 21 }, { almanac: createAlmanacWithPipes() })

// third run Chad who is 20
await engine.run({ name: 'Chad', age: 20 }, { almanac: createAlmanacWithPipes() })
}

start()

/*
* OUTPUT:
*
* Bob is 19 years old and is not Over 21(ish)
* Alice is 21 years old and is Over 21(ish)
* Chad is 20 years old and is Over 21(ish)
*/
40 changes: 26 additions & 14 deletions src/almanac.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,13 @@ function defaultPathResolver (value, path) {
* A new almanac is used for every engine run()
*/
export default class Almanac {
constructor (factMap, runtimeFacts = {}, options = {}) {
this.factMap = new Map(factMap)
constructor (options = {}) {
this.factMap = new Map()
this.factResultsCache = new Map() // { cacheKey: Promise<factValu> }
this.allowUndefinedFacts = Boolean(options.allowUndefinedFacts)
this.pathResolver = options.pathResolver || defaultPathResolver
this.events = { success: [], failure: [] }
this.ruleResults = []

for (const factId in runtimeFacts) {
let fact
if (runtimeFacts[factId] instanceof Fact) {
fact = runtimeFacts[factId]
} else {
fact = new Fact(factId, runtimeFacts[factId])
}

this._addConstantFact(fact)
debug(`almanac::constructor initialized runtime fact:${fact.id} with ${fact.value}<${typeof fact.value}>`)
}
}

/**
Expand Down Expand Up @@ -103,8 +91,32 @@ export default class Almanac {
return factValue
}

/**
* Add a fact definition to the engine. Facts are called by rules as they are evaluated.
* @param {object|Fact} id - fact identifier or instance of Fact
* @param {function} definitionFunc - function to be called when computing the fact value for a given rule
* @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance
*/
addFact (id, valueOrMethod, options) {
let factId = id
let fact
if (id instanceof Fact) {
factId = id.id
fact = id
} else {
fact = new Fact(id, valueOrMethod, options)
}
debug(`almanac::addFact id:${factId}`)
this.factMap.set(factId, fact)
if (fact.isConstant()) {
this._setFactValue(fact, {}, fact.value)
}
return this
}

/**
* Adds a constant fact during runtime. Can be used mid-run() to add additional information
* @deprecated use addFact
* @param {String} fact - fact identifier
* @param {Mixed} value - constant value of the fact
*/
Expand Down
21 changes: 18 additions & 3 deletions src/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,14 +261,29 @@ class Engine extends EventEmitter {
* @param {Object} runOptions - run options
* @return {Promise} resolves when the engine has completed running
*/
run (runtimeFacts = {}) {
run (runtimeFacts = {}, runOptions = {}) {
debug('engine::run started')
this.status = RUNNING
const almanacOptions = {

const almanac = runOptions.almanac || new Almanac({
allowUndefinedFacts: this.allowUndefinedFacts,
pathResolver: this.pathResolver
})

this.facts.forEach(fact => {
almanac.addFact(fact)
})
for (const factId in runtimeFacts) {
let fact
if (runtimeFacts[factId] instanceof Fact) {
fact = runtimeFacts[factId]
} else {
fact = new Fact(factId, runtimeFacts[factId])
}

almanac.addFact(fact)
debug(`engine::run initialized runtime fact:${fact.id} with ${fact.value}<${typeof fact.value}>`)
}
const almanac = new Almanac(this.facts, runtimeFacts, almanacOptions)
const orderedSets = this.prioritizeRules()
let cursor = Promise.resolve()
// for each rule set, evaluate in parallel,
Expand Down
3 changes: 2 additions & 1 deletion src/json-rules-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import Engine from './engine'
import Fact from './fact'
import Rule from './rule'
import Operator from './operator'
import Almanac from './almanac'

export { Fact, Rule, Operator, Engine }
export { Fact, Rule, Operator, Engine, Almanac }
export default function (rules, options) {
return new Engine(rules, options)
}
46 changes: 27 additions & 19 deletions test/almanac.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,33 @@ describe('Almanac', () => {
})

it('adds runtime facts', () => {
almanac = new Almanac(new Map(), { modelId: 'XYZ' })
almanac = new Almanac()
almanac.addFact('modelId', 'XYZ')
expect(almanac.factMap.get('modelId').value).to.equal('XYZ')
})
})

describe('constructor', () => {
describe('addFact', () => {
it('supports runtime facts as key => values', () => {
almanac = new Almanac(new Map(), { fact1: 3 })
almanac = new Almanac()
almanac.addFact('fact1', 3)
return expect(almanac.factValue('fact1')).to.eventually.equal(3)
})

it('supporrts runtime facts as dynamic callbacks', async () => {
almanac = new Almanac()
almanac.addFact('fact1', () => {
factSpy()
return Promise.resolve(3)
})
await expect(almanac.factValue('fact1')).to.eventually.equal(3)
await expect(factSpy).to.have.been.calledOnce()
})

it('supports runtime fact instances', () => {
const fact = new Fact('fact1', 3)
almanac = new Almanac(new Map(), { fact1: fact })
almanac = new Almanac()
almanac.addFact(fact)
return expect(almanac.factValue('fact1')).to.eventually.equal(fact.value)
})
})
Expand Down Expand Up @@ -69,9 +82,8 @@ describe('Almanac', () => {
if (params.userId) return params.userId
return 'unknown'
})
const factMap = new Map()
factMap.set(fact.id, fact)
almanac = new Almanac(factMap)
almanac = new Almanac()
almanac.addFact(fact)
})

it('allows parameters to be passed to the fact', async () => {
Expand Down Expand Up @@ -106,10 +118,9 @@ describe('Almanac', () => {

describe('_getFact', _ => {
it('retrieves the fact object', () => {
const facts = new Map()
const fact = new Fact('id', 1)
facts.set(fact.id, fact)
almanac = new Almanac(facts)
almanac = new Almanac()
almanac.addFact(fact)
expect(almanac._getFact('id')).to.equal(fact)
})
})
Expand All @@ -124,9 +135,8 @@ describe('Almanac', () => {

function setup (f = new Fact('id', 1)) {
fact = f
const facts = new Map()
facts.set(fact.id, fact)
almanac = new Almanac(facts)
almanac = new Almanac()
almanac.addFact(fact)
}
let fact
const FACT_VALUE = 2
Expand Down Expand Up @@ -154,9 +164,8 @@ describe('Almanac', () => {
name: 'Thomas'
}]
})
const factMap = new Map()
factMap.set(fact.id, fact)
almanac = new Almanac(factMap)
almanac = new Almanac()
almanac.addFact(fact)
const result = await almanac.factValue('foo', null, '$..name')
expect(result).to.deep.equal(['George', 'Thomas'])
})
Expand All @@ -167,9 +176,8 @@ describe('Almanac', () => {
factSpy()
return 'unknown'
}, factOptions)
const factMap = new Map()
factMap.set(fact.id, fact)
almanac = new Almanac(factMap)
almanac = new Almanac()
almanac.addFact(fact)
}

it('evaluates the fact every time when fact caching is off', () => {
Expand Down
Loading

0 comments on commit fff169d

Please sign in to comment.