Skip to content
Browse files

Support deep coercion for Hash attributes

Issue #98
  • Loading branch information...
1 parent 0d72026 commit 5ab13cee814c52c54cff29999b68229185af1b9d @greyblake greyblake committed Sep 1, 2012
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
110 lib/virtus/attribute/hash.rb
@@ -16,6 +16,116 @@ 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.
+ #
+ # @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?(:inject)
+ coerced.inject(new_hash) do |hash, key_and_value|
+ hash[key_and_value[0]] = key_and_value[1]
+ hash
+ end
+ end
+
+ # Return an instance of the Hash with redefined []= method to coerce
+ # keys and values on assigning.
+ #
+ # @return [Hash]
+ #
+ # @api private
+ def new_hash
+ hash = self.class.primitive.new
+ return hash unless @key_type_instance && @value_type_instance
+
+ key_coercion_method = @key_type_instance.coercion_method
+ value_coercion_method = @value_type_instance.coercion_method
+
+ # Redefine []= method to coerce key and value on assigning.
+ # It requires inlining of Attribute#coerce method to coerce.
+ # An alternative way would be using define_singleton_method or Sinatra's meta_def.
+ hash.instance_eval(<<-eorb, __FILE__, __LINE__+1)
+ def []=(key, value) # def []=(key, value)
+ coerced_key = Virtus::Coercion[key.class].#{key_coercion_method}(key) # coerced_key = Virtus::Coercion[key.class].to_sym(key)
+ coerced_value = Virtus::Coercion[value.class].#{value_coercion_method}(value) # coerced_value = Virtus::Coercion[value.class].to_f(value)
+ super(coerced_key, coerced_value) # super(coerce_key, coerce_value)
+ end # end
+ eorb
+
+ hash
+ end
+
end # class Hash
end # class Attribute
end # module Virtus
View
65 spec/integration/hash_member_coercion_spec.rb
@@ -0,0 +1,65 @@
+require 'spec_helper'
+require 'pry'
+
+
+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
+
+ context 'assign new value' do
+ before do
+ dimensions['width'] = 15
+ end
+
+ it 'should coerce key' do
+ dimensions[:width].should == 15
+ end
+
+ it 'should not add new keys' do
+ dimensions.keys.should =~ [:width, :height, :length]
+ end
+ 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
43 spec/unit/virtus/attribute/hash/new_hash_spec.rb
@@ -0,0 +1,43 @@
+require 'spec_helper'
+
+describe Virtus::Attribute::Hash, '#new_hash' do
+ subject { object.new_hash }
+
+ let(:options) { {} }
+ let(:object) { Virtus::Attribute::Hash.new('stuff', options) }
+
+ it { should be_instance_of(::Hash) }
+
+
+ context '`:key_type` and `:value_type` options' do
+ before do
+ subject[:one] = 1
+ subject[2] = '2.0'
+ end
+
+ context 'are undefined' do
+ it 'should not coerce keys on assigning' do
+ subject.keys.should =~ [:one, 2]
+ end
+
+ it 'should not coerce values on assigning' do
+ subject.values.should =~ [1, '2.0']
+ end
+ end
+
+ context 'are defined' do
+ let(:options) {{
+ :key_type => String,
+ :value_type => Integer
+ }}
+
+ it 'should coerce keys on assigning' do
+ subject.keys.should =~ ['one', '2']
+ end
+
+ it 'should coerce values on assigning' do
+ subject.values.should =~ [1, 2]
+ end
+ end
+ end
+end

0 comments on commit 5ab13ce

Please sign in to comment.
Something went wrong with that request. Please try again.