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::MigrationWe 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.
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
File: app/views/logic/new.rhtml
<%= start_form_tag %>File: app/views/logic/edit.rhtml
Bonus Logic
<%= text_area 'logic', 'script', 'style'=>'width:90%' %>
For
<%= text_field 'logic', 'employee' %>
<%= submit_tag 'Save' %>
<%= end_form_tag %>
<%= 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::BaseFile: app/models/logic.rb
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
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 BonusCalculationContextAt 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).
include Reloadable
extend Verbosity
bubbles :than, :is, :profit, :the, :to, :of, :bonus
numerics :thousand=>1000, :million=>1000000,
:>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
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| -%>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.
<%= "#{bonus.who} bonus: #{number_to_currency(bonus.amount.to_f*0.01)}" %>
<% end -%>
File: app/models/sql_calculation_context.rb
class SqlCalculationContextThe SqlCalculationContext makes use of two new methods, append and return_self, that were added to the Verbosity module.
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)',
:>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
File: app/models/verbosity.rb
module VerbosityAfter these additions you can use the 'execute all' link on the list page to view the results. As expected, the correct results are displayed:
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
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)