HomeRamblings  ⁄  ProgrammingRuby Language

One ActiveRecord Model Acting as a List and Tree

Published: April 30, 2009 (almost 5 years ago)

Occasionally, Rails can appear to make your life extremely easy while silently throwing you a curve-ball. I needed a model that required a hierarchy while also preserving order of the records. Although fairly straightforward to set up and start immediately using, there are a couple of "gotchas" to watch out for and this article covers those pitfalls and shows how to apply the cool new "dirty attributes" feature in ActiveRecord.

The Problem

I am working on a content management system (CMS) where I want the pages to have a hierarchical structure that turns into a menu with sub-menus. The content manager needs to also be able to order these pages so that the menu structure renders in the desired order.

The Solution

Two plugins jumped to mind almost immediately: acts_as_tree and acts_as_list. The tree plugin will manage the hierarchy, hinging off the parent_id field of the model whilist the list plugin uses the position column to manage the order. What's unique here is that I have never used both on one model, but doing so was surprisingly easy:

   1  class Page < ActiveRecord::Base
   2    acts_as_tree
   3    acts_as_list
   4  end

Constructing a Hierarchical Menu

Before going too far, if you haven't seen the Suckerfish menus, yet, please do check out the article as it will help you quickly see how I approached the menu rendering. Secondly, to install the two plugins, its a simple pair of command line calls as follows:

   1  script/plugin install acts_as_tree
   2  script/plugin install acts_as_list
With the CSS handling all the styling, all I needed was to render the nested unordered lists. I began by grabbing all of the pages at the top level (where parent_id is null) with this bit of code:

   1  class Page < ActiveRecord::Base
   2    acts_as_tree :order => :position
   3    acts_as_list
   4  
   5    def self.top_level_pages
   6      find(:all, :conditions => ["parent_id IS NULL"], :order => :position)
   7    end
   8  end

If you noticed the ":order => :position" clause and thought, "but acts_as_list handles that for you," then you have spotted the first "gotcha" I encountered with using both tree and list on a model. The tree plugin loses the position ordering that the list plugin mixes in and adding these order clauses in preserves the order of the records. With the query to get the top-level menus in place, I set the @pages variable by calling Page.top_level_pages in the controller and then rendered with this call in my view:

   1  <div id="site-navigation">
   2  	<ul>
   3  		<%= render(:partial => "/layouts/site_navigation", :collection => @pages) %>
   4  	</ul>
   5  </div>

To make it all nice and nested, I simply recursively called the same site_navigation partial on the children like so:

   1  <li><%= link_to(site_navigation.name, url_for(site_navigation))  %></li>
   2  <ul><%= render(:partial => "/layouts/site_navigation", :collection => site_navigation.children) %></ul>

To give the user ability to move the menu items around, I added "move_up" and "move_down" actions to the Page Controller in which I called the "move_higher" and "move_lower" methods that are mixed in by the acts_as_list. I realized rather quickly that the position index was getting out of sync and added a reindex method to the Page model to clean up the data along with a scope declaration to the model. Along the way, I also realized that if I moved a page from one parent node to another parent node, I potentially opened a gap in the position index in the collection of Pages and this again breaks the position index sequencing. The acts_as_list mix-in expects the position index to always go [0, 1, 2, 3, ...N]. Whenever this is not the case, the move_higher and move_lower methods stop working and the user interface no longer responds correctly. So this is why we care so much about the position index sequence. So, to handle scoping of the position index sequence correctly and to handle Pages being moved to another parent, we arrive at this final version of the Page model:

   1  class Page < ActiveRecord::Base
   2    acts_as_tree :order => :position
   3    acts_as_list :scope => :parent_id
   4  
   5    before_save :keep_position_sane
   6  
   7    def self.top_level_pages
   8      find(:all, :conditions => ["parent_id IS NULL"], :order => :position)
   9    end
  10    
  11    def self.reindex_top_level_pages(recurse = true, departing_child = nil)
  12      reindex_pages(self.top_level_pages, recurse, departing_child)
  13    end
  14    
  15    def reindex_children(recurse = true, departing_child = nil)
  16      Page.reindex_pages(children, recurse, departing_child)
  17    end
  18  
  19    private  
  20  
  21    # takes a given array of pages and recursively (or not) reindexes
  22    # if departing_child is supplied, it is removed from the array so 
  23    # that former siblings are reindexed as though it was already 
  24    # removed from the collection.
  25    def self.reindex_pages(pages, recurse, departing_child)
  26      pages.select{|r| r != departing_child}.each_with_index do |page, index|
  27        page.reindex_children(true) if recurse
  28        page.update_attributes(:position => index + 1)
  29      end
  30      true
  31    end
  32      
  33    # When the parent id of a node changes, the acts_as_list gets lost, so 
  34    # we need to reindex the affected nodes to keep things sane
  35    def keep_position_sane
  36      return unless self.parent_id_changed?
  37  
  38      # reindex the group this page is being removed from
  39      if self.parent_id_was.nil? then
  40        Page.reindex_top_level_pages(false, self)
  41      else
  42        Page.find(self.parent_id_was).reindex_children(false, self) 
  43      end
  44  
  45      # make this page the last sibling of the new parent group of pages
  46      last_page = (self.parent_id.nil? ? Page.top_level_pages.last : Page.find(self.parent_id).children.last)
  47      self.position = (last_page.nil? ? 1 : last_page.position + 1)
  48      true
  49    end
  50  end

Check out the "keep_position_sane" callback. You'll see a nifty application of the new Dirty records feature of ActiveRecord (which I believe was released with Rails 2.2) and was covered by Ryan Diagle in his post, What's New in Edge Rails: Dirty Objects. In order to detect that the parent node was indeed changing and which parent's collection the Page belonged to, I checked the "parent_id_was" value. In this case, I had to handle top level pages being moved into another Page's collection as well as A sub-page being promoted to top-level.

A Handy Rake Task

If you're wondering why the recursive parameter and otherwise unnecessarily complex procedures, its because I also added a rake task to recursively reindex all pages in the menu hierarchy:

   1    namespace :pages do
   2      desc "re-index page positions"
   3      task :reindex => :environment do
   4        Page.reindex_top_level_pages
   5      end
   6    end

Conclusion

Rails has really come a long ways since its early days, but its still not without its little "gotchas" and sometimes its tough to uncover silent failures (like with the position order clause not being rendered from the acts_as_list plugin) when mix-ins from different plug-ins are utilized. Hopefully, this article shows you a few new tricks and how to utilize somewhat competing plugins safely within one model. The new Dirty attributes feature of ActiveRecord definitely made the chore of implementing this functionality much easier and sane than it would've been back in the Rails 1.x days.

comments powered by Disqus