csv_decision 0.0.1 → 0.0.2
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.coveralls.yml +2 -0
- data/.rubocop.yml +16 -4
- data/.travis.yml +10 -0
- data/CHANGELOG.md +2 -0
- data/Gemfile +3 -0
- data/LICENSE +21 -0
- data/README.md +133 -19
- data/benchmark.rb +143 -0
- data/csv_decision.gemspec +8 -6
- data/lib/csv_decision.rb +18 -4
- data/lib/csv_decision/columns.rb +69 -0
- data/lib/csv_decision/data.rb +31 -16
- data/lib/csv_decision/decide.rb +47 -0
- data/lib/csv_decision/decision.rb +105 -0
- data/lib/csv_decision/header.rb +143 -8
- data/lib/csv_decision/input.rb +49 -0
- data/lib/csv_decision/load.rb +31 -0
- data/lib/csv_decision/matchers.rb +131 -0
- data/lib/csv_decision/matchers/numeric.rb +37 -0
- data/lib/csv_decision/matchers/pattern.rb +76 -0
- data/lib/csv_decision/matchers/range.rb +76 -0
- data/lib/csv_decision/options.rb +80 -50
- data/lib/csv_decision/parse.rb +77 -23
- data/lib/csv_decision/scan_row.rb +68 -0
- data/lib/csv_decision/table.rb +34 -6
- data/spec/csv_decision/columns_spec.rb +86 -0
- data/spec/csv_decision/data_spec.rb +16 -3
- data/spec/csv_decision/decision_spec.rb +30 -0
- data/spec/csv_decision/input_spec.rb +54 -0
- data/spec/csv_decision/load_spec.rb +28 -0
- data/spec/csv_decision/matchers/numeric_spec.rb +84 -0
- data/spec/csv_decision/matchers/pattern_spec.rb +183 -0
- data/spec/csv_decision/matchers/range_spec.rb +132 -0
- data/spec/csv_decision/options_spec.rb +67 -0
- data/spec/csv_decision/parse_spec.rb +2 -3
- data/spec/csv_decision/simple_example_spec.rb +45 -0
- data/spec/csv_decision/table_spec.rb +151 -0
- data/spec/data/invalid/invalid_header1.csv +4 -0
- data/spec/data/invalid/invalid_header2.csv +4 -0
- data/spec/data/invalid/invalid_header3.csv +4 -0
- data/spec/data/invalid/invalid_header4.csv +4 -0
- data/spec/data/valid/options_in_file1.csv +5 -0
- data/spec/data/valid/options_in_file2.csv +5 -0
- data/spec/data/valid/simple_example.csv +10 -0
- data/spec/data/valid/valid.csv +4 -4
- data/spec/spec_helper.rb +6 -0
- metadata +89 -12
data/lib/csv_decision/parse.rb
CHANGED
@@ -1,38 +1,92 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative 'table'
|
4
|
-
require_relative 'data'
|
5
|
-
require_relative 'header'
|
6
|
-
require_relative 'options'
|
7
|
-
|
8
3
|
# CSV Decision: CSV based Ruby decision tables.
|
9
4
|
# Created December 2017 by Brett Vickers
|
10
5
|
# See LICENSE and README.md for details.
|
11
6
|
module CSVDecision
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
options = Options.new(options)
|
7
|
+
class Error < StandardError; end
|
8
|
+
class CellValidationError < Error; end
|
9
|
+
class FileError < Error; end
|
16
10
|
|
17
|
-
|
18
|
-
|
11
|
+
# Builds a decision table from the input data - which may either be a file, CSV string
|
12
|
+
# or array of arrays.
|
13
|
+
#
|
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
|
+
def self.parse(data, options = {})
|
26
|
+
Parse.table(input: data, options: Options.normalize(options))
|
27
|
+
end
|
19
28
|
|
20
|
-
|
29
|
+
# Parse the CSV file and create a new decision table object.
|
30
|
+
#
|
31
|
+
# (see #parse)
|
32
|
+
module Parse
|
33
|
+
def self.table(input:, options:)
|
34
|
+
table = CSVDecision::Table.new
|
21
35
|
|
22
|
-
|
23
|
-
|
36
|
+
# In most cases the decision table will be loaded from a CSV file.
|
37
|
+
table.file = input if Data.input_file?(input)
|
24
38
|
|
25
|
-
|
26
|
-
|
39
|
+
parse_table(table: table, input: input, options: options)
|
40
|
+
rescue CSVDecision::Error => exp
|
41
|
+
raise_error(file: table.file, exception: exp)
|
42
|
+
end
|
27
43
|
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
44
|
+
def self.raise_error(file:, exception:)
|
45
|
+
raise exception unless file
|
46
|
+
message = "error processing CSV file #{table.file}\n#{exception.inspect}"
|
47
|
+
raise CSVDecision::FileError, message
|
48
|
+
end
|
49
|
+
private_class_method :raise_error
|
50
|
+
|
51
|
+
def self.parse_table(table:, input:, options:)
|
52
|
+
# Parse input data into an array of arrays
|
53
|
+
table.rows = Data.to_array(data: input)
|
54
|
+
|
55
|
+
# Pick up any options specified in the CSV file before the header row.
|
56
|
+
# These override any options passed as parameters to the parse method.
|
57
|
+
table.options = Options.from_csv(rows: table.rows, options: options).freeze
|
58
|
+
|
59
|
+
# Parse the header row
|
60
|
+
table.columns = CSVDecision::Columns.new(table)
|
61
|
+
|
62
|
+
parse_data(table: table, matchers: matchers(table.options).freeze)
|
63
|
+
|
64
|
+
table.freeze
|
65
|
+
end
|
66
|
+
private_class_method :parse_table
|
67
|
+
|
68
|
+
def self.parse_data(table:, matchers:)
|
69
|
+
table.rows.each_with_index do |row, index|
|
70
|
+
# Build an array of column indexes requiring simple matches.
|
71
|
+
# and a second array of columns requiring special matchers
|
72
|
+
table.scan_rows[index] = Matchers.parse(columns: table.columns.ins,
|
73
|
+
matchers: matchers,
|
74
|
+
row: row)
|
75
|
+
|
76
|
+
# parse_outputs(row, index)
|
77
|
+
|
78
|
+
row.freeze
|
79
|
+
table.scan_rows[index].freeze
|
80
|
+
end
|
81
|
+
|
82
|
+
table.columns.freeze
|
83
|
+
end
|
84
|
+
|
85
|
+
private_class_method :parse_data
|
33
86
|
|
34
|
-
|
35
|
-
|
87
|
+
def self.matchers(options)
|
88
|
+
options[:matchers].collect { |klass| klass.new(options) }
|
36
89
|
end
|
90
|
+
private_class_method :matchers
|
37
91
|
end
|
38
92
|
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'values'
|
4
|
+
|
5
|
+
# CSV Decision: CSV based Ruby decision tables.
|
6
|
+
# Created December 2017 by Brett Vickers
|
7
|
+
# See LICENSE and README.md for details.
|
8
|
+
module CSVDecision
|
9
|
+
# Data row object indicating which columns are constants versus procs.
|
10
|
+
class ScanRow
|
11
|
+
attr_accessor :constants
|
12
|
+
attr_accessor :procs
|
13
|
+
|
14
|
+
def initialize
|
15
|
+
@constants = []
|
16
|
+
@procs = []
|
17
|
+
end
|
18
|
+
|
19
|
+
def scan_columns(columns:, matchers:, row:)
|
20
|
+
columns.each_pair do |col, column|
|
21
|
+
# Empty cell matches everything, and so never needs to be scanned
|
22
|
+
next if (cell = row[col]) == ''
|
23
|
+
|
24
|
+
# If the column is text only then no special matchers need be invoked
|
25
|
+
next constants << col if column.text_only
|
26
|
+
|
27
|
+
# Need to scan the cell against all matchers, and possibly overwrite
|
28
|
+
# the cell contents with a proc.
|
29
|
+
row[col] = scan_cell(col: col, matchers: matchers, cell: cell)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def match_constants?(row:, scan_cols:)
|
34
|
+
constants.each do |col|
|
35
|
+
return false unless row[col] == scan_cols[col]
|
36
|
+
end
|
37
|
+
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
def match_procs?(row:, input:)
|
42
|
+
hash = input[:hash]
|
43
|
+
scan_cols = input[:scan_cols]
|
44
|
+
|
45
|
+
procs.each do |col|
|
46
|
+
match = Decide.eval_matcher(proc: row[col], value: scan_cols[col], hash: hash)
|
47
|
+
return false unless match
|
48
|
+
end
|
49
|
+
|
50
|
+
true
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
|
55
|
+
def scan_cell(col:, matchers:, cell:)
|
56
|
+
# Scan the cell against all the matchers
|
57
|
+
proc = Matchers.scan(matchers: matchers, cell: cell)
|
58
|
+
|
59
|
+
if proc
|
60
|
+
procs << col
|
61
|
+
return proc
|
62
|
+
end
|
63
|
+
|
64
|
+
constants << col
|
65
|
+
cell
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
data/lib/csv_decision/table.rb
CHANGED
@@ -1,26 +1,54 @@
|
|
1
|
-
# frozen_string_literal: true
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
# CSV Decision: CSV based Ruby decision tables.
|
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 decision
|
8
8
|
class Table
|
9
|
+
attr_accessor :columns
|
9
10
|
attr_accessor :file
|
10
|
-
attr_accessor :header
|
11
11
|
attr_accessor :options
|
12
|
+
attr_accessor :outs_functions
|
12
13
|
attr_accessor :rows
|
14
|
+
attr_accessor :scan_rows
|
13
15
|
attr_reader :tables
|
14
16
|
|
15
|
-
|
16
|
-
|
17
|
+
# Main public method for making decisions.
|
18
|
+
# @param input [Hash] - input hash (keys may or may not be symbolized)
|
19
|
+
# @return [Hash]
|
20
|
+
def decide(input)
|
21
|
+
Decide.decide(table: self, input: input, symbolize_keys: true).result
|
22
|
+
end
|
23
|
+
|
24
|
+
# Unsafe version of decide - will mutate the hash if set: option (planned feature)
|
25
|
+
# is used.
|
26
|
+
# @param input [Hash] - input hash (keys must be symbolized)
|
27
|
+
# @return [Hash]
|
28
|
+
def decide!(input)
|
29
|
+
Decide.decide(table: self, input: input, symbolize_keys: false).result
|
30
|
+
end
|
31
|
+
|
32
|
+
# Iterate through all data rows of the decision table.
|
33
|
+
# @param first [Integer] - start row
|
34
|
+
# @param last [Integer] - last row
|
35
|
+
def each(first = 0, last = @rows.count - 1)
|
36
|
+
index = first
|
37
|
+
while index <= (last || first)
|
38
|
+
yield(@rows[index], index)
|
39
|
+
|
40
|
+
index += 1
|
41
|
+
end
|
17
42
|
end
|
18
43
|
|
19
44
|
def initialize
|
45
|
+
@columns = nil
|
20
46
|
@file = nil
|
21
|
-
@
|
47
|
+
@matchers = []
|
22
48
|
@options = nil
|
49
|
+
@outs_functions = nil
|
23
50
|
@rows = []
|
51
|
+
@scan_rows = []
|
24
52
|
@tables = nil
|
25
53
|
end
|
26
54
|
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../lib/csv_decision'
|
4
|
+
|
5
|
+
SPEC_DATA_VALID ||= File.join(CSVDecision.root, 'spec', 'data', 'valid')
|
6
|
+
SPEC_DATA_INVALID ||= File.join(CSVDecision.root, 'spec', 'data', 'invalid')
|
7
|
+
|
8
|
+
describe CSVDecision::Columns do
|
9
|
+
describe '#new' do
|
10
|
+
it 'creates a columns object' do
|
11
|
+
table = CSVDecision::Table.new
|
12
|
+
columns = CSVDecision::Columns.new(table)
|
13
|
+
|
14
|
+
expect(columns).to be_a(CSVDecision::Columns)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'parses a decision table columns from a CSV file' do
|
19
|
+
data = <<~DATA
|
20
|
+
IN :input, OUT :output, IN/text : input, OUT/text:output
|
21
|
+
input0, output0, input1, output1
|
22
|
+
DATA
|
23
|
+
table = CSVDecision.parse(data)
|
24
|
+
|
25
|
+
expect(table.columns).to be_a(CSVDecision::Columns)
|
26
|
+
expect(table.columns.ins[0].to_h).to eq(name: :input, text_only: nil)
|
27
|
+
expect(table.columns.ins[2].to_h).to eq(name: :input, text_only: true)
|
28
|
+
expect(table.columns.outs[1].to_h).to eq(name: :output, text_only: nil)
|
29
|
+
expect(table.columns.outs[3].to_h).to eq(name: :output, text_only: true)
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'parses a decision table columns from a CSV file' do
|
33
|
+
file = Pathname(File.join(SPEC_DATA_VALID, 'valid.csv'))
|
34
|
+
result = CSVDecision.parse(file)
|
35
|
+
|
36
|
+
expect(result.columns).to be_a(CSVDecision::Columns)
|
37
|
+
expect(result.columns.ins).to eq(0 => CSVDecision::Columns::Entry.new(:input, nil))
|
38
|
+
expect(result.columns.outs).to eq(1 => CSVDecision::Columns::Entry.new(:output, nil))
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'rejects an invalid header column' do
|
42
|
+
data = [
|
43
|
+
['IN :input', 'BAD :output'],
|
44
|
+
['input', '']
|
45
|
+
]
|
46
|
+
|
47
|
+
expect { CSVDecision.parse(data) }
|
48
|
+
.to raise_error(CSVDecision::CellValidationError,
|
49
|
+
"header column 'BAD :output' is not valid as " \
|
50
|
+
'the column name is not well formed')
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'rejects a missing column name' do
|
54
|
+
data = [
|
55
|
+
['IN :input', 'IN: '],
|
56
|
+
['input', '']
|
57
|
+
]
|
58
|
+
|
59
|
+
expect { CSVDecision.parse(data) }
|
60
|
+
.to raise_error(CSVDecision::CellValidationError,
|
61
|
+
"header column 'IN:' is not valid as the column name is missing")
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'rejects an invalid column name' do
|
65
|
+
data = [
|
66
|
+
['IN :input', 'IN: a-b'],
|
67
|
+
['input', '']
|
68
|
+
]
|
69
|
+
|
70
|
+
expect { CSVDecision.parse(data) }
|
71
|
+
.to raise_error(CSVDecision::CellValidationError,
|
72
|
+
"header column 'IN: a-b' is not valid as " \
|
73
|
+
"the column name 'a-b' contains invalid characters")
|
74
|
+
end
|
75
|
+
|
76
|
+
context 'rejects invalid CSV decision table columns' do
|
77
|
+
Dir[File.join(SPEC_DATA_INVALID, 'invalid_columns*.csv')].each do |file_name|
|
78
|
+
pathname = Pathname(file_name)
|
79
|
+
|
80
|
+
it "rejects CSV file #{pathname.basename}" do
|
81
|
+
expect { CSVDecision.parse(pathname) }
|
82
|
+
.to raise_error(CSVDecision::FileError, /\Aerror processing CSV file/)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -22,15 +22,28 @@ describe CSVDecision::Data do
|
|
22
22
|
end
|
23
23
|
|
24
24
|
it 'parses a CSV file' do
|
25
|
-
file =
|
25
|
+
file = File.new(File.join(CSVDecision.root, 'spec/data/valid', 'empty.csv'))
|
26
26
|
result = CSVDecision::Data.to_array(data: file)
|
27
27
|
expect(result).to be_a Array
|
28
28
|
expect(result.empty?).to eq true
|
29
29
|
|
30
30
|
file = Pathname(File.join(CSVDecision.root, 'spec/data/valid', 'valid.csv'))
|
31
31
|
result = CSVDecision::Data.to_array(data: file)
|
32
|
-
|
33
|
-
|
32
|
+
expected = [
|
33
|
+
['', 'IN :input', '', 'OUT :output', ''],
|
34
|
+
['', 'input', '', '', '']
|
35
|
+
]
|
36
|
+
expect(result).to eq(expected)
|
37
|
+
|
38
|
+
file = Pathname(File.join(CSVDecision.root, 'spec/data/valid', 'options_in_file2.csv'))
|
39
|
+
result = CSVDecision::Data.to_array(data: file)
|
40
|
+
expected = [
|
41
|
+
['accumulate'],
|
42
|
+
['regexp_implicit'],
|
43
|
+
['IN :input', 'OUT :output'],
|
44
|
+
['input', '']
|
45
|
+
]
|
46
|
+
expect(result).to eq(expected)
|
34
47
|
end
|
35
48
|
|
36
49
|
it 'raises an error for invalid input' do
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../lib/csv_decision'
|
4
|
+
|
5
|
+
describe CSVDecision::Decision do
|
6
|
+
it 'decision for table with no functions and first_match: true' do
|
7
|
+
data = <<~DATA
|
8
|
+
IN :input, OUT :output, IN: input1
|
9
|
+
input0, output0, input1
|
10
|
+
input0, output1,
|
11
|
+
DATA
|
12
|
+
|
13
|
+
table = CSVDecision.parse(data)
|
14
|
+
|
15
|
+
input = { input: 'input0', input1: 'input1' }
|
16
|
+
|
17
|
+
decision = CSVDecision::Decision.new(table: table, input: input)
|
18
|
+
|
19
|
+
expect(decision).to be_a(CSVDecision::Decision)
|
20
|
+
expect(decision.empty?).to eq true
|
21
|
+
expect(decision.exist?).to eq false
|
22
|
+
|
23
|
+
row = table.rows[0]
|
24
|
+
decision.add(row)
|
25
|
+
|
26
|
+
expect(decision.empty?).to eq false
|
27
|
+
expect(decision.exist?).to eq true
|
28
|
+
expect(decision.result).to eq(output: 'output0')
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../../lib/csv_decision'
|
4
|
+
|
5
|
+
describe CSVDecision::Input do
|
6
|
+
it 'rejects a non-hash or empty hash value' do
|
7
|
+
expect { CSVDecision::Input.parse(table: nil, input: [], symbolize_keys: true ) }
|
8
|
+
.to raise_error(ArgumentError, 'input must be a non-empty hash')
|
9
|
+
expect { CSVDecision::Input.parse(table: nil, input: {}, symbolize_keys: true ) }
|
10
|
+
.to raise_error(ArgumentError, 'input must be a non-empty hash')
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'processes input hash with symbolize_keys: true' do
|
14
|
+
data = <<~DATA
|
15
|
+
IN :input, OUT :output, IN: input1
|
16
|
+
input0, output0, input1
|
17
|
+
input0, output1,
|
18
|
+
DATA
|
19
|
+
|
20
|
+
table = CSVDecision.parse(data)
|
21
|
+
|
22
|
+
input = { 'input' => 'input0', input1: 'input1' }
|
23
|
+
expected = {
|
24
|
+
hash: { input: 'input0', input1: 'input1' },
|
25
|
+
scan_cols: { 0 => 'input0', 2 => 'input1'}
|
26
|
+
}
|
27
|
+
|
28
|
+
result = CSVDecision::Input.parse(table: table, input: input, symbolize_keys: true)
|
29
|
+
|
30
|
+
expect(result).to eql expected
|
31
|
+
expect(result[:hash]).not_to equal expected[:hash]
|
32
|
+
expect(result[:hash].frozen?).to eq true
|
33
|
+
expect(result[:defaults].frozen?).to eq true
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'processes input hash with symbolize_keys: false' do
|
37
|
+
data = <<~DATA
|
38
|
+
IN :input, OUT :output, IN: input1
|
39
|
+
input0, output0, input1
|
40
|
+
input0, output1,
|
41
|
+
DATA
|
42
|
+
|
43
|
+
table = CSVDecision.parse(data)
|
44
|
+
input = { input: 'input0', input1: 'input1' }
|
45
|
+
expected = { hash: input, scan_cols: { 0 => 'input0', 2 => 'input1'} }
|
46
|
+
|
47
|
+
result = CSVDecision::Input.parse(table: table, input: input, symbolize_keys: false)
|
48
|
+
|
49
|
+
expect(result).to eql expected
|
50
|
+
expect(result[:hash]).to equal expected[:hash]
|
51
|
+
expect(result[:hash].frozen?).to eq false
|
52
|
+
expect(result[:defaults].frozen?).to eq true
|
53
|
+
end
|
54
|
+
end
|