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,220 @@
|
|
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
|
+
# Match table data cells against a valid decision table expression or a simple constant.
|
9
|
+
# @api private
|
10
|
+
class Matchers
|
11
|
+
# Composite object for a data cell proc. Note that we do not need it to be comparable.
|
12
|
+
# Implemented as an immutable array of 2 or 3 entries for memory compactness and speed.
|
13
|
+
# @api private
|
14
|
+
class Proc < Array
|
15
|
+
# @param type [Symbol] Type of the function value - e.g., :constant or :guard.
|
16
|
+
# @param function [Object] Either a lambda function,
|
17
|
+
# or some kind of constant such as an Integer.
|
18
|
+
# @param symbols [nil, Symbol, Array<Symbol>] The symbol or list of symbols
|
19
|
+
# that the function uses to reference input hash keys (which are always symbolized).
|
20
|
+
def initialize(type:, function:, symbols: nil)
|
21
|
+
super()
|
22
|
+
|
23
|
+
self << type
|
24
|
+
|
25
|
+
# Function values should always be frozen
|
26
|
+
self << function.freeze
|
27
|
+
|
28
|
+
# Some function values, such as constants or 0-arity functions, do not reference symbols.
|
29
|
+
self << symbols if symbols
|
30
|
+
|
31
|
+
freeze
|
32
|
+
end
|
33
|
+
|
34
|
+
# @param hash [Hash] Input hash to function call.
|
35
|
+
# @param value [Object] Input value to function call.
|
36
|
+
# @return [Object] Value returned from function call.
|
37
|
+
def call(hash:, value: nil)
|
38
|
+
func = fetch(1)
|
39
|
+
|
40
|
+
return func.call(hash) if fetch(0) == :guard
|
41
|
+
|
42
|
+
# All other procs can take one or two args
|
43
|
+
func.arity == 1 ? func.call(value) : func.call(value, hash)
|
44
|
+
end
|
45
|
+
|
46
|
+
# @return [Symbol] Type of the function value - e.g., :constant or :guard.
|
47
|
+
def type
|
48
|
+
fetch(0)
|
49
|
+
end
|
50
|
+
|
51
|
+
# @return [Object] Either a lambda function, or some kind of constant such as an Integer.
|
52
|
+
def function
|
53
|
+
fetch(1)
|
54
|
+
end
|
55
|
+
|
56
|
+
# @return [nil, Symbol, Array<Symbol>] The symbol or list of symbols
|
57
|
+
# that the function uses to reference input hash keys (which are always symbolized).
|
58
|
+
def symbols
|
59
|
+
fetch(2, nil)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Negation sign prefixed to ranges and functions.
|
64
|
+
NEGATE = '!'
|
65
|
+
|
66
|
+
# All regular expressions used for matching are anchored inside their own
|
67
|
+
# non-capturing group.
|
68
|
+
#
|
69
|
+
# @param value [String] String used to form an anchored regular expression.
|
70
|
+
# @return [Regexp] Anchored, frozen regular expression.
|
71
|
+
def self.regexp(value)
|
72
|
+
Regexp.new("\\A(?:#{value})\\z").freeze
|
73
|
+
end
|
74
|
+
|
75
|
+
# Symbols used for inequality
|
76
|
+
INEQUALITY = '!=|!'
|
77
|
+
|
78
|
+
# Match Regexp for inequality
|
79
|
+
INEQUALITY_RE = regexp(INEQUALITY)
|
80
|
+
|
81
|
+
# Equality, cell constants and functions specified by prefixing the value with
|
82
|
+
# one of these 3 symbols.
|
83
|
+
EQUALS = '==|:=|='
|
84
|
+
|
85
|
+
# Match Regexp for equality
|
86
|
+
EQUALS_RE = regexp(EQUALS)
|
87
|
+
|
88
|
+
# Method names are stricter than CSV column names.
|
89
|
+
METHOD_NAME_RE = /\A[_a-z][_a-z0-9]*[?!=]?\z/
|
90
|
+
|
91
|
+
# Normalize the operators which are a variation on equals/assignment.
|
92
|
+
#
|
93
|
+
# @param operator [String]
|
94
|
+
# @return [String]
|
95
|
+
def self.normalize_operator(operator)
|
96
|
+
EQUALS_RE.match?(operator) ? '==' : operator
|
97
|
+
end
|
98
|
+
|
99
|
+
# Regular expression used to recognise a numeric string with or without a decimal point.
|
100
|
+
NUMERIC = '[-+]?\d*(?<decimal>\.?)\d*'
|
101
|
+
|
102
|
+
NUMERIC_RE = regexp(NUMERIC)
|
103
|
+
private_constant :NUMERIC_RE
|
104
|
+
|
105
|
+
# Validate a numeric value and convert it to an Integer or BigDecimal if a valid numeric string.
|
106
|
+
#
|
107
|
+
# @param value [nil, String, Integer, BigDecimal]
|
108
|
+
# @return [nil, Integer, BigDecimal]
|
109
|
+
def self.numeric(value)
|
110
|
+
return value if value.is_a?(Integer) || value.is_a?(BigDecimal)
|
111
|
+
return unless value.is_a?(String)
|
112
|
+
|
113
|
+
to_numeric(value)
|
114
|
+
end
|
115
|
+
|
116
|
+
# Convert a numeric string into an Integer or BigDecimal, otherwise return nil.
|
117
|
+
#
|
118
|
+
# @param value [String]
|
119
|
+
# @return [nil, Integer, BigDecimal]
|
120
|
+
def self.to_numeric(value)
|
121
|
+
return unless (match = NUMERIC_RE.match(value))
|
122
|
+
|
123
|
+
return value.to_i if match['decimal'] == ''
|
124
|
+
BigDecimal(value.chomp('.'))
|
125
|
+
end
|
126
|
+
|
127
|
+
# Compare one object with another if they both respond to the compare method.
|
128
|
+
#
|
129
|
+
# @param lhs [Object]
|
130
|
+
# @param compare [Object]
|
131
|
+
# @param rhs [Object]
|
132
|
+
# @return [nil, Boolean]
|
133
|
+
def self.compare?(lhs:, compare:, rhs:)
|
134
|
+
# Is the rhs the same class or a superclass of lhs, and does rhs respond to the
|
135
|
+
# compare method?
|
136
|
+
return lhs.send(compare, rhs) if lhs.is_a?(rhs.class) && rhs.respond_to?(compare)
|
137
|
+
|
138
|
+
nil
|
139
|
+
end
|
140
|
+
|
141
|
+
# Parse the supplied input columns for the row supplied using an array of matchers.
|
142
|
+
#
|
143
|
+
# @param columns [Hash{Integer=>Columns::Entry}] Input columns hash.
|
144
|
+
# @param matchers [Array<Matchers::Matcher>]
|
145
|
+
# @param row [Array<String>] Data row being parsed.
|
146
|
+
# @return [Array<(Array, ScanRow)>] Used to scan a table row against an input hash for matches.
|
147
|
+
def self.parse(columns:, matchers:, row:)
|
148
|
+
# Build an array of column indexes requiring simple constant matches,
|
149
|
+
# and a second array of columns requiring special matchers.
|
150
|
+
scan_row = ScanRow.new
|
151
|
+
|
152
|
+
# Scan the columns in the data row, and build an object to scan this row against
|
153
|
+
# an input hash.
|
154
|
+
# Convert values in the data row if not just a simple constant.
|
155
|
+
row = scan_row.scan_columns(columns: columns, matchers: matchers, row: row)
|
156
|
+
|
157
|
+
[row, scan_row]
|
158
|
+
end
|
159
|
+
|
160
|
+
# @return [Array<Matchers::Matcher>] Matchers for the input columns.
|
161
|
+
attr_reader :ins
|
162
|
+
|
163
|
+
# @return [Array<Matchers::Matcher>] Matchers for the output columns.
|
164
|
+
attr_reader :outs
|
165
|
+
|
166
|
+
# @param options (see CSVDecision.parse)
|
167
|
+
def initialize(options)
|
168
|
+
matchers = options[:matchers].collect { |klass| klass.new(options) }
|
169
|
+
@ins = matchers.select(&:ins?)
|
170
|
+
@outs = matchers.select(&:outs?)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Parse the row's input columns using the input matchers.
|
174
|
+
#
|
175
|
+
# @param columns (see Matchers.parse)
|
176
|
+
# @param row (see Matchers.parse)
|
177
|
+
# @return (see Matchers.parse)
|
178
|
+
def parse_ins(columns:, row:)
|
179
|
+
Matchers.parse(columns: columns, matchers: @ins, row: row)
|
180
|
+
end
|
181
|
+
|
182
|
+
# Parse the row's output columns using the output matchers.
|
183
|
+
#
|
184
|
+
# @param columns (see Matchers.parse)
|
185
|
+
# @param row (see Matchers.parse)
|
186
|
+
# @return (see Matchers.parse)
|
187
|
+
def parse_outs(columns:, row:)
|
188
|
+
Matchers.parse(columns: columns, matchers: @outs, row: row)
|
189
|
+
end
|
190
|
+
|
191
|
+
# Subclass and override {#matches?} to implement a custom Matcher class.
|
192
|
+
class Matcher
|
193
|
+
def initialize(_options = nil); end
|
194
|
+
|
195
|
+
# Determine if the input cell string is recognised by this Matcher.
|
196
|
+
#
|
197
|
+
# @param cell [String] Data row cell.
|
198
|
+
# @return [false, CSVDecision::Proc] Returns false if this cell is not a match; otherwise
|
199
|
+
# returns the +CSVDecision::Proc+ object indicating if this is a constant or some type of
|
200
|
+
# function.
|
201
|
+
def matches?(cell); end
|
202
|
+
|
203
|
+
# Does this matcher apply to output cells?
|
204
|
+
#
|
205
|
+
# @return [Boolean] Return true if this matcher applies to output cells,
|
206
|
+
# false otherwise.
|
207
|
+
def outs?
|
208
|
+
false
|
209
|
+
end
|
210
|
+
|
211
|
+
# Does this matcher apply to output cells?
|
212
|
+
#
|
213
|
+
# @return [Boolean] Return true if this matcher applies to input cells,
|
214
|
+
# false otherwise.
|
215
|
+
def ins?
|
216
|
+
true
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,124 @@
|
|
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
|
+
# Validate and normalize the options values supplied.
|
9
|
+
# @api private
|
10
|
+
module Options
|
11
|
+
# Specialized cell value matchers beyond simple string compares.
|
12
|
+
# By default all these matchers are tried in the specified order on all
|
13
|
+
# input data cells.
|
14
|
+
DEFAULT_MATCHERS = [
|
15
|
+
Matchers::Range,
|
16
|
+
Matchers::Numeric,
|
17
|
+
Matchers::Pattern,
|
18
|
+
Matchers::Constant,
|
19
|
+
Matchers::Symbol,
|
20
|
+
Matchers::Guard
|
21
|
+
].freeze
|
22
|
+
|
23
|
+
# All valid CSVDecision::parse options with their default values.
|
24
|
+
VALID = {
|
25
|
+
first_match: true,
|
26
|
+
regexp_implicit: false,
|
27
|
+
text_only: false,
|
28
|
+
matchers: DEFAULT_MATCHERS
|
29
|
+
}.freeze
|
30
|
+
private_constant :VALID
|
31
|
+
|
32
|
+
# These options may appear in the CSV file before the header row.
|
33
|
+
# They get converted to a normalized option key value pair.
|
34
|
+
CSV_NAMES = {
|
35
|
+
first_match: [:first_match, true], accumulate: [:first_match, false],
|
36
|
+
regexp_implicit: [:regexp_implicit, true],
|
37
|
+
text_only: [:text_only, true], string_search: [:text_only, true]
|
38
|
+
}.freeze
|
39
|
+
private_constant :CSV_NAMES
|
40
|
+
|
41
|
+
# Validate options and supply default values for any options not explicitly set.
|
42
|
+
#
|
43
|
+
# @param options [Hash] Input options hash supplied by the user.
|
44
|
+
# @return [Hash] Options hash filled in with all required values, defaulted if necessary.
|
45
|
+
# @raise [CellValidationError] For invalid option keys.
|
46
|
+
def self.normalize(options)
|
47
|
+
validate(options)
|
48
|
+
default(options)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Read any options supplied in the CSV file placed before the header row.
|
52
|
+
#
|
53
|
+
# @param rows [Array<Array<String>>] Table data rows.
|
54
|
+
# @param options [Hash] Input options hash built so far.
|
55
|
+
# @return [Hash] Options hash overridden with any values found in the CSV file.
|
56
|
+
def self.from_csv(rows:, options:)
|
57
|
+
row = rows.first
|
58
|
+
return options if row.nil?
|
59
|
+
|
60
|
+
# Have we hit the header row?
|
61
|
+
return options if Header.row?(row)
|
62
|
+
|
63
|
+
# Scan each cell looking for valid option values
|
64
|
+
options = scan_cells(row: row, options: options)
|
65
|
+
|
66
|
+
rows.shift
|
67
|
+
from_csv(rows: rows, options: options)
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.scan_cells(row:, options:)
|
71
|
+
# Scan each cell looking for valid option values
|
72
|
+
row.each do |cell|
|
73
|
+
next if cell == ''
|
74
|
+
|
75
|
+
key, value = option?(cell)
|
76
|
+
options[key] = value if key
|
77
|
+
end
|
78
|
+
|
79
|
+
options
|
80
|
+
end
|
81
|
+
private_class_method :scan_cells
|
82
|
+
|
83
|
+
def self.default(options)
|
84
|
+
result = options.dup
|
85
|
+
|
86
|
+
# The user may override the list of matchers to be used
|
87
|
+
result[:matchers] = matchers(result)
|
88
|
+
|
89
|
+
# Supply any missing options with default values
|
90
|
+
VALID.each_pair do |key, value|
|
91
|
+
next if result.key?(key)
|
92
|
+
result[key] = value
|
93
|
+
end
|
94
|
+
|
95
|
+
result
|
96
|
+
end
|
97
|
+
private_class_method :default
|
98
|
+
|
99
|
+
def self.matchers(options)
|
100
|
+
return [] if options.key?(:matchers) && !options[:matchers]
|
101
|
+
return [] if options[:text_only]
|
102
|
+
return DEFAULT_MATCHERS unless options.key?(:matchers)
|
103
|
+
|
104
|
+
options[:matchers]
|
105
|
+
end
|
106
|
+
private_class_method :matchers
|
107
|
+
|
108
|
+
def self.option?(cell)
|
109
|
+
key = cell.strip.downcase.to_sym
|
110
|
+
|
111
|
+
CSV_NAMES[key]
|
112
|
+
end
|
113
|
+
private_class_method :option?
|
114
|
+
|
115
|
+
def self.validate(options)
|
116
|
+
invalid_options = options.keys - VALID.keys
|
117
|
+
|
118
|
+
return if invalid_options.empty?
|
119
|
+
|
120
|
+
raise CellValidationError, "invalid option(s) supplied: #{invalid_options.inspect}"
|
121
|
+
end
|
122
|
+
private_class_method :validate
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,165 @@
|
|
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
|
+
# All CSVDecision specific errors
|
9
|
+
class Error < StandardError; end
|
10
|
+
|
11
|
+
# Error validating a cell when parsing input table data.
|
12
|
+
class TableValidationError < Error; end
|
13
|
+
|
14
|
+
# Error validating a cell when parsing input table cell data.
|
15
|
+
class CellValidationError < Error; end
|
16
|
+
|
17
|
+
# Table parsing error message enhanced to include the file being processed.
|
18
|
+
class FileError < Error; end
|
19
|
+
|
20
|
+
# Builds a decision table from the input data - which may either be a file, CSV string
|
21
|
+
# or an array of arrays.
|
22
|
+
#
|
23
|
+
# @example Simple Example
|
24
|
+
# If you have cloned the gem's git repo, then you can run:
|
25
|
+
# table = CSVDecision.parse(Pathname('spec/data/valid/simple_example.csv'))
|
26
|
+
# #=> CSVDecision::Table
|
27
|
+
# table.decide(topic: 'finance', region: 'Europe') #=> team_member: 'Donald'
|
28
|
+
#
|
29
|
+
# @param data [Pathname, File, Array<Array<String>>, String] input data given as
|
30
|
+
# a CSV file, array of arrays or CSV string.
|
31
|
+
# @param options [Hash{Symbol=>Object}] Options hash controlling how the table is parsed and
|
32
|
+
# interpreted.
|
33
|
+
#
|
34
|
+
# @option options [Boolean] :first_match Stop scanning after finding the first row match.
|
35
|
+
# @option options [Boolean] :regexp_implicit Make regular expressions implicit rather than
|
36
|
+
# requiring the comparator =~. (Use with care.)
|
37
|
+
# @option options [Boolean] :text_only All cells treated as simple strings by turning off all
|
38
|
+
# special matchers.
|
39
|
+
# @option options [Array<Matchers::Matcher>] :matchers May be used to control the inclusion and
|
40
|
+
# ordering of special matchers. (Advanced feature, use with care.)
|
41
|
+
#
|
42
|
+
# @return [CSVDecision::Table] Resulting decision table.
|
43
|
+
#
|
44
|
+
# @raise [CSVDecision::CellValidationError] Table parsing cell validation error.
|
45
|
+
# @raise [CSVDecision::FileError] Table parsing error for a named CSV file.
|
46
|
+
#
|
47
|
+
def self.parse(data, options = {})
|
48
|
+
Parse.table(data: data, options: Options.normalize(options))
|
49
|
+
end
|
50
|
+
|
51
|
+
# Methods to parse the decision table and return CSVDecision::Table object.
|
52
|
+
# @api private
|
53
|
+
module Parse
|
54
|
+
# Parse the CSV file or input data and create a new decision table object.
|
55
|
+
#
|
56
|
+
# @param (see CSVDecision.parse)
|
57
|
+
# @return (see CSVDecision.parse)
|
58
|
+
def self.table(data:, options:)
|
59
|
+
table = CSVDecision::Table.new
|
60
|
+
|
61
|
+
# In most cases the decision table will be loaded from a CSV file.
|
62
|
+
table.file = data if Data.input_file?(data)
|
63
|
+
|
64
|
+
parse_table(table: table, input: data, options: options)
|
65
|
+
|
66
|
+
# The table object is now immutable.
|
67
|
+
table.columns.freeze
|
68
|
+
table.freeze
|
69
|
+
rescue CSVDecision::Error => exp
|
70
|
+
raise_error(file: table.file, exception: exp)
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.raise_error(file:, exception:)
|
74
|
+
raise exception unless file
|
75
|
+
|
76
|
+
raise CSVDecision::FileError,
|
77
|
+
"error processing CSV file #{file}\n#{exception.inspect}"
|
78
|
+
end
|
79
|
+
private_class_method :raise_error
|
80
|
+
|
81
|
+
def self.parse_table(table:, input:, options:)
|
82
|
+
# Parse input data into an array of arrays.
|
83
|
+
table.rows = Data.to_array(data: input)
|
84
|
+
|
85
|
+
# Pick up any options specified in the CSV file before the header row.
|
86
|
+
# These override any options passed as parameters to the parse method.
|
87
|
+
table.options = Options.from_csv(rows: table.rows, options: options).freeze
|
88
|
+
|
89
|
+
# Parse table header and data rows with special cell matchers.
|
90
|
+
parse_with_matchers(table: table, matchers: CSVDecision::Matchers.new(options))
|
91
|
+
|
92
|
+
# Build the data index if one is indicated
|
93
|
+
Index.build(table: table)
|
94
|
+
|
95
|
+
# Build a paths index if one is indicated
|
96
|
+
Paths.scan(table: table)
|
97
|
+
end
|
98
|
+
private_class_method :parse_table
|
99
|
+
|
100
|
+
def self.parse_with_matchers(table:, matchers:)
|
101
|
+
# Parse the header row
|
102
|
+
table.columns = Header.parse(table: table, matchers: matchers)
|
103
|
+
|
104
|
+
# Parse the table's the data rows.
|
105
|
+
parse_data(table: table, matchers: matchers)
|
106
|
+
end
|
107
|
+
private_class_method :parse_with_matchers
|
108
|
+
|
109
|
+
def self.parse_data(table:, matchers:)
|
110
|
+
table.rows.each_with_index do |row, index|
|
111
|
+
# Mutate the row if we find anything other than a simple string constant in its
|
112
|
+
# data cells.
|
113
|
+
row = parse_row(table: table, matchers: matchers, row: row, index: index)
|
114
|
+
|
115
|
+
# Does the row have any output functions?
|
116
|
+
outs_functions(table: table, index: index)
|
117
|
+
|
118
|
+
# No more mutations required for this row.
|
119
|
+
row.freeze
|
120
|
+
end
|
121
|
+
end
|
122
|
+
private_class_method :parse_data
|
123
|
+
|
124
|
+
def self.parse_row(table:, matchers:, row:, index:)
|
125
|
+
# Parse the input cells for this row
|
126
|
+
row = parse_row_ins(table: table, matchers: matchers, row: row, index: index)
|
127
|
+
|
128
|
+
# Parse the output cells for this row
|
129
|
+
parse_row_outs(table: table, matchers: matchers, row: row, index: index)
|
130
|
+
end
|
131
|
+
private_class_method :parse_row
|
132
|
+
|
133
|
+
def self.parse_row_ins(table:, matchers:, row:, index:)
|
134
|
+
# Parse the input cells for this row
|
135
|
+
row, table.scan_rows[index] = matchers.parse_ins(columns: table.columns.ins, row: row)
|
136
|
+
|
137
|
+
# Add any symbol references made by input cell procs to the column dictionary
|
138
|
+
Columns.ins_dictionary(columns: table.columns.dictionary, row: row)
|
139
|
+
|
140
|
+
row
|
141
|
+
end
|
142
|
+
private_class_method :parse_row_ins
|
143
|
+
|
144
|
+
def self.parse_row_outs(table:, matchers:, row:, index:)
|
145
|
+
# Parse the output cells for this row
|
146
|
+
row, table.outs_rows[index] = matchers.parse_outs(columns: table.columns.outs, row: row)
|
147
|
+
|
148
|
+
Columns.outs_dictionary(columns: table.columns, row: row)
|
149
|
+
|
150
|
+
row
|
151
|
+
end
|
152
|
+
private_class_method :parse_row_outs
|
153
|
+
|
154
|
+
def self.outs_functions(table:, index:)
|
155
|
+
return if table.outs_rows[index].procs.empty?
|
156
|
+
|
157
|
+
# Set this flag as the table has output functions
|
158
|
+
table.outs_functions = true
|
159
|
+
|
160
|
+
# Update the output columns that contain functions needing evaluation.
|
161
|
+
table.outs_rows[index].procs.each { |col| table.columns.outs[col].eval = true }
|
162
|
+
end
|
163
|
+
private_class_method :outs_functions
|
164
|
+
end
|
165
|
+
end
|
@@ -0,0 +1,78 @@
|
|
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 Paths
|
12
|
+
# Build the index of paths
|
13
|
+
#
|
14
|
+
# @param table [CSVDecision::Table] Decision table being indexed.
|
15
|
+
# @return [CSVDecision::Paths] The built index of paths.
|
16
|
+
def self.scan(table:)
|
17
|
+
# Do we even have paths?
|
18
|
+
columns = table.columns.paths.keys
|
19
|
+
return [] if columns.empty?
|
20
|
+
|
21
|
+
table.paths = Paths.new(table: table, columns: columns).paths
|
22
|
+
end
|
23
|
+
|
24
|
+
# @param current_value [Integer, Array] Current path value.
|
25
|
+
# @param index [Integer] Array row index to be included in the path entry.
|
26
|
+
# @return [Integer, Array] New path key value.
|
27
|
+
def self.value(current_value, index)
|
28
|
+
return [current_value, index] if current_value.is_a?(Integer)
|
29
|
+
|
30
|
+
current_value[-1] = index
|
31
|
+
current_value
|
32
|
+
end
|
33
|
+
|
34
|
+
# @param value [String] Cell value for the path: column.
|
35
|
+
# @return [nil, Symbol] Non-empty string converted to a symbol.
|
36
|
+
def self.symbol(value)
|
37
|
+
value.blank? ? nil : value.to_sym
|
38
|
+
end
|
39
|
+
|
40
|
+
# @return [Hash] The index hash mapping in input values to one or more data array row indexes.
|
41
|
+
attr_reader :paths
|
42
|
+
|
43
|
+
# @param table [CSVDecision::Table] Decision table.
|
44
|
+
# @param columns [Array<Index>] Array of column indexes to be indexed.
|
45
|
+
def initialize(table:, columns:)
|
46
|
+
@paths = []
|
47
|
+
@columns = columns
|
48
|
+
|
49
|
+
build(table)
|
50
|
+
|
51
|
+
freeze
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def build(table)
|
57
|
+
last_path = nil
|
58
|
+
key = -1
|
59
|
+
rows = nil
|
60
|
+
table.each do |row, index|
|
61
|
+
path = build_path(row: row)
|
62
|
+
if path == last_path
|
63
|
+
rows = Paths.value(rows, index)
|
64
|
+
else
|
65
|
+
rows = index
|
66
|
+
key += 1
|
67
|
+
last_path = path
|
68
|
+
end
|
69
|
+
|
70
|
+
@paths[key] = [path, rows]
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def build_path(row:)
|
75
|
+
@columns.map { |col| Paths.symbol(row[col]) }.compact
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|