├── .gitignore ├── .travis.yml ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── lib ├── symbolized.rb └── symbolized │ ├── core_ext │ ├── hash │ │ ├── keys.rb │ │ ├── reverse_merge.rb │ │ └── symbolized_hash.rb │ └── object │ │ ├── deep_dup.rb │ │ └── duplicable.rb │ ├── symbolized_hash.rb │ └── version.rb ├── symbolized.gemspec └── test ├── core_ext └── hash_ext_test.rb └── test_helpers.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /test/tmp/ 9 | /test/version_tmp/ 10 | /tmp/ 11 | 12 | ## Specific to RubyMotion: 13 | .dat* 14 | .repl_history 15 | build/ 16 | 17 | ## Documentation cache and generated files: 18 | /.yardoc/ 19 | /_yardoc/ 20 | /doc/ 21 | /rdoc/ 22 | 23 | ## Environment normalisation: 24 | /.bundle/ 25 | /vendor/bundle 26 | /lib/bundler/man/ 27 | 28 | # for a library or gem, you might want to ignore these files since the code is 29 | # intended to run in multiple environments; otherwise, check them in: 30 | Gemfile.lock 31 | .ruby-version 32 | .ruby-gemset 33 | 34 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 35 | .rvmrc 36 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | - 2.1.0 6 | - 2.2.0 7 | - 2.2.2 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'rake' 6 | 7 | group :test do 8 | gem 'minitest', '~> 5.1' 9 | end 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Tamer Shlash 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symbolized 2 | 3 | [![Build Status](https://travis-ci.org/TamerShlash/symbolized.svg?branch=master)](https://travis-ci.org/TamerShlash/symbolized) 4 | 5 | Symbolized provides a Hash with indifferent access, but with keys stored internally as symbols. 6 | This is particularly useful when you have a very big amount of hashes that share the same keys, 7 | and it may become inefficient to keep all these identical keys as strings. An example of this 8 | case is when you have data processing pipelines that process millions of hashes with the same 9 | keys. 10 | 11 | ## Installation 12 | 13 | You can either install it manually: 14 | 15 | % [sudo] gem install symbolized 16 | 17 | Or include it in your Gemfile: 18 | 19 | gem 'symbolized' 20 | 21 | And then run `bundle install`. 22 | 23 | ## Usage 24 | 25 | ```ruby 26 | require 'symbolized' 27 | 28 | # You can create a SymbolizedHash directly: 29 | 30 | symbolized_hash = SymbolizedHash.new 31 | symbolized_hash['a'] = 'b' 32 | symbolized_hash['a'] #=> 'b' 33 | symbolized_hash[:a] #=> 'b' 34 | symbolized_hash.keys #=> [:a] 35 | 36 | # Or initialize it with a normal hash: 37 | 38 | symbolized_hash = SymbolizedHash.new({'a' => 'b'}) 39 | symbolized_hash['a'] #=> 'b' 40 | symbolized_hash[:a] #=> 'b' 41 | symbolized_hash.keys #=> [:a] 42 | 43 | # Or use the Hash#to_symbolized_hash core extension: 44 | 45 | h = { 'a' => 'b' } 46 | h['a'] #=> 'b' 47 | h[:a] #=> nil 48 | h.keys #=> ['a'] 49 | 50 | symbolized_hash = h.to_symbolized_hash 51 | symbolized_hash['a'] #=> 'b' 52 | symbolized_hash[:a] #=> 'b' 53 | symbolized_hash.keys #=> [:a] 54 | 55 | ``` 56 | 57 | The gem provides almost the same methods and functionality provided by ActiveSupport's `HashWithIndifferentAccess`, while storing keys internally as Symbols. 58 | 59 | ## `ActiveSupport` Compatibility 60 | 61 | This gem is built with intent to be as much as possible compatible with ActiveSupport. You can include both `Symbolized` and `ActiveSupport`, and you are guaranteed to get ActiveSupport functionality and core extensions, and still have `Symbolized` core extension and class. 62 | 63 | ## Testing 64 | 65 | Checkout [travis.yml](.travis.yml) to see which Ruby versions the gem has been tested against. Alternatively, if you want to test it yourself, you can clone the repo, run `bundle install` and then run `rake test`. 66 | 67 | ## Suggestions, Discussions and Issues 68 | 69 | Please propose suggestions, open discussions, or report bugs and issues [here](https://github.com/TamerShlash/symbolized/issues). 70 | 71 | ## Contributing 72 | 73 | 1. Fork the [repo](https://github.com/TamerShlash/symbolized) 74 | 2. Create your feature branch (`git checkout -b my-new-feature`) 75 | 3. Commit your changes (`git commit -am 'Add some feature'`) 76 | 4. Push to the branch (`git push origin my-new-feature`) 77 | 5. Create a new Pull Request 78 | 79 | ## Credits 80 | 81 | The current code of this gem is heavily based on [ActiveSupport 4.2 HashWithIndifferentAccess](https://github.com/rails/rails/tree/4-2-stable/activesupport). Some parts are direct clones, other parts have been modified and/or refactored. 82 | 83 | ## License 84 | 85 | Copyright (c) 2015 [Tamer Shlash](https://github.com/TamerShlash) ([@TamerShlash](https://twitter.com/TamerShlash)). Released under the MIT [License](LICENSE). 86 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | Rake::TestTask.new do |t| 4 | t.libs << 'test' 5 | t.test_files = FileList['test/core_ext/hash_ext_test.rb'] 6 | end 7 | 8 | desc "Run tests" 9 | task :default => :test 10 | -------------------------------------------------------------------------------- /lib/symbolized.rb: -------------------------------------------------------------------------------- 1 | require 'symbolized/symbolized_hash' 2 | require 'symbolized/core_ext/hash/symbolized_hash' 3 | -------------------------------------------------------------------------------- /lib/symbolized/core_ext/hash/keys.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | # Returns a new hash with all keys converted using the block operation. 3 | # 4 | # hash = { name: 'Rob', age: '28' } 5 | # 6 | # hash.transform_keys{ |key| key.to_s.upcase } 7 | # # => {"NAME"=>"Rob", "AGE"=>"28"} 8 | def transform_keys 9 | return enum_for(:transform_keys) unless block_given? 10 | result = self.class.new 11 | each_key do |key| 12 | result[yield(key)] = self[key] 13 | end 14 | result 15 | end 16 | 17 | # Destructively convert all keys using the block operations. 18 | # Same as transform_keys but modifies +self+. 19 | def transform_keys! 20 | return enum_for(:transform_keys!) unless block_given? 21 | keys.each do |key| 22 | self[yield(key)] = delete(key) 23 | end 24 | self 25 | end 26 | 27 | # Returns a new hash with all keys converted to strings. 28 | # 29 | # hash = { name: 'Rob', age: '28' } 30 | # 31 | # hash.stringify_keys 32 | # # => {"name"=>"Rob", "age"=>"28"} 33 | def stringify_keys 34 | transform_keys{ |key| key.to_s } 35 | end 36 | 37 | # Destructively convert all keys to strings. Same as 38 | # +stringify_keys+, but modifies +self+. 39 | def stringify_keys! 40 | transform_keys!{ |key| key.to_s } 41 | end 42 | 43 | # Returns a new hash with all keys converted to symbols, as long as 44 | # they respond to +to_sym+. 45 | # 46 | # hash = { 'name' => 'Rob', 'age' => '28' } 47 | # 48 | # hash.symbolize_keys 49 | # # => {:name=>"Rob", :age=>"28"} 50 | def symbolize_keys 51 | transform_keys{ |key| key.to_sym rescue key } 52 | end 53 | alias_method :to_options, :symbolize_keys 54 | 55 | # Destructively convert all keys to symbols, as long as they respond 56 | # to +to_sym+. Same as +symbolize_keys+, but modifies +self+. 57 | def symbolize_keys! 58 | transform_keys!{ |key| key.to_sym rescue key } 59 | end 60 | alias_method :to_options!, :symbolize_keys! 61 | 62 | # Validate all keys in a hash match *valid_keys, raising 63 | # ArgumentError on a mismatch. 64 | # 65 | # Note that keys are treated differently than HashWithIndifferentAccess, 66 | # meaning that string and symbol keys will not match. 67 | # 68 | # { name: 'Rob', years: '28' }.assert_valid_keys(:name, :age) # => raises "ArgumentError: Unknown key: :years. Valid keys are: :name, :age" 69 | # { name: 'Rob', age: '28' }.assert_valid_keys('name', 'age') # => raises "ArgumentError: Unknown key: :name. Valid keys are: 'name', 'age'" 70 | # { name: 'Rob', age: '28' }.assert_valid_keys(:name, :age) # => passes, raises nothing 71 | def assert_valid_keys(*valid_keys) 72 | valid_keys.flatten! 73 | each_key do |k| 74 | unless valid_keys.include?(k) 75 | raise ArgumentError.new("Unknown key: #{k.inspect}. Valid keys are: #{valid_keys.map(&:inspect).join(', ')}") 76 | end 77 | end 78 | end 79 | 80 | # Returns a new hash with all keys converted by the block operation. 81 | # This includes the keys from the root hash and from all 82 | # nested hashes and arrays. 83 | # 84 | # hash = { person: { name: 'Rob', age: '28' } } 85 | # 86 | # hash.deep_transform_keys{ |key| key.to_s.upcase } 87 | # # => {"PERSON"=>{"NAME"=>"Rob", "AGE"=>"28"}} 88 | def deep_transform_keys(&block) 89 | _deep_transform_keys_in_object(self, &block) 90 | end 91 | 92 | # Destructively convert all keys by using the block operation. 93 | # This includes the keys from the root hash and from all 94 | # nested hashes and arrays. 95 | def deep_transform_keys!(&block) 96 | _deep_transform_keys_in_object!(self, &block) 97 | end 98 | 99 | # Returns a new hash with all keys converted to strings. 100 | # This includes the keys from the root hash and from all 101 | # nested hashes and arrays. 102 | # 103 | # hash = { person: { name: 'Rob', age: '28' } } 104 | # 105 | # hash.deep_stringify_keys 106 | # # => {"person"=>{"name"=>"Rob", "age"=>"28"}} 107 | def deep_stringify_keys 108 | deep_transform_keys{ |key| key.to_s } 109 | end 110 | 111 | # Destructively convert all keys to strings. 112 | # This includes the keys from the root hash and from all 113 | # nested hashes and arrays. 114 | def deep_stringify_keys! 115 | deep_transform_keys!{ |key| key.to_s } 116 | end 117 | 118 | # Returns a new hash with all keys converted to symbols, as long as 119 | # they respond to +to_sym+. This includes the keys from the root hash 120 | # and from all nested hashes and arrays. 121 | # 122 | # hash = { 'person' => { 'name' => 'Rob', 'age' => '28' } } 123 | # 124 | # hash.deep_symbolize_keys 125 | # # => {:person=>{:name=>"Rob", :age=>"28"}} 126 | def deep_symbolize_keys 127 | deep_transform_keys{ |key| key.to_sym rescue key } 128 | end 129 | 130 | # Destructively convert all keys to symbols, as long as they respond 131 | # to +to_sym+. This includes the keys from the root hash and from all 132 | # nested hashes and arrays. 133 | def deep_symbolize_keys! 134 | deep_transform_keys!{ |key| key.to_sym rescue key } 135 | end 136 | 137 | private 138 | # support methods for deep transforming nested hashes and arrays 139 | def _deep_transform_keys_in_object(object, &block) 140 | case object 141 | when Hash 142 | object.each_with_object({}) do |(key, value), result| 143 | result[yield(key)] = _deep_transform_keys_in_object(value, &block) 144 | end 145 | when Array 146 | object.map {|e| _deep_transform_keys_in_object(e, &block) } 147 | else 148 | object 149 | end 150 | end 151 | 152 | def _deep_transform_keys_in_object!(object, &block) 153 | case object 154 | when Hash 155 | object.keys.each do |key| 156 | value = object.delete(key) 157 | object[yield(key)] = _deep_transform_keys_in_object!(value, &block) 158 | end 159 | object 160 | when Array 161 | object.map! {|e| _deep_transform_keys_in_object!(e, &block)} 162 | else 163 | object 164 | end 165 | end 166 | end unless defined? ActiveSupport 167 | -------------------------------------------------------------------------------- /lib/symbolized/core_ext/hash/reverse_merge.rb: -------------------------------------------------------------------------------- 1 | class Hash 2 | # Merges the caller into +other_hash+. For example, 3 | # 4 | # options = options.reverse_merge(size: 25, velocity: 10) 5 | # 6 | # is equivalent to 7 | # 8 | # options = { size: 25, velocity: 10 }.merge(options) 9 | # 10 | # This is particularly useful for initializing an options hash 11 | # with default values. 12 | def reverse_merge(other_hash) 13 | other_hash.merge(self) 14 | end 15 | 16 | # Destructive +reverse_merge+. 17 | def reverse_merge!(other_hash) 18 | # right wins if there is no left 19 | merge!( other_hash ){|key,left,right| left } 20 | end 21 | alias_method :reverse_update, :reverse_merge! 22 | end unless defined? ActiveSupport 23 | -------------------------------------------------------------------------------- /lib/symbolized/core_ext/hash/symbolized_hash.rb: -------------------------------------------------------------------------------- 1 | require 'symbolized' 2 | 3 | class Hash 4 | # Returns a Symbolized::SymbolizedHash out of its receiver: 5 | # 6 | # { 'a' => 1 }.to_symbolized_hash[:a] # => 1 7 | def to_symbolized_hash 8 | Symbolized::SymbolizedHash.new_from_hash_copying_default(self) 9 | end 10 | 11 | # Called when object is nested under an object that receives 12 | # #to_symbolized_hash. This method will be called on the current object 13 | # by the enclosing object and is aliased to #to_symbolized_hash by 14 | # default. Subclasses of Hash may overwrite this method to return +self+ if 15 | # converting to a Symbolized::SymbolizedHash would not be desirable. 16 | # 17 | # b = { 'b' => 1 } 18 | # { a: b }.with_indifferent_access['a'] # calls b.nested_under_indifferent_access 19 | # # => { :b => 1 } 20 | alias nested_under_symbolized_hash to_symbolized_hash 21 | end 22 | -------------------------------------------------------------------------------- /lib/symbolized/core_ext/object/deep_dup.rb: -------------------------------------------------------------------------------- 1 | unless defined? ActiveSupport 2 | require 'symbolized/core_ext/object/duplicable' 3 | 4 | class Object 5 | # Returns a deep copy of object if it's duplicable. If it's 6 | # not duplicable, returns +self+. 7 | # 8 | # object = Object.new 9 | # dup = object.deep_dup 10 | # dup.instance_variable_set(:@a, 1) 11 | # 12 | # object.instance_variable_defined?(:@a) # => false 13 | # dup.instance_variable_defined?(:@a) # => true 14 | def deep_dup 15 | duplicable? ? dup : self 16 | end 17 | end 18 | 19 | class Array 20 | # Returns a deep copy of array. 21 | # 22 | # array = [1, [2, 3]] 23 | # dup = array.deep_dup 24 | # dup[1][2] = 4 25 | # 26 | # array[1][2] # => nil 27 | # dup[1][2] # => 4 28 | def deep_dup 29 | map { |it| it.deep_dup } 30 | end 31 | end 32 | 33 | class Hash 34 | # Returns a deep copy of hash. 35 | # 36 | # hash = { a: { b: 'b' } } 37 | # dup = hash.deep_dup 38 | # dup[:a][:c] = 'c' 39 | # 40 | # hash[:a][:c] # => nil 41 | # dup[:a][:c] # => "c" 42 | def deep_dup 43 | each_with_object(dup) do |(key, value), hash| 44 | hash[key.deep_dup] = value.deep_dup 45 | end 46 | end 47 | end 48 | end -------------------------------------------------------------------------------- /lib/symbolized/core_ext/object/duplicable.rb: -------------------------------------------------------------------------------- 1 | #-- 2 | # Most objects are cloneable, but not all. For example you can't dup +nil+: 3 | # 4 | # nil.dup # => TypeError: can't dup NilClass 5 | # 6 | # Classes may signal their instances are not duplicable removing +dup+/+clone+ 7 | # or raising exceptions from them. So, to dup an arbitrary object you normally 8 | # use an optimistic approach and are ready to catch an exception, say: 9 | # 10 | # arbitrary_object.dup rescue object 11 | # 12 | # Rails dups objects in a few critical spots where they are not that arbitrary. 13 | # That rescue is very expensive (like 40 times slower than a predicate), and it 14 | # is often triggered. 15 | # 16 | # That's why we hardcode the following cases and check duplicable? instead of 17 | # using that rescue idiom. 18 | #++ 19 | unless defined? ActiveSupport 20 | class Object 21 | # Can you safely dup this object? 22 | # 23 | # False for +nil+, +false+, +true+, symbol, number and BigDecimal(in 1.9.x) objects; 24 | # true otherwise. 25 | def duplicable? 26 | true 27 | end 28 | end 29 | 30 | class NilClass 31 | # +nil+ is not duplicable: 32 | # 33 | # nil.duplicable? # => false 34 | # nil.dup # => TypeError: can't dup NilClass 35 | def duplicable? 36 | false 37 | end 38 | end 39 | 40 | class FalseClass 41 | # +false+ is not duplicable: 42 | # 43 | # false.duplicable? # => false 44 | # false.dup # => TypeError: can't dup FalseClass 45 | def duplicable? 46 | false 47 | end 48 | end 49 | 50 | class TrueClass 51 | # +true+ is not duplicable: 52 | # 53 | # true.duplicable? # => false 54 | # true.dup # => TypeError: can't dup TrueClass 55 | def duplicable? 56 | false 57 | end 58 | end 59 | 60 | class Symbol 61 | # Symbols are not duplicable: 62 | # 63 | # :my_symbol.duplicable? # => false 64 | # :my_symbol.dup # => TypeError: can't dup Symbol 65 | def duplicable? 66 | false 67 | end 68 | end 69 | 70 | class Numeric 71 | # Numbers are not duplicable: 72 | # 73 | # 3.duplicable? # => false 74 | # 3.dup # => TypeError: can't dup Fixnum 75 | def duplicable? 76 | false 77 | end 78 | end 79 | 80 | require 'bigdecimal' 81 | class BigDecimal 82 | # Needed to support Ruby 1.9.x, as it doesn't allow dup on BigDecimal, instead 83 | # raises TypeError exception. Checking here on the runtime whether BigDecimal 84 | # will allow dup or not. 85 | begin 86 | BigDecimal.new('4.56').dup 87 | 88 | def duplicable? 89 | true 90 | end 91 | rescue TypeError 92 | # can't dup, so use superclass implementation 93 | end 94 | end 95 | 96 | class Method 97 | # Methods are not duplicable: 98 | # 99 | # method(:puts).duplicable? # => false 100 | # method(:puts).dup # => TypeError: allocator undefined for Method 101 | def duplicable? 102 | false 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/symbolized/symbolized_hash.rb: -------------------------------------------------------------------------------- 1 | require 'symbolized/core_ext/hash/keys' 2 | require 'symbolized/core_ext/hash/reverse_merge' 3 | 4 | module Symbolized 5 | # Implements a hash where keys :foo and "foo" are considered 6 | # to be the same. 7 | # 8 | # rgb = SymbolizedHash.new 9 | # 10 | # rgb[:black] = '#000000' 11 | # rgb[:black] # => '#000000' 12 | # rgb['black'] # => '#000000' 13 | # 14 | # rgb['white'] = '#FFFFFF' 15 | # rgb[:white] # => '#FFFFFF' 16 | # rgb['white'] # => '#FFFFFF' 17 | # 18 | # Internally strings are mapped to symbols when used as keys in the entire 19 | # writing interface (calling []=, merge, etc). This 20 | # mapping belongs to the public interface. For example, given: 21 | # 22 | # hash = SymbolizedHash.new('a' => 1) 23 | # 24 | # You are guaranteed that the key is returned as a symbol: 25 | # 26 | # hash.keys # => [:a] 27 | # 28 | # Technically other types of keys are accepted: 29 | # 30 | # hash = SymbolizedHash.new('a' => 1) 31 | # hash[0] = 0 32 | # hash # => { :a => 1, 0 => 0 } 33 | # 34 | # but this class is intended for use cases where strings or symbols are the 35 | # expected keys and it is convenient to understand both as the same. For 36 | # example, processing data throught a multi-step pipeline where steps can 37 | # be written by other people. 38 | # 39 | # Note that core extensions define Hash#to_symbolized_hash: 40 | # 41 | # rgb = { black: '#000000', 'white' => '#FFFFFF' }.to_symbolized_hash 42 | # 43 | # which may be handy. 44 | class SymbolizedHash < Hash 45 | # Returns +true+ so that Array#extract_options! finds members of 46 | # this class. 47 | def extractable_options? 48 | true 49 | end 50 | 51 | def symbolized 52 | dup 53 | end 54 | 55 | def nested_under_symbolized_hash 56 | self 57 | end 58 | 59 | def initialize(constructor = {}) 60 | if constructor.respond_to?(:to_hash) 61 | super() 62 | update(constructor) 63 | else 64 | super(constructor) 65 | end 66 | end 67 | 68 | def default(key = nil) 69 | if key.is_a?(String) && include?(key = key.to_sym) 70 | self[key] 71 | else 72 | super 73 | end 74 | end 75 | 76 | def self.new_from_hash_copying_default(hash) 77 | hash = hash.to_hash 78 | new(hash).tap do |new_hash| 79 | new_hash.default = hash.default 80 | new_hash.default_proc = hash.default_proc if hash.default_proc 81 | end 82 | end 83 | 84 | def self.[](*args) 85 | new.merge!(Hash[*args]) 86 | end 87 | 88 | alias_method :regular_writer, :[]= unless method_defined?(:regular_writer) 89 | alias_method :regular_update, :update unless method_defined?(:regular_update) 90 | 91 | # Assigns a new value to the hash: 92 | # 93 | # hash = SymbolizedHash.new 94 | # hash[:key] = 'value' 95 | # 96 | # This value can be later fetched using either +:key+ or +'key'+. 97 | def []=(key, value) 98 | regular_writer(convert_key(key), convert_value(value, for: :assignment)) 99 | end 100 | 101 | alias_method :store, :[]= 102 | 103 | # Updates the receiver in-place, merging in the hash passed as argument: 104 | # 105 | # hash_1 = SymbolizedHash.new 106 | # hash_1['key'] = 'value' 107 | # 108 | # hash_2 = SymbolizedHash.new 109 | # hash_2['key'] = 'New Value!' 110 | # 111 | # hash_1.update(hash_2) # => { :key => 'New Value!' } 112 | # 113 | # The argument can be either a SymbolizedHash or a regular +Hash+. 114 | # In either case the merge respects the semantics of indifferent access. 115 | # 116 | # If the argument is a regular hash with keys +:key+ and +"key"+ only one 117 | # of the values end up in the receiver, but which one is unspecified. 118 | # 119 | # When given a block, the value for duplicated keys will be determined 120 | # by the result of invoking the block with the duplicated key, the value 121 | # in the receiver, and the value in +other_hash+. The rules for duplicated 122 | # keys follow the semantics of indifferent access: 123 | # 124 | # hash_1[:key] = 10 125 | # hash_2['key'] = 12 126 | # hash_1.update(hash_2) { |key, old, new| old + new } # => { :key => 22 } 127 | def update(other_hash) 128 | if other_hash.is_a? SymbolizedHash 129 | super(other_hash) 130 | else 131 | other_hash.to_hash.each_pair do |key, value| 132 | if block_given? && key?(key) 133 | value = yield(convert_key(key), self[key], value) 134 | end 135 | regular_writer(convert_key(key), convert_value(value)) 136 | end 137 | self 138 | end 139 | end 140 | 141 | alias_method :merge!, :update 142 | 143 | # Checks the hash for a key matching the argument passed in: 144 | # 145 | # hash = SymbolizedHash.new 146 | # hash[:key] = 'value' 147 | # hash.key?(:key) # => true 148 | # hash.key?('key') # => true 149 | def key?(key) 150 | super(convert_key(key)) 151 | end 152 | 153 | alias_method :include?, :key? 154 | alias_method :has_key?, :key? 155 | alias_method :member?, :key? 156 | 157 | # Same as Hash#fetch where the key passed as argument can be 158 | # either a string or a symbol: 159 | # 160 | # counters = SymbolizedHash.new 161 | # counters['foo'] = 1 162 | # 163 | # counters.fetch(:foo) # => 1 164 | # counters.fetch(:bar, 0) # => 0 165 | # counters.fetch(:bar) { |key| 0 } # => 0 166 | # counters.fetch('zoo') # => KeyError: key not found: :zoo 167 | def fetch(key, *extras) 168 | super(convert_key(key), *extras) 169 | end 170 | 171 | # Returns an array of the values at the specified indices: 172 | # 173 | # hash = SymbolizedHash.new 174 | # hash['a'] = 'x' 175 | # hash['b'] = 'y' 176 | # hash.values_at(:a, :b) # => ["x", "y"] 177 | def values_at(*indices) 178 | indices.collect { |key| self[convert_key(key)] } 179 | end 180 | 181 | # Returns a shallow copy of the hash. 182 | # 183 | # hash = SymbolizedHash.new({ a: { b: 'b' } }) 184 | # dup = hash.dup 185 | # dup[:a][:c] = 'c' 186 | # 187 | # hash[:a][:c] # => nil 188 | # dup[:a][:c] # => "c" 189 | def dup 190 | self.class.new(self).tap do |new_hash| 191 | set_defaults(new_hash) 192 | end 193 | end 194 | 195 | # This method has the same semantics of +update+, except it does not 196 | # modify the receiver but rather returns a new symbolized hash with 197 | # indifferent access with the result of the merge. 198 | def merge(hash, &block) 199 | self.dup.update(hash, &block) 200 | end 201 | 202 | # Like +merge+ but the other way around: Merges the receiver into the 203 | # argument and returns a new hash with indifferent access as result: 204 | # 205 | # hash = SymbolizedHash.new 206 | # hash[:a] = nil 207 | # hash.reverse_merge('a' => 0, 'b' => 1) # => { :a => nil, :b => 1 } 208 | def reverse_merge(other_hash) 209 | super(self.class.new_from_hash_copying_default(other_hash)) 210 | end 211 | 212 | # Same semantics as +reverse_merge+ but modifies the receiver in-place. 213 | def reverse_merge!(other_hash) 214 | replace(reverse_merge( other_hash )) 215 | end 216 | 217 | # Replaces the contents of this hash with other_hash. 218 | # 219 | # h = { "a" => 100, "b" => 200 } 220 | # h.replace({ "c" => 300, "d" => 400 }) # => { :c => 300, :d => 400 } 221 | def replace(other_hash) 222 | super(self.class.new_from_hash_copying_default(other_hash)) 223 | end 224 | 225 | # Removes the specified key from the hash. 226 | def delete(key) 227 | super(convert_key(key)) 228 | end 229 | 230 | def symbolize_keys!; self end 231 | def deep_symbolize_keys!; self end 232 | def symbolize_keys; dup end 233 | def deep_symbolize_keys; dup end 234 | undef :stringify_keys! if method_defined? :stringify_keys! 235 | undef :deep_stringify_keys! if method_defined? :deep_stringify_keys! 236 | def stringify_keys; to_hash.stringify_keys! end 237 | def deep_stringify_keys; to_hash.deep_stringify_keys! end 238 | def to_options!; self end 239 | 240 | def select(*args, &block) 241 | dup.tap { |hash| hash.select!(*args, &block) } 242 | end 243 | 244 | def reject(*args, &block) 245 | dup.tap { |hash| hash.reject!(*args, &block) } 246 | end 247 | 248 | # Convert to a regular hash with symbol keys. 249 | def to_hash 250 | _new_hash = Hash.new 251 | set_defaults(_new_hash) 252 | 253 | each do |key, value| 254 | _new_hash[key] = convert_value(value, for: :to_hash) 255 | end 256 | _new_hash 257 | end 258 | 259 | protected 260 | def convert_key(key) 261 | key.kind_of?(String) ? key.to_sym : key 262 | end 263 | 264 | def convert_value(value, options = {}) 265 | if value.is_a? Hash 266 | if options[:for] == :to_hash 267 | value.to_hash 268 | else 269 | value.nested_under_symbolized_hash 270 | end 271 | elsif value.is_a?(Array) 272 | unless options[:for] == :assignment 273 | value = value.dup 274 | end 275 | value.map! { |e| convert_value(e, options) } 276 | else 277 | value 278 | end 279 | end 280 | 281 | def set_defaults(target) 282 | if default_proc 283 | target.default_proc = default_proc.dup 284 | else 285 | target.default = default 286 | end 287 | end 288 | end 289 | end 290 | 291 | SymbolizedHash = Symbolized::SymbolizedHash 292 | -------------------------------------------------------------------------------- /lib/symbolized/version.rb: -------------------------------------------------------------------------------- 1 | module Symbolized 2 | VERSION = '0.0.1' 3 | end -------------------------------------------------------------------------------- /symbolized.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'symbolized/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'symbolized' 8 | s.version = Symbolized::VERSION 9 | s.licenses = ['MIT'] 10 | s.summary = "Symbolized HashWithIndifferentAccess" 11 | s.description = "Hash with indifferent access, with keys stored internally as symbols." 12 | s.authors = ["Tamer Shlash"] 13 | s.email = 'mr.tamershlash@gmail.com' 14 | s.files = Dir["lib/**/*", "LICENSE", "README.md"] 15 | s.require_path = 'lib' 16 | s.homepage = 'https://github.com/TamerShlash/symbolized' 17 | end 18 | -------------------------------------------------------------------------------- /test/core_ext/hash_ext_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helpers' 2 | require 'symbolized/core_ext/object/deep_dup' 3 | require 'symbolized/core_ext/hash/symbolized_hash' 4 | 5 | class HashExtTest < Symbolized::TestCase 6 | class IndifferentHash < Symbolized::SymbolizedHash 7 | end 8 | 9 | class SubclassingArray < Array 10 | end 11 | 12 | class SubclassingHash < Hash 13 | end 14 | 15 | class NonIndifferentHash < Hash 16 | def nested_under_symbolized_hash 17 | self 18 | end 19 | end 20 | 21 | class HashByConversion 22 | def initialize(hash) 23 | @hash = hash 24 | end 25 | 26 | def to_hash 27 | @hash 28 | end 29 | end 30 | 31 | def setup 32 | @strings = { 'a' => 1, 'b' => 2 } 33 | @nested_strings = { 'a' => { 'b' => { 'c' => 3 } } } 34 | @symbols = { :a => 1, :b => 2 } 35 | @nested_symbols = { :a => { :b => { :c => 3 } } } 36 | @mixed = { :a => 1, 'b' => 2 } 37 | @nested_mixed = { 'a' => { :b => { 'c' => 3 } } } 38 | @fixnums = { 0 => 1, 1 => 2 } 39 | @nested_fixnums = { 0 => { 1 => { 2 => 3} } } 40 | @illegal_symbols = { [] => 3 } 41 | @nested_illegal_symbols = { [] => { [] => 3} } 42 | @upcase_strings = { 'A' => 1, 'B' => 2 } 43 | @nested_upcase_strings = { 'A' => { 'B' => { 'C' => 3 } } } 44 | @string_array_of_hashes = { 'a' => [ { 'b' => 2 }, { 'c' => 3 }, 4 ] } 45 | @symbol_array_of_hashes = { :a => [ { :b => 2 }, { :c => 3 }, 4 ] } 46 | @mixed_array_of_hashes = { :a => [ { :b => 2 }, { 'c' => 3 }, 4 ] } 47 | @upcase_array_of_hashes = { 'A' => [ { 'B' => 2 }, { 'C' => 3 }, 4 ] } 48 | end 49 | 50 | def test_methods 51 | h = {} 52 | assert_respond_to h, :transform_keys 53 | assert_respond_to h, :transform_keys! 54 | assert_respond_to h, :deep_transform_keys 55 | assert_respond_to h, :deep_transform_keys! 56 | assert_respond_to h, :symbolize_keys 57 | assert_respond_to h, :symbolize_keys! 58 | assert_respond_to h, :deep_symbolize_keys 59 | assert_respond_to h, :deep_symbolize_keys! 60 | assert_respond_to h, :stringify_keys 61 | assert_respond_to h, :stringify_keys! 62 | assert_respond_to h, :deep_stringify_keys 63 | assert_respond_to h, :deep_stringify_keys! 64 | assert_respond_to h, :to_options 65 | assert_respond_to h, :to_options! 66 | end 67 | 68 | def test_stringify_keys_for_symbolized_hash 69 | assert_instance_of Hash, @strings.to_symbolized_hash.stringify_keys 70 | assert_equal @strings, @strings.to_symbolized_hash.stringify_keys 71 | assert_equal @strings, @symbols.to_symbolized_hash.stringify_keys 72 | assert_equal @strings, @mixed.to_symbolized_hash.stringify_keys 73 | end 74 | 75 | def test_deep_stringify_keys_for_symbolized_hash 76 | assert_instance_of Hash, @nested_symbols.to_symbolized_hash.deep_stringify_keys 77 | assert_equal @nested_strings, @nested_strings.to_symbolized_hash.deep_stringify_keys 78 | assert_equal @nested_strings, @nested_symbols.to_symbolized_hash.deep_stringify_keys 79 | assert_equal @nested_strings, @nested_mixed.to_symbolized_hash.deep_stringify_keys 80 | end 81 | 82 | def test_stringify_keys_bang_for_symbolized_hash 83 | assert_raise(NoMethodError) { @strings.to_symbolized_hash.dup.stringify_keys! } 84 | assert_raise(NoMethodError) { @symbols.to_symbolized_hash.dup.stringify_keys! } 85 | assert_raise(NoMethodError) { @mixed.to_symbolized_hash.dup.stringify_keys! } 86 | end 87 | 88 | def test_deep_stringify_keys_bang_for_symbolized_hash 89 | assert_raise(NoMethodError) { @nested_strings.to_symbolized_hash.deep_dup.deep_stringify_keys! } 90 | assert_raise(NoMethodError) { @nested_symbols.to_symbolized_hash.deep_dup.deep_stringify_keys! } 91 | assert_raise(NoMethodError) { @nested_mixed.to_symbolized_hash.deep_dup.deep_stringify_keys! } 92 | end 93 | 94 | def test_symbolize_keys_for_symbolized_hash 95 | assert_instance_of Symbolized::SymbolizedHash, @symbols.to_symbolized_hash.symbolize_keys 96 | assert_equal @symbols, @strings.to_symbolized_hash.symbolize_keys 97 | assert_equal @symbols, @symbols.to_symbolized_hash.symbolize_keys 98 | assert_equal @symbols, @mixed.to_symbolized_hash.symbolize_keys 99 | end 100 | 101 | def test_deep_stringify_keys_for_symbolized_hash 102 | assert_instance_of Symbolized::SymbolizedHash, @nested_symbols.to_symbolized_hash.deep_symbolize_keys 103 | assert_equal @nested_symbols, @nested_strings.to_symbolized_hash.deep_symbolize_keys 104 | assert_equal @nested_symbols, @nested_symbols.to_symbolized_hash.deep_symbolize_keys 105 | assert_equal @nested_symbols, @nested_mixed.to_symbolized_hash.deep_symbolize_keys 106 | end 107 | 108 | def test_symbolize_keys_bang_for_symbolized_hash 109 | assert_instance_of Symbolized::SymbolizedHash, @symbols.to_symbolized_hash.dup.symbolize_keys! 110 | assert_equal @symbols, @strings.to_symbolized_hash.dup.symbolize_keys! 111 | assert_equal @symbols, @symbols.to_symbolized_hash.dup.symbolize_keys! 112 | assert_equal @symbols, @mixed.to_symbolized_hash.dup.symbolize_keys! 113 | end 114 | 115 | def test_deep_symbolize_keys_bang_for_symbolized_hash 116 | assert_instance_of Symbolized::SymbolizedHash, @nested_symbols.to_symbolized_hash.dup.deep_symbolize_keys! 117 | assert_equal @nested_symbols, @nested_strings.to_symbolized_hash.deep_dup.deep_symbolize_keys! 118 | assert_equal @nested_symbols, @nested_symbols.to_symbolized_hash.deep_dup.deep_symbolize_keys! 119 | assert_equal @nested_symbols, @nested_mixed.to_symbolized_hash.deep_dup.deep_symbolize_keys! 120 | end 121 | 122 | def test_symbolize_keys_preserves_keys_that_cant_be_symbolized_for_symbolized_hash 123 | assert_equal @illegal_symbols, @illegal_symbols.to_symbolized_hash.symbolize_keys 124 | assert_equal @illegal_symbols, @illegal_symbols.to_symbolized_hash.dup.symbolize_keys! 125 | end 126 | 127 | def test_deep_symbolize_keys_preserves_keys_that_cant_be_symbolized_for_symbolized_hash 128 | assert_equal @nested_illegal_symbols, @nested_illegal_symbols.to_symbolized_hash.deep_symbolize_keys 129 | assert_equal @nested_illegal_symbols, @nested_illegal_symbols.to_symbolized_hash.deep_dup.deep_symbolize_keys! 130 | end 131 | 132 | def test_symbolize_keys_preserves_fixnum_keys_for_symbolized_hash 133 | assert_equal @fixnums, @fixnums.to_symbolized_hash.symbolize_keys 134 | assert_equal @fixnums, @fixnums.to_symbolized_hash.dup.symbolize_keys! 135 | end 136 | 137 | def test_deep_symbolize_keys_preserves_fixnum_keys_for_symbolized_hash 138 | assert_equal @nested_fixnums, @nested_fixnums.to_symbolized_hash.deep_symbolize_keys 139 | assert_equal @nested_fixnums, @nested_fixnums.to_symbolized_hash.deep_dup.deep_symbolize_keys! 140 | end 141 | 142 | def test_nested_under_symbolized_hash 143 | foo = { "foo" => SubclassingHash.new.tap { |h| h["bar"] = "baz" } }.to_symbolized_hash 144 | assert_kind_of Symbolized::SymbolizedHash, foo["foo"] 145 | 146 | foo = { "foo" => NonIndifferentHash.new.tap { |h| h["bar"] = "baz" } }.to_symbolized_hash 147 | assert_kind_of NonIndifferentHash, foo["foo"] 148 | 149 | foo = { "foo" => IndifferentHash.new.tap { |h| h["bar"] = "baz" } }.to_symbolized_hash 150 | assert_kind_of IndifferentHash, foo["foo"] 151 | end 152 | 153 | def test_indifferent_assorted 154 | @strings = @strings.to_symbolized_hash 155 | @symbols = @symbols.to_symbolized_hash 156 | @mixed = @mixed.to_symbolized_hash 157 | 158 | assert_equal :a, @strings.__send__(:convert_key, 'a') 159 | 160 | assert_equal 1, @strings.fetch(:a) 161 | assert_equal 1, @strings.fetch('a'.to_sym) 162 | assert_equal 1, @strings.fetch('a') 163 | 164 | hashes = { :@strings => @strings, :@symbols => @symbols, :@mixed => @mixed } 165 | method_map = { :'[]' => 1, :fetch => 1, :values_at => [1], 166 | :has_key? => true, :include? => true, :key? => true, 167 | :member? => true } 168 | 169 | hashes.each do |name, hash| 170 | method_map.sort_by { |m| m.to_s }.each do |meth, expected| 171 | assert_equal(expected, hash.__send__(meth, 'a'), 172 | "Calling #{name}.#{meth} 'a'") 173 | assert_equal(expected, hash.__send__(meth, :a), 174 | "Calling #{name}.#{meth} :a") 175 | end 176 | end 177 | 178 | assert_equal [1, 2], @strings.values_at('a', 'b') 179 | assert_equal [1, 2], @strings.values_at(:a, :b) 180 | assert_equal [1, 2], @symbols.values_at('a', 'b') 181 | assert_equal [1, 2], @symbols.values_at(:a, :b) 182 | assert_equal [1, 2], @mixed.values_at('a', 'b') 183 | assert_equal [1, 2], @mixed.values_at(:a, :b) 184 | end 185 | 186 | def test_indifferent_reading 187 | hash = SymbolizedHash.new 188 | hash["a"] = 1 189 | hash["b"] = true 190 | hash["c"] = false 191 | hash["d"] = nil 192 | 193 | assert_equal 1, hash[:a] 194 | assert_equal true, hash[:b] 195 | assert_equal false, hash[:c] 196 | assert_equal nil, hash[:d] 197 | assert_equal nil, hash[:e] 198 | end 199 | 200 | def test_indifferent_reading_with_nonnil_default 201 | hash = SymbolizedHash.new(1) 202 | hash["a"] = 1 203 | hash["b"] = true 204 | hash["c"] = false 205 | hash["d"] = nil 206 | 207 | assert_equal 1, hash[:a] 208 | assert_equal true, hash[:b] 209 | assert_equal false, hash[:c] 210 | assert_equal nil, hash[:d] 211 | assert_equal 1, hash[:e] 212 | end 213 | 214 | def test_indifferent_writing 215 | hash = SymbolizedHash.new 216 | hash[:a] = 1 217 | hash['b'] = 2 218 | hash[3] = 3 219 | 220 | assert_equal hash['a'], 1 221 | assert_equal hash['b'], 2 222 | assert_equal hash[:a], 1 223 | assert_equal hash[:b], 2 224 | assert_equal hash[3], 3 225 | end 226 | 227 | def test_indifferent_update 228 | hash = SymbolizedHash.new 229 | hash[:a] = 'a' 230 | hash['b'] = 'b' 231 | 232 | updated_with_strings = hash.update(@strings) 233 | updated_with_symbols = hash.update(@symbols) 234 | updated_with_mixed = hash.update(@mixed) 235 | 236 | assert_equal updated_with_strings[:a], 1 237 | assert_equal updated_with_strings['a'], 1 238 | assert_equal updated_with_strings['b'], 2 239 | 240 | assert_equal updated_with_symbols[:a], 1 241 | assert_equal updated_with_symbols['b'], 2 242 | assert_equal updated_with_symbols[:b], 2 243 | 244 | assert_equal updated_with_mixed[:a], 1 245 | assert_equal updated_with_mixed['b'], 2 246 | 247 | assert [updated_with_strings, updated_with_symbols, updated_with_mixed].all? { |h| h.keys.size == 2 } 248 | end 249 | 250 | def test_update_with_to_hash_conversion 251 | hash = SymbolizedHash.new 252 | hash.update HashByConversion.new({ :a => 1 }) 253 | assert_equal hash['a'], 1 254 | end 255 | 256 | def test_indifferent_merging 257 | hash = SymbolizedHash.new 258 | hash[:a] = 'failure' 259 | hash['b'] = 'failure' 260 | 261 | other = { 'a' => 1, :b => 2 } 262 | 263 | merged = hash.merge(other) 264 | 265 | assert_equal SymbolizedHash, merged.class 266 | assert_equal 1, merged[:a] 267 | assert_equal 2, merged['b'] 268 | 269 | hash.update(other) 270 | 271 | assert_equal 1, hash[:a] 272 | assert_equal 2, hash['b'] 273 | end 274 | 275 | def test_merge_with_to_hash_conversion 276 | hash = SymbolizedHash.new 277 | merged = hash.merge HashByConversion.new({ :a => 1 }) 278 | assert_equal merged['a'], 1 279 | end 280 | 281 | def test_indifferent_replace 282 | hash = SymbolizedHash.new 283 | hash[:a] = 42 284 | 285 | replaced = hash.replace(b: 12) 286 | 287 | assert hash.key?('b') 288 | assert !hash.key?(:a) 289 | assert_equal 12, hash[:b] 290 | assert_same hash, replaced 291 | end 292 | 293 | def test_replace_with_to_hash_conversion 294 | hash = SymbolizedHash.new 295 | hash[:a] = 42 296 | 297 | replaced = hash.replace(HashByConversion.new(b: 12)) 298 | 299 | assert hash.key?('b') 300 | assert !hash.key?(:a) 301 | assert_equal 12, hash[:b] 302 | assert_same hash, replaced 303 | end 304 | 305 | def test_indifferent_merging_with_block 306 | hash = SymbolizedHash.new 307 | hash[:a] = 1 308 | hash['b'] = 3 309 | 310 | other = { 'a' => 4, :b => 2, 'c' => 10 } 311 | 312 | merged = hash.merge(other) { |key, old, new| old > new ? old : new } 313 | 314 | assert_equal SymbolizedHash, merged.class 315 | assert_equal 4, merged[:a] 316 | assert_equal 3, merged['b'] 317 | assert_equal 10, merged[:c] 318 | 319 | other_indifferent = SymbolizedHash.new('a' => 9, :b => 2) 320 | 321 | merged = hash.merge(other_indifferent) { |key, old, new| old + new } 322 | 323 | assert_equal SymbolizedHash, merged.class 324 | assert_equal 10, merged[:a] 325 | assert_equal 5, merged[:b] 326 | end 327 | 328 | def test_indifferent_reverse_merging 329 | hash = SymbolizedHash.new key: :old_value 330 | hash.reverse_merge! key: :new_value 331 | assert_equal :old_value, hash[:key] 332 | 333 | hash = SymbolizedHash.new('some' => 'value', 'other' => 'value') 334 | hash.reverse_merge!(:some => 'noclobber', :another => 'clobber') 335 | assert_equal 'value', hash[:some] 336 | assert_equal 'clobber', hash[:another] 337 | end 338 | 339 | def test_indifferent_deleting 340 | get_hash = proc{ { :a => 'foo' }.to_symbolized_hash } 341 | hash = get_hash.call 342 | assert_equal hash.delete(:a), 'foo' 343 | assert_equal hash.delete(:a), nil 344 | hash = get_hash.call 345 | assert_equal hash.delete('a'), 'foo' 346 | assert_equal hash.delete('a'), nil 347 | end 348 | 349 | def test_indifferent_select 350 | hash = Symbolized::SymbolizedHash.new(@strings).select {|k,v| v == 1} 351 | 352 | assert_equal({ :a => 1 }, hash) 353 | assert_instance_of Symbolized::SymbolizedHash, hash 354 | end 355 | 356 | def test_indifferent_select_returns_a_hash_when_unchanged 357 | hash = Symbolized::SymbolizedHash.new(@strings).select {|k,v| true} 358 | 359 | assert_instance_of Symbolized::SymbolizedHash, hash 360 | end 361 | 362 | def test_indifferent_select_bang 363 | indifferent_symbols = Symbolized::SymbolizedHash.new(@symbols) 364 | indifferent_symbols.select! {|k,v| v == 1} 365 | 366 | assert_equal({ :a => 1 }, indifferent_symbols) 367 | assert_instance_of Symbolized::SymbolizedHash, indifferent_symbols 368 | end 369 | 370 | def test_indifferent_reject 371 | hash = Symbolized::SymbolizedHash.new(@symbols).reject {|k,v| v != 1} 372 | 373 | assert_equal({ :a => 1 }, hash) 374 | assert_instance_of Symbolized::SymbolizedHash, hash 375 | end 376 | 377 | def test_indifferent_reject_bang 378 | indifferent_symbols = Symbolized::SymbolizedHash.new(@symbols) 379 | indifferent_symbols.reject! {|k,v| v != 1} 380 | 381 | assert_equal({ :a => 1 }, indifferent_symbols) 382 | assert_instance_of Symbolized::SymbolizedHash, indifferent_symbols 383 | end 384 | 385 | def test_indifferent_to_hash 386 | # Should convert to a Hash with Symbol keys. 387 | assert_equal @symbols, @mixed.to_symbolized_hash.to_hash 388 | 389 | # Should preserve the default value. 390 | mixed_with_default = @mixed.dup 391 | mixed_with_default.default = '1234' 392 | roundtrip = mixed_with_default.to_symbolized_hash.to_hash 393 | assert_equal @symbols, roundtrip 394 | assert_equal '1234', roundtrip.default 395 | 396 | # Ensure nested hashes are not HashWithIndiffereneAccess 397 | new_to_hash = @nested_mixed.to_symbolized_hash.to_hash 398 | assert_not new_to_hash.instance_of?(SymbolizedHash) 399 | assert_not new_to_hash[:a].instance_of?(SymbolizedHash) 400 | assert_not new_to_hash[:a][:b].instance_of?(SymbolizedHash) 401 | end 402 | 403 | def test_lookup_returns_the_same_object_that_is_stored_in_symbolized_hash 404 | hash = SymbolizedHash.new {|h, k| h[k] = []} 405 | hash[:a] << 1 406 | 407 | assert_equal [1], hash[:a] 408 | end 409 | 410 | def test_symbolized_hash_has_no_side_effects_on_existing_hash 411 | hash = {content: [{:foo => :bar, 'bar' => 'baz'}]} 412 | hash.to_symbolized_hash 413 | 414 | assert_equal [:foo, "bar"], hash[:content].first.keys 415 | end 416 | 417 | def test_indifferent_hash_with_array_of_hashes 418 | hash = { "urls" => { "url" => [ { "address" => "1" }, { "address" => "2" } ] }}.to_symbolized_hash 419 | assert_equal "1", hash[:urls][:url].first[:address] 420 | 421 | hash = hash.to_hash 422 | assert_not hash.instance_of?(SymbolizedHash) 423 | assert_not hash[:urls].instance_of?(SymbolizedHash) 424 | assert_not hash[:urls][:url].first.instance_of?(SymbolizedHash) 425 | end 426 | 427 | def test_should_preserve_array_subclass_when_value_is_array 428 | array = SubclassingArray.new 429 | array << { "address" => "1" } 430 | hash = { "urls" => { "url" => array }}.to_symbolized_hash 431 | assert_equal SubclassingArray, hash[:urls][:url].class 432 | end 433 | 434 | def test_should_preserve_array_class_when_hash_value_is_frozen_array 435 | array = SubclassingArray.new 436 | array << { "address" => "1" } 437 | hash = { "urls" => { "url" => array.freeze }}.to_symbolized_hash 438 | assert_equal SubclassingArray, hash[:urls][:url].class 439 | end 440 | 441 | def test_stringify_and_symbolize_keys_on_indifferent_preserves_hash 442 | h = SymbolizedHash.new 443 | h[:first] = 1 444 | h = h.stringify_keys 445 | assert_equal 1, h['first'] 446 | h = SymbolizedHash.new 447 | h['first'] = 1 448 | h = h.symbolize_keys 449 | assert_equal 1, h[:first] 450 | end 451 | 452 | def test_deep_stringify_and_deep_symbolize_keys_on_indifferent_preserves_hash 453 | h = SymbolizedHash.new 454 | h[:first] = 1 455 | h = h.deep_stringify_keys 456 | assert_equal 1, h['first'] 457 | h = SymbolizedHash.new 458 | h['first'] = 1 459 | h = h.deep_symbolize_keys 460 | assert_equal 1, h[:first] 461 | end 462 | 463 | def test_to_options_on_indifferent_preserves_hash 464 | h = SymbolizedHash.new 465 | h['first'] = 1 466 | h.to_options! 467 | assert_equal 1, h['first'] 468 | end 469 | 470 | def test_to_options_on_indifferent_preserves_works_as_hash_with_dup 471 | h = SymbolizedHash.new({ a: { b: 'b' } }) 472 | dup = h.dup 473 | 474 | dup[:a][:c] = 'c' 475 | assert_equal 'c', h[:a][:c] 476 | end 477 | 478 | def test_indifferent_sub_hashes 479 | h = {'user' => {'id' => 5}}.to_symbolized_hash 480 | ['user', :user].each {|user| [:id, 'id'].each {|id| assert_equal 5, h[user][id], "h[#{user.inspect}][#{id.inspect}] should be 5"}} 481 | 482 | h = {:user => {:id => 5}}.to_symbolized_hash 483 | ['user', :user].each {|user| [:id, 'id'].each {|id| assert_equal 5, h[user][id], "h[#{user.inspect}][#{id.inspect}] should be 5"}} 484 | end 485 | 486 | def test_indifferent_duplication 487 | # Should preserve default value 488 | h = SymbolizedHash.new 489 | h.default = '1234' 490 | assert_equal h.default, h.dup.default 491 | 492 | # Should preserve class for subclasses 493 | h = IndifferentHash.new 494 | assert_equal h.class, h.dup.class 495 | end 496 | 497 | def test_assert_valid_keys 498 | assert_nothing_raised do 499 | { :failure => "stuff", :funny => "business" }.assert_valid_keys([ :failure, :funny ]) 500 | { :failure => "stuff", :funny => "business" }.assert_valid_keys(:failure, :funny) 501 | end 502 | # not all valid keys are required to be present 503 | assert_nothing_raised do 504 | { :failure => "stuff", :funny => "business" }.assert_valid_keys([ :failure, :funny, :sunny ]) 505 | { :failure => "stuff", :funny => "business" }.assert_valid_keys(:failure, :funny, :sunny) 506 | end 507 | 508 | exception = assert_raise ArgumentError do 509 | { :failore => "stuff", :funny => "business" }.assert_valid_keys([ :failure, :funny ]) 510 | end 511 | assert_equal "Unknown key: :failore. Valid keys are: :failure, :funny", exception.message 512 | 513 | exception = assert_raise ArgumentError do 514 | { :failore => "stuff", :funny => "business" }.assert_valid_keys(:failure, :funny) 515 | end 516 | assert_equal "Unknown key: :failore. Valid keys are: :failure, :funny", exception.message 517 | 518 | exception = assert_raise ArgumentError do 519 | { :failore => "stuff", :funny => "business" }.assert_valid_keys([ :failure ]) 520 | end 521 | assert_equal "Unknown key: :failore. Valid keys are: :failure", exception.message 522 | 523 | exception = assert_raise ArgumentError do 524 | { :failore => "stuff", :funny => "business" }.assert_valid_keys(:failure) 525 | end 526 | assert_equal "Unknown key: :failore. Valid keys are: :failure", exception.message 527 | end 528 | 529 | def test_assorted_keys_not_stringified 530 | original = {Object.new => 2, 1 => 2, [] => true} 531 | indiff = original.to_symbolized_hash 532 | assert(!indiff.keys.any? {|k| k.kind_of? String}, "A key was converted to a string!") 533 | end 534 | 535 | def test_store_on_symbolized_hash 536 | hash = SymbolizedHash.new 537 | hash.store(:test1, 1) 538 | hash.store('test1', 11) 539 | hash[:test2] = 2 540 | hash['test2'] = 22 541 | expected = { :test1 => 11, :test2 => 22 } 542 | assert_equal expected, hash 543 | end 544 | 545 | def test_constructor_on_symbolized_hash 546 | hash = SymbolizedHash[:foo, 1] 547 | assert_equal 1, hash[:foo] 548 | assert_equal 1, hash['foo'] 549 | hash[:foo] = 3 550 | assert_equal 3, hash[:foo] 551 | assert_equal 3, hash['foo'] 552 | end 553 | 554 | def test_reverse_merge 555 | defaults = { :a => "x", :b => "y", :c => 10 }.freeze 556 | options = { :a => 1, :b => 2 } 557 | expected = { :a => 1, :b => 2, :c => 10 } 558 | 559 | # Should merge defaults into options, creating a new hash. 560 | assert_equal expected, options.reverse_merge(defaults) 561 | assert_not_equal expected, options 562 | 563 | # Should merge! defaults into options, replacing options. 564 | merged = options.dup 565 | assert_equal expected, merged.reverse_merge!(defaults) 566 | assert_equal expected, merged 567 | 568 | # Should be an alias for reverse_merge! 569 | merged = options.dup 570 | assert_equal expected, merged.reverse_update(defaults) 571 | assert_equal expected, merged 572 | end 573 | 574 | def test_new_with_to_hash_conversion 575 | hash = SymbolizedHash.new(HashByConversion.new(a: 1)) 576 | assert hash.key?('a') 577 | assert_equal 1, hash[:a] 578 | end 579 | 580 | def test_dup_with_default_proc 581 | hash = SymbolizedHash.new 582 | hash.default_proc = proc { |h, v| raise "walrus" } 583 | assert_nothing_raised { hash.dup } 584 | end 585 | 586 | def test_dup_with_default_proc_sets_proc 587 | hash = SymbolizedHash.new 588 | hash.default_proc = proc { |h, k| k + 1 } 589 | new_hash = hash.dup 590 | 591 | assert_equal 3, new_hash[2] 592 | 593 | new_hash.default = 2 594 | assert_equal 2, new_hash[:non_existant] 595 | end 596 | 597 | def test_to_hash_with_raising_default_proc 598 | hash = SymbolizedHash.new 599 | hash.default_proc = proc { |h, k| raise "walrus" } 600 | 601 | assert_nothing_raised { hash.to_hash } 602 | end 603 | 604 | def test_new_from_hash_copying_default_should_not_raise_when_default_proc_does 605 | hash = Hash.new 606 | hash.default_proc = proc { |h, k| raise "walrus" } 607 | 608 | assert_nothing_raised { SymbolizedHash.new_from_hash_copying_default(hash) } 609 | end 610 | end 611 | -------------------------------------------------------------------------------- /test/test_helpers.rb: -------------------------------------------------------------------------------- 1 | gem 'minitest' 2 | require 'minitest' 3 | require 'minitest/autorun' 4 | 5 | module Symbolized 6 | class TestCase < ::Minitest::Test 7 | # test/unit backwards compatibility methods 8 | alias :assert_raise :assert_raises 9 | alias :assert_not_empty :refute_empty 10 | alias :assert_not_equal :refute_equal 11 | alias :assert_not_in_delta :refute_in_delta 12 | alias :assert_not_in_epsilon :refute_in_epsilon 13 | alias :assert_not_includes :refute_includes 14 | alias :assert_not_instance_of :refute_instance_of 15 | alias :assert_not_kind_of :refute_kind_of 16 | alias :assert_no_match :refute_match 17 | alias :assert_not_nil :refute_nil 18 | alias :assert_not_operator :refute_operator 19 | alias :assert_not_predicate :refute_predicate 20 | alias :assert_not_respond_to :refute_respond_to 21 | alias :assert_not_same :refute_same 22 | 23 | # Fails if the block raises an exception. 24 | # 25 | # assert_nothing_raised do 26 | # ... 27 | # end 28 | def assert_nothing_raised(*args) 29 | yield 30 | end 31 | 32 | # Assert that an expression is not truthy. Passes if object is 33 | # +nil+ or +false+. "Truthy" means "considered true in a conditional" 34 | # like if foo. 35 | # 36 | # assert_not nil # => true 37 | # assert_not false # => true 38 | # assert_not 'foo' # => Expected "foo" to be nil or false 39 | # 40 | # An error message can be specified. 41 | # 42 | # assert_not foo, 'foo should be false' 43 | def assert_not(object, message = nil) 44 | message ||= "Expected #{mu_pp(object)} to be nil or false" 45 | assert !object, message 46 | end 47 | end 48 | end 49 | --------------------------------------------------------------------------------