├── .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 |
77 | 78 | in : 79 | add row 80 | add col 81 | del row 82 | del col 83 | undo 84 | 85 |
86 | 87 |
88 | 89 | decision table : 90 | add row 91 | add col 92 | del row 93 | del col 94 | undo 95 | 96 |
97 | 98 |
99 | 100 |
101 | 102 |
103 | 104 | out : 105 |
106 | 107 |
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 | [![Build Status](https://secure.travis-ci.org/jmettraux/rufus-decision.png)](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 | --------------------------------------------------------------------------------