csv_decision2 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
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