csv_decision 0.0.3 → 0.0.4

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/.codeclimate.yml +2 -0
  3. data/.gitignore +2 -1
  4. data/.travis.yml +2 -3
  5. data/CHANGELOG.md +19 -1
  6. data/README.md +49 -16
  7. data/{benchmark.rb → benchmarks/rufus_decision.rb} +1 -1
  8. data/csv_decision.gemspec +1 -1
  9. data/doc/CSVDecision/CellValidationError.html +143 -0
  10. data/doc/CSVDecision/Columns/Default.html +409 -0
  11. data/doc/CSVDecision/Columns/Dictionary.html +410 -0
  12. data/doc/CSVDecision/Columns/Entry.html +321 -0
  13. data/doc/CSVDecision/Columns.html +476 -0
  14. data/doc/CSVDecision/Constant.html +295 -0
  15. data/doc/CSVDecision/Data.html +344 -0
  16. data/doc/CSVDecision/Decide.html +434 -0
  17. data/doc/CSVDecision/Decision.html +604 -0
  18. data/doc/CSVDecision/Error.html +139 -0
  19. data/doc/CSVDecision/FileError.html +143 -0
  20. data/doc/CSVDecision/Function.html +229 -0
  21. data/doc/CSVDecision/Header.html +520 -0
  22. data/doc/CSVDecision/Input.html +305 -0
  23. data/doc/CSVDecision/Load.html +225 -0
  24. data/doc/CSVDecision/Matchers/Constant.html +242 -0
  25. data/doc/CSVDecision/Matchers/Function.html +342 -0
  26. data/doc/CSVDecision/Matchers/Matcher.html +325 -0
  27. data/doc/CSVDecision/Matchers/Numeric.html +277 -0
  28. data/doc/CSVDecision/Matchers/Pattern.html +600 -0
  29. data/doc/CSVDecision/Matchers/Range.html +413 -0
  30. data/doc/CSVDecision/Matchers/Symbol.html +280 -0
  31. data/doc/CSVDecision/Matchers.html +1529 -0
  32. data/doc/CSVDecision/Numeric.html +259 -0
  33. data/doc/CSVDecision/Options.html +445 -0
  34. data/doc/CSVDecision/Parse.html +270 -0
  35. data/doc/CSVDecision/ScanRow.html +746 -0
  36. data/doc/CSVDecision/Symbol.html +256 -0
  37. data/doc/CSVDecision/Table.html +1115 -0
  38. data/doc/CSVDecision.html +652 -0
  39. data/doc/_index.html +410 -0
  40. data/doc/class_list.html +51 -0
  41. data/doc/css/common.css +1 -0
  42. data/doc/css/full_list.css +58 -0
  43. data/doc/css/style.css +499 -0
  44. data/doc/file.README.html +264 -0
  45. data/doc/file_list.html +56 -0
  46. data/doc/frames.html +17 -0
  47. data/doc/index.html +264 -0
  48. data/doc/js/app.js +248 -0
  49. data/doc/js/full_list.js +216 -0
  50. data/doc/js/jquery.js +4 -0
  51. data/doc/method_list.html +683 -0
  52. data/doc/top-level-namespace.html +110 -0
  53. data/lib/csv_decision/columns.rb +15 -12
  54. data/lib/csv_decision/constant.rb +54 -0
  55. data/lib/csv_decision/decide.rb +5 -5
  56. data/lib/csv_decision/decision.rb +3 -1
  57. data/lib/csv_decision/function.rb +32 -0
  58. data/lib/csv_decision/header.rb +27 -18
  59. data/lib/csv_decision/input.rb +11 -8
  60. data/lib/csv_decision/matchers/constant.rb +18 -0
  61. data/lib/csv_decision/matchers/function.rb +11 -44
  62. data/lib/csv_decision/matchers/numeric.rb +5 -33
  63. data/lib/csv_decision/matchers/pattern.rb +26 -11
  64. data/lib/csv_decision/matchers/range.rb +21 -5
  65. data/lib/csv_decision/matchers/symbol.rb +20 -0
  66. data/lib/csv_decision/matchers.rb +85 -20
  67. data/lib/csv_decision/numeric.rb +38 -0
  68. data/lib/csv_decision/options.rb +36 -27
  69. data/lib/csv_decision/parse.rb +46 -39
  70. data/lib/csv_decision/scan_row.rb +19 -7
  71. data/lib/csv_decision/symbol.rb +73 -0
  72. data/lib/csv_decision/table.rb +24 -18
  73. data/lib/csv_decision.rb +25 -18
  74. data/spec/csv_decision/columns_spec.rb +1 -1
  75. data/spec/csv_decision/constant_spec.rb +60 -0
  76. data/spec/csv_decision/examples_spec.rb +119 -0
  77. data/spec/csv_decision/matchers/function_spec.rb +48 -28
  78. data/spec/csv_decision/matchers/numeric_spec.rb +4 -41
  79. data/spec/csv_decision/matchers/range_spec.rb +31 -61
  80. data/spec/csv_decision/matchers/symbol_spec.rb +65 -0
  81. data/spec/csv_decision/options_spec.rb +14 -2
  82. data/spec/csv_decision/parse_spec.rb +10 -0
  83. data/spec/csv_decision/table_spec.rb +112 -6
  84. data/spec/data/valid/simple_constants.csv +3 -3
  85. metadata +62 -7
  86. data/spec/csv_decision/simple_example_spec.rb +0 -75
  87. /data/spec/{csv_decision.rb → csv_decision_spec.rb} +0 -0
@@ -5,11 +5,12 @@
5
5
  # See LICENSE and README.md for details.
6
6
  module CSVDecision
7
7
  # Methods to assign a matcher to data cells
8
- module Matchers
8
+ class Matchers
9
9
  # Match cell against a Ruby-like range
10
10
  class Range < Matcher
11
11
  # Range types are .. or ...
12
12
  TYPE = '(\.\.\.|\.\.)'
13
+ private_constant :TYPE
13
14
 
14
15
  def self.range_re(value)
15
16
  Matchers.regexp(
@@ -19,10 +20,14 @@ module CSVDecision
19
20
  private_class_method :range_re
20
21
 
21
22
  NUMERIC_RANGE = range_re(Matchers::NUMERIC)
23
+ private_constant :NUMERIC_RANGE
22
24
 
23
25
  # One or more alphanumeric characters
24
26
  ALNUM = '[[:alnum:]][[:alnum:]]*'
27
+ private_constant :ALNUM
28
+
25
29
  ALNUM_RANGE = range_re(ALNUM)
30
+ private_constant :ALNUM_RANGE
26
31
 
27
32
  def self.convert(value, method)
28
33
  method ? Matchers.send(method, value) : value
@@ -37,6 +42,7 @@ module CSVDecision
37
42
 
38
43
  [negate, type == '...' ? min...max : min..max]
39
44
  end
45
+ private_class_method :range
40
46
 
41
47
  def self.numeric_range(negate, range)
42
48
  return ->(value) { range.include?(Matchers.numeric(value)) } unless negate
@@ -50,24 +56,34 @@ module CSVDecision
50
56
  end
51
57
  private_class_method :alnum_range
52
58
 
53
- def self.proc(match:, coerce: nil)
59
+ def self.range_proc(match:, coerce: nil)
54
60
  negate, range = range(match, coerce: coerce)
55
61
  method = coerce ? :numeric_range : :alnum_range
56
62
  function = Range.send(method, negate, range).freeze
57
63
  Proc.with(type: :proc, function: function)
58
64
  end
65
+ private_class_method :range_proc
59
66
 
60
- def matches?(cell)
67
+ # @param (see Matchers::Matcher#matches?)
68
+ # @return (see Matchers::Matcher#matches?)
69
+ def self.matches?(cell)
61
70
  if (match = NUMERIC_RANGE.match(cell))
62
- return Range.proc(match: match, coerce: :to_numeric)
71
+ return range_proc(match: match, coerce: :to_numeric)
63
72
  end
64
73
 
65
74
  if (match = ALNUM_RANGE.match(cell))
66
- return Range.proc(match: match)
75
+ return range_proc(match: match)
67
76
  end
68
77
 
69
78
  false
70
79
  end
80
+
81
+ # Range expression - e.g., +0...10+ or +a..z+
82
+ # @param (see Matchers::Matcher#matches?)
83
+ # @return (see Matchers::Matcher#matches?)
84
+ def matches?(cell)
85
+ Range.matches?(cell)
86
+ end
71
87
  end
72
88
  end
73
89
  end
@@ -0,0 +1,20 @@
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
+ # Recognise expressions in table data cells.
8
+ class Matchers
9
+ # Match cell against a
10
+ # * cell constant - e.g., := true, = nil
11
+ # * symbolic expression - e.g., :column, > :column
12
+ class Symbol < Matcher
13
+ # @param (see Matchers::Matcher#matches?)
14
+ # @return (see Matchers::Matcher#matches?)
15
+ def matches?(cell)
16
+ CSVDecision::Symbol.matches?(cell)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -6,63 +6,85 @@ require 'values'
6
6
  # Created December 2017 by Brett Vickers
7
7
  # See LICENSE and README.md for details.
8
8
  module CSVDecision
9
- # Value object for a cell proc
9
+ # Value object for a cell proc.
10
10
  Proc = Value.new(:type, :function)
11
11
 
12
- # Methods to assign a matcher to data cells
13
- module Matchers
14
- # Negation sign for ranges and functions
12
+ # Methods to assign a matcher to table data cells.
13
+ class Matchers
14
+ # Negation sign prefixed to ranges and functions.
15
15
  NEGATE = '!'
16
16
 
17
- # All regular expressions used for matching are anchored
17
+ # Cell constants and functions specified by prefixing the value with one of these 3 symbols
18
+ EQUALS = '==|:=|='
19
+
20
+ # All regular expressions used for matching are anchored inside their own
21
+ # non-capturing group.
18
22
  #
19
- # @param value [String]
20
- # @return [Regexp]
23
+ # @param value [String] String used to form an anchored regular expression.
24
+ # @return [Regexp] Anchored, frozen regular expression.
21
25
  def self.regexp(value)
22
- Regexp.new("\\A(#{value})\\z").freeze
26
+ Regexp.new("\\A(?:#{value})\\z").freeze
23
27
  end
24
28
 
25
29
  # Regular expression used to recognise a numeric string with or without a decimal point.
26
- NUMERIC = '[-+]?\d*(?<decimal>\.?)\d+'
30
+ NUMERIC = '[-+]?\d*(?<decimal>\.?)\d*'
27
31
  NUMERIC_RE = regexp(NUMERIC)
28
32
 
29
- # Validate a numeric value and convert it to an Integer or BigDecimal if a valid string.
33
+ # @param value [Object] Value from the input hash.
34
+ # @return [Boolean] Value is an Integer or a BigDecimal.
35
+ def self.numeric?(value)
36
+ value.is_a?(Integer) || value.is_a?(BigDecimal)
37
+ end
38
+
39
+ # Validate a numeric value and convert it to an Integer or BigDecimal if a valid numeric string.
30
40
  #
31
41
  # @param value [nil, String, Integer, BigDecimal]
32
42
  # @return [nil, Integer, BigDecimal]
33
43
  def self.numeric(value)
34
- return value if value.is_a?(Integer) || value.is_a?(BigDecimal)
44
+ return value if numeric?(value)
35
45
  return unless value.is_a?(String)
36
46
 
37
47
  to_numeric(value)
38
48
  end
39
49
 
40
- # Validate a numeric string and convert it to an Integer or BigDecimal.
50
+ # Convert a numeric string into an Integer or BigDecimal.
41
51
  #
42
52
  # @param value [String]
43
53
  # @return [nil, Integer, BigDecimal]
44
54
  def self.to_numeric(value)
45
55
  return unless (match = NUMERIC_RE.match(value))
56
+ coerce_numeric(match, value)
57
+ end
58
+
59
+ def self.coerce_numeric(match, value)
46
60
  return value.to_i if match['decimal'] == ''
47
- BigDecimal.new(value.chomp('.'))
61
+ BigDecimal(value.chomp('.'))
48
62
  end
63
+ private_class_method :coerce_numeric
49
64
 
50
65
  # Parse the supplied input columns for the row supplied using an array of matchers.
51
66
  #
52
- # @param columns [Hash] - Input columns hash
53
- # @param matchers [Array]
54
- # @param row [Array]
67
+ # @param columns [Hash{Integer=>Columns::Entry}] Input columns hash.
68
+ # @param matchers [Array<Matchers::Matcher>]
69
+ # @param row [Array<String>] Data row being parsed.
70
+ # @return [Array<(Array, ScanRow)>] Used to scan a table row against an input hash for matches.
55
71
  def self.parse(columns:, matchers:, row:)
56
72
  # Build an array of column indexes requiring simple constant matches,
57
73
  # and a second array of columns requiring special matchers.
58
74
  scan_row = ScanRow.new
59
75
 
60
- # scan_columns(columns: columns, matchers: matchers, row: row, scan_row: scan_row)
61
- scan_row.scan_columns(columns: columns, matchers: matchers, row: row)
76
+ row = scan_row.scan_columns(columns: columns, matchers: matchers, row: row)
62
77
 
63
- scan_row
78
+ scan_row.freeze
79
+
80
+ [row, scan_row.freeze]
64
81
  end
65
82
 
83
+ # Scan the table cell against all matches.
84
+ #
85
+ # @param matchers [Array<Matchers::Matcher>]
86
+ # @param cell [String]
87
+ # @return [false, Matchers::Proc]
66
88
  def self.scan(matchers:, cell:)
67
89
  matchers.each do |matcher|
68
90
  proc = matcher.matches?(cell)
@@ -73,12 +95,55 @@ module CSVDecision
73
95
  false
74
96
  end
75
97
 
98
+ def self.ins_matchers(options)
99
+ options[:matchers].collect { |klass| klass.new(options) }
100
+ end
101
+
102
+ def self.outs_matchers(matchers)
103
+ matchers.select { |obj| OUTS_MATCHERS.include?(obj.class) }
104
+ end
105
+
106
+ # @return [Array<Matchers::Matcher>] Matchers for the input columns.
107
+ attr_reader :ins
108
+
109
+ # @return [Array<Matchers::Matcher>] Matchers for the output columns.
110
+ attr_reader :outs
111
+
112
+ # @param options (see CSVDecision.parse)
113
+ def initialize(options)
114
+ @ins = Matchers.ins_matchers(options)
115
+ @outs = Matchers.outs_matchers(@ins)
116
+ end
117
+
118
+ # Parse the row's input columns using the input matchers.
119
+ #
120
+ # @param columns (see Matchers.parse)
121
+ # @param row (see Matchers.parse)
122
+ # @return (see Matchers.parse)
123
+ def parse_ins(columns:, row:)
124
+ Matchers.parse(columns: columns, matchers: @ins, row: row)
125
+ end
126
+
127
+ # Parse the row's output columns using the output matchers.
128
+ #
129
+ # @param columns (see Matchers.parse)
130
+ # @param row (see Matchers.parse)
131
+ # @return (see Matchers.parse)
132
+ def parse_outs(columns:, row:)
133
+ Matchers.parse(columns: columns, matchers: @outs, row: row)
134
+ end
135
+
76
136
  # @abstract Subclass and override {#matches?} to implement
77
137
  # a custom Matcher class.
78
138
  class Matcher
79
139
  def initialize(_options = nil); end
80
140
 
81
- def matches?(_cell); end
141
+ # Determine if the input cell string is recognised by this Matcher.
142
+ #
143
+ # @param cell [String] Data row cell.
144
+ # @return [false, CSVDecision::Proc] Returns false if this cell is not a match; otherwise returns the
145
+ # +CSVDecision::Proc+ object indicating if this is a constant or some type of function.
146
+ def matches?(cell); end
82
147
  end
83
148
  end
84
149
  end
@@ -0,0 +1,38 @@
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
+ # Recognise Ruby-like numeric comparison expressions.
8
+ module Numeric
9
+ # For example: >= 100 or != 0
10
+ COMPARISON = /\A(?<comparator><=|>=|<|>|!=)\s*(?<value>\S.*)\z/
11
+ private_constant :COMPARISON
12
+
13
+ # Coerce the input value to a numeric representation before invoking the comparison.
14
+ # If the coercion fails, it will produce a nil value which always fails to match.
15
+ COMPARATORS = {
16
+ '>' => proc { |numeric_cell, value| Matchers.numeric(value)&.> numeric_cell },
17
+ '>=' => proc { |numeric_cell, value| Matchers.numeric(value)&.>= numeric_cell },
18
+ '<' => proc { |numeric_cell, value| Matchers.numeric(value)&.< numeric_cell },
19
+ '<=' => proc { |numeric_cell, value| Matchers.numeric(value)&.<= numeric_cell },
20
+ '!=' => proc { |numeric_cell, value| Matchers.numeric(value)&.!= numeric_cell }
21
+ }.freeze
22
+ private_constant :COMPARATORS
23
+
24
+ # @param (see Matchers::Matcher#matches?)
25
+ # @return (see Matchers::Matcher#matches?)
26
+ def self.matches?(cell)
27
+ match = COMPARISON.match(cell)
28
+ return false unless match
29
+
30
+ numeric_cell = Matchers.to_numeric(match['value'])
31
+ return false unless numeric_cell
32
+
33
+ comparator = match['comparator']
34
+ Proc.with(type: :proc,
35
+ function: COMPARATORS[comparator].curry[numeric_cell].freeze)
36
+ end
37
+ end
38
+ end
@@ -10,32 +10,41 @@ module CSVDecision
10
10
  Matchers::Range,
11
11
  Matchers::Numeric,
12
12
  Matchers::Pattern,
13
- Matchers::Function
13
+ Matchers::Constant,
14
+ Matchers::Symbol
15
+ # Matchers::Function
14
16
  ].freeze
15
17
 
16
- # All valid options with their default values.
17
- VALID_OPTIONS = {
18
- first_match: true,
19
- regexp_implicit: false,
20
- text_only: false,
21
- matchers: DEFAULT_MATCHERS
22
- }.freeze
23
-
24
- # These options may appear in the CSV file before the header row.
25
- # Convert them to a normalized option key value pair.
26
- CSV_OPTION_NAMES = {
27
- first_match: [:first_match, true],
28
- accumulate: [:first_match, false],
29
- regexp_implicit: [:regexp_implicit, true],
30
- text_only: [:text_only, true]
31
- }.freeze
32
-
33
- # Validate and normalize the options hash supplied.
18
+ # Subset of matchers that apply to output cells
19
+ OUTS_MATCHERS = [
20
+ Matchers::Constant
21
+ # Matchers::Function
22
+ ].freeze
23
+
24
+ # Validate and normalize the options values supplied.
34
25
  module Options
26
+ # All valid CSVDecision::parse options with their default values.
27
+ VALID = {
28
+ first_match: true,
29
+ regexp_implicit: false,
30
+ text_only: false,
31
+ matchers: DEFAULT_MATCHERS
32
+ }.freeze
33
+
34
+ # These options may appear in the CSV file before the header row.
35
+ # They get converted to a normalized option key value pair.
36
+ CSV_NAMES = {
37
+ first_match: [:first_match, true],
38
+ accumulate: [:first_match, false],
39
+ regexp_implicit: [:regexp_implicit, true],
40
+ text_only: [:text_only, true]
41
+ }.freeze
42
+
35
43
  # Validate options and supply default values for any options not explicitly set.
36
44
  #
37
- # @param options [Hash] - input options hash supplied
38
- # @return [Hash] - options hash filled in with all required default values
45
+ # @param options [Hash] Input options hash supplied by the user.
46
+ # @return [Hash] Options hash filled in with all required values, defaulted if necessary.
47
+ # @raise [ArgumentError] For invalid option keys.
39
48
  def self.normalize(options)
40
49
  validate(options)
41
50
  default(options)
@@ -43,9 +52,9 @@ module CSVDecision
43
52
 
44
53
  # Read any options supplied in the CSV file placed before the header row.
45
54
  #
46
- # @param rows [Array<Array<String>>] - table data rows.
47
- # @param options [Hash] - input options hash built so far
48
- # @return [Hash] - options hash overridden with any option values in the CSV file
55
+ # @param rows [Array<Array<String>>] Table data rows.
56
+ # @param options [Hash] Input options hash built so far.
57
+ # @return [Hash] Options hash overridden with any values found in the CSV file.
49
58
  def self.from_csv(rows:, options:)
50
59
  row = rows.first
51
60
  return options if row.nil?
@@ -80,7 +89,7 @@ module CSVDecision
80
89
  result[:matchers] = matchers(result)
81
90
 
82
91
  # Supply any missing options with default values
83
- VALID_OPTIONS.each_pair do |key, value|
92
+ Options::VALID.each_pair do |key, value|
84
93
  next if result.key?(key)
85
94
  result[key] = value
86
95
  end
@@ -100,12 +109,12 @@ module CSVDecision
100
109
 
101
110
  def self.option?(cell)
102
111
  key = cell.downcase.to_sym
103
- return CSV_OPTION_NAMES[key] if CSV_OPTION_NAMES.key?(key)
112
+ return Options::CSV_NAMES[key] if Options::CSV_NAMES.key?(key)
104
113
  end
105
114
  private_class_method :option?
106
115
 
107
116
  def self.validate(options)
108
- invalid_options = options.keys - VALID_OPTIONS.keys
117
+ invalid_options = options.keys - Options::VALID.keys
109
118
 
110
119
  return if invalid_options.empty?
111
120
 
@@ -1,50 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # CSV Decision: CSV based Ruby decision tables.
4
- # Created December 2017 by Brett Vickers
4
+ # Created December 2017.
5
+ # @author Brett Vickers.
5
6
  # See LICENSE and README.md for details.
6
7
  module CSVDecision
8
+ # All CSVDecision specific errors
7
9
  class Error < StandardError; end
10
+
11
+ # Error validating a cell when parsing input table data.
8
12
  class CellValidationError < Error; end
13
+
14
+ # Table parsing error message enhanced to include the file being processed
9
15
  class FileError < Error; end
10
16
 
11
17
  # Builds a decision table from the input data - which may either be a file, CSV string
12
- # or array of arrays.
18
+ # or an array of arrays.
19
+ #
20
+ # @example Simple Example
21
+ # If you have cloned the gem's git repo, then you can run:
22
+ # table = CSVDecision.parse(Pathname('spec/data/valid/simple_example.csv')) #=> CSVDecision::Table
23
+ # table.decide(topic: 'finance', region: 'Europe') #=> team_member: 'Donald'
24
+ #
25
+ # @param data [Pathname, File, Array<Array<String>>, String] input data given as
26
+ # a CSV file, array of arrays or CSV string.
27
+ # @param options [Hash] Options hash supplied by the user.
28
+ #
29
+ # @option options [Boolean] :first_match Stop scanning after find the first row match.
30
+ # @option options [Boolean] :regexp_implicit Make regular expressions implicit rather than requiring the
31
+ # comparator =~. (Use with care.)
32
+ # @option options [Boolean] :text_only All cells treated as simple strings by turning off all special matchers.
33
+ # @option options [Array<Matchers::Matcher>] :matchers May be used to control the inclusion and ordering of
34
+ # special matchers. (Advanced feature, use with care.)
35
+ #
36
+ # @return [CSVDecision::Table] Resulting decision table.
37
+ #
38
+ # @raise [CSVDecision::CellValidationError] Table parsing cell validation error.
39
+ # @raise [CSVDecision::FileError] Table parsing error for a named CSV file.
13
40
  #
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
41
  def self.parse(data, options = {})
26
- Parse.table(input: data, options: Options.normalize(options))
42
+ Parse.table(data: data, options: Options.normalize(options))
27
43
  end
28
44
 
29
- # Parse the CSV file and create a new decision table object.
30
- #
31
- # (see #parse)
45
+ # Methods to parse the decision table and return CSVDecision::Table object.
32
46
  module Parse
33
- def self.table(input:, options:)
47
+ # Parse the CSV file or input data and create a new decision table object.
48
+ #
49
+ # @param (see CSVDecision.parse)
50
+ # @return (see CSVDecision.parse)
51
+ def self.table(data:, options:)
34
52
  table = CSVDecision::Table.new
35
53
 
36
54
  # In most cases the decision table will be loaded from a CSV file.
37
- table.file = input if Data.input_file?(input)
55
+ table.file = data if Data.input_file?(data)
56
+
57
+ parse_table(table: table, input: data, options: options)
38
58
 
39
- parse_table(table: table, input: input, options: options)
59
+ table.freeze
40
60
  rescue CSVDecision::Error => exp
41
61
  raise_error(file: table.file, exception: exp)
42
62
  end
43
63
 
44
64
  def self.raise_error(file:, exception:)
45
65
  raise exception unless file
46
- message = "error processing CSV file #{table.file}\n#{exception.inspect}"
47
- raise CSVDecision::FileError, message
66
+
67
+ raise CSVDecision::FileError,
68
+ "error processing CSV file #{file}\n#{exception.inspect}"
48
69
  end
49
70
  private_class_method :raise_error
50
71
 
@@ -59,34 +80,20 @@ module CSVDecision
59
80
  # Parse the header row
60
81
  table.columns = CSVDecision::Columns.new(table)
61
82
 
62
- parse_data(table: table, matchers: matchers(table.options).freeze)
63
-
64
- table.freeze
83
+ parse_data(table: table, matchers: Matchers.new(options))
65
84
  end
66
85
  private_class_method :parse_table
67
86
 
68
87
  def self.parse_data(table:, matchers:)
69
88
  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)
89
+ row, table.scan_rows[index] = matchers.parse_ins(columns: table.columns.ins, row: row)
90
+ row, table.outs_rows[index] = matchers.parse_outs(columns: table.columns.outs, row: row)
77
91
 
78
92
  row.freeze
79
- table.scan_rows[index].freeze
80
93
  end
81
94
 
82
95
  table.columns.freeze
83
96
  end
84
-
85
97
  private_class_method :parse_data
86
-
87
- def self.matchers(options)
88
- options[:matchers].collect { |klass| klass.new(options) }
89
- end
90
- private_class_method :matchers
91
98
  end
92
99
  end
@@ -8,26 +8,38 @@ require 'values'
8
8
  module CSVDecision
9
9
  # Data row object indicating which columns are constants versus procs.
10
10
  class ScanRow
11
- attr_accessor :constants
12
- attr_accessor :procs
11
+ # @return [Array<Integer>] Column indices for simple constants.
12
+ attr_reader :constants
13
+
14
+ # @return [Array<Integer>] Column indices for Proc objects.
15
+ attr_reader :procs
13
16
 
14
17
  def initialize
15
18
  @constants = []
16
19
  @procs = []
17
20
  end
18
21
 
19
- def scan_columns(columns:, matchers:, row:)
22
+ # Scan all the specified +columns+ (e.g., inputs) in the given +data+ row using the +matchers+
23
+ # array supplied.
24
+ #
25
+ # @param row [Array<String>] Data row.
26
+ # @param columns [Array<Columns::Entry>] Array of column dictionary entries.
27
+ # @param matchers [Array<Matchers::Matcher>] Array of table cell matchers.
28
+ # @return [Array] Data row with anything not a string constant replaced with a Proc or a non-string constant.
29
+ def scan_columns(row:, columns:, matchers:)
20
30
  columns.each_pair do |col, column|
21
31
  # Empty cell matches everything, and so never needs to be scanned
22
32
  next if (cell = row[col]) == ''
23
33
 
24
34
  # If the column is text only then no special matchers need be invoked
25
- next constants << col if column.text_only
35
+ next @constants << col if column.text_only
26
36
 
27
37
  # Need to scan the cell against all matchers, and possibly overwrite
28
38
  # the cell contents with a proc.
29
39
  row[col] = scan_cell(col: col, matchers: matchers, cell: cell)
30
40
  end
41
+
42
+ row
31
43
  end
32
44
 
33
45
  def match_constants?(row:, scan_cols:)
@@ -59,18 +71,18 @@ module CSVDecision
59
71
  return set(proc, col) if proc
60
72
 
61
73
  # Just a plain constant
62
- constants << col
74
+ @constants << col
63
75
  cell
64
76
  end
65
77
 
66
78
  def set(proc, col)
67
79
  # Unbox a constant
68
80
  if proc.type == :constant
69
- constants << col
81
+ @constants << col
70
82
  return proc.function
71
83
  end
72
84
 
73
- procs << col
85
+ @procs << col
74
86
  proc
75
87
  end
76
88
  end
@@ -0,0 +1,73 @@
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
+ # Recognise column symbol comparison expressions in input column data cells - e.g., +> :column+ or +!= :column+.
8
+ module Symbol
9
+ # Symbol comparison - e.g., > :column or != :column
10
+ SYMBOL_COMPARE =
11
+ "(?<comparator>#{Matchers::EQUALS}|!=|<|>|>=|<=)?\\s*:(?<name>#{Header::COLUMN_NAME})"
12
+ private_constant :SYMBOL_COMPARE
13
+
14
+ # Symbol comparision regular expression.
15
+ SYMBOL_COMPARE_RE = Matchers.regexp(SYMBOL_COMPARE)
16
+ private_constant :SYMBOL_COMPARE_RE
17
+
18
+ # These procs compare one input hash value to another, and so do not coerce numeric values.
19
+ # Note that we do *not* check +hash.key?(symbol)+, so a +nil+ value will match a missing hash key.
20
+ EQUALITY = {
21
+ ':=' => proc { |symbol, value, hash| value == hash[symbol] },
22
+ '!=' => proc { |symbol, value, hash| value != hash[symbol] }
23
+ }.freeze
24
+ private_constant :EQUALITY
25
+
26
+ def self.compare_proc(compare)
27
+ proc { |symbol, value, hash| compare?(lhs: value, compare: compare, rhs: hash[symbol]) }
28
+ end
29
+ private_class_method :compare_proc
30
+
31
+ COMPARE = {
32
+ # Equality and inequality - create a lambda proc by calling with the actual column name symbol
33
+ ':=' => ->(symbol) { EQUALITY[':='].curry[symbol].freeze },
34
+ '=' => ->(symbol) { EQUALITY[':='].curry[symbol].freeze },
35
+ '==' => ->(symbol) { EQUALITY[':='].curry[symbol].freeze },
36
+ '!=' => ->(symbol) { EQUALITY['!='].curry[symbol].freeze },
37
+
38
+ # Comparisons - create a lambda proc by calling with the actual column name symbol.
39
+ '>' => ->(symbol) { compare_proc(:'>' ).curry[symbol].freeze },
40
+ '>=' => ->(symbol) { compare_proc(:'>=').curry[symbol].freeze },
41
+ '<' => ->(symbol) { compare_proc(:'<' ).curry[symbol].freeze },
42
+ '<=' => ->(symbol) { compare_proc(:'<=').curry[symbol].freeze },
43
+ }.freeze
44
+ private_constant :COMPARE
45
+
46
+ def self.compare?(lhs:, compare:, rhs:)
47
+ # Is the rhs a superclass of lhs, and does rhs respond to the compare method?
48
+ return lhs.public_send(compare, rhs) if lhs.is_a?(rhs.class) && rhs.respond_to?(compare)
49
+
50
+ false
51
+ end
52
+ private_class_method :compare?
53
+
54
+ # E.g., > :col, we get comparator: >, args: col
55
+ def self.comparison(comparator:, name:)
56
+ function = COMPARE[comparator]
57
+ Proc.with(type: :symbol, function: function[name])
58
+ end
59
+ private_class_method :comparison
60
+
61
+ # @param (see Matchers::Matcher#matches?)
62
+ # @return (see Matchers::Matcher#matches?)
63
+ def self.matches?(cell)
64
+ match = SYMBOL_COMPARE_RE.match(cell)
65
+ return false unless match
66
+
67
+ comparator = match['comparator'] || '='
68
+ name = match['name'].to_sym
69
+
70
+ comparison(comparator: comparator, name: name)
71
+ end
72
+ end
73
+ end