(Not #dig!) How to determine a key *exists* in a deeply nested Ruby Hash?

Viewed 1470

Is there an "easy" way, short of hand-writing the kind of nested Hash/Array traversal performed by Hash#dig, that I can determine if a key is present in a deeply nested Hash? Another way to ask this is to say "determine if any value is assigned".

There is a difference between a Hash having nothing assigned, or it having an explicit nil assigned - especially if the Hash were constructed with a different missing key default value than nil!

h = { :one => { :two => nil }}
h.dig(:one, :two).nil? # => true; but :two *is* present; it is assigned "nil". 
h[:one].key?(:two) # => true, because the key exists

h = { :one => {}}
h.dig(:one, :two).nil? # => true; :two *is not* present; no value is assigned.
h[:one].key?(:two) # => FALSE, because the key does not exist
4 Answers

If you are purely checking the existence of a key, you can combine dig and key?. Use key? on the final or last key in your series of keys.

input_hash = {
  hello: {
    world: {
      existing: nil,
    }
  }
}

# Used !! to make the result boolean

!!input_hash.dig(:hello, :world)&.key?(:existing) # => true
!!input_hash.dig(:hello, :world)&.key?(:not_existing) # => false
!!input_hash.dig(:hello, :universe)&.has_key?(:not_existing) # => false

Inspired by your core extension suggestion I updated the implementation a bit to better mimic that of #dig

  • requires 1+ arguments
  • raises TypeError if the dig does not return nil, the resulting object does not respond to dig? and there are additional arguments to be "dug"
module Diggable
  def dig?(arg,*args)
    return self.member?(arg) if args.empty?
    if val = self[arg] and val.respond_to?(:dig?) 
      val.dig?(*args)
    else
     val.nil? ? false : raise(TypeError, "#{val.class} does not have a #dig? method")
    end
  end
end

[Hash,Struct,Array].each { |klass| klass.send(:include,Diggable) }

class Array
  def dig?(arg,*args)
    return arg.abs < self.size if args.empty?
    super
  end
end

if defined?(OpenStruct)
  class OpenStruct
    def dig?(arg,*args)
      self.to_h.dig?(arg,*args)
    end
  end
end

Usage

Foo = Struct.new(:a)

hash = {:one=>1, :two=>[1, 2, 3], :three=>[{:one=>1, :two=>2}, "hello", Foo.new([1,2,3]), {:one=>{:two=>{:three=>3}}}]}

hash.dig? #=> ArgumentError
hash.dig?(:one) #=> true
hash.dig?(:two, 0) #=> true
hash.dig?(:none) #=> false
hash.dig?(:none, 0) #=> false
hash.dig?(:two, -1) #=> true
hash.dig?(:two, 10) #=> false
hash.dig?(:three, 0, :two) #=> true
hash.dig?(:three, 0, :none) #=> false
hash.dig?(:three, 2, :a) #=> true
hash.dig?(:three, 3, :one, :two, :three, :f) #=> TypeError

Example

Here is a concise way of doing it which works with nested Array and Hash (and any other object that responds to fetch).

def deep_fetch? obj, *argv
  argv.each do |arg|
    return false unless obj.respond_to? :fetch
    obj = obj.fetch(arg) { return false }
  end
  true
end

obj = { hello: [ nil, { world: nil } ] }
deep_fetch? obj, :hell # => false
deep_fetch? obj, :hello, 0 # => true
deep_fetch? obj, :hello, 2 # => false
deep_fetch? obj, :hello, 0, :world # => false
deep_fetch? obj, :hello, 1, :world # => true
deep_fetch? obj, :hello, :world
TypeError (no implicit conversion of Symbol into Integer)

The previous code raises an error when accessing an Array element with a non-Integer index (just like Array#dig), which sometimes is not the behavior one is looking for. The following code works well in all cases, but the rescue is not a good practice:

def deep_fetch? obj, *argv
  argv.each { |arg| obj = obj.fetch(arg) } and true rescue false
end

obj = { hello: [ nil, { world: nil } ] }
deep_fetch? obj, :hell # => false
deep_fetch? obj, :hello, 0 # => true
deep_fetch? obj, :hello, 2 # => false
deep_fetch? obj, :hello, 0, :world # => false
deep_fetch? obj, :hello, 1, :world # => true
deep_fetch? obj, :hello, :world # => false

For reference - taking the unusual step of answering my own question ;-) - here's one of several ways I could solve this if I just wanted to write lots of Ruby.

def dig?(obj, *args)
  arg = args.shift()

  return case obj
    when Array
      if args.empty?
        arg >= 0 && arg <= obj.size
      else
        dig?(obj[arg], *args)
      end
    when Hash
      if args.empty?
        obj.key?(arg)
      else
        dig?(obj[arg], *args)
      end
    when nil
      false
    else
      raise ArgumentError
  end
end

Of course, one could also have opened up classes like Array and Hash and added #dig? to those, if you prefer core extensions over explicit methods:

class Hash
  def dig?(*args)
    arg = args.shift()

    if args.empty?
      self.key?(arg)
    else
      self[arg]&.dig?(*args) || false
    end
  end
end

class Array
  def dig?(*args)
    arg = args.shift()

    if args.empty?
      arg >= 0 && arg <= self.size
    else
      self[arg]&.dig?(*args) || false
    end
  end
end

...which would raise NoMethodError rather than ArgumentError if the #dig? arguments led to a non-Hash/Array node.

Obviously it would be possible to compress those down into more cunning / elegant solutions that use fewer lines, but the above has the benefit of IMHO being pretty easy to read.

In the scope of the original question, though, the hope was to lean more on anything Ruby has out-of-the-box. We've collectively acknowledged early-on that there is no single-method solution, but the answer from @AmazingRein gets close by reusing #dig to avoid recursion. We might adapt that as follows:

def dig?(obj, *args)
  last_arg = args.pop()
  obj      = obj.dig(*args) unless args.empty?

  return case obj
    when Array
      last_arg >= 0 && last_arg <= obj.size
    when Hash
      obj.key?(last_arg)
    when nil
      false
    else
      raise ArgumentError
  end
end

...which isn't too bad, all things considered.

# Example test...

hash = {:one=>1, :two=>[1, 2, 3], :three=>[{:one=>1, :two=>2}, "hello", {:one=>{:two=>{:three=>3}}}]}

puts dig?(hash, :one)
puts dig?(hash, :two, 0)
puts dig?(hash, :none)
puts dig?(hash, :none, 0)
puts dig?(hash, :two, -1)
puts dig?(hash, :two, 10)
puts dig?(hash, :three, 0, :two)
puts dig?(hash, :three, 0, :none)
puts dig?(hash, :three, 2, :one, :two, :three)
puts dig?(hash, :three, 2, :one, :two, :none)
Related