Class Rufus::Decision::Table

  1. lib/rufus/decision.rb
Parent: Object

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

Methods

public class

  1. new

public instance

  1. run
  2. to_csv
  3. transform
  4. transform!

Classes and Modules

Class Rufus::Decision::Table::Header

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

new (csv, params={})

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.

[show source]
     # 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

run (hash, options={})

Alias for transform

to_csv ()

Outputs back this table as a CSV String

[show source]
     # 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
transform (hash, options={})

Like transform, but the original hash doesn’t get touched, a copy of it gets transformed and finally returned.

[show source]
     # File lib/rufus/decision.rb, line 279
279:     def transform (hash, options={})
280: 
281:       transform!(hash.dup)
282:     end
transform! (hash, options={})

Passes the hash through the decision table and returns it, transformed.

[show source]
     # 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