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