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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.coveralls.yml +2 -0
  3. data/.rubocop.yml +16 -4
  4. data/.travis.yml +10 -0
  5. data/CHANGELOG.md +2 -0
  6. data/Gemfile +3 -0
  7. data/LICENSE +21 -0
  8. data/README.md +133 -19
  9. data/benchmark.rb +143 -0
  10. data/csv_decision.gemspec +8 -6
  11. data/lib/csv_decision.rb +18 -4
  12. data/lib/csv_decision/columns.rb +69 -0
  13. data/lib/csv_decision/data.rb +31 -16
  14. data/lib/csv_decision/decide.rb +47 -0
  15. data/lib/csv_decision/decision.rb +105 -0
  16. data/lib/csv_decision/header.rb +143 -8
  17. data/lib/csv_decision/input.rb +49 -0
  18. data/lib/csv_decision/load.rb +31 -0
  19. data/lib/csv_decision/matchers.rb +131 -0
  20. data/lib/csv_decision/matchers/numeric.rb +37 -0
  21. data/lib/csv_decision/matchers/pattern.rb +76 -0
  22. data/lib/csv_decision/matchers/range.rb +76 -0
  23. data/lib/csv_decision/options.rb +80 -50
  24. data/lib/csv_decision/parse.rb +77 -23
  25. data/lib/csv_decision/scan_row.rb +68 -0
  26. data/lib/csv_decision/table.rb +34 -6
  27. data/spec/csv_decision/columns_spec.rb +86 -0
  28. data/spec/csv_decision/data_spec.rb +16 -3
  29. data/spec/csv_decision/decision_spec.rb +30 -0
  30. data/spec/csv_decision/input_spec.rb +54 -0
  31. data/spec/csv_decision/load_spec.rb +28 -0
  32. data/spec/csv_decision/matchers/numeric_spec.rb +84 -0
  33. data/spec/csv_decision/matchers/pattern_spec.rb +183 -0
  34. data/spec/csv_decision/matchers/range_spec.rb +132 -0
  35. data/spec/csv_decision/options_spec.rb +67 -0
  36. data/spec/csv_decision/parse_spec.rb +2 -3
  37. data/spec/csv_decision/simple_example_spec.rb +45 -0
  38. data/spec/csv_decision/table_spec.rb +151 -0
  39. data/spec/data/invalid/invalid_header1.csv +4 -0
  40. data/spec/data/invalid/invalid_header2.csv +4 -0
  41. data/spec/data/invalid/invalid_header3.csv +4 -0
  42. data/spec/data/invalid/invalid_header4.csv +4 -0
  43. data/spec/data/valid/options_in_file1.csv +5 -0
  44. data/spec/data/valid/options_in_file2.csv +5 -0
  45. data/spec/data/valid/simple_example.csv +10 -0
  46. data/spec/data/valid/valid.csv +4 -4
  47. data/spec/spec_helper.rb +6 -0
  48. metadata +89 -12
@@ -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
- # Parse the input data which may either be a path name, CSV string or array of arrays
13
- def self.parse(input, options: {})
14
- # Parse and normalize user supplied options
15
- options = Options.new(options)
7
+ class Error < StandardError; end
8
+ class CellValidationError < Error; end
9
+ class FileError < Error; end
16
10
 
17
- # Parse input data, which may include overriding options specified in a CSV file
18
- table = Parse.data(table: Table.new, input: input, options: options)
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
- options = options.from_csv(table)
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
- # Set to the options hash
23
- table.options = options.attributes.freeze
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
- table.freeze
26
- end
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
- # Parse the CSV file and create a new decision table object
29
- module Parse
30
- def self.data(table:, input:, options:)
31
- table.file = input if input.is_a?(Pathname)
32
- table.rows = Data.to_array(data: input, options: options.attributes)
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
- table.header = Header.parse(table: table, options: options)
35
- table
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
@@ -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 deciosn
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
- def decide(_input, _symbolize_keys: true)
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
- @header = nil
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 = Pathname(File.join(CSVDecision.root, 'spec/data/valid', 'empty.csv'))
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
- expect(result).to be_a Array
33
- expect(result).to eq [['IN :input', 'OUT :output'], ['input', '']]
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