homeASCIIcasts

262: Trees With Ancestry 

(view original Railscast)

Other translations: Ja Es

Let’s say that we have an application that allows us to post messages. The messages page shows a list of all of the messages with a text area below in which we can add a new message.

Our messaging application.

All new messages appear at the bottom of the list. To improve the application we’d like to add the ability to thread messages. We’ll add a ‘reply’ link to each message that will allow users to reply to a specific message. The new message should then appear immediately below its parent in the list.

Back in episode 162 [watch, read] we created a tree association using the acts_as_tree plugin. This approach could work here but it wouldn’t give the best performance as it requires a separate SQL query for each message to determine that message’s children. It would be much better if we could fetch all of the descendants for a message with a single query.

There are a number of nested set plugins available but the one we’re going to use is called Ancestry. One unique feature that it has is that it stores all of the hierarchy information in a single string column, instead of just having a single integer field to store a record’s parent’s id. This enables each record to store all the information it needs to fetch related records and Ancestry provides a number of methods for this including parent, siblings and children.

To add Ancestry to our application we’ll first add a reference to the gem in the application’s Gemfile and then run bundle to install it.

/Gemfile

source 'http://rubygems.org'
gem 'rails', '3.0.6'
gem 'sqlite3'
gem 'nifty-generators'
gem 'ancestry'

Next we’ll need to run a migration to add the Ancestry functionality to the messages table.

$ rails g migration add_ancestry_to_message ancestry:string

The Ancestry README suggests adding an index to the ancestry field so we’ll add that to the migration before running it.

/db/migrate/20110418204049_add_ancestry_to_message.rb

class AddAncestryToMessage < ActiveRecord::Migration
  def self.up
    add_column :messages, :ancestry, :string
    add_index :messages, :ancestry
  end
  def self.down
    remove_index :messages, :ancestry
    remove_column :messages, :ancestry
  end
end

We can now run the migration in the usual way with rake db:migrate.

The final step is to modify the Message model file and add a call to has_ancestry.

/app/models/message.rb

class Message < ActiveRecord::Base
  has_ancestry
end

That’s it. Now we have Ancestry all set up.

Adding Threading To The Messages

With Ancestry set up we can now start making changes to our application. The first thing we’ll do is modify the view code that renders the list of messages and add a “Reply” link below each one. This will link to the new message page and, because we need to know which message the new one is a reply to, we’ll pass the id of the current message as a parent_id parameter.

/app/views/messages/index.html.erb

<% title "Messages" %>
<% for message in @messages %>
  <div class="message">
    <div class="created_at"><%= message.created_at.strftime("%B %d, %Y") %></div>   
    <div class="content">
      <%= message.content %>
    </div>
    <div class="actions">
      <%= link_to "Reply", new_message_path(:parent_id => message) %> |
      <%= link_to "Destroy", message, :confirm => "Are you sure?", :method => :delete %>
    </div>
  </div>
<% end %>
<%= render "form" %>

In the MessageController’s new action we’ll need to pass that parent_id parameter to the new Message. Ancestry will use this parent_id parameter to set the message’s parent.

/app/controllers/messages_controller.rb

def new
  @message = Message.new(:parent_id => params[:parent_id])
end

Finally inside the form for a new message we’ll add that parent_id as a hidden field so that the new message is marked as a child of the message that is being replied to.

/app/views/messages/_form.html.erb

<%= form_for @message do |f| %>
  <%= f.error_messages %>
  <%= f.hidden_field :parent_id %>
  <p>
    <%= f.label :content, "New Message" %><br />
    <%= f.text_area :content, :rows => 8 %>
  </p>
  <p><%= f.submit "Post Message" %></p>
<% end %>

We can try this out now. If we reload the messages page we’ll see a reply link for each message. Clicking one of the links will be take us to the new message page with the id of the message we’re replying to in the query string.

The new message form with the parent message’s id in the query string.

It would be good if we could see the message we’re replying to displayed on the form. Ideally we’d have some kind of AJAX-based functionality that would dynamically show the form inline when a ‘reply’ link is clicked but to keep our application simple we’ll display it on the new message page above the form. To make this easier to do we’ll move the code that displays a message out from the index view into a partial.

/app/views/messages/_message.html.erb

<div class="message">
  <div class="created_at"><%= message.created_at.strftime("%B %d, %Y") %></div>
  <div class="content">
    <%= message.content %>
  </div>
  <div class="actions">
    <%= link_to "Reply", new_message_path(:parent_id => message) %> |
    <%= link_to "Destroy", message, :confirm => "Are you sure?", :method => :delete %>
  </div>
</div>

We can now update the index view code to use the new partial.

/app/views/messages/index.html.erb

<% title "Messages" %>
<%= render @messages %>
<%= render "form" %>

Now, in the new template we can use that partial to render the parent message, if there is one.

/app/views/messages/new.html.erb

<% title "Reply" %>
<%= render @message.parent if @message.parent %>
<%= render "form" %>
<p><%= link_to "Back to Messages", messages_path %></p>

If we click ‘reply’ on an existing message now that message will appear above the form. We’ll enter a reply and see what happens.

The message that is being replied to is now shown above the form.

When we click the ‘Post Message’ button we’re redirected to the index action and we see our new message listed. The new message’s parent is recorded by Ancestry but, because of the way we’re rendering the list, the new message appears at the bottom of the list, rather than below its parent.

The new message is shown at the top of the form.

Arranging Messages

Ancestry provides a method called arrange that will return records as a nested set of hashes and this will work perfectly for rendering the messages in a threaded manner. We can pass in an :order clause to this method to specify how the records should be sorted.

We’ll use arrange in the index template to return the messages in the order we want. As arrange returns a hash we can can’t pass it’s output directly to render, instead we’ll have to loop through each returned message separately. We’ll do this in a helper method that we’ll call nested_messages and which we can use instead of render to render the threaded list of messages.

/app/views/messages/index.html.erb

<% title "Messages" %>
<%= nested_messages @messages.arrange(:order => :created_at) %>
<%= render "form" %>

We’ll write nested_messages in the MessagesHelper and it will take a nested set of message hashes. The method will need to loop through each hash. We’ll use map to do this as we’ll need to join everything back together at the end. The block that map takes will have a message as the key and its sub messages as the value.

Inside the block we render the current message and then recursively call nested_messages, passing in the current message’s descendants. We’ll want to modify the appearance of the sub messages later so we use content_tag to wrap the sub messages in a div which we give a class of nested_messages. Once we’ve rendered all of the messages we join them back together and call html_safe on the output.

/app/helpers/messages_helper.rb

module MessagesHelper
  def nested_messages(messages)
    messages.map do |message, sub_messages|
      render(message) + content_tag(:div, nested_messages(sub_messages), :class => "nested_messages")
    end.join.html_safe
  end
end

When we reload the message page now the reply to the first message is now shown in its correct place below its parent.

The messages are now shown in the correct order.

As we added a class to each sub message we can now apply some styling to indent them so that the relationship between each message and its replies can be more easily seen.

/public/stylesheets/application.css

.nested_messages {
  margin-left: 30px;
}

If a message has a large number of replies the margins will add up and some replies could appear too far across the screen. We can stop replies that are further than, say, three deep from being indented any further by adding the following style rule.

/public/stylesheets/application.css

.nested_messages .nested_messages .nested_messages {
  margin-left: 0;
}

When we reload the messages page now the nested messages are properly indented. If we add a reply to our existing reply that will be indented correctly too.

Replies are now indented.

Viewing a Single Thread

We’ll finish this episode off by adding one more small feature, modifying the application so that we can click on a message to view just that message and its replies.

The first step we’ll take is to change each message’s text to a link which will link to that message’s show action.

/app/views/messages/_message.html.erb

<div class="message">
  <div class="created_at"><%= message.created_at.strftime("%B %d, %Y") %></div>
  <div class="content">
    <%= link_to message.content, message %>
  </div>
  <div class="actions">
    <%= link_to "Reply", new_message_path(:parent_id => message) %> |
    <%= link_to "Destroy", message, :confirm => "Are you sure?", :method => :delete %>
  </div>
</div>

We can then modify the show template to display the messages. We call subtree on the message as this will fetch the message and all of its children and then call arrange on that to arrange it by its created date.

/app/views/messages/show.html.erb

<% title "Message" %>
<%= nested_messages @message.subtree.arrange(:order => :created_at) %>
<p><%= link_to "Back to All Messages", messages_path %></p>

If we reload the messages page now and click on one of the messages we’ll see that message and its children.

The messages from a single thread.

No matter how many replies there are to a given message the replies will all be fetched by one database query so we don’t need to wonder about this affecting our application’s performance.

That’s it for this episode on Ancestry. It’s a nice solution if you need to store Rails models in a tree structure and we’ll worth considering should you ever need to do that.