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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +3 -0
- data/.coveralls.yml +2 -0
- data/.gitignore +14 -0
- data/.rspec +2 -0
- data/.rubocop.yml +30 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.md +85 -0
- data/Dockerfile +6 -0
- data/Gemfile +7 -0
- data/LICENSE +21 -0
- data/README.md +356 -0
- data/benchmarks/rufus_decision.rb +158 -0
- data/csv_decision2.gemspec +38 -0
- data/doc/CSVDecision/CellValidationError.html +143 -0
- data/doc/CSVDecision/Columns/Default.html +589 -0
- data/doc/CSVDecision/Columns/Dictionary.html +801 -0
- data/doc/CSVDecision/Columns/Entry.html +508 -0
- data/doc/CSVDecision/Columns.html +1259 -0
- data/doc/CSVDecision/Constant.html +254 -0
- data/doc/CSVDecision/Data.html +479 -0
- data/doc/CSVDecision/Decide.html +302 -0
- data/doc/CSVDecision/Decision.html +1011 -0
- data/doc/CSVDecision/Defaults.html +291 -0
- data/doc/CSVDecision/Dictionary/Entry.html +1147 -0
- data/doc/CSVDecision/Dictionary.html +426 -0
- data/doc/CSVDecision/Error.html +139 -0
- data/doc/CSVDecision/FileError.html +143 -0
- data/doc/CSVDecision/Function.html +240 -0
- data/doc/CSVDecision/Guard.html +245 -0
- data/doc/CSVDecision/Header.html +647 -0
- data/doc/CSVDecision/Index.html +741 -0
- data/doc/CSVDecision/Input.html +404 -0
- data/doc/CSVDecision/Load.html +296 -0
- data/doc/CSVDecision/Matchers/Constant.html +484 -0
- data/doc/CSVDecision/Matchers/Function.html +511 -0
- data/doc/CSVDecision/Matchers/Guard.html +503 -0
- data/doc/CSVDecision/Matchers/Matcher.html +507 -0
- data/doc/CSVDecision/Matchers/Numeric.html +415 -0
- data/doc/CSVDecision/Matchers/Pattern.html +491 -0
- data/doc/CSVDecision/Matchers/Proc.html +704 -0
- data/doc/CSVDecision/Matchers/Range.html +379 -0
- data/doc/CSVDecision/Matchers/Symbol.html +426 -0
- data/doc/CSVDecision/Matchers.html +1567 -0
- data/doc/CSVDecision/Numeric.html +259 -0
- data/doc/CSVDecision/Options.html +443 -0
- data/doc/CSVDecision/Parse.html +282 -0
- data/doc/CSVDecision/Paths.html +742 -0
- data/doc/CSVDecision/Result.html +1200 -0
- data/doc/CSVDecision/Scan/InputHashes.html +369 -0
- data/doc/CSVDecision/Scan.html +313 -0
- data/doc/CSVDecision/ScanRow.html +866 -0
- data/doc/CSVDecision/Symbol.html +256 -0
- data/doc/CSVDecision/Table.html +1470 -0
- data/doc/CSVDecision/TableValidationError.html +143 -0
- data/doc/CSVDecision/Validate.html +422 -0
- data/doc/CSVDecision.html +621 -0
- data/doc/_index.html +471 -0
- data/doc/class_list.html +51 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +58 -0
- data/doc/css/style.css +499 -0
- data/doc/file.README.html +421 -0
- data/doc/file_list.html +56 -0
- data/doc/frames.html +17 -0
- data/doc/index.html +421 -0
- data/doc/js/app.js +248 -0
- data/doc/js/full_list.js +216 -0
- data/doc/js/jquery.js +4 -0
- data/doc/method_list.html +1163 -0
- data/doc/top-level-namespace.html +110 -0
- data/docker-compose.yml +13 -0
- data/lib/csv_decision/columns.rb +192 -0
- data/lib/csv_decision/data.rb +92 -0
- data/lib/csv_decision/decision.rb +196 -0
- data/lib/csv_decision/defaults.rb +47 -0
- data/lib/csv_decision/dictionary.rb +180 -0
- data/lib/csv_decision/header.rb +83 -0
- data/lib/csv_decision/index.rb +107 -0
- data/lib/csv_decision/input.rb +121 -0
- data/lib/csv_decision/load.rb +36 -0
- data/lib/csv_decision/matchers/constant.rb +74 -0
- data/lib/csv_decision/matchers/function.rb +56 -0
- data/lib/csv_decision/matchers/guard.rb +142 -0
- data/lib/csv_decision/matchers/numeric.rb +44 -0
- data/lib/csv_decision/matchers/pattern.rb +94 -0
- data/lib/csv_decision/matchers/range.rb +95 -0
- data/lib/csv_decision/matchers/symbol.rb +149 -0
- data/lib/csv_decision/matchers.rb +220 -0
- data/lib/csv_decision/options.rb +124 -0
- data/lib/csv_decision/parse.rb +165 -0
- data/lib/csv_decision/paths.rb +78 -0
- data/lib/csv_decision/result.rb +204 -0
- data/lib/csv_decision/scan.rb +117 -0
- data/lib/csv_decision/scan_row.rb +142 -0
- data/lib/csv_decision/table.rb +101 -0
- data/lib/csv_decision/validate.rb +85 -0
- data/lib/csv_decision.rb +45 -0
- data/spec/csv_decision/columns_spec.rb +251 -0
- data/spec/csv_decision/constant_spec.rb +36 -0
- data/spec/csv_decision/data_spec.rb +50 -0
- data/spec/csv_decision/decision_spec.rb +19 -0
- data/spec/csv_decision/examples_spec.rb +242 -0
- data/spec/csv_decision/index_spec.rb +58 -0
- data/spec/csv_decision/input_spec.rb +55 -0
- data/spec/csv_decision/load_spec.rb +28 -0
- data/spec/csv_decision/matchers/function_spec.rb +82 -0
- data/spec/csv_decision/matchers/guard_spec.rb +170 -0
- data/spec/csv_decision/matchers/numeric_spec.rb +47 -0
- data/spec/csv_decision/matchers/pattern_spec.rb +183 -0
- data/spec/csv_decision/matchers/range_spec.rb +70 -0
- data/spec/csv_decision/matchers/symbol_spec.rb +67 -0
- data/spec/csv_decision/options_spec.rb +94 -0
- data/spec/csv_decision/parse_spec.rb +44 -0
- data/spec/csv_decision/table_spec.rb +683 -0
- data/spec/csv_decision_spec.rb +7 -0
- data/spec/data/invalid/empty.csv +0 -0
- data/spec/data/invalid/invalid_header1.csv +4 -0
- data/spec/data/invalid/invalid_header2.csv +4 -0
- data/spec/data/invalid/invalid_header3.csv +4 -0
- data/spec/data/invalid/invalid_header4.csv +4 -0
- data/spec/data/valid/benchmark_regexp.csv +10 -0
- data/spec/data/valid/index_example.csv +13 -0
- data/spec/data/valid/multi_column_index.csv +10 -0
- data/spec/data/valid/multi_column_index2.csv +12 -0
- data/spec/data/valid/options_in_file1.csv +5 -0
- data/spec/data/valid/options_in_file2.csv +5 -0
- data/spec/data/valid/options_in_file3.csv +13 -0
- data/spec/data/valid/regular_expressions.csv +11 -0
- data/spec/data/valid/simple_constants.csv +5 -0
- data/spec/data/valid/simple_example.csv +10 -0
- data/spec/data/valid/valid.csv +4 -0
- data/spec/spec_helper.rb +106 -0
- 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
|
data/lib/csv_decision.rb
ADDED
@@ -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
|