require 'active_support/hash_with_indifferent_access' class Hash # Converts this Hash to an instance of # Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess, which is # a subclass of ActiveSupport::HashWithIndifferentAccess with the addition of # case-insensitive lookup. # # Note that this is more thorough than the ActiveSupport counterpart. It # converts recursively, so that all Hashes to arbitrary depth, including any # hashes inside Arrays, are converted. This is an expensive operation. # def with_indifferent_case_insensitive_access self.class.deep_indifferent_case_insensitive_access(self) end # Supports #with_indifferent_case_insensitive_access. Converts the given item # to indifferent, case-insensitive access as a Hash; or converts Array items # if given an Array; or returns the given object. # # Hashes and Arrays at all depths are duplicated as a result. # def self.deep_indifferent_case_insensitive_access(object) if object.is_a?(Hash) new_hash = Scimitar::Support::HashWithIndifferentCaseInsensitiveAccess.new object.each do | key, value | new_hash[key] = deep_indifferent_case_insensitive_access(value) end new_hash elsif object.is_a?(Array) object.map do | array_entry | deep_indifferent_case_insensitive_access(array_entry) end else object end end end module Scimitar module Support # A subclass of ActiveSupport::HashWithIndifferentAccess where not only # can Hash keys be queried as Symbols or Strings, but they are looked up # in a case-insensitive fashion too. # # During enumeration, Hash keys will always be returned in whatever case # they were originally set. Just as with # ActiveSupport::HashWithIndifferentAccess, though, the type of the keys is # always returned as a String, even if originally set as a Symbol - only # the upper/lower case nature of the original key is preserved. # # If a key is written more than once with the same effective meaning in a # to-string, to-downcase form, then whatever case was used *first* wins; # e.g. if you did hash['User'] = 23, then hash['USER'] = 42, the result # would be {"User" => 42}. # # It's important to remember that Hash#merge is shallow and replaces values # found at existing keys in the target ("this") hash with values in the # inbound Hash. If that new value that is itself a Hash, this *replaces* # the value. For example: # # * Original: 'Foo' => { 'Bar' => 42 } # * Merge: 'FOO' => { 'BAR' => 24 } # # ...results in "this" target hash's key +Foo+ being addressed in the merge # by inbound key +FOO+, so the case doesn't change. But the value for +Foo+ # is _replaced_ by the merging-in Hash completely: # # * Result: 'Foo' => { 'BAR' => 24 } # # ...and of course we might've replaced with a totally different type, such # as +true+: # # * Original: 'Foo' => { 'Bar' => 42 } # * Merge: 'FOO' => true # * Result: 'Foo' => true # # If you're intending to merge nested Hashes, then use ActiveSupport's # #deep_merge or an equivalent. This will have the expected outcome, where # the hash with 'BAR' is _merged_ into the existing value and, therefore, # the original 'Bar' key case is preserved: # # * Original: 'Foo' => { 'Bar' => 42 } # * Deep merge: 'FOO' => { 'BAR' => 24 } # * Result: 'Foo' => { 'Bar' => 24 } # class HashWithIndifferentCaseInsensitiveAccess < ActiveSupport::HashWithIndifferentAccess def with_indifferent_case_insensitive_access self end def initialize(constructor = nil) @scimitar_hash_with_indifferent_case_insensitive_access_key_map = {} super end # It's vital that the attribute map is carried over when one of these # objects is duplicated. Duplication of this ivar state does *not* happen # when 'dup' is called on our superclass, so we have to do that manually. # def dup duplicate = super duplicate.instance_variable_set( '@scimitar_hash_with_indifferent_case_insensitive_access_key_map', @scimitar_hash_with_indifferent_case_insensitive_access_key_map ) return duplicate end # Override the individual key writer. # def []=(key, value) string_key = scimitar_hash_with_indifferent_case_insensitive_access_string(key) indifferent_key = scimitar_hash_with_indifferent_case_insensitive_access_downcase(string_key) converted_value = convert_value(value, conversion: :assignment) # Note '||=', as there might have been a prior use of the "same" key in # a different case. The earliest one is preserved since the actual Hash # underneath all this is already using that variant of the key. # key_for_writing = ( @scimitar_hash_with_indifferent_case_insensitive_access_key_map[indifferent_key] ||= string_key ) regular_writer(key_for_writing, converted_value) end # Override #merge to express it in terms of #merge! (also overridden), so # that merged hashes can have their keys treated indifferently too. # def merge(*other_hashes, &block) dup.merge!(*other_hashes, &block) end # Modifies-self version of #merge, overriding Hash#merge!. # def merge!(*hashes_to_merge_to_self, &block) if block_given? hashes_to_merge_to_self.each do |hash_to_merge_to_self| hash_to_merge_to_self.each_pair do |key, value| value = block.call(key, self[key], value) if self.key?(key) self[key] = value end end else hashes_to_merge_to_self.each do |hash_to_merge_to_self| hash_to_merge_to_self.each_pair do |key, value| self[key] = value end end end self end # ======================================================================= # PRIVATE INSTANCE METHODS # ======================================================================= # private if Symbol.method_defined?(:name) def scimitar_hash_with_indifferent_case_insensitive_access_string(key) key.kind_of?(Symbol) ? key.name : key end else def scimitar_hash_with_indifferent_case_insensitive_access_string(key) key.kind_of?(Symbol) ? key.to_s : key end end def scimitar_hash_with_indifferent_case_insensitive_access_downcase(key) key.kind_of?(String) ? key.downcase : key end def convert_key(key) string_key = scimitar_hash_with_indifferent_case_insensitive_access_string(key) indifferent_key = scimitar_hash_with_indifferent_case_insensitive_access_downcase(string_key) @scimitar_hash_with_indifferent_case_insensitive_access_key_map[indifferent_key] || string_key end def convert_value(value, conversion: nil) if value.is_a?(Hash) if conversion == :to_hash value.to_hash else value.with_indifferent_case_insensitive_access end else super end end def update_with_single_argument(other_hash, block) if other_hash.is_a?(HashWithIndifferentCaseInsensitiveAccess) regular_update(other_hash, &block) else other_hash.to_hash.each_pair do |key, value| if block && key?(key) value = block.call(self.convert_key(key), self[key], value) end self.[]=(key, value) end end end end end end