51

A web service is returning a hash that contains an unknown number of nested hashes, some of which contain an array, which in turn contains an unknown number of nested hashes.

Some of the keys are not unique -- i.e. are present in more than one of the nested hashes.

However, all the keys that I actually care about are all unique.

Is there someway I can give a key to the top-level hash, and get back it's value even if the key-value pair is buried deep in this morass?

(The web service is Amazon Product Advertising API, which slightly varies the structure of the results that it gives depending on the number of results and the search types permitted in each product category.)

starball
  • 20,030
  • 7
  • 43
  • 238
steven_noble
  • 4,133
  • 10
  • 44
  • 77
  • 1
    This question comes up a lot, like [here](http://stackoverflow.com/questions/1820451/ruby-style-how-to-check-whether-a-nested-hash-element-exists) and [here](http://stackoverflow.com/questions/7139471/transform-a-ruby-hash-into-a-dotted-path-key-string) and many others. – Dave Newton Nov 28 '11 at 20:07
  • 1
    It always helps if you can create some sample data showing what you have encountered, so we don't have to imagine. Also, how is the data being sent? Do you receive XML and parse it? JSON? Or, are you using an call that returns the mystical structure and everything else is a black box? – the Tin Man Nov 28 '11 at 21:09

9 Answers9

41

Here's a simple recursive solution:

def nested_hash_value(obj,key)
  if obj.respond_to?(:key?) && obj.key?(key)
    obj[key]
  elsif obj.respond_to?(:each)
    r = nil
    obj.find{ |*a| r=nested_hash_value(a.last,key) }
    r
  end
end

h = { foo:[1,2,[3,4],{a:{bar:42}}] }
p nested_hash_value(h,:bar)
#=> 42
Phrogz
  • 296,393
  • 112
  • 651
  • 745
  • 9
    This code caused me stack overflow. I guess it's due to Strings and/or something else that will respond to `each` method. I changed `elsif obj.respond_to?(:each)` to `elsif obj.is_a?(Hash) or obj.is_a?(Array)`. Now it works fine. Thanks for your solution. – Vigneshwaran Mar 06 '13 at 11:43
  • 2
    it would be nice if this thing printed out its path (breadcrumbs?) as it went down... – Seamus Abshere Aug 22 '13 at 23:37
  • What if there are multiple hashes containing :bar key, what would be the solution if we want array of the values of each :bar keys? – Rajdeep Singh May 08 '15 at 08:00
  • @RSB Ask this as its own question if you want an answer. Better yet, try to come up with a solution yourself. (Hints: With a recursive function where you want to accumulate results, you either need to return values or use a closed-over data structure; Alternatively, you can use a non-recursive depth-first or breadth-first crawl by using a queue.) – Phrogz May 08 '15 at 15:47
34

No need for monkey patching, just use Hashie gem: https://github.com/intridea/hashie#deepfind

user = {
  name: { first: 'Bob', last: 'Boberts' },
  groups: [
    { name: 'Rubyists' },
    { name: 'Open source enthusiasts' }
  ]
}

user.extend Hashie::Extensions::DeepFind

user.deep_find(:name)   #=> { first: 'Bob', last: 'Boberts' }

For arbitrary Enumerable objects, there is another extension available, DeepLocate: https://github.com/intridea/hashie#deeplocate

denis.peplin
  • 9,585
  • 3
  • 48
  • 55
  • 3
    I found the Hashi::Extensions::DeepFind to be an excellent approach. And if you're looking to find keys that are duplicated, then the deep_find_all() method is awesome. Highly recommended. – JESii Mar 31 '17 at 17:18
  • 1
    I didn't vote down, but I wouldn't vote up because I find an overkill to use a gem for such a simple solution. – Andre Figueiredo Jan 25 '19 at 00:09
  • 1
    @Andre, why wouldn't you use a gem here? This gem surely has efficient and thoroughly-tested code. How much time are you going to spend rolling your own? Recursive methods are rarely trivial and are challenging to test. – Cary Swoveland May 19 '22 at 18:21
28

Combining a few of the answers and comments above:

class Hash
  def deep_find(key, object=self, found=nil)
    if object.respond_to?(:key?) && object.key?(key)
      return object[key]
    elsif object.is_a? Enumerable
      object.find { |*a| found = deep_find(key, a.last) }
      return found
    end
  end
end
barelyknown
  • 5,510
  • 3
  • 34
  • 46
20

Ruby 2.3 introduces Hash#dig, which allows you to do:

h = { foo: {bar: {baz: 1}}}

h.dig(:foo, :bar, :baz)           #=> 1
h.dig(:foo, :zot)                 #=> nil
Chris Edwards
  • 3,514
  • 2
  • 33
  • 40
14

A variation of barelyknown's solution: This will find all the values for a key in a hash rather than the first match.

class Hash
  def deep_find(key, object=self, found=[])
    if object.respond_to?(:key?) && object.key?(key)
      found << object[key]
    end
    if object.is_a? Enumerable
      found << object.collect { |*a| deep_find(key, a.last) }
    end
    found.flatten.compact
  end
end

{a: [{b: 1}, {b: 2}]}.deep_find(:b) will return [1, 2]

ReggieB
  • 8,100
  • 3
  • 38
  • 46
11

Despite this appearing to be a common problem, I've just spent a while trying to find/come up with exactly what I need, which I think is the same as your requirement. Neither of the links in the first response are spot-on.

class Hash
  def deep_find(key)
    key?(key) ? self[key] : self.values.inject(nil) {|memo, v| memo ||= v.deep_find(key) if v.respond_to?(:deep_find) }
  end
end

So given:

hash = {:get_transaction_list_response => { :get_transaction_list_return => { :transaction => [ { ... 

The following:

hash.deep_find(:transaction)

will find the array associated with the :transaction key.

This is not optimal as the inject will continue to iterate even if memo is populated.

Andy Triggs
  • 1,286
  • 12
  • 17
0

I use the following code

def search_hash(hash, key)
  return hash[key] if hash.assoc(key)
  hash.delete_if{|key, value| value.class != Hash}
  new_hash = Hash.new
  hash.each_value {|values| new_hash.merge!(values)}
  unless new_hash.empty?
    search_hash(new_hash, key)
  end
end
0

I ended up using this for a small trie search I wrote:

def trie_search(str, obj=self)
  if str.length <= 1
    obj[str]
  else
    str_array = str.chars
    next_trie = obj[str_array.shift]
    next_trie ? trie_search(str_array.join, next_trie) : nil
  end
end

Note: this is just for nested hashes at the moment. Currently no array support.

DaniG2k
  • 4,772
  • 36
  • 77
0

Because Rails 5 ActionController::Parameters no longer inherits from Hash, I've had to modify the method and make it specific to parameters.

module ActionController
  class Parameters
    def deep_find(key, object=self, found=nil)
      if object.respond_to?(:key?) && object.key?(key)
        return object[key]
      elsif object.respond_to?(:each)
        object = object.to_unsafe_h if object.is_a?(ActionController::Parameters)
        object.find { |*a| found = deep_find(key, a.last) }
        return found
      end
    end
  end
end

If the key is found, it returns the value of that key, but it doesn't return an ActionController::Parameter object so Strong Parameters are not preserved.

pzupan
  • 169
  • 1
  • 7
  • Didn't work for multiple params keys: { 0: [{b: '1'}], 1: [{b: '2'}] }.deep_find(:b) returns: #> '1' – m1l05z Dec 21 '17 at 13:14