require 'spec_helper' describe Hashie::Extensions::Coercion do class NotInitializable private_class_method :new end class Initializable attr_reader :coerced, :value def initialize(obj, coerced = nil) @coerced = coerced @value = obj.class.to_s end def coerced? !@coerced.nil? end end class Coercable < Initializable def self.coerce(obj) new(obj, true) end end before(:each) do class ExampleCoercableHash < Hash include Hashie::Extensions::Coercion include Hashie::Extensions::MergeInitializer end end subject { ExampleCoercableHash } let(:instance) { subject.new } describe '#coerce_key' do context 'nesting' do class BaseCoercableHash < Hash include Hashie::Extensions::Coercion include Hashie::Extensions::MergeInitializer end class NestedCoercableHash < BaseCoercableHash coerce_key :foo, String coerce_key :bar, Integer end class OtherNestedCoercableHash < BaseCoercableHash coerce_key :foo, Symbol end class RootCoercableHash < BaseCoercableHash coerce_key :nested, NestedCoercableHash coerce_key :other, OtherNestedCoercableHash coerce_key :nested_list, Array[NestedCoercableHash] coerce_key :nested_hash, Hash[String => NestedCoercableHash] end def test_nested_object(obj) expect(obj).to be_a(NestedCoercableHash) expect(obj[:foo]).to be_a(String) expect(obj[:bar]).to be_an(Integer) end subject { RootCoercableHash } let(:instance) { subject.new } it 'does not add coercions to superclass' do instance[:nested] = { foo: 'bar' } instance[:other] = { foo: 'bar' } expect(instance[:nested][:foo]).to be_a String expect(instance[:other][:foo]).to be_a Symbol end it 'coerces nested objects' do instance[:nested] = { foo: 123, bar: '456' } test_nested_object(instance[:nested]) end it 'coerces nested arrays' do instance[:nested_list] = [ { foo: 123, bar: '456' }, { foo: 234, bar: '567' }, { foo: 345, bar: '678' } ] expect(instance[:nested_list]).to be_a Array expect(instance[:nested_list].size).to eq(3) instance[:nested_list].each do |nested| test_nested_object nested end end it 'coerces nested hashes' do instance[:nested_hash] = { a: { foo: 123, bar: '456' }, b: { foo: 234, bar: '567' }, c: { foo: 345, bar: '678' } } expect(instance[:nested_hash]).to be_a Hash expect(instance[:nested_hash].size).to eq(3) instance[:nested_hash].each do |key, nested| expect(key).to be_a(String) test_nested_object nested end end context 'when repetitively including the module' do class RepetitiveCoercableHash < NestedCoercableHash include Hashie::Extensions::Coercion include Hashie::Extensions::MergeInitializer coerce_key :nested, NestedCoercableHash end subject { RepetitiveCoercableHash } let(:instance) { subject.new } it 'does not raise a stack overflow error' do expect do instance[:nested] = { foo: 123, bar: '456' } test_nested_object(instance[:nested]) end.not_to raise_error end end end it { expect(subject).to be_respond_to(:coerce_key) } it 'runs through coerce on a specified key' do subject.coerce_key :foo, Coercable instance[:foo] = 'bar' expect(instance[:foo]).to be_coerced end it 'skips unnecessary coercions' do subject.coerce_key :foo, Coercable instance[:foo] = Coercable.new('bar') expect(instance[:foo]).to_not be_coerced end it 'supports an array of keys' do subject.coerce_keys :foo, :bar, Coercable instance[:foo] = 'bar' instance[:bar] = 'bax' expect(instance[:foo]).to be_coerced expect(instance[:bar]).to be_coerced end it 'supports coercion for Array' do subject.coerce_key :foo, Array[Coercable] instance[:foo] = %w[bar bar2] expect(instance[:foo]).to all(be_coerced) expect(instance[:foo]).to be_a(Array) end it 'supports coercion for Set' do subject.coerce_key :foo, Set[Coercable] instance[:foo] = Set.new(%w[bar bar2]) expect(instance[:foo]).to all(be_coerced) expect(instance[:foo]).to be_a(Set) end it 'supports coercion for Set of primitive' do subject.coerce_key :foo, Set[Initializable] instance[:foo] = %w[bar bar2] expect(instance[:foo].map(&:value)).to all(eq 'String') expect(instance[:foo]).to be_none(&:coerced?) expect(instance[:foo]).to be_a(Set) end it 'supports coercion for Hash' do subject.coerce_key :foo, Hash[Coercable => Coercable] instance[:foo] = { 'bar_key' => 'bar_value', 'bar2_key' => 'bar2_value' } expect(instance[:foo].keys).to all(be_coerced) expect(instance[:foo].values).to all(be_coerced) expect(instance[:foo]).to be_a(Hash) end it 'supports coercion for Hash with primitive as value' do subject.coerce_key :foo, Hash[Coercable => Initializable] instance[:foo] = { 'bar_key' => '1', 'bar2_key' => '2' } expect(instance[:foo].values.map(&:value)).to all(eq 'String') expect(instance[:foo].keys).to all(be_coerced) end context 'coercing core types' do def test_coercion(literal, target_type, coerce_method) subject.coerce_key :foo, target_type instance[:foo] = literal expect(instance[:foo]).to be_a(target_type) expect(instance[:foo]).to eq(literal.send(coerce_method)) end RSpec.shared_examples 'coerces from numeric types' do |target_type, coerce_method| it "coerces from String to #{target_type} via #{coerce_method}" do test_coercion '2.0', target_type, coerce_method end it "coerces from Integer to #{target_type} via #{coerce_method}" do # Fixnum test_coercion 2, target_type, coerce_method # Bignum test_coercion 12_345_667_890_987_654_321, target_type, coerce_method end it "coerces from Rational to #{target_type} via #{coerce_method}" do test_coercion Rational(2, 3), target_type, coerce_method end end RSpec.shared_examples 'coerces from alphabetical types' do |target_type, coerce_method| it "coerces from String to #{target_type} via #{coerce_method}" do test_coercion 'abc', target_type, coerce_method end it "coerces from Symbol to #{target_type} via #{coerce_method}" do test_coercion :abc, target_type, coerce_method end end include_examples 'coerces from numeric types', Integer, :to_i include_examples 'coerces from numeric types', Float, :to_f include_examples 'coerces from numeric types', String, :to_s include_examples 'coerces from alphabetical types', String, :to_s include_examples 'coerces from alphabetical types', Symbol, :to_sym it 'can coerce String to Rational when possible' do test_coercion '2/3', Rational, :to_r end it 'can coerce String to Complex when possible' do test_coercion '2/3+3/4i', Complex, :to_c end it 'coerces collections with core types' do subject.coerce_key :foo, Hash[String => String] instance[:foo] = { abc: 123, xyz: 987 } expect(instance[:foo]).to eq( 'abc' => '123', 'xyz' => '987' ) end it 'can coerce via a proc' do subject.coerce_key(:foo, lambda do |v| case v when String return !!(v =~ /^(true|t|yes|y|1)$/i) when Numeric return !v.to_i.zero? else return v == true end end) true_values = [true, 'true', 't', 'yes', 'y', '1', 1, -1] false_values = [false, 'false', 'f', 'no', 'n', '0', 0] true_values.each do |v| instance[:foo] = v expect(instance[:foo]).to be_a(TrueClass) end false_values.each do |v| instance[:foo] = v expect(instance[:foo]).to be_a(FalseClass) end end it 'raises errors for non-coercable types' do subject.coerce_key :foo, NotInitializable expect { instance[:foo] = 'true' } .to raise_error(Hashie::CoercionError, /NotInitializable is not a coercable type/) end it 'can coerce false' do subject.coerce_key :foo, Coercable instance[:foo] = false expect(instance[:foo]).to be_coerced expect(instance[:foo].value).to eq('FalseClass') end it 'does not coerce nil' do subject.coerce_key :foo, String instance[:foo] = nil expect(instance[:foo]).to_not eq('') expect(instance[:foo]).to be_nil end end it 'calls #new if no coerce method is available' do subject.coerce_key :foo, Initializable instance[:foo] = 'bar' expect(instance[:foo].value).to eq 'String' expect(instance[:foo]).not_to be_coerced end it 'coerces when the merge initializer is used' do subject.coerce_key :foo, Coercable instance = subject.new(foo: 'bar') expect(instance[:foo]).to be_coerced end context 'when #replace is used' do before { subject.coerce_key :foo, :bar, Coercable } let(:instance) do subject.new(foo: 'bar').replace(foo: 'foz', bar: 'baz', hi: 'bye') end it 'coerces relevant keys' do expect(instance[:foo]).to be_coerced expect(instance[:bar]).to be_coerced expect(instance[:hi]).not_to respond_to(:coerced?) end it 'sets correct values' do expect(instance[:hi]).to eq 'bye' end end context 'when used with a Mash' do class UserMash < Hashie::Mash end class TweetMash < Hashie::Mash include Hashie::Extensions::Coercion coerce_key :user, UserMash end it 'coerces with instance initialization' do tweet = TweetMash.new(user: { email: 'foo@bar.com' }) expect(tweet[:user]).to be_a(UserMash) end it 'coerces when setting with attribute style' do tweet = TweetMash.new tweet.user = { email: 'foo@bar.com' } expect(tweet[:user]).to be_a(UserMash) end it 'coerces when setting with string index' do tweet = TweetMash.new tweet['user'] = { email: 'foo@bar.com' } expect(tweet[:user]).to be_a(UserMash) end it 'coerces when setting with symbol index' do tweet = TweetMash.new tweet[:user] = { email: 'foo@bar.com' } expect(tweet[:user]).to be_a(UserMash) end end context 'when used with a Trash' do class UserTrash < Hashie::Trash property :email end class TweetTrash < Hashie::Trash include Hashie::Extensions::Coercion property :user, from: :user_data coerce_key :user, UserTrash end it 'coerces with instance initialization' do tweet = TweetTrash.new(user_data: { email: 'foo@bar.com' }) expect(tweet[:user]).to be_a(UserTrash) end end context 'when used with IndifferentAccess to coerce a Mash' do class MyHash < Hash include Hashie::Extensions::Coercion include Hashie::Extensions::IndifferentAccess include Hashie::Extensions::MergeInitializer end class UserHash < MyHash end class TweetHash < MyHash coerce_key :user, UserHash end it 'coerces with instance initialization' do tweet = TweetHash.new(user: Hashie::Mash.new(email: 'foo@bar.com')) expect(tweet[:user]).to be_a(UserHash) end it 'coerces when setting with string index' do tweet = TweetHash.new tweet['user'] = Hashie::Mash.new(email: 'foo@bar.com') expect(tweet[:user]).to be_a(UserHash) end it 'coerces when setting with symbol index' do tweet = TweetHash.new tweet[:user] = Hashie::Mash.new(email: 'foo@bar.com') expect(tweet[:user]).to be_a(UserHash) end end context 'when subclassing' do class MyOwnBase < Hash include Hashie::Extensions::Coercion end class MyOwnHash < MyOwnBase coerce_key :value, Integer end class MyOwnSubclass < MyOwnHash end it 'inherits key coercions' do expect(MyOwnHash.key_coercions).to eql(MyOwnSubclass.key_coercions) end it 'the superclass does not accumulate coerced attributes from subclasses' do expect(MyOwnBase.key_coercions).to eq({}) end end context 'when using circular coercion' do context 'with a proc on one side' do class CategoryHash < Hash include Hashie::Extensions::Coercion include Hashie::Extensions::MergeInitializer coerce_key :products, lambda { |value| return value.map { |v| ProductHash.new(v) } if value.respond_to?(:map) ProductHash.new(v) } end class ProductHash < Hash include Hashie::Extensions::Coercion include Hashie::Extensions::MergeInitializer coerce_key :categories, Array[CategoryHash] end let(:category) do CategoryHash.new(type: 'rubygem', products: [Hashie::Mash.new(name: 'Hashie')]) end let(:product) do ProductHash.new(name: 'Hashie', categories: [Hashie::Mash.new(type: 'rubygem')]) end it 'coerces CategoryHash[:products] correctly' do expected = [ProductHash] actual = category[:products].map(&:class) expect(actual).to eq(expected) end it 'coerces ProductHash[:categories] correctly' do expected = [CategoryHash] actual = product[:categories].map(&:class) expect(actual).to eq(expected) end end context 'without a proc on either side' do it 'fails with a NameError since the other class is not defined yet' do attempted_code = lambda do class AnotherCategoryHash < Hash include Hashie::Extensions::Coercion include Hashie::Extensions::MergeInitializer coerce_key :products, Array[AnotherProductHash] end class AnotherProductHash < Hash include Hashie::Extensions::Coercion include Hashie::Extensions::MergeInitializer coerce_key :categories, Array[AnotherCategoryHash] end end expect { attempted_code.call }.to raise_error(NameError) end end end end describe '#coerce_value' do context 'with strict: true' do it 'coerces any value of the exact right class' do subject.coerce_value String, Coercable instance[:foo] = 'bar' instance[:bar] = 'bax' instance[:hi] = :bye expect(instance[:foo]).to be_coerced expect(instance[:bar]).to be_coerced expect(instance[:hi]).not_to respond_to(:coerced?) end it 'coerces values from a #replace call' do subject.coerce_value String, Coercable instance[:foo] = :bar instance.replace(foo: 'bar', bar: 'bax') expect(instance[:foo]).to be_coerced expect(instance[:bar]).to be_coerced end it 'does not coerce superclasses' do klass = Class.new(String) subject.coerce_value klass, Coercable instance[:foo] = 'bar' expect(instance[:foo]).not_to be_kind_of(Coercable) instance[:foo] = klass.new expect(instance[:foo]).to be_kind_of(Coercable) end end context 'core types' do it 'coerces String to Integer when possible' do subject.coerce_value String, Integer instance[:foo] = '2' instance[:bar] = '2.7' instance[:hi] = 'hi' expect(instance[:foo]).to be_a(Integer) expect(instance[:foo]).to eq(2) expect(instance[:bar]).to be_a(Integer) expect(instance[:bar]).to eq(2) expect(instance[:hi]).to be_a(Integer) expect(instance[:hi]).to eq(0) # not what I expected... end it 'coerces non-numeric from String to Integer' do # This was surprising, but I guess it's "correct" # unless there is a stricter `to_i` alternative subject.coerce_value String, Integer instance[:hi] = 'hi' expect(instance[:hi]).to be_a(Integer) expect(instance[:hi]).to eq(0) end it 'raises a CoercionError when coercion is not possible' do type = Integer subject.coerce_value type, Symbol expect { instance[:hi] = 1 }.to raise_error( Hashie::CoercionError, /Cannot coerce property :hi from #{type} to Symbol/ ) end it 'coerces Integer to String' do type = Integer subject.coerce_value type, String { fixnum: 2, bignum: 12_345_667_890_987_654_321, float: 2.7, rational: Rational(2, 3), complex: Complex(1) }.each do |k, v| instance[k] = v if v.is_a? type expect(instance[k]).to be_a(String) expect(instance[k]).to eq(v.to_s) else expect(instance[k]).to_not be_a(String) expect(instance[k]).to eq(v) end end end it 'coerces Numeric to String' do subject.coerce_value Numeric, String { fixnum: 2, bignum: 12_345_667_890_987_654_321, float: 2.7, rational: Rational(2, 3), complex: Complex(1) }.each do |k, v| instance[k] = v expect(instance[k]).to be_a(String) expect(instance[k]).to eq(v.to_s) end end it 'can coerce via a proc' do subject.coerce_value(String, lambda do |v| return !!(v =~ /^(true|t|yes|y|1)$/i) end) true_values = %w[true t yes y 1] false_values = %w[false f no n 0] true_values.each do |v| instance[:foo] = v expect(instance[:foo]).to be_a(TrueClass) end false_values.each do |v| instance[:foo] = v expect(instance[:foo]).to be_a(FalseClass) end end end end after(:each) do Object.send(:remove_const, :ExampleCoercableHash) end end