diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4d81e7d --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +# /node_modules/* and /bower_components/* ignored by default + +# Ignore built files except build/index.js +coverage/* +build/* +builds/* +bundle.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..24e8d90 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,11 @@ +{ + "extends": [ + "airbnb-base" + ], + "plugins": [ + "promise" + ], + "rules": { + "no-param-reassign": 0 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89d5d57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# directories +.idea/* +.vscode/* +local + +# generated files +.DS_Store +.vscode +Desktop.ini +Thumbs.db + +# specific file types +*.backup +*.bak +*.log +*.log.* +*.tmp +*.backup + +# node specific +node_modules +coverage +*/config/local.js +package-lock.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc377ab --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# rools diff --git a/package.json b/package.json new file mode 100644 index 0000000..f21e1ba --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "rools", + "version": "1.0.0-alpha.0", + "description": "A small business rules engine for Node", + "main": "src/index.js", + "author": "Frank Thelen", + "license": "MIT", + "repository": { + "type": "git", + "url": "git://github.com/frankthelen/rools" + }, + "keywords": [ + "rules", + "business", + "rule", + "engine" + ], + "scripts": { + "lint": "eslint . --ignore-path ./.eslintignore", + "test": "NODE_ENV=test mocha --recursive test", + "cover": "NODE_ENV=test istanbul cover _mocha -- --recursive test", + "coveralls": "npm run cover -- --report lcovonly && cat ./coverage/lcov.info | coveralls", + "preversion": "npm run lint && npm test" + }, + "engines": { + "node": ">=8.9.0" + }, + "devDependencies": { + "chai": "^4.1.2", + "chai-as-promised": "^7.1.1", + "coveralls": "^3.0.0", + "eslint": "^4.14.0", + "eslint-config-airbnb-base": "^12.1.0", + "eslint-plugin-import": "^2.8.0", + "eslint-plugin-promise": "^3.6.0", + "eslint-plugin-should-promised": "^2.0.0", + "istanbul": "^0.4.5", + "mocha": "^4.0.1", + "mocha-lcov-reporter": "^1.3.0", + "sinon": "^4.1.3", + "sinon-chai": "^2.14.0" + }, + "dependencies": { + "md5": "^2.2.1", + "uuid": "^3.1.0" + } +} diff --git a/src/Rools.js b/src/Rools.js new file mode 100644 index 0000000..9004fd4 --- /dev/null +++ b/src/Rools.js @@ -0,0 +1,62 @@ +class Rools { + constructor() { + this.actions = []; + this.premises = []; + } + + register(...rules) { + rules.forEach((rule) => { + const action = { + name: rule.name, + then: rule.then, + premises: [], + ready: false, // ready to fire + fired: false, // not yet fired + }; + this.actions.push(action); + const whens = Array.isArray(rule.when) ? rule.when : [rule.when]; + whens.forEach((when) => { + const premise = { + when, + value: undefined, // not yet evaluated + }; + action.premises.push(premise); + this.premises.push(premise); + }); + }); + } + + execute(facts) { + this.actions.forEach((action) => { // init + action.ready = false; + action.fired = false; + }); + let iteration = 0; + do { + this.premises.forEach((premise) => { + premise.value = premise.when(facts); + }); + this.actions.filter(action => !action.fired).forEach((action) => { + const num = action.premises.length; + const tru = action.premises.filter(premise => premise.value).length; + action.ready = tru === num; + }); + const actionsToBeFired = this.actions.filter(action => !action.fired && action.ready); + if (actionsToBeFired.length === 0) { + break; // for --> all done + } + if (actionsToBeFired.length > 1) { + console.error('conflict resolution missing!'); + } + actionsToBeFired.forEach((action) => { + console.log(`firing ${action.name}`); + action.fired = true; + action.then(facts); + }); + iteration += 1; + } while (iteration < 100); + return facts; + } +} + +module.exports = Rools; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e76a5e0 --- /dev/null +++ b/src/index.js @@ -0,0 +1,3 @@ +const Rools = require('./Rools'); + +module.exports = Rools; diff --git a/test/.eslintrc b/test/.eslintrc new file mode 100644 index 0000000..a27b0df --- /dev/null +++ b/test/.eslintrc @@ -0,0 +1,15 @@ +{ + "extends": "../.eslintrc", + "env": { + "mocha": true + }, + "globals" : { + "assert": false, + "expect": false, + "should": false, + "sinon": false + }, + "plugins": [ + "should-promised" + ] +} diff --git a/test/simple.spec.js b/test/simple.spec.js new file mode 100644 index 0000000..cda9972 --- /dev/null +++ b/test/simple.spec.js @@ -0,0 +1,106 @@ +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); +const sinon = require('sinon'); +const sinonChai = require('sinon-chai'); +const Rools = require('../src'); + +chai.use(chaiAsPromised); +chai.use(sinonChai); + +global.chai = chai; +global.sinon = sinon; +global.expect = chai.expect; +global.should = chai.should(); + +describe('Simple tests', () => { + const frank = { + name: 'frank', + stars: 347, + }; + + const michael = { + name: 'michael', + stars: 156, + }; + + const weatherGood = { + temperature: 20, + windy: true, + rainy: false, + }; + + const weatherBad = { + temperature: 9, + windy: true, + rainy: true, + }; + + const ruleMoodGreat = { + name: 'mood is great if stars is greater than 200', + when: facts => facts.user.stars >= 200, + then: (facts) => { + facts.user.mood = 'great'; + }, + }; + + const ruleMoodSad = { + name: 'mood is sad if stars is lower or equals 200', + when: facts => facts.user.stars < 200, + then: (facts) => { + facts.user.mood = 'sad'; + }, + }; + + const ruleGoWalking = { + name: 'go for a walk if mood is great and the weather is fine', + when: [ + facts => facts.user.mood === 'great', + facts => facts.weather.temperature >= 20, + facts => !facts.weather.rainy, + ], + then: (facts) => { + facts.goWalking = true; + }, + }; + + const ruleStayAtHome = { + name: 'stay at home if mood is sad or the weather is bad', + when: [ + facts => facts.weather.rainy || facts.user.mood === 'sad', + ], + then: (facts) => { + facts.stayAtHome = true; + }, + }; + + let rools; + + before(() => { + rools = new Rools(); + rools.register(ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome); + }); + + after(() => { + }); + + it('test 1', () => { + const result = rools.execute({ user: frank, weather: weatherGood }); + console.log(result); + expect(result.user.mood).to.be.equal('great'); + expect(result.goWalking).to.be.equal(true); + }); + + it('test 2', () => { + const result = rools.execute({ user: michael, weather: weatherGood }); + console.log(result); + expect(result.user.mood).to.be.equal('sad'); + expect(result.stayAtHome).to.be.equal(true); + }); + + it('test 3', () => { + const result = rools.execute({ user: frank, weather: weatherBad }); + console.log(result); + expect(result.user.mood).to.be.equal('great'); + expect(result.stayAtHome).to.be.equal(true); + }); +});