29: group_by Month 

(view original Railscast)

Other translations: It

Almost all Rails applications display a list of models and while we usually just display them in a certain order, it can be useful sometimes to group them too. Below is an application that displays a list of tasks, each of which has a due date. We want to display the tasks grouped by the month of their due date, rather than just in a list. To do this we’re going to use a method called group_by.

Our tasks as a list.

Our tasks as one long list.

To show how the group_by method works we’ll demonstrate it in Rails’ console.

>> a = (1..20).to_a
=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
>> a.group_by { |num| num/5 }
=> {0=>[1, 2, 3, 4], 1=>[5, 6, 7, 8, 9], 2=>[10, 11, 12, 13, 14], 3=>[15, 16, 17, 18, 19], 4=>[20]}

Grouping an array with group_by.

In the code above we generate a range from 1 to 20 then convert it into an array. We then run group_by on the array. group_by takes a block as an argument and returns a hash. Our block will return the number passed to it divided by 5.

Each key in the returned hash will be a value that was returned by the block, and each value in the hash will be an array of the values passed to the block that returned that result. So, for example. the values 1,2,3 and 4 will all return 0 when divided by 5 (as we’re dividing integers) so end up as values of the key 0. Likewise, 20 is the only number in our array to divide into 5 four times so becomes a value of the key 4.

A Brief Aside

The Railscast this episode is based on was written back in the Rails 1.x days. Since then the way group_by works has gone through a couple of changes. In Rails 2.0 - 2.2 it will return a nested array, so for the example above we’ll see the following output.

=> [[0, [1, 2, 3, 4]], [1, [5, 6, 7, 8, 9]], [2, [10, 11, 12, 13, 14]], [3, [15, 16, 17, 18, 19]], [4, [20]]]

The latest release at the time of writing is the first release candidate of Rails 2.3. This returns an OrderedHash object.

Back To The Tasks

Returning to our project, we’ll now use group_by to group our tasks by month. We’ll do this by editing our TaskController.

class TasksController < ApplicationController
  def index
    @tasks = Task.all
    @task_months = @tasks.group_by { |t| t.due_at.beginning_of_month }

We’re passing each task in turn to the group_by block and returning a date that is the first day of the month for that task’s due date. That way we’ll generate a hash whose keys are a date representing the first day of a month, and whose values are an array of all the tasks whose due date falls within that month.

Updating The View

<% @tasks.each do |task| %>
<p><b><%= task.name %></b> due on <%= task.to_date.to_s(:long) %></p>
<% end %>

The original view code loops through each task and displays it. We’ll change it so that it loops through each key /in our hash of grouped tasks and then through each task in that key’s values. The new view code looks like this.

<% @task_months.each do |month, tasks| %>
<h2><%= month.strftime("%B %Y") %></h2>
  <% tasks.each do |task| %>
    <p><b><%= task.name %></b> due at <%= task.due_at.to_date.to_s(:long) %></p>
  <% end %>
<% end %>

The updated view code.

The code now iterates over the keys in the hash (each of which is a date representing the first day of a month) and prints the month and year of that date using strftime. It then loops through each of that month’s tasks and prints its details. In the browser we can see that the tasks are now grouped by month.

The tasks are now grouped, but the months are in the wrong order.

All the right tasks, not necessarily in the right order.

There is still one problem remaining, however. The hash doesn’t order its keys in the way we want, which means that in the example below there is a task for January after all of February’s tasks. We need to order the keys in the hash so that they are iterated over in the correct order. The solution to this is to sort the keys, then iterate over them, getting the tasks for that key.

<% @task_months.keys.sort.each do |month| %>
<h2><%= month.strftime("%B %Y") %></h2>
  <% for task in @task_months[month] %>
    <p><b><%= task.name %></b> due at <%= task.due_at.to_date.to_s(:long) %></p>
  <% end %>
<% end %>

Now our tasks are displayed in the correct order.

It’s worth noting that group_by could be used to group our tasks by any of their properties. For example if we wanted to group them by the first letter of their name we could just as easily do so. This makes group_by a powerful way of grouping lists of items.