csv_decision 0.0.2 → 0.0.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 824f8e4c688f507bc8e0ea1c65c37d7a615e7d1d
4
- data.tar.gz: 644623f44ba59db10bc98470e113673f0482fbcd
3
+ metadata.gz: 5dd656bc42ac4a7e5bc1b3ba49599f972179fbe9
4
+ data.tar.gz: 8f97105d30b993279dbadf380798fb4fbc36d4dd
5
5
  SHA512:
6
- metadata.gz: 005e743b69070d458d93d8fef1fd1e75e6328d3046e6adb7e2461d0d3201848d011922e3536b4942949697d3cbb04f985af21da48b3464690c2373490ffffd5c
7
- data.tar.gz: e3dd1c8cc027ae660a82ad8f5c9be5f1307741aa329ca03c109bc28486acd59dac4ecc18ed497e75294f162fda596d91cd1e937f3ccbb494af4bd6fcf57e8692
6
+ metadata.gz: d7c5bb91fcb110d0ef7a39def02096ae65e6cfc4eb1b9b17ad4e17407baa02004d5e835a7ce24df016b9b47683afebfeb37e632372447082e0229cb46174a6de
7
+ data.tar.gz: bc3313687f8a01cdd82efdd59190bea881a4707382a23275c20b192b2d048db747a3200163e0b773c31fe9a500269484c10d990d363c9f75956df956ff1379c8
data/README.md CHANGED
@@ -1,33 +1,43 @@
1
1
  CSV Decision
2
2
  ============
3
3
 
4
- <a href="https://codeclimate.com/github/bpvickers/csv_decision/maintainability"><img src="https://api.codeclimate.com/v1/badges/466a6c52e8f6a3840967/maintainability" /></a>
4
+ [![Gem Version](https://badge.fury.io/rb/csv_decision.svg)](https://badge.fury.io/rb/csv_decision)
5
5
  [![Build Status](https://travis-ci.org/bpvickers/csv_decision.svg?branch=master)](https://travis-ci.org/bpvickers/csv_decision)
6
6
  [![Coverage Status](https://coveralls.io/repos/github/bpvickers/csv_decision/badge.svg?branch=master)](https://coveralls.io/github/bpvickers/csv_decision?branch=master)
7
- [![Gem Version](https://badge.fury.io/rb/csv_decision.svg)](http://badge.fury.io/rb/csv_decision)
7
+ <a href="https://codeclimate.com/github/bpvickers/csv_decision/maintainability"><img src="https://api.codeclimate.com/v1/badges/466a6c52e8f6a3840967/maintainability" /></a>
8
8
 
9
- # CSV based Ruby decision tables
9
+ ### CSV based Ruby decision tables (a lightweight Hash transformation gem)
10
10
 
11
- `csv_decision` is a Ruby gem for CSV (comma separated values) based
11
+ `csv_decision` is a RubyGem for CSV (comma separated values) based
12
12
  [decision tables](https://en.wikipedia.org/wiki/Decision_table).
13
- It accepts decision tables written in a CSV file, which can then be used to execute
14
- complex conditional logic against an input hash, producing a decision as an output hash.
13
+ It accepts decision tables implemented as a
14
+ [CSV file](https://en.wikipedia.org/wiki/Comma-separated_values),
15
+ which can then be used to execute complex conditional logic against an input hash,
16
+ producing a decision as an output hash.
15
17
 
16
18
  ### `csv_decision` features
17
- * fast decision-time performance
18
- * can use regular expressions, numeric comparisons and Ruby-style ranges
19
- * accepts data as a file, CSV string or an array of arrays.
20
- * all CSV cells are parsed for correctness, and helpful error messages generated for bad
21
- inputs
19
+ * Fast decision-time performance (see `benchmark.rb`).
20
+ * In addition to simple string matching, can use regular expressions,
21
+ numeric comparisons and Ruby-style ranges.
22
+ * Accepts data as a file, CSV string or an array of arrays. (For safety all input data is
23
+ force encoded to UTF-8, and non-ascii strings are converted to empty strings.)
24
+ * All CSV cells are parsed for correctness, and helpful error messages generated for bad
25
+ inputs.
26
+ * Either returns the first matching row as a hash, or accumulates all matches as an
27
+ array of hashes.
22
28
 
23
29
  ### Planned features
24
- * input columns may be indexed for faster lookup performance
25
- * either returns the first matching row as a hash, or accumulates all matches as an
26
- array of hashes.
27
- * use of output functions to formulate the final decision
28
- * can use column symbol references or built-in guard functions for matching
29
- * may be extended with user-defined Ruby functions for tailored logic
30
- * can use if conditions to filter the results of multi-row decision output
30
+ `csv_decision` is currently a work in progress, and will be enhanced to support
31
+ the following features:
32
+ * Input columns may be indexed for faster lookup performance.
33
+ * May use functions in the output columns to formulate the final decision.
34
+ * Input hash values may be conditionally defaulted using a constant or a function call
35
+ * Use of column symbol references or built-in guard functions in the input
36
+ columns for matching.
37
+ * Output columns may used interpolated strings referencing column symbols.
38
+ * May be extended with user-defined Ruby functions for tailored logic.
39
+ * Can use post-match guard conditions to filter the results of multi-row
40
+ decision output.
31
41
 
32
42
  ### Why use `csv_decision`?
33
43
 
@@ -50,6 +60,11 @@ complex conditional logic against an input hash, producing a decision as an outp
50
60
  gem 'csv_decision', '~> 0.0.1'
51
61
  ```
52
62
 
63
+ or simply
64
+ ```bash
65
+ gem install csv_decision
66
+ ```
67
+
53
68
  ### Simple example
54
69
 
55
70
  A decision table may be as simple or as complex as you like (although very complex
@@ -89,6 +104,7 @@ politics | | Henry
89
104
  Now for some code.
90
105
 
91
106
  ```ruby
107
+ # Valid CSV string
92
108
  data = <<~DATA
93
109
  in :topic, in :region, out :team_member
94
110
  sports, Europe, Alice
@@ -118,10 +134,42 @@ politics | | Henry
118
134
  table = CSVDecision.parse(Pathname('spec/data/valid/simple_example.csv'))
119
135
  ```
120
136
 
137
+ We can also load this same table using the option: `first_match: false`.
138
+
139
+ ```ruby
140
+ table = CSVDecision.parse(data, first_match: false)
141
+ table.decide(topic: 'finance', region: 'Europe') # returns team_member: %w[Donald Ernest Zach]
142
+ ```
143
+
144
+
121
145
  For more examples see `spec/csv_decision/table_spec.rb`.
122
146
  Complete documentation of all table parameters is in the code - see
123
147
  `lib/csv_decision/parse.rb` and `lib/csv_decision/table.rb`.
124
148
 
149
+ ### Constants other than strings
150
+ Although `csv_decision` is string oriented, it does recognise other types of constant
151
+ present in the input hash. Specifically, the following classes are recognized:
152
+ `Integer`, `BigDecimal` and `NilClass`.
153
+
154
+ This is accomplished by prefixing the value with one of the operators `=`, `==` or `:=`.
155
+ (The syntax is intentionally lax.)
156
+
157
+ For example:
158
+ ```ruby
159
+ data = <<~DATA
160
+ in :constant, out :type
161
+ :=nil, NilClass
162
+ ==false, FALSE
163
+ =true, TRUE
164
+ = 0, Zero
165
+ :=100.0, 100%
166
+ DATA
167
+
168
+ table = CSVDecision.parse(data)
169
+ table.decide(constant: nil) # returns type: 'NilClass'
170
+ table.decide(constant: 0) # returns type: 'Zero'
171
+ table.decide(constant: BigDecimal.new('100.0')) # returns type: '100%'
172
+ ```
125
173
 
126
174
  ### Testing
127
175
 
@@ -19,7 +19,8 @@ benchmarks = [
19
19
  data: 'simple_example.csv',
20
20
  input: { 'topic' => 'culture', 'region' => 'America' },
21
21
  # Expected results for first_match and accumulate
22
- first_match: { 'team_member' => 'Zach' }
22
+ first_match: { 'team_member' => 'Zach' },
23
+ accumulate: { 'team_member' => 'Zach' }
23
24
  }
24
25
  ].deep_freeze
25
26
 
@@ -31,7 +32,7 @@ puts '=' * tag_width
31
32
  puts ""
32
33
 
33
34
  # First match true and false run options
34
- [true].each do |first_match|
35
+ [true, false].each do |first_match|
35
36
  puts "Table Decision Option: first_match: #{first_match}"
36
37
  puts '-' * tag_width
37
38
 
@@ -5,7 +5,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5
5
 
6
6
  Gem::Specification.new do |spec|
7
7
  spec.name = 'csv_decision'
8
- spec.version = '0.0.2'
8
+ spec.version = '0.0.3'
9
9
  spec.authors = ['Brett Vickers']
10
10
  spec.email = ['brett@phillips-vickers.com']
11
11
  spec.description = 'CSV based Ruby decision tables.'
@@ -26,8 +26,9 @@ module CSVDecision
26
26
  autoload :Table, 'csv_decision/table'
27
27
 
28
28
  module Matchers
29
- autoload :Numeric, 'csv_decision/matchers/numeric'
30
- autoload :Pattern, 'csv_decision/matchers/pattern'
31
- autoload :Range, 'csv_decision/matchers/range'
29
+ autoload :Function, 'csv_decision/matchers/function'
30
+ autoload :Numeric, 'csv_decision/matchers/numeric'
31
+ autoload :Pattern, 'csv_decision/matchers/pattern'
32
+ autoload :Range, 'csv_decision/matchers/range'
32
33
  end
33
34
  end
@@ -9,25 +9,29 @@ module CSVDecision
9
9
  # Value object used for column dictionary entries
10
10
  Entry = Struct.new(:name, :text_only)
11
11
 
12
- # Value object used for columns with defaults
12
+ # Value object used for any columns with defaults
13
13
  Default = Struct.new(:name, :function, :default_if)
14
14
 
15
15
  # Dictionary of all data columns.
16
16
  # # Note that the key of each hash is the header cell's array column index.
17
17
  # Note that input and output columns can be interspersed and need not have unique names.
18
18
  class Dictionary
19
+ # Input columns
19
20
  attr_accessor :ins
21
+
22
+ # Output columns
20
23
  attr_accessor :outs
24
+
25
+ # Input hash path - optional (planned feature)
21
26
  attr_accessor :path
27
+
28
+ # Input columns with a default value (planned feature)
22
29
  attr_accessor :defaults
23
30
 
24
31
  def initialize
25
32
  @ins = {}
26
33
  @outs = {}
27
-
28
- # Path for the input hash - optional
29
34
  @path = {}
30
- # Hash of columns that require defaults to be set
31
35
  @defaults = {}
32
36
  end
33
37
  end
@@ -57,7 +61,7 @@ module CSVDecision
57
61
 
58
62
  def initialize(table)
59
63
  # If a column does not have a valid header cell, then it's empty of data.
60
- # Return the stripped header row, removing it from the data array.
64
+ # Return the stripped header row, and remove it from the data array.
61
65
  row = Header.strip_empty_columns(rows: table.rows)
62
66
 
63
67
  # Build a dictionary of all valid data columns from the header row.
@@ -17,12 +17,13 @@ module CSVDecision
17
17
 
18
18
  # More lenient than a Ruby method name -
19
19
  # any spaces will have been replaced with underscores
20
- COLUMN_NAME = %r{\A\w[\w:/!?]*\z}
20
+ COLUMN_NAME = "\\w[\\w:/!?]*"
21
+ COLUMN_NAME_RE = Matchers.regexp(COLUMN_NAME)
21
22
 
22
23
  # Does this row contain a recognisable header cell?
23
24
  #
24
- # @param row [Array<String>]
25
- # @return [true, false]
25
+ # @param row [Array<String>] header row
26
+ # @return [Boolean] true if the row looks like a header
26
27
  def self.row?(row)
27
28
  row.find { |cell| cell.match(COLUMN_TYPE) }
28
29
  end
@@ -34,9 +35,9 @@ module CSVDecision
34
35
  # header row.
35
36
  def self.strip_empty_columns(rows:)
36
37
  empty_cols = empty_columns?(row: rows.first)
37
- Data.strip_columns(data: rows, empty_columns: empty_cols) unless empty_cols.empty?
38
+ Data.strip_columns(data: rows, empty_columns: empty_cols) if empty_cols
38
39
 
39
- # Remove the header row from the data array.
40
+ # Remove header row from the data array.
40
41
  rows.shift
41
42
  end
42
43
 
@@ -54,7 +55,7 @@ module CSVDecision
54
55
  dictionary
55
56
  end
56
57
 
57
- def self.header_column?(cell:)
58
+ def self.validate_header_column(cell:)
58
59
  match = COLUMN_TYPE.match(cell)
59
60
  raise CellValidationError, 'column name is not well formed' unless match
60
61
 
@@ -66,14 +67,14 @@ module CSVDecision
66
67
  raise CellValidationError,
67
68
  "header column '#{cell}' is not valid as the #{exp.message}"
68
69
  end
69
- private_class_method :header_column?
70
+ private_class_method :validate_header_column
70
71
 
71
72
  # Array of all empty column indices.
72
73
  def self.empty_columns?(row:)
73
74
  result = []
74
75
  row&.each_with_index { |cell, index| result << index if cell == '' }
75
76
 
76
- result
77
+ result.empty? ? false : result
77
78
  end
78
79
  private_class_method :empty_columns?
79
80
 
@@ -83,14 +84,16 @@ module CSVDecision
83
84
 
84
85
  raise CellValidationError, 'column name is missing'
85
86
  end
87
+ private_class_method :column_name
86
88
 
87
89
  def self.format_column_name(name)
88
90
  column_name = name.strip.tr("\s", '_')
89
91
 
90
- return column_name.to_sym if COLUMN_NAME.match(column_name)
92
+ return column_name.to_sym if COLUMN_NAME_RE.match(column_name)
91
93
 
92
94
  raise CellValidationError, "column name '#{name}' contains invalid characters"
93
95
  end
96
+ private_class_method :format_column_name
94
97
 
95
98
  # Returns the normalized column type, along with an indication if
96
99
  # the column is text only
@@ -110,11 +113,12 @@ module CSVDecision
110
113
  [type, nil]
111
114
  end
112
115
  end
116
+ private_class_method :column_type
113
117
 
114
118
  def self.parse_cell(cell:, index:, dictionary:)
115
- column_type, column_name = header_column?(cell: cell)
119
+ column_type, column_name = validate_header_column(cell: cell)
116
120
 
117
- type, text_only = Header.column_type(column_type)
121
+ type, text_only = column_type(column_type)
118
122
 
119
123
  dictionary_entry(dictionary: dictionary,
120
124
  type: type,
@@ -9,61 +9,6 @@ module CSVDecision
9
9
  # Value object for a cell proc
10
10
  Proc = Value.new(:type, :function)
11
11
 
12
- # Value object for a data row indicating which columns are constants versus procs.
13
- # ScanRow = Struct.new(:constants, :procs) do
14
- # def scan_columns(columns:, matchers:, row:)
15
- # columns.each_pair do |col, column|
16
- # # Empty cell matches everything, and so never needs to be scanned
17
- # next if row[col] == ''
18
- #
19
- # # If the column is text only then no special matchers need be invoked
20
- # next constants << col if column.text_only
21
- #
22
- # # Need to scan the cell against all matchers
23
- # row[col] = scan_cell(col: col, matchers: matchers, cell: row[col])
24
- # end
25
- # end
26
- #
27
- # def match_constants?(row:, scan_cols:)
28
- # constants.each do |col|
29
- # value = scan_cols.fetch(col, [])
30
- # # This only happens if the column is indexed
31
- # next if value == []
32
- # return false unless row[col] == value
33
- # end
34
- #
35
- # true
36
- # end
37
- #
38
- # def match_procs?(row:, input:)
39
- # hash = input[:hash]
40
- # scan_cols = input[:scan_cols]
41
- #
42
- # procs.each do |col|
43
- # return false unless Decide.eval_matcher(proc: row[col],
44
- # value: scan_cols[col],
45
- # hash: hash)
46
- # end
47
- #
48
- # true
49
- # end
50
- #
51
- # private
52
- #
53
- # def scan_cell(col:, matchers:, cell:)
54
- # # Scan the cell against all the matchers
55
- # proc = Matchers.scan(matchers: matchers, cell: cell)
56
- #
57
- # if proc
58
- # procs << col
59
- # return proc
60
- # end
61
- #
62
- # constants << col
63
- # cell
64
- # end
65
- # end
66
-
67
12
  # Methods to assign a matcher to data cells
68
13
  module Matchers
69
14
  # Negation sign for ranges and functions
@@ -127,5 +72,13 @@ module CSVDecision
127
72
  # Must be a simple constant
128
73
  false
129
74
  end
75
+
76
+ # @abstract Subclass and override {#matches?} to implement
77
+ # a custom Matcher class.
78
+ class Matcher
79
+ def initialize(_options = nil); end
80
+
81
+ def matches?(_cell); end
82
+ end
130
83
  end
131
84
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017 by Brett Vickers
5
+ # See LICENSE and README.md for details.
6
+ module CSVDecision
7
+ # Methods to assign a matcher to data cells
8
+ module Matchers
9
+ # rubocop: disable Lint/BooleanSymbol
10
+ NON_NUMERIC_CONSTANTS = {
11
+ true: true,
12
+ false: false,
13
+ nil: nil
14
+ }.freeze
15
+ # rubocop: enable Lint/BooleanSymbol
16
+
17
+ def self.input_cell_constant?(match)
18
+ return false unless CELL_CONSTANT.member?(match['operator'])
19
+ return false unless match['args'] == ''
20
+ return false unless match['negate'] == ''
21
+
22
+ name = match['name'].to_sym
23
+ return false unless NON_NUMERIC_CONSTANTS.key?(name)
24
+
25
+ Proc.with(type: :constant, function: NON_NUMERIC_CONSTANTS[name])
26
+ end
27
+
28
+ # Match cell against a function call or symbolic expression.
29
+ class Function < Matcher
30
+ # Looks like a function call or symbol expressions, e.g.,
31
+ # == true
32
+ # := function(arg: symbol)
33
+ # == :column_name
34
+ FUNCTION_CALL =
35
+ "(?<operator>=|:=|==|<|>|!=|>=|<=|:|!\\s*:)\s*(?<negate>!?)\\s*(?<name>#{Header::COLUMN_NAME}|:)(?<args>.*)"
36
+ FUNCTION_RE = Matchers.regexp(FUNCTION_CALL)
37
+
38
+ # COMPARATORS = {
39
+ # '>' => proc { |numeric_cell, value| Matchers.numeric(value) &.> numeric_cell },
40
+ # '>=' => proc { |numeric_cell, value| Matchers.numeric(value) &.>= numeric_cell },
41
+ # '<' => proc { |numeric_cell, value| Matchers.numeric(value) &.< numeric_cell },
42
+ # '<=' => proc { |numeric_cell, value| Matchers.numeric(value) &.<= numeric_cell },
43
+ # '!=' => proc { |numeric_cell, value| Matchers.numeric(value) &.!= numeric_cell }
44
+ # }.freeze
45
+
46
+ def matches?(cell)
47
+ match = FUNCTION_RE.match(cell)
48
+ return false unless match
49
+
50
+ # Check if the guard condition is a cell constant
51
+ proc = Matchers.input_cell_constant?(match)
52
+ return proc if proc
53
+
54
+ false
55
+ end
56
+ end
57
+ end
58
+ end
@@ -6,11 +6,16 @@
6
6
  module CSVDecision
7
7
  # Methods to assign a matcher to data cells
8
8
  module Matchers
9
- # Match cell against a Ruby-like numeric comparison
10
- class Numeric
11
- # Range types are .. or ...
12
- COMPARISON = /\A(?<comparator><=|>=|<|>|!=)\s*(?<value>\S.*)\z/
9
+ # Cell constant specified by prefixing the value with these symbols
10
+ CELL_CONSTANT = Set.new(%w[== := =]).freeze
13
11
 
12
+ # Match cell against a Ruby-like numeric comparison or a numeric constant
13
+ class Numeric < Matcher
14
+ # For example: >= 100 or != 0
15
+ COMPARISON = /\A(?<comparator><=|>=|<|>|!=|:=|==|=)\s*(?<value>\S.*)\z/
16
+
17
+ # Coerce the input value to a numeric representation before invoking the comparison.
18
+ # If the coercion fails, it will produce a nil value which always fails to match.
14
19
  COMPARATORS = {
15
20
  '>' => proc { |numeric_cell, value| Matchers.numeric(value) &.> numeric_cell },
16
21
  '>=' => proc { |numeric_cell, value| Matchers.numeric(value) &.>= numeric_cell },
@@ -26,12 +31,16 @@ module CSVDecision
26
31
  numeric_cell = Matchers.numeric(match['value'])
27
32
  return false unless numeric_cell
28
33
 
34
+ comparator = match['comparator']
35
+
36
+ # If the comparator is assignment/equality, then just treat as a simple constant
37
+ if CELL_CONSTANT.member?(comparator)
38
+ return Proc.with(type: :constant, function: numeric_cell)
39
+ end
40
+
29
41
  Proc.with(type: :proc,
30
- function: COMPARATORS[match['comparator']].curry[numeric_cell])
42
+ function: COMPARATORS[comparator].curry[numeric_cell])
31
43
  end
32
-
33
- # This matcher does not need access to the options hash
34
- def initialize(_options = nil); end
35
44
  end
36
45
  end
37
46
  end
@@ -7,7 +7,7 @@ module CSVDecision
7
7
  # Methods to assign a matcher to data cells
8
8
  module Matchers
9
9
  # Match cell against a regular expression pattern
10
- class Pattern
10
+ class Pattern < Matcher
11
11
  EXPLICIT_COMPARISON = /\A(?<comparator>=~|!~|!=)\s*(?<value>\S.*)\z/
12
12
  IMPLICIT_COMPARISON = /\A(?<comparator>=~|!~|!=)?\s*(?<value>\S.*)\z/
13
13
 
@@ -7,7 +7,7 @@ module CSVDecision
7
7
  # Methods to assign a matcher to data cells
8
8
  module Matchers
9
9
  # Match cell against a Ruby-like range
10
- class Range
10
+ class Range < Matcher
11
11
  # Range types are .. or ...
12
12
  TYPE = '(\.\.\.|\.\.)'
13
13
 
@@ -68,9 +68,6 @@ module CSVDecision
68
68
 
69
69
  false
70
70
  end
71
-
72
- # This matcher does not need access to the options hash
73
- def initialize(_options = nil); end
74
71
  end
75
72
  end
76
73
  end
@@ -9,7 +9,8 @@ module CSVDecision
9
9
  DEFAULT_MATCHERS = [
10
10
  Matchers::Range,
11
11
  Matchers::Numeric,
12
- Matchers::Pattern
12
+ Matchers::Pattern,
13
+ Matchers::Function
13
14
  ].freeze
14
15
 
15
16
  # All valid options with their default values.
@@ -56,13 +56,22 @@ module CSVDecision
56
56
  # Scan the cell against all the matchers
57
57
  proc = Matchers.scan(matchers: matchers, cell: cell)
58
58
 
59
- if proc
60
- procs << col
61
- return proc
62
- end
59
+ return set(proc, col) if proc
63
60
 
61
+ # Just a plain constant
64
62
  constants << col
65
63
  cell
66
64
  end
65
+
66
+ def set(proc, col)
67
+ # Unbox a constant
68
+ if proc.type == :constant
69
+ constants << col
70
+ return proc.function
71
+ end
72
+
73
+ procs << col
74
+ proc
75
+ end
67
76
  end
68
77
  end
@@ -4,34 +4,53 @@
4
4
  # Created December 2017 by Brett Vickers
5
5
  # See LICENSE and README.md for details.
6
6
  module CSVDecision
7
- # Decision Table that accepts input hashes and makes decision
7
+ # Decision Table that accepts input hashes and makes decisions
8
8
  class Table
9
+ # CSVDecision::Columns object - dictionary of all input and output columns
9
10
  attr_accessor :columns
11
+
12
+ # File path name if decision table loaded from a CSV file
10
13
  attr_accessor :file
14
+
15
+ # All options used to parse the table
11
16
  attr_accessor :options
17
+
18
+ # Set if the table has any output functions (planned feature)
12
19
  attr_accessor :outs_functions
20
+
21
+ # Data rows - an array of arrays
13
22
  attr_accessor :rows
23
+
24
+ # Array of CSVDecision::ScanRow objects used to implement matching logic
14
25
  attr_accessor :scan_rows
26
+
27
+ # Any array of CSVDecision::Table pre-loaded tables passed to this decision table
28
+ # at load time. Used to allow this decision table to lookup values in other
29
+ # decision tables. (Planned feature.)
15
30
  attr_reader :tables
16
31
 
17
32
  # Main public method for making decisions.
33
+ #
18
34
  # @param input [Hash] - input hash (keys may or may not be symbolized)
19
- # @return [Hash]
35
+ # @return [Hash{Symbol => Object, Array<Object>}] decision
20
36
  def decide(input)
21
37
  Decide.decide(table: self, input: input, symbolize_keys: true).result
22
38
  end
23
39
 
24
- # Unsafe version of decide - will mutate the hash if set: option (planned feature)
25
- # is used.
26
- # @param input [Hash] - input hash (keys must be symbolized)
27
- # @return [Hash]
40
+ # Unsafe version of decide - will mutate the hash if set: column type
41
+ # is used (planned feature).
42
+ #
43
+ # @param input [Hash{Symbol => Object}] - input hash (all keys must already be symbolized)
44
+ # @return [Hash{Symbol => Object, Array<Object>}]
28
45
  def decide!(input)
29
46
  Decide.decide(table: self, input: input, symbolize_keys: false).result
30
47
  end
31
48
 
32
- # Iterate through all data rows of the decision table.
33
- # @param first [Integer] - start row
34
- # @param last [Integer] - last row
49
+ # Iterate through all data rows of the decision table, with an optional
50
+ # first and last row index given.
51
+ #
52
+ # @param first [Integer] start row
53
+ # @param last [Integer, nil] last row
35
54
  def each(first = 0, last = @rows.count - 1)
36
55
  index = first
37
56
  while index <= (last || first)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../../lib/csv_decision'
4
+
5
+ describe CSVDecision::Matchers::Function do
6
+ subject { described_class.new }
7
+
8
+ describe '#new' do
9
+ it { is_expected.to be_a CSVDecision::Matchers::Function }
10
+ it { is_expected.to be_a CSVDecision::Matchers::Matcher }
11
+ it { is_expected.to respond_to(:matches?).with(1).argument }
12
+ end
13
+
14
+ context 'cell value recognition' do
15
+ cells = {
16
+ ':= nil' => { operator: ':=', value: 'nil' },
17
+ '== nil' => { operator: '==', value: 'nil' },
18
+ '= nil' => { operator: '=', value: 'nil' },
19
+ '==true' => { operator: '==', value: 'true' },
20
+ ':=false' => { operator: ':=', value: 'false' },
21
+ }
22
+ cells.each_pair do |cell, expected|
23
+ it "recognises #{cell} as a constant" do
24
+ match = described_class::FUNCTION_RE.match(cell)
25
+ expect(match['operator']).to eq expected[:operator]
26
+ expect(match['name']).to eq expected[:value]
27
+ end
28
+ end
29
+ end
30
+
31
+ describe '#matches?' do
32
+ matcher = described_class.new
33
+
34
+ context 'constant matches value' do
35
+ data = [
36
+ ['= nil', nil],
37
+ [':= false', false],
38
+ ['==true', true]
39
+ ]
40
+
41
+ data.each do |cell, value|
42
+ it "comparision #{cell} matches #{value}" do
43
+ proc = matcher.matches?(cell)
44
+ expect(proc).to be_a(CSVDecision::Proc)
45
+ expect(proc.type).to eq :constant
46
+ expect(proc.function).to eq value
47
+ end
48
+ end
49
+ end
50
+
51
+
52
+ context 'does not match a function constant' do
53
+ data = ['1', ':column', ':= 1.1', ':= abc', 'abc', 'abc.*def', '-1..1', '0...3']
54
+
55
+ data.each do |cell|
56
+ it "cell #{cell} is not a comparision}" do
57
+ expect(matcher.matches?(cell)).to eq false
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -11,14 +11,16 @@ describe CSVDecision::Matchers::Numeric do
11
11
  end
12
12
 
13
13
  context 'cell value recognition' do
14
- ranges = {
14
+ cells = {
15
15
  '> -1' => { comparator: '>', value: '-1' },
16
16
  '>= 10.0' => { comparator: '>=', value: '10.0' },
17
17
  '< .0' => { comparator: '<', value: '.0' },
18
18
  '<= +1' => { comparator: '<=', value: '+1' },
19
- '!= 0.0' => { comparator: '!=', value: '0.0' },
19
+ '!=0.0' => { comparator: '!=', value: '0.0' },
20
+ ':= 0.0' => { comparator: ':=', value: '0.0' },
21
+ '= 1.0' => { comparator: '=', value: '1.0' }
20
22
  }
21
- ranges.each_pair do |cell, expected|
23
+ cells.each_pair do |cell, expected|
22
24
  it "recognises #{cell} as a comparision" do
23
25
  match = described_class::COMPARISON.match(cell)
24
26
  expect(match['comparator']).to eq expected[:comparator]
@@ -35,6 +37,7 @@ describe CSVDecision::Matchers::Numeric do
35
37
  ['< 1', 0],
36
38
  ['< 1', '0'],
37
39
  ['> 1', 5],
40
+ ['!= 1', 0],
38
41
  ['> 1', '5'],
39
42
  ['>= 1.1', BigDecimal.new('1.1')],
40
43
  ['<=-1.1', BigDecimal.new('-12')]
@@ -49,28 +52,25 @@ describe CSVDecision::Matchers::Numeric do
49
52
  end
50
53
  end
51
54
  end
52
- #
53
- # context 'range does not match value' do
54
- # data = [
55
- # [ '-1..+4', 5],
56
- # ['!-1..+4', 2],
57
- # %w[a...z z],
58
- # %w[!a..z m],
59
- # %w[-1..1 1.1],
60
- # ['-1..1', BigDecimal.new('1.1')],
61
- # ['-1..1', BigDecimal.new('1.1')]
62
- # ]
63
- #
64
- # data.each do |cell, value|
65
- # it "range #{cell} does not match #{value}" do
66
- # proc = matcher.matches?(cell)
67
- # expect(proc).to be_a(CSVDecision::Proc)
68
- # expect(proc.type).to eq :proc
69
- # expect(proc.function[value]).to eq false
70
- # end
71
- # end
72
- # end
73
- #
55
+
56
+ context 'numeric constant' do
57
+ data = [
58
+ ['== 1', 1],
59
+ [':= 0', 0],
60
+ ['==1.1', BigDecimal.new('1.1')],
61
+ ['=-1.2', BigDecimal.new('-1.2')]
62
+ ]
63
+
64
+ data.each do |cell, value|
65
+ it "constant expression #{cell} evaluates to #{value}" do
66
+ proc = matcher.matches?(cell)
67
+ expect(proc).to be_a(CSVDecision::Proc)
68
+ expect(proc.type).to eq :constant
69
+ expect(proc.function).to eq value
70
+ end
71
+ end
72
+ end
73
+
74
74
  context 'does not match a numeric comparision' do
75
75
  data = ['1', ':column', ':= nil', ':= true', 'abc', 'abc.*def', '-1..1', '0...3']
76
76
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative '../../lib/csv_decision'
4
4
 
5
- context 'simple example' do
5
+ context 'simple example - strings-only' do
6
6
  data = <<~DATA
7
7
  in :topic, in :region, out :team_member
8
8
  sports, Europe, Alice
@@ -29,7 +29,6 @@ context 'simple example' do
29
29
  expect(result).to eq(team_member: 'Zach')
30
30
  end
31
31
 
32
-
33
32
  it 'makes correct decisions for CSV file' do
34
33
  table = CSVDecision.parse(Pathname('spec/data/valid/simple_example.csv'))
35
34
 
@@ -43,3 +42,34 @@ context 'simple example' do
43
42
  expect(result).to eq(team_member: 'Zach')
44
43
  end
45
44
  end
45
+
46
+ context 'simple example - constants' do
47
+ data = <<~DATA
48
+ in :constant, out :type
49
+ :=nil, NilClass
50
+ ==false, FALSE
51
+ =true, TRUE
52
+ = 0, Zero
53
+ :=100.0, 100%
54
+ DATA
55
+
56
+ it 'makes correct decisions for CSV string' do
57
+ table = CSVDecision.parse(data)
58
+
59
+ result = table.decide(constant: nil)
60
+ expect(result).to eq(type: 'NilClass')
61
+
62
+ result = table.decide(constant: true)
63
+ expect(result).to eq(type: 'TRUE')
64
+
65
+ result = table.decide(constant: false)
66
+ expect(result).to eq(type: 'FALSE')
67
+
68
+ result = table.decide(constant: 0)
69
+ expect(result).to eq(type: 'Zero')
70
+
71
+ result = table.decide(constant: BigDecimal.new('100.0'))
72
+ expect(result).to eq(type: '100%')
73
+ end
74
+ end
75
+
@@ -56,6 +56,42 @@ describe CSVDecision::Table do
56
56
  end
57
57
  end
58
58
 
59
+ context 'makes correct decisions for simple non-string constants' do
60
+ examples = [
61
+ {
62
+ example: 'parses CSV file',
63
+ options: {},
64
+ data: Pathname(File.join(SPEC_DATA_VALID, 'simple_constants.csv'))
65
+ },
66
+ {
67
+ example: 'parses CSV string',
68
+ options: {},
69
+ data: <<~DATA
70
+ in :constant, out :type
71
+ :=nil, NilClass
72
+ = 0, Zero
73
+ :=100.0, 100%
74
+ , Unrecognized
75
+ DATA
76
+ },
77
+ ]
78
+ examples.each do |test|
79
+ %i[decide decide!].each do |method|
80
+ it "#{method} correctly #{test[:example]} with first_match: true" do
81
+ options = test[:options].merge(first_match: true)
82
+ table = CSVDecision.parse(test[:data], options)
83
+
84
+ expect(table.send(method, constant: nil)).to eq(type: 'NilClass')
85
+ expect(table.send(method, constant: 0)).to eq(type: 'Zero')
86
+ expect(table.send(method, constant: BigDecimal.new('100.0'))).to eq(type: '100%')
87
+ expect(table.send(method, constant: ':=nil')).to eq(type: 'Unrecognized')
88
+ expect(table.send(method, constant: '= 0')).to eq(type: 'Unrecognized')
89
+ expect(table.send(method, constant: ':=100.0')).to eq(type: 'Unrecognized')
90
+ end
91
+ end
92
+ end
93
+ end
94
+
59
95
  context 'makes correct decisions for a table with regexps and ranges' do
60
96
  examples = [
61
97
  {
@@ -66,7 +102,7 @@ describe CSVDecision::Table do
66
102
  18..35, maniac, Adelsky
67
103
  23..40, bad|maniac, Bronco
68
104
  36..50, bad.*, Espadas
69
- 51..78, , Thorsten
105
+ := 100, , Thorsten
70
106
  44..100, !~ maniac, Ojiisan
71
107
  > 100, maniac.*, Chester
72
108
  23..35, .*rich, Kerfelden
@@ -82,7 +118,7 @@ describe CSVDecision::Table do
82
118
  18..35, maniac, Adelsky
83
119
  23..40, =~ bad|maniac, Bronco
84
120
  36..50, =~ bad.*, Espadas
85
- 51..78, , Thorsten
121
+ ==100, , Thorsten
86
122
  44..100, !~ maniac, Ojiisan
87
123
  > 100, =~ maniac.*, Chester
88
124
  23..35, =~ .*rich, Kerfelden
@@ -98,7 +134,7 @@ describe CSVDecision::Table do
98
134
  >= 18, <= 35, maniac, Adelsky
99
135
  >= 23, <= 40, =~ bad|maniac, Bronco
100
136
  >= 36, <= 50, =~ bad.*, Espadas
101
- >= 51, <= 78, , Thorsten
137
+ == 100, , , Thorsten
102
138
  >= 44, <= 100, != maniac, Ojiisan
103
139
  > 100, , =~ maniac.*, Chester
104
140
  >= 23, <= 35, =~ .*rich, Kerfelden
@@ -113,12 +149,12 @@ describe CSVDecision::Table do
113
149
  options = test[:options].merge(first_match: true)
114
150
  table = CSVDecision.parse(test[:data], options)
115
151
 
116
- expect(table.send(method, age: 72)).to eq(salesperson: 'Thorsten')
152
+ expect(table.send(method, age: 100)).to eq(salesperson: 'Thorsten')
117
153
  expect(table.send(method, age: 25, trait: 'very rich')).to eq(salesperson: 'Kerfelden')
118
154
  expect(table.send(method, age: 25, trait: 'maniac')).to eq(salesperson: 'Adelsky')
119
155
  expect(table.send(method, age: 44, trait: 'maniac')).to eq(salesperson: 'Korolev')
120
156
  expect(table.send(method, age: 101, trait: 'maniacal')).to eq(salesperson: 'Chester')
121
- expect(table.send(method, age: 45, trait: 'cheerful')).to eq(salesperson: 'Ojiisan')
157
+ expect(table.send(method, age: 44, trait: 'cheerful')).to eq(salesperson: 'Ojiisan')
122
158
  expect(table.send(method, age: 49, trait: 'bad')).to eq(salesperson: 'Espadas')
123
159
  expect(table.send(method, age: 40, trait: 'maniac')).to eq(salesperson: 'Bronco')
124
160
  end
@@ -127,7 +163,7 @@ describe CSVDecision::Table do
127
163
  options = test[:options].merge(first_match: false)
128
164
  table = CSVDecision.parse(test[:data], options)
129
165
 
130
- expect(table.send(method, age: 72))
166
+ expect(table.send(method, age: 100))
131
167
  .to eq(salesperson: %w[Thorsten Ojiisan])
132
168
  expect(table.send(method, age: 25, trait: 'very rich'))
133
169
  .to eq(salesperson: 'Kerfelden')
@@ -0,0 +1,5 @@
1
+ in :constant,out :type
2
+ :=nil, NilClass
3
+ ==0, Zero
4
+ :=100.0, 100%
5
+ , Unrecognized
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: csv_decision
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Vickers
@@ -205,6 +205,7 @@ files:
205
205
  - lib/csv_decision/input.rb
206
206
  - lib/csv_decision/load.rb
207
207
  - lib/csv_decision/matchers.rb
208
+ - lib/csv_decision/matchers/function.rb
208
209
  - lib/csv_decision/matchers/numeric.rb
209
210
  - lib/csv_decision/matchers/pattern.rb
210
211
  - lib/csv_decision/matchers/range.rb
@@ -218,6 +219,7 @@ files:
218
219
  - spec/csv_decision/decision_spec.rb
219
220
  - spec/csv_decision/input_spec.rb
220
221
  - spec/csv_decision/load_spec.rb
222
+ - spec/csv_decision/matchers/function_spec.rb
221
223
  - spec/csv_decision/matchers/numeric_spec.rb
222
224
  - spec/csv_decision/matchers/pattern_spec.rb
223
225
  - spec/csv_decision/matchers/range_spec.rb
@@ -232,6 +234,7 @@ files:
232
234
  - spec/data/valid/empty.csv
233
235
  - spec/data/valid/options_in_file1.csv
234
236
  - spec/data/valid/options_in_file2.csv
237
+ - spec/data/valid/simple_constants.csv
235
238
  - spec/data/valid/simple_example.csv
236
239
  - spec/data/valid/valid.csv
237
240
  - spec/spec_helper.rb
@@ -266,6 +269,7 @@ test_files:
266
269
  - spec/csv_decision/decision_spec.rb
267
270
  - spec/csv_decision/input_spec.rb
268
271
  - spec/csv_decision/load_spec.rb
272
+ - spec/csv_decision/matchers/function_spec.rb
269
273
  - spec/csv_decision/matchers/numeric_spec.rb
270
274
  - spec/csv_decision/matchers/pattern_spec.rb
271
275
  - spec/csv_decision/matchers/range_spec.rb
@@ -280,6 +284,7 @@ test_files:
280
284
  - spec/data/valid/empty.csv
281
285
  - spec/data/valid/options_in_file1.csv
282
286
  - spec/data/valid/options_in_file2.csv
287
+ - spec/data/valid/simple_constants.csv
283
288
  - spec/data/valid/simple_example.csv
284
289
  - spec/data/valid/valid.csv
285
290
  - spec/spec_helper.rb