user2320239
user2320239

Reputation: 1038

Grouping an array of hashes

so I'm working on a project where I have an array of hashes:

[{:year=>2016, :month=>12, :account_id=>133, :price=>5},
 {:year=>2016, :month=>11, :account_id=>134, :price=>3},
 {:year=>2016, :month=>11, :account_id=>135, :price=>0},
 {:year=>2015, :month=>12, :account_id=>145, :price=>4},
 {:year=>2015, :month=>12, :account_id=>163, :price=>11}]

and basically I want to condense this down into the form:

{ 2016 => { 12 => { 1 => {:account_id=>133, :price=>5}},
            11 => { 1 => {:account_id=>134, :price=>3},
                    2 => {:account_id=>135, :price=>0}}},
  2015 => { 12 => { 1 => {:account_id=>145, :price=>4},
                    2 => {:account_id=>163, :price=>11}}}}

but I'm having real trouble getting this done, at the moment I have:

data_array = data_array.group_by{|x| x[:year]}
data_array.each{|x| x.group_by{|y| y[:month]}}

but this doesn't seem to work, I get an error saying no implicit conversion of Symbol into Integer.

Any help with understanding where I've gone wrong and what to do would be greatly appreciated.

Upvotes: 2

Views: 2258

Answers (4)

akuhn
akuhn

Reputation: 27793

Here is a simple two-liner

h = Hash.new { |h,k| h[k] = Hash.new { |h,k| h[k] = [] }}
ary.each { |each| h[each.delete(:year)][each.delete(:month)] << each }

NB, this modifies the input but I assume you are not interested in the original input after transforming it.

Value of h

{
  2016=>{12=>[{:account_id=>133, :price=>5}], 11=>[{:account_id=>134, :price=>3}, {:account_id=>135, :price=>0}]},
  2015=>{12=>[{:account_id=>145, :price=>4}, {:account_id=>163, :price=>11}]}
}

You can access the values in h with

h[2016][11][1] # => {:account_id=>135, :price=>0}

Upvotes: 0

Gene
Gene

Reputation: 46960

Know I'm late with this, but this problem has a beautiful recursive structure that deserves to be seen.

Inputs are the array of hashes and a list of keys to group on.

For the base case, the key list is empty. Just convert the array of hashes into an index-valued hash.

Otherwise, use the first key in the list to accumulate a hash with corresponding input values as keys, each mapped to a list of hashes with that key deleted. Each of these lists is just a smaller instance of the same problem using the remaining tail of keys! So recur to take care of them.

def group_and_index(a, keys)
  if keys.empty?
    a.each_with_object({}) {|h, ih| ih[ih.size + 1] = h }
  else
    r = Hash.new {|h, k| h[k] = [] }
    a.each {|h| r[h.delete(keys[0])].push(h) }
    r.each {|k, a| r[k] = group_and_index(a, keys[1..-1]) }
  end
end

If a key is missing in any of the input hashes, a nil will be used. Note this function modifies the original hashes. Call on a.map{|h| h.clone} if that's not desired. To get the example result:

group_and_index(array_of_hashes, [:year, :month])

Upvotes: 1

Cary Swoveland
Cary Swoveland

Reputation: 110675

arr = [{:year=>2016, :month=>12, :account_id=>133, :price=>5},
       {:year=>2016, :month=>11, :account_id=>134, :price=>3},
       {:year=>2016, :month=>11, :account_id=>135, :price=>0},
       {:year=>2015, :month=>12, :account_id=>145, :price=>4},
       {:year=>2015, :month=>12, :account_id=>163, :price=>11}]

arr.each_with_object({}) do |g,h|
  f = h.dig(g[:year], g[:month])
  counter = f ? f.size+1 : 1  
  h.update(g[:year]=>{ g[:month]=>
      { counter=>{ account_id: g[:account_id], price: g[:price] } } }) { |_yr,oh,nh|
        oh.merge(nh) { |_mon,ooh,nnh| ooh.merge(nnh) } }
end
  #=> {2016=>{12=>{1=>{:account_id=>133, :price=>5}},
  #           11=>{1=>{:account_id=>134, :price=>3},
  #                2=>{:account_id=>135, :price=>0}}
  #          },
  #    2015=>{12=>{1=>{:account_id=>145, :price=>4},
  #                2=>{:account_id=>163, :price=>11}}
  #          }
  #   }

This uses the methods Hash#dig and the forms of Hash#update (aka merge!) and Hash#merge that employ a block to determine the values of keys that are present in both hashes being merged. (See the docs for details.) Note that there are such blocks at two difference levels. If, for example,

{ 2016=>{ 11=>{ {1=>{:account_id=>133, :price=>5 } } } } }
{ 2016=>{ 11=>{ {2=>{:account_id=>135, :price=>0 } } } } }

are being merged, the block would determine the value of 2016. That involves merging the two hashes

{ 11=>{ {1=>{:account_id=>133, :price=>5 } } } }
{ 11=>{ {2=>{:account_id=>135, :price=>0 } } } }

which would call an inner block to determine the value of 11.

Upvotes: 0

Eric Duminil
Eric Duminil

Reputation: 54223

Refactored solution

Here's a longer but possibly better solution, with 3 helper methods :

class Array
  # Remove key from array of hashes
  def remove_key(key)
    map do |h|
      h.delete(key)
      h
    end
  end

  # Group hashes by values for given key, sort by value,
  # remove key from hashes, apply optional block to array of hashes.
  def to_grouped_hash(key)
    by_key = group_by { |h| h[key] }.sort_by { |value, _| value }
    by_key.map do |value, hashes|
      hashes_without = hashes.remove_key(key)
      new_hashes = block_given? ? yield(hashes_without) : hashes_without
      [value, new_hashes]
    end.to_h
  end

  # Convert array to indexed hash
  def to_indexed_hash(first = 0)
    map.with_index(first) { |v, i| [i, v] }.to_h
  end
end

Your script can then be written as :

data.to_grouped_hash(:year) do |year_data|
  year_data.to_grouped_hash(:month) do |month_data|
    month_data.to_indexed_hash(1)
  end
end

It doesn't need Rails or Activesupport, and returns :

{2015=>
  {12=>
    {1=>{:account_id=>145, :balance=>4}, 2=>{:account_id=>163, :balance=>11}}},
 2016=>
  {11=>
    {1=>{:account_id=>134, :balance=>3}, 2=>{:account_id=>135, :balance=>0}},
   12=>{1=>{:account_id=>133, :price=>5}}}}

Refinements could be use to avoid polluting the Array class.

Original one-liner

# require 'active_support/core_ext/hash'
# ^ uncomment in plain ruby script.

data.group_by{|h| h[:year]}
.map{|year, year_data|
  [
    year,
    year_data.group_by{|month_data| month_data[:month]}.map{|month, vs| [month, vs.map.with_index(1){|v,i| [i,v.except(:year, :month)]}.to_h]}
   .to_h]
}.to_h

It uses Hash#except from ActiveSupport.

It outputs :

{
    2016 => {
        12 => {
            1 => {
                :account_id => 133,
                     :price => 5
            }
        },
        11 => {
            1 => {
                :account_id => 134,
                   :balance => 3
            },
            2 => {
                :account_id => 135,
                   :balance => 0
            }
        }
    },
    2015 => {
        12 => {
            1 => {
                :account_id => 145,
                   :balance => 4
            },
            2 => {
                :account_id => 163,
                   :balance => 11
            }
        }
    }
}

Upvotes: 4

Related Questions