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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +3 -0
- data/.coveralls.yml +2 -0
- data/.gitignore +14 -0
- data/.rspec +2 -0
- data/.rubocop.yml +30 -0
- data/.travis.yml +6 -0
- data/CHANGELOG.md +85 -0
- data/Dockerfile +6 -0
- data/Gemfile +7 -0
- data/LICENSE +21 -0
- data/README.md +356 -0
- data/benchmarks/rufus_decision.rb +158 -0
- data/csv_decision2.gemspec +38 -0
- data/doc/CSVDecision/CellValidationError.html +143 -0
- data/doc/CSVDecision/Columns/Default.html +589 -0
- data/doc/CSVDecision/Columns/Dictionary.html +801 -0
- data/doc/CSVDecision/Columns/Entry.html +508 -0
- data/doc/CSVDecision/Columns.html +1259 -0
- data/doc/CSVDecision/Constant.html +254 -0
- data/doc/CSVDecision/Data.html +479 -0
- data/doc/CSVDecision/Decide.html +302 -0
- data/doc/CSVDecision/Decision.html +1011 -0
- data/doc/CSVDecision/Defaults.html +291 -0
- data/doc/CSVDecision/Dictionary/Entry.html +1147 -0
- data/doc/CSVDecision/Dictionary.html +426 -0
- data/doc/CSVDecision/Error.html +139 -0
- data/doc/CSVDecision/FileError.html +143 -0
- data/doc/CSVDecision/Function.html +240 -0
- data/doc/CSVDecision/Guard.html +245 -0
- data/doc/CSVDecision/Header.html +647 -0
- data/doc/CSVDecision/Index.html +741 -0
- data/doc/CSVDecision/Input.html +404 -0
- data/doc/CSVDecision/Load.html +296 -0
- data/doc/CSVDecision/Matchers/Constant.html +484 -0
- data/doc/CSVDecision/Matchers/Function.html +511 -0
- data/doc/CSVDecision/Matchers/Guard.html +503 -0
- data/doc/CSVDecision/Matchers/Matcher.html +507 -0
- data/doc/CSVDecision/Matchers/Numeric.html +415 -0
- data/doc/CSVDecision/Matchers/Pattern.html +491 -0
- data/doc/CSVDecision/Matchers/Proc.html +704 -0
- data/doc/CSVDecision/Matchers/Range.html +379 -0
- data/doc/CSVDecision/Matchers/Symbol.html +426 -0
- data/doc/CSVDecision/Matchers.html +1567 -0
- data/doc/CSVDecision/Numeric.html +259 -0
- data/doc/CSVDecision/Options.html +443 -0
- data/doc/CSVDecision/Parse.html +282 -0
- data/doc/CSVDecision/Paths.html +742 -0
- data/doc/CSVDecision/Result.html +1200 -0
- data/doc/CSVDecision/Scan/InputHashes.html +369 -0
- data/doc/CSVDecision/Scan.html +313 -0
- data/doc/CSVDecision/ScanRow.html +866 -0
- data/doc/CSVDecision/Symbol.html +256 -0
- data/doc/CSVDecision/Table.html +1470 -0
- data/doc/CSVDecision/TableValidationError.html +143 -0
- data/doc/CSVDecision/Validate.html +422 -0
- data/doc/CSVDecision.html +621 -0
- data/doc/_index.html +471 -0
- data/doc/class_list.html +51 -0
- data/doc/css/common.css +1 -0
- data/doc/css/full_list.css +58 -0
- data/doc/css/style.css +499 -0
- data/doc/file.README.html +421 -0
- data/doc/file_list.html +56 -0
- data/doc/frames.html +17 -0
- data/doc/index.html +421 -0
- data/doc/js/app.js +248 -0
- data/doc/js/full_list.js +216 -0
- data/doc/js/jquery.js +4 -0
- data/doc/method_list.html +1163 -0
- data/doc/top-level-namespace.html +110 -0
- data/docker-compose.yml +13 -0
- data/lib/csv_decision/columns.rb +192 -0
- data/lib/csv_decision/data.rb +92 -0
- data/lib/csv_decision/decision.rb +196 -0
- data/lib/csv_decision/defaults.rb +47 -0
- data/lib/csv_decision/dictionary.rb +180 -0
- data/lib/csv_decision/header.rb +83 -0
- data/lib/csv_decision/index.rb +107 -0
- data/lib/csv_decision/input.rb +121 -0
- data/lib/csv_decision/load.rb +36 -0
- data/lib/csv_decision/matchers/constant.rb +74 -0
- data/lib/csv_decision/matchers/function.rb +56 -0
- data/lib/csv_decision/matchers/guard.rb +142 -0
- data/lib/csv_decision/matchers/numeric.rb +44 -0
- data/lib/csv_decision/matchers/pattern.rb +94 -0
- data/lib/csv_decision/matchers/range.rb +95 -0
- data/lib/csv_decision/matchers/symbol.rb +149 -0
- data/lib/csv_decision/matchers.rb +220 -0
- data/lib/csv_decision/options.rb +124 -0
- data/lib/csv_decision/parse.rb +165 -0
- data/lib/csv_decision/paths.rb +78 -0
- data/lib/csv_decision/result.rb +204 -0
- data/lib/csv_decision/scan.rb +117 -0
- data/lib/csv_decision/scan_row.rb +142 -0
- data/lib/csv_decision/table.rb +101 -0
- data/lib/csv_decision/validate.rb +85 -0
- data/lib/csv_decision.rb +45 -0
- data/spec/csv_decision/columns_spec.rb +251 -0
- data/spec/csv_decision/constant_spec.rb +36 -0
- data/spec/csv_decision/data_spec.rb +50 -0
- data/spec/csv_decision/decision_spec.rb +19 -0
- data/spec/csv_decision/examples_spec.rb +242 -0
- data/spec/csv_decision/index_spec.rb +58 -0
- data/spec/csv_decision/input_spec.rb +55 -0
- data/spec/csv_decision/load_spec.rb +28 -0
- data/spec/csv_decision/matchers/function_spec.rb +82 -0
- data/spec/csv_decision/matchers/guard_spec.rb +170 -0
- data/spec/csv_decision/matchers/numeric_spec.rb +47 -0
- data/spec/csv_decision/matchers/pattern_spec.rb +183 -0
- data/spec/csv_decision/matchers/range_spec.rb +70 -0
- data/spec/csv_decision/matchers/symbol_spec.rb +67 -0
- data/spec/csv_decision/options_spec.rb +94 -0
- data/spec/csv_decision/parse_spec.rb +44 -0
- data/spec/csv_decision/table_spec.rb +683 -0
- data/spec/csv_decision_spec.rb +7 -0
- data/spec/data/invalid/empty.csv +0 -0
- data/spec/data/invalid/invalid_header1.csv +4 -0
- data/spec/data/invalid/invalid_header2.csv +4 -0
- data/spec/data/invalid/invalid_header3.csv +4 -0
- data/spec/data/invalid/invalid_header4.csv +4 -0
- data/spec/data/valid/benchmark_regexp.csv +10 -0
- data/spec/data/valid/index_example.csv +13 -0
- data/spec/data/valid/multi_column_index.csv +10 -0
- data/spec/data/valid/multi_column_index2.csv +12 -0
- data/spec/data/valid/options_in_file1.csv +5 -0
- data/spec/data/valid/options_in_file2.csv +5 -0
- data/spec/data/valid/options_in_file3.csv +13 -0
- data/spec/data/valid/regular_expressions.csv +11 -0
- data/spec/data/valid/simple_constants.csv +5 -0
- data/spec/data/valid/simple_example.csv +10 -0
- data/spec/data/valid/valid.csv +4 -0
- data/spec/spec_helper.rb +106 -0
- 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
|