One ActiveRecord Model Acting as a List and Tree
Posted by Michael on April 30, 2009 in Programming, Ruby Language
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.

Stumble It!