csv_decision2 0.5.1

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 (134) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +3 -0
  3. data/.coveralls.yml +2 -0
  4. data/.gitignore +14 -0
  5. data/.rspec +2 -0
  6. data/.rubocop.yml +30 -0
  7. data/.travis.yml +6 -0
  8. data/CHANGELOG.md +85 -0
  9. data/Dockerfile +6 -0
  10. data/Gemfile +7 -0
  11. data/LICENSE +21 -0
  12. data/README.md +356 -0
  13. data/benchmarks/rufus_decision.rb +158 -0
  14. data/csv_decision2.gemspec +38 -0
  15. data/doc/CSVDecision/CellValidationError.html +143 -0
  16. data/doc/CSVDecision/Columns/Default.html +589 -0
  17. data/doc/CSVDecision/Columns/Dictionary.html +801 -0
  18. data/doc/CSVDecision/Columns/Entry.html +508 -0
  19. data/doc/CSVDecision/Columns.html +1259 -0
  20. data/doc/CSVDecision/Constant.html +254 -0
  21. data/doc/CSVDecision/Data.html +479 -0
  22. data/doc/CSVDecision/Decide.html +302 -0
  23. data/doc/CSVDecision/Decision.html +1011 -0
  24. data/doc/CSVDecision/Defaults.html +291 -0
  25. data/doc/CSVDecision/Dictionary/Entry.html +1147 -0
  26. data/doc/CSVDecision/Dictionary.html +426 -0
  27. data/doc/CSVDecision/Error.html +139 -0
  28. data/doc/CSVDecision/FileError.html +143 -0
  29. data/doc/CSVDecision/Function.html +240 -0
  30. data/doc/CSVDecision/Guard.html +245 -0
  31. data/doc/CSVDecision/Header.html +647 -0
  32. data/doc/CSVDecision/Index.html +741 -0
  33. data/doc/CSVDecision/Input.html +404 -0
  34. data/doc/CSVDecision/Load.html +296 -0
  35. data/doc/CSVDecision/Matchers/Constant.html +484 -0
  36. data/doc/CSVDecision/Matchers/Function.html +511 -0
  37. data/doc/CSVDecision/Matchers/Guard.html +503 -0
  38. data/doc/CSVDecision/Matchers/Matcher.html +507 -0
  39. data/doc/CSVDecision/Matchers/Numeric.html +415 -0
  40. data/doc/CSVDecision/Matchers/Pattern.html +491 -0
  41. data/doc/CSVDecision/Matchers/Proc.html +704 -0
  42. data/doc/CSVDecision/Matchers/Range.html +379 -0
  43. data/doc/CSVDecision/Matchers/Symbol.html +426 -0
  44. data/doc/CSVDecision/Matchers.html +1567 -0
  45. data/doc/CSVDecision/Numeric.html +259 -0
  46. data/doc/CSVDecision/Options.html +443 -0
  47. data/doc/CSVDecision/Parse.html +282 -0
  48. data/doc/CSVDecision/Paths.html +742 -0
  49. data/doc/CSVDecision/Result.html +1200 -0
  50. data/doc/CSVDecision/Scan/InputHashes.html +369 -0
  51. data/doc/CSVDecision/Scan.html +313 -0
  52. data/doc/CSVDecision/ScanRow.html +866 -0
  53. data/doc/CSVDecision/Symbol.html +256 -0
  54. data/doc/CSVDecision/Table.html +1470 -0
  55. data/doc/CSVDecision/TableValidationError.html +143 -0
  56. data/doc/CSVDecision/Validate.html +422 -0
  57. data/doc/CSVDecision.html +621 -0
  58. data/doc/_index.html +471 -0
  59. data/doc/class_list.html +51 -0
  60. data/doc/css/common.css +1 -0
  61. data/doc/css/full_list.css +58 -0
  62. data/doc/css/style.css +499 -0
  63. data/doc/file.README.html +421 -0
  64. data/doc/file_list.html +56 -0
  65. data/doc/frames.html +17 -0
  66. data/doc/index.html +421 -0
  67. data/doc/js/app.js +248 -0
  68. data/doc/js/full_list.js +216 -0
  69. data/doc/js/jquery.js +4 -0
  70. data/doc/method_list.html +1163 -0
  71. data/doc/top-level-namespace.html +110 -0
  72. data/docker-compose.yml +13 -0
  73. data/lib/csv_decision/columns.rb +192 -0
  74. data/lib/csv_decision/data.rb +92 -0
  75. data/lib/csv_decision/decision.rb +196 -0
  76. data/lib/csv_decision/defaults.rb +47 -0
  77. data/lib/csv_decision/dictionary.rb +180 -0
  78. data/lib/csv_decision/header.rb +83 -0
  79. data/lib/csv_decision/index.rb +107 -0
  80. data/lib/csv_decision/input.rb +121 -0
  81. data/lib/csv_decision/load.rb +36 -0
  82. data/lib/csv_decision/matchers/constant.rb +74 -0
  83. data/lib/csv_decision/matchers/function.rb +56 -0
  84. data/lib/csv_decision/matchers/guard.rb +142 -0
  85. data/lib/csv_decision/matchers/numeric.rb +44 -0
  86. data/lib/csv_decision/matchers/pattern.rb +94 -0
  87. data/lib/csv_decision/matchers/range.rb +95 -0
  88. data/lib/csv_decision/matchers/symbol.rb +149 -0
  89. data/lib/csv_decision/matchers.rb +220 -0
  90. data/lib/csv_decision/options.rb +124 -0
  91. data/lib/csv_decision/parse.rb +165 -0
  92. data/lib/csv_decision/paths.rb +78 -0
  93. data/lib/csv_decision/result.rb +204 -0
  94. data/lib/csv_decision/scan.rb +117 -0
  95. data/lib/csv_decision/scan_row.rb +142 -0
  96. data/lib/csv_decision/table.rb +101 -0
  97. data/lib/csv_decision/validate.rb +85 -0
  98. data/lib/csv_decision.rb +45 -0
  99. data/spec/csv_decision/columns_spec.rb +251 -0
  100. data/spec/csv_decision/constant_spec.rb +36 -0
  101. data/spec/csv_decision/data_spec.rb +50 -0
  102. data/spec/csv_decision/decision_spec.rb +19 -0
  103. data/spec/csv_decision/examples_spec.rb +242 -0
  104. data/spec/csv_decision/index_spec.rb +58 -0
  105. data/spec/csv_decision/input_spec.rb +55 -0
  106. data/spec/csv_decision/load_spec.rb +28 -0
  107. data/spec/csv_decision/matchers/function_spec.rb +82 -0
  108. data/spec/csv_decision/matchers/guard_spec.rb +170 -0
  109. data/spec/csv_decision/matchers/numeric_spec.rb +47 -0
  110. data/spec/csv_decision/matchers/pattern_spec.rb +183 -0
  111. data/spec/csv_decision/matchers/range_spec.rb +70 -0
  112. data/spec/csv_decision/matchers/symbol_spec.rb +67 -0
  113. data/spec/csv_decision/options_spec.rb +94 -0
  114. data/spec/csv_decision/parse_spec.rb +44 -0
  115. data/spec/csv_decision/table_spec.rb +683 -0
  116. data/spec/csv_decision_spec.rb +7 -0
  117. data/spec/data/invalid/empty.csv +0 -0
  118. data/spec/data/invalid/invalid_header1.csv +4 -0
  119. data/spec/data/invalid/invalid_header2.csv +4 -0
  120. data/spec/data/invalid/invalid_header3.csv +4 -0
  121. data/spec/data/invalid/invalid_header4.csv +4 -0
  122. data/spec/data/valid/benchmark_regexp.csv +10 -0
  123. data/spec/data/valid/index_example.csv +13 -0
  124. data/spec/data/valid/multi_column_index.csv +10 -0
  125. data/spec/data/valid/multi_column_index2.csv +12 -0
  126. data/spec/data/valid/options_in_file1.csv +5 -0
  127. data/spec/data/valid/options_in_file2.csv +5 -0
  128. data/spec/data/valid/options_in_file3.csv +13 -0
  129. data/spec/data/valid/regular_expressions.csv +11 -0
  130. data/spec/data/valid/simple_constants.csv +5 -0
  131. data/spec/data/valid/simple_example.csv +10 -0
  132. data/spec/data/valid/valid.csv +4 -0
  133. data/spec/spec_helper.rb +106 -0
  134. metadata +352 -0
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Accumulate the matching row(s) into a result hash.
9
+ # @api private
10
+ class Result
11
+ # @return [Hash{Symbol=>Object}, Hash{Integer=>Object}] The decision result hash containing
12
+ # both result values and if: columns, which eventually get evaluated and removed.
13
+ attr_reader :attributes
14
+
15
+ # @return [Hash{Index=>Dictionary::Entry}] Output columns.
16
+ attr_reader :outs
17
+
18
+ # @return [nil, true] Set to true if the table has output functions.
19
+ attr_reader :outs_functions
20
+
21
+ # @return [Boolean] Returns true if this is a multi-row result
22
+ attr_reader :multi_result
23
+
24
+ # (see Decision.initialize)
25
+ def initialize(table:)
26
+ @outs = table.columns.outs
27
+ @outs_functions = table.outs_functions
28
+ @table = table
29
+ end
30
+
31
+ # Initialize the object for new input data.
32
+ #
33
+ # @param data [Hash{Symbol=>Object}] Input data hash.
34
+ # @return [void]
35
+ def input(data)
36
+ # Attributes hash contains the output decision key value pairs
37
+ @attributes = {}
38
+ @multi_result = false
39
+ # Partial result always copies in the input hash for calculating output functions.
40
+ # Note that these input key values will not be mutated, as output columns can never
41
+ # have the same symbol as an input hash key.
42
+ # However, the rest of this hash is mutated as output column evaluation results
43
+ # are accumulated.
44
+ @partial_result = data.slice(*@table.columns.input_keys) if @outs_functions
45
+ end
46
+
47
+ # Common case for building a single row result is just copying output column values to the
48
+ # final result hash.
49
+ # @param row [Array]
50
+ # @return [void]
51
+ def add_outs(row)
52
+ @outs.each_pair { |col, column| @attributes[column.name] = row[col] }
53
+ end
54
+
55
+ # Accumulate the outs into arrays of values.
56
+ # @param row [Array]
57
+ # @return [void]
58
+ def accumulate_outs(row)
59
+ @outs.each_pair { |col, column| add_cell(column_name: column.name, cell: row[col]) }
60
+ end
61
+
62
+ # Derive the final result.
63
+ # @return [Hash{Symbol=>Object}]
64
+ def final_result
65
+ # If there are no if: columns, then nothing needs to be filtered out of this result hash.
66
+ return @attributes if @table.columns.ifs.empty?
67
+
68
+ @multi_result ? multi_row_result : single_row_result
69
+ end
70
+
71
+ # Evaluate the output columns, and use them to start building the final result,
72
+ # along with the partial result required to evaluate functions.
73
+ #
74
+ # @param row [Array]
75
+ # @return (see #final)
76
+ def eval_outs(row)
77
+ # Set the constants first, in case the functions refer to them
78
+ eval_outs_constants(row: row)
79
+
80
+ # Then evaluate the procs, left to right
81
+ eval_outs_procs(row: row)
82
+
83
+ final_result
84
+ end
85
+
86
+ # Evaluate the cell proc using the partial result calculated so far.
87
+ #
88
+ # @param proc [Matchers::Pro]
89
+ # @param column_name [Symbol, Integer]
90
+ # @param index [Integer]
91
+ def eval_cell_proc(proc:, column_name:, index:)
92
+ @attributes[column_name][index] = proc.function[partial_result(index)]
93
+ end
94
+
95
+ private
96
+
97
+ # Case where we have a single row result, which either gets returned
98
+ # or filtered by the if: column conditions.
99
+ def single_row_result
100
+ # All if: columns must evaluate to true
101
+ if @table.columns.ifs.keys.all? { |col| @attributes[col] }
102
+ # Delete if: columns from final result
103
+ @table.columns.ifs.each_key { |col| @attributes.delete(col) }
104
+ return @attributes
105
+ end
106
+
107
+ false
108
+ end
109
+
110
+ def multi_row_result
111
+ @table.columns.ifs.each_key { |col| check_if_column(col) }
112
+
113
+ normalize_result
114
+ end
115
+
116
+ def check_if_column(col)
117
+ delete_rows = []
118
+ @attributes[col].each_with_index { |value, index| delete_rows << index unless value }
119
+
120
+ # Remove this if: column from the final result
121
+ @attributes.delete(col)
122
+
123
+ # Adjust the row index as we delete rows in sequence.
124
+ delete_rows.each_with_index { |index, sequence| delete_row(index - sequence) }
125
+ end
126
+
127
+ # Each result "row", given by the row +index+ is a collection of column arrays.
128
+ # @param index [Integer] Row index.
129
+ # @return [{Symbol=>Object}, {Integer=>Object}]
130
+ def delete_row(index)
131
+ @attributes.transform_values { |value| value.delete_at(index) }
132
+ end
133
+
134
+ # @return [{Symbol=>Object}] Decision result hash with any if: columns removed.
135
+ def normalize_result
136
+ # Peek at the first column's result and see how many rows it contains.
137
+ count = @attributes.values.first.count
138
+ @multi_result = count > 1
139
+
140
+ return {} if count.zero?
141
+ return @attributes.transform_values!(&:first) if count == 1
142
+
143
+ @attributes
144
+ end
145
+
146
+ def eval_outs_constants(row:)
147
+ @outs.each_pair do |col, column|
148
+ cell = row[col]
149
+ next if cell.is_a?(Matchers::Proc)
150
+
151
+ @partial_result[column.name] = cell
152
+ @attributes[column.name] = cell
153
+ end
154
+ end
155
+
156
+ def eval_outs_procs(row:)
157
+ @outs.each_pair do |col, column|
158
+ cell = row[col]
159
+ next unless cell.is_a?(Matchers::Proc)
160
+
161
+ eval_out_proc(cell: cell, column_name: column.name, column_type: column.type)
162
+ end
163
+ end
164
+
165
+ def eval_out_proc(cell:, column_name:, column_type:)
166
+ @attributes[column_name] = cell.function[@partial_result]
167
+
168
+ # Do not add if: columns to the partial result
169
+ return if column_type == :if
170
+ @partial_result[column_name] = @attributes[column_name]
171
+ end
172
+
173
+ def partial_result(index)
174
+ @attributes.each_pair do |column_name, values|
175
+ value = values[index]
176
+ # Delete this column from the partial result in case there is data from a prior result row
177
+ next @partial_result.delete(column_name) if value.is_a?(Matchers::Proc)
178
+
179
+ # Add this constant value to the partial result row built so far.
180
+ @partial_result[column_name] = value
181
+ end
182
+
183
+ @partial_result
184
+ end
185
+
186
+ def add_cell(column_name:, cell:)
187
+ case (current = @attributes[column_name])
188
+ when nil
189
+ @attributes[column_name] = cell
190
+
191
+ when Matchers::Proc
192
+ @attributes[column_name] = [current, cell]
193
+ @multi_result = true
194
+
195
+ when Array
196
+ @attributes[column_name] << cell
197
+
198
+ else
199
+ @attributes[column_name] = [current, cell]
200
+ @multi_result = true
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Scan the input hash for all the paths specified in the decision table
9
+ # @api private
10
+ class Scan
11
+ # Main method for making decisions with a table that has paths.
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 [Boolean] 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{Symbol=>Object}] Decision result.
18
+ def self.table(table:, input:, symbolize_keys:)
19
+ input = symbolize_keys ? input.deep_symbolize_keys : input
20
+ decision = Decision.new(table: table)
21
+ input_hashes = InputHashes.new
22
+
23
+ if table.options[:first_match]
24
+ scan_first_match(input: input, decision: decision, input_hashes: input_hashes)
25
+ else
26
+ scan_accumulate(input: input, decision: decision, input_hashes: input_hashes)
27
+ end
28
+ end
29
+
30
+ def self.scan_first_match(input:, decision:, input_hashes:)
31
+ decision.table.paths.each do |path, rows|
32
+ data = input_hashes.data(decision: decision, path: path, input: input)
33
+ next if data == {}
34
+
35
+ # Note that +rows+ must be enclosed in an array for this method to work.
36
+ result = decision.index_scan_first_match(
37
+ scan_cols: data[:scan_cols],
38
+ hash: data[:hash],
39
+ index_rows: [rows]
40
+ )
41
+ return result if result != {}
42
+ end
43
+
44
+ {}
45
+ end
46
+ private_class_method :scan_first_match
47
+
48
+ def self.scan_accumulate(input:, decision:, input_hashes:)
49
+ # Final result
50
+ result = {}
51
+
52
+ decision.table.paths.each do |path, rows|
53
+ data = input_hashes.data(decision: decision, path: path, input: input)
54
+ next if data == {}
55
+
56
+ result = scan(rows: rows, input: data, final: result, decision: decision)
57
+ end
58
+
59
+ result
60
+ end
61
+ private_class_method :scan_accumulate
62
+
63
+ def self.scan(rows:, input:, final:, decision:)
64
+ # Note that +rows+ must be enclosed in an array for this method to work.
65
+ result = decision.index_scan_accumulate(scan_cols: input[:scan_cols],
66
+ hash: input[:hash],
67
+ index_rows: [rows])
68
+
69
+ # Accumulate this potentially multi-row result into the final result.
70
+ final = accumulate(final: final, result: result) if result.present?
71
+
72
+ final
73
+ end
74
+ private_class_method :scan
75
+
76
+ def self.accumulate(final:, result:)
77
+ return result if final == {}
78
+
79
+ final.each_pair { |key, value| final[key] = Array(value) + Array(result[key]) }
80
+ final
81
+ end
82
+ private_class_method :accumulate
83
+
84
+ # Derive the parsed input hash, using a cache for speed.
85
+ class InputHashes
86
+ def initialize
87
+ @input_hashes = {}
88
+ end
89
+
90
+ # @param path [Array<Symbol] Path for the input hash.
91
+ # @param input [Hash{Symbol=>Object}] Input hash.
92
+ # @return [Hash{Symbol=>Object}] Parsed input hash.
93
+ def data(decision:, path:, input:)
94
+ result = input(decision: decision, path: path, input: input)
95
+
96
+ decision.input(result) unless result == {}
97
+
98
+ result
99
+ end
100
+
101
+ private
102
+
103
+ def input(decision:, path:, input:)
104
+ return @input_hashes[path] if @input_hashes.key?(path)
105
+
106
+ # Use the path - an array of symbol keys, to dig out the input sub-hash
107
+ hash = path.empty? ? input : input.dig(*path)
108
+
109
+ # Parse and transform the hash supplied as input
110
+ data = hash.blank? ? {} : Input.parse_data(table: decision.table, input: hash)
111
+
112
+ # Cache the parsed input hash data for this path
113
+ @input_hashes[path] = data
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Data row object indicating which columns are constants versus procs.
9
+ # @api private
10
+ class ScanRow
11
+ # These column types cannot have constants in their data cells.
12
+ NO_CONSTANTS = Set.new(%i[guard if]).freeze
13
+ private_constant :NO_CONSTANTS
14
+
15
+ # Scan the table cell against all matches.
16
+ #
17
+ # @param column [Dictionary::Entry] Column dictionary entry.
18
+ # @param matchers [Array<Matchers::Matcher>]
19
+ # @param cell [String]
20
+ # @return [false, Matchers::Proc]
21
+ def self.scan(column:, matchers:, cell:)
22
+ return false if cell == ''
23
+
24
+ proc = scan_matchers(column: column, matchers: matchers, cell: cell)
25
+ return proc if proc
26
+
27
+ # Must be a simple string constant - this is OK except for a certain column types.
28
+ invalid_constant?(type: :constant, column: column)
29
+ end
30
+
31
+ def self.scan_matchers(column:, matchers:, cell:)
32
+ matchers.each do |matcher|
33
+ # Guard function only accepts the same matchers as an output column.
34
+ next if guard_ins_matcher?(column, matcher)
35
+
36
+ proc = scan_proc(column: column, cell: cell, matcher: matcher)
37
+ return proc if proc
38
+ end
39
+
40
+ # Must be a string constant
41
+ false
42
+ end
43
+ private_class_method :scan_matchers
44
+
45
+ # A guard column can only use output matchers
46
+ def self.guard_ins_matcher?(column, matcher)
47
+ column.type == :guard && !matcher.outs?
48
+ end
49
+ private_class_method :guard_ins_matcher?
50
+
51
+ def self.scan_proc(column:, cell:, matcher:)
52
+ proc = matcher.matches?(cell)
53
+ invalid_constant?(type: proc.type, column: column) if proc
54
+
55
+ proc
56
+ end
57
+ private_class_method :scan_proc
58
+
59
+ def self.invalid_constant?(type:, column:)
60
+ return false unless type == :constant && NO_CONSTANTS.member?(column.type)
61
+
62
+ raise CellValidationError, "#{column.type}: column cannot contain constants"
63
+ end
64
+ private_class_method :invalid_constant?
65
+
66
+ # @return [Array<Integer>] Column indices for simple constants.
67
+ attr_accessor :constants
68
+
69
+ # @return [Array<Integer>] Column indices for Proc objects.
70
+ attr_reader :procs
71
+
72
+ def initialize
73
+ @constants = []
74
+ @procs = []
75
+ end
76
+
77
+ # Scan all the specified +columns+ (e.g., inputs) in the given +data+ row using the +matchers+
78
+ # array supplied.
79
+ #
80
+ # @param row [Array<String>] Data row - still just all string constants.
81
+ # @param columns [Array<Columns::Entry>] Array of column dictionary entries.
82
+ # @param matchers [Array<Matchers::Matcher>] Array of table cell matchers.
83
+ # @return [Array] Data row with anything not a string constant replaced with a Proc or a
84
+ # non-string constant.
85
+ def scan_columns(row:, columns:, matchers:)
86
+ columns.each_pair do |col, column|
87
+ cell = row[col]
88
+
89
+ # An empty input cell matches everything, and so never needs to be scanned,
90
+ # but it cannot be indexed either.
91
+ next column.indexed = false if cell == '' && column.ins?
92
+
93
+ # If the column is text only then no special matchers need be used.
94
+ next @constants << col if column.eval == false
95
+
96
+ # Need to scan the cell against all matchers, and possibly overwrite
97
+ # the cell contents with a Matchers::Proc value.
98
+ row[col] = scan_cell(column: column, col: col, matchers: matchers, cell: cell)
99
+ end
100
+
101
+ row
102
+ end
103
+
104
+ # Match cells in the input hash against a decision table row.
105
+ # @param row (see ScanRow.scan_columns)
106
+ # @param hash (see Decision#row_scan)
107
+ # @return [Boolean] True for a match, false otherwise.
108
+ def match?(row:, scan_cols:, hash:)
109
+ # Check any table row cell constants first, and maybe fail fast...
110
+ return false if @constants.any? { |col| row[col] != scan_cols[col] }
111
+
112
+ # These table row cells are Proc objects which need evaluating and
113
+ # must all return a truthy value.
114
+ @procs.all? { |col| row[col].call(value: scan_cols[col], hash: hash) }
115
+ end
116
+
117
+ private
118
+
119
+ def scan_cell(column:, col:, matchers:, cell:)
120
+ # Scan the cell against all the matchers
121
+ proc = ScanRow.scan(column: column, matchers: matchers, cell: cell)
122
+
123
+ return set(proc: proc, col: col, column: column) if proc
124
+
125
+ # Just a plain constant
126
+ @constants << col
127
+ cell
128
+ end
129
+
130
+ def set(proc:, col:, column:)
131
+ # Unbox a constant
132
+ if proc.type == :constant
133
+ @constants << col
134
+ return proc.function
135
+ end
136
+
137
+ @procs << col
138
+ column.indexed = false
139
+ proc
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Decision table that accepts an input hash and outputs a decision (hash).
9
+ class Table
10
+ # Make a decision based off an input hash.
11
+ #
12
+ # @note Input hash keys may or may not be symbolized.
13
+ # @param input [Hash] Input hash.
14
+ # @return [{Symbol => Object, Array<Object>}] Decision hash.
15
+ def decide(input)
16
+ decision(input: input, symbolize_keys: true)
17
+ end
18
+
19
+ # Unsafe version of decide - may mutate the input hash and assumes the input
20
+ # hash is symbolized.
21
+ #
22
+ # @param input (see #decide)
23
+ # @note Input hash must have its keys symbolized.
24
+ # Input hash will be mutated by any functions that have side effects.
25
+ # @return (see #decide)
26
+ def decide!(input)
27
+ decision( input: input, symbolize_keys: false)
28
+ end
29
+
30
+ # @return [CSVDecision::Columns] Dictionary of all input and output columns.
31
+ attr_accessor :columns
32
+
33
+ # @return [File, Pathname, nil] File path name if decision table was loaded from a
34
+ # CSV file.
35
+ attr_accessor :file
36
+
37
+ # @return [CSVDecision::Index] The index built on one or more input columns.
38
+ attr_accessor :index
39
+
40
+ # @return [CSVDecision::Path] The array of paths built on one or more input columns.
41
+ attr_accessor :paths
42
+
43
+ # @return [Hash] All options, explicitly set or defaulted, used to parse the table.
44
+ attr_accessor :options
45
+
46
+ # Set if the table row has any output functions (planned feature)
47
+ # @api private
48
+ attr_accessor :outs_functions
49
+
50
+ # @return [Array<Array>] Data rows after parsing.
51
+ # @api private
52
+ attr_accessor :rows
53
+
54
+ # @return [Array<CSVDecision::ScanRow>] Scanning objects used to implement input
55
+ # matching logic.
56
+ # @api private
57
+ attr_accessor :scan_rows
58
+
59
+ # @return [Array<CSVDecision::ScanRow>] Used to implement outputting of final results.
60
+ # @api private
61
+ attr_accessor :outs_rows
62
+
63
+ # @return [Array<CSVDecision::ScanRow>] Used to implement filtering of final results.
64
+ # @api private
65
+ attr_accessor :if_rows
66
+
67
+ # Iterate through all data rows of the decision table, with an optional
68
+ # first and last row index given.
69
+ #
70
+ # @param first [Integer] Start row.
71
+ # @param last [Integer, nil] Last row.
72
+ # @api private
73
+ def each(first = 0, last = @rows.count - 1)
74
+ index = first
75
+ while index <= last
76
+ yield(@rows[index], index)
77
+
78
+ index += 1
79
+ end
80
+ end
81
+
82
+ # @api private
83
+ def initialize
84
+ @paths = []
85
+ @outs_rows = []
86
+ @if_rows = []
87
+ @rows = []
88
+ @scan_rows = []
89
+ end
90
+
91
+ private
92
+
93
+ def decision(input:, symbolize_keys:)
94
+ if columns.paths.empty?
95
+ Decision.make(table: self, input: input, symbolize_keys: symbolize_keys)
96
+ else
97
+ Scan.table(table: self, input: input, symbolize_keys: symbolize_keys)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Parse and validate the column names in the header row.
9
+ # These methods are only required at table load time.
10
+ # @api private
11
+ module Validate
12
+ # These column types do not need a name.
13
+ COLUMN_TYPE_ANONYMOUS = Set.new(%i[guard if path]).freeze
14
+ private_constant :COLUMN_TYPE_ANONYMOUS
15
+
16
+ # Validate a column header cell and return its type and name.
17
+ #
18
+ # @param cell [String] Header cell.
19
+ # @param index [Integer] The header column's index.
20
+ # @return [Array<(Symbol, Symbol)>] Column type and column name symbols.
21
+ def self.column(cell:, index:)
22
+ match = Header::COLUMN_TYPE.match(cell)
23
+ raise CellValidationError, 'column name is not well formed' unless match
24
+
25
+ column_type = match['type']&.downcase&.to_sym
26
+ column_name = column_name(type: column_type, name: match['name'], index: index)
27
+
28
+ [column_type, column_name]
29
+ rescue CellValidationError => exp
30
+ raise CellValidationError, "header column '#{cell}' is not valid as the #{exp.message}"
31
+ end
32
+
33
+ # Validate the column name against the dictionary of column names.
34
+ #
35
+ # @param columns [Symbol=>[false, Integer]] Column name dictionary.
36
+ # @param name [Symbol] Column name.
37
+ # @param out [false, Integer] False if an input column, otherwise the column index of
38
+ # the output column.
39
+ # @return [void]
40
+ # @raise [CellValidationError] Column name invalid.
41
+ def self.name(columns:, name:, out:)
42
+ return unless (in_out = columns[name])
43
+
44
+ return validate_out_name(in_out: in_out, name: name) if out
45
+ validate_in_name(in_out: in_out, name: name)
46
+ end
47
+
48
+ def self.column_name(type:, name:, index:)
49
+ # if: columns are named after their index, which is an integer and so cannot
50
+ # clash with other column name types, which are symbols.
51
+ return index if type == :if
52
+
53
+ return format_column_name(name) if name.present?
54
+
55
+ return if COLUMN_TYPE_ANONYMOUS.member?(type)
56
+ raise CellValidationError, 'column name is missing'
57
+ end
58
+ private_class_method :column_name
59
+
60
+ def self.format_column_name(name)
61
+ column_name = name.strip.tr("\s", '_')
62
+
63
+ return column_name.to_sym if Header.column_name?(column_name)
64
+ raise CellValidationError, "column name '#{name}' contains invalid characters"
65
+ end
66
+ private_class_method :format_column_name
67
+
68
+ def self.validate_out_name(in_out:, name:)
69
+ if in_out == :in
70
+ raise CellValidationError, "output column name '#{name}' is also an input column"
71
+ end
72
+
73
+ raise CellValidationError, "output column name '#{name}' is duplicated"
74
+ end
75
+ private_class_method :validate_out_name
76
+
77
+ def self.validate_in_name(in_out:, name:)
78
+ # in: columns may be duped
79
+ return if in_out == :in
80
+
81
+ raise CellValidationError, "output column name '#{name}' is also an input column"
82
+ end
83
+ private_class_method :validate_in_name
84
+ end
85
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object'
4
+ require 'csv_decision/parse'
5
+
6
+ # CSV Decision: CSV based Ruby decision tables.
7
+ # Created December 2017.
8
+ # @author Brett Vickers <brett@phillips-vickers.com>
9
+ # See LICENSE and README.md for details.
10
+ module CSVDecision
11
+ # @return [String] gem project's root directory
12
+ def self.root
13
+ File.dirname __dir__
14
+ end
15
+
16
+ autoload :Columns, 'csv_decision/columns'
17
+ autoload :Data, 'csv_decision/data'
18
+ autoload :Decision, 'csv_decision/decision'
19
+ autoload :Defaults, 'csv_decision/defaults'
20
+ autoload :Dictionary, 'csv_decision/dictionary'
21
+ autoload :Header, 'csv_decision/header'
22
+ autoload :Index, 'csv_decision/index'
23
+ autoload :Input, 'csv_decision/input'
24
+ autoload :Load, 'csv_decision/load'
25
+ autoload :Matchers, 'csv_decision/matchers'
26
+ autoload :Options, 'csv_decision/options'
27
+ autoload :Parse, 'csv_decision/parse'
28
+ autoload :Paths, 'csv_decision/paths'
29
+ autoload :Result, 'csv_decision/result'
30
+ autoload :Scan, 'csv_decision/scan'
31
+ autoload :ScanRow, 'csv_decision/scan_row'
32
+ autoload :Table, 'csv_decision/table'
33
+ autoload :Validate, 'csv_decision/validate'
34
+
35
+ # Cell matchers
36
+ class Matchers
37
+ autoload :Constant, 'csv_decision/matchers/constant'
38
+ autoload :Function, 'csv_decision/matchers/function'
39
+ autoload :Guard, 'csv_decision/matchers/guard'
40
+ autoload :Numeric, 'csv_decision/matchers/numeric'
41
+ autoload :Pattern, 'csv_decision/matchers/pattern'
42
+ autoload :Range, 'csv_decision/matchers/range'
43
+ autoload :Symbol, 'csv_decision/matchers/symbol'
44
+ end
45
+ end