One ActiveRecord Model Acting as a List and Tree
Published: April 30, 2009 (almost 9 years ago)
Updated: about 3 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:
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:
script/plugin install acts_as_tree
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 2 3 4 5 6 7 8 |
class Page < ActiveRecord::Base acts_as_tree :order => :position acts_as_list def self.top_level_pages find(:all, :conditions => ["parent_id IS NULL"], :order => :position) end 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 2 3 4 5 |
<div id="site-navigation"> <ul> <%= render(:partial => "/layouts/site_navigation", :collection => @pages) %> </ul> </div> |
To make it all nice and nested, I simply recursively called the same site_navigation partial on the children like so:
1 2 |
<li><%= link_to(site_navigation.name, url_for(site_navigation)) %></li> <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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
class Page < ActiveRecord::Base acts_as_tree :order => :position acts_as_list :scope => :parent_id before_save :keep_position_sane def self.top_level_pages find(:all, :conditions => ["parent_id IS NULL"], :order => :position) end def self.reindex_top_level_pages(recurse = true, departing_child = nil) reindex_pages(self.top_level_pages, recurse, departing_child) end def reindex_children(recurse = true, departing_child = nil) Page.reindex_pages(children, recurse, departing_child) end private # takes a given array of pages and recursively (or not) reindexes # if departing_child is supplied, it is removed from the array so # that former siblings are reindexed as though it was already # removed from the collection. def self.reindex_pages(pages, recurse, departing_child) pages.select{|r| r != departing_child}.each_with_index do |page, index| page.reindex_children(true) if recurse page.update_attributes(:position => index + 1) end true end # When the parent id of a node changes, the acts_as_list gets lost, so # we need to reindex the affected nodes to keep things sane def keep_position_sane return unless self.parent_id_changed? # reindex the group this page is being removed from if self.parent_id_was.nil? then Page.reindex_top_level_pages(false, self) else Page.find(self.parent_id_was).reindex_children(false, self) end # make this page the last sibling of the new parent group of pages last_page = (self.parent_id.nil? ? Page.top_level_pages.last : Page.find(self.parent_id).children.last) self.position = (last_page.nil? ? 1 : last_page.position + 1) true end 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 2 3 4 5 6 |
namespace :pages do desc "re-index page positions" task :reindex => :environment do Page.reindex_top_level_pages end 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.

Michael Lang
a.k.a. Code Connoisseur
- [email protected]
- ICQ ‐ 25239620
- AIM ‐ mwlang88
- Yahoo! ‐ mwlang88
- Google ‐ mwlang
- Twitter ‐ @mwlang88
About Me
Education
Bachelor of ScienceInformation and Computer Science

Recent Musings
- On Hiring Good People
- Week Five in the Gym
- The True Power of the Internet
- Rekindling a desire to workout consistently
- I'd Rather Eat my Britches than Do This
- Mold Killer Recipe
- Gonna be Starting Something New
- Pitch Camp, what is it good for?
- Less communication can be more
- Let the Musings Begin
Recent Ramblings
- Working on a Referral Pre-Launch Site
- Making Commitments, Reaching Out
- Preparing for Countdown
- Ground Zero
- A Reflection of the Technologies Built Things With
- Dynamic Routing in Rails Revisited
- Creating Dynamic Routes at runtime in Rails 4
- Adding Google Analytics script to Sprockets
- Gems you should consider for every Rails projects
- Weak Password will get you Hacked!
Categories
BootsrapConfiguration
CSS
General
JavaScript
Macs
Programming
Python Language
Ruby Language
Servers
Setups
SQL
Systems
Tags
GitHub Repos
- Status updating...