csv_decision2 0.5.1

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 (134) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +3 -0
  3. data/.coveralls.yml +2 -0
  4. data/.gitignore +14 -0
  5. data/.rspec +2 -0
  6. data/.rubocop.yml +30 -0
  7. data/.travis.yml +6 -0
  8. data/CHANGELOG.md +85 -0
  9. data/Dockerfile +6 -0
  10. data/Gemfile +7 -0
  11. data/LICENSE +21 -0
  12. data/README.md +356 -0
  13. data/benchmarks/rufus_decision.rb +158 -0
  14. data/csv_decision2.gemspec +38 -0
  15. data/doc/CSVDecision/CellValidationError.html +143 -0
  16. data/doc/CSVDecision/Columns/Default.html +589 -0
  17. data/doc/CSVDecision/Columns/Dictionary.html +801 -0
  18. data/doc/CSVDecision/Columns/Entry.html +508 -0
  19. data/doc/CSVDecision/Columns.html +1259 -0
  20. data/doc/CSVDecision/Constant.html +254 -0
  21. data/doc/CSVDecision/Data.html +479 -0
  22. data/doc/CSVDecision/Decide.html +302 -0
  23. data/doc/CSVDecision/Decision.html +1011 -0
  24. data/doc/CSVDecision/Defaults.html +291 -0
  25. data/doc/CSVDecision/Dictionary/Entry.html +1147 -0
  26. data/doc/CSVDecision/Dictionary.html +426 -0
  27. data/doc/CSVDecision/Error.html +139 -0
  28. data/doc/CSVDecision/FileError.html +143 -0
  29. data/doc/CSVDecision/Function.html +240 -0
  30. data/doc/CSVDecision/Guard.html +245 -0
  31. data/doc/CSVDecision/Header.html +647 -0
  32. data/doc/CSVDecision/Index.html +741 -0
  33. data/doc/CSVDecision/Input.html +404 -0
  34. data/doc/CSVDecision/Load.html +296 -0
  35. data/doc/CSVDecision/Matchers/Constant.html +484 -0
  36. data/doc/CSVDecision/Matchers/Function.html +511 -0
  37. data/doc/CSVDecision/Matchers/Guard.html +503 -0
  38. data/doc/CSVDecision/Matchers/Matcher.html +507 -0
  39. data/doc/CSVDecision/Matchers/Numeric.html +415 -0
  40. data/doc/CSVDecision/Matchers/Pattern.html +491 -0
  41. data/doc/CSVDecision/Matchers/Proc.html +704 -0
  42. data/doc/CSVDecision/Matchers/Range.html +379 -0
  43. data/doc/CSVDecision/Matchers/Symbol.html +426 -0
  44. data/doc/CSVDecision/Matchers.html +1567 -0
  45. data/doc/CSVDecision/Numeric.html +259 -0
  46. data/doc/CSVDecision/Options.html +443 -0
  47. data/doc/CSVDecision/Parse.html +282 -0
  48. data/doc/CSVDecision/Paths.html +742 -0
  49. data/doc/CSVDecision/Result.html +1200 -0
  50. data/doc/CSVDecision/Scan/InputHashes.html +369 -0
  51. data/doc/CSVDecision/Scan.html +313 -0
  52. data/doc/CSVDecision/ScanRow.html +866 -0
  53. data/doc/CSVDecision/Symbol.html +256 -0
  54. data/doc/CSVDecision/Table.html +1470 -0
  55. data/doc/CSVDecision/TableValidationError.html +143 -0
  56. data/doc/CSVDecision/Validate.html +422 -0
  57. data/doc/CSVDecision.html +621 -0
  58. data/doc/_index.html +471 -0
  59. data/doc/class_list.html +51 -0
  60. data/doc/css/common.css +1 -0
  61. data/doc/css/full_list.css +58 -0
  62. data/doc/css/style.css +499 -0
  63. data/doc/file.README.html +421 -0
  64. data/doc/file_list.html +56 -0
  65. data/doc/frames.html +17 -0
  66. data/doc/index.html +421 -0
  67. data/doc/js/app.js +248 -0
  68. data/doc/js/full_list.js +216 -0
  69. data/doc/js/jquery.js +4 -0
  70. data/doc/method_list.html +1163 -0
  71. data/doc/top-level-namespace.html +110 -0
  72. data/docker-compose.yml +13 -0
  73. data/lib/csv_decision/columns.rb +192 -0
  74. data/lib/csv_decision/data.rb +92 -0
  75. data/lib/csv_decision/decision.rb +196 -0
  76. data/lib/csv_decision/defaults.rb +47 -0
  77. data/lib/csv_decision/dictionary.rb +180 -0
  78. data/lib/csv_decision/header.rb +83 -0
  79. data/lib/csv_decision/index.rb +107 -0
  80. data/lib/csv_decision/input.rb +121 -0
  81. data/lib/csv_decision/load.rb +36 -0
  82. data/lib/csv_decision/matchers/constant.rb +74 -0
  83. data/lib/csv_decision/matchers/function.rb +56 -0
  84. data/lib/csv_decision/matchers/guard.rb +142 -0
  85. data/lib/csv_decision/matchers/numeric.rb +44 -0
  86. data/lib/csv_decision/matchers/pattern.rb +94 -0
  87. data/lib/csv_decision/matchers/range.rb +95 -0
  88. data/lib/csv_decision/matchers/symbol.rb +149 -0
  89. data/lib/csv_decision/matchers.rb +220 -0
  90. data/lib/csv_decision/options.rb +124 -0
  91. data/lib/csv_decision/parse.rb +165 -0
  92. data/lib/csv_decision/paths.rb +78 -0
  93. data/lib/csv_decision/result.rb +204 -0
  94. data/lib/csv_decision/scan.rb +117 -0
  95. data/lib/csv_decision/scan_row.rb +142 -0
  96. data/lib/csv_decision/table.rb +101 -0
  97. data/lib/csv_decision/validate.rb +85 -0
  98. data/lib/csv_decision.rb +45 -0
  99. data/spec/csv_decision/columns_spec.rb +251 -0
  100. data/spec/csv_decision/constant_spec.rb +36 -0
  101. data/spec/csv_decision/data_spec.rb +50 -0
  102. data/spec/csv_decision/decision_spec.rb +19 -0
  103. data/spec/csv_decision/examples_spec.rb +242 -0
  104. data/spec/csv_decision/index_spec.rb +58 -0
  105. data/spec/csv_decision/input_spec.rb +55 -0
  106. data/spec/csv_decision/load_spec.rb +28 -0
  107. data/spec/csv_decision/matchers/function_spec.rb +82 -0
  108. data/spec/csv_decision/matchers/guard_spec.rb +170 -0
  109. data/spec/csv_decision/matchers/numeric_spec.rb +47 -0
  110. data/spec/csv_decision/matchers/pattern_spec.rb +183 -0
  111. data/spec/csv_decision/matchers/range_spec.rb +70 -0
  112. data/spec/csv_decision/matchers/symbol_spec.rb +67 -0
  113. data/spec/csv_decision/options_spec.rb +94 -0
  114. data/spec/csv_decision/parse_spec.rb +44 -0
  115. data/spec/csv_decision/table_spec.rb +683 -0
  116. data/spec/csv_decision_spec.rb +7 -0
  117. data/spec/data/invalid/empty.csv +0 -0
  118. data/spec/data/invalid/invalid_header1.csv +4 -0
  119. data/spec/data/invalid/invalid_header2.csv +4 -0
  120. data/spec/data/invalid/invalid_header3.csv +4 -0
  121. data/spec/data/invalid/invalid_header4.csv +4 -0
  122. data/spec/data/valid/benchmark_regexp.csv +10 -0
  123. data/spec/data/valid/index_example.csv +13 -0
  124. data/spec/data/valid/multi_column_index.csv +10 -0
  125. data/spec/data/valid/multi_column_index2.csv +12 -0
  126. data/spec/data/valid/options_in_file1.csv +5 -0
  127. data/spec/data/valid/options_in_file2.csv +5 -0
  128. data/spec/data/valid/options_in_file3.csv +13 -0
  129. data/spec/data/valid/regular_expressions.csv +11 -0
  130. data/spec/data/valid/simple_constants.csv +5 -0
  131. data/spec/data/valid/simple_example.csv +10 -0
  132. data/spec/data/valid/valid.csv +4 -0
  133. data/spec/spec_helper.rb +106 -0
  134. metadata +352 -0
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Parse the CSV file's header row. These methods are only required at table load time.
9
+ # @api private
10
+ module Dictionary
11
+ # Add a new symbol to the dictionary of named input and output columns.
12
+ #
13
+ # @param columns [{Symbol=>Symbol}] Hash of column names with key values :in or :out.
14
+ # @param name [Symbol] Symbolized column name.
15
+ # @param out [false, Index] False if an input column, otherwise the index of the output column.
16
+ # @return [Hash{Symbol=>[:in, Integer]}] Column dictionary updated with the new name.
17
+ def self.add_name(columns:, name:, out: false)
18
+ Validate.name(columns: columns, name: name, out: out)
19
+
20
+ columns[name] = out ? out : :in
21
+ columns
22
+ end
23
+
24
+ # Column dictionary entries.
25
+ class Entry
26
+ # Table used to build a column dictionary entry.
27
+ ENTRY = {
28
+ in: { type: :in, eval: nil },
29
+ 'in/text': { type: :in, eval: false },
30
+ set: { type: :set, eval: nil, set_if: true },
31
+ 'set/nil?': { type: :set, eval: nil, set_if: :nil? },
32
+ 'set/blank?': { type: :set, eval: nil, set_if: :blank? },
33
+ out: { type: :out, eval: nil },
34
+ 'out/text': { type: :out, eval: false },
35
+ guard: { type: :guard, eval: true },
36
+ if: { type: :if, eval: true },
37
+ path: { type: :path, eval: false }
38
+ }.freeze
39
+ private_constant :ENTRY
40
+
41
+ # Input column types.
42
+ INS_TYPES = %i[in guard set].freeze
43
+ private_constant :INS_TYPES
44
+
45
+ # Create a new column dictionary entry defaulting attributes from the column type,
46
+ # which is looked up in the above table.
47
+ #
48
+ # @param name [Symbol] Column name.
49
+ # @param type [Symbol] Column type.
50
+ # @return [Entry] Column dictionary entry.
51
+ def self.create(name:, type:)
52
+ entry = ENTRY[type]
53
+ new(name: name,
54
+ eval: entry[:eval], # Set if the column requires functions evaluated
55
+ type: entry[:type], # Column type
56
+ set_if: entry[:set_if], # Set if the column has a conditional default
57
+ indexed: entry[:type] != :guard) # A guard column cannot be indexed.
58
+ end
59
+
60
+ # @return [Boolean] Return true is this is an input column, false otherwise.
61
+ def ins?
62
+ @ins
63
+ end
64
+
65
+ # @return [Symbol] Column name.
66
+ attr_reader :name
67
+
68
+ # @return [Symbol] Column type.
69
+ attr_reader :type
70
+
71
+ # @return [Boolean] Returns true if this column is indexed
72
+ attr_accessor :indexed
73
+
74
+ # @return [nil, Boolean] If set to true then this column has procs that
75
+ # need evaluating, otherwise it only contains constants.
76
+ attr_accessor :eval
77
+
78
+ # @return [nil, true, Symbol] Defined for columns of type :set, nil otherwise.
79
+ # If true, then default is set unconditionally, otherwise the method symbol
80
+ # sent to the input hash value that must evaluate to a truthy value.
81
+ attr_reader :set_if
82
+
83
+ # @return [Matchers::Proc, Object] For a column of type set: gives the proc that must be
84
+ # evaluated to set the default value. If not a proc, then it's some type of constant.
85
+ attr_accessor :function
86
+
87
+ # @param name (see #name)
88
+ # @param type (see #type)
89
+ # @param eval (see #eval)
90
+ # @param set_if (see #set_if)
91
+ # @param indexed (see #indexed)
92
+ def initialize(name:, type:, eval: nil, set_if: nil, indexed: nil)
93
+ @name = name
94
+ @type = type
95
+ @eval = eval
96
+ @set_if = set_if
97
+ @function = nil
98
+ @ins = INS_TYPES.member?(type)
99
+ @indexed = indexed
100
+ end
101
+
102
+ # Convert the object's attributes to a hash.
103
+ #
104
+ # @return [Hash{Symbol=>[nil, Boolean, Symbol]}]
105
+ def to_h
106
+ {
107
+ name: @name,
108
+ type: @type,
109
+ eval: @eval,
110
+ set_if: @set_if
111
+ }
112
+ end
113
+ end
114
+
115
+ # Classify and build a dictionary of all input and output columns by
116
+ # parsing the header row.
117
+ #
118
+ # @param header [Array<String>] The header row after removing any empty columns.
119
+ # @param dictionary [Columns::Dictionary] Table's columns dictionary.
120
+ # @return [Columns::Dictionary] Table's columns dictionary.
121
+ def self.build(header:, dictionary:)
122
+ header.each_with_index do |cell, index|
123
+ dictionary = parse_cell(cell: cell, index: index, dictionary: dictionary)
124
+ end
125
+
126
+ dictionary
127
+ end
128
+
129
+ def self.parse_cell(cell:, index:, dictionary:)
130
+ column_type, column_name = Validate.column(cell: cell, index: index)
131
+
132
+ dictionary_entry(dictionary: dictionary,
133
+ entry: Entry.create(name: column_name, type: column_type),
134
+ index: index)
135
+ end
136
+ private_class_method :parse_cell
137
+
138
+ def self.dictionary_entry(dictionary:, entry:, index:)
139
+ case entry.type
140
+ # A guard column is still added to the ins hash for parsing as an input column.
141
+ when :in, :guard, :set
142
+ input_entry(dictionary: dictionary, entry: entry, index: index)
143
+
144
+ when :out, :if
145
+ output_entry(dictionary: dictionary, entry: entry, index: index)
146
+
147
+ when :path
148
+ dictionary.paths[index] = entry
149
+ end
150
+
151
+ dictionary
152
+ end
153
+ private_class_method :dictionary_entry
154
+
155
+ def self.output_entry(dictionary:, entry:, index:)
156
+ dictionary.outs[index] = entry
157
+
158
+ case entry.type
159
+ # if: columns are anonymous, even if the user names them
160
+ when :if
161
+ dictionary.ifs[index] = entry
162
+
163
+ when :out
164
+ Dictionary.add_name(columns: dictionary.columns, name: entry.name, out: index)
165
+ end
166
+ end
167
+ private_class_method :output_entry
168
+
169
+ def self.input_entry(dictionary:, entry:, index:)
170
+ dictionary.ins[index] = entry
171
+
172
+ # Default function will set the input value unconditionally or conditionally.
173
+ dictionary.defaults[index] = entry if entry.type == :set
174
+
175
+ # guard: columns are anonymous
176
+ Dictionary.add_name(columns: dictionary.columns, name: entry.name) unless entry.type == :guard
177
+ end
178
+ private_class_method :input_entry
179
+ end
180
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Parse the CSV file's header row. These methods are only required at table load time.
9
+ # @api private
10
+ module Header
11
+ # Column types recognised in the header row.
12
+ COLUMN_TYPE = %r{
13
+ \A(?<type>in/text|in|out/text|out|guard|if|set/nil\?|set/blank\?|set|path)
14
+ \s*:\s*(?<name>\S?.*)\z
15
+ }xi
16
+
17
+ # Regular expression string for a column name.
18
+ # More lenient than a Ruby method name - note any spaces will have been replaced with
19
+ # underscores.
20
+ COLUMN_NAME = "\\w[\\w:/!?]*"
21
+
22
+ # Regular expression for matching a column name.
23
+ COLUMN_NAME_RE = Matchers.regexp(Header::COLUMN_NAME)
24
+ private_constant :COLUMN_NAME_RE
25
+
26
+ # Return true if column name is valid.
27
+ #
28
+ # @param column_name [String]
29
+ # @return [Boolean]
30
+ def self.column_name?(column_name)
31
+ COLUMN_NAME_RE.match?(column_name)
32
+ end
33
+
34
+ # Check if the given row contains a recognisable header cell.
35
+ #
36
+ # @param row [Array<String>] Header row.
37
+ # @return [Boolean] Return true if the row looks like a header.
38
+ def self.row?(row)
39
+ row.any? { |cell| COLUMN_TYPE.match?(cell) }
40
+ end
41
+
42
+ # Strip empty columns from all data rows.
43
+ #
44
+ # @param rows [Array<Array<String>>] Data rows.
45
+ # @return [Array<Array<String>>] Data array after removing any empty columns
46
+ # and the header row.
47
+ def self.strip_empty_columns(rows:)
48
+ empty_cols = empty_columns?(row: rows.first)
49
+ Data.strip_columns(data: rows, empty_columns: empty_cols) if empty_cols
50
+
51
+ # Remove header row from the data array.
52
+ rows.shift
53
+ end
54
+
55
+ # Parse the header row, and the defaults row if present.
56
+ # @param table [CSVDecision::Table] Decision table being parsed.
57
+ # @param matchers [Array<Matchers::Matcher>] Array of special cell matchers.
58
+ # @return [CSVDecision::Columns] Table columns object.
59
+ def self.parse(table:, matchers:)
60
+ # Parse the header row
61
+ table.columns = CSVDecision::Columns.new(table)
62
+
63
+ # Parse the defaults row if present
64
+ return table.columns if table.columns.defaults.blank?
65
+
66
+ table.columns.defaults =
67
+ Defaults.parse(columns: table.columns, matchers: matchers.outs, row: table.rows.shift)
68
+
69
+ table.columns
70
+ end
71
+
72
+ # Build an array of all empty column indices.
73
+ # @param row [Array]
74
+ # @return [false, Array<Integer>]
75
+ def self.empty_columns?(row:)
76
+ result = []
77
+ row&.each_with_index { |cell, index| result << index if cell == '' }
78
+
79
+ result.empty? ? false : result
80
+ end
81
+ private_class_method :empty_columns?
82
+ end
83
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers.
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Build an index for a decision table with one or more input columns
9
+ # designated as keys
10
+ # @api private
11
+ class Index
12
+ # Build the index on the designated number of input columns.
13
+ #
14
+ # @param table [CSVDecision::Table] Decision table being indexed.
15
+ # @return [CSVDecision::Index] The built index.
16
+ def self.build(table:)
17
+ # Do we even have an index?
18
+ key_cols = index_columns(columns: table.columns.ins)
19
+ return if key_cols.empty?
20
+
21
+ table.index = Index.new(table: table, columns: key_cols)
22
+
23
+ # Indexed columns do not need to be scanned
24
+ trim_scan_rows(scan_rows: table.scan_rows, index_columns: table.index.columns)
25
+
26
+ table
27
+ end
28
+
29
+ # @param current_value [Integer, Array] Current index key value.
30
+ # @param index [Integer] Array row index to be included in the table index entry.
31
+ # @return [Integer, Array] New index key value.
32
+ def self.value(current_value, index)
33
+ return integer_value(current_value, index) if current_value.is_a?(Integer)
34
+
35
+ array_value(current_value, index)
36
+
37
+ current_value
38
+ end
39
+
40
+ def self.trim_scan_rows(scan_rows:, index_columns:)
41
+ scan_rows.each { |scan_row| scan_row.constants = scan_row.constants - index_columns }
42
+ end
43
+ private_class_method :trim_scan_rows
44
+
45
+ def self.index_columns(columns:)
46
+ key_cols = []
47
+ columns.each_pair { |col, column| key_cols << col if column.indexed }
48
+
49
+ key_cols
50
+ end
51
+ private_class_method :index_columns
52
+
53
+ # Current value is a row index integer
54
+ def self.integer_value(current_value, index)
55
+ # Is the new row index contiguous with the last start row/end row range?
56
+ current_value + 1 == index ? [[current_value, index]] : [current_value, index]
57
+ end
58
+ private_class_method :integer_value
59
+
60
+ # Current value is an array of row indexes
61
+ def self.array_value(current_value, index)
62
+ start_row, end_row = current_value.last
63
+
64
+ end_row = start_row if end_row.nil?
65
+
66
+ # Is the new row index contiguous with the last start row/end row range?
67
+ end_row + 1 == index ? current_value[-1] = [start_row, index] : current_value << index
68
+ end
69
+ private_class_method :array_value
70
+
71
+ # @return [Hash] The index hash mapping in input values to one or more data array row indexes.
72
+ attr_reader :hash
73
+
74
+ # @return [Array<Integer>] Array of column indices
75
+ attr_reader :columns
76
+
77
+ # @param table [CSVDecision::Table] Decision table.
78
+ # @param columns [Array<Index>] Array of column indexes to be indexed.
79
+ def initialize(table:, columns:)
80
+ @columns = columns
81
+ @hash = {}
82
+
83
+ build(table)
84
+
85
+ freeze
86
+ end
87
+
88
+ private
89
+
90
+ def build(table)
91
+ table.each do |row, index|
92
+ key = build_key(row: row)
93
+
94
+ current_value = @hash.key?(key)
95
+ @hash[key] = current_value ? Index.value(@hash[key], index) : index
96
+ end
97
+ end
98
+
99
+ def build_key(row:)
100
+ if @columns.count == 1
101
+ row[@columns[0]]
102
+ else
103
+ @columns.map { |col| row[col] }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Parse the input hash.
9
+ # @api private
10
+ module Input
11
+ # @param (see Decision.make)
12
+ # @return [Hash{Symbol=>Object}]
13
+ def self.parse(table:, input:, symbolize_keys:)
14
+ validate(input)
15
+
16
+ parsed_input =
17
+ parse_data(table: table, input: symbolize_keys ? input.symbolize_keys : input)
18
+
19
+ parsed_input[:key] = parse_key(table: table, hash: parsed_input[:hash]) if table.index
20
+ parsed_input
21
+ end
22
+
23
+ # @param table [CSVDecision::Table] Decision table.
24
+ # @param input [Hash] Input hash (keys may or may not be symbolized)
25
+ # @return [Hash{Symbol=>Object}]
26
+ def self.parse_data(table:, input:)
27
+ defaulted_columns = table.columns.defaults
28
+
29
+ # Code path optimized for no defaults
30
+ return parse_cells(table: table, input: input) if defaulted_columns.empty?
31
+
32
+ parse_defaulted(table: table, input: input, defaulted_columns: defaulted_columns)
33
+ end
34
+
35
+ def self.parse_key(table:, hash:)
36
+ return scan_key(table: table, hash: hash) if table.index.columns.count == 1
37
+
38
+ scan_keys(table: table, hash: hash).freeze
39
+ end
40
+ private_class_method :parse_key
41
+
42
+ def self.scan_key(table:, hash:)
43
+ col = table.index.columns[0]
44
+ column = table.columns.ins[col]
45
+
46
+ hash[column.name]
47
+ end
48
+ private_class_method :scan_key
49
+
50
+ def self.scan_keys(table:, hash:)
51
+ table.index.columns.map do |col|
52
+ column = table.columns.ins[col]
53
+
54
+ hash[column.name]
55
+ end
56
+ end
57
+ private_class_method :scan_keys
58
+
59
+ def self.validate(input)
60
+ return if input.is_a?(Hash) && !input.empty?
61
+ raise ArgumentError, 'input must be a non-empty hash'
62
+ end
63
+ private_class_method :validate
64
+
65
+ def self.parse_cells(table:, input:)
66
+ scan_cols = {}
67
+ table.columns.ins.each_pair do |col, column|
68
+ next if column.type == :guard
69
+
70
+ scan_cols[col] = input[column.name]
71
+ end
72
+
73
+ { hash: input, scan_cols: scan_cols }
74
+ end
75
+ private_class_method :parse_cells
76
+
77
+ def self.parse_defaulted(table:, input:, defaulted_columns:)
78
+ scan_cols = {}
79
+
80
+ table.columns.ins.each_pair do |col, column|
81
+ next if column.type == :guard
82
+
83
+ scan_cols[col] =
84
+ default_value(default: defaulted_columns[col], input: input, column: column)
85
+
86
+ # Also update the input hash with the default value.
87
+ input[column.name] = scan_cols[col]
88
+ end
89
+
90
+ { hash: input, scan_cols: scan_cols }
91
+ end
92
+ private_class_method :parse_defaulted
93
+
94
+ def self.default_value(default:, input:, column:)
95
+ value = input[column.name]
96
+
97
+ # Do we even have a default entry for this column?
98
+ return value if default.nil?
99
+
100
+ # Has the set condition been met, or is it unconditional?
101
+ return value unless default_if?(default.set_if, value)
102
+
103
+ # Expression may be a Proc that needs evaluating against the input hash,
104
+ # or else a constant.
105
+ eval_default(default.function, input)
106
+ end
107
+ private_class_method :default_value
108
+
109
+ def self.default_if?(set_if, value)
110
+ set_if == true || (value.respond_to?(set_if) && value.send(set_if))
111
+ end
112
+ private_class_method :default_if?
113
+
114
+ # Expression may be a Proc that needs evaluating against the input hash,
115
+ # or else a constant.
116
+ def self.eval_default(expression, input)
117
+ expression.is_a?(::Proc) ? expression[input] : expression
118
+ end
119
+ private_class_method :eval_default
120
+ end
121
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Load all the CSV files located in the designated folder path.
9
+ #
10
+ # @param path [Pathname] Directory containing CSV decision table files.
11
+ # @param options (see CSVDecision.parse)
12
+ # @return [Hash{Symbol=><CSVDecision::Table>}] Hash of decision tables keyed by the CSV
13
+ # file's symbolized base name.
14
+ # @raise [ArgumentError] Invalid path name or folder.
15
+ def self.load(path, options = {})
16
+ Load.path(path: path, options: options)
17
+ end
18
+
19
+ # Load all CSV files located in the specified folder.
20
+ # @api private
21
+ module Load
22
+ # (see CSVDecision.load)
23
+ def self.path(path:, options:)
24
+ raise ArgumentError, 'path argument must be a Pathname' unless path.is_a?(Pathname)
25
+ raise ArgumentError, 'path argument not a valid folder' unless path.directory?
26
+
27
+ tables = {}
28
+ Dir[path.join('*.csv')].each do |file_name|
29
+ table_name = File.basename(file_name, '.csv').to_sym
30
+ tables[table_name] = CSVDecision.parse(Pathname(file_name), options)
31
+ end
32
+
33
+ tables.freeze
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Methods to assign a matcher to data cells
9
+ # @api private
10
+ class Matchers
11
+ # Cell constant matcher - e.g., := true, = nil.
12
+ class Constant < Matcher
13
+ # Cell constant expression specified by prefixing the value with one of the three
14
+ # equality symbols.
15
+ EXPRESSION = Matchers.regexp("(?<operator>#{Matchers::EQUALS})\\s*(?<value>\\S.*)")
16
+ private_constant :EXPRESSION
17
+
18
+ # rubocop: disable Lint/BooleanSymbol
19
+ # Non-numeric constants recognised by CSV Decision.
20
+ NON_NUMERIC = {
21
+ nil: nil,
22
+ true: true,
23
+ false: false
24
+ }.freeze
25
+ private_constant :NON_NUMERIC
26
+ # rubocop: enable Lint/BooleanSymbol
27
+
28
+ # @param (see Matchers::Matcher#matches?)
29
+ # @return (see Matchers::Matcher#matches?)
30
+ # @api private
31
+ def self.matches?(cell)
32
+ return false unless (match = EXPRESSION.match(cell))
33
+
34
+ proc = non_numeric?(match)
35
+ return proc if proc
36
+
37
+ numeric?(match)
38
+ end
39
+
40
+ def self.proc(function:)
41
+ Matchers::Proc.new(type: :constant, function: function)
42
+ end
43
+ private_class_method :proc
44
+
45
+ def self.numeric?(match)
46
+ return false unless (value = Matchers.to_numeric(match['value']))
47
+
48
+ proc(function: value)
49
+ end
50
+ private_class_method :numeric?
51
+
52
+ def self.non_numeric?(match)
53
+ name = match['value'].to_sym
54
+ return false unless NON_NUMERIC.key?(name)
55
+
56
+ proc(function: NON_NUMERIC[name])
57
+ end
58
+ private_class_method :non_numeric?
59
+
60
+ # If a constant expression returns a Proc of type :constant,
61
+ # otherwise return false.
62
+ #
63
+ # (see Matcher#matches?)
64
+ def matches?(cell)
65
+ Matchers::Constant.matches?(cell)
66
+ end
67
+
68
+ # (see Matcher#outs?)
69
+ def outs?
70
+ true
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ # CSV Decision: CSV based Ruby decision tables.
4
+ # Created December 2017.
5
+ # @author Brett Vickers <brett@phillips-vickers.com>
6
+ # See LICENSE and README.md for details.
7
+ module CSVDecision
8
+ # Methods to assign a matcher to data cells
9
+ # @api private
10
+ class Matchers
11
+ # Match cell against a function call
12
+ # * no arguments - e.g., := present?
13
+ # * with arguments - e.g., :=lookup?(:table)
14
+ # TODO: fully implement
15
+ class Function < Matcher
16
+ # Looks like a function call or symbol expressions, e.g.,
17
+ # == true
18
+ # := function(arg: symbol)
19
+ # == :column_name
20
+ FUNCTION_CALL =
21
+ "(?<operator>=|:=|==|=|<|>|!=|>=|<=|:|!\\s*:)\\s*" \
22
+ "(?<negate>!?)\\s*" \
23
+ "(?<name>#{Header::COLUMN_NAME}|:)(?<args>.*)"
24
+ private_constant :FUNCTION_CALL
25
+
26
+ # Function call regular expression.
27
+ FUNCTION_RE = Matchers.regexp(FUNCTION_CALL)
28
+
29
+ def self.matches?(cell)
30
+ match = FUNCTION_RE.match(cell)
31
+ return false unless match
32
+
33
+ # operator = match['operator']&.gsub(/\s+/, '')
34
+ # name = match['name'].to_sym
35
+ # args = match['args'].strip
36
+ # negate = match['negate'] == Matchers::NEGATE
37
+ end
38
+
39
+ # @param options (see Parse.parse)
40
+ def initialize(options = {})
41
+ @options = options
42
+ end
43
+
44
+ # @param (see Matchers::Matcher#matches?)
45
+ # @return (see Matchers::Matcher#matches?)
46
+ def matches?(cell)
47
+ Function.matches?(cell)
48
+ end
49
+
50
+ # (see Matcher#outs?)
51
+ # def outs?
52
+ # true
53
+ # end
54
+ end
55
+ end
56
+ end