diff --git a/README.md b/README.md index c1cb2dd..6203126 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,7 @@ [![Build Status](https://travis-ci.org/leifg/morfo.png?branch=master)](https://travis-ci.org/leifg/morfo) [![Coverage Status](https://coveralls.io/repos/leifg/morfo/badge.png?branch=master)](https://coveralls.io/r/leifg/morfo) [![Code Climate](https://codeclimate.com/github/leifg/morfo.png)](https://codeclimate.com/github/leifg/morfo) [![Dependency Status](https://gemnasium.com/leifg/morfo.png)](https://gemnasium.com/leifg/morfo) [![Gem Version](https://badge.fury.io/rb/morfo.png)](http://badge.fury.io/rb/morfo) -This Gem is inspired by the [active_importer](https://github.com/continuum/active_importer) Gem. - -But instead of importing spreadsheets into models, you can morf (typo intended) arrays of Hashes into other arrays of hashes. +This gem acts like a universal converter from hashes into other hashes. You just define where your hash should get its data from and morfo will do the rest for you. ## Compatibility @@ -28,10 +26,14 @@ Or install it yourself as: In order to morf the hashes you have to provide a class that extends `Morf::Base` -Use the `map` method to specify what field you map to another field: +Use the `field` method to specify what fields exist and where they will get their data from: + +### Simple Mapping + +The most basic form is, just define another field from the input hash. The value will just be copied. class Title < Morfo::Base - map :title, :tv_show_title + field :tv_show_title, from: :title end Afterwards use the `morf` method to morf all hashes in one array to the end result: @@ -46,50 +48,12 @@ Afterwards use the `morf` method to morf all hashes in one array to the end resu # {tv_show_title: 'Breaking Bad'}, # ] -It is also possible to map fields to multiple other fields - - class MultiTitle < Morfo::Base - map :title, :tv_show_title - map :title, :show_title - end - - MultiTitle.morf([ - {title: 'The Walking Dead'} , - {title: 'Breaking Bad'}, - ]) - - # [ - # {tv_show_title: 'The Walking Dead', show_title: 'The Walking Dead'}, - # {tv_show_title: 'Breaking Bad', show_title: 'Breaking Bad'}, - # ] - -## Transformations - -For each mapping you can define a block, that will be called on every input: - - class AndZombies < Morfo::Base - map :title, :title do |title| - "#{title} and Zombies" - end - end - - AndZombies.morf([ - {title: 'Pride and Prejudice'}, - {title: 'Fifty Shades of Grey'}, - ]) - - # [ - # {title: 'Pride and Prejudice and Zombies'}, - # {title: 'Fifty Shades of Grey and Zombies'}, - # ] - -## Nested Values +If you want to have access to nested values, you'll have to provide an array as the key: -You can directly access nested values in the hashes: class Name < Morfo::Base - map [:name, :first], :first_name - map [:name, :last], :last_name + field :first_name, from: [:name, :first] + field :last_name, from: [:name, :last] end Name.morf([ @@ -112,22 +76,39 @@ You can directly access nested values in the hashes: # {first_name: 'Bruce',last_name: 'Wayne'}, # ] +## Transformations -It is also possible to store values in a nested hash: +Every field can also take a transformation block, so that the original input can be transformed. - class Wrapper < Morfo::Base - map :first_name, [:superhero, :name, :first] - map :last_name, [:superhero, :name, :last] + class AndZombies < Morfo::Base + field(:title, from: :title) {|title| "#{title} and Zombies"} end - Name.morf([ - {first_name: 'Clark',last_name: 'Kent'}, - {first_name: 'Bruce',last_name: 'Wayne'},, - ]) + AndZombies.morf([ + {title: 'Pride and Prejudice'}, + {title: 'Fifty Shades of Grey'}, + ]) + + # [ + # {title: 'Pride and Prejudice and Zombies'}, + # {title: 'Fifty Shades of Grey and Zombies'}, + # ] + +As the second argument, the whole row is passed into the block. So you can even do transformation based on the whole row. Or you can leave out all the arguments and return a static value. + + class NameConcatenator < Morfo::Base + field(:name) {|_, row| "#{row[:first_name]} #{row[:last_name]}"} + field(:status) { 'Best Friend' } + end + + NameConcatenator.morf([ + {first_name: 'Robin', last_name: 'Hood'}, + {first_name: 'Sherlock', last_name: 'Holmes'}, + ]) # [ - # { superhero: {name: { first: 'Clark', last: 'Kent'}}}, - # { superhero: {name: { first: 'Bruce', last: 'Wayne'}}}, + # {:name=>"Robin Hood", :status=>"Best Friend"}, + # {:name=>"Sherlock Holmes", :status=>'Best Friend'} # ] diff --git a/benchmarks/data.rb b/benchmarks/data.rb index 8a2e649..520be32 100644 --- a/benchmarks/data.rb +++ b/benchmarks/data.rb @@ -46,20 +46,20 @@ def stringify_keys hash class SimpleMappingSymbol < Morfo::Base BenchmarkData.row.keys.each do |field| - map field, :"#{field}_mapped" + field(:"#{field}_mapped", from: field) end end class SimpleMappingString < Morfo::Base BenchmarkData.row_string_keys.keys.each do |field| - map field, "#{field}_mapped" + field("#{field}_mapped", from: field) end end class NestedMappingSymbol < Morfo::Base BenchmarkData.row_nested.each do |key, value| value.keys.each do |field| - map [key, field], :"#{field}_mapped" + field(:"#{field}_mapped", from: [key, field]) end end end @@ -67,7 +67,7 @@ class NestedMappingSymbol < Morfo::Base class NestedMappingString < Morfo::Base BenchmarkData.row_nested_string_keys.each do |key, value| value.keys.each do |field| - map [key, field], "#{field}_mapped" + field("#{field}_mapped", from: [key, field]) end end end diff --git a/lib/morfo.rb b/lib/morfo.rb index 454d1d6..538dbba 100644 --- a/lib/morfo.rb +++ b/lib/morfo.rb @@ -1,9 +1,18 @@ require 'morfo/version' +require 'morfo/actions' module Morfo class Base - def self.map from, to, &transformation - mapping_actions << MapAction.new(from, to, transformation) + def self.field field_name, definition={}, &blk + if blk + mapping_actions << Morfo::Actions::TransformationAction.new(definition[:from], field_name, blk) + else + raise( + ArgumentError, + "No field to map from is specified for #{field_name.inspect}" + ) unless definition[:from] + mapping_actions << Morfo::Actions::MapAction.new(definition[:from], field_name) + end end def self.morf input @@ -31,42 +40,4 @@ def self.deep_merge! hash, other_hash, &block hash end end - - class MapAction - attr_reader :from - attr_reader :to - attr_reader :transformation - - def initialize from, to, transformation - @from = from - @to = to - @transformation = transformation - end - - def execute row - resulting_value = apply_transformation(extract_value(row)) - resulting_value ? store_value(to, resulting_value) : {} - end - - private - def extract_value row - Array(from).inject(row) do |resulting_value, key| - resulting_value ? resulting_value[key] : nil - end - end - - def apply_transformation row - transformation ? transformation.call(row) : row - end - - def store_value to, value - Array(to).reverse.inject({}) do |hash, key| - if hash.keys.first.nil? - hash.merge!(key => value) - else - { key => hash } - end - end - end - end end diff --git a/lib/morfo/actions.rb b/lib/morfo/actions.rb new file mode 100644 index 0000000..d7484e8 --- /dev/null +++ b/lib/morfo/actions.rb @@ -0,0 +1,56 @@ +module Morfo + module Actions + module ValueMethods + def extract_value from, row + Array(from).inject(row) do |resulting_value, key| + resulting_value ? resulting_value[key] : nil + end + end + + def store_value to, value + return {} if value.nil? + + Array(to).reverse.inject({}) do |hash, key| + if hash.keys.first.nil? + hash.merge!(key => value) + else + { key => hash } + end + end + end + end + + class MapAction + include ValueMethods + attr_reader :from + attr_reader :to + + def initialize from, to + @from = from + @to = to + end + + def execute row + store_value(to, extract_value(from, row)) + end + end + + class TransformationAction + include ValueMethods + attr_reader :to + attr_reader :from + attr_reader :transformation + + def initialize from, to, transformation + @from = from + @to = to + @transformation = transformation + end + + def execute row + resulting_value = from ? extract_value(from, row) : nil + store_value(to, transformation.call(resulting_value,row)) + end + end + end +end \ No newline at end of file diff --git a/lib/morfo/actions/base.rb b/lib/morfo/actions/base.rb new file mode 100644 index 0000000..e69de29 diff --git a/spec/lib/morfo_spec.rb b/spec/lib/morfo_spec.rb index 3ccb982..e92854f 100644 --- a/spec/lib/morfo_spec.rb +++ b/spec/lib/morfo_spec.rb @@ -31,10 +31,22 @@ end describe '#morf' do + context 'errors' do + subject(:no_from) do + class NilMapper < Morfo::Base + field :my_field, {} + end + NilMapper + end + it 'raises error for nil field' do + expect{no_from.morf([])}.to raise_error(ArgumentError) + end + end + context '1 to 1 conversion' do subject do class TitleMapper < Morfo::Base - map :title, :tv_show_title + field :tv_show_title, from: :title end TitleMapper end @@ -54,9 +66,7 @@ class TitleMapper < Morfo::Base context '1 to 1 conversion with transformation' do subject do class NumCastMapper < Morfo::Base - map :cast, :cast_num do |cast| - cast.size - end + field(:cast_num, from: :cast){|v,r| v.size} end NumCastMapper end @@ -70,8 +80,8 @@ class NumCastMapper < Morfo::Base context '1 to many conversion' do subject do class MutliTitleMapper < Morfo::Base - map :title, :title - map :title, :also_title + field :title, from: :title + field :also_title, from: :title end MutliTitleMapper end @@ -86,14 +96,21 @@ class MutliTitleMapper < Morfo::Base context 'nested source' do subject(:valid_path) do class ImdbRatingMapper < Morfo::Base - map [:ratings, :imdb], :rating + field :rating, from: [:ratings, :imdb] + end + ImdbRatingMapper + end + + subject(:valid_path_with_transformation) do + class ImdbRatingMapper < Morfo::Base + field(:rating, from: [:ratings, :imdb]){|v| "Rating: #{v}"} end ImdbRatingMapper end subject(:invalid_path) do class InvalidImdbRatingMapper < Morfo::Base - map [:very, :long, :path, :that, :might, :not, :exist], :rating + field :rating, from: [:very, :long, :path, :that, :might, :not, :exist] end InvalidImdbRatingMapper end @@ -103,6 +120,11 @@ class InvalidImdbRatingMapper < Morfo::Base expect(valid_path.morf(input)).to eq(expected_output) end + it 'maps nested attributes with transformation' do + expected_output = input.map{|v| {rating: "Rating: #{v[:ratings][:imdb]}"} } + expect(valid_path_with_transformation.morf(input)).to eq(expected_output) + end + it 'doesn\'t raise error for invalid path' do expected_output = [{},{}] expect(invalid_path.morf(input)).to eq(expected_output) @@ -112,8 +134,8 @@ class InvalidImdbRatingMapper < Morfo::Base context 'nested destination' do subject do class WrapperMapper < Morfo::Base - map :title, [:tv_show, :title] - map :channel, [:tv_show, :channel] + field([:tv_show, :title], from: :title) + field([:tv_show, :channel], from: :channel){|v| "Channel: #{v}"} end WrapperMapper end @@ -123,7 +145,7 @@ class WrapperMapper < Morfo::Base { tv_show: { title: v[:title], - channel: v[:channel], + channel: "Channel: #{v[:channel]}", } } } @@ -131,5 +153,37 @@ class WrapperMapper < Morfo::Base end end end + + context 'calculations' do + subject do + class TitlePrefixMapper < Morfo::Base + field(:title_with_channel){|v,r| "#{r[:title]}, (#{r[:channel]})"} + end + TitlePrefixMapper + end + + it 'maps calculation correctly' do + expected_output = input.map{|r| + { + title_with_channel: "#{r[:title]}, (#{r[:channel]})" + } + } + expect(subject.morf(input)).to eq(expected_output) + end + end + + context 'static values' do + subject do + class StaticTitleMapper < Morfo::Base + field(:new_title){ 'Static Title' } + end + StaticTitleMapper + end + + it 'maps static value correctly' do + expected_output = input.map{|r| {new_title: 'Static Title'} } + expect(subject.morf(input)).to eq(expected_output) + end + end end end