Convert array-of-hashes to a hash-of-hashes, index

2020-02-27 07:54发布

问题:

I've got an array of hashes representing objects as a response to an API call. I need to pull data from some of the hashes, and one particular key serves as an id for the hash object. I would like to convert the array into a hash with the keys as the ids, and the values as the original hash with that id.

Here's what I'm talking about:

api_response = [
  { :id => 1, :foo => 'bar' },
  { :id => 2, :foo => 'another bar' },
  # ..
]

ideal_response = {
  1 => { :id => 1, :foo => 'bar' },
  2 => { :id => 2, :foo => 'another bar' },
  # ..
}

There are two ways I could think of doing this.

  1. Map the data to the ideal_response (below)
  2. Use api_response.find { |x| x[:id] == i } for each record I need to access.
  3. A method I'm unaware of, possibly involving a way of using map to build a hash, natively.

My method of mapping:

keys = data.map { |x| x[:id] }
mapped = Hash[*keys.zip(data).flatten]

I can't help but feel like there is a more performant, tidier way of doing this. Option 2 is very performant when there are a very minimal number of records that need to be accessed. Mapping excels here, but it starts to break down when there are a lot of records in the response. Thankfully, I don't expect there to be more than 50-100 records, so mapping is sufficient.

Is there a smarter, tidier, or more performant way of doing this in Ruby?

回答1:

Ruby <= 2.0

> Hash[api_response.map { |r| [r[:id], r] }]
#=> {1=>{:id=>1, :foo=>"bar"}, 2=>{:id=>2, :foo=>"another bar"}} 

However, Hash::[] is pretty ugly and breaks the usual left-to-right OOP flow. That's why Facets proposed Enumerable#mash:

> require 'facets'
> api_response.mash { |r| [r[:id], r] }
#=> {1=>{:id=>1, :foo=>"bar"}, 2=>{:id=>2, :foo=>"another bar"}} 

This basic abstraction (convert enumerables to hashes) was asked to be included in Ruby long ago, alas, without luck.

Ruby >= 2.1

[UPDATE] Still no love for Enumerable#mash, but now we have Array#to_h. Not ideal -because we need an intermediate array- but better than nothing:

> object = api_response.map { |r| [r[:id], r] }.to_h


回答2:

Something like:

ideal_response = api_response.group_by{|i| i[:id]} 
#=> {1=>[{:id=>1, :foo=>"bar"}], 2=>[{:id=>2, :foo=>"another bar"}]}

It uses Enumerable's group_by, which works on collections, returning matches for whatever key value you want. Because it expects to find multiple occurrences of matching key-value hits it appends them to arrays, so you end up with a hash of arrays of hashes. You could peel back the internal arrays if you wanted but could run a risk of overwriting content if two of your hash IDs collided. group_by avoids that with the inner array.

Accessing a particular element is easy:

ideal_response[1][0]       #=> {:id=>1, :foo=>"bar"}
ideal_response[1][0][:foo] #=> "bar"

The way you show at the end of the question is another valid way of doing it. Both are reasonably fast and elegant.



回答3:

For this I'd probably just go:

ideal_response = api_response.each_with_object(Hash.new) { |o, h| h[o[:id]] = o }

Not super pretty with the multiple brackets in the block but it does the trick with just a single iteration of the api_response.