├── .gitignore
├── Gemfile
├── lib
├── rufus-decision.rb
└── rufus
│ ├── decision.rb
│ └── decision
│ ├── version.rb
│ ├── matchers
│ ├── string.rb
│ ├── numeric.rb
│ └── range.rb
│ ├── matcher.rb
│ ├── participant.rb
│ ├── hashes.rb
│ └── table.rb
├── test
├── input.csv
├── goal.csv
├── ruote
│ ├── test.rb
│ ├── base.rb
│ └── rt_0_basic.rb
├── table.csv
├── test.rb
├── matcher_test.rb
├── dt_4_eval.rb
├── pluggable_test.rb
├── dt_2_google.rb
├── base.rb
├── matcher_range_test.rb
├── matcher_string_test.rb
├── dt_3_bounded.rb
├── matcher_numeric_test.rb
├── dt_5_transpose.rb
├── dt_1_vertical.rb
├── short_circuit_matcher_test.rb
└── dt_0_basic.rb
├── demo
├── public
│ ├── images
│ │ ├── arrow.png
│ │ └── ruse_head_bg.png
│ ├── in.js
│ ├── decision.js
│ ├── js
│ │ ├── request.js
│ │ └── ruote-sheets.js
│ └── decision.html
├── README.txt
└── start.rb
├── .travis.yml
├── examples
├── reimbursement2.csv
├── reimbursement.csv
├── readme_example.rb
├── journalists.rb
└── reimbursement.rb
├── CREDITS.txt
├── TODO.txt
├── doc
└── PLUGGABLE_MATCHERS.md
├── rufus-decision.gemspec
├── LICENSE.txt
├── Rakefile
├── CHANGELOG.txt
├── bin
└── rufus_decide
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .rvmrc
2 | .rspec
3 | Gemfile.lock
4 | TODO.txt
5 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 |
2 | source 'https://rubygems.org'
3 |
4 | gemspec
5 |
6 |
--------------------------------------------------------------------------------
/lib/rufus-decision.rb:
--------------------------------------------------------------------------------
1 |
2 | require 'rufus/decision/table'
3 |
4 |
--------------------------------------------------------------------------------
/lib/rufus/decision.rb:
--------------------------------------------------------------------------------
1 |
2 | require 'rufus/decision/table'
3 |
4 |
--------------------------------------------------------------------------------
/test/input.csv:
--------------------------------------------------------------------------------
1 | age,trait,name
2 | 33,goofy,Longbow
3 | 45,maniac,Baumgartner
4 |
--------------------------------------------------------------------------------
/test/goal.csv:
--------------------------------------------------------------------------------
1 | age,name,salesperson,trait
2 | 33,Longbow,adeslky,goofy
3 | 45,Baumgartner,espadas,maniac
4 |
--------------------------------------------------------------------------------
/demo/public/images/arrow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jmettraux/rufus-decision/HEAD/demo/public/images/arrow.png
--------------------------------------------------------------------------------
/demo/public/images/ruse_head_bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jmettraux/rufus-decision/HEAD/demo/public/images/ruse_head_bg.png
--------------------------------------------------------------------------------
/demo/public/in.js:
--------------------------------------------------------------------------------
1 | data_in = [
2 | [ 'age', 'trait', 'name' ],
3 | [ 33, 'goofy', 'Longbow' ],
4 | [ 45, 'maniac', 'Baumgartner' ],
5 | ];
6 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: ruby
2 | rvm:
3 | - 1.8.7
4 | - 1.9.2
5 | - 1.9.3
6 | - 2.0.0
7 | #- jruby-19mode # JRuby in 1.9 mode
8 | #- rbx-19mode
9 | script: bundle exec rake
10 |
--------------------------------------------------------------------------------
/test/ruote/test.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision (ruote_participant)
4 | #
5 | # Tue Feb 16 14:36:34 JST 2010
6 | #
7 |
8 | Dir["#{File.dirname(__FILE__)}/rt_*.rb"].each { |path| load(path) }
9 |
10 |
--------------------------------------------------------------------------------
/demo/README.txt:
--------------------------------------------------------------------------------
1 |
2 | = rufus-decision demo webapp
3 |
4 | This is a small sinatra webapp.
5 |
6 | gem install sinatra
7 | ruby start.rb
8 |
9 | then point your browser to :
10 |
11 | http://localhost:4567/
12 |
13 |
--------------------------------------------------------------------------------
/demo/public/decision.js:
--------------------------------------------------------------------------------
1 | data_decision = [
2 | [ 'in:age', 'in:trait', 'out:salesperson' ],
3 | [ '18..35', '', 'Adeslky' ],
4 | [ '25..35', '', 'Bronco' ],
5 | [ '36..50', '', 'Espradas' ],
6 | [ '', 'maniac', 'Korolev' ],
7 | ];
8 |
--------------------------------------------------------------------------------
/examples/reimbursement2.csv:
--------------------------------------------------------------------------------
1 | in:type of visit,in:participating physician ?,out:reimbursement
2 | doctor office,yes,90%
3 | doctor office,no,50%
4 | hospital visit,yes,0%
5 | hospital visit,no,80%
6 | lab visit,yes,0%
7 | lab visit,no,70%
8 |
--------------------------------------------------------------------------------
/test/table.csv:
--------------------------------------------------------------------------------
1 | in:age,in:trait,out:salesperson
2 | 18..35,,adeslky
3 | 25..35,,bronco
4 | 36..50,,espadas
5 | 51..78,,thorsten
6 | 44..120,,ojiisan
7 | 25..35,rich,kerfelden
8 | ,cheerful,swanson
9 | ,maniac,korolev
10 |
--------------------------------------------------------------------------------
/CREDITS.txt:
--------------------------------------------------------------------------------
1 |
2 | = CREDITS.txt
3 |
4 |
5 | == Contributors
6 |
7 | - Michael Johnston - https://github.com/lastobelus
8 | - Rob S - https://github.com/qnm
9 |
10 |
11 | == Feedback
12 |
13 | - Wes Gamble
14 | - ocgarlan
15 | - Fu Zhang
16 |
17 |
--------------------------------------------------------------------------------
/test/test.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision
4 | #
5 | # 2007 something
6 | #
7 |
8 | $:.unshift(File.dirname(File.dirname(__FILE__)))
9 |
10 |
11 | Dir["#{File.dirname(__FILE__)}/dt_*.rb"].each { |path| load(path) }
12 | Dir["#{File.dirname(__FILE__)}/*_test.rb"].each { |path| load(path) }
13 |
14 |
--------------------------------------------------------------------------------
/test/matcher_test.rb:
--------------------------------------------------------------------------------
1 |
2 | require File.expand_path('../base.rb', __FILE__)
3 |
4 |
5 | class MatcherTest < Test::Unit::TestCase
6 |
7 | def test_base_class
8 | empty_matcher = Rufus::Decision::Matcher.new
9 | assert ! empty_matcher.matches?(Object.new, Object.new)
10 | assert empty_matcher.cell_substitution?
11 | end
12 | end
13 |
14 |
--------------------------------------------------------------------------------
/TODO.txt:
--------------------------------------------------------------------------------
1 |
2 | [o] move demo to sinatra
3 | [o] nuke fluo-json in demo
4 | [x] transform(h, options), :accumulate option ? NO
5 | [o] something about demo in README
6 | [o] demo/README.txt
7 | [o] ruote participant
8 |
9 | [ ] non csv string representation ? JSON ?
10 | [ ] dsl ?
11 |
12 | [ ] rufus-jig for GETting the table (304 ftw)
13 |
14 | [ ] participant : give decision table URL at runtime
15 |
16 |
--------------------------------------------------------------------------------
/doc/PLUGGABLE_MATCHERS.md:
--------------------------------------------------------------------------------
1 | # Proposal: Pluggable Matchers
2 |
3 | 1. add attr_accessor :matchers to Table
4 | 2. create Matcher class:
5 | 1. matches?
6 | 1. dsub? # make the dsub step optional for a matcher
7 | 3. extract NumericComparisonMatcher
8 | 1. add to default matchers
9 | 4. extract RangeMatcher
10 | 1. add to default matchers
11 | 5. extract StringComparisonMatcher
12 | 1. add to default matchers
13 |
--------------------------------------------------------------------------------
/examples/reimbursement.csv:
--------------------------------------------------------------------------------
1 | in:deductible met ?,in:type of visit,in:participating physician ?,out:reimbursement
2 | yes,doctor office,yes,90%
3 | yes,doctor office,no,50%
4 | yes,hospital visit,yes,n/a
5 | yes,hospital visit,no,80%
6 | yes,lab visit,yes,n/a
7 | yes,lab visit,no,70%
8 | no,doctor office,no,0%
9 | no,hospital visit,yes,n/a
10 | no,hospital visit,no,0%
11 | no,lab visit,yes,n/a
12 | no,lab visit,no,0%
13 |
--------------------------------------------------------------------------------
/test/dt_4_eval.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision
4 | #
5 | # Mon Oct 9 22:19:44 JST 2006
6 | #
7 |
8 | require File.expand_path('../base.rb', __FILE__)
9 |
10 |
11 | class Dt4Test < Test::Unit::TestCase
12 |
13 | def test_0
14 |
15 | eh = Rufus::Decision::EvalHashFilter.new({})
16 |
17 | eh['a'] = :a
18 | eh['b'] = 'r:5 * 5'
19 |
20 | assert_equal :a, eh['a']
21 | assert_equal 25, eh['b']
22 | assert_equal 72, eh['r:36+36']
23 | end
24 | end
25 |
26 |
--------------------------------------------------------------------------------
/test/pluggable_test.rb:
--------------------------------------------------------------------------------
1 |
2 | require File.expand_path('../base.rb', __FILE__)
3 |
4 |
5 | class PluggableTest < Test::Unit::TestCase
6 |
7 | def test_default_matchers
8 | table = Rufus::Decision::Table.new("\n")
9 |
10 | assert table.matchers[0].is_a?(
11 | Rufus::Decision::Matchers::Numeric)
12 | assert table.matchers[1].is_a?(
13 | Rufus::Decision::Matchers::Range)
14 | assert table.matchers[2].is_a?(
15 | Rufus::Decision::Matchers::String)
16 | end
17 | end
18 |
19 |
--------------------------------------------------------------------------------
/test/ruote/base.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision (ruote participant)
4 | #
5 | # Tue Feb 16 14:37:13 JST 2010
6 | #
7 |
8 | $:.unshift(
9 | File.expand_path(File.join(File.dirname(__FILE__), %w[ .. .. lib ])))
10 | $:.unshift(
11 | File.expand_path(File.join(File.dirname(__FILE__), %w[ .. .. .. .. ruote lib ])))
12 |
13 | require 'test/unit'
14 | require 'rubygems'
15 | require 'ruote'
16 | require 'rufus/decision'
17 |
18 |
19 | module RuoteBase
20 |
21 | def setup
22 |
23 | @engine =
24 | Ruote::Engine.new(
25 | Ruote::Worker.new(
26 | Ruote::HashStorage.new(
27 | 's_logger' => %w[ ruote/log/test_logger Ruote::TestLogger ])))
28 | end
29 |
30 | def teardown
31 |
32 | @engine.shutdown
33 | @engine.context.storage.purge!
34 | end
35 | end
36 |
37 |
--------------------------------------------------------------------------------
/demo/public/js/request.js:
--------------------------------------------------------------------------------
1 | function createHttpRequest () {
2 | var fs = [
3 | function () { return new XMLHttpRequest(); },
4 | function () { return new ActiveXObject('Msxml2.XMLHTTP'); },
5 | function () { return new ActiveXObject('Microsoft.XMLHTTP'); },
6 | ];
7 | for (var i = 0; i < fs.length; i++) {
8 | try { var r = fs[i](); if (r) return r; } catch (e) { continue; }
9 | }
10 | }
11 | function httpGet (uri) {
12 | var req = createHttpRequest();
13 | req.open('GET', uri, false); // asynchronous ? false
14 | req.send(null); // no body
15 | return req.responseText;
16 | }
17 | function httpPost(uri, data) {
18 | var req = createHttpRequest();
19 | req.open('POST', uri, false); // asynchronous ? false
20 | //req.setRequestHeader('Content-Length', data.length); // utf ???
21 | req.send(data);
22 | return req.responseText;
23 | }
24 |
--------------------------------------------------------------------------------
/rufus-decision.gemspec:
--------------------------------------------------------------------------------
1 |
2 | Gem::Specification.new do |s|
3 |
4 | s.name = 'rufus-decision'
5 |
6 | s.version = File.read(
7 | File.expand_path('../lib/rufus/decision/version.rb', __FILE__)
8 | ).match(/ VERSION *= *['"]([^'"]+)/)[1]
9 |
10 | s.platform = Gem::Platform::RUBY
11 | s.authors = [ 'John Mettraux' ]
12 | s.email = [ 'jmettraux@gmail.com' ]
13 | s.homepage = 'https://github.com/jmettraux/rufus-decision'
14 | s.rubyforge_project = 'rufus'
15 | s.license = 'MIT'
16 | s.summary = 'a decision table gem, based on CSV'
17 |
18 | s.description = %{
19 | CSV based Ruby decision tables
20 | }.strip
21 |
22 | #s.files = `git ls-files`.split("\n")
23 | s.files = Dir[
24 | 'Rakefile',
25 | 'lib/**/*.rb', 'spec/**/*.rb', 'test/**/*.rb',
26 | '*.gemspec', '*.txt', '*.rdoc', '*.md'
27 | ]
28 |
29 | s.add_development_dependency 'rake'
30 |
31 | s.add_dependency 'rufus-dollar'
32 | s.add_dependency 'rufus-treechecker'
33 |
34 | s.require_path = 'lib'
35 | end
36 |
37 |
--------------------------------------------------------------------------------
/test/dt_2_google.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision
4 | #
5 | # Sun Oct 29 15:41:44 JST 2006
6 | #
7 |
8 | require File.expand_path('../base.rb', __FILE__)
9 |
10 |
11 | class Dt2Test < Test::Unit::TestCase
12 | include DecisionTestMixin
13 |
14 | CSV3D = 'https://spreadsheets.google.com/pub?key=pCkopoeZwCNsMWOVeDjR1TQ&output=csv&gid=0'
15 |
16 | def test_3d
17 |
18 | return if ENV['SKIP_RUFUS_SLOW']
19 |
20 | h = {}
21 | h["weather"] = "raining"
22 | h["month"] = "december"
23 |
24 | do_test(CSV3D, h, { "take_umbrella?" => "yes" }, false)
25 |
26 | h = {}
27 | h["weather"] = "cloudy"
28 | h["month"] = "june"
29 |
30 | sleep 0.3 # don't request the doc too often
31 |
32 | do_test(CSV3D, h, { "take_umbrella?" => "yes" }, false)
33 |
34 | h = {}
35 | h["weather"] = "cloudy"
36 | h["month"] = "march"
37 |
38 | sleep 0.3 # don't request the doc too often
39 |
40 | do_test(CSV3D, h, { "take_umbrella?" => "no" }, false)
41 | end
42 | end
43 |
44 |
--------------------------------------------------------------------------------
/test/base.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision
4 | #
5 | # 2007 something
6 | #
7 |
8 | require 'test/unit'
9 | require 'rufus/decision'
10 |
11 |
12 | def trim_table(s)
13 |
14 | s.split("\n").collect(&:lstrip).join("\n").strip
15 | end
16 |
17 |
18 | module DecisionTestMixin
19 |
20 | protected
21 |
22 | def do_test(table_data, h, expected_result, verbose=false)
23 |
24 | table =
25 | if table_data.is_a?(Rufus::Decision::Table)
26 | table_data
27 | else
28 | Rufus::Decision::Table.new(table_data)
29 | end
30 |
31 | if verbose
32 | puts
33 | puts 'table :'
34 | puts table.to_csv
35 | puts
36 | puts 'before :'
37 | p h
38 | end
39 |
40 | h = table.transform!(h)
41 |
42 | if verbose
43 | puts
44 | puts 'after :'
45 | p h
46 | end
47 |
48 | expected_result.each do |k, v|
49 |
50 | value = h[k]
51 |
52 | value = value.join(';') if value.is_a?(Array)
53 |
54 | assert_equal v, value
55 | end
56 | end
57 | end
58 |
59 |
--------------------------------------------------------------------------------
/examples/readme_example.rb:
--------------------------------------------------------------------------------
1 |
2 | require 'rubygems'
3 | require 'rufus/decision'
4 |
5 | TABLE = Rufus::Decision::Table.new(%{
6 | in:age,in:trait,out:salesperson
7 |
8 | 18..35,,adeslky
9 | 25..35,,bronco
10 | 36..50,,espadas
11 | 51..78,,thorsten
12 | 44..120,,ojiisan
13 |
14 | 25..35,rich,kerfelden
15 | ,cheerful,swanson
16 | ,maniac,korolev
17 | })
18 |
19 | # Given a customer (a Hash instance directly, for
20 | # convenience), returns the name of the first
21 | # corresponding salesman.
22 | #
23 | def determine_salesperson (customer)
24 |
25 | TABLE.transform(customer)["salesperson"]
26 | end
27 |
28 | puts determine_salesperson(
29 | "age" => 72)
30 | # => thorsten
31 |
32 | puts determine_salesperson(
33 | "age" => 25, "trait" => "rich")
34 | # => adeslky
35 |
36 | puts determine_salesperson(
37 | "age" => 23, "trait" => "cheerful")
38 | # => adeslky
39 |
40 | puts determine_salesperson(
41 | "age" => 25, "trait" => "maniac")
42 | # => adeslky
43 |
44 | puts determine_salesperson(
45 | "age" => 44, "trait" => "maniac")
46 | # => espadas
47 |
48 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 |
2 | Copyright (c) 2006-2013, John Mettraux, jmettraux@gmail.com
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is
9 | furnished to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in
12 | all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | THE SOFTWARE.
21 |
22 |
--------------------------------------------------------------------------------
/test/matcher_range_test.rb:
--------------------------------------------------------------------------------
1 |
2 | require File.expand_path('../base.rb', __FILE__)
3 |
4 |
5 | class RangeMatcherTest < Test::Unit::TestCase
6 |
7 | def test_substitution_true
8 |
9 | m = Rufus::Decision::Matchers::Range.new
10 | assert m.cell_substitution?
11 | end
12 |
13 | def test_does_match
14 |
15 | m = Rufus::Decision::Matchers::Range.new
16 | %Q{
17 | 1..4 | 2
18 | 3..10 | 10
19 | 3...10 | 9
20 | }.strip.each_line do |line|
21 | cell, value = line.split('|').collect(&:strip)
22 | assert m.matches?(cell, value), "'#{cell}' should match '#{value}'"
23 | end
24 | end
25 |
26 | def test_doesnt_match
27 |
28 | m = Rufus::Decision::Matchers::Range.new
29 | %Q{
30 | foo | bar
31 | 1..3 | 0
32 | >0 | 1
33 | <2 | 2
34 | <=2 | 1
35 | >=4.2 | 4.3
36 | 1..4 | 5
37 | 3..10 | 11
38 | 3...10 | 10
39 | }.strip.each_line do |line|
40 | cell, value = line.split('|').collect(&:strip)
41 | assert ! m.matches?(cell, value), "'#{cell}' should NOT match '#{value}'"
42 | end
43 | end
44 | end
45 |
46 |
47 |
--------------------------------------------------------------------------------
/test/matcher_string_test.rb:
--------------------------------------------------------------------------------
1 |
2 | require File.expand_path('../base.rb', __FILE__)
3 |
4 |
5 | class StringMatcherTest < Test::Unit::TestCase
6 |
7 | def test_substitution_true
8 |
9 | m = Rufus::Decision::Matchers::String.new
10 | m.options = {}
11 |
12 | assert m.cell_substitution?
13 | end
14 |
15 | def test_does_match
16 |
17 | m = Rufus::Decision::Matchers::String.new
18 | m.options = {}
19 |
20 | %Q{
21 | a | a
22 | mary | mary
23 | }.strip.each_line do |line|
24 | cell, value = line.split('|').collect(&:strip)
25 | assert m.matches?(cell, value), "'#{cell}' should match '#{value}'"
26 | end
27 | end
28 |
29 | def test_doesnt_match
30 |
31 | m = Rufus::Decision::Matchers::String.new
32 | m.options = {}
33 |
34 | %Q{
35 | foo | bar
36 | 1..3 | 1
37 | >0 | 1
38 | <2 | 2
39 | <=2 | 1
40 | >=4.2 | 4.3
41 | }.strip.each_line do |line|
42 | cell, value = line.split('|').collect(&:strip)
43 | assert ! m.matches?(cell, value), "'#{cell}' should NOT match '#{value}'"
44 | end
45 | end
46 | end
47 |
48 |
49 |
--------------------------------------------------------------------------------
/test/dt_3_bounded.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision
4 | #
5 | # Mon Sep 7 13:42:09 JST 2009
6 | #
7 |
8 | require File.expand_path('../base.rb', __FILE__)
9 |
10 |
11 | class Dt3Test < Test::Unit::TestCase
12 | include DecisionTestMixin
13 |
14 | CSV14 = %{
15 | in:fstate,out:result
16 | apple,1
17 | orange,2
18 | }
19 |
20 | def test_bounded_match
21 |
22 | dt = Rufus::DecisionTable.new(CSV14)
23 |
24 | assert_equal(
25 | { 'fstate' => 'greenapples' },
26 | dt.transform({ 'fstate' => 'greenapples' }))
27 | end
28 |
29 | def test_unbounded_match
30 |
31 | dt = Rufus::DecisionTable.new(CSV14, :unbounded => true)
32 |
33 | assert_equal(
34 | { 'fstate' => 'apples', 'result' => '1' },
35 | dt.transform({ 'fstate' => 'apples' }))
36 | end
37 |
38 | CSV14b = %{
39 | unbounded,
40 | in:fstate,out:result
41 | apple,1
42 | orange,2
43 | }
44 |
45 | def test_unbounded_match_set_in_table
46 |
47 | dt = Rufus::DecisionTable.new(CSV14b)
48 |
49 | assert_equal(
50 | { 'fstate' => 'apples', 'result' => '1' },
51 | dt.transform({ 'fstate' => 'apples' }))
52 | end
53 | end
54 |
55 |
--------------------------------------------------------------------------------
/test/ruote/rt_0_basic.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision (ruote participant)
4 | #
5 | # Tue Feb 16 14:54:55 JST 2010
6 | #
7 |
8 | require File.join(File.dirname(__FILE__), 'base')
9 |
10 | require 'rufus/decision/participant'
11 |
12 | class Rt0Test < Test::Unit::TestCase
13 | include RuoteBase
14 |
15 | def test_basic
16 |
17 | @engine.register_participant(
18 | :decision,
19 | Rufus::Decision::Participant, :table => %{
20 | in:topic,in:region,out:team_member
21 | sports,europe,Alice
22 | sports,,Bob
23 | finance,america,Charly
24 | finance,europe,Donald
25 | finance,,Ernest
26 | politics,asia,Fujio
27 | politics,america,Gilbert
28 | politics,,Henry
29 | ,,Zach
30 | })
31 |
32 | pdef = Ruote.process_definition :name => 'dec-test', :revision => '1' do
33 | decision
34 | end
35 |
36 | wfid = @engine.launch(pdef, 'topic' => 'politics', 'region' => 'america')
37 | r = @engine.wait_for(wfid)
38 |
39 | assert_equal(
40 | {"topic"=>"politics", "region"=>"america", "team_member"=>"Gilbert"},
41 | r['workitem']['fields'])
42 | end
43 | end
44 |
45 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 |
2 | $:.unshift('.') # 1.9.2
3 |
4 | require 'rubygems'
5 |
6 | require 'rake'
7 | require 'rake/clean'
8 |
9 |
10 | #
11 | # clean
12 |
13 | CLEAN.include('pkg')
14 |
15 |
16 | #
17 | # test / spec
18 |
19 | task :test do
20 |
21 | exec 'bundle exec ruby test/test.rb'
22 | end
23 |
24 | task :default => [ :spec ]
25 | task :spec => [ :test ]
26 |
27 |
28 | #
29 | # gem
30 |
31 | GEMSPEC_FILE = Dir['*.gemspec'].first
32 | GEMSPEC = eval(File.read(GEMSPEC_FILE))
33 | GEMSPEC.validate
34 |
35 |
36 | desc %{
37 | builds the gem and places it in pkg/
38 | }
39 | task :build do
40 |
41 | sh "gem build #{GEMSPEC_FILE}"
42 | sh "mkdir pkg" rescue nil
43 | sh "mv #{GEMSPEC.name}-#{GEMSPEC.version}.gem pkg/"
44 | end
45 |
46 | desc %{
47 | builds the gem and pushes it to rubygems.org
48 | }
49 | task :push => :build do
50 |
51 | sh "gem push pkg/#{GEMSPEC.name}-#{GEMSPEC.version}.gem"
52 | end
53 |
54 |
55 | ##
56 | ## TO THE WEB
57 | #
58 | #task :upload_website => [ :clean, :yard ] do
59 | #
60 | # account = 'jmettraux@rubyforge.org'
61 | # webdir = '/var/www/gforge-projects/rufus'
62 | #
63 | # sh "rsync -azv -e ssh html/rufus-decision #{account}:#{webdir}/"
64 | #end
65 |
66 |
--------------------------------------------------------------------------------
/test/matcher_numeric_test.rb:
--------------------------------------------------------------------------------
1 |
2 | require File.expand_path('../base.rb', __FILE__)
3 |
4 |
5 | class NumericMatcherTest < Test::Unit::TestCase
6 |
7 | def test_substitution_true
8 |
9 | m = Rufus::Decision::Matchers::Numeric.new
10 | assert m.cell_substitution?
11 | end
12 |
13 | def test_does_match
14 |
15 | m = Rufus::Decision::Matchers::Numeric.new
16 | %Q{
17 | >0 | 1
18 | <2 | 1
19 | >=4.2 | 4.2
20 | >=4.2 | 4.201
21 | }.strip.each_line do |line|
22 | cell, value = line.split('|').collect(&:strip)
23 | assert m.matches?(cell, value), "'#{cell}' should match '#{value}'"
24 | end
25 | end
26 |
27 | def test_doesnt_match
28 |
29 | m = Rufus::Decision::Matchers::Numeric.new
30 | %Q{
31 | 7 | 7
32 | 7 | 8
33 | bob | bob
34 | 1..3 | 1..3
35 | 1..3 | 1
36 | >0 | -1
37 | <2 | 2
38 | >=4.2 | 4.1
39 | >=4.2 | 4.101
40 | }.strip.each_line do |line|
41 | cell, value = line.split('|').collect(&:strip)
42 | assert ! m.matches?(cell, value), "'#{cell}' should NOT match '#{value}'"
43 | end
44 | end
45 | end
46 |
47 |
48 |
--------------------------------------------------------------------------------
/examples/journalists.rb:
--------------------------------------------------------------------------------
1 |
2 | require 'rubygems'
3 | require 'rufus/decision' # sudo gem install rufus-decision
4 |
5 | table = %{
6 | in:topic,in:region,out:team_member
7 | sports,europe,Alice
8 | sports,,Bob
9 | finance,america,Charly
10 | finance,europe,Donald
11 | finance,,Ernest
12 | politics,asia,Fujio
13 | politics,america,Gilbert
14 | politics,,Henry
15 | ,,Zach
16 | }
17 |
18 | #Rufus::Decision.csv_to_a(table).transpose.each do |row|
19 | # puts row.join(', ')
20 | #end
21 | table = %{
22 | in:topic,sports,sports,finance,finance,finance,politics,politics,politics,
23 | in:region,europe,,america,europe,,asia,america,,
24 | out:team_member,Alice,Bob,Charly,Donald,Ernest,Fujio,Gilbert,Henry,Zach
25 | }
26 |
27 | table = Rufus::Decision::Table.new(table)
28 |
29 | p table.run('topic' => 'politics', 'region' => 'america')
30 | # => {"region"=>"america", "topic"=>"politics", "team_member"=>"Gilbert"}
31 |
32 | p table.run('topic' => 'sports', 'region' => 'antarctic')
33 | # => {"region"=>"antarctic", "topic"=>"sports", "team_member"=>"Bob"}
34 |
35 | p table.run('topic' => 'culture', 'region' => 'america')
36 | # => {"region"=>"america", "topic"=>"culture", "team_member"=>"Zach"}
37 |
38 |
--------------------------------------------------------------------------------
/lib/rufus/decision/version.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # Copyright (c) 2008-2013, John Mettraux, jmettraux@gmail.com
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a copy
5 | # of this software and associated documentation files (the "Software"), to deal
6 | # in the Software without restriction, including without limitation the rights
7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | # copies of the Software, and to permit persons to whom the Software is
9 | # furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | # THE SOFTWARE.
21 | #
22 | # Made in Japan.
23 | #++
24 |
25 |
26 | module Rufus
27 | module Decision
28 |
29 | VERSION = '1.4.0'
30 | end
31 | end
32 |
33 |
--------------------------------------------------------------------------------
/test/dt_5_transpose.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision
4 | #
5 | # Thu Apr 23 15:18:15 JST 2009
6 | #
7 |
8 | require File.expand_path('../base.rb', __FILE__)
9 |
10 |
11 | class Dt5Test < Test::Unit::TestCase
12 |
13 | def test_transpose_empty_array
14 |
15 | assert_equal([], Rufus::Decision.transpose([]))
16 | end
17 |
18 | def test_transpose_a_to_h
19 |
20 | assert_equal(
21 | [
22 | { 'age' => 33, 'name' => 'Jeff' },
23 | { 'age' => 35, 'name' => 'John' }
24 | ],
25 | Rufus::Decision.transpose([
26 | [ 'age', 'name' ],
27 | [ 33, 'Jeff' ],
28 | [ 35, 'John' ]
29 | ])
30 | )
31 | end
32 |
33 | def test_transpose_h_to_a
34 |
35 | assert_equal(
36 | [
37 | [ 'age', 'name' ],
38 | [ 33, 'Jeff' ],
39 | [ 35, 'John' ]
40 | ],
41 | Rufus::Decision.transpose([
42 | { 'age' => 33, 'name' => 'Jeff' },
43 | { 'age' => 35, 'name' => 'John' }
44 | ])
45 | )
46 | end
47 |
48 | def test_transpose_s_to_a
49 |
50 | assert_equal(
51 | [
52 | { 'age' => '33', 'name' => 'Jeff' },
53 | { 'age' => '35', 'name' => 'John' }
54 | ],
55 | Rufus::Decision.transpose(%{
56 | age,name
57 | 33,Jeff
58 | 35,John
59 | })
60 | )
61 | end
62 | end
63 |
64 |
--------------------------------------------------------------------------------
/CHANGELOG.txt:
--------------------------------------------------------------------------------
1 |
2 | = rufus-decision CHANGELOG.txt
3 |
4 |
5 | == rufus-decision - 1.4.0 not yet released
6 |
7 | - matcher extraction by Michael Johnston
8 |
9 |
10 | == rufus-decision - 1.3.2 released 2010/05/28
11 |
12 | - ruote participant : passing the participant options to the decision table
13 | Thanks Wes Gamble
14 | - accepting symbol or string as table option keys
15 |
16 |
17 | == rufus-decision - 1.3.1 released 2010/02/16
18 |
19 | - implemented Rufus::Decision::Participant, a ruote participant
20 |
21 |
22 | == rufus-decision - 1.3.0 released 2010/02/15
23 |
24 | - removing 'require "rubygems"' from lib/ (argh)
25 | - moving to jeweler (thanks Kenneth Kalmer)
26 | - lib/rufus/decision/ dir
27 |
28 |
29 | == rufus-decision - 1.2.0 released 2009/09/07
30 |
31 | - issue 1 : made 'bounded' default and added 'unbounded' option
32 |
33 |
34 | == rufus-decision - 1.1 released 2009/04/25
35 |
36 | - todo #25670 : :ruby_eval settable at table initialization
37 | - todo #25667 : :ignore_case, :through and :accumulate settable at table
38 | initialization (instead of only in the csv table itself)
39 | - todo #25647 : now accepts horizontal and vertical decision tables
40 | - todo #25642 : introducing bin/rufus_decided -t table.csv -i input.csv
41 | - todo #25629 : implemented Rufus::Decision.transpose(a)
42 | - todo #25630 : made Ruby 1.9.1 compatible
43 | - todo #25595 : Rufus::DecisionTable -> Rufus::Decision::Table
44 | - bug #25589 : fixed issue with empty values and in:ranges
45 |
46 |
47 | == rufus-decision - 1.0 released 2008/09/01
48 |
49 | - todo #20670 : dropped rufus-eval in favour of rufus-treechecker
50 |
51 |
52 | == rufus-decision - 0.9 released 2008/01/28
53 |
54 |
--------------------------------------------------------------------------------
/lib/rufus/decision/matchers/string.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # Copyright (c) 2007-2013, John Mettraux, jmettraux@gmail.com
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a copy
5 | # of this software and associated documentation files (the "Software"), to deal
6 | # in the Software without restriction, including without limitation the rights
7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | # copies of the Software, and to permit persons to whom the Software is
9 | # furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | # THE SOFTWARE.
21 | #
22 | # Made in Japan.
23 | #++
24 |
25 |
26 | module Rufus
27 | module Decision
28 | module Matchers
29 |
30 | class String < Matcher
31 |
32 | def matches?(cell, value)
33 |
34 | modifiers = 0
35 | modifiers += Regexp::IGNORECASE if options[:ignore_case]
36 |
37 | rcell = options[:unbounded] ?
38 | Regexp.new(cell, modifiers) : Regexp.new("^#{cell}$", modifiers)
39 |
40 | rcell.match(value)
41 | end
42 | end
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/rufus/decision/matcher.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # Copyright (c) 2007-2013, John Mettraux, jmettraux@gmail.com
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a copy
5 | # of this software and associated documentation files (the "Software"), to deal
6 | # in the Software without restriction, including without limitation the rights
7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | # copies of the Software, and to permit persons to whom the Software is
9 | # furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | # THE SOFTWARE.
21 | #
22 | # Made in Japan.
23 | #++
24 |
25 |
26 | module Rufus
27 | module Decision
28 |
29 | class Matcher
30 |
31 | attr_accessor :options
32 |
33 | # Subclasses of Matcher override this method to provide matching.
34 | # If a matcher wishes to "short-circuit" -- ie, stop further matching
35 | # by other matchers on this cell/value while not actually matching --
36 | # it should return :break
37 | def matches?(cell, value)
38 | false
39 | end
40 |
41 | def cell_substitution?
42 | true
43 | end
44 | end
45 | end
46 | end
47 |
48 | Dir[File.expand_path('../matchers/*.rb', __FILE__)].each { |pa| require(pa) }
49 |
50 |
--------------------------------------------------------------------------------
/lib/rufus/decision/matchers/numeric.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # Copyright (c) 2007-2013, John Mettraux, jmettraux@gmail.com
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a copy
5 | # of this software and associated documentation files (the "Software"), to deal
6 | # in the Software without restriction, including without limitation the rights
7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | # copies of the Software, and to permit persons to whom the Software is
9 | # furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | # THE SOFTWARE.
21 | #
22 | # Made in Japan.
23 | #++
24 |
25 |
26 | module Rufus
27 | module Decision
28 | module Matchers
29 |
30 | class Numeric < Matcher
31 |
32 | NUMERIC_COMPARISON = /^([><]=?)(.*)$/
33 |
34 | def matches?(cell, value)
35 |
36 | match = NUMERIC_COMPARISON.match(cell)
37 | return false if match.nil?
38 |
39 | comparator = match[1]
40 | cell = match[2]
41 |
42 | nvalue = Float(value) rescue value
43 | ncell = Float(cell) rescue cell
44 |
45 | value, cell = if nvalue.is_a?(::String) or ncell.is_a?(::String)
46 | [ "\"#{value}\"", "\"#{cell}\"" ]
47 | else
48 | [ nvalue, ncell ]
49 | end
50 |
51 | s = "#{value} #{comparator} #{cell}"
52 |
53 | Rufus::Decision::check_and_eval(s) rescue false
54 | end
55 | end
56 | end
57 | end
58 | end
59 |
--------------------------------------------------------------------------------
/demo/start.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # just a tiny Ruby Rack application for serving the stuff under demo/
4 | #
5 | # start with
6 | #
7 | # ruby start.rb
8 | #
9 | # (don't forget to
10 | #
11 | # sudo gem install rack mongrel
12 | #
13 | # if necessary)
14 | #
15 |
16 | $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
17 |
18 | require 'rubygems'
19 | require 'json'
20 | require 'sinatra'
21 | require 'rufus/decision'
22 |
23 | #class App
24 | # def initialize
25 | # @rfapp = Rack::File.new(File.dirname(__FILE__) + '/public')
26 | # end
27 | # def call (env)
28 | # return decide(env) if env['PATH_INFO'] == '/decision'
29 | # env['PATH_INFO'] = '/index.html' if env['PATH_INFO'] == '/'
30 | # @rfapp.call(env)
31 | # end
32 | # protected
33 | # def in_to_h (keys, values)
34 | # keys.inject({}) { |h, k| h[k] = values.shift; h }
35 | # end
36 | # def decide (env)
37 | # json = env['rack.input'].read
38 | # json = JSON.parse(json)
39 | # dt = Rufus::Decision::Table.new(json.last)
40 | # input = Rufus::Decision.transpose(json.first)
41 | # # from array of arrays to array of hashes
42 | # output = input.inject([]) { |a, hash| a << dt.transform(hash); a }
43 | # output = Rufus::Decision.transpose(output)
44 | # # from array of hashes to array of arrays
45 | # [ 200, {}, output.to_json ]
46 | # end
47 | #end
48 |
49 | use Rack::CommonLogger
50 | use Rack::ShowExceptions
51 |
52 | set :public, File.expand_path(File.join(File.dirname(__FILE__), 'public'))
53 | #set :views, File.expand_path(File.join(File.dirname(__FILE__), 'views'))
54 |
55 | get '/' do
56 |
57 | redirect '/decision.html'
58 | end
59 |
60 | post '/decide' do
61 |
62 | json = env['rack.input'].read
63 | json = JSON.parse(json)
64 |
65 | dt = Rufus::Decision::Table.new(json.last)
66 | input = Rufus::Decision.transpose(json.first)
67 | # from array of arrays to array of hashes
68 |
69 | output = input.inject([]) { |a, hash| a << dt.transform(hash); a }
70 | output = Rufus::Decision.transpose(output)
71 | # from array of hashes to array of arrays
72 |
73 | response.headers['Content-Type'] = 'application/json'
74 |
75 | output.to_json
76 | end
77 |
78 |
--------------------------------------------------------------------------------
/test/dt_1_vertical.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-deciision
4 | #
5 | # Sun Oct 29 15:41:44 JST 2006
6 | #
7 |
8 | require File.expand_path('../base.rb', __FILE__)
9 |
10 |
11 | class Dt1Test < Test::Unit::TestCase
12 | include DecisionTestMixin
13 |
14 | CSV1 = %{
15 | ,,
16 | in:fx,in:fy,out:fz
17 | ,,
18 | a,${fx},0
19 | c,d,${fx}
20 | e,f,${r:3+4}
21 | g,h,${r:'${fx}' + '${fy}'}
22 | }
23 |
24 | def test_1
25 |
26 | wi = { 'fx' => 'c', 'fy' => 'd' }
27 | do_test(CSV1, wi, { 'fz' => 'c' }, false)
28 |
29 | wi = { 'fx' => 'a', 'fy' => 'a' }
30 | do_test(CSV1, wi, { 'fz' => '0' }, false)
31 | end
32 |
33 | def test_1b
34 |
35 | table = Rufus::Decision::Table.new(CSV1, :ruby_eval => true)
36 |
37 | h = { 'fx' => 'e', 'fy' => 'f' }
38 | do_test(table, h, { 'fz' => '7' }, false)
39 | end
40 |
41 | def test_1c
42 |
43 | table = Rufus::Decision::Table.new(CSV1, :ruby_eval => true)
44 |
45 | h = { 'fx' => 'g', 'fy' => 'h' }
46 | do_test(table, h, { 'fz' => 'gh' }, false)
47 | end
48 |
49 | CSV2 = [
50 | %w{ in:fx in:fy out:fz },
51 | %w{ a ${fx} 0 },
52 | %w{ c d ${fx} },
53 | %w{ e f ${r:3+4} },
54 | [ 'g', 'h', "${r:'${fx}' + '${fy}'}" ]
55 | ]
56 |
57 | def test_with_array_table
58 |
59 | wi = { 'fx' => 'c', 'fy' => 'd' }
60 | do_test(CSV2, wi, { 'fz' => 'c' }, false)
61 | end
62 |
63 | def test_empty_string_to_float
64 |
65 | wi = { 'age' => '', 'trait' => 'maniac', 'name' => 'Baumgarter' }
66 |
67 | table = [
68 | %w{ in:age in:trait out:salesperson },
69 | [ '18..35', '', 'Adeslky' ],
70 | [ '25..35', '', 'Bronco' ],
71 | [ '36..50', '', 'Espradas' ],
72 | [ '', 'maniac', 'Korolev' ]
73 | ]
74 |
75 | do_test(table, wi, { 'salesperson' => 'Korolev' }, false)
76 | end
77 |
78 | def test_vertical_rules
79 |
80 | table = CSV2.transpose
81 |
82 | wi = { 'fx' => 'c', 'fy' => 'd' }
83 | do_test(table, wi, { 'fz' => 'c' }, false)
84 | end
85 |
86 | def test_vertical_rules_2
87 |
88 | table = %{
89 | in:topic,sports,sports,finance,finance,finance,politics,politics,politics,
90 | in:region,europe,,america,europe,,asia,america,,
91 | out:team_member,Alice,Bob,Charly,Donald,Ernest,Fujio,Gilbert,Henry,Zach
92 | }
93 |
94 | h = { 'topic' => 'politics', 'region' => 'america' }
95 | do_test(table, h, { 'team_member' => 'Gilbert' }, false)
96 | end
97 | end
98 |
99 |
--------------------------------------------------------------------------------
/test/short_circuit_matcher_test.rb:
--------------------------------------------------------------------------------
1 |
2 | require File.expand_path('../base.rb', __FILE__)
3 |
4 |
5 | class ShortCircuitMatcherTest < Test::Unit::TestCase
6 | include DecisionTestMixin
7 |
8 | class Matcher1 < Rufus::Decision::Matcher
9 |
10 | def matches?(cell, value)
11 | value.include?("aaa")
12 | end
13 | end
14 |
15 | class Matcher2 < Rufus::Decision::Matcher
16 |
17 | def matches?(cell, value)
18 | value.include?("aa")
19 | end
20 | end
21 |
22 | class ShortCircuit < Rufus::Decision::Matcher
23 |
24 | def matches?(cell, value)
25 |
26 | m = cell.match(/^short:(maybe:)?(.*)$/)
27 |
28 | return false unless m # no match
29 |
30 | match = (m[2] == value)
31 |
32 | return true if match # match
33 | return false if m[1] # no match
34 |
35 | :break # no match, but break
36 | end
37 | end
38 |
39 | class RaisingMatcher < Rufus::Decision::Matcher
40 |
41 | def matches?(cell, value)
42 | raise "No!"
43 | end
44 | end
45 |
46 | def test_default_no_short_circuit
47 |
48 | empty_matcher1 = Matcher1.new
49 | empty_matcher2 = Matcher2.new
50 |
51 | table = Rufus::Decision::Table.new(%Q{
52 | in:first_col,out:second_col
53 | anything, works
54 | })
55 |
56 | table.matchers = [ empty_matcher1, empty_matcher2 ]
57 |
58 | wi = { 'first_col' => 'aa' }
59 | do_test(table, wi, { "second_col" => "works" }, false)
60 | end
61 |
62 | def test_short_circuit
63 |
64 | table = Rufus::Decision::Table.new(%Q{
65 | in:first_col,out:second_col
66 | short:aaa, works
67 | })
68 |
69 | table.matchers = [
70 | ShortCircuit.new,
71 | RaisingMatcher.new
72 | ]
73 |
74 | wi = { 'first_col' => 'aa' }
75 | assert_nothing_raised { table.transform wi }
76 |
77 | do_test(table, wi, {}, false)
78 |
79 | wi = { 'first_col' => 'aaa' }
80 | assert_nothing_raised { table.transform wi }
81 |
82 | do_test(table, wi, { 'second_col' => 'works' }, false)
83 | end
84 |
85 | def test_short_circuit_maybe
86 |
87 | table = Rufus::Decision::Table.new(%Q{
88 | in:first_col,out:second_col
89 | short:maybe:aaa, works
90 | })
91 |
92 | table.matchers = [
93 | ShortCircuit.new,
94 | RaisingMatcher.new
95 | ]
96 |
97 | wi = { 'first_col' => 'aa' }
98 | assert_raise(RuntimeError) { table.transform wi }
99 |
100 | wi = { 'first_col' => 'aaa' }
101 | assert_nothing_raised { table.transform wi }
102 |
103 | do_test(table, wi, { 'second_col' => 'works' }, false)
104 | end
105 | end
106 |
107 |
--------------------------------------------------------------------------------
/lib/rufus/decision/matchers/range.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # Copyright (c) 2007-2013, John Mettraux, jmettraux@gmail.com
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a copy
5 | # of this software and associated documentation files (the "Software"), to deal
6 | # in the Software without restriction, including without limitation the rights
7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | # copies of the Software, and to permit persons to whom the Software is
9 | # furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | # THE SOFTWARE.
21 | #
22 | # Made in Japan.
23 | #++
24 |
25 |
26 | module Rufus
27 | module Decision
28 | module Matchers
29 |
30 | class Range < Matcher
31 |
32 | # A regexp for checking if a string is a numeric Ruby range
33 | #
34 | RUBY_NUMERIC_RANGE_REGEXP = Regexp.compile(
35 | "^\\d+(\\.\\d+)?\\.{2,3}\\d+(\\.\\d+)?$")
36 |
37 | # A regexp for checking if a string is an alpha Ruby range
38 | #
39 | RUBY_ALPHA_RANGE_REGEXP = Regexp.compile(
40 | "^([A-Za-z])(\\.{2,3})([A-Za-z])$")
41 |
42 | # If the string contains a Ruby range definition
43 | # (ie something like "93.0..94.5" or "56..72"), it will return
44 | # the Range instance.
45 | # Will return nil else.
46 | #
47 | # The Ruby range returned (if any) will accept String or Numeric,
48 | # ie (4..6).include?("5") will yield true.
49 | #
50 | def to_ruby_range(s)
51 |
52 | range = if RUBY_NUMERIC_RANGE_REGEXP.match(s)
53 |
54 | eval(s)
55 |
56 | else
57 |
58 | m = RUBY_ALPHA_RANGE_REGEXP.match(s)
59 |
60 | m ? eval("'#{m[1]}'#{m[2]}'#{m[3]}'") : nil
61 | end
62 |
63 | class << range
64 |
65 | alias :old_include? :include?
66 |
67 | def include? (elt)
68 | elt = first.is_a?(::Numeric) ? (Float(elt) rescue '') : elt
69 | old_include?(elt)
70 | end
71 |
72 | end if range
73 |
74 | range
75 | end
76 |
77 | def matches?(cell, value)
78 |
79 | range = to_ruby_range(cell)
80 |
81 | range && range.include?(value)
82 | end
83 | end
84 | end
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/demo/public/decision.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | testing a decision table
5 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |

74 |
75 |
76 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
136 |
137 |
138 |
139 |
140 |
--------------------------------------------------------------------------------
/examples/reimbursement.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # ruote + rufus-decision example
4 | #
5 | # featured in
6 | #
7 | # http://jmettraux.wordpress.com/2010/02/17/ruote-and-decision-tables/
8 | #
9 |
10 | require 'rubygems'
11 |
12 |
13 | #
14 | # preparing the workflow engine
15 |
16 | require 'ruote'
17 |
18 | engine = Ruote::Engine.new(Ruote::Worker.new(Ruote::HashStorage.new()))
19 | # for this example we use a transient, in-memory engine
20 |
21 | #engine.context.logger.noisy = true
22 | # useful when debugging
23 |
24 |
25 | #
26 | # the process definition
27 |
28 | pdef = Ruote.process_definition :name => 'reimbursement', :revision => '1.0' do
29 | cursor do
30 | customer :task => 'fill form'
31 | clerk :task => 'control form'
32 | rewind :if => '${f:missing_info}'
33 | determine_reimbursement_level
34 | finance :task => 'reimburse customer'
35 | customer :task => 'reimbursement notification'
36 | end
37 | end
38 |
39 |
40 | #
41 | # the 'inbox/worklist' participant
42 | #
43 | # for this example, they are only STDOUT/STDIN participants
44 |
45 | class StdoutParticipant
46 | include Ruote::LocalParticipant
47 |
48 | def initialize (opts)
49 | end
50 |
51 | def consume (workitem)
52 |
53 | puts '-' * 80
54 | puts "participant : #{workitem.participant_name}"
55 |
56 | if workitem.fields['params']['task'] == 'fill form'
57 | puts " * reimbursement claim, please fill details :"
58 | echo "description : "
59 | workitem.fields['description'] = STDIN.gets.strip
60 | echo "type of visit (doctor office / hospital visit / lab visit) : "
61 | workitem.fields['type of visit'] = STDIN.gets.strip
62 | echo "participating physician ? (yes or no) : "
63 | workitem.fields['participating physician ?'] = STDIN.gets.strip
64 | else
65 | puts " . reimbursement claim :"
66 | workitem.fields.each do |k, v|
67 | puts "#{k} --> #{v.inspect}"
68 | end
69 | end
70 |
71 | reply_to_engine(workitem)
72 | end
73 |
74 | def echo (s)
75 | print(s); STDOUT.flush
76 | end
77 | end
78 |
79 | engine.register_participant 'customer', StdoutParticipant
80 | engine.register_participant 'clerk', StdoutParticipant
81 | engine.register_participant 'finance', StdoutParticipant
82 |
83 |
84 | #
85 | # the "decision" participant
86 |
87 | #engine.register_participant 'determine_reimbursement_level' do |workitem|
88 | # workitem.fields['reimbursement'] =
89 | # case workitem.fields['type of visit']
90 | # when 'doctor office'
91 | # workitem.fields['participating physician ?'] == 'yes' ? '90%' : '50%'
92 | # when 'hospital visit'
93 | # workitem.fields['participating physician ?'] == 'yes' ? '0%' : '80%'
94 | # when 'lab visit'
95 | # workitem.fields['participating physician ?'] == 'yes' ? '0%' : '70%'
96 | # else
97 | # '0%'
98 | # end
99 | #end
100 |
101 | require 'rufus/decision/participant'
102 |
103 | #engine.register_participant(
104 | # 'determine_reimbursement_level',
105 | # Rufus::Decision::Participant,
106 | # :table => %{
107 | #in:type of visit,in:participating physician ?,out:reimbursement
108 | #doctor office,yes,90%
109 | #doctor office,no,50%
110 | #hospital visit,yes,0%
111 | #hospital visit,no,80%
112 | #lab visit,yes,0%
113 | #lab visit,no,70%
114 | # })
115 |
116 | engine.register_participant(
117 | 'determine_reimbursement_level',
118 | Rufus::Decision::Participant,
119 | :table => 'http://github.com/jmettraux/rufus-decision/raw/master/examples/reimbursement2.csv')
120 |
121 |
122 | #
123 | # running the example...
124 |
125 | puts '=' * 80
126 | puts "launching process"
127 |
128 | wfid = engine.launch(pdef)
129 |
130 | engine.wait_for(wfid)
131 | # don't let the Ruby runtime exits until our [unique] process instance is over
132 |
133 | #ps = engine.process(wfid)
134 | #if ps
135 | # puts ps.errors.first.message
136 | # puts ps.errors.first.trace
137 | #end
138 | # useful when debugging
139 |
140 | puts '=' * 80
141 | puts "done."
142 |
143 |
--------------------------------------------------------------------------------
/bin/rufus_decide:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | $:.unshift(File.dirname(__FILE__) + '/../lib') \
4 | if File.exist?(File.dirname(__FILE__) + '/../lib/rufus')
5 | # in dev mode, use the local rufus/decision
6 |
7 | require 'rubygems'
8 | require 'rufus/decision'
9 |
10 | rest = []
11 | opts = {}
12 | while arg = ARGV.shift do
13 | if arg.match(/^-/)
14 | opts[arg] = (ARGV.first && ! ARGV.first.match(/^-/)) ? ARGV.shift : true
15 | else
16 | rest << arg
17 | end
18 | end
19 |
20 | USAGE = %{
21 |
22 | = #{File.basename(__FILE__)} -i input.csv -t table.csv
23 |
24 | runs decision table 'table.csv' on input 'input.csv', outputs as CSV.
25 |
26 | == for example
27 |
28 | #{File.basename(__FILE__)} -i input.csv -t table.csv
29 |
30 | == options
31 |
32 | -v, --version : print the version of itog.rb and exits
33 | -h, --help : print this help text and exits
34 |
35 | -i, --input : points to input file (mandatory)
36 | -t, --table : points to the decision table file (mandatory)
37 |
38 | -r, --ruby : output as a Ruby hash representation instead of CSV
39 | -j, --json : output as a JSON hash representation instead of CSV
40 |
41 | -T, --through : don't stop at first match, run each row
42 | -I, --ignore-case : ignore case when comparing values for row matching
43 | -A, --accumulate : use with -t, each time a new match is made for an 'out',
44 | values are not overriden but gathered in an array
45 | -R, --ruby-eval : allow evaluation of embedded ruby code (potentially
46 | harmful)
47 |
48 | -g, --goal : points to an ideal target CSV file
49 | (decision table testing)
50 |
51 | }
52 |
53 | if (opts['-h'] or opts['--help'])
54 | puts USAGE
55 | exit(0)
56 | end
57 |
58 | if (opts['-v'] or opts['--version'])
59 | puts "rufus-decision #{Rufus::Decision::VERSION}"
60 | exit(0)
61 | end
62 |
63 | ipath = opts['-i'] || opts['--input']
64 | tpath = opts['-t'] || opts['--table']
65 | gpath = opts['-g'] || opts['--goal']
66 |
67 | if ipath == nil or tpath == nil
68 |
69 | puts
70 | puts " ** missing --input and/or --table parameter"
71 | puts USAGE
72 | exit(1)
73 | end
74 |
75 | #
76 | # load CSV files
77 |
78 | input = Rufus::Decision.csv_to_a(ipath)
79 | input = Rufus::Decision.transpose(input)
80 |
81 | params = {}
82 | params[:ignore_case] = opts['-I'] || opts['--ignore-case']
83 | params[:ruby_eval] = opts['-R'] || opts['--ruby-eval']
84 | params[:through] = opts['-T'] || opts['--through']
85 | params[:accumulate] = opts['-A'] || opts['--accumulate']
86 |
87 | table = Rufus::Decision::Table.new(tpath, params)
88 |
89 | goal = gpath ? Rufus::Decision.csv_to_a(gpath) : nil
90 |
91 | #
92 | # run the decision table for each input row
93 |
94 | output = input.inject([]) { |a, hash| a << table.transform(hash); a }
95 |
96 | if goal
97 | #
98 | # check if output matches 'goal'
99 |
100 | puts
101 |
102 | goal = Rufus::Decision.transpose(goal)
103 |
104 | failures = []
105 |
106 | goal.each_with_index do |hash, y|
107 | if hash == output[y]
108 | print '.'
109 | else
110 | print 'f'
111 | failures << [ y, output[y], hash ]
112 | end
113 | end
114 |
115 | puts
116 |
117 | failures.each do |f|
118 | row, output, expected = f
119 | puts
120 | puts " at row #{row}, expected"
121 | puts " #{expected.inspect}"
122 | puts " but got"
123 | puts " #{output.inspect}"
124 | end
125 |
126 | puts "\n#{goal.size} rows, #{failures.size} failures"
127 |
128 | else
129 | #
130 | # print output
131 |
132 | if opts['-j'] or opts['--json']
133 |
134 | require 'json' # sudo gem install json
135 | puts output.to_json
136 |
137 | elsif opts['-r'] or opts['--ruby']
138 |
139 | p output
140 |
141 | else # CSV
142 |
143 | output = Rufus::Decision.transpose(output)
144 | output.each do |row|
145 | puts row.join(',')
146 | end
147 | end
148 | end
149 |
150 |
--------------------------------------------------------------------------------
/lib/rufus/decision/participant.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # Copyright (c) 2007-2013, John Mettraux, jmettraux@gmail.com
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a copy
5 | # of this software and associated documentation files (the "Software"), to deal
6 | # in the Software without restriction, including without limitation the rights
7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | # copies of the Software, and to permit persons to whom the Software is
9 | # furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | # THE SOFTWARE.
21 | #
22 | # Made in Japan.
23 | #++
24 |
25 | require 'rufus/decision'
26 | require 'ruote/participant'
27 |
28 |
29 | module Rufus::Decision
30 |
31 | #
32 | # Decision participants were named 'CSV participants' prior to ruote 2.1.
33 | #
34 | # Make sure you have the gem "rufus-decision" installed in order to
35 | # use this decision participant.
36 | #
37 | #
38 | # == blog post
39 | #
40 | # This Rufus::Decision::Participant was introduced in the "ruote and
41 | # decision tables" blog post :
42 | #
43 | # http://jmettraux.wordpress.com/2010/02/17/ruote-and-decision-tables/
44 | #
45 | #
46 | # == example
47 | #
48 | # In this example, a participant named 'decide_team_member' is bound in the
49 | # ruote engine and, depending on the value of the workitem fields 'topic'
50 | # and region, sets the value of the field named 'team_member' :
51 | #
52 | # require 'rufus/decision/participant'
53 | #
54 | # engine.register_participant(
55 | # :decide_team_member
56 | # Rufus::Decision::Participant, :table => %{
57 | # in:topic,in:region,out:team_member
58 | # sports,europe,Alice
59 | # sports,,Bob
60 | # finance,america,Charly
61 | # finance,europe,Donald
62 | # finance,,Ernest
63 | # politics,asia,Fujio
64 | # politics,america,Gilbert
65 | # politics,,Henry
66 | # ,,Zach
67 | # })
68 | #
69 | # pdef = Ruote.process_definition :name => 'dec-test', :revision => '1' do
70 | # sequence do
71 | # decide_team_member
72 | # participant :ref => '${team_member}'
73 | # end
74 | # end
75 | #
76 | # A process instance about the election results in Venezuela :
77 | #
78 | # engine.launch(
79 | # pdef,
80 | # 'topic' => 'politics',
81 | # 'region' => 'america',
82 | # 'line' => 'election results in Venezuela')
83 | #
84 | # would thus get routed to Gilbert.
85 | #
86 | # To learn more about decision tables :
87 | #
88 | # http://github.com/jmettraux/rufus-decision
89 | #
90 | #
91 | # == pointing to a table via a URI
92 | #
93 | # Note that you can reference the table by its URI :
94 | #
95 | # engine.register_participant(
96 | # :decide_team_member
97 | # Rufus::Decision::Participant,
98 | # :table => 'http://decisions.example.com/journalists.csv')
99 | #
100 | # If the table were a Google Spreadsheet, it would look like (note the
101 | # trailing &output=csv) :
102 | #
103 | # engine.register_participant(
104 | # :decide_team_member
105 | # Rufus::Decision::Participant,
106 | # :table => 'http://spreadsheets.google.com/pub?key=pCZNVR1TQ&output=csv')
107 | #
108 | class Participant
109 | include Ruote::LocalParticipant
110 |
111 | def initialize(opts={})
112 |
113 | @options = opts
114 | end
115 |
116 | def consume(workitem)
117 |
118 | table = @options['table']
119 | raise(ArgumentError.new("'table' option is missing")) unless table
120 |
121 | table = Rufus::Decision::Table.new(table, @options)
122 |
123 | workitem.fields = table.run(workitem.fields)
124 |
125 | reply_to_engine(workitem)
126 | end
127 | end
128 | end
129 |
130 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | # rufus-decision
3 |
4 | [](http://travis-ci.org/jmettraux/rufus-decision)
5 |
6 | CSV based Ruby decision tables.
7 |
8 | *WARNING*: this library is not maintained anymore. Sorry.
9 |
10 |
11 | ## getting it
12 |
13 | ```
14 | gem install rufus-decision
15 | ```
16 |
17 | or add ```gem 'rufus-decision'``` to your Gemfile.
18 |
19 |
20 | ## tools based on rufus-decision
21 |
22 | * https://github.com/lastobelus/rudelo - Set Logic matcher
23 |
24 |
25 | ## blog posts
26 |
27 | * http://jmettraux.wordpress.com/2009/04/25/rufus-decision-11-ruby-decision-tables/
28 | * http://jmettraux.wordpress.com/2010/02/17/ruote-and-decision-tables/
29 |
30 |
31 | ## usage
32 |
33 | More info at http://rufus.rubyforge.org/rufus-decision/Rufus/Decision/Table.html but here is a recap.
34 |
35 | An example where a few rules determine which salesperson should interact with a customer with given characteristics.
36 |
37 |
38 | ```ruby
39 | require 'rubygems'
40 | require 'rufus/decision'
41 |
42 | TABLE = Rufus::Decision::Table.new(%{
43 | in:age,in:trait,out:salesperson
44 |
45 | 18..35,,adeslky
46 | 25..35,,bronco
47 | 36..50,,espadas
48 | 51..78,,thorsten
49 | 44..120,,ojiisan
50 |
51 | 25..35,rich,kerfelden
52 | ,cheerful,swanson
53 | ,maniac,korolev
54 | })
55 |
56 | # Given a customer (a Hash instance directly, for
57 | # convenience), returns the name of the first
58 | # corresponding salesman.
59 | #
60 | def determine_salesperson (customer)
61 |
62 | TABLE.transform(customer)["salesperson"]
63 | end
64 |
65 | puts determine_salesperson(
66 | "age" => 72) # => thorsten
67 |
68 | puts determine_salesperson(
69 | "age" => 25, "trait" => "rich") # => adeslky
70 |
71 | puts determine_salesperson(
72 | "age" => 23, "trait" => "cheerful") # => adeslky
73 |
74 | puts determine_salesperson(
75 | "age" => 25, "trait" => "maniac") # => adeslky
76 |
77 | puts determine_salesperson(
78 | "age" => 44, "trait" => "maniac") # => espadas
79 | ```
80 |
81 | More at Rufus::Decision::Table
82 |
83 | Note that you can use a CSV table served over HTTP like in :
84 |
85 | ```ruby
86 | require 'rubygems'
87 | require 'rufus/decision'
88 |
89 | TABLE = Rufus::DecisionTable.new(
90 | 'https://spreadsheets.google.com/pub?key=pCkopoeZwCNsMWOVeDjR1TQ&output=csv')
91 |
92 | # the CSV is :
93 | #
94 | # in:weather,in:month,out:take_umbrella?
95 | #
96 | # raining,,yes
97 | # sunny,,no
98 | # cloudy,june,yes
99 | # cloudy,may,yes
100 | # cloudy,,no
101 |
102 | def take_umbrella? (weather, month=nil)
103 | h = TABLE.transform('weather' => weather, 'month' => month)
104 | h['take_umbrella?'] == 'yes'
105 | end
106 |
107 | puts take_umbrella?('cloudy', 'june')
108 | # => true
109 |
110 | puts take_umbrella?('sunny', 'june')
111 | # => false
112 | ```
113 |
114 | In this example, the CSV table is the direction CSV representation of the Google spreadsheet at : https://spreadsheets.google.com/pub?key=pCkopoeZwCNsMWOVeDjR1TQ
115 |
116 | WARNING though : use at your own risk. CSV loaded from untrusted locations may contain harmful code. The rufus-decision gem has an abstract tree checker integrated, it will check all the CSVs that contain calls in Ruby and raise a security error when possibly harmful code is spotted. Bullet vs Armor. Be warned.
117 |
118 | ### redirections
119 |
120 | (Courtesy of [lastobelus](https://github.com/lastobelus))
121 |
122 | To help with redirections, one can modify the above example into:
123 |
124 | ```ruby
125 | require 'rubygems'
126 | require 'rufus/decision'
127 |
128 | require 'open_uri_redirections'
129 | # https://github.com/jaimeiniesta/open_uri_redirections
130 |
131 | TABLE = Rufus::DecisionTable.new(
132 | 'http://spreadsheets.google.com/pub?key=pCkopoeZwCNsMWOVeDjR1TQ&output=csv',
133 | :open_uri => { :allow_redirections => :safe })
134 | ```
135 |
136 |
137 | ## web demo
138 |
139 | There is a small demo of an input table + a decision table. You can run it by doing :
140 |
141 | ```
142 | # (ouch, kind of old style...)
143 |
144 | gem install sinatra
145 | git clone git://github.com/jmettraux/rufus-decision.git
146 | cd rufus-decision
147 | ruby demo/start.rb
148 | ```
149 |
150 | and then point your browser to http://localhost:4567/
151 |
152 |
153 | ## dependencies
154 |
155 | The gem 'rufus-dollar' (http://rufus.rubyforge.org/rufus-dollar) and the 'rufus-treechecker' gem (http://rufus.rubyforge.org/rufus-treechecker).
156 |
157 |
158 | ## mailing list
159 |
160 | On the rufus-ruby list : http://groups.google.com/group/rufus-ruby
161 |
162 |
163 | ## irc
164 |
165 | irc.freenode.net #ruote
166 |
167 |
168 | ## issue tracker
169 |
170 | https://github.com/jmettraux/rufus-decision/issues
171 |
172 |
173 | ## source
174 |
175 | https://github.com/jmettraux/rufus-decision
176 |
177 | ```
178 | git clone git://github.com/jmettraux/rufus-decision.git
179 | ```
180 |
181 |
182 | ## author
183 |
184 | John Mettraux, jmettraux@gmail.com
185 | http://jmettraux.wordpress.com
186 |
187 |
188 | ## license
189 |
190 | MIT
191 |
192 |
--------------------------------------------------------------------------------
/lib/rufus/decision/hashes.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # Copyright (c) 2008-2013, John Mettraux, jmettraux@gmail.com
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a copy
5 | # of this software and associated documentation files (the "Software"), to deal
6 | # in the Software without restriction, including without limitation the rights
7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | # copies of the Software, and to permit persons to whom the Software is
9 | # furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | # THE SOFTWARE.
21 | #
22 | # Made in Japan.
23 | #++
24 |
25 | require 'fileutils'
26 | require 'rufus/treechecker'
27 |
28 |
29 | module Rufus
30 | module Decision
31 |
32 | #
33 | # In the Java world, this class would be considered abstract.
34 | #
35 | # It's used to build sequences of filter on hashes (or instances
36 | # that respond to the [], []=, has_key? methods).
37 | #
38 | # It relies on 'prefixed string keys' like "x:y", where the prefix is 'x'
39 | # and the partial key is then 'y'.
40 | #
41 | # Rufus::EvalHashFilter is an implementation of an HashFilter.
42 | #
43 | class HashFilter
44 |
45 | attr_reader :parent_hash
46 |
47 | def initialize(parent_hash)
48 |
49 | @parent_hash = parent_hash
50 | end
51 |
52 | def [](key)
53 |
54 | p, k = do_split(key)
55 |
56 | do_lookup(key, p, k)
57 | end
58 |
59 | def []=(key, value)
60 |
61 | p, v = do_split(value)
62 |
63 | do_put(key, value, p, v)
64 | end
65 |
66 | protected
67 |
68 | def do_split(element)
69 |
70 | return [ nil, element ] unless element.is_a?(String)
71 |
72 | a = element.split(':', 2)
73 | return [ nil ] + a if a.length == 1
74 | a
75 | end
76 |
77 | def handles_prefix?(p)
78 |
79 | false
80 | end
81 |
82 | def do_eval(key, p, k)
83 |
84 | raise NotImplementedError.new(
85 | "missing do_eval(key, p, k) implementation")
86 | end
87 |
88 | def do_lookup(key, p, k)
89 |
90 | if handles_prefix?(p)
91 |
92 | do_eval(key, p, k)
93 |
94 | elsif @parent_hash.respond_to?(:do_lookup)
95 |
96 | @parent_hash.do_lookup(key, p, k)
97 |
98 | else
99 |
100 | @parent_hash[key]
101 | end
102 | end
103 |
104 | def do_put(key, value, p, v)
105 |
106 | val = value
107 |
108 | if handles_prefix?(p)
109 |
110 | @parent_hash[key] = do_eval(value, p, v)
111 |
112 | elsif @parent_hash.respond_to?(:do_put)
113 |
114 | @parent_hash.do_put key, value, p, v
115 |
116 | else
117 |
118 | @parent_hash[key] = value
119 | end
120 | end
121 | end
122 |
123 | #
124 | # Implements the r:, ruby: and reval: prefixes in lookups
125 | #
126 | # require 'rubygems'
127 | # require 'rufus/decision/hashes'
128 | #
129 | # h = {}
130 | #
131 | # eh = Rufus::Decision::EvalHashFilter.new(h)
132 | #
133 | # eh['a'] = :a
134 | # p h # => { 'a' => :a }
135 | #
136 | # eh['b'] = "r:5 * 5"
137 | # p h # => { 'a' => :a, 'b' => 25 }
138 | #
139 | # assert_equal :a, eh['a']
140 | # assert_equal 25, eh['b']
141 | # assert_equal 72, eh['r:36+36']
142 | #
143 | class EvalHashFilter < HashFilter
144 |
145 | def initialize(parent_hash)
146 |
147 | super(parent_hash)
148 | end
149 |
150 | protected
151 |
152 | RP = %w{ r ruby reval }
153 |
154 | def handles_prefix? (prefix)
155 |
156 | RP.include?(prefix)
157 | end
158 |
159 | # Ready for override.
160 | #
161 | def get_binding
162 |
163 | binding()
164 | end
165 |
166 | def do_eval(key, p, k)
167 |
168 | Rufus::Decision.check_and_eval(k, get_binding)
169 | end
170 | end
171 |
172 | # An abstract syntax tree check (prior to any ruby eval)
173 | #
174 | TREECHECKER = Rufus::TreeChecker.new do
175 |
176 | exclude_fvccall :abort, :exit, :exit!
177 | exclude_fvccall :system, :fork, :syscall, :trap, :require, :load
178 |
179 | #exclude_call_to :class
180 | exclude_fvcall :private, :public, :protected
181 |
182 | exclude_def # no method definition
183 | exclude_eval # no eval, module_eval or instance_eval
184 | exclude_backquotes # no `rm -fR the/kitchen/sink`
185 | exclude_alias # no alias or aliast_method
186 | exclude_global_vars # $vars are off limits
187 | exclude_module_tinkering # no module opening
188 | exclude_raise # no raise or throw
189 |
190 | exclude_rebinding Kernel # no 'k = Kernel'
191 |
192 | exclude_access_to(
193 | IO, File, FileUtils, Process, Signal, Thread, ThreadGroup)
194 |
195 | exclude_class_tinkering
196 |
197 | exclude_call_to :instance_variable_get, :instance_variable_set
198 | end
199 | TREECHECKER.freeze
200 |
201 | # Given a string (of ruby code), first makes sure it doesn't contain
202 | # harmful code, then evaluates it.
203 | #
204 | def self.check_and_eval(ruby_code, bndng=binding())
205 |
206 | begin
207 | TREECHECKER.check(ruby_code)
208 | rescue
209 | raise $!, "Error parsing #{ruby_code} -> #{$!}", $!.backtrace
210 | end
211 | # OK, green for eval...
212 |
213 | begin
214 | eval(ruby_code, bndng)
215 | rescue
216 | raise $!, "Error evaling #{ruby_code} -> #{$!}", $!.backtrace
217 | end
218 | end
219 | end
220 | end
221 |
222 |
--------------------------------------------------------------------------------
/test/dt_0_basic.rb:
--------------------------------------------------------------------------------
1 |
2 | #
3 | # testing rufus-decision
4 | #
5 | # Sun Oct 29 15:41:44 JST 2006
6 | #
7 |
8 | require File.expand_path('../base.rb', __FILE__)
9 |
10 |
11 | class Dt0Test < Test::Unit::TestCase
12 | include DecisionTestMixin
13 |
14 | CSV0 = %{
15 | ,,
16 | in:fx,in:fy,out:fz
17 | ,,
18 | a,b,0
19 | c,d,1
20 | e,f,2
21 | }
22 |
23 | def test_0
24 |
25 | wi = {
26 | "fx" => "c",
27 | "fy" => "d"
28 | }
29 | do_test(CSV0, wi, { "fz" => "1" }, false)
30 |
31 | wi = {
32 | "fx" => "a",
33 | "fy" => "d"
34 | }
35 | do_test(CSV0, wi, { "fz" => nil }, false)
36 | end
37 |
38 | CSV0B = %{
39 | in:fx,in:fy,out:fz
40 |
41 | a,b,0
42 | c,d,1
43 | e,f,2
44 | }
45 |
46 | def test_0b
47 |
48 | wi = {
49 | "fx" => "c",
50 | "fy" => "d"
51 | }
52 | do_test(CSV0B, wi, { "fz" => "1" }, false)
53 | end
54 |
55 | # test 1 moved to decision_1_test.rb
56 |
57 | CSV2 = %{
58 | in:fx, in:fy, out:fz
59 | ,,
60 | a, b, 0
61 | c, d, 1
62 | e, f, 2
63 | }
64 |
65 | def test_2
66 |
67 | wi = { "fx" => "c", "fy" => "d" }
68 | do_test(CSV2, wi, { "fz" => "1" }, false)
69 |
70 | wi = { "fx" => "a", "fy" => "d" }
71 | do_test(CSV2, wi, { "fz" => nil }, false)
72 | end
73 |
74 | CSV3 = %{
75 | in:weather, in:month, out:take_umbrella?
76 | ,,
77 | raining, , yes
78 | sunny, , no
79 | cloudy, june, yes
80 | cloudy, may, yes
81 | cloudy, , no
82 | }
83 |
84 | def test_3
85 |
86 | wi = {
87 | "weather" => "raining",
88 | "month" => "december"
89 | }
90 | do_test(CSV3, wi, { "take_umbrella?" => "yes" }, false)
91 |
92 | wi = {
93 | "weather" => "cloudy",
94 | "month" => "june"
95 | }
96 | do_test(CSV3, wi, { "take_umbrella?" => "yes" }, false)
97 |
98 | wi = {
99 | "weather" => "cloudy",
100 | "month" => "march"
101 | }
102 | do_test(CSV3, wi, { "take_umbrella?" => "no" }, false)
103 | end
104 |
105 | def test_3b
106 |
107 | h = {}
108 | h["weather"] = "raining"
109 | h["month"] = "december"
110 | do_test(CSV3, h, { "take_umbrella?" => "yes" }, false)
111 |
112 | h = {}
113 | h["weather"] = "cloudy"
114 | h["month"] = "june"
115 | do_test(CSV3, h, { "take_umbrella?" => "yes" }, false)
116 |
117 | h = {}
118 | h["weather"] = "cloudy"
119 | h["month"] = "march"
120 | do_test(CSV3, h, { "take_umbrella?" => "no" }, false)
121 | end
122 |
123 | def test_3c
124 |
125 | table = Rufus::DecisionTable.new(%{
126 | in:topic,in:region,out:team_member
127 | sports,europe,Alice
128 | sports,,Bob
129 | finance,america,Charly
130 | finance,europe,Donald
131 | finance,,Ernest
132 | politics,asia,Fujio
133 | politics,america,Gilbert
134 | politics,,Henry
135 | ,,Zach
136 | })
137 |
138 | h = {}
139 | h["topic"] = "politics"
140 | table.transform! h
141 |
142 | assert_equal "Henry", h["team_member"]
143 | end
144 |
145 | def test_3e
146 |
147 | table = Rufus::DecisionTable.new(%{
148 | in:topic,in:region,out:team_member
149 | sports,europe,Alice
150 | })
151 |
152 | h0 = {}
153 | h0["topic"] = "politics"
154 | h1 = table.transform! h0
155 |
156 | assert_equal h0.object_id, h1.object_id
157 |
158 | h0 = {}
159 | h0["topic"] = "politics"
160 | h1 = table.transform h0
161 |
162 | assert_not_equal h0.object_id, h1.object_id
163 | end
164 |
165 |
166 | def test_through_and_ignorecase
167 |
168 | table = %{
169 | through,ignorecase,,
170 | ,,,
171 | in:fx, in:fy, out:fX, out:fY
172 | ,,,
173 | a, , true,
174 | , a, , true
175 | b, , false,
176 | , b, , false
177 | }
178 |
179 | wi = { 'fx' => 'a', 'fy' => 'a' }
180 | do_test(table, wi, { 'fX' => 'true', 'fY' => 'true' }, false)
181 |
182 | wi = { 'fx' => 'a', 'fy' => 'b' }
183 | do_test(table, wi, { 'fX' => 'true', 'fY' => 'false' }, false)
184 |
185 | wi = { 'fx' => 'A', 'fy' => 'b' }
186 | do_test(table, wi, { 'fX' => 'true', 'fY' => 'false' }, false)
187 | end
188 |
189 | def test_through_and_ignorecase_set_at_table_initialization
190 |
191 | table = %{
192 | in:fx, in:fy, out:fX, out:fY
193 | ,,,
194 | a, , true,
195 | , a, , true
196 | b, , false,
197 | , b, , false
198 | }
199 |
200 | table = Rufus::Decision::Table.new(
201 | table, :through => true, :ignore_case => true)
202 |
203 | wi = { 'fx' => 'a', 'fy' => 'a' }
204 | do_test(table, wi, { 'fX' => 'true', 'fY' => 'true' }, false)
205 |
206 | wi = { 'fx' => 'a', 'fy' => 'b' }
207 | do_test(table, wi, { 'fX' => 'true', 'fY' => 'false' }, false)
208 |
209 | wi = { 'fx' => 'A', 'fy' => 'b' }
210 | do_test(table, wi, { 'fX' => 'true', 'fY' => 'false' }, false)
211 | end
212 |
213 | #
214 | # TEST 6
215 |
216 | CSV6 = %{
217 | ,
218 | in:fx, out:fy
219 | ,
220 | <10,a
221 | <=100,b
222 | ,c
223 | }
224 |
225 | def test_6
226 |
227 | wi = {
228 | "fx" => "5"
229 | }
230 | do_test(CSV6, wi, { "fy" => "a" }, false)
231 |
232 | wi = {
233 | "fx" => "100.0001"
234 | }
235 | do_test(CSV6, wi, { "fy" => "c" }, false)
236 | end
237 |
238 | #
239 | # TEST 7
240 |
241 | CSV7 = %{
242 | ,
243 | in:fx, out:fy
244 | ,
245 | >100,a
246 | >=10,b
247 | ,c
248 | }
249 |
250 | def test_7
251 |
252 | wi = {
253 | "fx" => "5"
254 | }
255 | do_test(CSV7, wi, { "fy" => "c" }, false)
256 |
257 | wi = {
258 | "fx" => "10"
259 | }
260 | do_test(CSV7, wi, { "fy" => "b" }, false)
261 |
262 | wi = {
263 | "fx" => "10a"
264 | }
265 | do_test(CSV7, wi, { "fy" => "a" }, false)
266 | end
267 |
268 | CSV8 = trim_table %{
269 | in:efx,in:efy,out:efz
270 | a,b,0
271 | c,d,1
272 | e,f,2
273 | }
274 |
275 | def test_8
276 |
277 | assert_equal CSV8, Rufus::DecisionTable.new(CSV8).to_csv
278 | end
279 |
280 | CSV9 = %{
281 | in:fx,in:fy,out:fz
282 | a,b,0
283 | c,d,${r: 1 + 2}
284 | e,f,2
285 | }
286 |
287 | def test_ruby_eval
288 |
289 | table = Rufus::Decision::Table.new(CSV9, :ruby_eval => true)
290 |
291 | wi = { 'fx' => 'c', 'fy' => 'd' }
292 | do_test(table, wi, { 'fz' => '3' }, false)
293 | end
294 |
295 | def test_ruby_eval_string_key
296 |
297 | table = Rufus::Decision::Table.new(CSV9, 'ruby_eval' => true)
298 |
299 | wi = { 'fx' => 'c', 'fy' => 'd' }
300 | do_test(table, wi, { 'fz' => '3' }, false)
301 | end
302 |
303 | CSV10 = %{
304 | in:fx,in:fx,out:fz
305 | >90,<92,ok
306 | ,,bad
307 | }
308 |
309 | def test_10
310 |
311 | wi = { "fx" => "91" }
312 | do_test(CSV10, wi, { "fz" => "ok" }, false)
313 |
314 | wi = { "fx" => "95" }
315 | do_test(CSV10, wi, { "fz" => "bad" }, false)
316 |
317 | wi = { "fx" => "81" }
318 | do_test(CSV10, wi, { "fz" => "bad" }, false)
319 | end
320 |
321 | CSV11 = %{
322 | through
323 | in:f1,in:f1,in:f2,in:f3,out:o1,out:e1,out:e2
324 |
325 | <100,>=95,<=2.0,<=5,"Output1",,
326 | <100,>=95,,,"Output2",,
327 | <100,>=95,>2.0,,"Expection1",,
328 | <100,>=95,,>2.0,,,"Expection2"
329 | <100,>=95,,>2.0,"Invalid",,
330 | }
331 |
332 | def test_fu_zhang
333 |
334 | wi = { 'f1' => 97, 'f2' => 5 }
335 | do_test CSV11, wi, { 'o1' => 'Expection1' }, false
336 | end
337 |
338 | CSV12 = %{
339 | accumulate
340 | ,,,
341 | in:fx, in:fy, out:fX, out:fY
342 | ,,,
343 | a, , red, green
344 | , a, blue, purple
345 | b, , yellow, beige
346 | , b, white, kuro
347 | }
348 |
349 | def test_accumulate
350 |
351 | wi = { "fx" => "a", "fy" => "a" }
352 | do_test CSV12, wi, { "fX" => "red;blue", "fY" => "green;purple" }, false
353 |
354 | wi = { "fx" => "a", "fy" => "a", "fX" => "BLACK" }
355 | do_test CSV12, wi, {
356 | "fX" => "BLACK;red;blue", "fY" => "green;purple" }, false
357 |
358 | wi = { "fx" => "a", "fy" => "a", "fX" => [ "BLACK", "BLUE" ] }
359 | do_test CSV12, wi, {
360 | "fX" => "BLACK;BLUE;red;blue", "fY" => "green;purple" }, false
361 | end
362 |
363 | def test_to_ruby_range
364 |
365 | dt = Rufus::Decision::Matchers::Range.new
366 |
367 | assert_not_nil dt.to_ruby_range("99..100")
368 | assert_not_nil dt.to_ruby_range("99...100")
369 | assert_not_nil dt.to_ruby_range("99.12..100.56")
370 | assert_nil dt.to_ruby_range("99....100")
371 | assert_nil dt.to_ruby_range("9a9..100")
372 | assert_nil dt.to_ruby_range("9a9..1a00")
373 |
374 | assert_equal dt.to_ruby_range("a..z"), 'a'..'z'
375 | assert_equal dt.to_ruby_range("a..Z"), 'a'..'Z'
376 | assert_equal dt.to_ruby_range("a...z"), 'a'...'z'
377 | assert_nil dt.to_ruby_range("a..%")
378 |
379 | r = dt.to_ruby_range "4..6"
380 | assert r.include?(5)
381 | assert r.include?("5")
382 | assert (not r.include?(7))
383 | assert (not r.include?("7"))
384 | end
385 |
386 | CSV13 = %{
387 | in:fx,out:fz
388 | 90..92,ok
389 | ,bad
390 | }
391 |
392 | def test_range
393 |
394 | wi = { "fx" => "91" }
395 | do_test CSV13, wi, { "fz" => "ok" }, false
396 |
397 | wi = { "fx" => "95" }
398 | do_test CSV13, wi, { "fz" => "bad" }, false
399 |
400 | wi = { "fx" => "81" }
401 | do_test CSV13, wi, { "fz" => "bad" }, false
402 | end
403 | end
404 |
405 |
--------------------------------------------------------------------------------
/demo/public/js/ruote-sheets.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright (c) 2009-2013, John Mettraux, jmettraux@gmail.com
3 | *
4 | * Permission is hereby granted, free of charge, to any person obtaining a copy
5 | * of this software and associated documentation files (the "Software"), to deal
6 | * in the Software without restriction, including without limitation the rights
7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | * copies of the Software, and to permit persons to whom the Software is
9 | * furnished to do so, subject to the following conditions:
10 | *
11 | * The above copyright notice and this permission notice shall be included in
12 | * all copies or substantial portions of the Software.
13 | *
14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | * THE SOFTWARE.
21 | *
22 | * Made in Japan.
23 | */
24 |
25 | var RuoteSheets = function() {
26 |
27 | var DEFAULT_CELL_WIDTH = 150;
28 |
29 | //
30 | // MISC FUNCTIONS
31 |
32 | function dwrite(msg) {
33 | document.body.appendChild(document.createTextNode(msg));
34 | document.body.appendChild(document.createElement('br'));
35 | }
36 |
37 | function findElt(i) {
38 | if ((typeof i) == 'string') return document.getElementById(i);
39 | else return i;
40 | }
41 |
42 | function createElement(parentNode, tag, klass, atts) {
43 | var e = document.createElement(tag);
44 | if (parentNode) parentNode.appendChild(e);
45 | if (klass) e.setAttribute('class', klass);
46 | if (atts) { for (k in atts) { e.setAttribute(k, atts[k]); } }
47 | return e;
48 | }
49 |
50 | function addClass(elt, klass) {
51 | elt.setAttribute('class', elt.getAttribute('class') + ' ' + klass);
52 | }
53 | function removeClass(elt, klass) {
54 | // warning : naive on the right border (after word)
55 | elt.setAttribute(
56 | 'class', elt.getAttribute('class').replace(' ' + klass, ''));
57 | }
58 |
59 | function placeAfter(elt, newElt) {
60 | var p = elt.parentNode;
61 | var n = elt.nextSibling;
62 | if (n) p.insertBefore(newElt, n);
63 | else p.appendChild(newElt);
64 | }
65 |
66 | //
67 | // EVENT HANDLERS
68 |
69 | function cellOnFocus(evt) {
70 | var e = evt || window.event;
71 | var sheet = e.target.parentNode.parentNode;
72 | unfocusSheet(sheet);
73 | setCurrentCell(sheet, e.target);
74 | }
75 |
76 | function cellOnKeyDown(evt) {
77 | var e = evt || window.event;
78 | var cell = e.target;
79 | if ( ! cell.previousValue) {
80 | save(cell.parentNode.parentNode);
81 | cell.previousValue = cell.value;
82 | }
83 | return true;
84 | }
85 |
86 | function cellOnKeyUp(evt) {
87 | var e = evt || window.event;
88 | var c = e.charCode || e.keyCode;
89 | //alert("" + c + " / " + e.ctrlKey + " - " + e.altKey + " - " + e.shiftKey);
90 | if (c == 38 || c == 40) move(e, c);
91 | if (isCellEmpty(e.target) && (c == 37 || c == 39)) move(e, c);
92 | return (c != 13);
93 | }
94 |
95 | function cellOnChange(evt) {
96 | var e = evt || window.event;
97 | e.target.previousValue = null; // cleaning
98 | }
99 |
100 | function handleOnMouseDown(evt) {
101 | var e = evt || window.event;
102 | var headcell = e.target.parentNode;
103 | var headrow = headcell.parentNode;
104 | var col = determineRowCol(headcell)[1];
105 | headrow.down = [ e.target.parentNode, e.clientX, col ];
106 | }
107 |
108 | function rowOnMouseDown(evt) {
109 |
110 | var e = evt || window.event;
111 | var sheet = e.target.parentNode.parentNode;
112 | sheet.mouseDownCell = e.target;
113 | }
114 |
115 | function rowOnMouseUp(evt) {
116 |
117 | var e = evt || window.event;
118 |
119 | var sheet = e.target.parentNode.parentNode;
120 |
121 | var c0 = sheet.mouseDownCell;
122 | if ( ! c0) return;
123 |
124 | var c1 = findCellByCoordinates(sheet, e.clientX, e.clientY);
125 | if ( ! c1) c1 = c0;
126 |
127 | var rc0 = determineRowCol(c0);
128 | var rc1 = determineRowCol(c1);
129 | var minx = Math.min(rc0[1], rc1[1]);
130 | var miny = Math.min(rc0[0], rc1[0]);
131 | var maxx = Math.max(rc0[1], rc1[1]);
132 | var maxy = Math.max(rc0[0], rc1[0]);
133 |
134 | //dwrite('' + minx + '/' + miny + ' // ' + maxx + '/' + maxy);
135 | iterate(sheet, function(t, x, y, e) {
136 | if (t != 'cell') return false;
137 | if (x < minx || x > maxx) return false;
138 | if (y < miny || y > maxy) return false;
139 | addClass(e, 'focused');
140 | });
141 | }
142 |
143 | function getHeadRow(elt) {
144 | var t = getRuseType(elt);
145 | if (t == 'headcell') return getHeadRow(elt.parentNode);
146 | if (t == 'headrow') return elt;
147 | return null;
148 | }
149 |
150 | function handleOnMouseUp(evt) {
151 |
152 | var e = evt || window.event;
153 |
154 | var hr = getHeadRow(e.target);
155 |
156 | if ( ! hr.down) return;
157 |
158 | var d = e.clientX - hr.down[1];
159 | var w = hr.down[0].offsetWidth + d;
160 | if (w < 12) w = 12; // minimal width
161 |
162 | save(hr.parentNode);
163 |
164 | iterate(hr.parentNode, function(t, x, y, e) {
165 | if ((t == 'cell' || t == 'headcell') && x == hr.down[2]) {
166 | e.style.width = '' + w + 'px';
167 | }
168 | });
169 | hr.down = null;
170 | }
171 |
172 | //
173 | // ...
174 |
175 | function unfocusSheet(sheet) {
176 | iterate(sheet, function (t, x, y, e) {
177 | if (t == 'cell') removeClass(e, 'focused');
178 | });
179 | }
180 |
181 | function getCurrentCell(sheet) {
182 |
183 | sheet = findElt(sheet);
184 |
185 | if (( ! sheet.currentCell) ||
186 | ( ! sheet.currentCell.parentNode) ||
187 | ( ! sheet.currentCell.parentNode.parentNode)) {
188 |
189 | sheet.currentCell = null;
190 | return findCell(sheet, 0, 0);
191 | }
192 | return sheet.currentCell;
193 | }
194 |
195 | function setCurrentCell(sheet, cell) {
196 |
197 | findElt(sheet).currentCell = cell;
198 | }
199 |
200 | function isCellEmpty(elt) {
201 |
202 | return (elt.value == '');
203 | }
204 |
205 | function move(e, c) {
206 |
207 | var rc = determineRowCol(e.target);
208 | var row = rc[0]; var col = rc[1];
209 |
210 | if (c == 38) row--;
211 | else if (c == 40) row++;
212 | else if (c == 37) col--;
213 | else if (c == 39) col++;
214 |
215 | var cell = findCell(e.target.parentNode.parentNode, row, col);
216 |
217 | if (cell != null) {
218 | cell.focus();
219 | addClass(cell, 'focused'); // not using :focus
220 | setCurrentCell(cell.parentNode.parentNode, cell);
221 | }
222 | }
223 |
224 | function determineRowCol(elt) {
225 |
226 | var cc = elt.getAttribute('class').split(' ');
227 | return [ cc[1].split('_')[1], cc[2].split('_')[1] ];
228 | }
229 |
230 | function findRow(sheet, y) {
231 | var row = iterate(sheet, function(t, x, yy, e) {
232 | if (t == 'row' && yy == y) return e;
233 | });
234 | return row ? row[0] : null;
235 | }
236 |
237 | function findCell(sheet, y, x) {
238 |
239 | var row = findRow(sheet, y);
240 | if (row == null) return null;
241 |
242 | for (var i = 0; i < row.childNodes.length; i++) {
243 | var c = row.childNodes[i];
244 | if (getRuseType(c) != 'cell') continue;
245 | if (c.getAttribute('class').match(' column_' + x)) return c;
246 | }
247 | return null;
248 | }
249 |
250 | function computeColumns(data) {
251 |
252 | var cols = 0;
253 |
254 | for (var y = 0; y < data.length; y++) {
255 | var row = data[y];
256 | if (row.length > cols) cols = row.length;
257 | }
258 | return cols;
259 | }
260 |
261 | function getWidths(sheet) {
262 | var widths = [];
263 | iterate(sheet, function(t, x, y, e) {
264 | if (t == 'headcell') widths.push(e.offsetWidth);
265 | });
266 | return widths;
267 | }
268 |
269 | function getRuseType(elt) {
270 | if (elt.nodeType != 1) return false;
271 | var c = elt.getAttribute('class');
272 | if ( ! c) return false;
273 | var m = c.match(/^ruse_([^ ]+)/);
274 | return m ? m[1] : false;
275 | }
276 |
277 | function renderHeadCell(headrow, x, w) {
278 |
279 | var c = createElement(headrow, 'div', 'ruse_headcell row_-1 column_' + x);
280 |
281 | c.style.width = '' + (w || DEFAULT_CELL_WIDTH) + 'px';
282 |
283 | createElement(c, 'div', 'ruse_headcell_left');
284 |
285 | var handle = createElement(c, 'div', 'ruse_headcell_handle');
286 | handle.onmousedown = handleOnMouseDown;
287 |
288 | return c;
289 | }
290 |
291 | function renderHeadRow(sheet, widths, cols) {
292 |
293 | var headrow = createElement(sheet, 'div', 'ruse_headrow');
294 | headrow.onmouseup = handleOnMouseUp;
295 |
296 | for (var x = 0; x < cols; x++) { renderHeadCell(headrow, x, widths[x]); }
297 |
298 | createElement(headrow, 'div', null, { style: 'clear: both;' });
299 | }
300 |
301 | function createRow(sheet) {
302 | var row = createElement(sheet, 'div', 'ruse_row');
303 | row.onmousedown = rowOnMouseDown;
304 | row.onmouseup = rowOnMouseUp;
305 | return row;
306 | }
307 |
308 | function createCell(row, value, width) {
309 |
310 | if (value == undefined) value = '';
311 | if ((typeof value) != 'string') value = '' + value;
312 |
313 | if ( ! width) width = DEFAULT_CELL_WIDTH;
314 |
315 | var cell = createElement(row, 'input', 'ruse_cell');
316 |
317 | cell.setAttribute('type', 'text');
318 |
319 | cell.onkeydown = cellOnKeyDown;
320 | cell.onkeyup = cellOnKeyUp;
321 | cell.onfocus = cellOnFocus;
322 | cell.onchange = cellOnChange;
323 |
324 | cell.value = value;
325 | cell.style.width = width + 'px';
326 |
327 | return cell;
328 | }
329 |
330 | function render(sheet, data, widths) {
331 |
332 | sheet = findElt(sheet);
333 |
334 | while (sheet.firstChild) { sheet.removeChild(sheet.firstChild); }
335 |
336 | if ( ! widths) {
337 | widths = getWidths(sheet);
338 | }
339 | if ( ! widths) {
340 | widths = [];
341 | for (var x = 0; x < cols; x++) { widths.push(DEFAULT_CELL_WIDTH); }
342 | }
343 |
344 | sheet = findElt(sheet);
345 |
346 | var rows = data.length;
347 | var cols = computeColumns(data);
348 |
349 | renderHeadRow(sheet, widths, cols);
350 |
351 | for (var y = 0; y < rows; y++) {
352 |
353 | var rdata = data[y];
354 | var row = createRow(sheet);
355 | for (var x = 0; x < cols; x++) { createCell(row, rdata[x], widths[x]); }
356 | }
357 |
358 | reclass(sheet);
359 | }
360 |
361 | function renderEmpty(container, rows, cols) {
362 |
363 | container = findElt(container);
364 |
365 | var data = [];
366 | for (var y = 0; y < rows; y++) {
367 | var row = [];
368 | for (var x = 0; x < cols; x++) row.push('');
369 | data.push(row);
370 | }
371 | render(container, data);
372 | }
373 |
374 | // exit if func returns something than is considered true
375 | //
376 | function iterate(sheet, func) {
377 |
378 | sheet = findElt(sheet);
379 |
380 | var y = 0;
381 |
382 | for (var yy = 0; yy < sheet.childNodes.length; yy++) {
383 |
384 | var e = sheet.childNodes[yy];
385 |
386 | var rowType = getRuseType(e);
387 | if ( ! rowType) continue;
388 |
389 | var x = 0;
390 |
391 | var r = func.call(null, rowType, x, y, e);
392 | if (r) return [ r, rowType, x, y, e ];
393 |
394 | for (var xx = 0; xx < e.childNodes.length; xx++) {
395 |
396 | var ee = e.childNodes[xx];
397 |
398 | var cellType = getRuseType(ee);
399 | if ( ! cellType) continue;
400 |
401 | var r = func.call(null, cellType, x, y, ee);
402 | if (r) return [ r, cellType, x, y, ee ];
403 |
404 | x++;
405 | }
406 | if (rowType == 'row') y++;
407 | }
408 | }
409 |
410 | function findCellByCoordinates(sheet, x, y) { // mouse coordinates
411 |
412 | var r = iterate(sheet, function(t, xx, yy, e) {
413 |
414 | if (t != 'cell') return false;
415 |
416 | var ex = e.offsetLeft; var ey = e.offsetTop;
417 | var ew = ex + e.offsetWidth; var eh = ey + e.offsetHeight;
418 | if (x < ex || x > ew) return false;
419 | if (y < ey || y > eh) return false;
420 |
421 | return e;
422 | });
423 |
424 | return r ? r[0] : null;
425 | }
426 |
427 | function countRows(sheet) {
428 | return toArray(sheet).length;
429 | }
430 |
431 | function countCols(sheet) {
432 | return (toArray(sheet)[0] || []).length;
433 | }
434 |
435 | function reclass(sheet) {
436 | iterate(sheet, function(t, x, y, e) {
437 | if (t == 'row')
438 | e.setAttribute('class', 'ruse_row row_' + y);
439 | else if (t == 'cell')
440 | e.setAttribute('class', 'ruse_cell row_' + y + ' column_' + x);
441 | else if (t == 'headcell')
442 | e.setAttribute('class', 'ruse_headcell row_-1 column_' + x);
443 | });
444 | }
445 |
446 | function toArray(sheet) {
447 |
448 | var a = [];
449 | var row = null;
450 |
451 | iterate(sheet, function(t, x, y, e) {
452 | if (t == 'row') { row = []; a.push(row); }
453 | else if (t == 'cell') { row.push(e.value); }
454 | });
455 |
456 | return a;
457 | }
458 |
459 | function focusedToArray(sheet) {
460 | var a = [];
461 | var r = null;
462 | iterate(sheet, function (t, x, y, e) {
463 | if (t == 'row') { r = []; a.push(r); }
464 | if (t != 'cell') return false;
465 | if (e.getAttribute('class').match(/ focused/)) r.push(e.value);
466 | });
467 | var aa = [];
468 | for (var y = 0; y < a.length; y++) {
469 | var row = a[y];
470 | if (row.length > 0) aa.push(row);
471 | }
472 | return aa;
473 | }
474 |
475 | function currentCol(sheet, col) {
476 | if (col == undefined) {
477 | var cell = getCurrentCell(sheet);
478 | col = determineRowCol(cell)[1];
479 | }
480 | return col;
481 | }
482 |
483 | function currentRow(sheet, row) {
484 | if (row == undefined) {
485 | var cell = getCurrentCell(sheet);
486 | return cell.parentNode;
487 | }
488 | return findRow(sheet, row);
489 | }
490 |
491 | function addRow(sheet, row) {
492 |
493 | save(sheet);
494 |
495 | row = currentRow(sheet, row);
496 | var cols = countCols(sheet);
497 | var newRow = createRow(null);
498 | var widths = getWidths(sheet);
499 | placeAfter(row, newRow);
500 | for (var x = 0; x < cols; x++) { createCell(newRow, '', widths[x]); }
501 | reclass(sheet);
502 | }
503 |
504 | function addCol(sheet, col) {
505 |
506 | save(sheet);
507 |
508 | col = currentCol(sheet, col);
509 |
510 | var cells = [];
511 | iterate(sheet, function(t, x, y, e) {
512 | if ((t == 'cell' || t == 'headcell') && x == col) cells.push(e);
513 | });
514 | var headcell = cells[0];
515 | var newHeadCell = renderHeadCell(headcell.parentNode, col);
516 | placeAfter(headcell, newHeadCell);
517 | for (var y = 1; y < cells.length; y++) {
518 | var cell = cells[y];
519 | var newCell = createCell(cell.parentNode, '');
520 | placeAfter(cell, newCell);
521 | }
522 | reclass(sheet);
523 | }
524 |
525 | function deleteCol(sheet, col) {
526 |
527 | if (countCols(sheet) <= 1) return;
528 |
529 | save(sheet);
530 |
531 | col = currentCol(sheet, col);
532 | var cells = [];
533 | iterate(sheet, function(t, x, y, e) {
534 | if ((t == 'cell' || t == 'headcell') && x == col) cells.push(e);
535 | });
536 | for (var y = 0; y < cells.length; y++) {
537 | var cell = cells[y];
538 | cell.parentNode.removeChild(cell);
539 | }
540 | reclass(sheet);
541 | }
542 |
543 | function deleteRow(sheet, row) {
544 |
545 | if (countRows(sheet) <= 1) return;
546 |
547 | save(sheet);
548 |
549 | var forgetCurrent = (row == undefined);
550 | row = currentRow(sheet, row);
551 | row.parentNode.removeChild(row);
552 | reclass(sheet);
553 | if (forgetCurrent) setCurrentCell(sheet, null);
554 | }
555 |
556 | function save(sheet) {
557 | sheet = findElt(sheet);
558 | if ( ! sheet.stack) sheet.stack = [];
559 | sheet.stack.push([ toArray(sheet), getWidths(sheet) ]);
560 | while (sheet.stack.length > 100) { sheet.stack.shift(); }
561 | }
562 |
563 | function undo(sheet) {
564 | sheet = findElt(sheet);
565 | if ( ! sheet.stack || sheet.stack.length < 1) return;
566 | var state = sheet.stack.pop();
567 | render(sheet, state[0], state[1]);
568 | }
569 |
570 | return { // the 'public' stuff
571 |
572 | render: render,
573 | renderEmpty: renderEmpty,
574 | addRow: addRow,
575 | addCol: addCol,
576 | deleteRow: deleteRow,
577 | deleteCol: deleteCol,
578 | undo: undo,
579 | toArray: toArray, // returns the current table as a JS array
580 | getWidths: getWidths, // returns the current widths (a JS array)
581 | focusedToArray: focusedToArray,
582 | };
583 | }();
584 |
585 |
--------------------------------------------------------------------------------
/lib/rufus/decision/table.rb:
--------------------------------------------------------------------------------
1 | #--
2 | # Copyright (c) 2007-2013, John Mettraux, jmettraux@gmail.com
3 | #
4 | # Permission is hereby granted, free of charge, to any person obtaining a copy
5 | # of this software and associated documentation files (the "Software"), to deal
6 | # in the Software without restriction, including without limitation the rights
7 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | # copies of the Software, and to permit persons to whom the Software is
9 | # furnished to do so, subject to the following conditions:
10 | #
11 | # The above copyright notice and this permission notice shall be included in
12 | # all copies or substantial portions of the Software.
13 | #
14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20 | # THE SOFTWARE.
21 | #
22 | # Made in Japan.
23 | #++
24 |
25 | require 'csv'
26 | require 'open-uri'
27 |
28 | require 'rufus/dollar'
29 | require 'rufus/decision/hashes'
30 | require 'rufus/decision/matcher'
31 |
32 |
33 | module Rufus
34 | module Decision
35 |
36 | #
37 | # A decision table is a description of a set of rules as a CSV (comma
38 | # separated values) file. Such a file can be edited / generated by
39 | # a spreadsheet (Excel, Google spreadsheets, Gnumeric, ...)
40 | #
41 | # == Disclaimer
42 | #
43 | # The decision / CSV table system is no replacement for
44 | # full rule engines with forward and backward chaining, RETE implementation
45 | # and the like...
46 | #
47 | #
48 | # == Usage
49 | #
50 | # The following CSV file
51 | #
52 | # in:topic,in:region,out:team_member
53 | # sports,europe,Alice
54 | # sports,,Bob
55 | # finance,america,Charly
56 | # finance,europe,Donald
57 | # finance,,Ernest
58 | # politics,asia,Fujio
59 | # politics,america,Gilbert
60 | # politics,,Henry
61 | # ,,Zach
62 | #
63 | # embodies a rule for distributing items (piece of news) labelled with a
64 | # topic and a region to various members of a team.
65 | # For example, all news about finance from Europe are to be routed to
66 | # Donald.
67 | #
68 | # Evaluation occurs row by row. The "in out" row states which field
69 | # is considered at input and which are to be modified if the "ins" do
70 | # match.
71 | #
72 | # The default behaviour is to change the value of the "outs" if all the
73 | # "ins" match and then terminate.
74 | # An empty "in" cell means "matches any".
75 | #
76 | # Enough words, some code :
77 | #
78 | # require 'rufus/decision'
79 | #
80 | # table = Rufus::Decision::Table.new(%{
81 | # in:topic,in:region,out:team_member
82 | # sports,europe,Alice
83 | # sports,,Bob
84 | # finance,america,Charly
85 | # finance,europe,Donald
86 | # finance,,Ernest
87 | # politics,asia,Fujio
88 | # politics,america,Gilbert
89 | # politics,,Henry
90 | # ,,Zach
91 | # })
92 | #
93 | # h = {}
94 | # h["topic"] = "politics"
95 | #
96 | # table.transform!(h)
97 | #
98 | # puts h["team_member"]
99 | # # will yield "Henry" who takes care of all the politics stuff,
100 | # # except for Asia and America
101 | #
102 | # '>', '>=', '<' and '<=' can be put in front of individual cell values :
103 | #
104 | # table = Rufus::Decision::Table.new(%{
105 | # ,
106 | # in:fx, out:fy
107 | # ,
108 | # >100,a
109 | # >=10,b
110 | # ,c
111 | # })
112 | #
113 | # h = { 'fx' => '10' }
114 | # h = table.transform(h)
115 | #
116 | # p h # => { 'fx' => '10', 'fy' => 'b' }
117 | #
118 | # Such comparisons are done after the elements are transformed to float
119 | # numbers. By default, non-numeric arguments will get compared as Strings.
120 | #
121 | #
122 | # == transform and transform!
123 | #
124 | # The method transform! acts directly on its parameter hash, the method
125 | # transform will act on a copy of it. Both methods return their transformed
126 | # hash.
127 | #
128 | #
129 | # == [ruby] ranges
130 | #
131 | # Ruby-like ranges are also accepted in cells.
132 | #
133 | # in:f0,out:result
134 | # ,
135 | # 0..32,low
136 | # 33..66,medium
137 | # 67..100,high
138 | #
139 | # will set the field 'result' to 'low' for f0 => 24
140 | #
141 | #
142 | # == Options
143 | #
144 | # You can put options on their own in a cell BEFORE the line containing
145 | # "in:xxx" and "out:yyy" (ins and outs).
146 | #
147 | # Three options are supported, "ignorecase", "through" and "accumulate".
148 | #
149 | # * "ignorecase", if found by the decision table will make any match (in the
150 | # "in" columns) case unsensitive.
151 | #
152 | # * "through", will make sure that EVERY row is evaluated and potentially
153 | # applied. The default behaviour (without "through"), is to stop the
154 | # evaluation after applying the results of the first matching row.
155 | #
156 | # * "accumulate", behaves as with "through" set but instead of overriding
157 | # values each time a match is found, will gather them in an array.
158 | #
159 | # an example of 'accumulate'
160 | #
161 | # accumulate
162 | # in:f0,out:result
163 | # ,
164 | # ,normal
165 | # >10,large
166 | # >100,xl
167 | #
168 | # will yield { result => [ 'normal', 'large' ]} for f0 => 56
169 | #
170 | # * "unbounded", by default, string matching is 'bounded', "apple" will match
171 | # 'apple', but not 'greenapple'. When "unbounded" is set, 'greenapple' will
172 | # match. ('bounded', in reality, means the target value is surrounded
173 | # by ^ and $)
174 | #
175 | # === Setting options at table initialization
176 | #
177 | # It's OK to set the options at initialization time :
178 | #
179 | # table = Rufus::Decision::Table.new(
180 | # csv, :ruby_eval => true, :accumulate => true)
181 | #
182 | #
183 | # == Cross references
184 | #
185 | # By using the 'dollar notation', it's possible to reference a value
186 | # already in the hash (that is, the hash undergoing 'transformation').
187 | #
188 | # in:value,in:roundup,out:newvalue
189 | # 0..32,true,32
190 | # 33..65,true,65
191 | # 66..99,true,99
192 | # ,,${value}
193 | #
194 | # Here, if 'roundup' is set to true, newvalue will hold 32, 65 or 99
195 | # as value, else it will simply hold the 'value'.
196 | #
197 | # The value is the value as currently found in the transformed hash, not
198 | # as found in the original (non-transformed) hash.
199 | #
200 | #
201 | # == Ruby code evaluation
202 | #
203 | # The dollar notation can be used for yet another trick, evaluation of
204 | # ruby code at transform time.
205 | #
206 | # Note though that this feature is only enabled via the :ruby_eval
207 | # option of the transform!() method.
208 | #
209 | # decisionTable.transform!(h, :ruby_eval => true)
210 | #
211 | # That decision table may look like :
212 | #
213 | # in:value,in:result
214 | # 0..32,${r:Time.now.to_f}
215 | # 33..65,${r:call_that_other_function()}
216 | # 66..99,${r:${value} * 3}
217 | #
218 | # (It's a very simplistic example, but I hope it demonstrates the
219 | # capabilities of this technique)
220 | #
221 | # It's OK to set the :ruby_eval parameter when initializing the decision
222 | # table :
223 | #
224 | # table = Rufus::Decision::Table.new(csv, :ruby_eval => true)
225 | #
226 | # so that there is no need to specify it at transform() call time.
227 | #
228 | #
229 | # == See also
230 | #
231 | # * http://jmettraux.wordpress.com/2007/02/11/ruby-decision-tables/
232 | #
233 | class Table
234 |
235 | IN = /^in:/
236 | OUT = /^out:/
237 | IN_OR_OUT = /^(in|out):/
238 |
239 | # Access to the table options.
240 | #
241 | # Here is a description of the options:
242 | #
243 | # == :first_match
244 | #
245 | # when set to true, the transformation process stops after the
246 | # first match got applied.
247 | #
248 | # == :ignore_case
249 | #
250 | # when set to true, matches evaluation ignores case.
251 | #
252 | # == :accumulate
253 | #
254 | # when set to true, multiple matches result get accumulated in
255 | # an array.
256 | #
257 | # == :ruby_eval
258 | #
259 | # when set to true, evaluation of ruby code for output is allowed. False
260 | # by default.
261 | #
262 | # == :unbound
263 | #
264 | # false (bounded) by default : exact matches for string matching. When
265 | # 'unbounded', target 'apple' will match for values like 'greenapples' or
266 | # 'apple seed'.
267 | #
268 | attr_reader :options
269 |
270 | # set of matchers that will be applied (in order) to determine if a
271 | # cell matches the hash to be transformed. By default this is set to
272 | # [
273 | # Rufus::Decision::Matchers::Numeric.new,
274 | # Rufus::Decision::Matchers::Range.new,
275 | # Rufus::Decision::Matchers::String.new
276 | # ]
277 | #
278 | attr_accessor :matchers
279 |
280 | # The constructor for DecisionTable, you can pass a String, an Array
281 | # (of arrays), a File object. The CSV parser coming with Ruby will take
282 | # care of it and a DecisionTable instance will be built.
283 | #
284 | # Options are :through, :ignore_case, :accumulate (which
285 | # forces :through to true when set) and :ruby_eval. See
286 | # Rufus::Decision::Table for more details.
287 | #
288 | # Options passed to this method do override the options defined
289 | # in the CSV itself.
290 | #
291 | # == options
292 | #
293 | # * :through : when set, all the rows of the decision table are considered
294 | # * :ignore_case : case is ignored (not ignored by default)
295 | # * :accumulate : gather instead of overriding (implies :through)
296 | # * :ruby_eval : ruby code evaluation is OK
297 | # * :unbound[ed]: when true "apple" matches "apple" and "green apples"
298 | #
299 | # * :open_uri: optionally contains a hash of options passed to the .open
300 | # method of open-uri.
301 | #
302 | def initialize(csv, options=nil)
303 |
304 | @options = {}; (options || {}).each { |k, v| @options[k.to_sym] = v }
305 |
306 | @rows = Rufus::Decision.csv_to_a(csv, @options[:open_uri])
307 |
308 | shift_options_from_table
309 |
310 | normalize_option(:unbounded, :unbound)
311 | normalize_option(:first_match, :firstmatch)
312 | normalize_option(:ignore_case, :ignorecase)
313 |
314 | @options[:first_match] = true if @options[:first_match].nil?
315 |
316 | @options[:first_match] = false if @options[:through]
317 | @options[:first_match] = false if @options[:accumulate]
318 |
319 | @matchers =
320 | (
321 | @options.delete(:matchers) ||
322 | [ Matchers::Numeric, Matchers::Range, Matchers::String ]
323 | ).collect { |m|
324 | matcher = m.is_a?(Class) ? m.new : m
325 | if matcher.respond_to?(:options=)
326 | matcher.options = @options
327 | elsif matcher.respond_to?(:table=)
328 | matcher.table = self
329 | end
330 | matcher
331 | }
332 |
333 | parse_header_row
334 | end
335 |
336 | # Like transform, but the original hash doesn't get touched,
337 | # a copy of it gets transformed and finally returned.
338 | #
339 | def transform(hash)
340 |
341 | transform!(hash.dup)
342 | end
343 |
344 | # Passes the hash through the decision table and returns it,
345 | # transformed.
346 | #
347 | def transform!(hash)
348 |
349 | hash = Rufus::Decision::EvalHashFilter.new(hash) if @options[:ruby_eval]
350 |
351 | @rows.each do |row|
352 | next unless matches?(row, hash)
353 | apply(row, hash)
354 | break if @options[:first_match]
355 | end
356 |
357 | hash.is_a?(Rufus::Decision::HashFilter) ? hash.parent_hash : hash
358 | end
359 |
360 | alias :run :transform
361 |
362 | # Outputs back this table as a CSV String
363 | #
364 | def to_csv
365 |
366 | @rows.inject([ @header.to_csv ]) { |a, row|
367 | a << row.join(',')
368 | }.join("\n")
369 | end
370 |
371 | protected
372 |
373 | # Returns true if the hash matches the in: values for this row
374 | #
375 | def matches?(row, hash)
376 |
377 | @header.ins.each do |x, in_header|
378 |
379 | in_header = "${#{in_header}}"
380 |
381 | value = Rufus::dsub(in_header, hash)
382 |
383 | cell = row[x]
384 |
385 | next if cell == nil || cell == ''
386 |
387 | return false unless @matchers.any? { |matcher|
388 |
389 | c = matcher.cell_substitution? ? Rufus::dsub(cell, hash) : cell
390 | m = matcher.matches?(c, value)
391 |
392 | break false if m == :break
393 |
394 | m
395 | }
396 | end
397 |
398 | true
399 | end
400 |
401 | def apply(row, hash)
402 |
403 | @header.outs.each do |x, out_header|
404 |
405 | value = row[x]
406 |
407 | next if value == nil || value == ''
408 |
409 | begin
410 | value = Rufus::dsub(value, hash)
411 | rescue
412 | raise $!, "Error substituting in #{value} -> #{$!}", $!.backtrace
413 | end
414 |
415 | hash[out_header] = if @options[:accumulate]
416 | #
417 | # accumulate
418 |
419 | v = hash[out_header]
420 |
421 | if v and v.is_a?(Array)
422 | v + Array(value)
423 | elsif v
424 | [ v, value ]
425 | else
426 | value
427 | end
428 | else
429 | #
430 | # override
431 |
432 | value
433 | end
434 | end
435 | end
436 |
437 | OPTION_NAMES =
438 | %w{
439 | accumulate first_match firstmatch
440 | ignorecase ignore_case through unbound unbounded
441 | }
442 |
443 | # Options can be placed in the cells before the header row.
444 | # This method shifts those "option rows" and sets the table options
445 | # accordingly.
446 | #
447 | def shift_options_from_table
448 |
449 | row = @rows.first
450 |
451 | return unless row
452 | # end of table somehow
453 |
454 | return if row.find { |cell| cell && cell.match(IN_OR_OUT) }
455 | # just hit the header row
456 |
457 | row.each do |cell|
458 |
459 | cell = cell.downcase
460 |
461 | @options[cell.to_sym] = true if OPTION_NAMES.include?(cell)
462 | end
463 |
464 | @rows.shift
465 |
466 | shift_options_from_table
467 | end
468 |
469 | # Used to align options on :unbounded or :ignore_case
470 | #
471 | def normalize_option(name, *variants)
472 |
473 | variants.each do |variant|
474 | break if @options[name] != nil
475 | @options[name] = @options.delete(variant)
476 | end
477 | end
478 |
479 | # Returns true if the first row of the table contains just an "in:" or
480 | # an "out:"
481 | #
482 | def is_vertical_table?(first_row)
483 |
484 | bin = false
485 | bout = false
486 |
487 | first_row.each do |cell|
488 | bin ||= cell.match(IN)
489 | bout ||= cell.match(OUT)
490 | return false if bin and bout
491 | end
492 |
493 | true
494 | end
495 |
496 | def parse_header_row
497 |
498 | row = @rows.first
499 |
500 | return unless row
501 |
502 | if is_vertical_table?(row)
503 | @rows = @rows.transpose
504 | row = @rows.first
505 | end
506 |
507 | @rows.shift
508 |
509 | row.each_with_index do |cell, x|
510 | next unless cell.match(IN_OR_OUT)
511 | (@header ||= Header.new).add(cell, x)
512 | end
513 | end
514 |
515 |
516 | class Header
517 |
518 | attr_accessor :ins, :outs
519 |
520 | def initialize
521 |
522 | @ins = {}
523 | @outs = {}
524 | end
525 |
526 | def add(cell, x)
527 |
528 | if cell.match(IN)
529 |
530 | @ins[x] = cell[3..-1]
531 |
532 | elsif cell.match(OUT)
533 |
534 | @outs[x] = cell[4..-1]
535 |
536 | end
537 | # else don't add
538 | end
539 |
540 | def to_csv
541 |
542 | (@ins.keys.sort.collect { |k| "in:#{@ins[k]}" } +
543 | @outs.keys.sort.collect { |k| "out:#{@outs[k]}" }).join(',')
544 | end
545 | end
546 | end
547 |
548 | # Given a CSV string or the URI / path to a CSV file, turns the CSV
549 | # into an array of array.
550 | #
551 | def self.csv_to_a(csv, open_uri_options=nil)
552 |
553 | return csv if csv.is_a?(Array)
554 |
555 | open_uri_options ||= {}
556 |
557 | csv = csv.to_s if csv.is_a?(URI)
558 | csv = open(csv, open_uri_options) if is_uri?(csv)
559 |
560 | csv_lib = defined?(CSV::Reader) ? CSV::Reader : CSV
561 | # no CSV::Reader for Ruby 1.9.1
562 |
563 | csv_lib.parse(csv).inject([]) { |rows, row|
564 | row = row.collect { |cell| cell ? cell.strip : '' }
565 | rows << row if row.find { |cell| (cell != '') }
566 | rows
567 | }
568 | end
569 |
570 | # Returns true if the string is a URI false if it's something else
571 | # (CSV data ?)
572 | #
573 | def self.is_uri?(string)
574 |
575 | return false if string.index("\n") # quick one
576 |
577 | begin
578 | URI::parse(string); return true
579 | rescue
580 | end
581 |
582 | false
583 | end
584 |
585 | # Turns an array of array (rows / columns) into an array of hashes.
586 | # The first row is considered the "row of keys".
587 | #
588 | # [
589 | # [ 'age', 'name' ],
590 | # [ 33, 'Jeff' ],
591 | # [ 35, 'John' ]
592 | # ]
593 | #
594 | # =>
595 | #
596 | # [
597 | # { 'age' => 33, 'name' => 'Jeff' },
598 | # { 'age' => 35, 'name' => 'John' }
599 | # ]
600 | #
601 | # You can also pass the CSV as a string or the URI/path to the actual CSV
602 | # file.
603 | #
604 | def self.transpose(a)
605 |
606 | a = csv_to_a(a) if a.is_a?(String)
607 |
608 | return a if a.empty?
609 |
610 | first = a.first
611 |
612 | if first.is_a?(Hash)
613 |
614 | keys = first.keys.sort
615 | [ keys ] + a.collect { |row|
616 | keys.collect { |k| row[k] }
617 | }
618 | else
619 |
620 | keys = first
621 | a[1..-1].collect { |row|
622 | (0..keys.size - 1).inject({}) { |h, i| h[keys[i]] = row[i]; h }
623 | }
624 | end
625 | end
626 | end
627 | end
628 |
629 | module Rufus
630 |
631 | #
632 | # An 'alias' for the class Rufus::Decision::Table
633 | #
634 | # (for backward compatibility)
635 | #
636 | class DecisionTable < Rufus::Decision::Table
637 | end
638 | end
639 |
640 |
--------------------------------------------------------------------------------