csv_decision 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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