csv_decision 0.0.2 → 0.0.3
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/README.md +66 -18
- data/benchmark.rb +3 -2
- data/csv_decision.gemspec +1 -1
- data/lib/csv_decision.rb +4 -3
- data/lib/csv_decision/columns.rb +9 -5
- data/lib/csv_decision/header.rb +15 -11
- data/lib/csv_decision/matchers.rb +8 -55
- data/lib/csv_decision/matchers/function.rb +58 -0
- data/lib/csv_decision/matchers/numeric.rb +17 -8
- data/lib/csv_decision/matchers/pattern.rb +1 -1
- data/lib/csv_decision/matchers/range.rb +1 -4
- data/lib/csv_decision/options.rb +2 -1
- data/lib/csv_decision/scan_row.rb +13 -4
- data/lib/csv_decision/table.rb +28 -9
- data/spec/csv_decision/matchers/function_spec.rb +62 -0
- data/spec/csv_decision/matchers/numeric_spec.rb +25 -25
- data/spec/csv_decision/simple_example_spec.rb +32 -2
- data/spec/csv_decision/table_spec.rb +42 -6
- data/spec/data/valid/simple_constants.csv +5 -0
- metadata +6 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5dd656bc42ac4a7e5bc1b3ba49599f972179fbe9
|
4
|
+
data.tar.gz: 8f97105d30b993279dbadf380798fb4fbc36d4dd
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: d7c5bb91fcb110d0ef7a39def02096ae65e6cfc4eb1b9b17ad4e17407baa02004d5e835a7ce24df016b9b47683afebfeb37e632372447082e0229cb46174a6de
|
7
|
+
data.tar.gz: bc3313687f8a01cdd82efdd59190bea881a4707382a23275c20b192b2d048db747a3200163e0b773c31fe9a500269484c10d990d363c9f75956df956ff1379c8
|
data/README.md
CHANGED
@@ -1,33 +1,43 @@
|
|
1
1
|
CSV Decision
|
2
2
|
============
|
3
3
|
|
4
|
-
|
4
|
+
[](https://badge.fury.io/rb/csv_decision)
|
5
5
|
[](https://travis-ci.org/bpvickers/csv_decision)
|
6
6
|
[](https://coveralls.io/github/bpvickers/csv_decision?branch=master)
|
7
|
-
|
7
|
+
<a href="https://codeclimate.com/github/bpvickers/csv_decision/maintainability"><img src="https://api.codeclimate.com/v1/badges/466a6c52e8f6a3840967/maintainability" /></a>
|
8
8
|
|
9
|
-
|
9
|
+
### CSV based Ruby decision tables (a lightweight Hash transformation gem)
|
10
10
|
|
11
|
-
`csv_decision` is a
|
11
|
+
`csv_decision` is a RubyGem for CSV (comma separated values) based
|
12
12
|
[decision tables](https://en.wikipedia.org/wiki/Decision_table).
|
13
|
-
It accepts decision tables
|
14
|
-
|
13
|
+
It accepts decision tables implemented as a
|
14
|
+
[CSV file](https://en.wikipedia.org/wiki/Comma-separated_values),
|
15
|
+
which can then be used to execute complex conditional logic against an input hash,
|
16
|
+
producing a decision as an output hash.
|
15
17
|
|
16
18
|
### `csv_decision` features
|
17
|
-
*
|
18
|
-
* can use regular expressions,
|
19
|
-
|
20
|
-
*
|
21
|
-
|
19
|
+
* Fast decision-time performance (see `benchmark.rb`).
|
20
|
+
* In addition to simple string matching, can use regular expressions,
|
21
|
+
numeric comparisons and Ruby-style ranges.
|
22
|
+
* Accepts data as a file, CSV string or an array of arrays. (For safety all input data is
|
23
|
+
force encoded to UTF-8, and non-ascii strings are converted to empty strings.)
|
24
|
+
* All CSV cells are parsed for correctness, and helpful error messages generated for bad
|
25
|
+
inputs.
|
26
|
+
* Either returns the first matching row as a hash, or accumulates all matches as an
|
27
|
+
array of hashes.
|
22
28
|
|
23
29
|
### Planned features
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
* use
|
28
|
-
*
|
29
|
-
*
|
30
|
-
|
30
|
+
`csv_decision` is currently a work in progress, and will be enhanced to support
|
31
|
+
the following features:
|
32
|
+
* Input columns may be indexed for faster lookup performance.
|
33
|
+
* May use functions in the output columns to formulate the final decision.
|
34
|
+
* Input hash values may be conditionally defaulted using a constant or a function call
|
35
|
+
* Use of column symbol references or built-in guard functions in the input
|
36
|
+
columns for matching.
|
37
|
+
* Output columns may used interpolated strings referencing column symbols.
|
38
|
+
* May be extended with user-defined Ruby functions for tailored logic.
|
39
|
+
* Can use post-match guard conditions to filter the results of multi-row
|
40
|
+
decision output.
|
31
41
|
|
32
42
|
### Why use `csv_decision`?
|
33
43
|
|
@@ -50,6 +60,11 @@ complex conditional logic against an input hash, producing a decision as an outp
|
|
50
60
|
gem 'csv_decision', '~> 0.0.1'
|
51
61
|
```
|
52
62
|
|
63
|
+
or simply
|
64
|
+
```bash
|
65
|
+
gem install csv_decision
|
66
|
+
```
|
67
|
+
|
53
68
|
### Simple example
|
54
69
|
|
55
70
|
A decision table may be as simple or as complex as you like (although very complex
|
@@ -89,6 +104,7 @@ politics | | Henry
|
|
89
104
|
Now for some code.
|
90
105
|
|
91
106
|
```ruby
|
107
|
+
# Valid CSV string
|
92
108
|
data = <<~DATA
|
93
109
|
in :topic, in :region, out :team_member
|
94
110
|
sports, Europe, Alice
|
@@ -118,10 +134,42 @@ politics | | Henry
|
|
118
134
|
table = CSVDecision.parse(Pathname('spec/data/valid/simple_example.csv'))
|
119
135
|
```
|
120
136
|
|
137
|
+
We can also load this same table using the option: `first_match: false`.
|
138
|
+
|
139
|
+
```ruby
|
140
|
+
table = CSVDecision.parse(data, first_match: false)
|
141
|
+
table.decide(topic: 'finance', region: 'Europe') # returns team_member: %w[Donald Ernest Zach]
|
142
|
+
```
|
143
|
+
|
144
|
+
|
121
145
|
For more examples see `spec/csv_decision/table_spec.rb`.
|
122
146
|
Complete documentation of all table parameters is in the code - see
|
123
147
|
`lib/csv_decision/parse.rb` and `lib/csv_decision/table.rb`.
|
124
148
|
|
149
|
+
### Constants other than strings
|
150
|
+
Although `csv_decision` is string oriented, it does recognise other types of constant
|
151
|
+
present in the input hash. Specifically, the following classes are recognized:
|
152
|
+
`Integer`, `BigDecimal` and `NilClass`.
|
153
|
+
|
154
|
+
This is accomplished by prefixing the value with one of the operators `=`, `==` or `:=`.
|
155
|
+
(The syntax is intentionally lax.)
|
156
|
+
|
157
|
+
For example:
|
158
|
+
```ruby
|
159
|
+
data = <<~DATA
|
160
|
+
in :constant, out :type
|
161
|
+
:=nil, NilClass
|
162
|
+
==false, FALSE
|
163
|
+
=true, TRUE
|
164
|
+
= 0, Zero
|
165
|
+
:=100.0, 100%
|
166
|
+
DATA
|
167
|
+
|
168
|
+
table = CSVDecision.parse(data)
|
169
|
+
table.decide(constant: nil) # returns type: 'NilClass'
|
170
|
+
table.decide(constant: 0) # returns type: 'Zero'
|
171
|
+
table.decide(constant: BigDecimal.new('100.0')) # returns type: '100%'
|
172
|
+
```
|
125
173
|
|
126
174
|
### Testing
|
127
175
|
|
data/benchmark.rb
CHANGED
@@ -19,7 +19,8 @@ benchmarks = [
|
|
19
19
|
data: 'simple_example.csv',
|
20
20
|
input: { 'topic' => 'culture', 'region' => 'America' },
|
21
21
|
# Expected results for first_match and accumulate
|
22
|
-
first_match: { 'team_member' => 'Zach' }
|
22
|
+
first_match: { 'team_member' => 'Zach' },
|
23
|
+
accumulate: { 'team_member' => 'Zach' }
|
23
24
|
}
|
24
25
|
].deep_freeze
|
25
26
|
|
@@ -31,7 +32,7 @@ puts '=' * tag_width
|
|
31
32
|
puts ""
|
32
33
|
|
33
34
|
# First match true and false run options
|
34
|
-
[true].each do |first_match|
|
35
|
+
[true, false].each do |first_match|
|
35
36
|
puts "Table Decision Option: first_match: #{first_match}"
|
36
37
|
puts '-' * tag_width
|
37
38
|
|
data/csv_decision.gemspec
CHANGED
@@ -5,7 +5,7 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
5
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
7
|
spec.name = 'csv_decision'
|
8
|
-
spec.version = '0.0.
|
8
|
+
spec.version = '0.0.3'
|
9
9
|
spec.authors = ['Brett Vickers']
|
10
10
|
spec.email = ['brett@phillips-vickers.com']
|
11
11
|
spec.description = 'CSV based Ruby decision tables.'
|
data/lib/csv_decision.rb
CHANGED
@@ -26,8 +26,9 @@ module CSVDecision
|
|
26
26
|
autoload :Table, 'csv_decision/table'
|
27
27
|
|
28
28
|
module Matchers
|
29
|
-
autoload :
|
30
|
-
autoload :
|
31
|
-
autoload :
|
29
|
+
autoload :Function, 'csv_decision/matchers/function'
|
30
|
+
autoload :Numeric, 'csv_decision/matchers/numeric'
|
31
|
+
autoload :Pattern, 'csv_decision/matchers/pattern'
|
32
|
+
autoload :Range, 'csv_decision/matchers/range'
|
32
33
|
end
|
33
34
|
end
|
data/lib/csv_decision/columns.rb
CHANGED
@@ -9,25 +9,29 @@ module CSVDecision
|
|
9
9
|
# Value object used for column dictionary entries
|
10
10
|
Entry = Struct.new(:name, :text_only)
|
11
11
|
|
12
|
-
# Value object used for columns with defaults
|
12
|
+
# Value object used for any columns with defaults
|
13
13
|
Default = Struct.new(:name, :function, :default_if)
|
14
14
|
|
15
15
|
# Dictionary of all data columns.
|
16
16
|
# # Note that the key of each hash is the header cell's array column index.
|
17
17
|
# Note that input and output columns can be interspersed and need not have unique names.
|
18
18
|
class Dictionary
|
19
|
+
# Input columns
|
19
20
|
attr_accessor :ins
|
21
|
+
|
22
|
+
# Output columns
|
20
23
|
attr_accessor :outs
|
24
|
+
|
25
|
+
# Input hash path - optional (planned feature)
|
21
26
|
attr_accessor :path
|
27
|
+
|
28
|
+
# Input columns with a default value (planned feature)
|
22
29
|
attr_accessor :defaults
|
23
30
|
|
24
31
|
def initialize
|
25
32
|
@ins = {}
|
26
33
|
@outs = {}
|
27
|
-
|
28
|
-
# Path for the input hash - optional
|
29
34
|
@path = {}
|
30
|
-
# Hash of columns that require defaults to be set
|
31
35
|
@defaults = {}
|
32
36
|
end
|
33
37
|
end
|
@@ -57,7 +61,7 @@ module CSVDecision
|
|
57
61
|
|
58
62
|
def initialize(table)
|
59
63
|
# If a column does not have a valid header cell, then it's empty of data.
|
60
|
-
# Return the stripped header row,
|
64
|
+
# Return the stripped header row, and remove it from the data array.
|
61
65
|
row = Header.strip_empty_columns(rows: table.rows)
|
62
66
|
|
63
67
|
# Build a dictionary of all valid data columns from the header row.
|
data/lib/csv_decision/header.rb
CHANGED
@@ -17,12 +17,13 @@ module CSVDecision
|
|
17
17
|
|
18
18
|
# More lenient than a Ruby method name -
|
19
19
|
# any spaces will have been replaced with underscores
|
20
|
-
COLUMN_NAME =
|
20
|
+
COLUMN_NAME = "\\w[\\w:/!?]*"
|
21
|
+
COLUMN_NAME_RE = Matchers.regexp(COLUMN_NAME)
|
21
22
|
|
22
23
|
# Does this row contain a recognisable header cell?
|
23
24
|
#
|
24
|
-
# @param row [Array<String>]
|
25
|
-
# @return [true
|
25
|
+
# @param row [Array<String>] header row
|
26
|
+
# @return [Boolean] true if the row looks like a header
|
26
27
|
def self.row?(row)
|
27
28
|
row.find { |cell| cell.match(COLUMN_TYPE) }
|
28
29
|
end
|
@@ -34,9 +35,9 @@ module CSVDecision
|
|
34
35
|
# header row.
|
35
36
|
def self.strip_empty_columns(rows:)
|
36
37
|
empty_cols = empty_columns?(row: rows.first)
|
37
|
-
Data.strip_columns(data: rows, empty_columns: empty_cols)
|
38
|
+
Data.strip_columns(data: rows, empty_columns: empty_cols) if empty_cols
|
38
39
|
|
39
|
-
# Remove
|
40
|
+
# Remove header row from the data array.
|
40
41
|
rows.shift
|
41
42
|
end
|
42
43
|
|
@@ -54,7 +55,7 @@ module CSVDecision
|
|
54
55
|
dictionary
|
55
56
|
end
|
56
57
|
|
57
|
-
def self.
|
58
|
+
def self.validate_header_column(cell:)
|
58
59
|
match = COLUMN_TYPE.match(cell)
|
59
60
|
raise CellValidationError, 'column name is not well formed' unless match
|
60
61
|
|
@@ -66,14 +67,14 @@ module CSVDecision
|
|
66
67
|
raise CellValidationError,
|
67
68
|
"header column '#{cell}' is not valid as the #{exp.message}"
|
68
69
|
end
|
69
|
-
private_class_method :
|
70
|
+
private_class_method :validate_header_column
|
70
71
|
|
71
72
|
# Array of all empty column indices.
|
72
73
|
def self.empty_columns?(row:)
|
73
74
|
result = []
|
74
75
|
row&.each_with_index { |cell, index| result << index if cell == '' }
|
75
76
|
|
76
|
-
result
|
77
|
+
result.empty? ? false : result
|
77
78
|
end
|
78
79
|
private_class_method :empty_columns?
|
79
80
|
|
@@ -83,14 +84,16 @@ module CSVDecision
|
|
83
84
|
|
84
85
|
raise CellValidationError, 'column name is missing'
|
85
86
|
end
|
87
|
+
private_class_method :column_name
|
86
88
|
|
87
89
|
def self.format_column_name(name)
|
88
90
|
column_name = name.strip.tr("\s", '_')
|
89
91
|
|
90
|
-
return column_name.to_sym if
|
92
|
+
return column_name.to_sym if COLUMN_NAME_RE.match(column_name)
|
91
93
|
|
92
94
|
raise CellValidationError, "column name '#{name}' contains invalid characters"
|
93
95
|
end
|
96
|
+
private_class_method :format_column_name
|
94
97
|
|
95
98
|
# Returns the normalized column type, along with an indication if
|
96
99
|
# the column is text only
|
@@ -110,11 +113,12 @@ module CSVDecision
|
|
110
113
|
[type, nil]
|
111
114
|
end
|
112
115
|
end
|
116
|
+
private_class_method :column_type
|
113
117
|
|
114
118
|
def self.parse_cell(cell:, index:, dictionary:)
|
115
|
-
column_type, column_name =
|
119
|
+
column_type, column_name = validate_header_column(cell: cell)
|
116
120
|
|
117
|
-
type, text_only =
|
121
|
+
type, text_only = column_type(column_type)
|
118
122
|
|
119
123
|
dictionary_entry(dictionary: dictionary,
|
120
124
|
type: type,
|
@@ -9,61 +9,6 @@ module CSVDecision
|
|
9
9
|
# Value object for a cell proc
|
10
10
|
Proc = Value.new(:type, :function)
|
11
11
|
|
12
|
-
# Value object for a data row indicating which columns are constants versus procs.
|
13
|
-
# ScanRow = Struct.new(:constants, :procs) do
|
14
|
-
# def scan_columns(columns:, matchers:, row:)
|
15
|
-
# columns.each_pair do |col, column|
|
16
|
-
# # Empty cell matches everything, and so never needs to be scanned
|
17
|
-
# next if row[col] == ''
|
18
|
-
#
|
19
|
-
# # If the column is text only then no special matchers need be invoked
|
20
|
-
# next constants << col if column.text_only
|
21
|
-
#
|
22
|
-
# # Need to scan the cell against all matchers
|
23
|
-
# row[col] = scan_cell(col: col, matchers: matchers, cell: row[col])
|
24
|
-
# end
|
25
|
-
# end
|
26
|
-
#
|
27
|
-
# def match_constants?(row:, scan_cols:)
|
28
|
-
# constants.each do |col|
|
29
|
-
# value = scan_cols.fetch(col, [])
|
30
|
-
# # This only happens if the column is indexed
|
31
|
-
# next if value == []
|
32
|
-
# return false unless row[col] == value
|
33
|
-
# end
|
34
|
-
#
|
35
|
-
# true
|
36
|
-
# end
|
37
|
-
#
|
38
|
-
# def match_procs?(row:, input:)
|
39
|
-
# hash = input[:hash]
|
40
|
-
# scan_cols = input[:scan_cols]
|
41
|
-
#
|
42
|
-
# procs.each do |col|
|
43
|
-
# return false unless Decide.eval_matcher(proc: row[col],
|
44
|
-
# value: scan_cols[col],
|
45
|
-
# hash: hash)
|
46
|
-
# end
|
47
|
-
#
|
48
|
-
# true
|
49
|
-
# end
|
50
|
-
#
|
51
|
-
# private
|
52
|
-
#
|
53
|
-
# def scan_cell(col:, matchers:, cell:)
|
54
|
-
# # Scan the cell against all the matchers
|
55
|
-
# proc = Matchers.scan(matchers: matchers, cell: cell)
|
56
|
-
#
|
57
|
-
# if proc
|
58
|
-
# procs << col
|
59
|
-
# return proc
|
60
|
-
# end
|
61
|
-
#
|
62
|
-
# constants << col
|
63
|
-
# cell
|
64
|
-
# end
|
65
|
-
# end
|
66
|
-
|
67
12
|
# Methods to assign a matcher to data cells
|
68
13
|
module Matchers
|
69
14
|
# Negation sign for ranges and functions
|
@@ -127,5 +72,13 @@ module CSVDecision
|
|
127
72
|
# Must be a simple constant
|
128
73
|
false
|
129
74
|
end
|
75
|
+
|
76
|
+
# @abstract Subclass and override {#matches?} to implement
|
77
|
+
# a custom Matcher class.
|
78
|
+
class Matcher
|
79
|
+
def initialize(_options = nil); end
|
80
|
+
|
81
|
+
def matches?(_cell); end
|
82
|
+
end
|
130
83
|
end
|
131
84
|
end
|
@@ -0,0 +1,58 @@
|
|
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
|
+
# Methods to assign a matcher to data cells
|
8
|
+
module Matchers
|
9
|
+
# rubocop: disable Lint/BooleanSymbol
|
10
|
+
NON_NUMERIC_CONSTANTS = {
|
11
|
+
true: true,
|
12
|
+
false: false,
|
13
|
+
nil: nil
|
14
|
+
}.freeze
|
15
|
+
# rubocop: enable Lint/BooleanSymbol
|
16
|
+
|
17
|
+
def self.input_cell_constant?(match)
|
18
|
+
return false unless CELL_CONSTANT.member?(match['operator'])
|
19
|
+
return false unless match['args'] == ''
|
20
|
+
return false unless match['negate'] == ''
|
21
|
+
|
22
|
+
name = match['name'].to_sym
|
23
|
+
return false unless NON_NUMERIC_CONSTANTS.key?(name)
|
24
|
+
|
25
|
+
Proc.with(type: :constant, function: NON_NUMERIC_CONSTANTS[name])
|
26
|
+
end
|
27
|
+
|
28
|
+
# Match cell against a function call or symbolic expression.
|
29
|
+
class Function < Matcher
|
30
|
+
# Looks like a function call or symbol expressions, e.g.,
|
31
|
+
# == true
|
32
|
+
# := function(arg: symbol)
|
33
|
+
# == :column_name
|
34
|
+
FUNCTION_CALL =
|
35
|
+
"(?<operator>=|:=|==|<|>|!=|>=|<=|:|!\\s*:)\s*(?<negate>!?)\\s*(?<name>#{Header::COLUMN_NAME}|:)(?<args>.*)"
|
36
|
+
FUNCTION_RE = Matchers.regexp(FUNCTION_CALL)
|
37
|
+
|
38
|
+
# COMPARATORS = {
|
39
|
+
# '>' => proc { |numeric_cell, value| Matchers.numeric(value) &.> numeric_cell },
|
40
|
+
# '>=' => proc { |numeric_cell, value| Matchers.numeric(value) &.>= numeric_cell },
|
41
|
+
# '<' => proc { |numeric_cell, value| Matchers.numeric(value) &.< numeric_cell },
|
42
|
+
# '<=' => proc { |numeric_cell, value| Matchers.numeric(value) &.<= numeric_cell },
|
43
|
+
# '!=' => proc { |numeric_cell, value| Matchers.numeric(value) &.!= numeric_cell }
|
44
|
+
# }.freeze
|
45
|
+
|
46
|
+
def matches?(cell)
|
47
|
+
match = FUNCTION_RE.match(cell)
|
48
|
+
return false unless match
|
49
|
+
|
50
|
+
# Check if the guard condition is a cell constant
|
51
|
+
proc = Matchers.input_cell_constant?(match)
|
52
|
+
return proc if proc
|
53
|
+
|
54
|
+
false
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -6,11 +6,16 @@
|
|
6
6
|
module CSVDecision
|
7
7
|
# Methods to assign a matcher to data cells
|
8
8
|
module Matchers
|
9
|
-
#
|
10
|
-
|
11
|
-
# Range types are .. or ...
|
12
|
-
COMPARISON = /\A(?<comparator><=|>=|<|>|!=)\s*(?<value>\S.*)\z/
|
9
|
+
# Cell constant specified by prefixing the value with these symbols
|
10
|
+
CELL_CONSTANT = Set.new(%w[== := =]).freeze
|
13
11
|
|
12
|
+
# Match cell against a Ruby-like numeric comparison or a numeric constant
|
13
|
+
class Numeric < Matcher
|
14
|
+
# For example: >= 100 or != 0
|
15
|
+
COMPARISON = /\A(?<comparator><=|>=|<|>|!=|:=|==|=)\s*(?<value>\S.*)\z/
|
16
|
+
|
17
|
+
# Coerce the input value to a numeric representation before invoking the comparison.
|
18
|
+
# If the coercion fails, it will produce a nil value which always fails to match.
|
14
19
|
COMPARATORS = {
|
15
20
|
'>' => proc { |numeric_cell, value| Matchers.numeric(value) &.> numeric_cell },
|
16
21
|
'>=' => proc { |numeric_cell, value| Matchers.numeric(value) &.>= numeric_cell },
|
@@ -26,12 +31,16 @@ module CSVDecision
|
|
26
31
|
numeric_cell = Matchers.numeric(match['value'])
|
27
32
|
return false unless numeric_cell
|
28
33
|
|
34
|
+
comparator = match['comparator']
|
35
|
+
|
36
|
+
# If the comparator is assignment/equality, then just treat as a simple constant
|
37
|
+
if CELL_CONSTANT.member?(comparator)
|
38
|
+
return Proc.with(type: :constant, function: numeric_cell)
|
39
|
+
end
|
40
|
+
|
29
41
|
Proc.with(type: :proc,
|
30
|
-
function: COMPARATORS[
|
42
|
+
function: COMPARATORS[comparator].curry[numeric_cell])
|
31
43
|
end
|
32
|
-
|
33
|
-
# This matcher does not need access to the options hash
|
34
|
-
def initialize(_options = nil); end
|
35
44
|
end
|
36
45
|
end
|
37
46
|
end
|
@@ -7,7 +7,7 @@ module CSVDecision
|
|
7
7
|
# Methods to assign a matcher to data cells
|
8
8
|
module Matchers
|
9
9
|
# Match cell against a regular expression pattern
|
10
|
-
class Pattern
|
10
|
+
class Pattern < Matcher
|
11
11
|
EXPLICIT_COMPARISON = /\A(?<comparator>=~|!~|!=)\s*(?<value>\S.*)\z/
|
12
12
|
IMPLICIT_COMPARISON = /\A(?<comparator>=~|!~|!=)?\s*(?<value>\S.*)\z/
|
13
13
|
|
@@ -7,7 +7,7 @@ module CSVDecision
|
|
7
7
|
# Methods to assign a matcher to data cells
|
8
8
|
module Matchers
|
9
9
|
# Match cell against a Ruby-like range
|
10
|
-
class Range
|
10
|
+
class Range < Matcher
|
11
11
|
# Range types are .. or ...
|
12
12
|
TYPE = '(\.\.\.|\.\.)'
|
13
13
|
|
@@ -68,9 +68,6 @@ module CSVDecision
|
|
68
68
|
|
69
69
|
false
|
70
70
|
end
|
71
|
-
|
72
|
-
# This matcher does not need access to the options hash
|
73
|
-
def initialize(_options = nil); end
|
74
71
|
end
|
75
72
|
end
|
76
73
|
end
|
data/lib/csv_decision/options.rb
CHANGED
@@ -56,13 +56,22 @@ module CSVDecision
|
|
56
56
|
# Scan the cell against all the matchers
|
57
57
|
proc = Matchers.scan(matchers: matchers, cell: cell)
|
58
58
|
|
59
|
-
if proc
|
60
|
-
procs << col
|
61
|
-
return proc
|
62
|
-
end
|
59
|
+
return set(proc, col) if proc
|
63
60
|
|
61
|
+
# Just a plain constant
|
64
62
|
constants << col
|
65
63
|
cell
|
66
64
|
end
|
65
|
+
|
66
|
+
def set(proc, col)
|
67
|
+
# Unbox a constant
|
68
|
+
if proc.type == :constant
|
69
|
+
constants << col
|
70
|
+
return proc.function
|
71
|
+
end
|
72
|
+
|
73
|
+
procs << col
|
74
|
+
proc
|
75
|
+
end
|
67
76
|
end
|
68
77
|
end
|
data/lib/csv_decision/table.rb
CHANGED
@@ -4,34 +4,53 @@
|
|
4
4
|
# Created December 2017 by Brett Vickers
|
5
5
|
# See LICENSE and README.md for details.
|
6
6
|
module CSVDecision
|
7
|
-
# Decision Table that accepts input hashes and makes
|
7
|
+
# Decision Table that accepts input hashes and makes decisions
|
8
8
|
class Table
|
9
|
+
# CSVDecision::Columns object - dictionary of all input and output columns
|
9
10
|
attr_accessor :columns
|
11
|
+
|
12
|
+
# File path name if decision table loaded from a CSV file
|
10
13
|
attr_accessor :file
|
14
|
+
|
15
|
+
# All options used to parse the table
|
11
16
|
attr_accessor :options
|
17
|
+
|
18
|
+
# Set if the table has any output functions (planned feature)
|
12
19
|
attr_accessor :outs_functions
|
20
|
+
|
21
|
+
# Data rows - an array of arrays
|
13
22
|
attr_accessor :rows
|
23
|
+
|
24
|
+
# Array of CSVDecision::ScanRow objects used to implement matching logic
|
14
25
|
attr_accessor :scan_rows
|
26
|
+
|
27
|
+
# Any array of CSVDecision::Table pre-loaded tables passed to this decision table
|
28
|
+
# at load time. Used to allow this decision table to lookup values in other
|
29
|
+
# decision tables. (Planned feature.)
|
15
30
|
attr_reader :tables
|
16
31
|
|
17
32
|
# Main public method for making decisions.
|
33
|
+
#
|
18
34
|
# @param input [Hash] - input hash (keys may or may not be symbolized)
|
19
|
-
# @return [Hash]
|
35
|
+
# @return [Hash{Symbol => Object, Array<Object>}] decision
|
20
36
|
def decide(input)
|
21
37
|
Decide.decide(table: self, input: input, symbolize_keys: true).result
|
22
38
|
end
|
23
39
|
|
24
|
-
# Unsafe version of decide - will mutate the hash if set:
|
25
|
-
# is used.
|
26
|
-
#
|
27
|
-
# @
|
40
|
+
# Unsafe version of decide - will mutate the hash if set: column type
|
41
|
+
# is used (planned feature).
|
42
|
+
#
|
43
|
+
# @param input [Hash{Symbol => Object}] - input hash (all keys must already be symbolized)
|
44
|
+
# @return [Hash{Symbol => Object, Array<Object>}]
|
28
45
|
def decide!(input)
|
29
46
|
Decide.decide(table: self, input: input, symbolize_keys: false).result
|
30
47
|
end
|
31
48
|
|
32
|
-
# Iterate through all data rows of the decision table
|
33
|
-
#
|
34
|
-
#
|
49
|
+
# Iterate through all data rows of the decision table, with an optional
|
50
|
+
# first and last row index given.
|
51
|
+
#
|
52
|
+
# @param first [Integer] start row
|
53
|
+
# @param last [Integer, nil] last row
|
35
54
|
def each(first = 0, last = @rows.count - 1)
|
36
55
|
index = first
|
37
56
|
while index <= (last || first)
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../../lib/csv_decision'
|
4
|
+
|
5
|
+
describe CSVDecision::Matchers::Function do
|
6
|
+
subject { described_class.new }
|
7
|
+
|
8
|
+
describe '#new' do
|
9
|
+
it { is_expected.to be_a CSVDecision::Matchers::Function }
|
10
|
+
it { is_expected.to be_a CSVDecision::Matchers::Matcher }
|
11
|
+
it { is_expected.to respond_to(:matches?).with(1).argument }
|
12
|
+
end
|
13
|
+
|
14
|
+
context 'cell value recognition' do
|
15
|
+
cells = {
|
16
|
+
':= nil' => { operator: ':=', value: 'nil' },
|
17
|
+
'== nil' => { operator: '==', value: 'nil' },
|
18
|
+
'= nil' => { operator: '=', value: 'nil' },
|
19
|
+
'==true' => { operator: '==', value: 'true' },
|
20
|
+
':=false' => { operator: ':=', value: 'false' },
|
21
|
+
}
|
22
|
+
cells.each_pair do |cell, expected|
|
23
|
+
it "recognises #{cell} as a constant" do
|
24
|
+
match = described_class::FUNCTION_RE.match(cell)
|
25
|
+
expect(match['operator']).to eq expected[:operator]
|
26
|
+
expect(match['name']).to eq expected[:value]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
describe '#matches?' do
|
32
|
+
matcher = described_class.new
|
33
|
+
|
34
|
+
context 'constant matches value' do
|
35
|
+
data = [
|
36
|
+
['= nil', nil],
|
37
|
+
[':= false', false],
|
38
|
+
['==true', true]
|
39
|
+
]
|
40
|
+
|
41
|
+
data.each do |cell, value|
|
42
|
+
it "comparision #{cell} matches #{value}" do
|
43
|
+
proc = matcher.matches?(cell)
|
44
|
+
expect(proc).to be_a(CSVDecision::Proc)
|
45
|
+
expect(proc.type).to eq :constant
|
46
|
+
expect(proc.function).to eq value
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
|
52
|
+
context 'does not match a function constant' do
|
53
|
+
data = ['1', ':column', ':= 1.1', ':= abc', 'abc', 'abc.*def', '-1..1', '0...3']
|
54
|
+
|
55
|
+
data.each do |cell|
|
56
|
+
it "cell #{cell} is not a comparision}" do
|
57
|
+
expect(matcher.matches?(cell)).to eq false
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -11,14 +11,16 @@ describe CSVDecision::Matchers::Numeric do
|
|
11
11
|
end
|
12
12
|
|
13
13
|
context 'cell value recognition' do
|
14
|
-
|
14
|
+
cells = {
|
15
15
|
'> -1' => { comparator: '>', value: '-1' },
|
16
16
|
'>= 10.0' => { comparator: '>=', value: '10.0' },
|
17
17
|
'< .0' => { comparator: '<', value: '.0' },
|
18
18
|
'<= +1' => { comparator: '<=', value: '+1' },
|
19
|
-
'!=
|
19
|
+
'!=0.0' => { comparator: '!=', value: '0.0' },
|
20
|
+
':= 0.0' => { comparator: ':=', value: '0.0' },
|
21
|
+
'= 1.0' => { comparator: '=', value: '1.0' }
|
20
22
|
}
|
21
|
-
|
23
|
+
cells.each_pair do |cell, expected|
|
22
24
|
it "recognises #{cell} as a comparision" do
|
23
25
|
match = described_class::COMPARISON.match(cell)
|
24
26
|
expect(match['comparator']).to eq expected[:comparator]
|
@@ -35,6 +37,7 @@ describe CSVDecision::Matchers::Numeric do
|
|
35
37
|
['< 1', 0],
|
36
38
|
['< 1', '0'],
|
37
39
|
['> 1', 5],
|
40
|
+
['!= 1', 0],
|
38
41
|
['> 1', '5'],
|
39
42
|
['>= 1.1', BigDecimal.new('1.1')],
|
40
43
|
['<=-1.1', BigDecimal.new('-12')]
|
@@ -49,28 +52,25 @@ describe CSVDecision::Matchers::Numeric do
|
|
49
52
|
end
|
50
53
|
end
|
51
54
|
end
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
# end
|
72
|
-
# end
|
73
|
-
#
|
55
|
+
|
56
|
+
context 'numeric constant' do
|
57
|
+
data = [
|
58
|
+
['== 1', 1],
|
59
|
+
[':= 0', 0],
|
60
|
+
['==1.1', BigDecimal.new('1.1')],
|
61
|
+
['=-1.2', BigDecimal.new('-1.2')]
|
62
|
+
]
|
63
|
+
|
64
|
+
data.each do |cell, value|
|
65
|
+
it "constant expression #{cell} evaluates to #{value}" do
|
66
|
+
proc = matcher.matches?(cell)
|
67
|
+
expect(proc).to be_a(CSVDecision::Proc)
|
68
|
+
expect(proc.type).to eq :constant
|
69
|
+
expect(proc.function).to eq value
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
74
|
context 'does not match a numeric comparision' do
|
75
75
|
data = ['1', ':column', ':= nil', ':= true', 'abc', 'abc.*def', '-1..1', '0...3']
|
76
76
|
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
require_relative '../../lib/csv_decision'
|
4
4
|
|
5
|
-
context 'simple example' do
|
5
|
+
context 'simple example - strings-only' do
|
6
6
|
data = <<~DATA
|
7
7
|
in :topic, in :region, out :team_member
|
8
8
|
sports, Europe, Alice
|
@@ -29,7 +29,6 @@ context 'simple example' do
|
|
29
29
|
expect(result).to eq(team_member: 'Zach')
|
30
30
|
end
|
31
31
|
|
32
|
-
|
33
32
|
it 'makes correct decisions for CSV file' do
|
34
33
|
table = CSVDecision.parse(Pathname('spec/data/valid/simple_example.csv'))
|
35
34
|
|
@@ -43,3 +42,34 @@ context 'simple example' do
|
|
43
42
|
expect(result).to eq(team_member: 'Zach')
|
44
43
|
end
|
45
44
|
end
|
45
|
+
|
46
|
+
context 'simple example - constants' do
|
47
|
+
data = <<~DATA
|
48
|
+
in :constant, out :type
|
49
|
+
:=nil, NilClass
|
50
|
+
==false, FALSE
|
51
|
+
=true, TRUE
|
52
|
+
= 0, Zero
|
53
|
+
:=100.0, 100%
|
54
|
+
DATA
|
55
|
+
|
56
|
+
it 'makes correct decisions for CSV string' do
|
57
|
+
table = CSVDecision.parse(data)
|
58
|
+
|
59
|
+
result = table.decide(constant: nil)
|
60
|
+
expect(result).to eq(type: 'NilClass')
|
61
|
+
|
62
|
+
result = table.decide(constant: true)
|
63
|
+
expect(result).to eq(type: 'TRUE')
|
64
|
+
|
65
|
+
result = table.decide(constant: false)
|
66
|
+
expect(result).to eq(type: 'FALSE')
|
67
|
+
|
68
|
+
result = table.decide(constant: 0)
|
69
|
+
expect(result).to eq(type: 'Zero')
|
70
|
+
|
71
|
+
result = table.decide(constant: BigDecimal.new('100.0'))
|
72
|
+
expect(result).to eq(type: '100%')
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
@@ -56,6 +56,42 @@ describe CSVDecision::Table do
|
|
56
56
|
end
|
57
57
|
end
|
58
58
|
|
59
|
+
context 'makes correct decisions for simple non-string constants' do
|
60
|
+
examples = [
|
61
|
+
{
|
62
|
+
example: 'parses CSV file',
|
63
|
+
options: {},
|
64
|
+
data: Pathname(File.join(SPEC_DATA_VALID, 'simple_constants.csv'))
|
65
|
+
},
|
66
|
+
{
|
67
|
+
example: 'parses CSV string',
|
68
|
+
options: {},
|
69
|
+
data: <<~DATA
|
70
|
+
in :constant, out :type
|
71
|
+
:=nil, NilClass
|
72
|
+
= 0, Zero
|
73
|
+
:=100.0, 100%
|
74
|
+
, Unrecognized
|
75
|
+
DATA
|
76
|
+
},
|
77
|
+
]
|
78
|
+
examples.each do |test|
|
79
|
+
%i[decide decide!].each do |method|
|
80
|
+
it "#{method} correctly #{test[:example]} with first_match: true" do
|
81
|
+
options = test[:options].merge(first_match: true)
|
82
|
+
table = CSVDecision.parse(test[:data], options)
|
83
|
+
|
84
|
+
expect(table.send(method, constant: nil)).to eq(type: 'NilClass')
|
85
|
+
expect(table.send(method, constant: 0)).to eq(type: 'Zero')
|
86
|
+
expect(table.send(method, constant: BigDecimal.new('100.0'))).to eq(type: '100%')
|
87
|
+
expect(table.send(method, constant: ':=nil')).to eq(type: 'Unrecognized')
|
88
|
+
expect(table.send(method, constant: '= 0')).to eq(type: 'Unrecognized')
|
89
|
+
expect(table.send(method, constant: ':=100.0')).to eq(type: 'Unrecognized')
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
59
95
|
context 'makes correct decisions for a table with regexps and ranges' do
|
60
96
|
examples = [
|
61
97
|
{
|
@@ -66,7 +102,7 @@ describe CSVDecision::Table do
|
|
66
102
|
18..35, maniac, Adelsky
|
67
103
|
23..40, bad|maniac, Bronco
|
68
104
|
36..50, bad.*, Espadas
|
69
|
-
|
105
|
+
:= 100, , Thorsten
|
70
106
|
44..100, !~ maniac, Ojiisan
|
71
107
|
> 100, maniac.*, Chester
|
72
108
|
23..35, .*rich, Kerfelden
|
@@ -82,7 +118,7 @@ describe CSVDecision::Table do
|
|
82
118
|
18..35, maniac, Adelsky
|
83
119
|
23..40, =~ bad|maniac, Bronco
|
84
120
|
36..50, =~ bad.*, Espadas
|
85
|
-
|
121
|
+
==100, , Thorsten
|
86
122
|
44..100, !~ maniac, Ojiisan
|
87
123
|
> 100, =~ maniac.*, Chester
|
88
124
|
23..35, =~ .*rich, Kerfelden
|
@@ -98,7 +134,7 @@ describe CSVDecision::Table do
|
|
98
134
|
>= 18, <= 35, maniac, Adelsky
|
99
135
|
>= 23, <= 40, =~ bad|maniac, Bronco
|
100
136
|
>= 36, <= 50, =~ bad.*, Espadas
|
101
|
-
|
137
|
+
== 100, , , Thorsten
|
102
138
|
>= 44, <= 100, != maniac, Ojiisan
|
103
139
|
> 100, , =~ maniac.*, Chester
|
104
140
|
>= 23, <= 35, =~ .*rich, Kerfelden
|
@@ -113,12 +149,12 @@ describe CSVDecision::Table do
|
|
113
149
|
options = test[:options].merge(first_match: true)
|
114
150
|
table = CSVDecision.parse(test[:data], options)
|
115
151
|
|
116
|
-
expect(table.send(method, age:
|
152
|
+
expect(table.send(method, age: 100)).to eq(salesperson: 'Thorsten')
|
117
153
|
expect(table.send(method, age: 25, trait: 'very rich')).to eq(salesperson: 'Kerfelden')
|
118
154
|
expect(table.send(method, age: 25, trait: 'maniac')).to eq(salesperson: 'Adelsky')
|
119
155
|
expect(table.send(method, age: 44, trait: 'maniac')).to eq(salesperson: 'Korolev')
|
120
156
|
expect(table.send(method, age: 101, trait: 'maniacal')).to eq(salesperson: 'Chester')
|
121
|
-
expect(table.send(method, age:
|
157
|
+
expect(table.send(method, age: 44, trait: 'cheerful')).to eq(salesperson: 'Ojiisan')
|
122
158
|
expect(table.send(method, age: 49, trait: 'bad')).to eq(salesperson: 'Espadas')
|
123
159
|
expect(table.send(method, age: 40, trait: 'maniac')).to eq(salesperson: 'Bronco')
|
124
160
|
end
|
@@ -127,7 +163,7 @@ describe CSVDecision::Table do
|
|
127
163
|
options = test[:options].merge(first_match: false)
|
128
164
|
table = CSVDecision.parse(test[:data], options)
|
129
165
|
|
130
|
-
expect(table.send(method, age:
|
166
|
+
expect(table.send(method, age: 100))
|
131
167
|
.to eq(salesperson: %w[Thorsten Ojiisan])
|
132
168
|
expect(table.send(method, age: 25, trait: 'very rich'))
|
133
169
|
.to eq(salesperson: 'Kerfelden')
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: csv_decision
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Brett Vickers
|
@@ -205,6 +205,7 @@ files:
|
|
205
205
|
- lib/csv_decision/input.rb
|
206
206
|
- lib/csv_decision/load.rb
|
207
207
|
- lib/csv_decision/matchers.rb
|
208
|
+
- lib/csv_decision/matchers/function.rb
|
208
209
|
- lib/csv_decision/matchers/numeric.rb
|
209
210
|
- lib/csv_decision/matchers/pattern.rb
|
210
211
|
- lib/csv_decision/matchers/range.rb
|
@@ -218,6 +219,7 @@ files:
|
|
218
219
|
- spec/csv_decision/decision_spec.rb
|
219
220
|
- spec/csv_decision/input_spec.rb
|
220
221
|
- spec/csv_decision/load_spec.rb
|
222
|
+
- spec/csv_decision/matchers/function_spec.rb
|
221
223
|
- spec/csv_decision/matchers/numeric_spec.rb
|
222
224
|
- spec/csv_decision/matchers/pattern_spec.rb
|
223
225
|
- spec/csv_decision/matchers/range_spec.rb
|
@@ -232,6 +234,7 @@ files:
|
|
232
234
|
- spec/data/valid/empty.csv
|
233
235
|
- spec/data/valid/options_in_file1.csv
|
234
236
|
- spec/data/valid/options_in_file2.csv
|
237
|
+
- spec/data/valid/simple_constants.csv
|
235
238
|
- spec/data/valid/simple_example.csv
|
236
239
|
- spec/data/valid/valid.csv
|
237
240
|
- spec/spec_helper.rb
|
@@ -266,6 +269,7 @@ test_files:
|
|
266
269
|
- spec/csv_decision/decision_spec.rb
|
267
270
|
- spec/csv_decision/input_spec.rb
|
268
271
|
- spec/csv_decision/load_spec.rb
|
272
|
+
- spec/csv_decision/matchers/function_spec.rb
|
269
273
|
- spec/csv_decision/matchers/numeric_spec.rb
|
270
274
|
- spec/csv_decision/matchers/pattern_spec.rb
|
271
275
|
- spec/csv_decision/matchers/range_spec.rb
|
@@ -280,6 +284,7 @@ test_files:
|
|
280
284
|
- spec/data/valid/empty.csv
|
281
285
|
- spec/data/valid/options_in_file1.csv
|
282
286
|
- spec/data/valid/options_in_file2.csv
|
287
|
+
- spec/data/valid/simple_constants.csv
|
283
288
|
- spec/data/valid/simple_example.csv
|
284
289
|
- spec/data/valid/valid.csv
|
285
290
|
- spec/spec_helper.rb
|