rufus-decision 1.2.0 → 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/demo/start.rb ADDED
@@ -0,0 +1,78 @@
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('lib')
17
+
18
+ require 'rubygems'
19
+ require 'rack'
20
+ require 'json'
21
+ require 'rufus/decision'
22
+
23
+
24
+ class App
25
+
26
+ def initialize
27
+ @rfapp = Rack::File.new(File.dirname(__FILE__) + '/public')
28
+ end
29
+
30
+ def call (env)
31
+ return decide(env) if env['PATH_INFO'] == '/decision'
32
+ env['PATH_INFO'] = '/index.html' if env['PATH_INFO'] == '/'
33
+ @rfapp.call(env)
34
+ end
35
+
36
+ protected
37
+
38
+ def in_to_h (keys, values)
39
+ keys.inject({}) { |h, k| h[k] = values.shift; h }
40
+ end
41
+
42
+ def decide (env)
43
+
44
+ json = env['rack.input'].read
45
+ json = JSON.parse(json)
46
+
47
+ dt = Rufus::Decision::Table.new(json.last)
48
+
49
+ input = Rufus::Decision.transpose(json.first)
50
+ # from array of arrays to array of hashes
51
+
52
+ output = input.inject([]) { |a, hash| a << dt.transform(hash); a }
53
+
54
+ output = Rufus::Decision.transpose(output)
55
+ # from array of hashes to array of arrays
56
+
57
+ [ 200, {}, output.to_json ]
58
+ end
59
+ end
60
+
61
+ b = Rack::Builder.new do
62
+
63
+ use Rack::CommonLogger
64
+ use Rack::ShowExceptions
65
+ run App.new
66
+ end
67
+
68
+ port = 4567
69
+
70
+ puts ".. [#{Time.now}] listening on port #{port}"
71
+
72
+ Rack::Handler::Mongrel.run(b, :Port => port) do |server|
73
+ trap(:INT) do
74
+ puts ".. [#{Time.now}] stopped."
75
+ server.stop
76
+ end
77
+ end
78
+
@@ -0,0 +1,51 @@
1
+
2
+ require 'rubygems'
3
+ require 'rufus/decision'
4
+
5
+ include Rufus
6
+
7
+
8
+ TABLE = DecisionTable.new("""
9
+ in:age,in:trait,out:salesperson
10
+
11
+ 18..35,,adeslky
12
+ 25..35,,bronco
13
+ 36..50,,espadas
14
+ 51..78,,thorsten
15
+ 44..120,,ojiisan
16
+
17
+ 25..35,rich,kerfelden
18
+ ,cheerful,swanson
19
+ ,maniac,korolev
20
+ """)
21
+
22
+ #
23
+ # Given a customer (a Hash instance directly, for
24
+ # convenience), returns the name of the first
25
+ # corresponding salesman.
26
+ #
27
+ def determine_salesperson (customer)
28
+
29
+ TABLE.transform(customer)["salesperson"]
30
+ end
31
+
32
+ puts determine_salesperson(
33
+ "age" => 72)
34
+ # => thorsten
35
+
36
+ puts determine_salesperson(
37
+ "age" => 25, "trait" => "rich")
38
+ # => adeslky
39
+
40
+ puts determine_salesperson(
41
+ "age" => 23, "trait" => "cheerful")
42
+ # => adeslky
43
+
44
+ puts determine_salesperson(
45
+ "age" => 25, "trait" => "maniac")
46
+ # => adeslky
47
+
48
+ puts determine_salesperson(
49
+ "age" => 44, "trait" => "maniac")
50
+ # => espadas
51
+
@@ -1,3 +1,3 @@
1
1
 
2
- require 'rufus/decision'
2
+ require 'rufus/decision/table'
3
3
 
@@ -1,683 +1,3 @@
1
- #--
2
- # Copyright (c) 2007-2009, 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
1
 
25
-
26
- require 'csv'
27
- require 'open-uri'
28
-
29
- require 'rubygems'
30
- require 'rufus/dollar'
31
-
32
- require 'rufus/hashes'
33
-
34
-
35
- module Rufus
36
- module Decision
37
-
38
- VERSION = '1.2.0'
39
-
40
- #
41
- # A decision table is a description of a set of rules as a CSV (comma
42
- # separated values) file. Such a file can be edited / generated by
43
- # a spreadsheet (Excel, Google spreadsheets, Gnumeric, ...)
44
- #
45
- # == Disclaimer
46
- #
47
- # The decision / CSV table system is no replacement for
48
- # full rule engines with forward and backward chaining, RETE implementation
49
- # and the like...
50
- #
51
- #
52
- # == Usage
53
- #
54
- # The following CSV file
55
- #
56
- # in:topic,in:region,out:team_member
57
- # sports,europe,Alice
58
- # sports,,Bob
59
- # finance,america,Charly
60
- # finance,europe,Donald
61
- # finance,,Ernest
62
- # politics,asia,Fujio
63
- # politics,america,Gilbert
64
- # politics,,Henry
65
- # ,,Zach
66
- #
67
- # embodies a rule for distributing items (piece of news) labelled with a
68
- # topic and a region to various members of a team.
69
- # For example, all news about finance from Europe are to be routed to
70
- # Donald.
71
- #
72
- # Evaluation occurs row by row. The "in out" row states which field
73
- # is considered at input and which are to be modified if the "ins" do
74
- # match.
75
- #
76
- # The default behaviour is to change the value of the "outs" if all the
77
- # "ins" match and then terminate.
78
- # An empty "in" cell means "matches any".
79
- #
80
- # Enough words, some code :
81
- #
82
- # require 'rufus/decision'
83
- #
84
- # table = Rufus::Decision::Table.new(%{
85
- # in:topic,in:region,out:team_member
86
- # sports,europe,Alice
87
- # sports,,Bob
88
- # finance,america,Charly
89
- # finance,europe,Donald
90
- # finance,,Ernest
91
- # politics,asia,Fujio
92
- # politics,america,Gilbert
93
- # politics,,Henry
94
- # ,,Zach
95
- # })
96
- #
97
- # h = {}
98
- # h["topic"] = "politics"
99
- #
100
- # table.transform!(h)
101
- #
102
- # puts h["team_member"]
103
- # # will yield "Henry" who takes care of all the politics stuff,
104
- # # except for Asia and America
105
- #
106
- # '>', '>=', '<' and '<=' can be put in front of individual cell values :
107
- #
108
- # table = Rufus::Decision::Table.new(%{
109
- # ,
110
- # in:fx, out:fy
111
- # ,
112
- # >100,a
113
- # >=10,b
114
- # ,c
115
- # })
116
- #
117
- # h = { 'fx' => '10' }
118
- # h = table.transform(h)
119
- #
120
- # p h # => { 'fx' => '10', 'fy' => 'b' }
121
- #
122
- # Such comparisons are done after the elements are transformed to float
123
- # numbers. By default, non-numeric arguments will get compared as Strings.
124
- #
125
- #
126
- # == transform and transform!
127
- #
128
- # The method transform! acts directly on its parameter hash, the method
129
- # transform will act on a copy of it. Both methods return their transformed
130
- # hash.
131
- #
132
- #
133
- # == [ruby] ranges
134
- #
135
- # Ruby-like ranges are also accepted in cells.
136
- #
137
- # in:f0,out:result
138
- # ,
139
- # 0..32,low
140
- # 33..66,medium
141
- # 67..100,high
142
- #
143
- # will set the field 'result' to 'low' for f0 => 24
144
- #
145
- #
146
- # == Options
147
- #
148
- # You can put options on their own in a cell BEFORE the line containing
149
- # "in:xxx" and "out:yyy" (ins and outs).
150
- #
151
- # Three options are supported, "ignorecase", "through" and "accumulate".
152
- #
153
- # * "ignorecase", if found by the decision table will make any match (in the
154
- # "in" columns) case unsensitive.
155
- #
156
- # * "through", will make sure that EVERY row is evaluated and potentially
157
- # applied. The default behaviour (without "through"), is to stop the
158
- # evaluation after applying the results of the first matching row.
159
- #
160
- # * "accumulate", behaves as with "through" set but instead of overriding
161
- # values each time a match is found, will gather them in an array.
162
- #
163
- # an example of 'accumulate'
164
- #
165
- # accumulate
166
- # in:f0,out:result
167
- # ,
168
- # ,normal
169
- # >10,large
170
- # >100,xl
171
- #
172
- # will yield { result => [ 'normal', 'large' ]} for f0 => 56
173
- #
174
- # * "unbounded", by default, string matching is 'bounded', "apple" will match
175
- # 'apple', but not 'greenapple'. When "unbounded" is set, 'greenapple' will
176
- # match. ('bounded', in reality, means the target value is surrounded
177
- # by ^ and $)
178
- #
179
- # === Setting options at table initialization
180
- #
181
- # It's OK to set the options at initialization time :
182
- #
183
- # table = Rufus::Decision::Table.new(
184
- # csv, :ruby_eval => true, :accumulate => true)
185
- #
186
- #
187
- # == Cross references
188
- #
189
- # By using the 'dollar notation', it's possible to reference a value
190
- # already in the hash (that is, the hash undergoing 'transformation').
191
- #
192
- # in:value,in:roundup,out:newvalue
193
- # 0..32,true,32
194
- # 33..65,true,65
195
- # 66..99,true,99
196
- # ,,${value}
197
- #
198
- # Here, if 'roundup' is set to true, newvalue will hold 32, 65 or 99
199
- # as value, else it will simply hold the 'value'.
200
- #
201
- # The value is the value as currently found in the transformed hash, not
202
- # as found in the original (non-transformed) hash.
203
- #
204
- #
205
- # == Ruby code evaluation
206
- #
207
- # The dollar notation can be used for yet another trick, evaluation of
208
- # ruby code at transform time.
209
- #
210
- # Note though that this feature is only enabled via the :ruby_eval
211
- # option of the transform!() method.
212
- #
213
- # decisionTable.transform!(h, :ruby_eval => true)
214
- #
215
- # That decision table may look like :
216
- #
217
- # in:value,in:result
218
- # 0..32,${r:Time.now.to_f}
219
- # 33..65,${r:call_that_other_function()}
220
- # 66..99,${r:${value} * 3}
221
- #
222
- # (It's a very simplistic example, but I hope it demonstrates the
223
- # capabilities of this technique)
224
- #
225
- # It's OK to set the :ruby_eval parameter when initializing the decision
226
- # table :
227
- #
228
- # table = Rufus::Decision::Table.new(csv, :ruby_eval => true)
229
- #
230
- # so that there is no need to specify it at transform() call time.
231
- #
232
- #
233
- # == See also
234
- #
235
- # * http://jmettraux.wordpress.com/2007/02/11/ruby-decision-tables/
236
- #
237
- class Table
238
-
239
- IN = /^in:/
240
- OUT = /^out:/
241
- IN_OR_OUT = /^(in|out):/
242
- NUMERIC_COMPARISON = /^([><]=?)(.*)$/
243
-
244
- # when set to true, the transformation process stops after the
245
- # first match got applied.
246
- #
247
- attr_accessor :first_match
248
-
249
- # when set to true, matches evaluation ignores case.
250
- #
251
- attr_accessor :ignore_case
252
-
253
- # when set to true, multiple matches result get accumulated in
254
- # an array.
255
- #
256
- attr_accessor :accumulate
257
-
258
- # when set to true, evaluation of ruby code for output is allowed. False
259
- # by default.
260
- #
261
- attr_accessor :ruby_eval
262
-
263
- # false (bounded) by default : exact matches for string matching. When
264
- # 'unbounded', target 'apple' will match for values like 'greenapples' or
265
- # 'apple seed'.
266
- #
267
- attr_accessor :unbound
268
-
269
- # The constructor for DecisionTable, you can pass a String, an Array
270
- # (of arrays), a File object. The CSV parser coming with Ruby will take
271
- # care of it and a DecisionTable instance will be built.
272
- #
273
- # Options are :through, :ignore_case, :accumulate (which
274
- # forces :through to true when set) and :ruby_eval. See
275
- # Rufus::Decision::Table for more details.
276
- #
277
- # Options passed to this method do override the options defined
278
- # in the CSV itself.
279
- #
280
- # == options
281
- #
282
- # * :through : when set, all the rows of the decision table are considered
283
- # * :ignore_case : case is ignored (not ignored by default)
284
- # * :accumulate : gather instead of overriding (implies :through)
285
- # * :ruby_eval : ruby code evaluation is OK
286
- #
287
- def initialize (csv, options={})
288
-
289
- @rows = Rufus::Decision.csv_to_a(csv)
290
-
291
- extract_options
292
-
293
- parse_header_row
294
-
295
- @first_match = false if options[:through] == true
296
- @first_match = true if @first_match.nil?
297
-
298
- set_opt(options, :ignore_case, :ignorecase)
299
- set_opt(options, :accumulate)
300
- set_opt(options, :ruby_eval)
301
- set_opt(options, :unbounded)
302
-
303
- @first_match = false if @accumulate
304
- end
305
-
306
- # Like transform, but the original hash doesn't get touched,
307
- # a copy of it gets transformed and finally returned.
308
- #
309
- def transform (hash)
310
-
311
- transform!(hash.dup)
312
- end
313
-
314
- # Passes the hash through the decision table and returns it,
315
- # transformed.
316
- #
317
- def transform! (hash)
318
-
319
- hash = Rufus::Decision::EvalHashFilter.new(hash) if @ruby_eval
320
-
321
- @rows.each do |row|
322
- next unless matches?(row, hash)
323
- apply(row, hash)
324
- break if @first_match
325
- end
326
-
327
- hash.is_a?(Rufus::Decision::HashFilter) ? hash.parent_hash : hash
328
- end
329
-
330
- alias :run :transform
331
-
332
- # Outputs back this table as a CSV String
333
- #
334
- def to_csv
335
-
336
- @rows.inject([ @header.to_csv ]) { |a, row|
337
- a << row.join(',')
338
- }.join("\n")
339
- end
340
-
341
- protected
342
-
343
- def set_opt (options, *optnames)
344
-
345
- optnames.each do |oname|
346
-
347
- v = options[oname]
348
- next unless v != nil
349
- instance_variable_set("@#{optnames.first.to_s}", v)
350
- return
351
- end
352
- end
353
-
354
-
355
- # Returns true if the hash matches the in: values for this row
356
- #
357
- def matches? (row, hash)
358
-
359
- @header.ins.each do |x, in_header|
360
-
361
- in_header = "${#{in_header}}"
362
-
363
- value = Rufus::dsub(in_header, hash)
364
-
365
- cell = row[x]
366
-
367
- next if cell == nil || cell == ''
368
-
369
- cell = Rufus::dsub(cell, hash)
370
-
371
- b = if m = NUMERIC_COMPARISON.match(cell)
372
-
373
- numeric_compare(m, value, cell)
374
- else
375
-
376
- range = to_ruby_range(cell)
377
- range ? range.include?(value) : string_compare(value, cell)
378
- end
379
-
380
- return false unless b
381
- end
382
-
383
- true
384
- end
385
-
386
- def string_compare (value, cell)
387
-
388
- modifiers = 0
389
- modifiers += Regexp::IGNORECASE if @ignore_case
390
-
391
- rcell = @unbounded ?
392
- Regexp.new(cell, modifiers) : Regexp.new("^#{cell}$", modifiers)
393
-
394
- rcell.match(value)
395
- end
396
-
397
- def numeric_compare (match, value, cell)
398
-
399
- comparator = match[1]
400
- cell = match[2]
401
-
402
- nvalue = Float(value) rescue value
403
- ncell = Float(cell) rescue cell
404
-
405
- value, cell = if nvalue.is_a?(String) or ncell.is_a?(String)
406
- [ "\"#{value}\"", "\"#{cell}\"" ]
407
- else
408
- [ nvalue, ncell ]
409
- end
410
-
411
- s = "#{value} #{comparator} #{cell}"
412
-
413
- Rufus::Decision::check_and_eval(s) rescue false
414
- end
415
-
416
- def apply (row, hash)
417
-
418
- @header.outs.each do |x, out_header|
419
-
420
- value = row[x]
421
-
422
- next if value == nil || value == ''
423
-
424
- value = Rufus::dsub(value, hash)
425
-
426
- hash[out_header] = if @accumulate
427
- #
428
- # accumulate
429
-
430
- v = hash[out_header]
431
-
432
- if v and v.is_a?(Array)
433
- v + Array(value)
434
- elsif v
435
- [ v, value ]
436
- else
437
- value
438
- end
439
- else
440
- #
441
- # override
442
-
443
- value
444
- end
445
- end
446
- end
447
-
448
- def extract_options
449
-
450
- row = @rows.first
451
-
452
- return unless row
453
- # end of table somehow
454
-
455
- return if row.find { |cell| cell && cell.match(IN_OR_OUT) }
456
- # just hit the header row
457
-
458
- row.each do |cell|
459
-
460
- cell = cell.downcase
461
-
462
- if cell == 'ignorecase' or cell == 'ignore_case'
463
- @ignore_case = true
464
- elsif cell == 'through'
465
- @first_match = false
466
- elsif cell == 'accumulate'
467
- @first_match = false
468
- @accumulate = true
469
- elsif cell == 'unbounded'
470
- @unbounded = true
471
- end
472
- end
473
-
474
- @rows.shift
475
-
476
- extract_options
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
- # A regexp for checking if a string is a numeric Ruby range
516
- #
517
- RUBY_NUMERIC_RANGE_REGEXP = Regexp.compile(
518
- "^\\d+(\\.\\d+)?\\.{2,3}\\d+(\\.\\d+)?$")
519
-
520
- # A regexp for checking if a string is an alpha Ruby range
521
- #
522
- RUBY_ALPHA_RANGE_REGEXP = Regexp.compile(
523
- "^([A-Za-z])(\\.{2,3})([A-Za-z])$")
524
-
525
- # If the string contains a Ruby range definition
526
- # (ie something like "93.0..94.5" or "56..72"), it will return
527
- # the Range instance.
528
- # Will return nil else.
529
- #
530
- # The Ruby range returned (if any) will accept String or Numeric,
531
- # ie (4..6).include?("5") will yield true.
532
- #
533
- def to_ruby_range (s)
534
-
535
- range = if RUBY_NUMERIC_RANGE_REGEXP.match(s)
536
-
537
- eval(s)
538
-
539
- else
540
-
541
- m = RUBY_ALPHA_RANGE_REGEXP.match(s)
542
-
543
- m ? eval("'#{m[1]}'#{m[2]}'#{m[3]}'") : nil
544
- end
545
-
546
- class << range
547
-
548
- alias :old_include? :include?
549
-
550
- def include? (elt)
551
-
552
- elt = first.is_a?(Numeric) ? (Float(elt) rescue '') : elt
553
- old_include?(elt)
554
- end
555
-
556
- end if range
557
-
558
- range
559
- end
560
-
561
- class Header
562
-
563
- attr_accessor :ins, :outs
564
-
565
- def initialize
566
-
567
- @ins = {}
568
- @outs = {}
569
- end
570
-
571
- def add (cell, x)
572
-
573
- if cell.match(IN)
574
-
575
- @ins[x] = cell[3..-1]
576
-
577
- elsif cell.match(OUT)
578
-
579
- @outs[x] = cell[4..-1]
580
-
581
- end
582
- # else don't add
583
- end
584
-
585
- def to_csv
586
-
587
- (@ins.keys.sort.collect { |k| "in:#{@ins[k]}" } +
588
- @outs.keys.sort.collect { |k| "out:#{@outs[k]}" }).join(',')
589
- end
590
- end
591
- end
592
-
593
- # Given a CSV string or the URI / path to a CSV file, turns the CSV
594
- # into an array of array.
595
- #
596
- def self.csv_to_a (csv)
597
-
598
- return csv if csv.is_a?(Array)
599
-
600
- csv = csv.to_s if csv.is_a?(URI)
601
- csv = open(csv) if is_uri?(csv)
602
-
603
- csv_lib = defined?(CSV::Reader) ? CSV::Reader : CSV
604
- # no CSV::Reader for Ruby 1.9.1
605
-
606
- csv_lib.parse(csv).inject([]) { |rows, row|
607
- row = row.collect { |cell| cell ? cell.strip : '' }
608
- rows << row if row.find { |cell| (cell != '') }
609
- rows
610
- }
611
- end
612
-
613
- # Returns true if the string is a URI false if it's something else
614
- # (CSV data ?)
615
- #
616
- def self.is_uri? (string)
617
-
618
- return false if string.index("\n") # quick one
619
-
620
- begin
621
- URI::parse(string); return true
622
- rescue
623
- end
624
-
625
- false
626
- end
627
-
628
- # Turns an array of array (rows / columns) into an array of hashes.
629
- # The first row is considered the "row of keys".
630
- #
631
- # [
632
- # [ 'age', 'name' ],
633
- # [ 33, 'Jeff' ],
634
- # [ 35, 'John' ]
635
- # ]
636
- #
637
- # =>
638
- #
639
- # [
640
- # { 'age' => 33, 'name' => 'Jeff' },
641
- # { 'age' => 35, 'name' => 'John' }
642
- # ]
643
- #
644
- # You can also pass the CSV as a string or the URI/path to the actual CSV
645
- # file.
646
- #
647
- def self.transpose (a)
648
-
649
- a = csv_to_a(a) if a.is_a?(String)
650
-
651
- return a if a.empty?
652
-
653
- first = a.first
654
-
655
- if first.is_a?(Hash)
656
-
657
- keys = first.keys.sort
658
- [ keys ] + a.collect { |row|
659
- keys.collect { |k| row[k] }
660
- }
661
- else
662
-
663
- keys = first
664
- a[1..-1].collect { |row|
665
- (0..keys.size - 1).inject({}) { |h, i| h[keys[i]] = row[i]; h }
666
- }
667
- end
668
- end
669
-
670
- end
671
- end
672
-
673
- module Rufus
674
-
675
- #
676
- # An 'alias' for the class Rufus::Decision::Table
677
- #
678
- # (for backward compatibility)
679
- #
680
- class DecisionTable < Rufus::Decision::Table
681
- end
682
- end
2
+ require 'rufus/decision/table'
683
3