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