Ruby 2.3 dig Method - Thoughts and Examples
27 Aug 2016Since Ruby 2.3 launched its new Hash#dig
and Array#dig
feature, it got my attention on how it would help “digging” unreliable objects. This incredible method makes you safely navigate through nested objects when dealing with third party APIs.
It also makes it makes easy to refactor projects using OpenStruct
s structures and replace those with hashes.
What it is?
The #dig
is basically an elvis operator for hashes and arrays. It’ll retrieve a value from the array/hash, given a set of keys. If no value is found, it’ll return nil
.
Ruby 2.3 also introduced a safe navigational operator, but I won’t be covering this since Giorgi Mitrev wrote a very good article about it.
Here are some practical examples on how you could be using this on your code:
Examples
Rails parameters
Whenever dealing with forms and their nested attributes, eventually, you’ll face nested hashes.
Let’s say you want to convert some user checkboxes values to integers:
# receiving params as `params = { user: { choices: ["1", "3", "5"] } }`
def coerce_user_choices
choices = params.dig(:user, :choices) || []
choices.map(&:to_i)
end
That’s nice! Way better than reading
params[:user] && params[:user][:choices] || []
Dealing with APIs
Imagine you’re receiving the following values from a JSON API:
{
person: {
attributes: {
address: {
street: "Street Foo",
country: {
name: "Country Bar",
}
}
}
}
}
With #dig
, you can navigate through it without worrying about nil
s or invalid attributes, allowing you to create a wrapper around this response object.
Let’s say you wanted to select some of the JSON response data and format it to present to your view layer:
module Presenter
class Person
attr_reader :json
PERSON_ATTRS = [:person, :attributes]
COUNTRY_NAME = PERSON_ATTRS + [:address, :country, :name]
STREET_NAME = PERSON_ATTRS + [:address, :street]
def initialize(person_json)
@json = person_json
end
def country
json.dig(*COUNTRY_NAME)
end
def street
json.dig(*STREET_NAME)
end
end
end
presenter = Presenter::Person.new(person_json)
presenter.country # => "Country Bar"
presenter.street # => "Street Foo"
Easy, isn’t it? Way nicer than using a bunch of ActiveSupport#try
s everywhere. Better, It’s just Ruby!
Refactoring OpenStructs
Couple of years back, I’ve inherited a project with a bunch of nested OpenStruct
s. The good part on OpenStruct
is also its biggest disadvantage: it allows flexible attributes, making objects completely unpredictable.
For the sake of simplicity, let’s say you have the following small scenario:
require 'ostruct'
class Country
def self.read
OpenStruct.new({
name: "Country Bar"
})
end
end
class Address
def self.read
OpenStruct.new({
country: Country.read
})
end
end
class Person < OpenStruct
def self.read
OpenStruct.new({
address: Address.read
})
end
end
# Querying a person's name:
person = Person.read
person.address.country.name # => "Country Bar"
Terrifying, isn’t it?
Now consider this: the Address
class could actually return an empty OpenStruct
instead of a populated one, or worse, just a null object. You would get NoMethodError
being raised on almost every step you take when navigating this object. Wow…
Now that we have power to #dig
up this, we could transform everything into a hash and just dig it!
# No more OpenStructs!
class Country
def self.read
{ name: "Country Bar" }
end
end
class Address
def self.read
{ country: Country.read }
end
end
class Person < OpenStruct
def self.read
{ address: Address.read }
end
end
# Querying a person's name:
person = Person.read
person.dig(:address, :country, :name) # => "Country Bar"
This is way more readable and faster than very slow Ruby OpenStructs.
Is it fast enough?
What about #dig
’s speed?
Let’s compare some #dig
methods with old ways to access Hashes and Arrays values:
require 'benchmark/ips'
Benchmark.ips do |x|
example_hash = { a: { b: { c: 3 } } }
example_array = [1, [2, [3]]]
x.report("Hash#dig found") { example_hash.dig(:a, :b, :c) }
x.report("Hash#dig not found") { example_hash.dig(:a, :b, :foo, :bar) }
x.report("Hash navigation found") { example_hash[:a][:b][:c] }
x.report("Hash navigation not found") { example_hash[:a][:b][:d] }
x.report("Array#dig found") { example_array.dig(1, 1, 0) }
x.report("Array#dig not found") { example_array.dig(1, 1, 1, 1) }
x.report("Array navigation found") { example_array[1][1][0] }
x.report("Array navigation not found") { example_array[1][1][1] }
x.compare!
end
Comparison:
Array navigation not found: 10120102.0 i/s
Array navigation found: 9955150.5 i/s - same-ish: difference falls within error
Array#dig found: 7998485.3 i/s - 1.27x slower
Array#dig not found: 7855449.0 i/s - 1.29x slower
Hash navigation not found: 7713907.2 i/s - 1.31x slower
Hash navigation found: 7582034.4 i/s - 1.33x slower
Hash#dig found: 6896451.4 i/s - 1.47x slower
Hash#dig not found: 6483433.9 i/s - 1.56x slower
Worst case scenario is a 1.5x slower method, which still operates above the 6 million operations per second. Yep, this is fast enough.
Conclusion
The new #dig
method versatility is amazing, as it now provides Ruby programmers the ability of querying unknown objects with ease and safety. It’s also fast enough to avoid any worries around performance when using it.
One more great addition to Ruby’s incredible API :)