csv_decision 0.0.2 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Gem Version](https://badge.fury.io/rb/csv_decision.svg)](https://badge.fury.io/rb/csv_decision)
|
5
5
|
[![Build Status](https://travis-ci.org/bpvickers/csv_decision.svg?branch=master)](https://travis-ci.org/bpvickers/csv_decision)
|
6
6
|
[![Coverage Status](https://coveralls.io/repos/github/bpvickers/csv_decision/badge.svg?branch=master)](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
|