csv_decision2 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
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