Closure tree

Easily and efficiently make your ActiveRecord model support hierarchies

Closure Tree

Closure_tree lets your ActiveRecord models act as nodes in a tree data structure

Common applications include modeling hierarchical data, like tags, page graphs in CMSes, and tracking user referrals.

Build Status Gem Version Code Climate Dependency Status

Dramatically more performant than ancestry and acts_as_tree, and even more awesome than awesome_nested_set, closure_tree has some great features:

See Bill Karwin's excellent Models for hierarchical data presentation for a description of different tree storage algorithms.

Table of Contents

Installation

Note that closure_tree only supports Rails 3.2 and later, and has test coverage for MySQL, PostgreSQL, and SQLite.

  1. Add this to your Gemfile: gem 'closure_tree'

  2. Run bundle install

  3. Add acts_as_tree to your hierarchical model(s). Make sure you add acts_as_tree *after any attr_accessible and self.table_name = lines in your model. Please review the available options you can provide.

  4. Add a migration to add a parent_id column to the model you want to act_as_tree. You may want to also add a column for deterministic ordering of children, but that's optional.

    class AddParentIdToTag < ActiveRecord::Migration
      def change
        add_column :tag, :parent_id, :integer
      end
    end
    

    Note that if the column is null, the tag will be considered a root node.

  5. Add a database migration to store the hierarchy for your model. By default the table name will be the model's table name, followed by "_hierarchies". Note that by calling acts_as_tree, a "virtual model" (in this case, TagHierarchy) will be added automatically, so you don't need to create it.

    class CreateTagHierarchies < ActiveRecord::Migration
      def change
        create_table :tag_hierarchies, :id => false do |t|
          t.integer  :ancestor_id, :null => false   # ID of the parent/grandparent/great-grandparent/... tag
          t.integer  :descendant_id, :null => false # ID of the target tag
          t.integer  :generations, :null => false   # Number of generations between the ancestor and the descendant. Parent/child = 1, for example.
        end
    
        # For "all progeny of…" and leaf selects:
        add_index :tag_hierarchies, [:ancestor_id, :descendant_id, :generations],
          :unique => true, :name => "tag_anc_desc_udx"
    
        # For "all ancestors of…" selects,
        add_index :tag_hierarchies, [:descendant_id],
          :name => "tag_desc_idx"
      end
    end
    
  6. Run rake db:migrate

  7. If you're migrating from another system where your model already has a parent_id column, run Tag.rebuild! and your tag_hierarchies table will be truncated and rebuilt.

    If you're starting from scratch you don't need to call rebuild!.

Usage

Creation

Create a root node:

grandparent = Tag.create(:name => 'Grandparent')

Child nodes are created by appending to the children collection:

parent = grandparent.children.create(:name => 'Parent')

Or by appending to the children collection:

child2 = Tag.new(:name => 'Second Child')
parent.children << child2

Or by calling the "add_child" method:

child3 = Tag.new(:name => 'Third Child')
parent.add_child child3

Then:

grandparent.self_and_descendants.collect(&:name)
=> ["Grandparent", "Parent", "First Child", "Second Child", "Third Child"]

child1.ancestry_path
=> ["Grandparent", "Parent", "First Child"]

find_or_create_by_path

You can find as well as find_or_create by "ancestry paths".

If you provide an array of strings to these methods, they reference the name column in your model, which can be overridden with the :name_column option provided to acts_as_tree.

child = Tag.find_or_create_by_path(["grandparent", "parent", "child"])

As of v5.0.0, find_or_create_by_path can also take an array of attribute hashes:

child = Tag.find_or_create_by_path([
  {name: "Grandparent", title: "Sr."},
  {name: "Parent", title: "Mrs."},
  {name: "Child", title: "Jr."}
])

If you're using STI, The attribute hashes can contain the sti_name and things work as expected:

child = Label.find_or_create_by_path([
  {type: 'DateLabel', name: '2014'},
  {type: 'DateLabel', name: 'August'},
  {type: 'DateLabel', name: '5'},
  {type: 'EventLabel', name: 'Visit the Getty Center'}
])

Moving nodes around the tree

Nodes can be moved around to other parents, and closure_tree moves the node's descendancy to the new parent for you:

d = Tag.find_or_create_by_path %w(a b c d)
h = Tag.find_or_create_by_path %w(e f g h)
e = h.root
d.add_child(e) # "d.children << e" would work too, of course
h.ancestry_path
=> ["a", "b", "c", "d", "e", "f", "g", "h"]

Nested hashes

hash_tree provides a method for rendering a subtree as an ordered nested hash:

b = Tag.find_or_create_by_path %w(a b)
a = b.parent
b2 = Tag.find_or_create_by_path %w(a b2)
d1 = b.find_or_create_by_path %w(c1 d1)
c1 = d1.parent
d2 = b.find_or_create_by_path %w(c2 d2)
c2 = d2.parent

Tag.hash_tree
=> {a => {b => {c1 => {d1 => {}}, c2 => {d2 => {}}}, b2 => {}}}

Tag.hash_tree(:limit_depth => 2)
=> {a => {b => {}, b2 => {}}}

b.hash_tree
=> {b => {c1 => {d1 => {}}, c2 => {d2 => {}}}}

b.hash_tree(:limit_depth => 2)
=> {b => {c1 => {}, c2 => {}}}

If your tree is large (or might become so), use :limit_depth.

Without this option, hash_tree will load the entire contents of that table into RAM. Your server may not be happy trying to do this.

HT: ancestry and elhoyos

Graph visualization

to_dot_digraph is suitable for passing into Graphviz.

For example, for the above tree, write out the DOT file with ruby:

File.open("example.dot", "w") { |f| f.write(Tag.root.to_dot_digraph) }

Then, in a shell, dot -Tpng example.dot > example.png, which produces:

Example tree

If you want to customize the label value, override the #to_digraph_label instance method in your model.

Just for kicks, this is the test tree I used for proving that preordered tree traversal was correct:

Preordered test tree

Available options

When you include acts_as_tree in your model, you can provide a hash to override the following defaults:

Accessing Data

Class methods

Instance methods

Polymorphic hierarchies with STI

Polymorphic models using single table inheritance (STI) are supported:

  1. Create a db migration that adds a String type column to your model
  2. Subclass the model class. You only need to add acts_as_tree to your base class:
class Tag < ActiveRecord::Base
  acts_as_tree
end
class WhenTag < Tag ; end
class WhereTag < Tag ; end
class WhatTag < Tag ; end

Please note that Rails (<= 3.2) doesn't handle polymorphic associations correctly if you use the :type attribute, so this doesn't work:

# BAD: ActiveRecord ignores the :type attribute:
root.children.create(:name => "child", :type => "WhenTag")

Instead, use either .add_child or children <<:

# GOOD!
a = Tag.create!(:name => "a")
b = WhenTag.new(:name => "b")
a.children << b
c = WhatTag.new(:name => "c")
b.add_child(c)

See issue 43 for more information.

Deterministic ordering

By default, children will be ordered by your database engine, which may not be what you want.

If you want to order children alphabetically, and your model has a name column, you'd do this:

class Tag < ActiveRecord::Base
  acts_as_tree :order => 'name'
end

If you want a specific order, add a new integer column to your model in a migration:

t.integer :sort_order

and in your model:

class OrderedTag < ActiveRecord::Base
  acts_as_tree :order => 'sort_order'
end

When you enable order, you'll also have the following new methods injected into your model:

If your order column is an integer attribute, you'll also have these:

root = OrderedTag.create(name: 'root')
a = root.append_child(Label.new(name: 'a'))
b = OrderedTag.create(name: 'b')
c = OrderedTag.create(name: 'c')

# We have to call 'root.reload.children' because root won't be in sync with the database otherwise:

a.append_sibling(b)
root.reload.children.pluck(:name)
=> ["a", "b"]

a.prepend_sibling(b)
root.reload.children.pluck(:name)
=> ["b", "a"]

a.append_sibling(c)
root.reload.children.pluck(:name)
=> ["b", "a", "c"]

b.append_sibling(c)
root.reload.children.pluck(:name)
=> ["b", "c", "a"]

Concurrency

Several methods, especially #rebuild and #find_or_create_by_path, cannot run concurrently correctly. #find_or_create_by_path, for example, may create duplicate nodes.

Database row-level locks work correctly with PostgreSQL, but MySQL's row-level locking is broken, and erroneously reports deadlocks where there are none. To work around this, and have a consistent implementation for both MySQL and PostgreSQL, with_advisory_lock is used automatically to ensure correctness.

If you are already managing concurrency elsewhere in your application, and want to disable the use of with_advisory_lock, pass :with_advisory_lock => false in the options hash:

class Tag
  acts_as_tree :with_advisory_lock => false
end

Note that you will eventually have data corruption if you disable advisory locks, write to your database with multiple threads, and don't provide an alternative mutex.

FAQ

Are there any how-to articles on how to use this gem?

Yup! Ilya Bodrov wrote Nested Comments with Rails.

Does this work well with #default_scope?

No. Please see issue 86 for details.

Does this gem support multiple parents?

No. This gem's API is based on the assumption that each node has either 0 or 1 parent.

The underlying closure tree structure will support multiple parents, but there would be many breaking-API changes to support it. I'm open to suggestions and pull requests.

How do I use this with test fixtures?

Test fixtures aren't going to be running your after_save hooks after inserting all your fixture data, so you need to call .rebuild! before your test runs. There's an example in the spec tag_spec.rb:

  describe "Tag with fixtures" do
    fixtures :tags
    before :each do
      Tag.rebuild! # <- required if you use fixtures
    end

However, if you're just starting with Rails, may I humbly suggest you adopt a factory library, rather than using fixtures? Lots of people have written about this already.

There are many lock-* files in my project directory after test runs

This is expected if you aren't using MySQL or Postgresql for your tests.

SQLite doesn't have advisory locks, so we resort to file locking, which will only work if the FLOCK_DIR is set consistently for all ruby processes.

In your spec_helper.rb or minitest_helper.rb, add a before and after block:

before do
  ENV['FLOCK_DIR'] = Dir.mktmpdir
end

after do
  FileUtils.remove_entry_secure ENV['FLOCK_DIR']
end

Testing with Closure Tree

Closure tree comes with some RSpec2/3 matchers which you may use for your tests:

require 'spec_helper'
require 'closure_tree/test/matcher'

describe Category do
 # Should syntax
 it { should be_a_closure_tree }
 # Expect syntax
 it { is_expected.to be_a_closure_tree }
end

describe Label do
 # Should syntax
 it { should be_a_closure_tree.ordered }
 # Expect syntax
 it { is_expected.to be_a_closure_tree.ordered }
end

describe TodoList::Item do
 # Should syntax
 it { should be_a_closure_tree.ordered(:priority_order) }
 # Expect syntax
 it { is_expected.to be_a_closure_tree.ordered(:priority_order) }
end

Testing

Closure tree is tested under every valid combination of

Assuming you're using rbenv, you can use tests.sh to run the test matrix locally.

Change log

See the change log.

Thanks to