262: Trees With Ancestry
(view original Railscast)
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.
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.
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.
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.
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.
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.
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.
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.