Ignacio Villaverde
Ignacio Villaverde

Reputation: 1264

Ruby aggregation with objects

Lets say I have something like this:

class FruitCount
   attr_accessor :name, :count

   def initialize(name, count)
     @name = name
     @count = count
   end
 end

 obj1 = FruitCount.new('Apple', 32)
 obj2 = FruitCount.new('Orange', 5)
 obj3 = FruitCount.new('Orange', 3)
 obj4 = FruitCount.new('Kiwi', 15)
 obj5 = FruitCount.new('Kiwi', 1)

 fruit_counts = [obj1, obj2, obj3, obj4, obj5]

Now what I need, is a function build_fruit_summary which due to a given fruit_counts array, it returns the following summary:

fruits_summary = {
  fruits: [
    { 
      name: 'Apple',
      count: 32
    },
    {
      name: 'Orange',
      count: 8
    },
    {
      name: 'Kiwi',
      count: 16
    }
  ],
  total: {
    name: 'AllFruits',
    count: 56
  }
}

I just cannot figure out the best way to do the aggregations.

Edit:

In my example I have more than one count.

class FruitCount
   attr_accessor :name, :count1, :count2

   def initialize(name, count1, count2)
     @name = name
     @count1 = count1
     @count2 = count2
   end
 end

Upvotes: 1

Views: 3099

Answers (3)

DiegoSalazar
DiegoSalazar

Reputation: 13531

Ruby's Enumerable is your friend, particularly each_with_object which is a form of reduce.

You first need the fruits value:

fruits = fruit_counts.each_with_object([]) do |fruit, list|
  aggregate = list.detect { |f| f[:name] == fruit.name }

  if aggregate.nil?
    aggregate = { name: fruit.name, count: 0 }
    list << aggregate
  end

  aggregate[:count] += fruit.count
  aggregate[:count2] += fruit.count2
end

UPDATE: added multiple counts within the single fruity loop.

The above will serialize each fruit object - maintaining a count for each fruit - into a hash and aggregate them into an empty list array, and assign the aggregate array to the fruits variable.

Now, get the total value:

total = { name: 'AllFruits', count: fruit_counts.map { |f| f.count + f.count2 }.reduce(:+) }

UPDATE: total taking into account multiple count attributes within a single loop.

The above maps the fruit_counts array, plucking each object's count attribute, resulting in an array of integers. Then, reduce is getting the sum of the array's integers.

Now put it all together into the summary:

fruits_summary = { fruits: fruits, total: total }

You can formalize this in an OOP style by introducing a FruitCollection object that uses the Enumerable module:

class FruitCollection
  include Enumerable

  def initialize(fruits)
    @fruits = fruits
  end

  def summary
    { fruits: fruit_counts, total: total }
  end

  def each(&block)
    @fruits.each &block
  end

  def fruit_counts
    each_with_object([]) do |fruit, list|
      aggregate = list.detect { |f| f[:name] == fruit.name }

      if aggregate.nil?
        aggregate = { name: fruit.name, count: 0 }
        list << aggregate
      end

      aggregate[:count] += fruit.count
      aggregate[:count2] += fruit.count2
    end
  end

  def total
    { name: 'AllFruits', count: map { |f| f.count + f.count2 }.reduce(:+) }
  end
end

Now pass your fruit_count array into that object:

fruit_collection = FruitCollection.new fruit_counts
fruits_summary = fruit_collection.summary

The reason the above works is by overriding the each method which Enumerable uses under the hood for every enumerable method. This means we can call each_with_object, reduce, and map (among others listed in the enumerable docs above) and it will iterate over the fruits since we told it to in the above each method.

Here's an article on Enumerable.

UPDATE: your multiple counts can be easily added by adding a total attribute to your fruit object:

class FruitCount
   attr_accessor :name, :count1, :count2

   def initialize(name, count1, count2)
     @name = name
     @count1 = count1
     @count2 = count2
   end

   def total
     @count1 + @count2
   end
end

Then just use fruit.total whenever you need to aggregate the totals:

fruit_counts.map(&:total).reduce(:+)

Upvotes: 2

Cary Swoveland
Cary Swoveland

Reputation: 110685

counts = fruit_counts.each_with_object(Hash.new(0)) {|obj, h| h[obj.name] += obj.count}
  #=> {"Apple"=>32, "Orange"=>8, "Kiwi"=>16}

fruits_summary =
  { fruits: counts.map { |name, count| { name: name, count: count } },
    total: { name: 'AllFruits', count: counts.values.reduce(:+) }
  }
  #=> {:fruits=>[
  #      {:name=>"Apple",  :count=>32},
  #      {:name=>"Orange", :count=> 8},
  #      {:name=>"Kiwi",   :count=>16}],
  #    :total=>
  #      {:name=>"AllFruits", :count=>56}
  #   }

Upvotes: 0

Ivan Yurov
Ivan Yurov

Reputation: 1618

fruits_summary = {
  fruits: fruit_counts
    .group_by { |f| f.name }
    .map do |fruit_name, objects|
      {
        name: fruit_name,
        count: objects.map(&:count).reduce(:+)
      } 
  end,
 total: {
   name: 'AllFruits',
   count: fruit_counts.map(&:count).reduce(:+)
 }
}

Not very efficient way, though :)

UPD: fixed keys in fruits collection

Or slightly better version:

fruits_summary = {
  fuits: fruit_counts
    .reduce({}) { |acc, fruit| acc[fruit.name] = acc.fetch(fruit.name, 0) + fruit.count; acc }
    .map { |name, count| {name: name, count: count} },
  total: {
    name: 'AllFruits',
    count: fruit_counts.map(&:count).reduce(:+)
  }
}

Upvotes: 0

Related Questions