Skip to content

Commit

Permalink
Turned ActiveRecord::Acts::Tree into a plugin #9514 [lifolifo]
Browse files Browse the repository at this point in the history
  • Loading branch information
dhh committed Sep 11, 2007
0 parents commit ebb360a
Show file tree
Hide file tree
Showing 10 changed files with 408 additions and 0 deletions.
26 changes: 26 additions & 0 deletions README
@@ -0,0 +1,26 @@
acts_as_tree
============

Specify this +acts_as+ extension if you want to model a tree structure by providing a parent association and a children
association. This requires that you have a foreign key column, which by default is called +parent_id+.

class Category < ActiveRecord::Base
acts_as_tree :order => "name"
end

Example:
root
\_ child1
\_ subchild1
\_ subchild2

root = Category.create("name" => "root")
child1 = root.children.create("name" => "child1")
subchild1 = child1.children.create("name" => "subchild1")

root.parent # => nil
child1.parent # => root
root.children # => [child1]
root.children.first.children.first # => subchild1

Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license
22 changes: 22 additions & 0 deletions Rakefile
@@ -0,0 +1,22 @@
require 'rake'
require 'rake/testtask'
require 'rake/rdoctask'

desc 'Default: run unit tests.'
task :default => :test

desc 'Test acts_as_tree plugin.'
Rake::TestTask.new(:test) do |t|
t.libs << 'lib'
t.pattern = 'test/**/*_test.rb'
t.verbose = true
end

desc 'Generate documentation for in_place_editing plugin.'
Rake::RDocTask.new(:rdoc) do |rdoc|
rdoc.rdoc_dir = 'rdoc'
rdoc.title = 'InPlaceEditing'
rdoc.options << '--line-numbers' << '--inline-source'
rdoc.rdoc_files.include('README')
rdoc.rdoc_files.include('lib/**/*.rb')
end
1 change: 1 addition & 0 deletions init.rb
@@ -0,0 +1 @@
require 'acts_as_tree'
94 changes: 94 additions & 0 deletions lib/acts_as_tree.rb
@@ -0,0 +1,94 @@
module ActsAsTree
def self.included(base)
base.extend(ClassMethods)
end

# Specify this +acts_as+ extension if you want to model a tree structure by providing a parent association and a children
# association. This requires that you have a foreign key column, which by default is called +parent_id+.
#
# class Category < ActiveRecord::Base
# acts_as_tree :order => "name"
# end
#
# Example:
# root
# \_ child1
# \_ subchild1
# \_ subchild2
#
# root = Category.create("name" => "root")
# child1 = root.children.create("name" => "child1")
# subchild1 = child1.children.create("name" => "subchild1")
#
# root.parent # => nil
# child1.parent # => root
# root.children # => [child1]
# root.children.first.children.first # => subchild1
#
# In addition to the parent and children associations, the following instance methods are added to the class
# after calling <tt>acts_as_tree</tt>:
# * <tt>siblings</tt> - Returns all the children of the parent, excluding the current node (<tt>[subchild2]</tt> when called on <tt>subchild1</tt>)
# * <tt>self_and_siblings</tt> - Returns all the children of the parent, including the current node (<tt>[subchild1, subchild2]</tt> when called on <tt>subchild1</tt>)
# * <tt>ancestors</tt> - Returns all the ancestors of the current node (<tt>[child1, root]</tt> when called on <tt>subchild2</tt>)
# * <tt>root</tt> - Returns the root of the current node (<tt>root</tt> when called on <tt>subchild2</tt>)
module ClassMethods
# Configuration options are:
#
# * <tt>foreign_key</tt> - specifies the column name to use for tracking of the tree (default: +parent_id+)
# * <tt>order</tt> - makes it possible to sort the children according to this SQL snippet.
# * <tt>counter_cache</tt> - keeps a count in a +children_count+ column if set to +true+ (default: +false+).
def acts_as_tree(options = {})
configuration = { :foreign_key => "parent_id", :order => nil, :counter_cache => nil }
configuration.update(options) if options.is_a?(Hash)

belongs_to :parent, :class_name => name, :foreign_key => configuration[:foreign_key], :counter_cache => configuration[:counter_cache]
has_many :children, :class_name => name, :foreign_key => configuration[:foreign_key], :order => configuration[:order], :dependent => :destroy

class_eval <<-EOV
include ActsAsTree::InstanceMethods
def self.roots
find(:all, :conditions => "#{configuration[:foreign_key]} IS NULL", :order => #{configuration[:order].nil? ? "nil" : %Q{"#{configuration[:order]}"}})
end
def self.root
find(:first, :conditions => "#{configuration[:foreign_key]} IS NULL", :order => #{configuration[:order].nil? ? "nil" : %Q{"#{configuration[:order]}"}})
end
EOV
end
end

module InstanceMethods
# Returns list of ancestors, starting from parent until root.
#
# subchild1.ancestors # => [child1, root]
def ancestors
node, nodes = self, []
nodes << node = node.parent while node.parent
nodes
end

# Returns the root node of the tree.
def root
node = self
node = node.parent while node.parent
node
end

# Returns all siblings of the current node.
#
# subchild1.siblings # => [subchild2]
def siblings
self_and_siblings - [self]
end

# Returns all siblings and a reference to the current node.
#
# subchild1.self_and_siblings # => [subchild1, subchild2]
def self_and_siblings
parent ? parent.children : self.class.roots
end
end
end

ActiveRecord::Base.send(:include, ActsAsTree)
38 changes: 38 additions & 0 deletions test/abstract_unit.rb
@@ -0,0 +1,38 @@
$:.unshift(File.dirname(__FILE__) + '/../../../rails/activesupport/lib')
$:.unshift(File.dirname(__FILE__) + '/../../../rails/activerecord/lib')
$:.unshift(File.dirname(__FILE__) + '/../lib')

require 'test/unit'
require 'active_support'
require 'active_record'
require 'active_record/fixtures'
require 'acts_as_tree'

config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__) + "/debug.log")
ActiveRecord::Base.configurations = {'test' => config[ENV['DB'] || 'sqlite3']}
ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations['test'])

load(File.dirname(__FILE__) + "/schema.rb") if File.exist?(File.dirname(__FILE__) + "/schema.rb")

class Test::Unit::TestCase #:nodoc:
self.fixture_path = File.dirname(__FILE__) + "/fixtures/"
self.use_transactional_fixtures = true
self.use_instantiated_fixtures = false

def create_fixtures(*table_names, &block)
Fixtures.create_fixtures(File.dirname(__FILE__) + "/fixtures/", table_names, {}, &block)
end

def assert_queries(num = 1)
$query_count = 0
yield
ensure
assert_equal num, $query_count, "#{$query_count} instead of #{num} queries were executed."
end

def assert_no_queries(&block)
assert_queries(0, &block)
end

end
123 changes: 123 additions & 0 deletions test/acts_as_tree_test.rb
@@ -0,0 +1,123 @@
require File.join(File.dirname(__FILE__), 'abstract_unit')
require File.join(File.dirname(__FILE__), 'fixtures/mixin')

class TreeTest < Test::Unit::TestCase
fixtures :mixins

def test_children
assert_equal mixins(:tree_1).children, mixins(:tree_2, :tree_4)
assert_equal mixins(:tree_2).children, [mixins(:tree_3)]
assert_equal mixins(:tree_3).children, []
assert_equal mixins(:tree_4).children, []
end

def test_parent
assert_equal mixins(:tree_2).parent, mixins(:tree_1)
assert_equal mixins(:tree_2).parent, mixins(:tree_4).parent
assert_nil mixins(:tree_1).parent
end

def test_delete
assert_equal 6, TreeMixin.count
mixins(:tree_1).destroy
assert_equal 2, TreeMixin.count
mixins(:tree2_1).destroy
mixins(:tree3_1).destroy
assert_equal 0, TreeMixin.count
end

def test_insert
@extra = mixins(:tree_1).children.create

assert @extra

assert_equal @extra.parent, mixins(:tree_1)

assert_equal 3, mixins(:tree_1).children.size
assert mixins(:tree_1).children.include?(@extra)
assert mixins(:tree_1).children.include?(mixins(:tree_2))
assert mixins(:tree_1).children.include?(mixins(:tree_4))
end

def test_ancestors
assert_equal [], mixins(:tree_1).ancestors
assert_equal [mixins(:tree_1)], mixins(:tree_2).ancestors
assert_equal mixins(:tree_2, :tree_1), mixins(:tree_3).ancestors
assert_equal [mixins(:tree_1)], mixins(:tree_4).ancestors
assert_equal [], mixins(:tree2_1).ancestors
assert_equal [], mixins(:tree3_1).ancestors
end

def test_root
assert_equal mixins(:tree_1), TreeMixin.root
assert_equal mixins(:tree_1), mixins(:tree_1).root
assert_equal mixins(:tree_1), mixins(:tree_2).root
assert_equal mixins(:tree_1), mixins(:tree_3).root
assert_equal mixins(:tree_1), mixins(:tree_4).root
assert_equal mixins(:tree2_1), mixins(:tree2_1).root
assert_equal mixins(:tree3_1), mixins(:tree3_1).root
end

def test_roots
assert_equal mixins(:tree_1, :tree2_1, :tree3_1), TreeMixin.roots
end

def test_siblings
assert_equal mixins(:tree2_1, :tree3_1), mixins(:tree_1).siblings
assert_equal [mixins(:tree_4)], mixins(:tree_2).siblings
assert_equal [], mixins(:tree_3).siblings
assert_equal [mixins(:tree_2)], mixins(:tree_4).siblings
assert_equal mixins(:tree_1, :tree3_1), mixins(:tree2_1).siblings
assert_equal mixins(:tree_1, :tree2_1), mixins(:tree3_1).siblings
end

def test_self_and_siblings
assert_equal mixins(:tree_1, :tree2_1, :tree3_1), mixins(:tree_1).self_and_siblings
assert_equal mixins(:tree_2, :tree_4), mixins(:tree_2).self_and_siblings
assert_equal [mixins(:tree_3)], mixins(:tree_3).self_and_siblings
assert_equal mixins(:tree_2, :tree_4), mixins(:tree_4).self_and_siblings
assert_equal mixins(:tree_1, :tree2_1, :tree3_1), mixins(:tree2_1).self_and_siblings
assert_equal mixins(:tree_1, :tree2_1, :tree3_1), mixins(:tree3_1).self_and_siblings
end
end

class TreeTestWithEagerLoading < Test::Unit::TestCase
fixtures :mixins

def test_eager_association_loading
roots = TreeMixin.find(:all, :include=>"children", :conditions=>"mixins.parent_id IS NULL", :order=>"mixins.id")
assert_equal mixins(:tree_1, :tree2_1, :tree3_1), roots
assert_no_queries do
assert_equal 2, roots[0].children.size
assert_equal 0, roots[1].children.size
assert_equal 0, roots[2].children.size
end
end

def test_eager_association_loading_with_recursive_cascading_three_levels_has_many
root_node = RecursivelyCascadedTreeMixin.find(:first, :include=>{:children=>{:children=>:children}}, :order => 'mixins.id')
assert_equal mixins(:recursively_cascaded_tree_4), assert_no_queries { root_node.children.first.children.first.children.first }
end

def test_eager_association_loading_with_recursive_cascading_three_levels_has_one
root_node = RecursivelyCascadedTreeMixin.find(:first, :include=>{:first_child=>{:first_child=>:first_child}}, :order => 'mixins.id')
assert_equal mixins(:recursively_cascaded_tree_4), assert_no_queries { root_node.first_child.first_child.first_child }
end

def test_eager_association_loading_with_recursive_cascading_three_levels_belongs_to
leaf_node = RecursivelyCascadedTreeMixin.find(:first, :include=>{:parent=>{:parent=>:parent}}, :order => 'mixins.id DESC')
assert_equal mixins(:recursively_cascaded_tree_1), assert_no_queries { leaf_node.parent.parent.parent }
end
end

class TreeTestWithoutOrder < Test::Unit::TestCase
fixtures :mixins

def test_root
assert mixins(:tree_without_order_1, :tree_without_order_2).include?(TreeMixinWithoutOrder.root)
end

def test_roots
assert_equal [], mixins(:tree_without_order_1, :tree_without_order_2) - TreeMixinWithoutOrder.roots
end
end
18 changes: 18 additions & 0 deletions test/database.yml
@@ -0,0 +1,18 @@
sqlite:
:adapter: sqlite
:dbfile: acts_as_tree_plugin.sqlite.db
sqlite3:
:adapter: sqlite3
:dbfile: acts_as_tree_plugin.sqlite3.db
postgresql:
:adapter: postgresql
:username: postgres
:password: postgres
:database: acts_as_tree_plugin_test
:min_messages: ERROR
mysql:
:adapter: mysql
:host: localhost
:username: rails
:password:
:database: acts_as_tree_plugin_test
15 changes: 15 additions & 0 deletions test/fixtures/mixin.rb
@@ -0,0 +1,15 @@
class Mixin < ActiveRecord::Base
end

class TreeMixin < Mixin
acts_as_tree :foreign_key => "parent_id", :order => "id"
end

class TreeMixinWithoutOrder < Mixin
acts_as_tree :foreign_key => "parent_id"
end

class RecursivelyCascadedTreeMixin < Mixin
acts_as_tree :foreign_key => "parent_id"
has_one :first_child, :class_name => 'RecursivelyCascadedTreeMixin', :foreign_key => :parent_id
end

0 comments on commit ebb360a

Please sign in to comment.