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.
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