hash_engine 0.3.0

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