data_translation 1.1.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.
data/CHANGELOG ADDED
@@ -0,0 +1,10 @@
1
+ Version 1.1 (2011-04-28)
2
+
3
+ * Switched order of source checking so that hashes are checked prior to source
4
+ * Changed #from_source to call processor with different arugments depending
5
+ upon processor arity
6
+
7
+
8
+ Version 1.0 (2010-07-28)
9
+
10
+ * Created initial library.
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2010 Scott Patterson
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
data/README ADDED
@@ -0,0 +1,5 @@
1
+ Provides a means to write data translation maps in Ruby and translate from a source object
2
+ to a hash. A mixin is also provided to help ease the creation of new objects from
3
+ mappings.
4
+
5
+ See DataTranslation and DataTranslation::Destination for details and examples.
@@ -0,0 +1,268 @@
1
+ # Simple class to provide an easy way to map and transform data.
2
+ #
3
+ # ==Example Usage
4
+ #
5
+ # agent = DataTranslation.new do |m|
6
+ # m.option :strict, true
7
+ #
8
+ # m.set 'source_id', 1
9
+ #
10
+ # m.link 'login', 'Username'
11
+ # m.link 'first_name', 'FirstName'
12
+ # m.link 'last_name', 'LastName'
13
+ # m.link 'phone_number', lambda {|source| "(#{source['Area']}) #{source['Phone']}"}
14
+ #
15
+ # m.processor do |values|
16
+ # Agent.find_or_create_by_source_id_and_login(values['source_id'], values['login'])
17
+ # end
18
+ # end
19
+ #
20
+ # source = {'Username' => 'spatterson',
21
+ # 'FirstName' => 'Scott',
22
+ # 'LastName' => 'Patterson',
23
+ # 'Area' => '123',
24
+ # 'Phone' => '456-7890'}
25
+ #
26
+ # results = agent.transform(source)
27
+ #
28
+ # puts results.inspect # => {"phone_number" => "(123) 456-7890",
29
+ # # "last_name" => "Patterson",
30
+ # # "login" => "spatterson",
31
+ # # "first_name" => "Scott"
32
+ # # "source_id" => 1}
33
+ #
34
+ # new_object = agent.from_source(source)
35
+
36
+ class DataTranslation
37
+ VERSION = '1.1.0'
38
+
39
+ attr_reader :mappings, :static_values, :options
40
+
41
+ # Includes DataTranslation::Destination in the specified class (klass). If a name
42
+ # is given, yields and returns a DataTranslation for that name. i.e.
43
+ # DataTranslation.destination(PlainClass, :source_name) {|dtm| dtm.link ...}
44
+ def self.destination(klass, name = nil)
45
+ # No need to include ourself again if we've already extended the class
46
+ klass.class_eval("include(Destination)") unless klass.include?(Destination)
47
+
48
+ if name
49
+ klass.data_translation_map(name) {|dtm| yield dtm} if block_given?
50
+ klass.data_translation_map(name)
51
+ end
52
+ end
53
+
54
+ # Constructor, yields self to allow for block-based configuration.
55
+ # Defaults: strict = true
56
+ def initialize # yields self
57
+ @mappings = {}
58
+ @static_values = {}
59
+ @options = {:strict => true}
60
+ @processor = nil
61
+
62
+ yield self if block_given?
63
+ end
64
+
65
+ # Sets the option with name to value.
66
+ # Current options are:
67
+ # :strict = boolean; true will raise a NonresponsiveSource exception if the source
68
+ # object does not respond to a mapping.
69
+ # false ignores non-existant fields in the source object.
70
+ def option(name, value)
71
+ @options[name.to_sym] = value
72
+ end
73
+
74
+ # Sets a specified to field to value during transformation without regard to the source object.
75
+ # If you wish to link to source object data or use a lambda/block, use #link instead.
76
+ # to may be any object that can be stored as a Hash key.
77
+ #
78
+ # set :field_name, 'Static value'
79
+ def set(to, value)
80
+ @static_values[to] = value
81
+ end
82
+
83
+ # Links a destination field to a source method, element, or lambda.
84
+ # to may be a string, symbol, or any object that can be used as a hash key.
85
+ # If from is a lambda, called with one argument (the source passed to #transform).
86
+ # Alternatively, can be called with a block instead of a lambda or from.
87
+ #
88
+ # link 'field_name', 'FieldName'
89
+ # link :field_name, 'FieldName'
90
+ # link :field_name, lambda {|source| source...}
91
+ # link(:field_name) {|source| source...}
92
+ def link(to, from = nil, &block)
93
+ @mappings[to] = block.nil? ? from : block
94
+ end
95
+
96
+ # If called without a block, returns the current block. If called with a block,
97
+ # sets the block that will be run with the results from #transform when #from_source
98
+ # is called. The results of the transformation will be passed if the block
99
+ # expects only one parameter. If the block expects two parameters both the results
100
+ # and the original source object will be provided.
101
+ def processor(&block) # |transform_results [, source_data]|
102
+ if block_given?
103
+ @processor = block
104
+ else
105
+ @processor
106
+ end
107
+ end
108
+
109
+ # Removes the currently defined processor, if any.
110
+ def remove_processor
111
+ @processor = nil
112
+ end
113
+
114
+ # Given a source object, returns a new hash with elements as determined by the
115
+ # current mapping. Mapping is set by one or more calls to #link. Options passed
116
+ # to this method override the instance options. See #option for a list of options.
117
+ # #link values will override #set values.
118
+ def transform(source, options = {})
119
+ options = @options.merge(options)
120
+
121
+ apply_static_values(options).merge(apply_mappings(source, options))
122
+ end
123
+
124
+ # Given a source object, returns the results of #transform if no processor is defined
125
+ # or the results of calling the processor block as defined by #processor.
126
+ def from_source(source, options = {})
127
+ results = transform(source, options)
128
+
129
+ if @processor
130
+ if @processor.arity == 1
131
+ @processor.call(results)
132
+ else
133
+ @processor.call(results, source)
134
+ end
135
+ else
136
+ results
137
+ end
138
+ end
139
+
140
+ # Yields the mapping object so that options and links can be applied within a block.
141
+ def update # yields self
142
+ yield self
143
+ end
144
+
145
+ private
146
+
147
+ # Given a source object and optional options hash, iterates over the current mappings
148
+ # (defined by #link) and returns a Hash of results.
149
+ def apply_mappings(source, options = {})
150
+ results = {}
151
+
152
+ @mappings.each do |to, from|
153
+ if from.respond_to? :call # Lambda
154
+ results[to] = from.call(source)
155
+ elsif source.respond_to?(:[]) && # Hash-like Object
156
+ (options[:strict] == false || source.has_key?(from))
157
+ results[to] = source[from]
158
+ elsif source.respond_to?(from.to_sym) # Source Object
159
+ results[to] = source.send(from.to_sym)
160
+ else
161
+ raise NonresponsiveSource,
162
+ "#{to}: #{source.class} does not respond to '#{from}' (#{from.class})"
163
+ end
164
+ end
165
+
166
+ results
167
+ end
168
+
169
+ # Returns a hash of static values as defined by the #set method.
170
+ def apply_static_values(options = {})
171
+ @static_values # currently nothing to do to process, so we just pass it along for now.
172
+ end
173
+
174
+ ## Mixins ##
175
+
176
+ # Provides helper methods for mapping and creating new objects from source data.
177
+ #
178
+ # In addition to including the mixin DataTranslation::Destination, a class method
179
+ # called initialize_from_data_translation may optionally be provided that takes a hash
180
+ # of translated data. If not present, the from_source will pass a hash of the
181
+ # transformation results to the new method.
182
+ #
183
+ # Multiple mappings may be given for a single destination by using different names
184
+ # for them. e.g. :source1 and :source2 as names yield different mappings.
185
+ #
186
+ # ==Example Usage
187
+ #
188
+ # class DestinationObject
189
+ # include DataTranslation::Destination
190
+ #
191
+ # def self.initialize_from_data_translation(results)
192
+ # end
193
+ # end
194
+ #
195
+ # DestinationObject.data_translation_map(:source_name) do |dtm|
196
+ # dtm.link 'first_key', 'Key1'
197
+ # end
198
+ #
199
+ # source = {'Key1' => 'Value1'}
200
+ #
201
+ # new_object = DestinationObject.from_source(:source_name, source)
202
+
203
+ module Destination
204
+ # Provides our class methods when included.
205
+ def self.included(base)
206
+ base.extend(ClassMethods)
207
+ end
208
+
209
+ module ClassMethods
210
+ # Given the name of a mapping and a source object, transforms the source object
211
+ # based upon the specified mapping and attempts to process the results using one of
212
+ # several methods that are checked in the following order:
213
+ # * DataTranslation#processor defined block
214
+ # * Destination class initialize_from_data_translation
215
+ # * Destination class default constructor
216
+ # with the resulting hash if that method is not defined.
217
+ # Returns an instance of the class based upon the source data.
218
+ def from_source(name, source)
219
+ dtm = data_translation_map(name)
220
+
221
+ if dtm.processor
222
+ dtm.from_source(source)
223
+ elsif respond_to?(:initialize_from_data_translation)
224
+ initialize_from_data_translation(dtm.transform(source))
225
+ else
226
+ new(dtm.transform(source))
227
+ end
228
+ end
229
+
230
+ # Given the name of a mapping, returns the existing mapping with that name
231
+ # or creates one with that name and yields it if a block is given.
232
+ # Returns the DataTranslation mapping for the specified name.
233
+ def data_translation_map(name) # yields DataTranslation
234
+ @dt_mappings ||= {}
235
+ @dt_mappings[name] ||= DataTranslation.new
236
+
237
+ yield @dt_mappings[name] if block_given?
238
+
239
+ @dt_mappings[name]
240
+ end
241
+
242
+ # Returns a string containing a sample DataTranslation mapping for this instance
243
+ # based upon actual column names (assuming this is an ActiveRecord class or
244
+ # another class that provides an array of string column/attribute names via a
245
+ # column_names class method).
246
+ #
247
+ # Passing the name argument sets it as the translation name in the output.
248
+ def stub_data_translation_from_column_names(name = 'name')
249
+ map = ["DataTranslation.destination(#{self}, :#{name}) do |dtm|"]
250
+
251
+ column_names.each do |col|
252
+ map << "\tdtm.link :#{col}, :#{col}"
253
+ end
254
+
255
+ map << "end"
256
+
257
+ map.join("\n")
258
+ end
259
+ end
260
+ end
261
+
262
+
263
+ ## Exceptions ##
264
+
265
+ # Raised when the source object does not respond to the from link.
266
+ class NonresponsiveSource < Exception
267
+ end
268
+ end
@@ -0,0 +1,159 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'test/unit'
4
+ require 'mocha'
5
+ require 'data_translation'
6
+
7
+ class TC_DataTranslation < Test::Unit::TestCase
8
+ def test_should_set_options
9
+ dt = DataTranslation.new do |m|
10
+ m.option :strict, true
11
+ end
12
+
13
+ assert_equal true, dt.options[:strict]
14
+ end
15
+
16
+ def test_should_link_string_key
17
+ dt = DataTranslation.new do |m|
18
+ m.link 'To', 'From'
19
+ end
20
+
21
+ assert_equal 'From', dt.mappings['To']
22
+ end
23
+
24
+ def test_should_link_to_lambda
25
+ dt = DataTranslation.new do |m|
26
+ m.link 'To', lambda {|source| 'Lambda'}
27
+ end
28
+
29
+ assert_equal 'Lambda', dt.mappings['To'].call({})
30
+ end
31
+
32
+ def test_should_link_to_block
33
+ dt = DataTranslation.new do |m|
34
+ m.link('To') {|source| 'Block'}
35
+ end
36
+
37
+ assert_equal 'Block', dt.mappings['To'].call({})
38
+ end
39
+
40
+ def test_should_set_static_value
41
+ dt = DataTranslation.new do |m|
42
+ m.set 'To', 'StaticFrom'
43
+ end
44
+
45
+ assert_equal 'StaticFrom', dt.static_values['To']
46
+ end
47
+
48
+ def test_should_transform_with_lambda
49
+ dt = DataTranslation.new do |m|
50
+ m.link 'Phone', lambda {|source| "(#{source['Area']}) #{source['PhoneNumber']}"}
51
+ end
52
+
53
+ source = {'Area' => 123, 'PhoneNumber' => '456-7890'}
54
+
55
+ assert_equal '(123) 456-7890', dt.transform(source)['Phone']
56
+ end
57
+
58
+ def test_should_transform_with_block
59
+ dt = DataTranslation.new do |m|
60
+ m.link('Phone') {|source| "(#{source['Area']}) #{source['PhoneNumber']}"}
61
+ end
62
+
63
+ source = {'Area' => 123, 'PhoneNumber' => '456-7890'}
64
+
65
+ assert_equal '(123) 456-7890', dt.transform(source)['Phone']
66
+ end
67
+
68
+ def test_should_transform_hash_source
69
+ source = {'Key1' => 'Value1', 'Key2' => 'Value2'}
70
+
71
+ dt = DataTranslation.new do |m|
72
+ m.link 'Dest1', 'Key1'
73
+ m.link 'Dest2', 'Key2'
74
+ end
75
+
76
+ results = dt.transform(source)
77
+
78
+ assert_equal 'Value1', results['Dest1']
79
+ assert_equal 'Value2', results['Dest2']
80
+ end
81
+
82
+ def test_should_transform_object_source
83
+ source = mock('Key1' => 'Value1', 'Key2' => 'Value2')
84
+
85
+ dt = DataTranslation.new do |m|
86
+ m.link 'Dest1', 'Key1'
87
+ m.link 'Dest2', 'Key2'
88
+ end
89
+
90
+ results = dt.transform(source)
91
+
92
+ assert_equal 'Value1', results['Dest1']
93
+ assert_equal 'Value2', results['Dest2']
94
+ end
95
+
96
+ # Ran into an issue when mapping US address data from a hash with
97
+ # symbol keys. :zip was specified as a key, but because we checked
98
+ # for object methods first, enumerable#zip was being called instead
99
+ # of hash#[].
100
+ def test_should_check_for_hashlike_object_before_source
101
+ source = {:zip => '99999'}
102
+
103
+ dt = DataTranslation.new do |m|
104
+ m.link 'ZipCode', :zip
105
+ end
106
+
107
+ source.expects(:zip).never
108
+
109
+ results = dt.transform(source)
110
+
111
+ assert_equal '99999', results['ZipCode']
112
+ end
113
+
114
+ def test_should_call_processor_on_transform
115
+ dt = DataTranslation.new do |m|
116
+ m.link :name, 'Name'
117
+
118
+ m.processor do |results|
119
+ "Construct called with #{results[:name]}"
120
+ end
121
+ end
122
+
123
+ assert_equal 'Construct called with value', dt.from_source({'Name' => 'value'})
124
+ end
125
+
126
+ def test_should_raise_exception_when_strict
127
+ source = {}
128
+
129
+ dt = DataTranslation.new {|m| m.link 'Key1', 'Value1'}
130
+
131
+ assert_raises(DataTranslation::NonresponsiveSource) do
132
+ dt.transform(source)
133
+ end
134
+ end
135
+
136
+ def test_should_transform_with_static_value
137
+ dt = DataTranslation.new {|m| m.set 'To', 'StaticValue'}
138
+
139
+ assert_equal({'To' => 'StaticValue'}, dt.transform({}))
140
+ end
141
+
142
+ def test_should_not_raise_exception_when_not_strict
143
+ source = {}
144
+
145
+ dt = DataTranslation.new {|m| m.link 'Key1', 'Value1'}
146
+
147
+ assert_nothing_raised do
148
+ dt.transform(source, :strict => false)
149
+ end
150
+ end
151
+
152
+ def test_should_update_dt
153
+ dt = DataTranslation.new {|m| m.option :strict, true}
154
+ assert dt.options[:strict]
155
+
156
+ dt.update {|m| m.option :strict, false}
157
+ assert ! dt.options[:strict]
158
+ end
159
+ end
@@ -0,0 +1,124 @@
1
+ $:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
2
+
3
+ require 'test/unit'
4
+ require 'mocha'
5
+ require 'data_translation'
6
+
7
+ ## Test Objects
8
+
9
+ class TestObject
10
+ include DataTranslation::Destination
11
+
12
+ attr_reader :name, :options
13
+
14
+ def self.initialize_from_data_translation(params)
15
+ TestObject.new(params.delete('name'), params)
16
+ end
17
+
18
+ def initialize(name, options = {})
19
+ @name = name
20
+ @options = options
21
+ end
22
+
23
+ def self.column_names
24
+ ['id', 'first_name', 'last_name']
25
+ end
26
+ end
27
+
28
+ class PlainObject
29
+ attr_reader :options
30
+
31
+ def initialize(options)
32
+ @options = options
33
+ end
34
+ end
35
+
36
+
37
+ class TC_DataTranslationDestination < Test::Unit::TestCase
38
+ def setup
39
+ @source = {'Name' => 'test object', 'Key1' => 'Value1', 'Key2' => 'Value2'}
40
+ end
41
+
42
+ def test_should_create_map(klass = TestObject)
43
+ klass.data_translation_map(:hash_source) do |m|
44
+ m.option :strict, true
45
+
46
+ m.link 'name', 'Name'
47
+ m.link 'first_key', 'Key1'
48
+ m.link 'second_key', 'Key2'
49
+
50
+ m.remove_processor
51
+ yield m if block_given?
52
+ end
53
+
54
+ assert klass.data_translation_map(:hash_source).options[:strict]
55
+ assert_equal 'Name', klass.data_translation_map(:hash_source).mappings['name']
56
+ assert_equal 'Key1', klass.data_translation_map(:hash_source).mappings['first_key']
57
+ assert_equal 'Key2', klass.data_translation_map(:hash_source).mappings['second_key']
58
+ end
59
+
60
+ def test_should_create_new_object_using_custom_constructor
61
+ test_should_create_map
62
+
63
+ to = TestObject.from_source(:hash_source, @source)
64
+
65
+ assert_equal 'test object', to.name
66
+ assert_equal 'Value1', to.options['first_key']
67
+ assert_equal 'Value2', to.options['second_key']
68
+ end
69
+
70
+ def test_should_create_new_object_using_default_constructor
71
+ DataTranslation.destination(PlainObject)
72
+ test_should_create_map(PlainObject)
73
+
74
+ to = PlainObject.from_source(:hash_source, @source)
75
+
76
+ assert_equal 'test object', to.options['name']
77
+ assert_equal 'Value1', to.options['first_key']
78
+ assert_equal 'Value2', to.options['second_key']
79
+ end
80
+
81
+ def test_should_call_processor_if_given
82
+ DataTranslation.destination(PlainObject)
83
+ test_should_create_map(PlainObject) do |m|
84
+ m.processor do |results, source|
85
+ results.values.sort # for consistency we sort our values
86
+ end
87
+ end
88
+
89
+ assert_equal ['Value1', 'Value2', 'test object'],
90
+ PlainObject.from_source(:hash_source, @source)
91
+ end
92
+
93
+ def test_should_return_mapping_for_name
94
+ test_should_create_map
95
+
96
+ assert TestObject.data_translation_map(:hash_source).kind_of?(DataTranslation)
97
+ end
98
+
99
+ def test_should_make_class_destination
100
+ DataTranslation.destination(PlainObject)
101
+
102
+ assert PlainObject.include?(DataTranslation::Destination)
103
+ end
104
+
105
+ def test_should_make_class_destination_and_yield
106
+ DataTranslation.destination(PlainObject, :hash_source) do |dtm|
107
+ dtm.link 'first_key', 'Key1'
108
+ end
109
+
110
+ assert_equal 'Key1', PlainObject.data_translation_map(:hash_source).mappings['first_key']
111
+ assert PlainObject.respond_to?(:from_source)
112
+ end
113
+
114
+ def test_should_stub_map_from_column_names
115
+ assert_equal "DataTranslation.destination(TestObject, :my_name) do |dtm|\n\tdtm.link :id, :id\n\tdtm.link :first_name, :first_name\n\tdtm.link :last_name, :last_name\nend",
116
+ TestObject.stub_data_translation_from_column_names(:my_name)
117
+ end
118
+
119
+ def test_should_not_include_destination_multiple_times
120
+ DataTranslation::Destination.expects(:included).never
121
+
122
+ DataTranslation.destination(TestObject)
123
+ end
124
+ end
data/test/ts_all.rb ADDED
@@ -0,0 +1,4 @@
1
+ $:.unshift(File.dirname(__FILE__))
2
+
3
+ require 'tc_data_translation'
4
+ require 'tc_data_translation_destination'
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: data_translation
3
+ version: !ruby/object:Gem::Version
4
+ hash: 19
5
+ prerelease:
6
+ segments:
7
+ - 1
8
+ - 1
9
+ - 0
10
+ version: 1.1.0
11
+ platform: ruby
12
+ authors:
13
+ - Scott Patterson
14
+ autorequire:
15
+ bindir: bin
16
+ cert_chain: []
17
+
18
+ date: 2011-06-28 00:00:00 -07:00
19
+ default_executable:
20
+ dependencies: []
21
+
22
+ description: Provides a means to write data translation maps in Ruby and transform data from a source object.
23
+ email: scott.patterson@digitalaun.com
24
+ executables: []
25
+
26
+ extensions: []
27
+
28
+ extra_rdoc_files:
29
+ - README
30
+ - LICENSE
31
+ - CHANGELOG
32
+ files:
33
+ - lib/data_translation.rb
34
+ - test/tc_data_translation.rb
35
+ - test/tc_data_translation_destination.rb
36
+ - test/ts_all.rb
37
+ - README
38
+ - LICENSE
39
+ - CHANGELOG
40
+ has_rdoc: true
41
+ homepage:
42
+ licenses: []
43
+
44
+ post_install_message:
45
+ rdoc_options:
46
+ - --main
47
+ - README
48
+ require_paths:
49
+ - lib
50
+ required_ruby_version: !ruby/object:Gem::Requirement
51
+ none: false
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ hash: 3
56
+ segments:
57
+ - 0
58
+ version: "0"
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ none: false
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ hash: 3
65
+ segments:
66
+ - 0
67
+ version: "0"
68
+ requirements: []
69
+
70
+ rubyforge_project:
71
+ rubygems_version: 1.5.0
72
+ signing_key:
73
+ specification_version: 3
74
+ summary: Generic data mapping and translation expressed in Ruby.
75
+ test_files:
76
+ - test/ts_all.rb