csv_decision 0.0.1 → 0.0.2
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/.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
|