hash_engine 0.3.0

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.
@@ -0,0 +1,20 @@
1
+ require 'hash_engine/actions'
2
+ require 'hash_engine/fetchers'
3
+ require 'hash_engine/conditionals'
4
+ require 'hash_engine/format'
5
+ require 'hash_engine/extract'
6
+ require 'hash_engine/transform'
7
+ require 'hash_engine/csv_parse'
8
+
9
+ module HashEngine
10
+ extend Actions
11
+ extend Fetchers
12
+ extend Conditionals
13
+ extend Format
14
+ extend Extract
15
+ extend Transform
16
+ extend CSVParse
17
+ extend self
18
+
19
+ VERSION = '0.3.0'
20
+ end
@@ -0,0 +1,82 @@
1
+ require 'hash_engine/format'
2
+
3
+ module HashEngine
4
+ module Actions
5
+
6
+ include Format
7
+
8
+ @@actions = {}
9
+
10
+ def actions
11
+ @@actions
12
+ end
13
+
14
+ def add_action name, &block
15
+ @@actions[name] = block
16
+ end
17
+
18
+ def valid_action?(action)
19
+ actions.has_key?(action)
20
+ end
21
+
22
+ def action(type, field_data, fetched_data)
23
+ if valid_action?(type)
24
+ actions[type].call(fetched_data, field_data)
25
+ end
26
+ end
27
+
28
+ unprotected_proc = Proc.new do |data, action_data|
29
+ if action_data.is_a?(Proc)
30
+ action_data.call(*data)
31
+ else
32
+ eval(action_data).call(*data)
33
+ end
34
+ end
35
+
36
+ @@actions['proc'] = Proc.new do |data, action_data|
37
+ if data.is_a?(Array) && data.any?(&:nil?) || data.nil?
38
+ nil
39
+ else
40
+ unprotected_proc.call(data, action_data)
41
+ end
42
+ end
43
+
44
+ @@actions['unprotected_proc'] = unprotected_proc
45
+
46
+ @@actions['lookup_map'] = Proc.new do |key, hash|
47
+ if hash.has_key?(key) ||
48
+ hash.default ||
49
+ hash.default_proc then
50
+ hash[key]
51
+ elsif hash.has_key?('default') then
52
+ hash['default']
53
+ elsif hash.has_key?('default_to_key')
54
+ key
55
+ end
56
+ end
57
+
58
+ @@actions['join'] = Proc.new {|data, sep| data.is_a?(Array) ? data.join(sep) : data }
59
+ @@actions['first_value'] = Proc.new { |data, first| data.is_a?(Array) ? data.detect {|f| !(f.nil? || f.empty?)} : data }
60
+
61
+ # 1.8.7 behavior
62
+ @@actions['max_length'] = Proc.new {|data, length| /\w/u.match(data.to_s).to_s }
63
+ # 1.9.x behavior
64
+ if RUBY_VERSION > "1.9"
65
+ @@actions['max_length'] = Proc.new {|data, length| data.to_s.slice(0, length.to_i) }
66
+ end
67
+
68
+ @@actions['format'] = Proc.new do |data, format_instructions|
69
+ # puts " Formatting data: #{data} with #{format_instructions}"
70
+ if format_instructions.is_a?(Hash) &&
71
+ format_instructions.has_key?('strftime') &&
72
+ data.respond_to?(:strftime) then
73
+ data.send(:strftime, format_instructions['strftime'])
74
+ elsif @@formats.has_key?(format_instructions)
75
+ @@formats[format_instructions][data]
76
+ else
77
+ data
78
+ end
79
+ end
80
+
81
+ end
82
+ end
@@ -0,0 +1,13 @@
1
+ module HashEngine
2
+ module AddError
3
+
4
+ def add_error(error_array, long, short)
5
+ if error_array.first == :long
6
+ error_array.push long
7
+ else
8
+ error_array.push short
9
+ end
10
+ end
11
+
12
+ end
13
+ end
@@ -0,0 +1,32 @@
1
+ module HashEngine
2
+ module Conditionals
3
+
4
+ @@conditionals = {}
5
+
6
+ def conditionals
7
+ @@conditionals
8
+ end
9
+
10
+ def add_conditional name, &block
11
+ @@conditionals[name] = block
12
+ end
13
+
14
+ def valid_conditional?(conditional)
15
+ conditionals.has_key?(conditional)
16
+ end
17
+
18
+ def conditional(type, left_operand, right_operand)
19
+ if valid_conditional?(type)
20
+ conditionals[type].call(left_operand, right_operand)
21
+ end
22
+ end
23
+
24
+ @@conditionals['ne'] = Proc.new {|left, right| left != right }
25
+ @@conditionals['eq'] = Proc.new {|left, right| left == right }
26
+ @@conditionals['lt'] = Proc.new {|left, right| left < right }
27
+ @@conditionals['gt'] = Proc.new {|left, right| left > right }
28
+ @@conditionals['lteq'] = Proc.new {|left, right| left <= right }
29
+ @@conditionals['gteq'] = Proc.new {|left, right| left >= right }
30
+ @@conditionals['exist'] = Proc.new {|left, right| !!left }
31
+ end
32
+ end
@@ -0,0 +1,49 @@
1
+ # Code based on FasterCSV
2
+ # Created by James Edward Gray II on 2005-10-31.
3
+ # Copyright 2005 Gray Productions. All rights reserved.
4
+ #
5
+ # This is a cut down and simplified version for dealing with a single line
6
+
7
+ module HashEngine
8
+ module CSVParse
9
+
10
+ QUOTE_CHAR = '"'.freeze
11
+ DEFAULT_DELIMITER = ','.freeze
12
+ QUOTED_FIELD = Regexp.new /^"(.*)"$/
13
+
14
+ def parse_line(line, headers, delimiter=DEFAULT_DELIMITER)
15
+ delimiter = DEFAULT_DELIMITER if delimiter.empty?
16
+ result = {:error => []}
17
+ unless line.empty?
18
+ parse = line.chomp
19
+ csv = Array.new
20
+ current_field = String.new
21
+ field_quotes = 0
22
+ parse.split(delimiter, -1).each do |match|
23
+ if current_field.empty? && match.count(QUOTE_CHAR).zero?
24
+ csv << (match.empty? ? nil : match)
25
+ else
26
+ current_field << match
27
+ field_quotes += match.count(QUOTE_CHAR)
28
+ if field_quotes % 2 == 0
29
+ in_quotes = current_field[QUOTED_FIELD, 1] || current_field
30
+ current_field = in_quotes
31
+ current_field.gsub!(QUOTE_CHAR * 2, QUOTE_CHAR) # unescape contents
32
+ csv << current_field
33
+ current_field = String.new
34
+ field_quotes = 0
35
+ else # we found a quoted field that spans multiple lines
36
+ current_field << delimiter
37
+ end
38
+ end
39
+ end
40
+ if csv.size == headers.size
41
+ headers.each_with_index {|name, index| result[name] = csv[index] }
42
+ else
43
+ result[:error] = ["header.size: #{headers.size} parsed_data.size: #{csv.size}"]
44
+ end
45
+ end
46
+ result
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,139 @@
1
+ require 'hash_engine/format'
2
+
3
+ module HashEngine
4
+ module Extract
5
+ include Format
6
+
7
+ @@reserved_keys ||= {'method' => true, 'method_args' => true, 'cast' => true, 'required' => true}.freeze
8
+
9
+ # Collects data from a root object based on the provided instructions hash.
10
+ # The data is returned as a flat hash. A hash is used to specify the set of
11
+ # attributes which should be included in the .
12
+
13
+ def extract(objects, instructions)
14
+ if instructions.nil? || instructions.empty?
15
+ # can't do anything
16
+ {:error => ['Missing instructions']}
17
+ else
18
+ objects_to_walk = instructions.keys
19
+ if objects.nil?
20
+ {:error => ['Missing object(s)']}
21
+ else
22
+ if objects.is_a?(Hash)
23
+ # hash path
24
+ objects_given = objects.keys
25
+ if delta = objects_to_walk - objects_given and
26
+ !delta.empty?
27
+ {:error => ["Missing object(s): #{(delta).sort.join(', ')}"]}
28
+ else
29
+ # instructions for objects a, b, c
30
+ # given objects a, b, c
31
+ fetch_objects(objects_hash, objects_to_walk, instructions)
32
+ end
33
+ else
34
+ # single object path
35
+ if objects_to_walk.size > 1
36
+ {:error => ["Instructions given for #{objects_to_walk.sort.join(', ')} but only 1 object given"]}
37
+ else
38
+ # instructions for 1 object
39
+ # given 1 object
40
+ object_name = objects_to_walk.first
41
+ fetch_objects({object_name => objects}, objects_to_walk, instructions)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+
48
+ def fetch_objects(objects_hash, objects_to_walk, instructions)
49
+ results = {:error => []}
50
+ objects_to_walk.each do |object_name|
51
+ instructions[object_name].each_pair do |field, field_instructions|
52
+ # Command keywords are reserved and will not be walked.
53
+ unless @@reserved_keys.has_key?(field)
54
+ fetch_value(field, field_instructions, objects_hash[object_name], object_name, instructions[object_name], results)
55
+ end
56
+ end
57
+ end
58
+ results
59
+ end
60
+
61
+ def fetch_attributes(object, parent_name, object_hash, results)
62
+ object_hash.each_pair do |field, field_instructions|
63
+ # Command keywords are reserved and will not be walked.
64
+ unless @@reserved_keys.has_key?(field)
65
+ fetch_value(field, field_instructions, object, parent_name, object_hash, results)
66
+ end
67
+ end
68
+ end
69
+
70
+ def fetch_value(field, field_instructions, object, parent_name, object_hash, results)
71
+ # puts " Field: #{field} field_instructions #{field_instructions.inspect} object #{object.inspect}"
72
+ method = fetch_method(object, field, field_instructions)
73
+ # puts " Method: #{method.inspect}"
74
+ args = fetch_method_args(field, field_instructions)
75
+ if method
76
+ return_val = object.send(method, *args)
77
+ return_val = cast_value(return_val, field, field_instructions)
78
+ # check if we need to dive deeper into recursion
79
+ set_result_or_recurse(return_val, parent_name, field, field_instructions, results)
80
+ else
81
+ append_error_for_required_fields(results,
82
+ "#{parent_name} does not respond to any of: #{fetch_method_array(field, field_instructions).join(', ')}", field_instructions)
83
+ end
84
+ end
85
+
86
+ def set_result_or_recurse(return_val, parent_name, field, field_instructions, results)
87
+ if field_instructions && field_instructions.keys.any? {|k| !@@reserved_keys.has_key?(k) }
88
+ # yes, dive!
89
+ if return_val.nil?
90
+ append_error_for_required_fields(results, "Missing required field: #{parent_name}.#{field}", field_instructions)
91
+ else
92
+ fetch_attributes(return_val, "#{parent_name}.#{field}", field_instructions, results)
93
+ end
94
+ else
95
+ # no, add to results
96
+ results[field] = return_val
97
+ end
98
+ end
99
+
100
+ def cast_value(return_val, field, field_instructions)
101
+ if field_instructions && field_instructions.has_key?('cast')
102
+ return_val = format_value(return_val, field_instructions['cast'])
103
+ else
104
+ return_val
105
+ end
106
+ end
107
+
108
+ def fetch_method_array(field, field_instructions)
109
+ # Don't use compact! => returns nil if unchanged instead of returning the unchanged array
110
+ [(field_instructions && field_instructions['method']), field, field+'?'].compact
111
+ end
112
+
113
+ # Identify the method to be called.
114
+ def fetch_method(object, field, field_instructions)
115
+ fetch_method_array(field, field_instructions).detect {|method|
116
+ object.respond_to?(method) }
117
+ end
118
+
119
+ def fetch_method_args(field, field_instructions)
120
+ if field_instructions && field_instructions['method_args']
121
+ # if args specified make sure its an array
122
+ if field_instructions['method_args'].is_a?(Array)
123
+ field_instructions['method_args']
124
+ else
125
+ [ field_instructions['method_args'] ]
126
+ end
127
+ else
128
+ []
129
+ end
130
+ end
131
+
132
+ def append_error_for_required_fields(results, message, instructions)
133
+ # Only log an error for required fields.
134
+ if (!instructions || instructions.fetch('required', true))
135
+ results[:error] << message
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,27 @@
1
+ module HashEngine
2
+ module Fetchers
3
+ @@fetchers = {}
4
+
5
+ def fetchers
6
+ @@fetchers
7
+ end
8
+
9
+ def add_fetcher name, &block
10
+ @@fetchers[name] = block
11
+ end
12
+
13
+ def valid_fetcher?(fetcher)
14
+ fetchers.has_key?(fetcher)
15
+ end
16
+
17
+ def fetcher(type, field_data, customer_data)
18
+ if valid_fetcher?(type)
19
+ fetchers[type].call(field_data, customer_data)
20
+ end
21
+ end
22
+
23
+ @@fetchers['input'] = Proc.new {|field, data| data[field] }
24
+ @@fetchers['literal'] = Proc.new {|field, data| field }
25
+ @@fetchers['data'] = Proc.new {|field, data| data.values_at(*field) }
26
+ end
27
+ end
@@ -0,0 +1,62 @@
1
+ require 'date'
2
+
3
+ module HashEngine
4
+ module Format
5
+
6
+ @@formats = {}
7
+
8
+ def formats
9
+ @@formats
10
+ end
11
+
12
+ def add_format name, &block
13
+ @@formats[name] = block
14
+ end
15
+
16
+ # leverage the fact that procs and hash have a common interface
17
+ def format(data, format_instructions)
18
+ if format_instructions.is_a?(Hash) &&
19
+ format_instructions.has_key?('strftime') &&
20
+ data.respond_to?(:strftime) then
21
+ data.send(:strftime, format_instructions['strftime'])
22
+ elsif formats.has_key?(format_instructions)
23
+ formats[format_instructions][data]
24
+ else
25
+ data
26
+ end
27
+ end
28
+
29
+ # \W includes '_0-9'
30
+ @@formats['alpha'] = Proc.new {|data| data.to_s.gsub(/[^A-Za-z]/,'') }
31
+
32
+ # \W includes '_'
33
+ @@formats['alphanumeric'] = Proc.new {|data| data.to_s.gsub(/[^A-Za-z0-9]/,'') }
34
+ @@formats['no_whitespace'] = Proc.new {|data| data.to_s.gsub(/[^A-Za-z0-9-]/,'') }
35
+ @@formats['numeric'] = Proc.new {|data| data.to_s.gsub(/\D/,'') }
36
+ @@formats['string'] = Proc.new {|data| data.to_s.strip }
37
+
38
+ # 1.8.7 behavior
39
+ @@formats['first'] = Proc.new {|data| /\w/u.match(data.to_s).to_s }
40
+
41
+ # 1.9.x behavior
42
+ if RUBY_VERSION > "1.9"
43
+ @@formats['first'] = Proc.new {|data| data.to_s[0] }
44
+ end
45
+
46
+ @@formats['float'] = Proc.new {|data| data.to_f }
47
+ @@formats['upcase'] = Proc.new {|data| data.to_s.upcase }
48
+ @@formats['downcase'] = Proc.new {|data| data.to_s.downcase }
49
+ @@formats['capitalize'] = Proc.new {|data| data.to_s.capitalize }
50
+ @@formats['reverse'] = Proc.new {|data| data.to_s.reverse }
51
+ @@formats['date'] = Proc.new {|data| Date.parse(data) rescue data }
52
+
53
+ int_lookup = Hash.new {|hash, key| hash[key] = key.to_i }
54
+ int_lookup[true] = 1
55
+ int_lookup[false] = 0
56
+ int_lookup[nil] = 0
57
+ @@formats['integer'] = int_lookup
58
+
59
+ @@formats['boolean'] = Hash.new {|hash, key|
60
+ hash[key] = ['true', 't', 'yes', 'y', '1'].include?(key.to_s.downcase) }
61
+ end
62
+ end
@@ -0,0 +1,196 @@
1
+ require 'hash_engine/format'
2
+ require 'hash_engine/actions'
3
+ require 'hash_engine/add_error'
4
+ require 'hash_engine/fetchers'
5
+ require 'hash_engine/csv_parse'
6
+ #require 'csv'
7
+
8
+ module HashEngine
9
+ module Transform
10
+ include Format
11
+ include Actions
12
+ include AddError
13
+ include Fetchers
14
+ include CSVParse
15
+
16
+ DEFAULT_INSTRUCTIONS = {'default_value' => '',
17
+ 'allow_nil' => false,
18
+ 'suppress_nil' => true,
19
+ 'allow_blank' => false,
20
+ 'suppress_blank' => true,
21
+ 'long_error' => true,
22
+ 'copy_source' => false,
23
+ 'delimiter' => DEFAULT_DELIMITER,
24
+ 'quiet' => false }
25
+
26
+ def nil_check(instructions, value)
27
+ instructions['allow_nil'] == false && value.nil?
28
+ end
29
+
30
+ def blank_check(instructions, value)
31
+ instructions['allow_blank'] == false && value.is_a?(String) && value !~ /\S/
32
+ end
33
+
34
+ def required_check(field_hash)
35
+ !(field_hash['optional'] == true)
36
+ end
37
+
38
+ def suppress_nil(instructions, value)
39
+ instructions['suppress_nil'] == true && value.nil?
40
+ end
41
+
42
+ def suppress_blank(instructions, value)
43
+ instructions['suppress_blank'] == true && value.is_a?(String) && value !~ /\S/
44
+ end
45
+
46
+ def default_or_suppress(value, field_name, field_hash, instructions, result)
47
+ if (nil_check(instructions, value) || blank_check(instructions, value)) &&
48
+ required_check(field_hash) then
49
+ add_error(result[:error], "required field '#{field_name}' missing", field_name)
50
+ result[field_name] = instructions['default_value']
51
+ else
52
+ unless suppress_nil(instructions, value) || suppress_blank(instructions, value)
53
+ result[field_name] = value
54
+ end
55
+ end
56
+ end
57
+
58
+ def transform(data, passed_instructions)
59
+ instructions = DEFAULT_INSTRUCTIONS.merge(passed_instructions)
60
+ if instructions['fields']
61
+ # continue
62
+ result = if instructions['copy_source']
63
+ data.merge(:error => [])
64
+ else
65
+ {:error => []}
66
+ end
67
+ result[:error].push(instructions['long_error'] ? :long : :short)
68
+ instructions['fields'].each_pair do |field_name, field_hash|
69
+ value = get_value(field_name, field_hash, data, result[:error])
70
+ default_or_suppress(value, field_name, field_hash, instructions, result)
71
+ end
72
+ # remove the :long/:short instruction
73
+ result[:error].shift
74
+ else
75
+ result = {:error => ["Missing instructions"]}
76
+ end
77
+ result.delete(:error) if instructions['quiet']
78
+ result
79
+ end
80
+
81
+
82
+ # Given String:
83
+ # ok|http://www.domain.com|1234567890
84
+ # Given Instructions:
85
+ # deliminator: '|'
86
+ # hash_keys:
87
+ # - status:
88
+ # lookup_map:
89
+ # ok: accepted
90
+ # decline: reject
91
+ # default: error
92
+ # - payload:
93
+ # - uuid:
94
+ # Return:
95
+ # status: accepted
96
+ # payload: http://www.domain.com
97
+ # uuid: 1234567890
98
+
99
+ def csv_transform(data_string, passed_instructions, additional_data={})
100
+ instructions = DEFAULT_INSTRUCTIONS.merge(passed_instructions)
101
+ result = {:error => ["Missing CSV instructions"]}
102
+ if instructions['header']
103
+ result = parse_line data_string, instructions['header'], instructions['delimiter']
104
+ result = transform(result.merge(additional_data), instructions) if result[:error].empty?
105
+ end
106
+ result.delete(:error) if instructions['quiet']
107
+ result
108
+ end
109
+
110
+
111
+ def simple_instructions(field_name, field_hash, source_data, error_array)
112
+ instructions = []
113
+ field_hash.each_pair do |key, value|
114
+ case
115
+ when key == 'conditional_eval'
116
+ instructions.unshift(key => value)
117
+ when key == 'subgroup'
118
+ instructions.unshift(key => value)
119
+ when valid_fetcher?(key)
120
+ instructions.unshift(key => value)
121
+ when valid_action?(key)
122
+ instructions.push(key => value)
123
+ else
124
+ add_error(error_array, "Invalid operation '#{key.inspect}' in transform for field '#{field_name}'", field_name)
125
+ data = nil
126
+ end
127
+ end
128
+ process_instructions(field_name, instructions, source_data, error_array)
129
+ end
130
+
131
+ def concat(data, fetched)
132
+ if data.nil?
133
+ fetched
134
+ elsif data.is_a?(Array) && fetched.is_a?(Array)
135
+ data + fetched
136
+ elsif data.is_a?(Array)
137
+ data.push fetched
138
+ elsif fetched.is_a?(Array)
139
+ fetched.unshift(data)
140
+ else
141
+ [data, fetched]
142
+ end
143
+ end
144
+
145
+ def conditional_eval(field_name, instructions, source_data, error_array)
146
+ left_operand = get_value("#{field_name}_conditional_eval_left_operand",
147
+ instructions['left_operand'], source_data, error_array)
148
+ right_operand = get_value("#{field_name}_conditional_eval_right_operand",
149
+ instructions['right_operand'], source_data, error_array)
150
+ if conditional(instructions['operator'], left_operand, right_operand)
151
+ get_value("#{field_name}_conditional_eval_true_instructions",
152
+ instructions['true_instructions'], source_data, error_array)
153
+ else
154
+ get_value("#{field_name}_conditional_eval_false_instructions",
155
+ instructions['false_instructions'], source_data, error_array)
156
+ end
157
+ end
158
+
159
+ def process_instructions(field_name, instruction_array, source_data, error_array)
160
+ data = nil
161
+ instruction_array.each do |instruction_hash|
162
+ (instruction, instruction_data) = instruction_hash.first
163
+ # puts " Evaluating instruction: #{instruction} with instruction_data: #{instruction_data}"
164
+ case
165
+ when instruction == 'subgroup'
166
+ fetched = get_value(field_name, instruction_data, source_data, error_array)
167
+ data = concat(data, fetched)
168
+ when instruction == 'conditional_eval'
169
+ fetched = conditional_eval(field_name, instruction_data, source_data, error_array)
170
+ data = concat(data, fetched)
171
+ when valid_fetcher?(instruction)
172
+ fetched = fetcher(instruction, instruction_data, source_data)
173
+ data = concat(data, fetched)
174
+ when valid_action?(instruction)
175
+ data = action(instruction, instruction_data, data)
176
+ else
177
+ add_error(error_array, "Invalid operation '#{instruction_hash.inspect}' in transform for field '#{field_name}'", field_name)
178
+ data = nil
179
+ break
180
+ end
181
+ end
182
+ data
183
+ end
184
+
185
+ def get_value(field_name, field_data, source_data, error_array)
186
+ case field_data
187
+ when Array
188
+ process_instructions(field_name, field_data, source_data, error_array)
189
+ when Hash
190
+ simple_instructions(field_name, field_data, source_data, error_array)
191
+ else
192
+ fetcher('input', field_data, source_data)
193
+ end
194
+ end
195
+ end
196
+ end