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