Permalink
Browse files

Turned ActiveRecord::Acts::Tree into a plugin #9514 [lifolifo]

  • Loading branch information...
0 parents commit ebb360afdac258fa9599b4e58289f85fe54a67dd @dhh dhh committed Sep 11, 2007
Showing with 408 additions and 0 deletions.
  1. +26 −0 README
  2. +22 −0 Rakefile
  3. +1 −0 init.rb
  4. +94 −0 lib/acts_as_tree.rb
  5. +38 −0 test/abstract_unit.rb
  6. +123 −0 test/acts_as_tree_test.rb
  7. +18 −0 test/database.yml
  8. +15 −0 test/fixtures/mixin.rb
  9. +59 −0 test/fixtures/mixins.yml
  10. +12 −0 test/schema.rb
26 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
@@ -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
@@ -0,0 +1 @@
+require 'acts_as_tree'
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
Oops, something went wrong.

0 comments on commit ebb360a

Please sign in to comment.