diff --git a/README.md b/README.md index cadac54..0be8a1b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## DESCRIPTION: Maps values from hashes with different structures and/or key names. Ideal for normalizing arbitrary data to be consumed by your applications, or to prepare your data for different display formats (ie. json). - + Tiny module that allows you to easily adapt from one hash structure to another with a simple declarative DSL. ## FEATURES/PROBLEMS: @@ -61,7 +61,7 @@ You can use HashMapper in your own little hash-like objects: class NiceHash include Enumerable extend HashMapper - + map from('/names/first'), to('/first_name') map from('/names/last'), to('/last_name') @@ -89,7 +89,7 @@ end #### Coercing values -You want to make sure an incoming value gets converted to a certain type, so +You want to make sure an incoming value gets converted to a certain type, so ```ruby {'one' => '1', 'two' => '2'} @@ -152,6 +152,51 @@ map from('/names'), to('/user') do |names| end ``` +#### Keeping unmapped keys as is + +You want unmapped keys from the input to be transmitted to the output, use the `keep_unmapped_keys` option on the `normalize` or `denormalize` methods. + +Usage exemple: +```ruby +class ManyLevels + extend HashMapper + map from('/name'), to('/tag_attributes/name') + map from('/properties/type'), to('/tag_attributes/type') + map from('/tagid'), to('/tag_id') + map from('/properties/egg'), to('/chicken') +end + +input = { +{ + :name => 'ismael', + :tag => ["Ruby"], + :tagid => 1, + :properties => { + :type => 'BLAH', + :egg => 33, + :thing => "thingy" + } +} + +ManyLevels.normalize(input, keep_unmapped_keys: true) + +# outputs: + { + :tag_id => 1, + :tag => ["Ruby"], + :chicken => 33, + :properties => { + :thing => "thingy" + }, + :tag_attributes => { + :name => 'ismael', + :type => 'BLAH' + } + } +``` + +Keys `:tag` and `:properties/:thing` are kept without even being declared in the Mapper. + ### Mapping in reverse Cool, you can map one hash into another, but what if I want the opposite operation? @@ -165,9 +210,9 @@ output = NameMapper.normalize(input) # => {:first_name => 'Mark', :last_name => NameMapper.denormalize(output) # => input ``` - + This will work with your block filters and even nested mappers (see below). - + ### Advanced usage #### Array access You want: @@ -252,7 +297,7 @@ end But HashMapper's nested mappers will actually do that for you if a value is an array, so: -```ruby +```ruby map from('/employees'), to('employees'), using: UserMapper ``` ... Will map each employee using UserMapper. @@ -268,12 +313,12 @@ They all yield a block with 2 arguments - the hash you are mapping from and the ```ruby class EggMapper map from('/raw'), to('/fried') - + before_normalize do |input, output| - input['raw'] ||= 'please' # this will give 'raw' a default value + input['raw'] ||= 'please' # this will give 'raw' a default value input end - + after_denormalize do |input, output| output.to_a # the denormalized object will now be an array, not a hash!! end @@ -290,12 +335,12 @@ You can pass one extra argument to before and after filters if you need to: ```ruby class EggMapper map from('/raw'), to('/fried') - + before_normalize do |input, output, opts| - input['raw'] ||= 'please' unless opts[:no_default] # this will give 'raw' a default value + input['raw'] ||= 'please' unless opts[:no_default] # this will give 'raw' a default value input end - + after_denormalize do |input, output, opts| output.to_a # the denormalized object will now be an array, not a hash!! end @@ -306,7 +351,7 @@ EggMapper.normalize({}, no_default: true) EggMapper.denormalize({fried: 4}) ``` - + ## REQUIREMENTS: ## TODO: diff --git a/hash_mapper.gemspec b/hash_mapper.gemspec index 861473c..40e6ff3 100644 --- a/hash_mapper.gemspec +++ b/hash_mapper.gemspec @@ -8,7 +8,7 @@ Gem::Specification.new do |s| s.authors = ['Ismael Celis'] s.description = %q{Tiny module that allows you to easily adapt from one hash structure to another with a simple declarative DSL.} s.email = %q{ismaelct@gmail.com} - + s.files = `git ls-files`.split("\n") s.homepage = %q{http://github.com/ismasan/hash_mapper} s.rdoc_options = ['--charset=UTF-8'] @@ -17,13 +17,15 @@ Gem::Specification.new do |s| s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } s.require_paths = ['lib'] - + if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then s.add_runtime_dependency("activesupport", ">= 4") + s.add_runtime_dependency("ruby_dig") else s.add_dependency("activesupport", ">= 4") + s.add_dependency("ruby_dig") end - + # specify any dependencies here; for example: s.add_development_dependency 'rspec' s.add_development_dependency 'rake' diff --git a/lib/hash_mapper.rb b/lib/hash_mapper.rb index 5adc552..3704983 100644 --- a/lib/hash_mapper.rb +++ b/lib/hash_mapper.rb @@ -1,4 +1,5 @@ require 'hash_mapper/version' +require 'ruby_dig' $:.unshift(File.dirname(__FILE__)) unless $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__))) @@ -7,6 +8,9 @@ def require_active_support require 'active_support/core_ext/array/extract_options' require 'active_support/core_ext/hash/indifferent_access' require 'active_support/core_ext/object/duplicable' + require 'active_support/core_ext/object/deep_dup' + require 'active_support/core_ext/object/blank' + require 'active_support/core_ext/hash/deep_merge' require 'active_support/core_ext/class/attribute' end @@ -101,6 +105,7 @@ def after_denormalize(&blk) def perform_hash_mapping(a_hash, meth, opts) output = {} + a_hash = a_hash.deep_dup # Before filters a_hash = self.send(:"before_#{meth}_filters").inject(a_hash) do |memo, filter| @@ -109,13 +114,41 @@ def perform_hash_mapping(a_hash, meth, opts) # Do the mapping self.maps.each do |m| - m.process_into(output, a_hash, meth) + m.process_into(output, a_hash, meth, opts) end # After filters - self.send(:"after_#{meth}_filters").inject(output) do |memo, filter| + output = self.send(:"after_#{meth}_filters").inject(output) do |memo, filter| filter.call(a_hash, memo, opts) end + + output = merge_hashes(output, a_hash, meth) if opts[:keep_unmapped_keys] + + output + end + + def merge_hashes(output, dupped_hash, meth) + clean_dup(dupped_hash, meth).deep_merge(output) + end + + def clean_dup(dupped_hash, meth) + self.maps.each do |m| + segments = meth == :normalize ? m.path_from.segments.dup : m.path_to.segments.dup + key = segments.pop + digged_hash(dupped_hash, segments).delete(key) + end + clean(dupped_hash) + end + + def clean(hash) + return unless hash.is_a?(Hash) + hash.keys.each { |k| clean(hash[k]) } + hash.delete_if { |_, v| v.blank? } + end + + def digged_hash(hash, segments) + return hash if segments.empty? + hash.dig(*segments) || {} end # Contains PathMaps @@ -132,14 +165,14 @@ def initialize(path_from, path_to, options = {}) @default_value = options.fetch(:default, :hash_mapper_no_default) end - def process_into(output, input, meth = :normalize) + def process_into(output, input, meth = :normalize, opts) path_1, path_2 = (meth == :normalize ? [path_from, path_to] : [path_to, path_from]) - value = get_value_from_input(output, input, path_1, meth) + value = get_value_from_input(output, input, path_1, meth, opts) set_value_in_output(output, path_2, value) end protected - def get_value_from_input(output, input, path, meth) + def get_value_from_input(output, input, path, meth, opts) value = path.inject(input) do |h,e| if h.is_a?(Hash) v = [h[e.to_sym], h[e.to_s]].compact.first @@ -149,7 +182,7 @@ def get_value_from_input(output, input, path, meth) return :hash_mapper_no_value if v.nil? v end - delegated_mapper ? delegate_to_nested_mapper(value, meth) : value + delegated_mapper ? delegate_to_nested_mapper(value, meth, opts) : value end def set_value_in_output(output, path, value) @@ -163,14 +196,14 @@ def set_value_in_output(output, path, value) add_value_to_hash!(output, path, value) end - def delegate_to_nested_mapper(value, meth) + def delegate_to_nested_mapper(value, meth, opts) case value when Array - value.map {|h| delegated_mapper.send(meth, h)} + value.map {|h| delegated_mapper.send(meth, h, opts)} when nil return :hash_mapper_no_value else - delegated_mapper.send(meth, value) + delegated_mapper.send(meth, value, opts) end end @@ -190,9 +223,7 @@ def add_value_to_hash!(hash, path, value) end end end - end - end # contains array of path segments @@ -246,7 +277,5 @@ def parse(path) end.flatten segments end - end - end diff --git a/spec/hash_mapper_spec.rb b/spec/hash_mapper_spec.rb index 6c2aa11..694cb63 100644 --- a/spec/hash_mapper_spec.rb +++ b/spec/hash_mapper_spec.rb @@ -6,24 +6,24 @@ class OneLevel end describe 'mapping a hash with one level' do - + before :each do @from = {:name => 'ismael'} @to = {:nombre => 'ismael'} end - + it "should map to" do OneLevel.normalize(@from).should == @to end - + it "should have indifferent access" do OneLevel.normalize({'name' => 'ismael'}).should == @to end - + it "should map back the other way" do OneLevel.denormalize(@to).should == @from end - + end class ManyLevels @@ -32,10 +32,11 @@ class ManyLevels map from('/properties/type'), to('/tag_attributes/type') map from('/tagid'), to('/tag_id') map from('/properties/egg'), to('/chicken') + map from('/properties/others/things'), to('/properties/others_things') end describe 'mapping from one nested hash to another' do - + before :each do @from = { :name => 'ismael', @@ -45,7 +46,7 @@ class ManyLevels :egg => 33 } } - + @to = { :tag_id => 1, :chicken => 33, @@ -55,15 +56,86 @@ class ManyLevels } } end - + it "should map from and to different depths" do ManyLevels.normalize(@from).should == @to end - + it "should map back the other way" do ManyLevels.denormalize(@to).should == @from end - +end + +describe 'arrays in hashes' do + before :each do + @from = { + :name => ['ismael','sachiyo'], + :tagid => 1, + :properties => { + :type => 'BLAH', + :egg => 33 + } + } + + @to = { + :tag_id => 1, + :chicken => 33, + :tag_attributes => { + :name => ['ismael','sachiyo'], + :type => 'BLAH' + } + } + end + + it "should map array values as normal" do + ManyLevels.normalize(@from).should == @to + end +end + +describe "hash with not mapped attributes" do + before :each do + @from = { + :name => 'ismael', + :title => ["Ruby"], + :test => "coin", + :tagid => 1, + :properties => { + :type => 'BLAH', + :egg => 33, + :thing => "plop", + others: { + things: "something" + } + } + } + + @to = { + :tag_id => 1, + :title => ["Ruby"], + :test => "coin", + :chicken => 33, + :properties => { + :thing => "plop", + others_things: "something" + }, + :tag_attributes => { + :name => 'ismael', + :type => 'BLAH' + } + } + end + + context "when normalizing" do + it "keeps them" do + ManyLevels.normalize(@from, keep_unmapped_keys: true).should == @to + end + end + + context "when denormalizing" do + it "should map back the other way" do + ManyLevels.denormalize(@to, keep_unmapped_keys: true).should == @from + end + end end class DifferentTypes @@ -73,63 +145,37 @@ class DifferentTypes end describe 'coercing types' do - + before :each do @from = { :strings => {:a => '10'}, :integers =>{:b => 20} } - + @to = { :integers => {:a => 10}, :strings => {:b => '20'} } end - + it "should coerce values to specified types" do DifferentTypes.normalize(@from).should == @to end - + it "should coerce the other way if specified" do DifferentTypes.denormalize(@to).should == @from end - -end - -describe 'arrays in hashes' do - before :each do - @from = { - :name => ['ismael','sachiyo'], - :tagid => 1, - :properties => { - :type => 'BLAH', - :egg => 33 - } - } - - @to = { - :tag_id => 1, - :chicken => 33, - :tag_attributes => { - :name => ['ismael','sachiyo'], - :type => 'BLAH' - } - } - end - - it "should map array values as normal" do - ManyLevels.normalize(@from).should == @to - end end + class WithArrays extend HashMapper map from('/arrays/names[0]'), to('/first_name') map from('/arrays/names[1]'), to('/last_name') map from('/arrays/company'), to('/work/company') end - + describe "array indexes" do before :each do @from = { @@ -144,11 +190,11 @@ class WithArrays :work => {:company => 'New Bamboo'} } end - + it "should extract defined array values" do WithArrays.normalize(@from).should == @to end - + it "should map the other way restoring arrays" do WithArrays.denormalize(@to).should == @from end @@ -156,9 +202,6 @@ class WithArrays class PersonWithBlock extend HashMapper - def self.normalize(h) - super - end map from('/names/first'){|n| n.gsub('+','')}, to('/first_name'){|n| "+++#{n}+++"} end class PersonWithBlockOneWay @@ -175,24 +218,24 @@ class PersonWithBlockOneWay :first_name => '+++Ismael+++' } end - + it "should pass final value through given block" do PersonWithBlock.normalize(@from).should == @to end - + it "should be able to map the other way using a block" do PersonWithBlock.denormalize(@to).should == @from end - + it "should accept a block for just one direction" do PersonWithBlockOneWay.normalize(@from).should == @to end - + end class ProjectMapper extend HashMapper - + map from('/name'), to('/project_name') map from('/author_hash'), to('/author'), using(PersonWithBlock) end @@ -210,20 +253,45 @@ class ProjectMapper :author => {:first_name => '+++Ismael+++'} } end - + it "should delegate nested hashes to another mapper" do ProjectMapper.normalize(@from).should == @to end - + it "should translate the other way using nested hashes" do ProjectMapper.denormalize(@to).should == @from end - +end + +describe "with nested mapper and options" do + before :each do + @from ={ + :name => 'HashMapper', + :author_hash => { + :names => {:id => 12, :first => 'Ismael'} + } + } + @to = { + :project_name => 'HashMapper', + :author => { + names: { :id => 12 }, + :first_name => '+++Ismael+++' + } + } + end + + it "should delegate nested hashes to another mapper" do + ProjectMapper.normalize(@from, keep_unmapped_keys: true).should == @to + end + + it "should translate the other way using nested hashes" do + ProjectMapper.denormalize(@to, keep_unmapped_keys: true).should == @from + end end class CompanyMapper extend HashMapper - + map from('/name'), to('/company_name') map from('/employees'), to('/employees') do |employees_array| employees_array.collect{|emp_hash| PersonWithBlock.normalize(emp_hash)} @@ -232,7 +300,7 @@ class CompanyMapper class CompanyEmployeesMapper extend HashMapper - + map from('/name'), to('/company_name') map from('/employees'), to('/employees'), using(PersonWithBlock) end @@ -256,11 +324,11 @@ class CompanyEmployeesMapper ] } end - + it "should pass array value though given block mapper" do CompanyMapper.normalize(@from).should == @to end - + it "should map array elements automatically" do CompanyEmployeesMapper.normalize(@from).should == @to end @@ -268,11 +336,11 @@ class CompanyEmployeesMapper class NoKeys extend HashMapper - + map from('/exists'), to('/exists_yahoo') #in map from('/exists_as_nil'), to('/exists_nil') #in map from('/foo'), to('/bar') # not in - + end describe "with non-matching maps" do @@ -286,28 +354,28 @@ class NoKeys :exists_yahoo => 1 } end - + it "should ignore maps that don't exist" do NoKeys.normalize(@input).should == @output end end describe "with false values" do - + it "should include values in output" do NoKeys.normalize({'exists' => false}).should == {:exists_yahoo => false} NoKeys.normalize({:exists => false}).should == {:exists_yahoo => false} end - + end describe "with nil values" do - + it "should not include values in output" do NoKeys.normalize({:exists => nil}).should == {} NoKeys.normalize({'exists' => nil}).should == {} end - + end class WithBeforeFilters @@ -390,11 +458,11 @@ class C < B } end - + it "should inherit mappings" do B.normalize(@from).should == @to_b end - + it "should not affect other mappers" do NotRelated.normalize('n' => 'nn').should == {:n => {:n => 'nn'}} end @@ -407,7 +475,7 @@ class MixedMappings end describe "dealing with strings and symbols" do - + it "should be able to normalize from a nested hash with string keys" do MixedMappings.normalize( 'big' => {'jobs' => 5}, @@ -415,7 +483,7 @@ class MixedMappings ).should == {:dodo => 5, :bingo => {:biscuit => 3.2}} end - + it "should not symbolized keys in value hashes" do MixedMappings.normalize( 'big' => {'jobs' => 5}, @@ -423,7 +491,7 @@ class MixedMappings ).should == {:dodo => 5, :bingo => {:biscuit => {'string key' => 'value'}}} end - + end class DefaultValues