Skip to content

Commit

Permalink
Get absolute paths working.
Browse files Browse the repository at this point in the history
  • Loading branch information
bpvickers committed Dec 31, 2018
1 parent e81a023 commit abcf14d
Show file tree
Hide file tree
Showing 16 changed files with 124 additions and 52 deletions.
2 changes: 1 addition & 1 deletion lib/csv_decision/defaults.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def self.parse(columns:, matchers:, row:)
defaults = columns.defaults

# Scan the default row for procs and constants
scan_row = ScanRow.new.scan_columns(row: row, columns: defaults, matchers: matchers)
scan_row = ScanRow.new(columns).scan_columns(row: row, columns: defaults, matchers: matchers)

parse_columns(defaults: defaults, columns: columns.dictionary, row: scan_row)
end
Expand Down
6 changes: 4 additions & 2 deletions lib/csv_decision/dictionary.rb
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,10 @@ def self.input_entry(dictionary:, entry:, index:)
# Default function will set the input value unconditionally or conditionally.
dictionary.defaults[index] = entry if entry.type == :set

# guard: columns are anonymous
Dictionary.add_name(columns: dictionary.columns, name: entry.name) unless entry.type == :guard
# Exclude guard: columns which are anonymous, and the case where paths are used.
unless entry.type == :guard || !dictionary.paths.empty?
Dictionary.add_name(columns: dictionary.columns, name: entry.name)
end
end
private_class_method :input_entry
end
Expand Down
73 changes: 48 additions & 25 deletions lib/csv_decision/matchers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,30 @@ class Matchers
# Implemented as an immutable array of 2 or 3 entries for memory compactness and speed.
# @api private
class Proc < Array
def self.define(type:, function:, symbols: nil)
if type == :guard
Guard.new(type: type, function: function, symbols: symbols)
elsif type == :constant
Matchers::Proc.new(type: type, function: function)
elsif function.arity == 1
Proc1.new(type: type, function: function, symbols: symbols)
else
Proc2.new(type: type, function: function, symbols: symbols)
end
end

# @param type [Symbol] Type of the function value - e.g., :constant or :guard.
# @param function [Object] Either a lambda function,
# or some kind of constant such as an Integer.
# @param symbols [nil, Symbol, Array<Symbol>] The symbol or list of symbols
# that the function uses to reference input hash keys (which are always symbolized).
def initialize(type:, function:, symbols: nil)
super()

self << type

# Function values should always be frozen
self << function.freeze

# Some function values, such as constants or 0-arity functions, do not reference symbols.
self << symbols if symbols
size = symbols.nil? ? 2 : 3

super(size) { |i| [type, function.freeze, symbols][i] }
freeze
end

# @param hash [Hash] Input hash to function call.
# @param value [Object] Input value to function call.
# @return [Object] Value returned from function call.
def call(hash:, value: nil)
func = fetch(1)

return func.call(hash) if fetch(0) == :guard

# All other procs can take one or two args
func.arity == 1 ? func.call(value) : func.call(value, hash)
end

# @return [Symbol] Type of the function value - e.g., :constant or :guard.
def type
fetch(0)
Expand All @@ -58,6 +51,27 @@ def function
def symbols
fetch(2, nil)
end

# Call guard Proc
class Guard < Matchers::Proc
def call(args)
fetch(1).call(args[:hash])
end
end

# Call proc of arity 1
class Proc1 < Proc
def call(args)
fetch(1).call(args[:value])
end
end

# Call proc of arity 2
class Proc2 < Matchers::Proc
def call(hash:, value:)
fetch(1).call(value, hash)
end
end
end

# Negation sign prefixed to ranges and functions.
Expand Down Expand Up @@ -162,10 +176,10 @@ def self.path(path)
# @param matchers [Array<Matchers::Matcher>]
# @param row [Array<String>] Data row being parsed.
# @return [Array<(Array, ScanRow)>] Used to scan a table row against an input hash for matches.
def self.parse(columns:, matchers:, row:)
def self.parse(columns:, matchers:, row:, path: [])
# Build an array of column indexes requiring simple constant matches,
# and a second array of columns requiring special matchers.
scan_row = ScanRow.new
scan_row = ScanRow.new(columns, path)

# Scan the columns in the data row, and build an object to scan this row against
# an input hash.
Expand Down Expand Up @@ -194,7 +208,8 @@ def initialize(options)
# @param row (see Matchers.parse)
# @return (see Matchers.parse)
def parse_ins(columns:, row:)
Matchers.parse(columns: columns, matchers: @ins, row: row)
path = columns.paths.empty? ? [] : parse_ins_path(columns.paths, row)
Matchers.parse(path: path, columns: columns.ins, matchers: @ins, row: row)
end

# Parse the row's output columns using the output matchers.
Expand All @@ -203,7 +218,15 @@ def parse_ins(columns:, row:)
# @param row (see Matchers.parse)
# @return (see Matchers.parse)
def parse_outs(columns:, row:)
Matchers.parse(columns: columns, matchers: @outs, row: row)
path = columns.paths.empty? ? [] : parse_ins_path(columns.paths, row)
Matchers.parse(path: path, columns: columns.outs, matchers: @outs, row: row)
end

def parse_ins_path(paths, row)
paths.each_key.with_object([]) do |col, path|
name = row[col]
path << name.to_sym unless name.blank?
end
end

# Subclass and override {#matches?} to implement a custom Matcher class.
Expand Down
2 changes: 1 addition & 1 deletion lib/csv_decision/matchers/constant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def self.matches?(cell)
end

def self.proc(function:)
Matchers::Proc.new(type: :constant, function: function)
Matchers::Proc.define(type: :constant, function: function)
end
private_class_method :proc

Expand Down
6 changes: 3 additions & 3 deletions lib/csv_decision/matchers/guard.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def self.symbol_proc(cell, path)
method = match['negate'].present? ? '!:' : ':'
proc = SYMBOL_PROC[method]
symbols = path + Matchers.path(match['name'])
Matchers::Proc.new(type: :guard, symbols: [symbols], function: proc.curry[symbols].freeze)
Matchers::Proc.define(type: :guard, symbols: [symbols], function: proc.curry[symbols].freeze)
end
private_class_method :symbol_proc

Expand All @@ -118,8 +118,8 @@ def self.symbol_guard(cell, path)

proc, value = guard_proc(match)
symbols = path + Matchers.path(match['name'])
Matchers::Proc.new(type: :guard, symbols: [symbols],
function: proc.curry[symbols][value].freeze)
Matchers::Proc.define(type: :guard, symbols: [symbols],
function: proc.curry[symbols][value].freeze)
end
private_class_method :symbol_guard

Expand Down
4 changes: 2 additions & 2 deletions lib/csv_decision/matchers/numeric.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ def self.matches?(cell)
return false unless (numeric_cell = Matchers.to_numeric(match['value']))

comparator = match['comparator']
Matchers::Proc.new(type: :proc,
function: COMPARATORS[comparator].curry[numeric_cell].freeze)
Matchers::Proc.define(type: :proc,
function: COMPARATORS[comparator].curry[numeric_cell])
end

# (see Matcher#matches?)
Expand Down
2 changes: 1 addition & 1 deletion lib/csv_decision/matchers/pattern.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def self.matches?(cell, regexp_explicit:)
# No need for a regular expression if we have simple string inequality
pattern = comparator == '!=' ? value : Matchers.regexp(value)

Proc.new(type: :proc, function: PATTERN_LAMBDAS[comparator].curry[pattern].freeze)
Proc.define(type: :proc, function: PATTERN_LAMBDAS[comparator].curry[pattern])
end

# @param options [Hash{Symbol=>Object}] Used to determine the value of regexp_implicit:.
Expand Down
2 changes: 1 addition & 1 deletion lib/csv_decision/matchers/range.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def self.range_proc(match:, coerce: nil)
negate, range = range(match, coerce: coerce)
method = coerce ? :numeric_range : :alnum_range
function = Range.send(method, negate, range).freeze
Proc.new(type: :proc, function: function)
Proc.define(type: :proc, function: function)
end
private_class_method :range_proc

Expand Down
4 changes: 2 additions & 2 deletions lib/csv_decision/matchers/symbol.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def self.method_proc(negate:)
# E.g., > :col, we get comparator: >, name: col
def self.comparison(comparator:, name:)
function = COMPARE[comparator]
Matchers::Proc.new(type: :symbol, function: function[name], symbols: [name])
Matchers::Proc.define(type: :symbol, function: function[name], symbols: [name])
end
private_class_method :comparison

Expand Down Expand Up @@ -111,7 +111,7 @@ def self.method_function(name:, negate:)
return false unless METHOD_NAME_RE.match?(name)

function = COMPARE[negate ? '!.' : '.']
Matchers::Proc.new(type: :proc, function: function[name])
Matchers::Proc.define(type: :proc, function: function[name])
end
private_class_method :method_function

Expand Down
4 changes: 2 additions & 2 deletions lib/csv_decision/parse.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def self.parse_row(table:, matchers:, row:, index:)

def self.parse_row_ins(table:, matchers:, row:, index:)
# Parse the input cells for this row
row, table.scan_rows[index] = matchers.parse_ins(columns: table.columns.ins, row: row)
row, table.scan_rows[index] = matchers.parse_ins(columns: table.columns, row: row)

# Add any symbol references made by input cell procs to the column dictionary
Columns.ins_dictionary(columns: table.columns.dictionary, row: row)
Expand All @@ -146,7 +146,7 @@ def self.parse_row_ins(table:, matchers:, row:, index:)

def self.parse_row_outs(table:, matchers:, row:, index:)
# Parse the output cells for this row
row, table.outs_rows[index] = matchers.parse_outs(columns: table.columns.outs, row: row)
row, table.outs_rows[index] = matchers.parse_outs(columns: table.columns, row: row)

Columns.outs_dictionary(columns: table.columns, row: row)

Expand Down
3 changes: 3 additions & 0 deletions lib/csv_decision/result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,12 @@ def input(data)

def self.extract_hash(data, keys, result: {})
key = keys[0]
return result unless data.key?(key)

value = data[key]
return result.deep_merge!(key => value) if keys.length == 1

return result unless value.is_a?(::Hash)
result.deep_merge!(key => extract_hash(value, keys[1..-1]))
end

Expand Down
31 changes: 25 additions & 6 deletions lib/csv_decision/scan_row.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,21 @@ class ScanRow
# @param matchers [Array<Matchers::Matcher>]
# @param cell [String]
# @return [false, Matchers::Proc]
def self.scan(columns:, col:, matchers:, cell:)
def self.scan(path:, columns:, col:, matchers:, cell:)
return false if cell == ''

proc = scan_matchers(columns: columns, col: col, matchers: matchers, cell: cell)
proc = scan_matchers(path: path, columns: columns, col: col, matchers: matchers, cell: cell)
return proc if proc

# Must be a simple string constant - this is OK except for a certain column types.
invalid_constant?(type: :constant, column: columns[col])
end

def self.scan_matchers(columns:, col:, matchers:, cell:)
def self.scan_matchers(path:, columns:, col:, matchers:, cell:)
column = columns[col]
path = []

# An if: guard looks at the flat output hash
path = column.type == :if ? [] : path

matchers.each do |matcher|
# Guard function only accepts the same matchers as an output column.
Expand Down Expand Up @@ -72,9 +74,11 @@ def self.invalid_constant?(type:, column:)
# @return [Array<Integer>] Column indices for Proc objects.
attr_reader :procs

def initialize
def initialize(columns, path = [])
@constants = []
@procs = []
@path = path
@columns = columns
end

# Scan all the specified +columns+ (e.g., inputs) in the given +data+ row using the +matchers+
Expand Down Expand Up @@ -109,6 +113,9 @@ def scan_columns(row:, columns:, matchers:)
# @param hash (see Decision#row_scan)
# @return [Boolean] True for a match, false otherwise.
def match?(row:, scan_cols:, hash:)
# If this row has a path, then need a different algorithm
return match_path?(row: row, hash: hash) unless @path.empty?

# Check any table row cell constants first, and maybe fail fast...
return false if @constants.any? { |col| row[col] != scan_cols[col] }

Expand All @@ -119,11 +126,23 @@ def match?(row:, scan_cols:, hash:)

private

def match_path?(row:, hash:)
path = hash.dig(*@path)
return unless path.is_a?(::Hash)

# Check any table row cell constants first, and maybe fail fast...
return false if @constants.any? { |col| row[col] != path[@columns[col].name] }

# These table row cells are Proc objects which need evaluating and
# must all return a truthy value.
@procs.all? { |col| row[col].call(value: path[@columns[col].name], hash: hash) }
end

def scan_cell(columns:, col:, matchers:, cell:)
column = columns[col]

# Scan the cell against all the matchers
proc = ScanRow.scan(columns: columns, col: col, matchers: matchers, cell: cell)
proc = ScanRow.scan(path: @path, columns: columns, col: col, matchers: matchers, cell: cell)

return set(proc: proc, col: col, column: column) if proc

Expand Down
8 changes: 4 additions & 4 deletions lib/csv_decision/table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,11 @@ def initialize
private

def decision(input:, symbolize_keys:)
if columns.paths.empty?
# if columns.paths.empty?
Decision.make(table: self, input: input, symbolize_keys: symbolize_keys)
else
Scan.table(table: self, input: input, symbolize_keys: symbolize_keys)
end
# else
# Scan.table(table: self, input: input, symbolize_keys: symbolize_keys)
# end
end
end
end
2 changes: 1 addition & 1 deletion spec/csv_decision/columns_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@
DATA
table = CSVDecision.parse(data)

expect(table.columns.input_keys).to eq %i[type_cd type_id]
expect(table.columns.input_keys).to eq [[:header, :type_cd], [:payload, :type_cd], [:payload, :ref_data, :type_id]]
expect(table.columns.paths[0].to_h).to eq(name: nil, eval: false, type: :path, set_if: nil)
expect(table.columns.paths[1].to_h).to eq(name: nil, eval: false, type: :path, set_if: nil)
end
Expand Down
25 changes: 25 additions & 0 deletions spec/csv_decision/examples_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -267,4 +267,29 @@
expect(table.decide(input)).to eq(value: %w[Client Trading 100.00 5010])
expect(table.decide!(input)).to eq(value: %w[Client Trading 100.00 5010])
end

it 'scans the input mixed hash path accumulating matches' do
data = <<~DATA
path:, guard:, out :value
header, :client == AAPL, :source_name
header, , :metrics[service_name]
payload, , :amount
payload, , :ref_data[account_id]
DATA
table = CSVDecision.parse(data, first_match: false)

input = {
header: {
id: 1, type_cd: 'BUY', source_name: 'Client', client: 'AAPL',
metrics: { service_name: 'Trading', receive_time: '12:00' }
},
payload: {
tran_id: 9, amount: '100.00',
ref_data: { account_id: '5010', type_id: 'BUYL' }
}
}

expect(table.decide(input)).to eq(value: %w[Client Trading 100.00 5010])
expect(table.decide!(input)).to eq(value: %w[Client Trading 100.00 5010])
end
end
2 changes: 1 addition & 1 deletion spec/csv_decision/table_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@

it 'scans the input hash paths accumulating matches' do
data = <<~DATA
path:, path:, out :value, out :key, if:
path:, path:, out :value, out :key, if:
header, , :source_name, source_nm, :value.present?
header, , :client_name, client_nm, :value.present?
header, , :client_ref, client_ref_id, :value.present?
Expand Down

0 comments on commit abcf14d

Please sign in to comment.