Ignacio Villaverde Ignacio Villaverde - 3 months ago 11
Ruby Question

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

Answer

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(:+)