data_translation 1.1.0

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