Class: Rufus::Decision::Table

Inherits:
Object
  • Object
show all
Defined in:
lib/rufus/decision/table.rb

Overview

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
  • “unbounded”, by default, string matching is ‘bounded’, “apple” will match ‘apple’, but not ‘greenapple’. When “unbounded” is set, ‘greenapple’ will match. (‘bounded’, in reality, means the target value is surrounded by ^ and $)

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

Direct Known Subclasses

Table

Constant Summary

IN =
/^in:/
OUT =
/^out:/
IN_OR_OUT =
/^(in|out):/
NUMERIC_COMPARISON =
/^([><]=?)(.*)$/
RUBY_NUMERIC_RANGE_REGEXP = A regexp for checking if a string is a numeric Ruby range.
Regexp.compile(
"^\\d+(\\.\\d+)?\\.{2,3}\\d+(\\.\\d+)?$")
RUBY_ALPHA_RANGE_REGEXP = A regexp for checking if a string is an alpha Ruby range.
Regexp.compile(
"^([A-Za-z])(\\.{2,3})([A-Za-z])$")

Instance Attribute Summary

Instance Method Summary

Constructor Details

- (Table) initialize(csv, options = {})

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.

Options are :through, :ignore_case, :accumulate (which forces :through to true when set) and :ruby_eval. See Rufus::Decision::Table for more details.

Options passed to this method do override the options defined in the CSV itself.

options

  • :through : when set, all the rows of the decision table are considered
  • :ignore_case : case is ignored (not ignored by default)
  • :accumulate : gather instead of overriding (implies :through)
  • :ruby_eval : ruby code evaluation is OK


283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/rufus/decision/table.rb', line 283

def initialize (csv, options={})

  @rows = Rufus::Decision.csv_to_a(csv)

  extract_options

  parse_header_row

  @first_match = false if options[:through] == true
  @first_match = true if @first_match.nil?

  set_opt(options, :ignore_case, :ignorecase)
  set_opt(options, :accumulate)
  set_opt(options, :ruby_eval)
  set_opt(options, :unbounded)

  @first_match = false if @accumulate
end

Instance Attribute Details

- (Object) accumulate

when set to true, multiple matches result get accumulated in an array.



252
253
254
# File 'lib/rufus/decision/table.rb', line 252

def accumulate
  @accumulate
end

- (Object) first_match

when set to true, the transformation process stops after the first match got applied.



243
244
245
# File 'lib/rufus/decision/table.rb', line 243

def first_match
  @first_match
end

- (Object) ignore_case

when set to true, matches evaluation ignores case.



247
248
249
# File 'lib/rufus/decision/table.rb', line 247

def ignore_case
  @ignore_case
end

- (Object) ruby_eval

when set to true, evaluation of ruby code for output is allowed. False by default.



257
258
259
# File 'lib/rufus/decision/table.rb', line 257

def ruby_eval
  @ruby_eval
end

- (Object) unbound

false (bounded) by default : exact matches for string matching. When ‘unbounded’, target ‘apple’ will match for values like ‘greenapples’ or ‘apple seed’.



263
264
265
# File 'lib/rufus/decision/table.rb', line 263

def unbound
  @unbound
end

Instance Method Details

- (Object) to_csv

Outputs back this table as a CSV String



330
331
332
333
334
335
# File 'lib/rufus/decision/table.rb', line 330

def to_csv

  @rows.inject([ @header.to_csv ]) { |a, row|
    a << row.join(',')
  }.join("\n")
end

- (Object) transform(hash) Also known as: run

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



305
306
307
308
# File 'lib/rufus/decision/table.rb', line 305

def transform (hash)

  transform!(hash.dup)
end

- (Object) transform!(hash)

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



313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/rufus/decision/table.rb', line 313

def transform! (hash)

  hash = Rufus::Decision::EvalHashFilter.new(hash) if @ruby_eval

  @rows.each do |row|
    next unless matches?(row, hash)
    apply(row, hash)
    break if @first_match
  end

  hash.is_a?(Rufus::Decision::HashFilter) ? hash.parent_hash : hash
end