csv_decision 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.codeclimate.yml +2 -0
- data/.gitignore +2 -1
- data/.travis.yml +2 -3
- data/CHANGELOG.md +19 -1
- data/README.md +49 -16
- data/{benchmark.rb → benchmarks/rufus_decision.rb} +1 -1
- data/csv_decision.gemspec +1 -1
- data/doc/CSVDecision/CellValidationError.html +143 -0
- data/doc/CSVDecision/Columns/Default.html +409 -0
- data/doc/CSVDecision/Columns/Dictionary.html +410 -0
- data/doc/CSVDecision/Columns/Entry.html +321 -0
- data/doc/CSVDecision/Columns.html +476 -0
- data/doc/CSVDecision/Constant.html +295 -0
- data/doc/CSVDecision/Data.html +344 -0
- data/doc/CSVDecision/Decide.html +434 -0
- data/doc/CSVDecision/Decision.html +604 -0
- data/doc/CSVDecision/Error.html +139 -0
- data/doc/CSVDecision/FileError.html +143 -0
- data/doc/CSVDecision/Function.html +229 -0
- data/doc/CSVDecision/Header.html +520 -0
- data/doc/CSVDecision/Input.html +305 -0
- data/doc/CSVDecision/Load.html +225 -0
- data/doc/CSVDecision/Matchers/Constant.html +242 -0
- data/doc/CSVDecision/Matchers/Function.html +342 -0
- data/doc/CSVDecision/Matchers/Matcher.html +325 -0
- data/doc/CSVDecision/Matchers/Numeric.html +277 -0
- data/doc/CSVDecision/Matchers/Pattern.html +600 -0
- data/doc/CSVDecision/Matchers/Range.html +413 -0
- data/doc/CSVDecision/Matchers/Symbol.html +280 -0
- data/doc/CSVDecision/Matchers.html +1529 -0
- data/doc/CSVDecision/Numeric.html +259 -0
- data/doc/CSVDecision/Options.html +445 -0
- data/doc/CSVDecision/Parse.html +270 -0
- data/doc/CSVDecision/ScanRow.html +746 -0
- data/doc/CSVDecision/Symbol.html +256 -0
- data/doc/CSVDecision/Table.html +1115 -0
- data/doc/CSVDecision.html +652 -0
- data/doc/_index.html +410 -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 +264 -0
- data/doc/file_list.html +56 -0
- data/doc/frames.html +17 -0
- data/doc/index.html +264 -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 +683 -0
- data/doc/top-level-namespace.html +110 -0
- data/lib/csv_decision/columns.rb +15 -12
- data/lib/csv_decision/constant.rb +54 -0
- data/lib/csv_decision/decide.rb +5 -5
- data/lib/csv_decision/decision.rb +3 -1
- data/lib/csv_decision/function.rb +32 -0
- data/lib/csv_decision/header.rb +27 -18
- data/lib/csv_decision/input.rb +11 -8
- data/lib/csv_decision/matchers/constant.rb +18 -0
- data/lib/csv_decision/matchers/function.rb +11 -44
- data/lib/csv_decision/matchers/numeric.rb +5 -33
- data/lib/csv_decision/matchers/pattern.rb +26 -11
- data/lib/csv_decision/matchers/range.rb +21 -5
- data/lib/csv_decision/matchers/symbol.rb +20 -0
- data/lib/csv_decision/matchers.rb +85 -20
- data/lib/csv_decision/numeric.rb +38 -0
- data/lib/csv_decision/options.rb +36 -27
- data/lib/csv_decision/parse.rb +46 -39
- data/lib/csv_decision/scan_row.rb +19 -7
- data/lib/csv_decision/symbol.rb +73 -0
- data/lib/csv_decision/table.rb +24 -18
- data/lib/csv_decision.rb +25 -18
- data/spec/csv_decision/columns_spec.rb +1 -1
- data/spec/csv_decision/constant_spec.rb +60 -0
- data/spec/csv_decision/examples_spec.rb +119 -0
- data/spec/csv_decision/matchers/function_spec.rb +48 -28
- data/spec/csv_decision/matchers/numeric_spec.rb +4 -41
- data/spec/csv_decision/matchers/range_spec.rb +31 -61
- data/spec/csv_decision/matchers/symbol_spec.rb +65 -0
- data/spec/csv_decision/options_spec.rb +14 -2
- data/spec/csv_decision/parse_spec.rb +10 -0
- data/spec/csv_decision/table_spec.rb +112 -6
- data/spec/data/valid/simple_constants.csv +3 -3
- metadata +62 -7
- data/spec/csv_decision/simple_example_spec.rb +0 -75
- /data/spec/{csv_decision.rb → csv_decision_spec.rb} +0 -0
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
# See LICENSE and README.md for details.
|
|
6
6
|
module CSVDecision
|
|
7
7
|
# Methods to assign a matcher to data cells
|
|
8
|
-
|
|
8
|
+
class Matchers
|
|
9
9
|
# Match cell against a Ruby-like range
|
|
10
10
|
class Range < Matcher
|
|
11
11
|
# Range types are .. or ...
|
|
12
12
|
TYPE = '(\.\.\.|\.\.)'
|
|
13
|
+
private_constant :TYPE
|
|
13
14
|
|
|
14
15
|
def self.range_re(value)
|
|
15
16
|
Matchers.regexp(
|
|
@@ -19,10 +20,14 @@ module CSVDecision
|
|
|
19
20
|
private_class_method :range_re
|
|
20
21
|
|
|
21
22
|
NUMERIC_RANGE = range_re(Matchers::NUMERIC)
|
|
23
|
+
private_constant :NUMERIC_RANGE
|
|
22
24
|
|
|
23
25
|
# One or more alphanumeric characters
|
|
24
26
|
ALNUM = '[[:alnum:]][[:alnum:]]*'
|
|
27
|
+
private_constant :ALNUM
|
|
28
|
+
|
|
25
29
|
ALNUM_RANGE = range_re(ALNUM)
|
|
30
|
+
private_constant :ALNUM_RANGE
|
|
26
31
|
|
|
27
32
|
def self.convert(value, method)
|
|
28
33
|
method ? Matchers.send(method, value) : value
|
|
@@ -37,6 +42,7 @@ module CSVDecision
|
|
|
37
42
|
|
|
38
43
|
[negate, type == '...' ? min...max : min..max]
|
|
39
44
|
end
|
|
45
|
+
private_class_method :range
|
|
40
46
|
|
|
41
47
|
def self.numeric_range(negate, range)
|
|
42
48
|
return ->(value) { range.include?(Matchers.numeric(value)) } unless negate
|
|
@@ -50,24 +56,34 @@ module CSVDecision
|
|
|
50
56
|
end
|
|
51
57
|
private_class_method :alnum_range
|
|
52
58
|
|
|
53
|
-
def self.
|
|
59
|
+
def self.range_proc(match:, coerce: nil)
|
|
54
60
|
negate, range = range(match, coerce: coerce)
|
|
55
61
|
method = coerce ? :numeric_range : :alnum_range
|
|
56
62
|
function = Range.send(method, negate, range).freeze
|
|
57
63
|
Proc.with(type: :proc, function: function)
|
|
58
64
|
end
|
|
65
|
+
private_class_method :range_proc
|
|
59
66
|
|
|
60
|
-
|
|
67
|
+
# @param (see Matchers::Matcher#matches?)
|
|
68
|
+
# @return (see Matchers::Matcher#matches?)
|
|
69
|
+
def self.matches?(cell)
|
|
61
70
|
if (match = NUMERIC_RANGE.match(cell))
|
|
62
|
-
return
|
|
71
|
+
return range_proc(match: match, coerce: :to_numeric)
|
|
63
72
|
end
|
|
64
73
|
|
|
65
74
|
if (match = ALNUM_RANGE.match(cell))
|
|
66
|
-
return
|
|
75
|
+
return range_proc(match: match)
|
|
67
76
|
end
|
|
68
77
|
|
|
69
78
|
false
|
|
70
79
|
end
|
|
80
|
+
|
|
81
|
+
# Range expression - e.g., +0...10+ or +a..z+
|
|
82
|
+
# @param (see Matchers::Matcher#matches?)
|
|
83
|
+
# @return (see Matchers::Matcher#matches?)
|
|
84
|
+
def matches?(cell)
|
|
85
|
+
Range.matches?(cell)
|
|
86
|
+
end
|
|
71
87
|
end
|
|
72
88
|
end
|
|
73
89
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# CSV Decision: CSV based Ruby decision tables.
|
|
4
|
+
# Created December 2017 by Brett Vickers
|
|
5
|
+
# See LICENSE and README.md for details.
|
|
6
|
+
module CSVDecision
|
|
7
|
+
# Recognise expressions in table data cells.
|
|
8
|
+
class Matchers
|
|
9
|
+
# Match cell against a
|
|
10
|
+
# * cell constant - e.g., := true, = nil
|
|
11
|
+
# * symbolic expression - e.g., :column, > :column
|
|
12
|
+
class Symbol < Matcher
|
|
13
|
+
# @param (see Matchers::Matcher#matches?)
|
|
14
|
+
# @return (see Matchers::Matcher#matches?)
|
|
15
|
+
def matches?(cell)
|
|
16
|
+
CSVDecision::Symbol.matches?(cell)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -6,63 +6,85 @@ require 'values'
|
|
|
6
6
|
# Created December 2017 by Brett Vickers
|
|
7
7
|
# See LICENSE and README.md for details.
|
|
8
8
|
module CSVDecision
|
|
9
|
-
# Value object for a cell proc
|
|
9
|
+
# Value object for a cell proc.
|
|
10
10
|
Proc = Value.new(:type, :function)
|
|
11
11
|
|
|
12
|
-
# Methods to assign a matcher to data cells
|
|
13
|
-
|
|
14
|
-
# Negation sign
|
|
12
|
+
# Methods to assign a matcher to table data cells.
|
|
13
|
+
class Matchers
|
|
14
|
+
# Negation sign prefixed to ranges and functions.
|
|
15
15
|
NEGATE = '!'
|
|
16
16
|
|
|
17
|
-
#
|
|
17
|
+
# Cell constants and functions specified by prefixing the value with one of these 3 symbols
|
|
18
|
+
EQUALS = '==|:=|='
|
|
19
|
+
|
|
20
|
+
# All regular expressions used for matching are anchored inside their own
|
|
21
|
+
# non-capturing group.
|
|
18
22
|
#
|
|
19
|
-
# @param value [String]
|
|
20
|
-
# @return [Regexp]
|
|
23
|
+
# @param value [String] String used to form an anchored regular expression.
|
|
24
|
+
# @return [Regexp] Anchored, frozen regular expression.
|
|
21
25
|
def self.regexp(value)
|
|
22
|
-
Regexp.new("\\A(
|
|
26
|
+
Regexp.new("\\A(?:#{value})\\z").freeze
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
# Regular expression used to recognise a numeric string with or without a decimal point.
|
|
26
|
-
NUMERIC = '[-+]?\d*(?<decimal>\.?)\d
|
|
30
|
+
NUMERIC = '[-+]?\d*(?<decimal>\.?)\d*'
|
|
27
31
|
NUMERIC_RE = regexp(NUMERIC)
|
|
28
32
|
|
|
29
|
-
#
|
|
33
|
+
# @param value [Object] Value from the input hash.
|
|
34
|
+
# @return [Boolean] Value is an Integer or a BigDecimal.
|
|
35
|
+
def self.numeric?(value)
|
|
36
|
+
value.is_a?(Integer) || value.is_a?(BigDecimal)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Validate a numeric value and convert it to an Integer or BigDecimal if a valid numeric string.
|
|
30
40
|
#
|
|
31
41
|
# @param value [nil, String, Integer, BigDecimal]
|
|
32
42
|
# @return [nil, Integer, BigDecimal]
|
|
33
43
|
def self.numeric(value)
|
|
34
|
-
return value if
|
|
44
|
+
return value if numeric?(value)
|
|
35
45
|
return unless value.is_a?(String)
|
|
36
46
|
|
|
37
47
|
to_numeric(value)
|
|
38
48
|
end
|
|
39
49
|
|
|
40
|
-
#
|
|
50
|
+
# Convert a numeric string into an Integer or BigDecimal.
|
|
41
51
|
#
|
|
42
52
|
# @param value [String]
|
|
43
53
|
# @return [nil, Integer, BigDecimal]
|
|
44
54
|
def self.to_numeric(value)
|
|
45
55
|
return unless (match = NUMERIC_RE.match(value))
|
|
56
|
+
coerce_numeric(match, value)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.coerce_numeric(match, value)
|
|
46
60
|
return value.to_i if match['decimal'] == ''
|
|
47
|
-
BigDecimal
|
|
61
|
+
BigDecimal(value.chomp('.'))
|
|
48
62
|
end
|
|
63
|
+
private_class_method :coerce_numeric
|
|
49
64
|
|
|
50
65
|
# Parse the supplied input columns for the row supplied using an array of matchers.
|
|
51
66
|
#
|
|
52
|
-
# @param columns [Hash]
|
|
53
|
-
# @param matchers [Array]
|
|
54
|
-
# @param row [Array]
|
|
67
|
+
# @param columns [Hash{Integer=>Columns::Entry}] Input columns hash.
|
|
68
|
+
# @param matchers [Array<Matchers::Matcher>]
|
|
69
|
+
# @param row [Array<String>] Data row being parsed.
|
|
70
|
+
# @return [Array<(Array, ScanRow)>] Used to scan a table row against an input hash for matches.
|
|
55
71
|
def self.parse(columns:, matchers:, row:)
|
|
56
72
|
# Build an array of column indexes requiring simple constant matches,
|
|
57
73
|
# and a second array of columns requiring special matchers.
|
|
58
74
|
scan_row = ScanRow.new
|
|
59
75
|
|
|
60
|
-
|
|
61
|
-
scan_row.scan_columns(columns: columns, matchers: matchers, row: row)
|
|
76
|
+
row = scan_row.scan_columns(columns: columns, matchers: matchers, row: row)
|
|
62
77
|
|
|
63
|
-
scan_row
|
|
78
|
+
scan_row.freeze
|
|
79
|
+
|
|
80
|
+
[row, scan_row.freeze]
|
|
64
81
|
end
|
|
65
82
|
|
|
83
|
+
# Scan the table cell against all matches.
|
|
84
|
+
#
|
|
85
|
+
# @param matchers [Array<Matchers::Matcher>]
|
|
86
|
+
# @param cell [String]
|
|
87
|
+
# @return [false, Matchers::Proc]
|
|
66
88
|
def self.scan(matchers:, cell:)
|
|
67
89
|
matchers.each do |matcher|
|
|
68
90
|
proc = matcher.matches?(cell)
|
|
@@ -73,12 +95,55 @@ module CSVDecision
|
|
|
73
95
|
false
|
|
74
96
|
end
|
|
75
97
|
|
|
98
|
+
def self.ins_matchers(options)
|
|
99
|
+
options[:matchers].collect { |klass| klass.new(options) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.outs_matchers(matchers)
|
|
103
|
+
matchers.select { |obj| OUTS_MATCHERS.include?(obj.class) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Array<Matchers::Matcher>] Matchers for the input columns.
|
|
107
|
+
attr_reader :ins
|
|
108
|
+
|
|
109
|
+
# @return [Array<Matchers::Matcher>] Matchers for the output columns.
|
|
110
|
+
attr_reader :outs
|
|
111
|
+
|
|
112
|
+
# @param options (see CSVDecision.parse)
|
|
113
|
+
def initialize(options)
|
|
114
|
+
@ins = Matchers.ins_matchers(options)
|
|
115
|
+
@outs = Matchers.outs_matchers(@ins)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Parse the row's input columns using the input matchers.
|
|
119
|
+
#
|
|
120
|
+
# @param columns (see Matchers.parse)
|
|
121
|
+
# @param row (see Matchers.parse)
|
|
122
|
+
# @return (see Matchers.parse)
|
|
123
|
+
def parse_ins(columns:, row:)
|
|
124
|
+
Matchers.parse(columns: columns, matchers: @ins, row: row)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Parse the row's output columns using the output matchers.
|
|
128
|
+
#
|
|
129
|
+
# @param columns (see Matchers.parse)
|
|
130
|
+
# @param row (see Matchers.parse)
|
|
131
|
+
# @return (see Matchers.parse)
|
|
132
|
+
def parse_outs(columns:, row:)
|
|
133
|
+
Matchers.parse(columns: columns, matchers: @outs, row: row)
|
|
134
|
+
end
|
|
135
|
+
|
|
76
136
|
# @abstract Subclass and override {#matches?} to implement
|
|
77
137
|
# a custom Matcher class.
|
|
78
138
|
class Matcher
|
|
79
139
|
def initialize(_options = nil); end
|
|
80
140
|
|
|
81
|
-
|
|
141
|
+
# Determine if the input cell string is recognised by this Matcher.
|
|
142
|
+
#
|
|
143
|
+
# @param cell [String] Data row cell.
|
|
144
|
+
# @return [false, CSVDecision::Proc] Returns false if this cell is not a match; otherwise returns the
|
|
145
|
+
# +CSVDecision::Proc+ object indicating if this is a constant or some type of function.
|
|
146
|
+
def matches?(cell); end
|
|
82
147
|
end
|
|
83
148
|
end
|
|
84
149
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# CSV Decision: CSV based Ruby decision tables.
|
|
4
|
+
# Created December 2017 by Brett Vickers
|
|
5
|
+
# See LICENSE and README.md for details.
|
|
6
|
+
module CSVDecision
|
|
7
|
+
# Recognise Ruby-like numeric comparison expressions.
|
|
8
|
+
module Numeric
|
|
9
|
+
# For example: >= 100 or != 0
|
|
10
|
+
COMPARISON = /\A(?<comparator><=|>=|<|>|!=)\s*(?<value>\S.*)\z/
|
|
11
|
+
private_constant :COMPARISON
|
|
12
|
+
|
|
13
|
+
# Coerce the input value to a numeric representation before invoking the comparison.
|
|
14
|
+
# If the coercion fails, it will produce a nil value which always fails to match.
|
|
15
|
+
COMPARATORS = {
|
|
16
|
+
'>' => proc { |numeric_cell, value| Matchers.numeric(value)&.> numeric_cell },
|
|
17
|
+
'>=' => proc { |numeric_cell, value| Matchers.numeric(value)&.>= numeric_cell },
|
|
18
|
+
'<' => proc { |numeric_cell, value| Matchers.numeric(value)&.< numeric_cell },
|
|
19
|
+
'<=' => proc { |numeric_cell, value| Matchers.numeric(value)&.<= numeric_cell },
|
|
20
|
+
'!=' => proc { |numeric_cell, value| Matchers.numeric(value)&.!= numeric_cell }
|
|
21
|
+
}.freeze
|
|
22
|
+
private_constant :COMPARATORS
|
|
23
|
+
|
|
24
|
+
# @param (see Matchers::Matcher#matches?)
|
|
25
|
+
# @return (see Matchers::Matcher#matches?)
|
|
26
|
+
def self.matches?(cell)
|
|
27
|
+
match = COMPARISON.match(cell)
|
|
28
|
+
return false unless match
|
|
29
|
+
|
|
30
|
+
numeric_cell = Matchers.to_numeric(match['value'])
|
|
31
|
+
return false unless numeric_cell
|
|
32
|
+
|
|
33
|
+
comparator = match['comparator']
|
|
34
|
+
Proc.with(type: :proc,
|
|
35
|
+
function: COMPARATORS[comparator].curry[numeric_cell].freeze)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/csv_decision/options.rb
CHANGED
|
@@ -10,32 +10,41 @@ module CSVDecision
|
|
|
10
10
|
Matchers::Range,
|
|
11
11
|
Matchers::Numeric,
|
|
12
12
|
Matchers::Pattern,
|
|
13
|
-
Matchers::
|
|
13
|
+
Matchers::Constant,
|
|
14
|
+
Matchers::Symbol
|
|
15
|
+
# Matchers::Function
|
|
14
16
|
].freeze
|
|
15
17
|
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# These options may appear in the CSV file before the header row.
|
|
25
|
-
# Convert them to a normalized option key value pair.
|
|
26
|
-
CSV_OPTION_NAMES = {
|
|
27
|
-
first_match: [:first_match, true],
|
|
28
|
-
accumulate: [:first_match, false],
|
|
29
|
-
regexp_implicit: [:regexp_implicit, true],
|
|
30
|
-
text_only: [:text_only, true]
|
|
31
|
-
}.freeze
|
|
32
|
-
|
|
33
|
-
# Validate and normalize the options hash supplied.
|
|
18
|
+
# Subset of matchers that apply to output cells
|
|
19
|
+
OUTS_MATCHERS = [
|
|
20
|
+
Matchers::Constant
|
|
21
|
+
# Matchers::Function
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Validate and normalize the options values supplied.
|
|
34
25
|
module Options
|
|
26
|
+
# All valid CSVDecision::parse options with their default values.
|
|
27
|
+
VALID = {
|
|
28
|
+
first_match: true,
|
|
29
|
+
regexp_implicit: false,
|
|
30
|
+
text_only: false,
|
|
31
|
+
matchers: DEFAULT_MATCHERS
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
# These options may appear in the CSV file before the header row.
|
|
35
|
+
# They get converted to a normalized option key value pair.
|
|
36
|
+
CSV_NAMES = {
|
|
37
|
+
first_match: [:first_match, true],
|
|
38
|
+
accumulate: [:first_match, false],
|
|
39
|
+
regexp_implicit: [:regexp_implicit, true],
|
|
40
|
+
text_only: [:text_only, true]
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
35
43
|
# Validate options and supply default values for any options not explicitly set.
|
|
36
44
|
#
|
|
37
|
-
# @param options [Hash]
|
|
38
|
-
# @return [Hash]
|
|
45
|
+
# @param options [Hash] Input options hash supplied by the user.
|
|
46
|
+
# @return [Hash] Options hash filled in with all required values, defaulted if necessary.
|
|
47
|
+
# @raise [ArgumentError] For invalid option keys.
|
|
39
48
|
def self.normalize(options)
|
|
40
49
|
validate(options)
|
|
41
50
|
default(options)
|
|
@@ -43,9 +52,9 @@ module CSVDecision
|
|
|
43
52
|
|
|
44
53
|
# Read any options supplied in the CSV file placed before the header row.
|
|
45
54
|
#
|
|
46
|
-
# @param rows [Array<Array<String>>]
|
|
47
|
-
# @param options [Hash]
|
|
48
|
-
# @return [Hash]
|
|
55
|
+
# @param rows [Array<Array<String>>] Table data rows.
|
|
56
|
+
# @param options [Hash] Input options hash built so far.
|
|
57
|
+
# @return [Hash] Options hash overridden with any values found in the CSV file.
|
|
49
58
|
def self.from_csv(rows:, options:)
|
|
50
59
|
row = rows.first
|
|
51
60
|
return options if row.nil?
|
|
@@ -80,7 +89,7 @@ module CSVDecision
|
|
|
80
89
|
result[:matchers] = matchers(result)
|
|
81
90
|
|
|
82
91
|
# Supply any missing options with default values
|
|
83
|
-
|
|
92
|
+
Options::VALID.each_pair do |key, value|
|
|
84
93
|
next if result.key?(key)
|
|
85
94
|
result[key] = value
|
|
86
95
|
end
|
|
@@ -100,12 +109,12 @@ module CSVDecision
|
|
|
100
109
|
|
|
101
110
|
def self.option?(cell)
|
|
102
111
|
key = cell.downcase.to_sym
|
|
103
|
-
return
|
|
112
|
+
return Options::CSV_NAMES[key] if Options::CSV_NAMES.key?(key)
|
|
104
113
|
end
|
|
105
114
|
private_class_method :option?
|
|
106
115
|
|
|
107
116
|
def self.validate(options)
|
|
108
|
-
invalid_options = options.keys -
|
|
117
|
+
invalid_options = options.keys - Options::VALID.keys
|
|
109
118
|
|
|
110
119
|
return if invalid_options.empty?
|
|
111
120
|
|
data/lib/csv_decision/parse.rb
CHANGED
|
@@ -1,50 +1,71 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# CSV Decision: CSV based Ruby decision tables.
|
|
4
|
-
# Created December 2017
|
|
4
|
+
# Created December 2017.
|
|
5
|
+
# @author Brett Vickers.
|
|
5
6
|
# See LICENSE and README.md for details.
|
|
6
7
|
module CSVDecision
|
|
8
|
+
# All CSVDecision specific errors
|
|
7
9
|
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
# Error validating a cell when parsing input table data.
|
|
8
12
|
class CellValidationError < Error; end
|
|
13
|
+
|
|
14
|
+
# Table parsing error message enhanced to include the file being processed
|
|
9
15
|
class FileError < Error; end
|
|
10
16
|
|
|
11
17
|
# Builds a decision table from the input data - which may either be a file, CSV string
|
|
12
|
-
# or array of arrays.
|
|
18
|
+
# or an array of arrays.
|
|
19
|
+
#
|
|
20
|
+
# @example Simple Example
|
|
21
|
+
# If you have cloned the gem's git repo, then you can run:
|
|
22
|
+
# table = CSVDecision.parse(Pathname('spec/data/valid/simple_example.csv')) #=> CSVDecision::Table
|
|
23
|
+
# table.decide(topic: 'finance', region: 'Europe') #=> team_member: 'Donald'
|
|
24
|
+
#
|
|
25
|
+
# @param data [Pathname, File, Array<Array<String>>, String] input data given as
|
|
26
|
+
# a CSV file, array of arrays or CSV string.
|
|
27
|
+
# @param options [Hash] Options hash supplied by the user.
|
|
28
|
+
#
|
|
29
|
+
# @option options [Boolean] :first_match Stop scanning after find the first row match.
|
|
30
|
+
# @option options [Boolean] :regexp_implicit Make regular expressions implicit rather than requiring the
|
|
31
|
+
# comparator =~. (Use with care.)
|
|
32
|
+
# @option options [Boolean] :text_only All cells treated as simple strings by turning off all special matchers.
|
|
33
|
+
# @option options [Array<Matchers::Matcher>] :matchers May be used to control the inclusion and ordering of
|
|
34
|
+
# special matchers. (Advanced feature, use with care.)
|
|
35
|
+
#
|
|
36
|
+
# @return [CSVDecision::Table] Resulting decision table.
|
|
37
|
+
#
|
|
38
|
+
# @raise [CSVDecision::CellValidationError] Table parsing cell validation error.
|
|
39
|
+
# @raise [CSVDecision::FileError] Table parsing error for a named CSV file.
|
|
13
40
|
#
|
|
14
|
-
# @param data [Pathname, File, Array<Array<String>>, String] - input data given as
|
|
15
|
-
# a file, array of arrays or CSV string.
|
|
16
|
-
# @param options [Hash] - options hash supplied by the user
|
|
17
|
-
# * first_match: stop after finding the first match
|
|
18
|
-
# * regexp_implicit: Set to make regular expressions implicit rather than requiring
|
|
19
|
-
# the comparator =~
|
|
20
|
-
# * text_only: Set to make all cells be treated as simple strings by turning
|
|
21
|
-
# off all special matchers.
|
|
22
|
-
# * matchers May be used to control the inclusion and ordering of special
|
|
23
|
-
# matchers.
|
|
24
|
-
# @return [CSVDecision::Table] - resulting decision table
|
|
25
41
|
def self.parse(data, options = {})
|
|
26
|
-
Parse.table(
|
|
42
|
+
Parse.table(data: data, options: Options.normalize(options))
|
|
27
43
|
end
|
|
28
44
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# (see #parse)
|
|
45
|
+
# Methods to parse the decision table and return CSVDecision::Table object.
|
|
32
46
|
module Parse
|
|
33
|
-
|
|
47
|
+
# Parse the CSV file or input data and create a new decision table object.
|
|
48
|
+
#
|
|
49
|
+
# @param (see CSVDecision.parse)
|
|
50
|
+
# @return (see CSVDecision.parse)
|
|
51
|
+
def self.table(data:, options:)
|
|
34
52
|
table = CSVDecision::Table.new
|
|
35
53
|
|
|
36
54
|
# In most cases the decision table will be loaded from a CSV file.
|
|
37
|
-
table.file =
|
|
55
|
+
table.file = data if Data.input_file?(data)
|
|
56
|
+
|
|
57
|
+
parse_table(table: table, input: data, options: options)
|
|
38
58
|
|
|
39
|
-
|
|
59
|
+
table.freeze
|
|
40
60
|
rescue CSVDecision::Error => exp
|
|
41
61
|
raise_error(file: table.file, exception: exp)
|
|
42
62
|
end
|
|
43
63
|
|
|
44
64
|
def self.raise_error(file:, exception:)
|
|
45
65
|
raise exception unless file
|
|
46
|
-
|
|
47
|
-
raise CSVDecision::FileError,
|
|
66
|
+
|
|
67
|
+
raise CSVDecision::FileError,
|
|
68
|
+
"error processing CSV file #{file}\n#{exception.inspect}"
|
|
48
69
|
end
|
|
49
70
|
private_class_method :raise_error
|
|
50
71
|
|
|
@@ -59,34 +80,20 @@ module CSVDecision
|
|
|
59
80
|
# Parse the header row
|
|
60
81
|
table.columns = CSVDecision::Columns.new(table)
|
|
61
82
|
|
|
62
|
-
parse_data(table: table, matchers:
|
|
63
|
-
|
|
64
|
-
table.freeze
|
|
83
|
+
parse_data(table: table, matchers: Matchers.new(options))
|
|
65
84
|
end
|
|
66
85
|
private_class_method :parse_table
|
|
67
86
|
|
|
68
87
|
def self.parse_data(table:, matchers:)
|
|
69
88
|
table.rows.each_with_index do |row, index|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
table.scan_rows[index] = Matchers.parse(columns: table.columns.ins,
|
|
73
|
-
matchers: matchers,
|
|
74
|
-
row: row)
|
|
75
|
-
|
|
76
|
-
# parse_outputs(row, index)
|
|
89
|
+
row, table.scan_rows[index] = matchers.parse_ins(columns: table.columns.ins, row: row)
|
|
90
|
+
row, table.outs_rows[index] = matchers.parse_outs(columns: table.columns.outs, row: row)
|
|
77
91
|
|
|
78
92
|
row.freeze
|
|
79
|
-
table.scan_rows[index].freeze
|
|
80
93
|
end
|
|
81
94
|
|
|
82
95
|
table.columns.freeze
|
|
83
96
|
end
|
|
84
|
-
|
|
85
97
|
private_class_method :parse_data
|
|
86
|
-
|
|
87
|
-
def self.matchers(options)
|
|
88
|
-
options[:matchers].collect { |klass| klass.new(options) }
|
|
89
|
-
end
|
|
90
|
-
private_class_method :matchers
|
|
91
98
|
end
|
|
92
99
|
end
|
|
@@ -8,26 +8,38 @@ require 'values'
|
|
|
8
8
|
module CSVDecision
|
|
9
9
|
# Data row object indicating which columns are constants versus procs.
|
|
10
10
|
class ScanRow
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# @return [Array<Integer>] Column indices for simple constants.
|
|
12
|
+
attr_reader :constants
|
|
13
|
+
|
|
14
|
+
# @return [Array<Integer>] Column indices for Proc objects.
|
|
15
|
+
attr_reader :procs
|
|
13
16
|
|
|
14
17
|
def initialize
|
|
15
18
|
@constants = []
|
|
16
19
|
@procs = []
|
|
17
20
|
end
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
# Scan all the specified +columns+ (e.g., inputs) in the given +data+ row using the +matchers+
|
|
23
|
+
# array supplied.
|
|
24
|
+
#
|
|
25
|
+
# @param row [Array<String>] Data row.
|
|
26
|
+
# @param columns [Array<Columns::Entry>] Array of column dictionary entries.
|
|
27
|
+
# @param matchers [Array<Matchers::Matcher>] Array of table cell matchers.
|
|
28
|
+
# @return [Array] Data row with anything not a string constant replaced with a Proc or a non-string constant.
|
|
29
|
+
def scan_columns(row:, columns:, matchers:)
|
|
20
30
|
columns.each_pair do |col, column|
|
|
21
31
|
# Empty cell matches everything, and so never needs to be scanned
|
|
22
32
|
next if (cell = row[col]) == ''
|
|
23
33
|
|
|
24
34
|
# If the column is text only then no special matchers need be invoked
|
|
25
|
-
next constants << col if column.text_only
|
|
35
|
+
next @constants << col if column.text_only
|
|
26
36
|
|
|
27
37
|
# Need to scan the cell against all matchers, and possibly overwrite
|
|
28
38
|
# the cell contents with a proc.
|
|
29
39
|
row[col] = scan_cell(col: col, matchers: matchers, cell: cell)
|
|
30
40
|
end
|
|
41
|
+
|
|
42
|
+
row
|
|
31
43
|
end
|
|
32
44
|
|
|
33
45
|
def match_constants?(row:, scan_cols:)
|
|
@@ -59,18 +71,18 @@ module CSVDecision
|
|
|
59
71
|
return set(proc, col) if proc
|
|
60
72
|
|
|
61
73
|
# Just a plain constant
|
|
62
|
-
constants << col
|
|
74
|
+
@constants << col
|
|
63
75
|
cell
|
|
64
76
|
end
|
|
65
77
|
|
|
66
78
|
def set(proc, col)
|
|
67
79
|
# Unbox a constant
|
|
68
80
|
if proc.type == :constant
|
|
69
|
-
constants << col
|
|
81
|
+
@constants << col
|
|
70
82
|
return proc.function
|
|
71
83
|
end
|
|
72
84
|
|
|
73
|
-
procs << col
|
|
85
|
+
@procs << col
|
|
74
86
|
proc
|
|
75
87
|
end
|
|
76
88
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# CSV Decision: CSV based Ruby decision tables.
|
|
4
|
+
# Created December 2017 by Brett Vickers
|
|
5
|
+
# See LICENSE and README.md for details.
|
|
6
|
+
module CSVDecision
|
|
7
|
+
# Recognise column symbol comparison expressions in input column data cells - e.g., +> :column+ or +!= :column+.
|
|
8
|
+
module Symbol
|
|
9
|
+
# Symbol comparison - e.g., > :column or != :column
|
|
10
|
+
SYMBOL_COMPARE =
|
|
11
|
+
"(?<comparator>#{Matchers::EQUALS}|!=|<|>|>=|<=)?\\s*:(?<name>#{Header::COLUMN_NAME})"
|
|
12
|
+
private_constant :SYMBOL_COMPARE
|
|
13
|
+
|
|
14
|
+
# Symbol comparision regular expression.
|
|
15
|
+
SYMBOL_COMPARE_RE = Matchers.regexp(SYMBOL_COMPARE)
|
|
16
|
+
private_constant :SYMBOL_COMPARE_RE
|
|
17
|
+
|
|
18
|
+
# These procs compare one input hash value to another, and so do not coerce numeric values.
|
|
19
|
+
# Note that we do *not* check +hash.key?(symbol)+, so a +nil+ value will match a missing hash key.
|
|
20
|
+
EQUALITY = {
|
|
21
|
+
':=' => proc { |symbol, value, hash| value == hash[symbol] },
|
|
22
|
+
'!=' => proc { |symbol, value, hash| value != hash[symbol] }
|
|
23
|
+
}.freeze
|
|
24
|
+
private_constant :EQUALITY
|
|
25
|
+
|
|
26
|
+
def self.compare_proc(compare)
|
|
27
|
+
proc { |symbol, value, hash| compare?(lhs: value, compare: compare, rhs: hash[symbol]) }
|
|
28
|
+
end
|
|
29
|
+
private_class_method :compare_proc
|
|
30
|
+
|
|
31
|
+
COMPARE = {
|
|
32
|
+
# Equality and inequality - create a lambda proc by calling with the actual column name symbol
|
|
33
|
+
':=' => ->(symbol) { EQUALITY[':='].curry[symbol].freeze },
|
|
34
|
+
'=' => ->(symbol) { EQUALITY[':='].curry[symbol].freeze },
|
|
35
|
+
'==' => ->(symbol) { EQUALITY[':='].curry[symbol].freeze },
|
|
36
|
+
'!=' => ->(symbol) { EQUALITY['!='].curry[symbol].freeze },
|
|
37
|
+
|
|
38
|
+
# Comparisons - create a lambda proc by calling with the actual column name symbol.
|
|
39
|
+
'>' => ->(symbol) { compare_proc(:'>' ).curry[symbol].freeze },
|
|
40
|
+
'>=' => ->(symbol) { compare_proc(:'>=').curry[symbol].freeze },
|
|
41
|
+
'<' => ->(symbol) { compare_proc(:'<' ).curry[symbol].freeze },
|
|
42
|
+
'<=' => ->(symbol) { compare_proc(:'<=').curry[symbol].freeze },
|
|
43
|
+
}.freeze
|
|
44
|
+
private_constant :COMPARE
|
|
45
|
+
|
|
46
|
+
def self.compare?(lhs:, compare:, rhs:)
|
|
47
|
+
# Is the rhs a superclass of lhs, and does rhs respond to the compare method?
|
|
48
|
+
return lhs.public_send(compare, rhs) if lhs.is_a?(rhs.class) && rhs.respond_to?(compare)
|
|
49
|
+
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
private_class_method :compare?
|
|
53
|
+
|
|
54
|
+
# E.g., > :col, we get comparator: >, args: col
|
|
55
|
+
def self.comparison(comparator:, name:)
|
|
56
|
+
function = COMPARE[comparator]
|
|
57
|
+
Proc.with(type: :symbol, function: function[name])
|
|
58
|
+
end
|
|
59
|
+
private_class_method :comparison
|
|
60
|
+
|
|
61
|
+
# @param (see Matchers::Matcher#matches?)
|
|
62
|
+
# @return (see Matchers::Matcher#matches?)
|
|
63
|
+
def self.matches?(cell)
|
|
64
|
+
match = SYMBOL_COMPARE_RE.match(cell)
|
|
65
|
+
return false unless match
|
|
66
|
+
|
|
67
|
+
comparator = match['comparator'] || '='
|
|
68
|
+
name = match['name'].to_sym
|
|
69
|
+
|
|
70
|
+
comparison(comparator: comparator, name: name)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|