Support deep coercion for Hash attributes #112

Merged
merged 9 commits into from Oct 1, 2012
View
13 README.md
@@ -210,6 +210,19 @@ user.phone_numbers # => [#<PhoneNumber:0x007fdb2d3bef88 @number="212-555-1212">,
user.addresses # => #<Set: {#<Address:0x007fdb2d3be448 @address="1234 Any St.", @locality="Anytown", @region="DC", @postal_code="21234">}>
```
+### Hash attributes coercion
+
+``` ruby
+class Package
+ include Virtus
+
+ attribute :dimensions, Hash[Symbol => Float]
+end
+
+package = Package.new(:dimensions => { 'width' => "2.2", :height => 2, "length" => 4.5 })
+package.dimensions # => { :with => 2.2, :height => 2.0, :length => 4.5 }
+```
+
### Value Objects
``` ruby
View
2 lib/virtus/attribute.rb
@@ -83,7 +83,7 @@ def self.determine_type(class_or_name)
case class_or_name
when ::Class
Attribute::EmbeddedValue.determine_type(class_or_name) || super
- when ::Array, ::Set
+ when ::Array, ::Set, ::Hash
super(class_or_name.class)
else
super
View
83 lib/virtus/attribute/hash.rb
@@ -16,6 +16,89 @@ class Hash < Object
primitive ::Hash
coercion_method :to_hash
+ # The type to which keys of this hash will be coerced
+ #
+ # @example
+ #
+ # class Request
+ # include Virtus
+ #
+ # attribute :headers, Hash[Symbol => String]
+ # end
+ #
+ # Post.attributes[:headers].key_type # => Virtus::Attribute::Symbol
+ #
+ # @return [Virtus::Attribute]
+ #
+ # @api public
+ attr_reader :key_type
+
+ # The type to which values of this hash will be coerced
+ #
+ # @example
+ #
+ # class Request
+ # include Virtus
+ #
+ # attribute :headers, Hash[Symbol => String]
+ # end
+ #
+ # Post.attributes[:headers].value_type # => Virtus::Attribute::String
+ #
+ # @return [Virtus::Attribute]
+ #
+ # @api public
+ attr_reader :value_type
+
+ # Handles hashes with [key_type => value_type] syntax.
@dkubb
dkubb Sep 2, 2012

It's a tiny point, but there's an extra blank line here.

+ #
+ # @param [Class]
+ #
+ # @param [Hash]
+ #
+ # @return [Hash]
+ #
+ # @api private
+ def self.merge_options(type, options)
+ if !type.respond_to?(:size)
+ options
+ elsif type.size > 1
+ raise ArgumentError, "more than one [key => value] pair in `#{type.inspect}`"
+ else
+ options.merge(:key_type => type.keys.first, :value_type => type.values.first)
+ end
+ end
+
+ # Initializes an instance of {Virtus::Attribute::Hash}
+ #
+ # @api private
+ def initialize(*)
+ super
+ if @options.has_key?(:key_type) && @options.has_key?(:value_type)
+ @key_type = @options[:key_type]
+ @value_type = @options[:value_type]
+ @key_type_instance = Attribute.build(@name, @key_type)
+ @value_type_instance = Attribute.build(@name, @value_type)
+ end
+ end
+
+ # Coerce a hash with keys and values
+ #
+ # @param [Object]
+ #
+ # @return [Object]
+ #
+ # @api private
+ def coerce(value)
+ coerced = super
+ return coerced unless coerced.respond_to?(:each_with_object)
+ coerced.each_with_object({}) do |key_and_value, hash|
+ key = @key_type_instance.coerce(key_and_value[0])
+ value = @value_type_instance.coerce(key_and_value[1])
+ hash[key] = value
+ end
+ end
+
end # class Hash
end # class Attribute
end # module Virtus
View
50 spec/integration/hash_attributes_coercion_spec.rb
@@ -0,0 +1,50 @@
+require 'spec_helper'
+
+
+class Package
+ include Virtus
+
+ attribute :dimensions, Hash[Symbol => Float]
+ attribute :meta_info , Hash[String => String]
+end
+
+
+describe Package do
+ let(:instance) do
+ described_class.new(
+ :dimensions => { 'width' => "2.2", :height => 2, "length" => 4.5 },
+ :meta_info => { 'from' => :Me , :to => 'You' }
+ )
+ end
+
+ let(:dimensions) { instance.dimensions }
+ let(:meta_info) { instance.meta_info }
+
+ describe '#dimensions' do
+ subject { dimensions }
+
+ it { should have(3).keys }
+ it { should have_key :width }
+ it { should have_key :height }
+ it { should have_key :length }
+
+ it 'should be coerced to [Symbol => Float] format' do
+ dimensions[:width].should be_eql(2.2)
+ dimensions[:height].should be_eql(2.0)
+ dimensions[:length].should be_eql(4.5)
+ end
+ end
+
+ describe '#meta_info' do
+ subject { meta_info }
+
+ it { should have(2).keys }
+ it { should have_key 'from' }
+ it { should have_key 'to' }
+
+ it 'should be coerced to [String => String] format' do
+ meta_info['from'].should == 'Me'
+ meta_info['to'].should == 'You'
+ end
+ end
+end
View
33 spec/unit/virtus/attribute/hash/class_methods/merge_options_spec.rb
@@ -0,0 +1,33 @@
+require 'spec_helper'
+
+describe Virtus::Attribute::Hash, '.merge_options' do
+ subject { described_class.merge_options(type, options) }
+
+ let(:type) { { String => Integer } }
+ let(:options) { { :opt => 'val' } }
+
+ context 'when `type` responds to `size`' do
+ context 'when size is == 1' do
+ specify { subject[:opt].should == 'val' }
+ specify { subject[:key_type].should == String }
+ specify { subject[:value_type].should == Integer }
+ end
+
+ context 'when size is > 1' do
+ let(:type) { { :opt1 => 'val1', :opt2 => 'val2' } }
+
+ it 'should raise ArgumentError' do
+ message = "more than one [key => value] pair in `#{type.inspect}`"
+ expect { subject }.to raise_error(ArgumentError, message)
+ end
+ end
+ end
+
+ context 'when `type` does not respond to `size`' do
+ before do
+ type.should_receive(:respond_to?).with(:size).and_return(false)
+ end
+
+ it { should be_eql(options) }
+ end
+end
View
20 spec/unit/virtus/attribute/hash/coerce_spec.rb
@@ -0,0 +1,20 @@
+require 'spec_helper'
+
+describe Virtus::Attribute::Hash, '#coerce' do
+ subject { object.coerce(input_value) }
+
+ let(:options) { {:key_type => String, :value_type => Float } }
+ let(:object) { Virtus::Attribute::Hash.new('stuff', options) }
+
+ context 'respond to `inject`' do
+ let(:input_value) { { :one => '1', 'two' => 2 } }
+
+ it { should == { 'one' => 1.0, 'two' => 2.0 } }
+ end
+
+ context 'does not respond to `inject`' do
+ let(:input_value) { :symbol }
+
+ it { should == :symbol }
+ end
+end