Rulebox
A Simple & Intuitive Natural Langue Rules Abstraction for ColdBox Applications
Install / Use
/learn @coldbox-modules/RuleboxREADME
RuleBox: A Rule Engine For ColdBox Applications
RuleBox is a modern intuitive and natural language rule engine based upon the great work of RuleBook: https://github.com/rulebook-rules/rulebook ported over to ColdFusion (CFML).
Tired of classes filled with if/then/else statements? Need a nice abstraction that allows rules to be easily specified in a way that decouples them from each other? Want to write rules the same way that you write the rest of your code [in ColdFusion]? RuleBox is right for you!
RuleBox allows you to write rules in an expressive and dynamic Domain Specific Language modeled closely after the Given-When-Then (https://martinfowler.com/bliki/GivenWhenThen.html) methodology.
Requirements
- Lucee 5+
- Adobe ColdFusion 2016+
Installation
Just leverage CommandBox: box install rulebox and it will install as a module in your ColdBox application.
Usage
The module will register the following objects in WireBox:
Rule@rulebox- A transient ruleRuleBook@rulebox- A transient rule book objectBuilder@rulebox- A static class that can be used to build a-la-carte rules and rule books.Result@rulebox- RuleBooks produce results and this is the object that models such results.
Defining Rules
The preferred approach is for you to create your own RuleBook that extends: rulebox.models.RuleBook with a method called defineRules(). In this method you will define all the rules that apply to that specific RuleBook using our DSL. There is nothing stopping you from creating rulebooks on the fly, which can allow you to create dynamic or a-la-carte rules if needed.
RuleBooks should be transient objects as they are reused when binded with facts.
A HelloWorld Example
component extends="rulebox.models.RuleBook"{
function defineRules(){
// Add a new rule to this rulebook
addRule(
newRule( "MyRule" )
.then( function( facts ){
systemOutput( "Hello " );
} )
.then( function( facts ){
systemOutput( "World" );
} )
);
}
}
As you can see from above, new rules are created by calling the newRule() method with an optional name that you can use to identify the rule you register. You can also define rules as a closure/lambda with slightly different syntax:
component extends="rulebox.models.RuleBook"{
// Closures
function defineRules(){
addRule( function( rule ){
rule
.setName( "MyRule" )
.then( function( facts ){
systemOutput( "Hello " );
} )
.then( function( facts ){
systemOutput( "World" );
} );
} );
}
}
// Lambdas: Lucee 5+ ONLY
function defineRules(){
addRule( ( rule ) => {
rule
.setName( "MyRule" )
.then( ( facts ) => systemOutput( "Hello " ) )
.then( ( facts ) => systemOutput( "World " ) )
} );
}
}
The RuleBook also has a
nameproperty, so you can attach a human readable name to the RuleBook viasetName( name )method.
...or use 2 rules
component extends="rulebox.models.RuleBook"{
function defineRules(){
.addRule(
newRule()
.then( function(){
systemOutput( "Hello " );
} )
)
.addRule(
newRule()
.then( function(){
systemOutput( "World " );
} )
)
}
}
now, run it!
getInstance("HelloWorld").run();
If you are in Lucee 5+, you can also leverage lambdas, which can provide a nicer syntax for declaring rules:
component extends="rulebox.models.RuleBook"{
function defineRules(){
.addRule( (rule) => rule.then( () => systemOutput( "Hello " ) ) )
.addRule( (rule) => rule.then( () => systemOutput( "World " ) ) )
}
}
Like mentioned before, I can also create a-la-carte rules and a rulebook by leveraging the Builder:
builder = getInstance( "Builder@rulebox" );
builder
.create( "My RuleBook" );
.addRule(
builder.rule()
.then( function( facts ){
systemOutput( "Hello " );
} )
)
.addRule(
builder.rule()
.then( function( facts ){
systemOutput( "World " );
} )
)
.run();
The Above Example Using Facts
component extends="rulebox.models.RuleBook"{
function defineRules(){
addRule(
newRule()
.when( function( facts ){
return facts.keyExists( "hello" );
})
.then( function( facts ){
systemOutput( facts.hello );
} )
)
.addRule(
newRule()
.when( function( facts ){
return facts.keyExists( "world" );
})
.then( function( facts ){
systemOutput( facts.world );
} )
);
}
}
..or it could be a single rule
component extends="rulebox.models.RuleBook"{
function defineRules(){
addRule(
newRule()
.when( function(){
return facts.keyExists( "hello" ) && facts.keyExists( "world" );
})
using( "hello" ).then( function(){
systemOutput( facts.hello );
} );
using( "world" ).then( function(){
systemOutput( facts.world );
} );
);
}
}
now, run it!
getInstance( "MyRuleBook" )
.run( {
"hello" : "Hello ",
"world" : " World"
} );
# or using the givenAll() method
getInstance( "MyRuleBook" )
.givenAll( {
"hello" : "Hello ",
"world" : " World"
} )
.run();
A More Complex Scenario
The Requirements:
MegaBank issues home loans. If an applicant's credit score is less than 600 then they must pay 4x the current rate. If an applicant’s credit score is between 600, but less than 700, then they must pay a an additional point on top of their rate. If an applicant’s credit score is at least 700 and they have at least $25,000 cash on hand, then they get a quarter point reduction on their rate. If an applicant is a first time home buyer then they get a 20% reduction on their calculated rate after adjustments are made based on credit score (note: first time home buyer discount is only available for applicants with a 600 credit score or greater).
Given those set of requirements we will create the rules, but this time we will also track results using a RuleBox Result object. The Result object is passed to the then() methods and it has a very simple API for dealing with results. Please note that the same instance of that Result object is passed from rule to rule, so you can work on the result. Much how map, reduce functions work. The Result object can also be pre-set with a default value by leveraging the withDefaultValue() method in the RuleBook object. If not, the default value would be null.
Basic Result methods are:
setValue()- Set the value in the resultgetValue()- Get the valueisPresent()- Has the value been set or is itnull
Applicant.cfc
component accessors="true"{
property creditScore;
property cashOnHand;
property firstTimeHomeBuyer;
function init( creditScore, cashOnHand, firstTimeHomeBuyer ){
variables.creditScore = arguments.creditScore;
variables.cashOnHand = arguments.cashOnHand;
variables.firstTimeHomeBuyer = arguments.firstTimeHomeBuyer;
return this;
}
}
This Applicant.cfc tracks our home loan applicants, now let's build the rules for this home loan.
HomeLoanRateRules.cfc
/**
* This rule book determines rules for a home loan rate
*/
component extends="rulebox.models.RuleBook"{
function defineRules(){
//credit score under 600 gets a 4x rate increase
addRule(
newRule()
.when( function( facts ){ return facts.applicant.getCreditScore() < 600; } )
.then( function( facts, result ){ result.setValue( result.getValue() * 4 ); } )
.stop()
);
//credit score between 600 and 700 pays a 1 point increase
addRule(
newRule()
.when( function( facts ){ return facts.applicant.getCreditScore() < 700; } )
.then( function( facts, result ){ result.setValue( result.getValue() + 1 ); } )
);
//credit score is 700 and they have at least $25,000 cash on hand
addRule(
newRule()
.when( function( facts ){
return ( facts.applicant.getCreditScore() >= 700 && facts.applicant.getCashOnHand() >= 25000 );
} )
.then( function( facts, result ){ result.setValue( result.getValue() - 0.25 ); } )
);
// first time homebuyers get 20% off their rate (except if they have a creditScore < 600)
addRule(
newRule()
.when( function( facts ){ return facts.applicant.getFirstTimeHomeBuyer(); } )
.then( function( facts, result ){ result.setValue( result.getValue() * 0.80 ); } )
);
}
}
Now that we have built the rules and applicant, let's run them with a few example applicants. Remember, you would run these from a handler or another service method. Below I am running them from a BDD test:
describe("Home Loan Rate Rules", function () {
it("Can calculate a first time home buyer with 20,000 down and 650 credit score", function () {
var homeLoans = getInstance("tests.resources.HomeLoanRateRuleBook")
.withDefaultResult(4.5)
.given(
"applicant",
new tests.resources.Applicant(650, 20000, true)
);
homeLoans.run();
expect(homeLoans.getResult().isPresent()).toBeTrue();
expect(homeLoans.getResult().getValue()).toBe(4.4);
});
it("Can calculate a non first home buyer with 20,000 down and 650 credit score", function () {
var homeLoans = getInstance("tests.resources.HomeLoanRateRuleBook")
.withDefaultResult(4.5)
.given(
"applicant",
new tests.resources.Applicant(650, 20000, false)
);
homeLoans.run();
expect(homeLoans.getResult().isPresent()).toBeTrue();
expect(homeLoans.getResult().getValue()).toBe(5.5);
});
});
Let's even take this further and just use facts instead of the Applicant.cfc
/**
* This rule book determines rules for a home loan rate using facts
*/
component extends="rulebox.models.RuleBook"{
function defineRules(){
//credit score under 600 gets a 4x rate increase
addRule(
newRule()
.when( function( facts ){ return facts[ "creditScore" ] < 600; } )
.then( function( facts, result ){ result.setValue( result.getValue() * 4 ); } ) .stop()
);
//credit sc
