A decision table is a description of a set of rules as a CSV (comma separated values) file. Such a file can be edited / generated by a spreadsheet (Excel, Google spreadsheets, Gnumeric, …)
Disclaimer
The decision / CSV table system is no replacement for full rule engines with forward and backward chaining, RETE implementation and the like...
Usage
The following CSV file
in:topic,in:region,out:team_member sports,europe,Alice sports,,Bob finance,america,Charly finance,europe,Donald finance,,Ernest politics,asia,Fujio politics,america,Gilbert politics,,Henry ,,Zach
embodies a rule for distributing items (piece of news) labelled with a topic and a region to various members of a team. For example, all news about finance from Europe are to be routed to Donald.
Evaluation occurs row by row. The “in out” row states which field is considered at input and which are to be modified if the “ins” do match.
The default behaviour is to change the value of the “outs” if all the “ins” match and then terminate. An empty “in” cell means “matches any”.
Enough words, some code :
require 'rufus/decision'
table = Rufus::Decision::Table.new(%{
in:topic,in:region,out:team_member
sports,europe,Alice
sports,,Bob
finance,america,Charly
finance,europe,Donald
finance,,Ernest
politics,asia,Fujio
politics,america,Gilbert
politics,,Henry
,,Zach
})
h = {}
h["topic"] = "politics"
table.transform!(h)
puts h["team_member"]
# will yield "Henry" who takes care of all the politics stuff,
# except for Asia and America
’>’, ’>=’, ’<’ and ’<=’ can be put in front of individual cell values :
table = Rufus::Decision::Table.new(%{
,
in:fx, out:fy
,
>100,a
>=10,b
,c
})
h = { 'fx' => '10' }
h = table.transform(h)
p h # => { 'fx' => '10', 'fy' => 'b' }
Such comparisons are done after the elements are transformed to float numbers. By default, non-numeric arguments will get compared as Strings.
transform and transform!
The method transform! acts directly on its parameter hash, the method transform will act on a copy of it. Both methods return their transformed hash.
[ruby] ranges
Ruby-like ranges are also accepted in cells.
in:f0,out:result , 0..32,low 33..66,medium 67..100,high
will set the field ‘result’ to ‘low’ for f0 => 24
Options
You can put options on their own in a cell BEFORE the line containing “in:xxx” and “out:yyy” (ins and outs).
Three options are supported, “ignorecase”, “through” and “accumulate”.
- “ignorecase”, if found by the decision table will make any match (in the “in” columns) case unsensitive.
- “through”, will make sure that EVERY row is evaluated and potentially applied. The default behaviour (without “through”), is to stop the evaluation after applying the results of the first matching row.
- “accumulate”, behaves as with “through” set but instead of overriding values each time a match is found, will gather them in an array.
an example of ‘accumulate’
accumulate
in:f0,out:result
,
,normal
>10,large
>100,xl
will yield { result => [ 'normal', 'large' ]} for f0 => 56
Setting options at table initialization
It’s OK to set the options at initialization time :
table = Rufus::Decision::Table.new( csv, :ruby_eval => true, :accumulate => true)
Cross references
By using the ‘dollar notation’, it’s possible to reference a value already in the hash (that is, the hash undergoing ‘transformation’).
in:value,in:roundup,out:newvalue
0..32,true,32
33..65,true,65
66..99,true,99
,,${value}
Here, if ‘roundup’ is set to true, newvalue will hold 32, 65 or 99 as value, else it will simply hold the ‘value’.
The value is the value as currently found in the transformed hash, not as found in the original (non-transformed) hash.
Ruby code evaluation
The dollar notation can be used for yet another trick, evaluation of ruby code at transform time.
Note though that this feature is only enabled via the :ruby_eval option of the transform!() method.
decisionTable.transform!(h, :ruby_eval => true)
That decision table may look like :
in:value,in:result
0..32,${r:Time.now.to_f}
33..65,${r:call_that_other_function()}
66..99,${r:${value} * 3}
(It’s a very simplistic example, but I hope it demonstrates the capabilities of this technique)
It’s OK to set the :ruby_eval parameter when initializing the decision table :
table = Rufus::Decision::Table.new(csv, :ruby_eval => true)
so that there is no need to specify it at transform() call time.
See also
Constants
| IN | = | /^in:/ | ||
| OUT | = | /^out:/ | ||
| IN_OR_OUT | = | /^(in|out):/ | ||
| NUMERIC_COMPARISON | = | /^([><]=?)(.*)$/ | ||
| RUBY_NUMERIC_RANGE_REGEXP | = | Regexp.compile( "^\\d+(\\.\\d+)?\\.{2,3}\\d+(\\.\\d+)?$") | A regexp for checking if a string is a numeric Ruby range | |
| RUBY_ALPHA_RANGE_REGEXP | = | Regexp.compile( "^([A-Za-z])(\\.{2,3})([A-Za-z])$") | A regexp for checking if a string is an alpha Ruby range |
Attributes
| accumulate | [RW] | when set to true, multiple matches result get accumulated in an array. |
| first_match | [RW] | when set to true, the transformation process stops after the first match got applied. |
| ignore_case | [RW] | when set to true, matches evaluation ignores case. |
Public class methods
The constructor for DecisionTable, you can pass a String, an Array (of arrays), a File object. The CSV parser coming with Ruby will take care of it and a DecisionTable instance will be built.
parameters (options) are :through, :ignore_case, :accumulate (which forces :through to true when set) and :ruby_eval. See Rufus::Decision::Table for more details.
# File lib/rufus/decision.rb, line 261 261: def initialize (csv, params={}) 262: 263: @first_match = (params[:through] != true) 264: @ignore_case = params[:ignore_case] || params[:ignorecase] 265: @accumulate = params[:accumulate] 266: @ruby_eval = params[:ruby_eval] 267: 268: @first_match = false if @accumulate 269: 270: @rows = Rufus::Decision.csv_to_a(csv) 271: 272: extract_options 273: parse_header_row 274: end
Public instance methods
Outputs back this table as a CSV String
# File lib/rufus/decision.rb, line 305 305: def to_csv 306: 307: a = [ @header.to_csv ] 308: @rows.inject(a) { |a, row| a << row.join(',') }.join("\n") 309: end
Like transform, but the original hash doesn’t get touched, a copy of it gets transformed and finally returned.
# File lib/rufus/decision.rb, line 279 279: def transform (hash, options={}) 280: 281: transform!(hash.dup) 282: end
Passes the hash through the decision table and returns it, transformed.
# File lib/rufus/decision.rb, line 287 287: def transform! (hash, options={}) 288: 289: hash = Rufus::Decision::EvalHashFilter.new(hash) \ 290: if @ruby_eval || options[:ruby_eval] == true 291: 292: @rows.each do |row| 293: next unless matches?(row, hash) 294: apply(row, hash) 295: break if @first_match 296: end 297: 298: hash.is_a?(Rufus::Decision::HashFilter) ? hash.parent_hash : hash 299: end