csv_decision 0.2.0 → 0.3.0

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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +5 -1
  3. data/README.md +11 -8
  4. data/benchmarks/rufus_decision.rb +9 -1
  5. data/csv_decision.gemspec +1 -1
  6. data/doc/CSVDecision/CellValidationError.html +1 -1
  7. data/doc/CSVDecision/Columns/Dictionary.html +29 -29
  8. data/doc/CSVDecision/Columns.html +394 -47
  9. data/doc/CSVDecision/Data.html +2 -2
  10. data/doc/CSVDecision/Decide.html +23 -159
  11. data/doc/CSVDecision/Decision.html +370 -32
  12. data/doc/CSVDecision/Defaults.html +1 -1
  13. data/doc/CSVDecision/Dictionary/Entry.html +157 -55
  14. data/doc/CSVDecision/Dictionary.html +37 -21
  15. data/doc/CSVDecision/Error.html +1 -1
  16. data/doc/CSVDecision/FileError.html +1 -1
  17. data/doc/CSVDecision/Header.html +142 -1
  18. data/doc/CSVDecision/Index.html +741 -0
  19. data/doc/CSVDecision/Input.html +14 -61
  20. data/doc/CSVDecision/Load.html +1 -1
  21. data/doc/CSVDecision/Matchers/Constant.html +1 -1
  22. data/doc/CSVDecision/Matchers/Function.html +1 -1
  23. data/doc/CSVDecision/Matchers/Guard.html +13 -147
  24. data/doc/CSVDecision/Matchers/Matcher.html +13 -13
  25. data/doc/CSVDecision/Matchers/Numeric.html +1 -1
  26. data/doc/CSVDecision/Matchers/Pattern.html +1 -1
  27. data/doc/CSVDecision/Matchers/Proc.html +147 -14
  28. data/doc/CSVDecision/Matchers/Range.html +1 -1
  29. data/doc/CSVDecision/Matchers/Symbol.html +1 -1
  30. data/doc/CSVDecision/Matchers.html +55 -162
  31. data/doc/CSVDecision/Options.html +21 -21
  32. data/doc/CSVDecision/Parse.html +2 -180
  33. data/doc/CSVDecision/Result.html +220 -38
  34. data/doc/CSVDecision/ScanRow.html +69 -325
  35. data/doc/CSVDecision/Table.html +128 -40
  36. data/doc/CSVDecision/TableValidationError.html +1 -1
  37. data/doc/CSVDecision/Validate.html +1 -1
  38. data/doc/CSVDecision.html +4 -4
  39. data/doc/_index.html +8 -8
  40. data/doc/class_list.html +1 -1
  41. data/doc/file.README.html +13 -11
  42. data/doc/index.html +13 -11
  43. data/doc/method_list.html +206 -150
  44. data/doc/top-level-namespace.html +1 -1
  45. data/lib/csv_decision/columns.rb +87 -1
  46. data/lib/csv_decision/decision.rb +54 -29
  47. data/lib/csv_decision/defaults.rb +1 -1
  48. data/lib/csv_decision/dictionary.rb +32 -22
  49. data/lib/csv_decision/header.rb +17 -0
  50. data/lib/csv_decision/index.rb +107 -0
  51. data/lib/csv_decision/input.rb +45 -13
  52. data/lib/csv_decision/matchers/guard.rb +2 -0
  53. data/lib/csv_decision/matchers.rb +14 -8
  54. data/lib/csv_decision/options.rb +7 -19
  55. data/lib/csv_decision/parse.rb +12 -96
  56. data/lib/csv_decision/result.rb +10 -9
  57. data/lib/csv_decision/scan_row.rb +20 -44
  58. data/lib/csv_decision/table.rb +7 -4
  59. data/lib/csv_decision.rb +1 -1
  60. data/spec/csv_decision/columns_spec.rb +6 -6
  61. data/spec/csv_decision/data_spec.rb +0 -5
  62. data/spec/csv_decision/index_spec.rb +58 -0
  63. data/spec/csv_decision/input_spec.rb +7 -2
  64. data/spec/csv_decision/options_spec.rb +16 -1
  65. data/spec/csv_decision/parse_spec.rb +4 -5
  66. data/spec/csv_decision/table_spec.rb +70 -0
  67. data/spec/data/{valid → invalid}/empty.csv +0 -0
  68. data/spec/data/valid/index_example.csv +12 -0
  69. data/spec/data/valid/multi_column_index.csv +10 -0
  70. data/spec/data/valid/multi_column_index2.csv +12 -0
  71. data/spec/data/valid/options_in_file3.csv +13 -0
  72. metadata +16 -5
  73. data/lib/csv_decision/decide.rb +0 -45
@@ -100,7 +100,7 @@
100
100
  </div>
101
101
 
102
102
  <div id="footer">
103
- Generated on Sat Jan 13 10:02:46 2018 by
103
+ Generated on Sat Jan 20 15:44:34 2018 by
104
104
  <a href="http://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
105
105
  0.9.12 (ruby-2.4.0).
106
106
  </div>
@@ -8,6 +8,89 @@ module CSVDecision
8
8
  # Dictionary of all this table's columns - inputs, outputs etc.
9
9
  # @api private
10
10
  class Columns
11
+ # @param columns [CSVDecision::Columns] Table's columns dictionary.
12
+ # @param row [Array] Data row.
13
+ # @return [void]
14
+ def self.outs_dictionary(columns:, row:)
15
+ row.each_with_index do |cell, index|
16
+ outs_check_cell(columns: columns, cell: cell, index: index)
17
+ end
18
+ end
19
+
20
+ # @param columns [CSVDecision::Columns] Table's columns dictionary.
21
+ # @param row [Array] Data row.
22
+ # @return [void]
23
+ def self.ins_dictionary(columns:, row:)
24
+ row.each { |cell| ins_cell_dictionary(columns: columns, cell: cell) }
25
+ end
26
+
27
+ # @param columns [CSVDecision::Columns] Table's columns dictionary.
28
+ # @param cell [Object] Data row cell.
29
+ # @return [void]
30
+ def self.ins_cell_dictionary(columns:, cell:)
31
+ return unless cell.is_a?(Matchers::Proc)
32
+ return if cell.symbols.nil?
33
+
34
+ add_ins_symbols(columns: columns, cell: cell)
35
+ end
36
+
37
+ def self.outs_check_cell(columns:, cell:, index:)
38
+ return unless cell.is_a?(Matchers::Proc)
39
+ return if cell.symbols.nil?
40
+
41
+ check_outs_symbols(columns: columns, cell: cell, index: index)
42
+ end
43
+ private_class_method :outs_check_cell
44
+
45
+ def self.check_outs_symbols(columns:, cell:, index:)
46
+ Array(cell.symbols).each do |symbol|
47
+ check_outs_symbol(columns: columns, symbol: symbol, index: index)
48
+ end
49
+ end
50
+ private_class_method :check_outs_symbols
51
+
52
+ def self.check_outs_symbol(columns:, symbol:, index:)
53
+ in_out = columns.dictionary[symbol]
54
+
55
+ # If its an input column symbol then we're good.
56
+ return if ins_symbol?(columns: columns, symbol: symbol, in_out: in_out)
57
+
58
+ # Check if this output symbol reference is on or after this cell's column
59
+ invalid_out_ref?(columns, index, in_out)
60
+ end
61
+ private_class_method :check_outs_symbol
62
+
63
+ # If the symbol exists either as an input or does not exist then we're good.
64
+ def self.ins_symbol?(columns:, symbol:, in_out:)
65
+ return true if in_out == :in
66
+
67
+ # It must an input symbol, as all the output symbols have been parsed.
68
+ return columns.dictionary[symbol] = :in if in_out.nil?
69
+
70
+ false
71
+ end
72
+ private_class_method :ins_symbol?
73
+
74
+ def self.invalid_out_ref?(columns, index, in_out)
75
+ return false if in_out < index
76
+
77
+ that_column = if in_out == index
78
+ 'reference to itself'
79
+ else
80
+ "an out of order reference to output column '#{columns.outs[in_out].name}'"
81
+ end
82
+ raise CellValidationError,
83
+ "output column '#{columns.outs[index].name}' makes #{that_column}"
84
+ end
85
+ private_class_method :invalid_out_ref?
86
+
87
+ def self.add_ins_symbols(columns:, cell:)
88
+ Array(cell.symbols).each do |symbol|
89
+ CSVDecision::Dictionary.add_name(columns: columns, name: symbol)
90
+ end
91
+ end
92
+ private_class_method :add_ins_symbols
93
+
11
94
  # Dictionary of all table data columns.
12
95
  # The key of each hash is the header cell's array column index.
13
96
  # Note that input and output columns may be interspersed, and multiple input columns
@@ -86,8 +169,11 @@ module CSVDecision
86
169
  # Return the stripped header row, and remove it from the data array.
87
170
  row = Header.strip_empty_columns(rows: table.rows)
88
171
 
172
+ # No header row found?
173
+ raise TableValidationError, 'table has no header row' unless row
174
+
89
175
  # Build a dictionary of all valid data columns from the header row.
90
- @dictionary = CSVDecision::Dictionary.build(header: row, dictionary: Dictionary.new) if row
176
+ @dictionary = CSVDecision::Dictionary.build(header: row, dictionary: Dictionary.new)
91
177
 
92
178
  freeze
93
179
  end
@@ -8,6 +8,28 @@ module CSVDecision
8
8
  # Accumulate the matching row(s) and calculate the final result.
9
9
  # @api private
10
10
  class Decision
11
+ # Main method for making decisions.
12
+ #
13
+ # @param table [CSVDecision::Table] Decision table.
14
+ # @param input [Hash] Input hash (keys may or may not be symbolized)
15
+ # @param symbolize_keys [true, false] Set to false if keys are symbolized and it's
16
+ # OK to mutate the input hash. Otherwise a copy of the input hash is symbolized.
17
+ # @return [Hash] Decision result.
18
+ def self.make(table:, input:, symbolize_keys:)
19
+ # Parse and transform the hash supplied as input
20
+ input = Input.parse(table: table, input: input, symbolize_keys: symbolize_keys)
21
+
22
+ # The decision object collects the results of the search and
23
+ # calculates the final result
24
+ decision = Decision.new(table: table, input: input[:hash])
25
+
26
+ if table.index
27
+ decision.index(table: table, input: input)
28
+ else
29
+ decision.scan(table: table, hash: input[:hash], scan_cols: input[:scan_cols])
30
+ end
31
+ end
32
+
11
33
  # @param table [CSVDecision::Table] Decision table being processed.
12
34
  # @param input [Hash{Symbol=>Object}] Input hash data structure.
13
35
  def initialize(table:, input:)
@@ -20,44 +42,47 @@ module CSVDecision
20
42
  @rows_picked = []
21
43
 
22
44
  # Relevant table attributes
23
- table_attributes(table)
45
+ @first_match = table.options[:first_match]
24
46
  end
25
47
 
26
48
  # Scan the decision table up against the input hash.
27
49
  #
28
- # @param (see #initialize)
29
- # @return [{Symbol=>Object}] Decision result.
30
- def scan(table:, input:)
50
+ # @param table (see #initialize)
51
+ # @param hash [Hash] Input hash.
52
+ # @param scan_cols [Hash{Index=>Object}] Input column values to scan.
53
+ # @return [Hash{Symbol=>Object}] Decision result.
54
+ def scan(table:, hash:, scan_cols:)
31
55
  table.each do |row, index|
32
- # +row_scan+ returns false if more rows need to be scanned, truthy otherwise.
33
- return result if row_scan(input: input, row: row, scan_row: table.scan_rows[index])
56
+ next unless table.scan_rows[index].match?(row: row, hash: hash, scan_cols: scan_cols)
57
+ return @result.attributes if add(row)
34
58
  end
35
59
 
36
- result
60
+ @rows_picked.empty? ? {} : accumulated_result
37
61
  end
38
62
 
39
- private
63
+ # Use an index to scan the decision table up against the input hash.
64
+ #
65
+ # @param (see #initialize)
66
+ # @param input [Hash] Hash of parsed input data.
67
+ # @return [{Symbol=>Object}] Decision result.
68
+ def index(table:, input:)
69
+ # If the index lookup fails, there's no match
70
+ return {} unless (rows = table.index.hash[input[:key]])
40
71
 
41
- # Record the relevant table attributes.
42
- def table_attributes(table)
43
- @first_match = table.options[:first_match]
44
- @outs = table.columns.outs
45
- @outs_functions = table.outs_functions
72
+ index_scan(table: table, scan_cols: input[:scan_cols], hash: input[:hash], rows: Array(rows))
46
73
  end
47
74
 
48
- # Derive the final result.
49
- #
50
- # @return [nil, Hash{Symbol=>Object}] Final result hash if matches found,
51
- # otherwise the empty hash for no result.
52
- def result
53
- return {} if @rows_picked.blank?
54
- @first_match ? @result.attributes : accumulated_result
55
- end
75
+ private
76
+
77
+ def index_scan(table:, scan_cols:, hash:, rows:)
78
+ rows.each do |start_row, end_row|
79
+ table.each(start_row, end_row || start_row) do |row, index|
80
+ next unless table.scan_rows[index].match?(row: row, hash: hash, scan_cols: scan_cols)
81
+ return @result.attributes if add(row)
82
+ end
83
+ end
56
84
 
57
- # Scan the row for matches against the input conditions.
58
- def row_scan(input:, row:, scan_row:)
59
- # +add+ returns false if more rows need to be scanned, truthy otherwise.
60
- add(row) if Decide.matches?(row: row, input: input, scan_row: scan_row)
85
+ @rows_picked.empty? ? {} : accumulated_result
61
86
  end
62
87
 
63
88
  # Add a matched row to the decision object being built.
@@ -76,7 +101,7 @@ module CSVDecision
76
101
  end
77
102
 
78
103
  def accumulated_result
79
- return @result.final unless @outs_functions
104
+ return @result.final unless @result.outs_functions
80
105
  return @result.eval_outs(@rows_picked.first) unless @result.multi_result
81
106
 
82
107
  multi_row_result
@@ -84,12 +109,12 @@ module CSVDecision
84
109
 
85
110
  def multi_row_result
86
111
  # Scan each output column that contains functions
87
- @outs.each_pair { |col, column| eval_column_procs(col: col, column: column) if column.eval }
112
+ @result.outs.each_pair { |col, column| eval_procs(col: col, column: column) if column.eval }
88
113
 
89
114
  @result.final
90
115
  end
91
116
 
92
- def eval_column_procs(col:, column:)
117
+ def eval_procs(col:, column:)
93
118
  @rows_picked.each_with_index do |row, index|
94
119
  proc = row[col]
95
120
  next unless proc.is_a?(Matchers::Proc)
@@ -103,7 +128,7 @@ module CSVDecision
103
128
  # This decision row may contain procs, which if present will need to be evaluated.
104
129
  # If this row contains if: columns then this row may be filtered out, in which case
105
130
  # this method call will return false.
106
- return eval_single_row(row) if @outs_functions
131
+ return eval_single_row(row) if @result.outs_functions
107
132
 
108
133
  # Common case is just copying output column values to the final result.
109
134
  @rows_picked = row
@@ -40,7 +40,7 @@ module CSVDecision
40
40
  entry.function = cell.function
41
41
 
42
42
  # Add any referenced input column symbols to the column name dictionary
43
- Parse.ins_cell_dictionary(columns: columns, cell: cell)
43
+ Columns.ins_cell_dictionary(columns: columns, cell: cell)
44
44
  end
45
45
  private_class_method :parse_cell
46
46
  end
@@ -8,6 +8,19 @@ module CSVDecision
8
8
  # Parse the CSV file's header row. These methods are only required at table load time.
9
9
  # @api private
10
10
  module Dictionary
11
+ # Add a new symbol to the dictionary of named input and output columns.
12
+ #
13
+ # @param columns [{Symbol=>Symbol}] Hash of column names with key values :in or :out.
14
+ # @param name [Symbol] Symbolized column name.
15
+ # @param out [false, Index] False if an input column, otherwise the index of the output column.
16
+ # @return [Hash{Symbol=>[:in, Integer]}] Column dictionary updated with the new name.
17
+ def self.add_name(columns:, name:, out: false)
18
+ Validate.name(columns: columns, name: name, out: out)
19
+
20
+ columns[name] = out ? out : :in
21
+ columns
22
+ end
23
+
11
24
  # Column dictionary entries.
12
25
  class Entry
13
26
  # Table used to build a column dictionary entry.
@@ -29,14 +42,18 @@ module CSVDecision
29
42
  private_constant :INS_TYPES
30
43
 
31
44
  # Create a new column dictionary entry defaulting attributes from the column type,
32
- # which is looked up in +ENTRY+ table.
45
+ # which is looked up in the above table.
33
46
  #
34
47
  # @param name [Symbol] Column name.
35
48
  # @param type [Symbol] Column type.
36
49
  # @return [Entry] Column dictionary entry.
37
50
  def self.create(name:, type:)
38
51
  entry = ENTRY[type]
39
- new(name: name, eval: entry[:eval], type: entry[:type], set_if: entry[:set_if])
52
+ new(name: name,
53
+ eval: entry[:eval], # Set if the column requires functions evaluated
54
+ type: entry[:type], # Column type
55
+ set_if: entry[:set_if], # Set if the column has a conditional default
56
+ indexed: entry[:type] != :guard) # A guard column cannot be indexed.
40
57
  end
41
58
 
42
59
  # @return [Boolean] Return true is this is an input column, false otherwise.
@@ -50,6 +67,9 @@ module CSVDecision
50
67
  # @return [Symbol] Column type.
51
68
  attr_reader :type
52
69
 
70
+ # @return [Boolean] Returns true if this column is indexed
71
+ attr_accessor :indexed
72
+
53
73
  # @return [nil, Boolean] If set to true then this column has procs that
54
74
  # need evaluating, otherwise it only contains constants.
55
75
  attr_accessor :eval
@@ -67,13 +87,15 @@ module CSVDecision
67
87
  # @param type (see #type)
68
88
  # @param eval (see #eval)
69
89
  # @param set_if (see #set_if)
70
- def initialize(name:, type:, eval: nil, set_if: nil)
90
+ # @param indexed (see #indexed)
91
+ def initialize(name:, type:, eval: nil, set_if: nil, indexed: nil)
71
92
  @name = name
72
93
  @type = type
73
94
  @eval = eval
74
95
  @set_if = set_if
75
96
  @function = nil
76
97
  @ins = INS_TYPES.member?(type)
98
+ @indexed = indexed
77
99
  end
78
100
 
79
101
  # Convert the object's attributes to a hash.
@@ -93,7 +115,8 @@ module CSVDecision
93
115
  # parsing the header row.
94
116
  #
95
117
  # @param header [Array<String>] The header row after removing any empty columns.
96
- # @return [Hash<Hash>] Column dictionary is a hash of hashes.
118
+ # @param dictionary [Columns::Dictionary] Table's columns dictionary.
119
+ # @return [Columns::Dictionary] Table's columns dictionary.
97
120
  def self.build(header:, dictionary:)
98
121
  header.each_with_index do |cell, index|
99
122
  dictionary = parse_cell(cell: cell, index: index, dictionary: dictionary)
@@ -102,19 +125,6 @@ module CSVDecision
102
125
  dictionary
103
126
  end
104
127
 
105
- # Add a new symbol to the dictionary of named input and output columns.
106
- #
107
- # @param columns [{Symbol=>Symbol}] Hash of column names with key values :in or :out.
108
- # @param name [Symbol] Symbolized column name.
109
- # @param out [false, Index] False if an input column, otherwise the index of the output column.
110
- # @return [Hash{Symbol=>[:in, Integer]}] Column dictionary updated with the new name.
111
- def self.add_name(columns:, name:, out: false)
112
- Validate.name(columns: columns, name: name, out: out)
113
-
114
- columns[name] = out ? out : :in
115
- columns
116
- end
117
-
118
128
  def self.parse_cell(cell:, index:, dictionary:)
119
129
  column_type, column_name = Validate.column(cell: cell, index: index)
120
130
 
@@ -139,16 +149,16 @@ module CSVDecision
139
149
  private_class_method :dictionary_entry
140
150
 
141
151
  def self.output_entry(dictionary:, entry:, index:)
152
+ dictionary.outs[index] = entry
153
+
142
154
  case entry.type
143
- # if: columns are anonymous
155
+ # if: columns are anonymous, even if the user names them
144
156
  when :if
145
157
  dictionary.ifs[index] = entry
146
158
 
147
159
  when :out
148
- add_name(columns: dictionary.columns, name: entry.name, out: index)
160
+ Dictionary.add_name(columns: dictionary.columns, name: entry.name, out: index)
149
161
  end
150
-
151
- dictionary.outs[index] = entry
152
162
  end
153
163
  private_class_method :output_entry
154
164
 
@@ -159,7 +169,7 @@ module CSVDecision
159
169
  dictionary.defaults[index] = entry if entry.type == :set
160
170
 
161
171
  # guard: columns are anonymous
162
- add_name(columns: dictionary.columns, name: entry.name) unless entry.type == :guard
172
+ Dictionary.add_name(columns: dictionary.columns, name: entry.name) unless entry.type == :guard
163
173
  end
164
174
  private_class_method :input_entry
165
175
  end
@@ -52,6 +52,23 @@ module CSVDecision
52
52
  rows.shift
53
53
  end
54
54
 
55
+ # Parse the header row, and the defaults row if present.
56
+ # @param table [CSVDecision::Table] Decision table being parsed.
57
+ # @param matchers [Array<Matchers::Matcher>] Array of special cell matchers.
58
+ # @return [CSVDecision::Columns] Table columns object.
59
+ def self.parse(table:, matchers:)
60
+ # Parse the header row
61
+ table.columns = CSVDecision::Columns.new(table)
62
+
63
+ # Parse the defaults row if present
64
+ return table.columns if table.columns.defaults.blank?
65
+
66
+ table.columns.defaults =
67
+ Defaults.parse(columns: table.columns, matchers: matchers.outs, row: table.rows.shift)
68
+
69
+ table.columns
70
+ end
71
+
55
72
  # Build an array of all empty column indices.
56
73
  # @param row [Array]
57
74
  # @return [false, Array<Integer>]
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers.
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Build an index for a decision table with one or more input columns
9
+ # designated as keys
10
+ # @api private
11
+ class Index
12
+ # Build the index on the designated number of input columns.
13
+ #
14
+ # @param table [CSVDecision::Table] Decision table being indexed.
15
+ # @return [CSVDecision::Index] The built index.
16
+ def self.build(table:)
17
+ # Do we even have an index?
18
+ key_cols = index_columns(columns: table.columns.ins)
19
+ return if key_cols.empty?
20
+
21
+ table.index = Index.new(table: table, columns: key_cols)
22
+
23
+ # Indexed columns do not need to be scanned
24
+ trim_scan_rows(scan_rows: table.scan_rows, index_columns: table.index.columns)
25
+
26
+ table
27
+ end
28
+
29
+ # @param current_value [Integer, Array] Current index key value.
30
+ # @param index [Integer] Array row index to be included in the table index entry.
31
+ # @return [Integer, Array] New index key value.
32
+ def self.value(current_value, index)
33
+ return integer_value(current_value, index) if current_value.is_a?(Integer)
34
+
35
+ array_value(current_value, index)
36
+
37
+ current_value
38
+ end
39
+
40
+ def self.trim_scan_rows(scan_rows:, index_columns:)
41
+ scan_rows.each { |scan_row| scan_row.constants = scan_row.constants - index_columns }
42
+ end
43
+ private_class_method :trim_scan_rows
44
+
45
+ def self.index_columns(columns:)
46
+ key_cols = []
47
+ columns.each_pair { |col, column| key_cols << col if column.indexed }
48
+
49
+ key_cols
50
+ end
51
+ private_class_method :index_columns
52
+
53
+ # Current value is a row index integer
54
+ def self.integer_value(current_value, index)
55
+ # Is the new row index contiguous with the last start row/end row range?
56
+ current_value + 1 == index ? [[current_value, index]] : [current_value, index]
57
+ end
58
+ private_class_method :integer_value
59
+
60
+ # Current value is an array of row indexes
61
+ def self.array_value(current_value, index)
62
+ start_row, end_row = current_value.last
63
+
64
+ end_row = start_row if end_row.nil?
65
+
66
+ # Is the new row index contiguous with the last start row/end row range?
67
+ end_row + 1 == index ? current_value[-1] = [start_row, index] : current_value << index
68
+ end
69
+ private_class_method :array_value
70
+
71
+ # @return [Hash] The index hash mapping in input values to one or more data array row indexes.
72
+ attr_reader :hash
73
+
74
+ # @return [Array<Integer>] Array of column indices
75
+ attr_reader :columns
76
+
77
+ # @param table [CSVDecision::Table] Decision table.
78
+ # @param columns [Array<Index>] Array of column indexes to be indexed.
79
+ def initialize(table:, columns:)
80
+ @columns = columns
81
+ @hash = {}
82
+
83
+ build(table)
84
+
85
+ freeze
86
+ end
87
+
88
+ private
89
+
90
+ def build(table)
91
+ table.each do |row, index|
92
+ key = build_key(row: row)
93
+
94
+ current_value = @hash.key?(key)
95
+ @hash[key] = current_value ? Index.value(@hash[key], index) : index
96
+ end
97
+ end
98
+
99
+ def build_key(row:)
100
+ if @columns.count == 1
101
+ row[@columns[0]]
102
+ else
103
+ @columns.map { |col| row[col] }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -17,24 +17,48 @@ module CSVDecision
17
17
  def self.parse(table:, input:, symbolize_keys:)
18
18
  validate(input)
19
19
 
20
- parsed_input =
21
- parse_input(table: table, input: input(table, input, symbolize_keys))
20
+ parsed_input = parse_input(table: table, input: symbolize_keys ? input.symbolize_keys : input)
22
21
 
23
- # We can freeze it as we made our own copy
24
- parsed_input[:hash].freeze if symbolize_keys
22
+ key = table.index ? parse_key(table: table, hash: parsed_input[:hash]) : nil
25
23
 
26
- parsed_input.freeze
24
+ result(symbolize_keys: symbolize_keys, input: parsed_input, key: key)
27
25
  end
28
26
 
29
- def self.input(table, input, symbolize_keys)
30
- return input unless symbolize_keys
27
+ def self.result(symbolize_keys:, input:, key:)
28
+ hash = input[:hash]
29
+ {
30
+ # We can freeze the input hash for safety if we made our own copy.
31
+ hash: symbolize_keys ? hash.freeze : hash,
32
+ scan_cols: input[:scan_cols].freeze,
33
+ # Build the index key if this table is indexed.
34
+ key: key
35
+ }
36
+ end
37
+ private_class_method :result
38
+
39
+ def self.parse_key(table:, hash:)
40
+ return scan_key(table: table, hash: hash) if table.index.columns.count == 1
41
+
42
+ scan_keys(table: table, hash: hash).freeze
43
+ end
44
+ private_class_method :parse_key
45
+
46
+ def self.scan_key(table:, hash:)
47
+ col = table.index.columns[0]
48
+ column = table.columns.ins[col]
49
+
50
+ hash[column.name]
51
+ end
52
+ private_class_method :scan_key
53
+
54
+ def self.scan_keys(table:, hash:)
55
+ table.index.columns.map do |col|
56
+ column = table.columns.ins[col]
31
57
 
32
- # For safety the default is to symbolize the keys of a copy of the input hash.
33
- input = input.symbolize_keys
34
- input.slice!(*table.columns.input_keys)
35
- input
58
+ hash[column.name]
59
+ end
36
60
  end
37
- private_class_method :input
61
+ private_class_method :scan_keys
38
62
 
39
63
  def self.validate(input)
40
64
  return if input.is_a?(Hash) && !input.empty?
@@ -44,10 +68,13 @@ module CSVDecision
44
68
 
45
69
  def self.parse_input(table:, input:)
46
70
  defaulted_columns = table.columns.defaults
47
- parse_cells(table: table, input: input) if defaulted_columns.empty?
71
+
72
+ # Code path optimized for no defaults
73
+ return parse_cells(table: table, input: input) if defaulted_columns.empty?
48
74
 
49
75
  parse_defaulted(table: table, input: input, defaulted_columns: defaulted_columns)
50
76
  end
77
+
51
78
  private_class_method :parse_input
52
79
 
53
80
  def self.parse_cells(table:, input:)
@@ -60,6 +87,7 @@ module CSVDecision
60
87
 
61
88
  { hash: input, scan_cols: scan_cols }
62
89
  end
90
+
63
91
  private_class_method :parse_cells
64
92
 
65
93
  def self.parse_defaulted(table:, input:, defaulted_columns:)
@@ -77,6 +105,7 @@ module CSVDecision
77
105
 
78
106
  { hash: input, scan_cols: scan_cols }
79
107
  end
108
+
80
109
  private_class_method :parse_defaulted
81
110
 
82
111
  def self.default_value(default:, input:, column:)
@@ -92,11 +121,13 @@ module CSVDecision
92
121
  # or else a constant.
93
122
  eval_default(default.function, input)
94
123
  end
124
+
95
125
  private_class_method :default_value
96
126
 
97
127
  def self.default_if?(set_if, value)
98
128
  set_if == true || (value.respond_to?(set_if) && value.send(set_if))
99
129
  end
130
+
100
131
  private_class_method :default_if?
101
132
 
102
133
  # Expression may be a Proc that needs evaluating against the input hash,
@@ -104,6 +135,7 @@ module CSVDecision
104
135
  def self.eval_default(expression, input)
105
136
  expression.is_a?(::Proc) ? expression[input] : expression
106
137
  end
138
+
107
139
  private_class_method :eval_default
108
140
  end
109
141
  end
@@ -46,12 +46,14 @@ module CSVDecision
46
46
  def self.symbol_function(symbol, method, hash)
47
47
  hash[symbol].respond_to?(method) && hash[symbol].send(method)
48
48
  end
49
+ private_class_method :symbol_function
49
50
 
50
51
  def self.regexp_match(symbol, value, hash)
51
52
  return false unless value.is_a?(String)
52
53
  data = hash[symbol]
53
54
  data.is_a?(String) && Matchers.regexp(value).match?(data)
54
55
  end
56
+ private_class_method :regexp_match
55
57
 
56
58
  FUNCTION = {
57
59
  '.' => proc { |symbol, method, hash| symbol_function(symbol, method, hash) },