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,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
|