Starkers
Starkers

Reputation: 10551

Nested comments from scratch

Let's say I have a comment model:

class Comment < ActiveRecord::Base
    has_many :replies, class: "Comment", foreign_key: "reply_id"
end

I can show a comment instance`s replies in a view like so:

comment.replies do |reply|
   reply.content
end

However, how do I loop through the replies of the reply? And its reply? And its reply ad infitum? I'm feeling we need to make a multidimensional array of the replies via class method and then loop through this array in the view.

I don't want to use a gem, I want to learn

Upvotes: 12

Views: 3377

Answers (9)

Andrew Hacking
Andrew Hacking

Reputation: 6366

I've had various generally bad experience with the different hierarchy gems available for ActiveRecord. Typically you do not want to do this yourself as your queries will end up being very inefficient.

The Ancestry gem was ok, but I had to move away from it because 'children' is a scope and NOT an association. This means you CANNOT use nested attributes with it because nested attributes only work with associations, not scopes. That may or may not be a problem depending on what you are doing, such as ordering or updating siblings through the parent or updating entire subtrees/graphs in a single operation.

The most efficient ActiveRecord gem for this is the Closure Tree gem and I had good results with it, with the caveat that splatting/ mutating entire sub-trees was diabolical because of the way ActiveRecord works. If you don't need to compute things over a tree when doing updates then it is the way to go.

I've since moved away from ActiveRecord to Sequel and it has recursive common table expression (RCTE) support which is used by its built-in tree plugin. An RCTE tree is as fast as is theoretically possible to update (just modify a single parent_id as in a naive implementation) and querying is also typically orders of magnitude faster than other approaches because of the SQL RCTE feature it uses. It is also the most space efficient approach since there is just parent_id to maintain. I am not aware of any ActiveRecord solutions that support RCTE trees because ActiveRecord doesn't cover nearly as much of the SQL spectrum that Sequel does.

If you're not wedded to ActiveRecord then Sequel and Postgres is a formidable combination IMO. You will find out the deficiencies in AR when your queries become ever so slightly complex. There is always pain moving to another ORM as its not the out of the box stock rails approach but I have been able to express queries that I couldn't do with ActiveRecord or ARel (even though they were pretty simple), and generally improved query performance across the board 10-20 times over what I was getting with ActiveRecord. In my use case with maintaining trees of data its hundreds of times faster. That means tens to hundreds times less server infrastructure I need for the same load. Think about it.

Upvotes: 0

Kumar Akarsh
Kumar Akarsh

Reputation: 4980

This will be my approach:

  • I have a Comment Model and a Reply model.
  • Comment has_many association with Reply
  • Reply has belongs_to association with Comment
  • Reply has self referential HABTM

    class Reply < ActiveRecord::Base
      belongs_to :comment
      has_and_belongs_to_many :sub_replies,
                      class_name: 'Reply',
                      join_table: :replies_sub_replies,
                      foreign_key: :reply_id,
                      association_foreign_key: :sub_reply_id
    
      def all_replies(reply = self,all_replies = [])
        sub_replies = reply.sub_replies
        all_replies << sub_replies
        return if sub_replies.count == 0
        sub_replies.each do |sr|
          if sr.sub_replies.count > 0
            all_replies(sr,all_replies)
          end
        end
        return all_replies
      end
    
    end 
    

    Now to get a reply from a comment etc:

  • Getting all replies from a comment: @comment.replies
  • Getting the Comment from any reply: @reply.comment
  • Getting the intermediate level of replies from a reply: @reply.sub_replies
  • Getting all levels of replies from a reply: @reply.all_replies

Upvotes: 1

khaled_gomaa
khaled_gomaa

Reputation: 3422

My approach is to make this done as efficient as possible. First lets address how to do that:

  1. DRY solution.
  2. Least Number of queries to retrieve the comments.

Thinking about that, I have found that most of the people address the first but not the second.So lets start with the easy one. we have to have partial for the comments so referencing the answer of jeanaux

we can use his approach to display the comments and will update it later in the answer

<!-- view -->
<div id="comments">
  <%= render partial: "comment", collection: @comments %>
</div>

<!-- _comment partial -->
<div class="comment">
  <p><%= comment.content %></p>
  <%= render partial: "comment", collection: comment.replies %>
</div> 

We must now retrieve those comments in one query if possible so we can just do this in the controller. to be able to do this all comments and replies should have a commentable_id (and type if polymorphic) so that when we query we can get all comments then group them the way we want.

So if we have a post for example and we want to get all its comments we will say in the controller. @comments = @post.comments.group_by {|c| c.reply_id}

by this we have comments in one query processed to be displayed directly Now we can do this to display them instead of what we previously did

All the comments that are not replies are now in the @comments[nil] as they had no reply_id (NB: I don like the @comments[nil] if anyone has any other suggestion please comment or edit)

<!-- view -->
<div id="comments">
  <%= render partial: "comment", collection: @comments[nil] %>
</div>

All the replies for each comment will be in the has under the parent comment id

<!-- _comment partial -->
<div class="comment">
  <p><%= comment.content %></p>
  <%= render partial: "comment", collection: @comments[comment.id] %>
</div> 

To wrap up:

  1. We added an object_id in the comment model to be able to retrieve them( if not already there)
  2. We added grouping by reply_id to retrieve the comments with one query and process them for the view.
  3. We added a partial that recursively displays the comments (as proposed by jeanaux).

Upvotes: 3

spickermann
spickermann

Reputation: 107142

That can be solved with resursion or with a special data structure. Recursion is simpler to implement, whereas a datastructure like the one used by the nested_set gem is more performant.

Recursion

First an example how it works in pure Ruby.

class Comment < Struct.new(:content, :replies); 

  def print_nested(level = 0)
    puts "#{'  ' * level}#{content}"   # handle current comment

    if replies
      replies.each do |reply|
        # here is the list of all nested replies generated, do not care 
        # about how deep the subtree is, cause recursion...
        reply.print_nested(level + 1)
      end
    end
  end
end

Example

comments = [ Comment.new(:c_1, [ Comment.new(:c_1a) ]),
             Comment.new(:c_2, [ Comment.new(:c_2a),
                                 Comment.new(:c_2b, [ Comment.new(:c_2bi),
                                                      Comment.new(:c_2bii) ]),
                                 Comment.new(:c_2c) ]),
             Comment.new(:c_3),
             Comment.new(:c_4) ]

comments.each(&:print_nested)

# Output
# 
# c_1
#   c_1a
# c_2
#   c_2a
#   c_2b
#     c_2bi
#     c_2bii
#   c_2c
# c_3
# c_4

And now with recursive calls of Rails view partials:

# in your comment show view 
<%= render :partial => 'nested_comment', :collection => @comment.replies %>

# recursion in a comments/_nested_comment.html.erb partial
<%= nested_comment.content %>
<%= render :partial => 'nested_comment', :collection => nested_comment.replies %>

Nested Set

Setup your database structure, see the docs: http://rubydoc.info/gems/nested_set/1.7.1/frames That add the something like following (untested) to your app.

# in model
acts_as_nested_set

# in controller
def index
  @comment = Comment.root   # `root` is provided by the gem
end

# in helper
module NestedSetHelper

  def root_node(node, &block)
    content_tag(:li, :id => "node_#{node.id}") do
      node_tag(node) +
      with_output_buffer(&block)
    end
  end

  def render_tree(hash, options = {}, &block)
    if hash.present?
      content_tag :ul, options do
        hash.each do |node, child|
          block.call node, render_tree(child, &block)
        end
      end
    end
  end

  def node_tag(node)
    content_tag(:div, node.content)
  end

end

# in index view
<ul>
  <%= render 'tree', :root => @comment %>
</ul>

# in _tree view
<%= root_node(root) do %>
  <%= render_tree root.descendants.arrange do |node, child| %>

    <%= content_tag :li, :id => "node_#{node.id}" do %>
      <%= node_tag(node) %>
      <%= child %>
    <% end %>

  <% end %>
<% end %>

This code is from an old Rails 3.0 app, slightly change and untested. Therefore it will probably not work out of the box, but should illustrate the idea.

Upvotes: 2

jeanaux
jeanaux

Reputation: 713

It seems like what you have is one short step away from what you want. You just need to use recursion to call the same code for each reply as you're calling for the original comments. E.g.

<!-- view -->
<div id="comments">
  <%= render partial: "comment", collection: @comments %>
</div>

<!-- _comment partial -->
<div class="comment">
  <p><%= comment.content %></p>
  <%= render partial: "comment", collection: comment.replies %>
</div>

NB: this isn't the most efficient way of doing things. Each time you call comment.replies active record will run another database query. There's definitely room for improvement but that's the basic idea anyway.

Upvotes: 9

Richard Peck
Richard Peck

Reputation: 76784

We've done this:

enter image description here

We used the ancestry gem to create a hierarchy-centric dataset, and then outputted with a partial outputting an ordered list:

#app/views/categories/index.html.erb
<% # collection = ancestry object %>
<%= render partial: "category", locals: { collection: collection } %>

#app/views/categories/_category.html.erb
<ol class="categories">
    <% collection.arrange.each do |category, sub_item| %>
        <li>
            <!-- Category -->
            <div class="category">
                <%= link_to category.title, edit_admin_category_path(category) %>
                <%= link_to "+", admin_category_new_path(category), title: "New Categorgy", data: {placement: "bottom"} %>

                <% if category.prime? %>
                    <%= link_to "", admin_category_path(category), title: "Delete", data: {placement: "bottom", confirm: "Really?"}, method: :delete, class: "icon ion-ios7-close-outline" %>
                <% end %>

                <!-- Page -->
                <%= link_to "", new_admin_category_page_path(category), title: "New Page", data: {placement: "bottom"}, class: "icon ion-compose" %>
            </div>

            <!-- Pages -->
            <%= render partial: "pages", locals: { id: category.name } %>

            <!-- Children -->
            <% if category.has_children? %>
                <%= render partial: "category", locals: { collection: category.children } %>
            <% end %>

        </li>
    <% end %>
</ol>

We also made a nested dropdown:

enter image description here

#app/helpers/application_helper.rb
def nested_dropdown(items)
    result = []
    items.map do |item, sub_items|
        result << [('- ' * item.depth) + item.name, item.id]
        result += nested_dropdown(sub_items) unless sub_items.blank?
    end
    result
end

Upvotes: 2

Woahdae
Woahdae

Reputation: 5051

Would using a nested set still count as 'from scratch'?

The short description of a nested set is a database-specific strategy of querying hierarchies by storing/querying pre- and post-order tree traversal counts.

A picture is worth a thousand words (see also, the wikipedia page on nested sets).

There are a bunch of nested set gems, and I can personally speak for the quality of Awesome Nested Set and Ancestry

Then, Awesome Nested Set (I know from experience, presumably Ancestry too) provide helpers to do a single query to pull up all records under a tree, and iterate through the tree in sorted depth-first order, passing in the level while you go.

The view code for Awesome Nested Set would be something like:

<% Comment.each_with_level(@post.comments.self_and_descendants) do |comment, level| %>
  <div style="margin-left: <%= level * 50 %>px">
    <%= comment.body %>
    <%# etc %>
  </div>
<% end %>

I just made that up from vague memories, and it's been a while, so this is where it can be "an exercise for the reader"

Upvotes: 3

Oscar Valdez Esquea
Oscar Valdez Esquea

Reputation: 920

You'd collect the reply's replies within each Reply iteration.

<% comment.replies do |reply| %>
   <%= reply.content %>
   <% reply_replies = Post.where("reply_id = #{reply.id}").all %>
   <% reply_replies .each do |p| %>
     <%= p.post %>
   <% end
<% end %>

Though im not sure if it'd be the most conventional way cost-wise.

Upvotes: -1

Jelle
Jelle

Reputation: 339

It seems like you need a self-referential association. Check out the following railscast: http://railscasts.com/episodes/163-self-referential-association

Upvotes: 2

Related Questions