Monday, July 31, 2006

BNL 05 - Multiple Contexts

01 - Introduction
02 - The Problem
03 - The Solution
04 - DAMP BNLs
05 - Multiple Contexts

Multiple Contexts

In 'The Solution' we proved our concept by replacing the existing system with one that allows the business logic to be written by the subject matter experts. However, the solution we provided has a few limitations our client would like us to overcome:
It can only be run from the command line, they would like a web interface
It only executes bonus calculations for the entire group; they would like to execute individual bonuses
It does not scale well; they would like the burden of calculation to be transferred to their database server.

The first thing we are going to do to accommodate these requirements is create a table to store the bonus logic.
class AddBonusLogic < ActiveRecord::Migration
def self.up
create_table :logic do |table|
table.column :script, :text
table.column :employee, :string
end
end

def self.down
drop_table :logic
end
end
We are also going to need a few views, a model, and a controller to allow the business users to add new bonus logic and to edit existing bonus logic.

File: app/views/logic/new.rhtml
<%= start_form_tag %>
Bonus Logic

<%= text_area 'logic', 'script', 'style'=>'width:90%' %>


For

<%= text_field 'logic', 'employee' %>
<%= submit_tag 'Save' %>
<%= end_form_tag %>
File: app/views/logic/edit.rhtml

<%= start_form_tag %>
Bonus Logic

<%= text_area 'logic', 'script', 'style'=>'width:90%' %>


For

<%= text_field 'logic', 'employee' %>
<%= submit_tag 'Save' %>
<%= end_form_tag %>


File: app/views/logic/list.rhtml
Bonuses: - <%= link_to 'new', :action=>:new %> <%= link_to 'execute all', :action=>:execute_all %>

<% @all_logic.each do |logic| %>
<%= logic.employee %>
<%= link_to 'edit', :action=>:edit, :id=>logic.id %>
<%= link_to 'execute', :action=>:execute, :id=>logic.id %>


<%= logic.script %>

<% end -%>


File: app/controllers/logic_controller.rb
class LogicController < ActionController::Base
def new
if request.get?
@logic = Logic.new
else
Logic.create(params[:logic])
redirect_to :action=>:list
end
end

def list
@all_logic = Logic.find :all
end

def edit
if request.get?
@logic = Logic.find params[:id]
else
Logic.find(params[:id]).update_attributes(params[:logic])
redirect_to :action=>:list
end
end

def execute
@bonus = BonusCalculationContext.evaluate(Logic.find(params[:id]))
end

def execute_all
@bonuses = []
Logic.find(:all).each do |logic|
@bonuses << SqlCalculationContext.evaluate(logic)
end
@bonuses
end
end
File: app/models/logic.rb
class Logic < ActiveRecord::Base
end


Next we need to save all the bonus logic to the database. We can copy and paste the logic from the bonus_logic folder into the web interface.

When you are done pasting the list page should show the following logic:

Jackie Johnson
apply bonus of the total profit times one percent if the current month is equal to february
apply bonus of the drug profit times two percent if the current month is equal to february


Joe Noone
apply bonus of ten thousand dollars if the total profit is less than one million dollars and the current month is equal to march
apply bonus of ten thousand dollars if the total profit is equal to one million dollars and the current month is equal to march
apply bonus of the total profit times one percent if the total profit is greater than to one million dollars and the current month is equal to march


John Jones
apply bonus of ten thousand dollars if the total profit is greater than one million dollars and the current month is equal to january
apply bonus of ten thousand dollars if the total profit is greater than two million dollars and the current month is equal to january
apply bonus of ten thousand dollars if the total profit is greater than three million dollars and the current month is equal to january
apply bonus of the toothbrush profit times five percent if the current month is equal to january

The list page also has an execute link that links to a page that doesn't yet exist. The execute.rhtml file is a simple page that shows the employee's name and their calculated bonus:

File: app/views/logic/execute.rhtml
<%= "#{@bonus.who} bonus: #{number_to_currency(@bonus.amount.to_f*0.01)}" %>
A brief look in the logic_controller reveals that the majority of the work in the execute method is being done in the BonusCalculationContext class. The BonusCalculationContext class has slightly changed to allow you to pass a Logic instance, instead of a path, to evaluate. Other than the change in how the employee name and bonus logic are stored the class is basically the same:

File: app/models/bonus_calculation_context.rb
class BonusCalculationContext
include Reloadable
extend Verbosity
bubbles :than, :is, :profit, :the, :to, :of, :bonus
numerics :thousand=>1000, :million=>1000000,
:one=>1, :two=>2, :three=>3, :five=>5, :ten=>10
operations :greater=>">", :equal=>"==", :times=>"*", :less=>"<"
constants :dollars=>100, :percent=>0.01, :january=>7, :february=>7, :march=>7
attr_accessor :employee_name

def initialize
@bonus_amount = 0
end

def self.evaluate(logic)
context = self.new
context.employee_name = logic.employee
logic.script.split(/\n/).each { |spec| context.instance_eval(spec) }
context.resulting_bonus
end

def last_profit
@last_year_profit ||= Profit.find :first
end

def total(arg)
result = eval "#{last_profit.total} #{arg}"
return result.round if result.respond_to? :round
result
end

def month(arg)
".month #{arg}"
end

def current(arg)
eval "Time.now#{arg}"
end

def toothbrush(arg)
eval("#{last_profit.toothbrush_in_cents} #{arg}").round
end

def drug(arg)
eval("#{last_profit.drug_in_cents} #{arg}").round
end

def apply(amount)
@bonus_amount += amount
end

def resulting_bonus
Bonus.new(@employee_name, @bonus_amount)
end
end
At this point we have fulfilled the first two of the new requirements. A quick check shows that the logic is still producing the correct results for each employee's bonus (and now the results are formatted nicely thanks to the rails helper method number_to_currency).

Jackie Johnson bonus: $135,517.02
John Jones bonus: $92,525.00
Joe Noone bonus: $53,509.01

To accomidate the last requirement we are going to execute our bonus logic in another context. One advantage to expressing your business rules in a Domain Specific Language is the ability to execute them in various contexts. By executing the DSL in various contexts you can generate multiple behaviors from the same business logic. When the rule changes over time, all parts of the system that reference the rule will also be changed.

The execute_all.rhtml file displays the results of executing the bonus logic for all employees.

File: app/views/logic/execute_all.rhtml
<% @bonuses.each do |bonus| -%>
<%= "#{bonus.who} bonus: #{number_to_currency(bonus.amount.to_f*0.01)}" %>

<% end -%>
The execute_all.rhtml view uses the execute_all method of the (previously shown) LogicController. The execute_all method uses SqlCalculationContext.evaluate to delegate the calculations to the database server. For the example I'm using Postgres 8.1.4.

File: app/models/sql_calculation_context.rb
class SqlCalculationContext
extend Verbosity
append :sql_string,
:apply=>'select',
:total=>'(select toothbrush_in_cents + drug_in_cents from profits)',
:drug=>'(select drug_in_cents from profits)',
:toothbrush=>'(select toothbrush_in_cents from profits)',
:one=>1, :two=>2, :three=>3, :five=>5, :ten=>10,
:percent=>'* .01', :thousand=> '* 100000', :million=>'* 100000000',
:if=>'where', :and=>'and',
:month=>1,
:january=>1, :february=>1, :march=>1,
:times=>'*', :equal=>'=', :greater=>'>', :less=>'<'
return_self :bonus, :of, :the, :profit, :current, :is, :to, :dollars, :than

attr_accessor :employee_name, :bonus_amount

def self.evaluate(logic)
context = self.new
context.employee_name = logic.employee
sql_statements = []
logic.script.split(/\n/).each do |spec|
sql_statements << context.instance_eval(spec.gsub(' ','.')).sql_string
context.clear_sql_string!
end
sql_statements = sql_statements.collect { |sql| "coalesce((#{sql}),0)"}
complete_sql = "select " + sql_statements.join("+")
context.bonus_amount = execute(complete_sql).result.flatten[0].to_f.round
context.resulting_bonus
end

def execute(sql)
ActiveRecord::Base.connection.execute(sql)
end

def resulting_bonus
Bonus.new(employee_name, bonus_amount)
end
end
The SqlCalculationContext makes use of two new methods, append and return_self, that were added to the Verbosity module.

File: app/models/verbosity.rb
module Verbosity

def bubbles(*methods)
methods.each do |method|
define_method(method) { |args| args }
end
end

def numerics(hash)
hash.each_pair do |name, multiplier|
define_method(name) { |args| multiplier * args }
end
end

def operations(hash)
hash.each_pair do |name, operator|
define_method(name) { |args| "#{operator} #{args}" }
end
end

def constants(hash)
hash.each_pair do |name, value|
define_method(name) { |args| value }
end
end

def append(var, hash)
eval "define_method(:#{var}) { @#{var} ||= String.new }"
eval "define_method(:clear_#{var}!) { @#{var} = String.new }"
hash.each_pair do |name, value|
eval "define_method(:#{name}) { @#{var} = #{var} + '#{value} '; self }"
end
end

def return_self(*methods)
methods.each do |method|
define_method(method) { self }
end
end

end
After these additions you can use the 'execute all' link on the list page to view the results. As expected, the correct results are displayed:

Jackie Johnson bonus: $135,517.02
John Jones bonus: $92,525.00
Joe Noone bonus: $53,509.01

This doesn't appear very impressive since we already knew how to calculate the results. However, the interesting thing is that instead of calculating the results using ruby, the results are calculated from generated sql statements. The sql that executes the results for each employee is generated by the SqlCalculationContext.
select coalesce((select (select toothbrush_in_cents + drug_in_cents from profits) * 1 * .01 where 1 = 1 ),0)+coalesce((select (select drug_in_cents from profits) * 2 * .01 where 1 = 1 ),0)
Post a Comment