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