diff --git a/README.md b/README.md index 64b9b02..af2022c 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,6 @@ Rules are specified in pure JavaScript rather than in a separate, special-purpos *Secondary design goal* was to provide RETE-like efficiency and optimization. -It uses some of the cool new ES6 stuff, e.g., Generators, `Proxy`, `Reflect`, `Set`. *JavaScript Rocks!* - ## Install ```bash @@ -67,7 +65,7 @@ const Rools = require('rools'); const rools = new Rools(); rools.register(ruleMoodGreat, ruleGoWalking); -const result = rools.evaluate(facts); +const result = await rools.evaluate(facts); ``` This is the result: ```js @@ -83,8 +81,6 @@ This is the result: The engine does forward-chaining and works in the usual match-resolve-act cycle. -Rule evaluation is non-blocking, i.e., each evaluation step is one execution block (using ES6 Generators). - ### Conflict resolution If there is more than one rule ready to fire, i.e., the conflict set is greater 1, the following conflict resolution strategies are applied (in this order): @@ -98,6 +94,33 @@ For optimization purposes, it might be desired to stop the engine as soon as a s This can be achieved by settings the respective rules' property `final` to `true`. Default, of course, is `false`. +### Async actions + +While premises (`when`) are always working synchronously on the facts, +actions (`then`) can be synchronous or asynchronous. + +Example: asynchronous action using async/await +```js +const rule = { + name: 'check availability', + when: facts => facts.user.address.country === 'germany', + then: async (facts) => { + facts.products = await availabilityCheck(facts.user.address); + }, +}; +``` + +Example: synchronous action using promises +```js +const rule = { + name: 'check availability', + when: facts => facts.user.address.country === 'germany', + then: facts => + availabilityCheck(facts.user.address) + .then((result) => { facts.products = result; }), +}; +``` + ### Optimization I It is very common that different rules partially share the same premises. @@ -187,7 +210,7 @@ const facts = { ... }; ... -rools.evaluate(facts); +await rools.evaluate(facts); ``` This optimization targets runtime performance. @@ -216,8 +239,8 @@ Rules are plain JavaScript objects with the following properties: | Property | Required | Default | Description | |-------------|----------|---------|-------------| | `name` | yes | - | A string value identifying the rule. This is used logging and debugging purposes only. | -| `when` | yes | - | A JavaScript function or an array of functions. These are the premises of your rule. The functions' interface is `(facts) => ...`. They must return a boolean value. | -| `then` | yes | - | A JavaScript function to be executed when the rule fires. The function's interface is `(facts) => { ... }`. | +| `when` | yes | - | A synchronous JavaScript function or an array of functions. These are the premises of your rule. The functions' interface is `(facts) => ...`. They must return a boolean value. | +| `then` | yes | - | A synchronous or asynchronous JavaScript function to be executed when the rule fires. The function's interface is `(facts) => { ... }` or `async (facts) => { ... }`. | | `priority` | no | `0` | If during `evaluate()` there is more than one rule ready to fire, i.e., the conflict set is greater 1, rules with higher priority will fire first. Negative values are supported. | | `final` | no | `false` | Marks a rule as final. If during `evaluate()` a final rule fires, the engine will stop the evaluation. | @@ -225,7 +248,7 @@ Rules are plain JavaScript objects with the following properties: It can be called multiple time. New rules will become effective immediately. -`register()` may `throw` an exception, e.g., if a rule is formally incorrect. +`register()` is working synchronously and may `throw` an exception, e.g., if a rule is formally incorrect. If an exception is thrown, the affected Rools instance becomes inconsistent and should no longer be used. Example: @@ -252,7 +275,7 @@ const rools = new Rools(); rools.register(ruleMoodGreat, ruleGoWalking); ``` -### `evaluate()` -- evaluate facts +### `async evaluate()` -- evaluate facts Facts are plain JavaScript or JSON objects. For example: ```js @@ -269,7 +292,7 @@ const facts = { }; const rools = new Rools(); rools.register(...); -rools.evaluate(facts); +await rools.evaluate(facts); ``` Sometimes, it is handy to combine facts using ES6 shorthand notation: @@ -285,26 +308,19 @@ const weather = { }; const rools = new Rools(); rools.register(...); -rools.evaluate({ user, weather }); +await rools.evaluate({ user, weather }); ``` -*Important*: Please note that rules read the facts (`when`) as well as write to the facts (`then`). Please make sure you provide a fresh set of facts whenever you call `evaluate()`. +Please note that rules read the facts (`when`) as well as write to the facts (`then`). +Please make sure you provide a fresh set of facts whenever you call `evaluate()`. -If during `evaluate()`, firing actions (`then`) or evaluating premises (`when`) raise errors, `evaluate()` will *not* fail. However, the errors are passed to its logger, which you can hook into like this. - -```js -const delegate = ({ message, rule, error }) => { - console.error(message, rule, error); -}; -const rools = new Rools({ logging: { delegate } }); -... -rools.evaluate(facts); -``` +`evaluate()` is working asynchronously, i.e., it returns a promise. +If a premise (`when`) fails, `evaluate()` will still *not* fail (for robustness reasons). +If an action (`then`) fails, `evaluate()` will reject its promise. ### Todos Some of the features on my list are: * Conflict resolution by specificity - * Asynchronous actions (`then`) * Action/rule groups * More unit tests diff --git a/package.json b/package.json index e621416..0a7455b 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "devDependencies": { "chai": "^4.1.2", "chai-as-promised": "^7.1.1", + "chai-shallow-deep-equal": "^1.4.6", "coveralls": "^3.0.0", "eslint": "^4.14.0", "eslint-config-airbnb-base": "^12.1.0", diff --git a/src/Rools.js b/src/Rools.js index 2a6a72e..25dd39d 100644 --- a/src/Rools.js +++ b/src/Rools.js @@ -54,8 +54,9 @@ class Rools { assert(rule.then, `"rule.then" is required "${rule.name}"`); } - evaluate(facts) { + async evaluate(facts) { // init + this.logger.log({ type: 'debug', message: 'evaluate init' }); const memory = {}; // working memory this.actions.forEach((action) => { memory[action.id] = { ready: false, fired: false }; @@ -68,18 +69,15 @@ class Rools { const activeSegments = new Set(); const premisesBySegment = {}; // hash // match-resolve-act cycle - for ( - let step = 0; - step < this.maxSteps && - !this.evaluateStep(proxy, delegator, memory, activeSegments, premisesBySegment, step) - .next().done; - step += 1 - ) ; - // for convenience only + for (let step = 0; step < this.maxSteps; step += 1) { + const goOn = // eslint-disable-next-line no-await-in-loop + await this.evaluateStep(proxy, delegator, memory, activeSegments, premisesBySegment, step); + if (!goOn) break; // for + } return facts; } - * evaluateStep(facts, delegator, memory, activeSegments, premisesBySegment, step) { + async evaluateStep(facts, delegator, memory, activeSegments, premisesBySegment, step) { this.logger.log({ type: 'debug', message: `evaluate step ${step}` }); // create agenda for premises const premisesAgenda = step === 0 ? this.premises : new Set(); @@ -102,7 +100,7 @@ class Rools { premises.add(premise); // might grow for "hidden" conditions }); memory[premise.id].value = premise.when(facts); // >>> evaluate premise! - } catch (error) { + } catch (error) { // ignore error! memory[premise.id].value = undefined; this.logger.log({ type: 'error', message: 'error in premise (when)', rule: premise.name, error, @@ -135,7 +133,7 @@ class Rools { const action = this.evaluateSelect(conflictSet); if (!action) { this.logger.log({ type: 'debug', message: 'evaluation complete' }); - return; // done + return false; // done } // fire action this.logger.log({ type: 'debug', message: 'fire action', rule: action.name }); @@ -146,11 +144,12 @@ class Rools { this.logger.log({ type: 'debug', message: `write "${segment}"`, rule: action.name }); activeSegments.add(segment); }); - action.then(facts); // >>> fire action! - } catch (error) { + await this.fire(action, facts); // >>> fire action! + } catch (error) { // re-throw error! this.logger.log({ type: 'error', message: 'error in action (then)', rule: action.name, error, }); + throw new Error(`error in action (then): ${action.name}`, error); } finally { delegator.unset(); } @@ -159,10 +158,10 @@ class Rools { this.logger.log({ type: 'debug', message: 'evaluation stop after final rule', rule: action.name, }); - return; // done + return false; // done } // next step - yield; // not yet done + return true; // not yet done } evaluateSelect(actions) { @@ -179,6 +178,15 @@ class Rools { this.logger.log({ type: 'debug', message: 'conflict resolution by priority' }); return actionsWithPrio[0]; } + + async fire(action, facts) { // eslint-disable-line class-methods-use-this + try { + const thenable = action.then(facts); // >>> fire action! + return thenable && thenable.then ? thenable : Promise.resolve(); + } catch (error) { + return Promise.reject(error); + } + } } module.exports = Rools; diff --git a/test/async.spec.js b/test/async.spec.js new file mode 100644 index 0000000..f7f6c32 --- /dev/null +++ b/test/async.spec.js @@ -0,0 +1,22 @@ +const Rools = require('../src'); +const { frank } = require('./facts/users')(); +const { rule1, rule2 } = require('./rules/availability'); +require('./setup'); + +describe('Rules.evaluate() / async', () => { + it('should call async action / action with async/await', async () => { + const rools = new Rools({ logging: { debug: true } }); + rools.register(rule1); + const result = await rools.evaluate({ user: frank }); + console.log(result); // eslint-disable-line no-console + expect(result.products).to.shallowDeepEqual(['dsl', 'm4g', 'm3g']); + }); + + it('should call async action / action with promises', async () => { + const rools = new Rools({ logging: { debug: true } }); + rools.register(rule2); + const result = await rools.evaluate({ user: frank }); + console.log(result); // eslint-disable-line no-console + expect(result.products).to.shallowDeepEqual(['dsl', 'm4g', 'm3g']); + }); +}); diff --git a/test/errors.spec.js b/test/errors.spec.js index 1644e72..9c02a6b 100644 --- a/test/errors.spec.js +++ b/test/errors.spec.js @@ -1,3 +1,4 @@ +const assert = require('assert'); const Rools = require('../src'); const { frank } = require('./facts/users')(); const { good } = require('./facts/weather')(); @@ -7,7 +8,7 @@ const { require('./setup'); describe('Rules.evaluate()', () => { - it('should not fail if `when` throws error', () => { + it('should not fail if `when` throws error', async () => { const brokenRule = { name: 'broken rule #1', when: facts => facts.bla.blub === 'blub', // TypeError: Cannot read property 'blub' of undefined @@ -16,12 +17,16 @@ describe('Rules.evaluate()', () => { const rools = new Rools({ logging: { error: false } }); const facts = { user: frank, weather: good }; rools.register(brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); - expect(() => rools.evaluate(facts)).to.not.throw(); + try { + await rools.evaluate(facts); + } catch (error) { + assert.fail(); + } expect(facts.user.mood).to.be.equal('great'); expect(facts.goWalking).to.be.equal(true); }); - it('should not fail if `then` throws error', () => { + it('should fail if `then` throws error', async () => { const brokenRule = { name: 'broken rule #2', when: () => true, // fire immediately @@ -32,8 +37,11 @@ describe('Rules.evaluate()', () => { const rools = new Rools({ logging: { error: false } }); const facts = { user: frank, weather: good }; rools.register(brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); - expect(() => rools.evaluate(facts)).to.not.throw(); - expect(facts.user.mood).to.be.equal('great'); - expect(facts.goWalking).to.be.equal(true); + try { + await rools.evaluate(facts); + assert.fail(); + } catch (error) { + // ignore + } }); }); diff --git a/test/facts/users.js b/test/facts/users.js index 072cfee..c7237c1 100644 --- a/test/facts/users.js +++ b/test/facts/users.js @@ -1,14 +1,26 @@ const frank = { name: 'frank', stars: 347, + dateOfBirth: new Date('1995-01-01'), + address: { + city: 'hamburg', + street: 'redderkoppel', + country: 'germany', + }, }; const michael = { name: 'michael', stars: 156, + dateOfBirth: new Date('1999-08-08'), + address: { + city: 'san Francisco', + street: 'willard', + country: 'usa', + }, }; module.exports = () => ({ - frank: { ...frank }, - michael: { ...michael }, + frank: JSON.parse(JSON.stringify(frank)), + michael: JSON.parse(JSON.stringify(michael)), }); diff --git a/test/facts/weather.js b/test/facts/weather.js index f1a2976..30bc0b6 100644 --- a/test/facts/weather.js +++ b/test/facts/weather.js @@ -1,16 +1,20 @@ const good = { temperature: 20, + humidity: 39, + pressure: 1319, windy: true, rainy: false, }; const bad = { temperature: 9, + humidity: 89, + pressure: 1013, windy: true, rainy: true, }; module.exports = () => ({ - good: { ...good }, - bad: { ...bad }, + good: JSON.parse(JSON.stringify(good)), + bad: JSON.parse(JSON.stringify(bad)), }); diff --git a/test/final.spec.js b/test/final.spec.js index 78a1a43..c2da2dc 100644 --- a/test/final.spec.js +++ b/test/final.spec.js @@ -19,8 +19,8 @@ describe('Rules.evaluate() / final rule', () => { ); }); - it('should terminate after final rule', () => { - const result = rools.evaluate({ user: frank, weather: good }); + it('should terminate after final rule', async () => { + const result = await rools.evaluate({ user: frank, weather: good }); expect(result.user.mood).to.be.equal('great'); expect(result.goWalking).to.be.equal(undefined); expect(result.stayAtHome).to.be.equal(undefined); diff --git a/test/logging.spec.js b/test/logging.spec.js index b6a6aa8..3753ecd 100644 --- a/test/logging.spec.js +++ b/test/logging.spec.js @@ -7,18 +7,18 @@ const { require('./setup'); describe('Rules.evaluate() / delegate logging', () => { - it('should log debug', () => { + it('should log debug', async () => { let counter = 0; const spy = () => { counter += 1; }; const rools = new Rools({ logging: { error: false, debug: true, delegate: spy } }); rools.register(ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); - rools.evaluate({ user: frank, weather: good }); + await rools.evaluate({ user: frank, weather: good }); expect(counter).to.not.be.equals(0); }); - it('should log errors', () => { + it('should log errors', async () => { const brokenRule = { name: 'broken rule #2', when: () => true, // fire immediately @@ -32,11 +32,15 @@ describe('Rules.evaluate() / delegate logging', () => { }; const rools = new Rools({ logging: { error: true, debug: false, delegate: spy } }); rools.register(brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); - rools.evaluate({ user: frank, weather: good }); + try { + await rools.evaluate({ user: frank, weather: good }); + } catch (error) { + // ignore + } expect(counter).to.not.be.equals(0); }); - it('should log errors by default', () => { + it('should log errors by default', async () => { const brokenRule = { name: 'broken rule #2', when: () => true, // fire immediately @@ -50,7 +54,11 @@ describe('Rules.evaluate() / delegate logging', () => { }; const rools = new Rools({ logging: { delegate: spy } }); rools.register(brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); - rools.evaluate({ user: frank, weather: good }); + try { + await rools.evaluate({ user: frank, weather: good }); + } catch (error) { + // ignore + } expect(counter).to.not.be.equals(0); }); }); @@ -66,14 +74,14 @@ describe('Rules.evaluate() / console logging', () => { console.error.restore(); // eslint-disable-line no-console }); - it('should log debug', () => { + it('should log debug', async () => { const rools = new Rools({ logging: { error: false, debug: true } }); rools.register(ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); - rools.evaluate({ user: frank, weather: good }); + await rools.evaluate({ user: frank, weather: good }); expect(console.log).to.be.called; // eslint-disable-line no-unused-expressions, no-console }); - it('should log errors', () => { + it('should log errors', async () => { const brokenRule = { name: 'broken rule #2', when: () => true, // fire immediately @@ -83,11 +91,15 @@ describe('Rules.evaluate() / console logging', () => { }; const rools = new Rools({ logging: { error: true, debug: false } }); rools.register(brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); - rools.evaluate({ user: frank, weather: good }); + try { + await rools.evaluate({ user: frank, weather: good }); + } catch (error) { + // ignore + } expect(console.error).to.be.called; // eslint-disable-line no-unused-expressions, no-console }); - it('should log errors by default', () => { + it('should log errors by default', async () => { const brokenRule = { name: 'broken rule #2', when: () => true, // fire immediately @@ -97,11 +109,15 @@ describe('Rules.evaluate() / console logging', () => { }; const rools = new Rools(); rools.register(brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); - rools.evaluate({ user: frank, weather: good }); + try { + await rools.evaluate({ user: frank, weather: good }); + } catch (error) { + // ignore + } expect(console.error).to.be.called; // eslint-disable-line no-unused-expressions, no-console }); - it('should log errors by default / 2', () => { + it('should log errors by default / 2', async () => { const brokenRule = { name: 'broken rule #2', when: () => true, // fire immediately @@ -111,7 +127,11 @@ describe('Rules.evaluate() / console logging', () => { }; const rools = new Rools({}); rools.register(brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); - rools.evaluate({ user: frank, weather: good }); + try { + await rools.evaluate({ user: frank, weather: good }); + } catch (error) { + // ignore + } expect(console.error).to.be.called; // eslint-disable-line no-unused-expressions, no-console }); }); diff --git a/test/longer.spec.js b/test/longer.spec.js index 731f4a5..2a6404d 100644 --- a/test/longer.spec.js +++ b/test/longer.spec.js @@ -79,12 +79,12 @@ describe('Rules.evaluate() / longer cycle', () => { rools.register(rule7, rule0, rule2, rule3, rule1, rule6, rule8, rule4, rule9, rule5); }); - it('should fire 10 rules', () => { + it('should fire 10 rules', async () => { const frank = { name: 'frank', stars: 0, }; - const result = rools.evaluate({ user: frank }); + const result = await rools.evaluate({ user: frank }); expect(result.user.stars).to.be.equal(10); }); }); diff --git a/test/rules/availability.js b/test/rules/availability.js new file mode 100644 index 0000000..9b5612e --- /dev/null +++ b/test/rules/availability.js @@ -0,0 +1,30 @@ +const availabilityCheck = (address) => { // eslint-disable-line arrow-body-style + return new Promise((resolve) => { + setTimeout(() => { + if (address.country === 'germany') { + if (address.city === 'hamburg') { + return resolve(['dsl', 'm4g', 'm3g']); + } + } + return resolve([]); + }, 100); + }); +}; + +const rule1 = { + name: 'check availability of products (async await)', + when: facts => facts.user.address.country === 'germany', + then: async (facts) => { + facts.products = await availabilityCheck(facts.user.address); + }, +}; + +const rule2 = { + name: 'check availability of products (promises)', + when: facts => facts.user.address.country === 'germany', + then: facts => + availabilityCheck(facts.user.address) + .then((result) => { facts.products = result; }), +}; + +module.exports = { rule1, rule2 }; diff --git a/test/setup.js b/test/setup.js index 7389f34..24030b6 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,9 +1,11 @@ const chai = require('chai'); const chaiAsPromised = require('chai-as-promised'); +const chaiShallowDeepEqual = require('chai-shallow-deep-equal'); const sinon = require('sinon'); const sinonChai = require('sinon-chai'); chai.use(chaiAsPromised); +chai.use(chaiShallowDeepEqual); chai.use(sinonChai); global.chai = chai; diff --git a/test/simple.spec.js b/test/simple.spec.js index bd9157b..353d50d 100644 --- a/test/simple.spec.js +++ b/test/simple.spec.js @@ -14,24 +14,24 @@ describe('Rules.evaluate() / simple scenarios', () => { rools.register(ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); }); - it('Test 1', () => { - const result = rools.evaluate({ user: frank, weather: good }); + it('Test 1', async () => { + const result = await rools.evaluate({ user: frank, weather: good }); // console.log(result); // eslint-disable-line no-console expect(result.user.mood).to.be.equal('great'); expect(result.goWalking).to.be.equal(true); expect(result.stayAtHome).to.be.equal(undefined); }); - it('Test 2', () => { - const result = rools.evaluate({ user: michael, weather: good }); + it('Test 2', async () => { + const result = await rools.evaluate({ user: michael, weather: good }); // console.log(result); // eslint-disable-line no-console expect(result.user.mood).to.be.equal('sad'); expect(result.goWalking).to.be.equal(undefined); expect(result.stayAtHome).to.be.equal(true); }); - it('Test 3', () => { - const result = rools.evaluate({ user: frank, weather: bad }); + it('Test 3', async () => { + const result = await rools.evaluate({ user: frank, weather: bad }); // console.log(result); // eslint-disable-line no-console expect(result.user.mood).to.be.equal('great'); expect(result.goWalking).to.be.equal(undefined);