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,110 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta charset="utf-8">
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6
|
+
<title>
|
7
|
+
Top Level Namespace
|
8
|
+
|
9
|
+
— Documentation by YARD 0.9.12
|
10
|
+
|
11
|
+
</title>
|
12
|
+
|
13
|
+
<link rel="stylesheet" href="css/style.css" type="text/css" charset="utf-8" />
|
14
|
+
|
15
|
+
<link rel="stylesheet" href="css/common.css" type="text/css" charset="utf-8" />
|
16
|
+
|
17
|
+
<script type="text/javascript" charset="utf-8">
|
18
|
+
pathId = "";
|
19
|
+
relpath = '';
|
20
|
+
</script>
|
21
|
+
|
22
|
+
|
23
|
+
<script type="text/javascript" charset="utf-8" src="js/jquery.js"></script>
|
24
|
+
|
25
|
+
<script type="text/javascript" charset="utf-8" src="js/app.js"></script>
|
26
|
+
|
27
|
+
|
28
|
+
</head>
|
29
|
+
<body>
|
30
|
+
<div class="nav_wrap">
|
31
|
+
<iframe id="nav" src="class_list.html?1"></iframe>
|
32
|
+
<div id="resizer"></div>
|
33
|
+
</div>
|
34
|
+
|
35
|
+
<div id="main" tabindex="-1">
|
36
|
+
<div id="header">
|
37
|
+
<div id="menu">
|
38
|
+
|
39
|
+
<a href="_index.html">Index</a> »
|
40
|
+
|
41
|
+
|
42
|
+
<span class="title">Top Level Namespace</span>
|
43
|
+
|
44
|
+
</div>
|
45
|
+
|
46
|
+
<div id="search">
|
47
|
+
|
48
|
+
<a class="full_list_link" id="class_list_link"
|
49
|
+
href="class_list.html">
|
50
|
+
|
51
|
+
<svg width="24" height="24">
|
52
|
+
<rect x="0" y="4" width="24" height="4" rx="1" ry="1"></rect>
|
53
|
+
<rect x="0" y="12" width="24" height="4" rx="1" ry="1"></rect>
|
54
|
+
<rect x="0" y="20" width="24" height="4" rx="1" ry="1"></rect>
|
55
|
+
</svg>
|
56
|
+
</a>
|
57
|
+
|
58
|
+
</div>
|
59
|
+
<div class="clear"></div>
|
60
|
+
</div>
|
61
|
+
|
62
|
+
<div id="content"><h1>Top Level Namespace
|
63
|
+
|
64
|
+
|
65
|
+
|
66
|
+
</h1>
|
67
|
+
<div class="box_info">
|
68
|
+
|
69
|
+
|
70
|
+
|
71
|
+
|
72
|
+
|
73
|
+
|
74
|
+
|
75
|
+
|
76
|
+
|
77
|
+
|
78
|
+
|
79
|
+
</div>
|
80
|
+
|
81
|
+
<h2>Defined Under Namespace</h2>
|
82
|
+
<p class="children">
|
83
|
+
|
84
|
+
|
85
|
+
<strong class="modules">Modules:</strong> <span class='object_link'><a href="CSVDecision.html" title="CSVDecision (module)">CSVDecision</a></span>
|
86
|
+
|
87
|
+
|
88
|
+
|
89
|
+
|
90
|
+
</p>
|
91
|
+
|
92
|
+
|
93
|
+
|
94
|
+
|
95
|
+
|
96
|
+
|
97
|
+
|
98
|
+
|
99
|
+
|
100
|
+
</div>
|
101
|
+
|
102
|
+
<div id="footer">
|
103
|
+
Generated on Sun Feb 11 10:26:07 2018 by
|
104
|
+
<a href="http://yardoc.org" title="Yay! A Ruby Documentation Tool" target="_parent">yard</a>
|
105
|
+
0.9.12 (ruby-2.4.0).
|
106
|
+
</div>
|
107
|
+
|
108
|
+
</div>
|
109
|
+
</body>
|
110
|
+
</html>
|
data/docker-compose.yml
ADDED
@@ -0,0 +1,192 @@
|
|
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
|
+
# Dictionary of all this table's columns - inputs, outputs etc.
|
9
|
+
# @api private
|
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
|
+
|
94
|
+
# Dictionary of all table data columns.
|
95
|
+
# The key of each hash is the header cell's array column index.
|
96
|
+
# Note that input and output columns may be interspersed, and multiple input columns
|
97
|
+
# may refer to the same input hash key symbol.
|
98
|
+
# However, output columns must have unique symbols, which cannot overlap with input
|
99
|
+
# column symbols.
|
100
|
+
class Dictionary
|
101
|
+
# @return [Hash{Integer=>Entry}] All column names.
|
102
|
+
attr_accessor :columns
|
103
|
+
|
104
|
+
# @return [Hash{Integer=>Entry}] All input column dictionary entries.
|
105
|
+
attr_accessor :ins
|
106
|
+
|
107
|
+
# @return [Hash{Integer=>Entry}] All defaulted input column dictionary
|
108
|
+
# entries. This is actually just a subset of :ins.
|
109
|
+
attr_accessor :defaults
|
110
|
+
|
111
|
+
# @return [Hash{Integer=>Entry}] All output column dictionary entries.
|
112
|
+
attr_accessor :outs
|
113
|
+
|
114
|
+
# @return [Hash{Integer=>Entry}] All if: column dictionary entries.
|
115
|
+
# This is actually just a subset of :outs.
|
116
|
+
attr_accessor :ifs
|
117
|
+
|
118
|
+
# @return [Hash{Integer=>Symbol}] All path columns.
|
119
|
+
# This is actually just a subset of :outs.
|
120
|
+
attr_accessor :paths
|
121
|
+
|
122
|
+
def initialize
|
123
|
+
@columns = {}
|
124
|
+
@defaults = {}
|
125
|
+
@ifs = {}
|
126
|
+
@ins = {}
|
127
|
+
@outs = {}
|
128
|
+
@paths = {}
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Input columns with defaults specified
|
133
|
+
def defaults
|
134
|
+
@dictionary&.defaults
|
135
|
+
end
|
136
|
+
|
137
|
+
# Set defaults for columns with defaults specified
|
138
|
+
def defaults=(value)
|
139
|
+
@dictionary.defaults = value
|
140
|
+
end
|
141
|
+
|
142
|
+
# @return [Hash{Symbol=>[false, Integer]}] Dictionary of all
|
143
|
+
# input and output column names.
|
144
|
+
def dictionary
|
145
|
+
@dictionary.columns
|
146
|
+
end
|
147
|
+
|
148
|
+
# Input columns hash keyed by column index.
|
149
|
+
# @return [Hash{Index=>Entry}]
|
150
|
+
def ins
|
151
|
+
@dictionary.ins
|
152
|
+
end
|
153
|
+
|
154
|
+
# Output columns hash keyed by column index.
|
155
|
+
# @return [Hash{Index=>Entry}]
|
156
|
+
def outs
|
157
|
+
@dictionary&.outs
|
158
|
+
end
|
159
|
+
|
160
|
+
# if: columns hash keyed by column index.
|
161
|
+
# @return [Hash{Index=>Entry}]
|
162
|
+
def ifs
|
163
|
+
@dictionary.ifs
|
164
|
+
end
|
165
|
+
|
166
|
+
# path: columns hash keyed by column index.
|
167
|
+
# @return [Hash{Index=>Entry}]
|
168
|
+
def paths
|
169
|
+
@dictionary.paths
|
170
|
+
end
|
171
|
+
|
172
|
+
# @return [Array<Symbol>] All input column symbols.
|
173
|
+
def input_keys
|
174
|
+
@dictionary.columns.select { |_k, v| v == :in }.keys
|
175
|
+
end
|
176
|
+
|
177
|
+
# @param table [Table] Decision table being constructed.
|
178
|
+
def initialize(table)
|
179
|
+
# If a column does not have a valid header cell, then it's empty of data.
|
180
|
+
# Return the stripped header row, and remove it from the data array.
|
181
|
+
row = Header.strip_empty_columns(rows: table.rows)
|
182
|
+
|
183
|
+
# No header row found?
|
184
|
+
raise TableValidationError, 'table has no header row' unless row
|
185
|
+
|
186
|
+
# Build a dictionary of all valid data columns from the header row.
|
187
|
+
@dictionary = CSVDecision::Dictionary.build(header: row, dictionary: Dictionary.new)
|
188
|
+
|
189
|
+
freeze
|
190
|
+
end
|
191
|
+
end
|
192
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'csv'
|
4
|
+
|
5
|
+
# CSV Decision: CSV based Ruby decision tables.
|
6
|
+
# Created December 2017.
|
7
|
+
# @author Brett Vickers <brett@phillips-vickers.com>
|
8
|
+
# See LICENSE and README.md for details..
|
9
|
+
module CSVDecision
|
10
|
+
# All cells starting with this character are comments, and treated as a blank cell.
|
11
|
+
COMMENT_CHARACTER = '#'
|
12
|
+
private_constant :COMMENT_CHARACTER
|
13
|
+
|
14
|
+
# Methods to load data from a file, CSV string or an array of arrays.
|
15
|
+
# @api private
|
16
|
+
module Data
|
17
|
+
# Options passed to CSV.parse and CSV.read.
|
18
|
+
CSV_OPTIONS = { encoding: 'UTF-8', skip_blanks: true }.freeze
|
19
|
+
private_constant :CSV_OPTIONS
|
20
|
+
|
21
|
+
# Parse the input data which may either be a file path name, CSV string or
|
22
|
+
# array of arrays. Strips out empty columns/rows and comment cells.
|
23
|
+
#
|
24
|
+
# @param data (see Parse.parse)
|
25
|
+
# @return [Array<Array<String>>] Data array stripped of empty rows.
|
26
|
+
def self.to_array(data:)
|
27
|
+
strip_rows(data: data_array(data))
|
28
|
+
end
|
29
|
+
|
30
|
+
# If the input is a file name return true, otherwise false.
|
31
|
+
#
|
32
|
+
# @param data (see Parse.parse)
|
33
|
+
# @return [Boolean] Set to true if the input data is passed as a File or Pathname.
|
34
|
+
def self.input_file?(data)
|
35
|
+
data.is_a?(Pathname) || data.is_a?(File)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Strip the empty columns from the input data rows.
|
39
|
+
#
|
40
|
+
# @param data (see Parse.parse)
|
41
|
+
# @param empty_columns [Array<Index>]
|
42
|
+
# @return [Array<Array<String>>] Data array stripped of empty columns.
|
43
|
+
def self.strip_columns(data:, empty_columns:)
|
44
|
+
# Adjust column indices as we delete columns the rest shift to the left by 1
|
45
|
+
empty_columns.map!.with_index { |col, index| col - index }
|
46
|
+
|
47
|
+
# Delete all empty columns from the array of arrays
|
48
|
+
empty_columns.each { |col| data.each_index { |row| data[row].delete_at(col) } }
|
49
|
+
end
|
50
|
+
|
51
|
+
# Parse the input data which may either be a file path name, CSV string or
|
52
|
+
# array of arrays
|
53
|
+
def self.data_array(input)
|
54
|
+
return CSV.read(input, **CSV_OPTIONS) if input_file?(input)
|
55
|
+
return input.deep_dup if input.is_a?(Array) && input[0].is_a?(Array)
|
56
|
+
return CSV.parse(input, **CSV_OPTIONS) if input.is_a?(String)
|
57
|
+
|
58
|
+
raise ArgumentError,
|
59
|
+
"#{input.class} input invalid; " \
|
60
|
+
'input must be a file path name, CSV string or array of arrays'
|
61
|
+
end
|
62
|
+
private_class_method :data_array
|
63
|
+
|
64
|
+
def self.strip_rows(data:)
|
65
|
+
rows = []
|
66
|
+
data.each do |row|
|
67
|
+
row = strip_cells(row: row)
|
68
|
+
rows << row if row.find { |cell| cell != '' }
|
69
|
+
end
|
70
|
+
rows
|
71
|
+
end
|
72
|
+
private_class_method :strip_rows
|
73
|
+
|
74
|
+
# Strip cells of leading/trailing spaces; treat comments as an empty cell.
|
75
|
+
# Non string values treated as empty cells.
|
76
|
+
# Non-ascii strings treated as empty cells by default.
|
77
|
+
def self.strip_cells(row:)
|
78
|
+
row.map! { |cell| strip_cell(cell) }
|
79
|
+
end
|
80
|
+
private_class_method :strip_cells
|
81
|
+
|
82
|
+
def self.strip_cell(cell)
|
83
|
+
return '' unless cell.is_a?(String)
|
84
|
+
cell = cell.force_encoding('UTF-8')
|
85
|
+
return '' unless cell.ascii_only?
|
86
|
+
return '' if cell.lstrip[0] == COMMENT_CHARACTER
|
87
|
+
|
88
|
+
cell.strip
|
89
|
+
end
|
90
|
+
private_class_method :strip_cell
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,196 @@
|
|
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) and calculate the final result.
|
9
|
+
# @api private
|
10
|
+
class Decision
|
11
|
+
# Main method for making decisions without a path.
|
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.make(table:, input:, symbolize_keys:)
|
19
|
+
# Parse and transform the hash supplied as input
|
20
|
+
data = 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.new(table: table).scan(data)
|
25
|
+
end
|
26
|
+
|
27
|
+
# @return [Boolean] True if a first match decision table.
|
28
|
+
attr_reader :first_match
|
29
|
+
|
30
|
+
# @return [CSVDecision::Table] Decision table object.
|
31
|
+
attr_reader :table
|
32
|
+
|
33
|
+
# @param table [CSVDecision::Table] Decision table being processed.
|
34
|
+
def initialize(table:)
|
35
|
+
# The result object is a hash of values, and each value will be an array if this is
|
36
|
+
# a multi-row result for the +first_match: false+ option.
|
37
|
+
@result = Result.new(table: table)
|
38
|
+
@first_match = table.options[:first_match]
|
39
|
+
@table = table
|
40
|
+
end
|
41
|
+
|
42
|
+
# Initialize the input data used to make the decision.
|
43
|
+
#
|
44
|
+
# @param data [Hash{Symbol=>Object}] Input hash data structure.
|
45
|
+
# @return [void]
|
46
|
+
def input(data)
|
47
|
+
@result.input(data[:hash])
|
48
|
+
|
49
|
+
# All rows picked by the matching process. An array if +first_match: false+,
|
50
|
+
# otherwise a single row.
|
51
|
+
@rows_picked = []
|
52
|
+
|
53
|
+
@input = data
|
54
|
+
end
|
55
|
+
|
56
|
+
# Scan the decision table and produce an output decision.
|
57
|
+
#
|
58
|
+
# @param data [Hash{Symbol=>Object}] Input hash data structure.
|
59
|
+
# @return (see .make)
|
60
|
+
def scan(data)
|
61
|
+
input(data)
|
62
|
+
# Use the table's index if present
|
63
|
+
@table.index ? index_scan : table_scan
|
64
|
+
end
|
65
|
+
|
66
|
+
# Scan the index for a first match result.
|
67
|
+
#
|
68
|
+
# @param scan_cols [Hash{Integer=>Object}]
|
69
|
+
# @param hash [Hash{Symbol=>Object}]
|
70
|
+
# @param index_rows [Array<Integer>]
|
71
|
+
# @return [Hash{Symbol=>Object}]
|
72
|
+
def index_scan_first_match(scan_cols:, hash:, index_rows:)
|
73
|
+
index_rows.each do |start_row, end_row|
|
74
|
+
@table.each(start_row, end_row || start_row) do |row, index|
|
75
|
+
next unless @table.scan_rows[index].match?(row: row, hash: hash, scan_cols: scan_cols)
|
76
|
+
|
77
|
+
return @result.attributes if first_match_found(row)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
{}
|
82
|
+
end
|
83
|
+
|
84
|
+
# Scan the index for an accumulated result.
|
85
|
+
#
|
86
|
+
# @param scan_cols [Hash{Integer=>Object}]
|
87
|
+
# @param hash [Hash{Symbol=>Object}]
|
88
|
+
# @param index_rows [Array<Integer>]
|
89
|
+
# @return [Hash{Symbol=>Object}]
|
90
|
+
def index_scan_accumulate(scan_cols:, hash:, index_rows:)
|
91
|
+
index_rows.each do |start_row, end_row|
|
92
|
+
@table.each(start_row, end_row || start_row) do |row, index|
|
93
|
+
next unless @table.scan_rows[index].match?(row: row, hash: hash, scan_cols: scan_cols)
|
94
|
+
|
95
|
+
# Accumulate output rows.
|
96
|
+
@rows_picked << row
|
97
|
+
@result.accumulate_outs(row)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
@rows_picked.empty? ? {} : accumulated_result
|
102
|
+
end
|
103
|
+
|
104
|
+
private
|
105
|
+
|
106
|
+
# Use an index to scan the decision table up against the input hash.
|
107
|
+
def index_scan_rows(rows:)
|
108
|
+
if @first_match
|
109
|
+
index_scan_first_match(scan_cols: @input[:scan_cols], hash: @input[:hash], index_rows: rows)
|
110
|
+
else
|
111
|
+
index_scan_accumulate(scan_cols: @input[:scan_cols], hash: @input[:hash], index_rows: rows)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
def scan_first_match(hash:, scan_cols:)
|
116
|
+
@table.each do |row, index|
|
117
|
+
next unless @table.scan_rows[index].match?(row: row, hash: hash, scan_cols: scan_cols)
|
118
|
+
|
119
|
+
return @result.attributes if first_match_found(row)
|
120
|
+
end
|
121
|
+
|
122
|
+
{}
|
123
|
+
end
|
124
|
+
|
125
|
+
def scan_accumulate(hash:, scan_cols:)
|
126
|
+
@table.each do |row, index|
|
127
|
+
next unless @table.scan_rows[index].match?(row: row, hash: hash, scan_cols: scan_cols)
|
128
|
+
|
129
|
+
# Accumulate output rows
|
130
|
+
@rows_picked << row
|
131
|
+
@result.accumulate_outs(row)
|
132
|
+
end
|
133
|
+
|
134
|
+
@rows_picked.empty? ? {} : accumulated_result
|
135
|
+
end
|
136
|
+
|
137
|
+
# Scan the decision table up against the input hash.
|
138
|
+
def table_scan
|
139
|
+
if @first_match
|
140
|
+
scan_first_match(hash: @input[:hash], scan_cols: @input[:scan_cols])
|
141
|
+
else
|
142
|
+
scan_accumulate(hash: @input[:hash], scan_cols: @input[:scan_cols])
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
# Use an index to scan the decision table up against the input hash.
|
147
|
+
def index_scan
|
148
|
+
# If the index lookup fails, there's no match.
|
149
|
+
return {} unless (index_rows = Array(@table.index.hash[@input[:key]]))
|
150
|
+
|
151
|
+
index_scan_rows(rows: index_rows)
|
152
|
+
end
|
153
|
+
|
154
|
+
def accumulated_result
|
155
|
+
return @result.final_result unless @result.outs_functions
|
156
|
+
return @result.eval_outs(@rows_picked.first) unless @result.multi_result
|
157
|
+
|
158
|
+
multi_row_result
|
159
|
+
end
|
160
|
+
|
161
|
+
def multi_row_result
|
162
|
+
# Scan each output column that contains functions
|
163
|
+
@result.outs.each_pair { |col, column| eval_procs(col: col, column: column) if column.eval }
|
164
|
+
|
165
|
+
@result.final_result
|
166
|
+
end
|
167
|
+
|
168
|
+
def eval_procs(col:, column:)
|
169
|
+
@rows_picked.each_with_index do |row, index|
|
170
|
+
cell = row[col]
|
171
|
+
next unless cell.is_a?(Matchers::Proc)
|
172
|
+
|
173
|
+
# Evaluate the proc and update the result
|
174
|
+
@result.eval_cell_proc(proc: cell, column_name: column.name, index: index)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
def first_match_found(row)
|
179
|
+
# This decision row may contain procs, which if present will need to be evaluated.
|
180
|
+
# If this row contains if: columns then this row may be filtered out, in which case
|
181
|
+
# this method call will return false.
|
182
|
+
return eval_single_row(row) if @result.outs_functions
|
183
|
+
|
184
|
+
# Common case is just copying output column values to the final result.
|
185
|
+
@rows_picked = row
|
186
|
+
@result.add_outs(row)
|
187
|
+
end
|
188
|
+
|
189
|
+
def eval_single_row(row)
|
190
|
+
return false unless (result = @result.eval_outs(row))
|
191
|
+
|
192
|
+
@rows_picked = row
|
193
|
+
result
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
@@ -0,0 +1,47 @@
|
|
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
|
+
# Parse the default row beneath the header row if present
|
9
|
+
# @api private
|
10
|
+
module Defaults
|
11
|
+
# Parse the defaults row that (optionally) appears just after the header row.
|
12
|
+
# We have already determined that this row must be present.
|
13
|
+
# @param columns [{Integer=>Dictionary::Entry}] Hash of header columns with defaults.
|
14
|
+
# @param matchers [Array<Matchers>] Output cell special matchers.
|
15
|
+
# @param row [Array<String>] Defaults row that appears just after the header row.
|
16
|
+
# @raise [TableValidationError] Missing defaults row.
|
17
|
+
def self.parse(columns:, matchers:, row:)
|
18
|
+
raise TableValidationError, 'Missing defaults row' if row.nil?
|
19
|
+
|
20
|
+
defaults = columns.defaults
|
21
|
+
|
22
|
+
# Scan the default row for procs and constants
|
23
|
+
scan_row = ScanRow.new.scan_columns(row: row, columns: defaults, matchers: matchers)
|
24
|
+
|
25
|
+
parse_columns(defaults: defaults, columns: columns.dictionary, row: scan_row)
|
26
|
+
end
|
27
|
+
|
28
|
+
def self.parse_columns(defaults:, columns:, row:)
|
29
|
+
defaults.each_pair do |col, entry|
|
30
|
+
parse_cell(cell: row[col], columns: columns, entry: entry)
|
31
|
+
end
|
32
|
+
|
33
|
+
defaults
|
34
|
+
end
|
35
|
+
private_class_method :parse_columns
|
36
|
+
|
37
|
+
def self.parse_cell(cell:, columns:, entry:)
|
38
|
+
return entry.function = cell unless cell.is_a?(Matchers::Proc)
|
39
|
+
|
40
|
+
entry.function = cell.function
|
41
|
+
|
42
|
+
# Add any referenced input column symbols to the column name dictionary
|
43
|
+
Columns.ins_cell_dictionary(columns: columns, cell: cell)
|
44
|
+
end
|
45
|
+
private_class_method :parse_cell
|
46
|
+
end
|
47
|
+
end
|