# #-- # Copyright (c) 2007, John Mettraux, OpenWFE.org # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: # # . Redistributions of source code must retain the above copyright notice, this # list of conditions and the following disclaimer. # # . Redistributions in binary form must reproduce the above copyright notice, # this list of conditions and the following disclaimer in the documentation # and/or other materials provided with the distribution. # # . Neither the name of the "OpenWFE" nor the names of its contributors may be # used to endorse or promote products derived from this software without # specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. #++ # # $Id$ # # # "made in Japan" # # John Mettraux at openwfe.org # require 'csv' require 'openwfe/utils' require 'openwfe/util/dollar' include OpenWFE module OpenWFE # # A 'CsvTable' is called a 'decision table' in OpenWFEja (the initial # Java implementation of OpenWFE). # # A csv table is a description of a set of rules as a CSV (comma # separated values) file. Such a file can be edited / generated by # a spreadsheet (Excel, Google spreadsheets, Gnumeric, ...) # # The following CSV file # # in:topic,in:region,out:team_member # sports,europe,Alice # sports,,Bob # finance,america,Charly # finance,europe,Donald # finance,,Ernest # politics,asia,Fujio # politics,america,Gilbert # politics,,Henry # ,,Zach # # embodies a rule for distributing items (piece of news) labelled with a # topic and a region to various members of a team. # For example, all news about finance from Europe are to be routed to # Donald. # # Evaluation occurs row by row. The "in out" row states which field # is considered at input and which are to be modified if the "ins" do # match. # # The default behaviour is to change the value of the "outs" if all the # "ins" match and then terminate. # An empty "in" cell means "matches any". # # Enough words, some code : # # table = CsvTable.new(""" # in:topic,in:region,out:team_member # sports,europe,Alice # sports,,Bob # finance,america,Charly # finance,europe,Donald # finance,,Ernest # politics,asia,Fujio # politics,america,Gilbert # politics,,Henry # ,,Zach # """) # # h = {} # h["topic"] = "politics" # # table.transform(h) # # puts h["team_member"] # # will yield "Henry" who takes care of all the politics stuff, # # except for Asia and America # # '>', '>=', '<' and '<=' can be put in front of individual cell values : # # table = CsvTable.new(""" # , # in:fx, out:fy # , # >100,a # >=10,b # ,c # """) # # h = { 'fx' => '10' } # table.transform(h) # # require 'pp'; pp h # # will yield { 'fx' => '10', 'fy' => 'b' } # # Such comparisons are done after the elements are transformed to float # numbers. By default, non-numeric arguments will get compared as Strings. # # # Disclaimer : the decision / CSV table system is no replacement for # full rule engines with forward and backward chaining, RETE implementation # and the like... # # # Options : # # You can put options on their own in a cell BEFORE the line containing # "in:xxx" and "out:yyy" (ins and outs). # # Currently, two options are supported, "ignorecase" and "through". # # "ignorecase", if found by the CsvTable will make any match (in the "in" # columns) case unsensitive. # # "through", will make sure that EVERY row is evaluated and potentially # applied. The default behaviour (without "through"), is to stop the # evaluation after applying the results of the first matching row. # # # CSV Tables are available to workflows as CsvParticipant. # # # See also : # # http://jmettraux.wordpress.com/2007/02/11/ruby-decision-tables/ # http://rubyforge.org/viewvc/trunk/openwfe-ruby/test/csv_test.rb?root=openwferu&view=co # class CsvTable attr_accessor \ :first_match, :ignore_case, :header, :rows # # The constructor for CsvTable, you can pass a String, an Array # (of arrays), a File object. The CSV parser coming with Ruby will take # care of it and a CsvTable instance will be built. # def initialize (csv_data) @first_match = true @ignore_case = false @header = nil @rows = [] csv_array = to_csv_array(csv_data) csv_array.each do |row| next if empty_row? row if @header #@rows << row @rows << row.collect { |c| c.strip if c } else parse_header_row(row) end end end # # Returns the workitem massaged by the csv table # def transform_wi (flow_expression, workitem) @rows.each do |row| if matches?(row, flow_expression, workitem) apply(row, flow_expression, workitem) break if @first_match end end return workitem end # # Passes a simple Hash instance though the csv table # def transform (hash) wi = InFlowWorkItem.new() wi.attributes = hash return transform_wi(nil, wi).attributes end protected def to_csv_array (csv_data) return csv_data if csv_data.kind_of? Array return CSV::Reader.parse(csv_data) end def matches? (row, fexp, wi) return false if empty_row? row #puts #puts "__row match ?" #require 'pp' #pp row @header.ins.each_with_index do |in_header, icol| in_header = resolve_in_header(in_header) value = OpenWFE::dosub(in_header, fexp, wi) cell = row[icol] next if not cell cell = cell.strip next if cell.length < 1 cell = OpenWFE::dosub(cell, fexp, wi) #puts "__does '#{value}' match '#{cell}' ?" b = if cell[0, 1] == '<' or cell[0, 1] == '>' numeric_compare(value, cell) else regex_compare(value, cell) end return false unless b end #puts "__row matches" return true end def regex_compare (value, cell) modifiers = 0 modifiers += Regexp::IGNORECASE if @ignore_case rcell = Regexp.new(cell, modifiers) rcell.match(value) end def numeric_compare (value, cell) comparator = cell[0, 1] comparator += "=" if cell[1, 1] == "=" cell = cell[comparator.length..-1] nvalue = narrow(value) ncell = narrow(cell) if nvalue.is_a? String or ncell.is_a? String value = '"' + value + '"' cell = '"' + cell + '"' else value = nvalue cell = ncell end s = "#{value} #{comparator} #{cell}" #puts "...>>>#{s}<<<" begin return OpenWFE::eval_safely(s, 4) rescue Exception => e return false end end def narrow (s) begin return Float(s) rescue Exception => e return s end end def resolve_in_header (in_header) in_header = "f:#{in_header}" \ if points_to_nothing? in_header return "${#{in_header}}" end def apply (row, fexp, wi) #puts "__ apply() wi.class : #{wi.class.name}" @header.outs.each_with_index do |out_header, icol| next unless out_header value = row[icol] next unless value #next unless value.strip.length > 0 next unless value.length > 0 value = OpenWFE::dosub(value, fexp, wi) #puts "___ value:'#{value}'" #puts "___ value:'"+value+"'" type, target = points_at(out_header) #puts "___ t:'#{type}' target:'#{target}'" if type == "v" fexp.set_variable(target, value) if fexp elsif type == "f" wi.set_attribute(target, value) elsif type == "r" #instance_eval(value) OpenWFE::instance_eval_safely(self, value, 3) end end end def parse_header_row (row) row.each_with_index do |cell, icol| next unless cell cell = cell.strip s = cell.downcase if s == "ignorecase" or s == "ignore_case" @ignore_case = true next end if s == "through" @first_match = false next end if OpenWFE::starts_with(cell, "in:") or OpenWFE::starts_with(cell, "out:") @header = Header.new unless @header @header.add cell, icol end end end def empty_row? (row) return true unless row return true if (row.length == 1 and not row[0]) row.each do |cell| return false if cell end return true end def points_to_nothing? (label) (not points_to_variable? label) and \ (not points_to_field? label) and \ (not points_to_ruby? label) end def points_at (label) v = points_to_variable? label return "v", v if v r = points_to_ruby? label return "r", r if r f = points_to_field? label return "f", f if f return "f", label # else end def points_to_variable? (label) points_to?(label, [ "v", "var", "variable" ]) end def points_to_field? (label) points_to?(label, [ "f", "field" ]) end def points_to_ruby? (label) points_to?(label, [ "r", "ruby", "reval" ]) end def points_to? (label, names) i = label.index(":") return nil unless i s = label[0..i-1] names.each do |name| return label[i+1..-1] if s == name end return nil end class Header attr_accessor :ins, :outs def initialize @ins = [] @outs = [] end def add (cell, icol) if OpenWFE::starts_with(cell, "in:") @ins[icol] = cell[3..-1] #puts "i added #{@ins[icol]}" elsif OpenWFE::starts_with(cell, "out:") @outs[icol] = cell[4..-1] #puts "o added #{@outs[icol]}" end # else don't add end end end end