Browse files

Initial commit, moving from svn to git

  • Loading branch information...
0 parents commit 2adace6ed939fd0fc70f14631784df377e0f1063 @xavier committed Jul 9, 2009
Showing with 659 additions and 0 deletions.
  1. +43 −0 README
  2. +25 −0 Rakefile
  3. +2 −0 init.rb
  4. +212 −0 lib/active_record/acts/tree_with_dotted_ids.rb
  5. +373 −0 test/acts_as_tree_test.rb
  6. +4 −0 test/database.yml
  7. 0 test/schema.rb
43 README
@@ -0,0 +1,43 @@
+acts_as_tree_with_dotted_ids
+============================
+
+This is an extension to Rails good old acts_as_tree which uses an extra "dotted_ids" column
+which stores the path of the node as string of ID joined by dots, hence the name.
+
+This solves performances issues related to in-database tree structure by allowing for O(1)
+ancestor/child verification and O(N) subtree access.
+
+The plugin adds the following instance methods:
+
+* ancestor_of?(node)
+* descendant_of?(node)
+* all_children
+
+As well as rewritten, optimized versions of
+
+From the original acts_as_tree README:
+
+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
+Copyright (c) 2008 Xavier Defrang, released under the MIT license
25 Rakefile
@@ -0,0 +1,25 @@
+
+$:.reject! { |e| e.include? 'TextMate' }
+
+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 acts_as_tree plugin.'
+Rake::RDocTask.new(:rdoc) do |rdoc|
+ rdoc.rdoc_dir = 'rdoc'
+ rdoc.title = 'acts_as_tree_with_dotted_ids'
+ rdoc.options << '--line-numbers' << '--inline-source'
+ rdoc.rdoc_files.include('README')
+ rdoc.rdoc_files.include('lib/**/*.rb')
+end
2 init.rb
@@ -0,0 +1,2 @@
+require 'active_record/acts/tree_with_dotted_ids'
+ActiveRecord::Base.send :include, ActiveRecord::Acts::TreeWithDottedIds
212 lib/active_record/acts/tree_with_dotted_ids.rb
@@ -0,0 +1,212 @@
+module ActiveRecord
+ module Acts
+ module TreeWithDottedIds
+ 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+ and a string or text column called +dotted_ids+ which will be used to store the path to each node in the tree.
+ #
+ # class Category < ActiveRecord::Base
+ # acts_as_tree_with_dotted_ids :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>)
+ # * <tt>depth</tt> - Returns the depth of the current node starting from 0 as the depth of root nodes.
+ 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_with_dotted_ids(options = {}, &b)
+ 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, &b
+
+ after_save :assign_dotted_ids
+ after_validation_on_update :update_dotted_ids
+
+ class_eval <<-EOV
+ include ActiveRecord::Acts::TreeWithDottedIds::InstanceMethods
+
+ def self.roots
+ res = 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
+
+ def parent_foreign_key_changed?
+ #{configuration[:foreign_key]}_changed?
+ end
+
+ EOV
+ end
+
+ # Performs a depth first traversal of the tree, yield each node to the given block
+ def traverse(nodes = nil, &block)
+ nodes ||= self.roots
+ nodes.each do |node|
+ yield node
+ traverse(node.children, &block)
+ end
+ end
+
+ # Traverse the whole tree from roots to leaves and rebuild the dotted_ids path
+ # Call it from you migration to upgrade an existing acts_as_tree model.
+ def rebuild_dotted_ids!
+ transaction do
+ traverse { |node| node.dotted_ids = nil; node.save! }
+ end
+ end
+
+ end
+
+ module InstanceMethods
+
+ # Returns list of ancestors, starting from parent until root.
+ #
+ # subchild1.ancestors # => [child1, root]
+ def ancestors
+ if self.dotted_ids
+ ids = self.dotted_ids.split('.')[0...-1]
+ self.class.find(:all, :conditions => {:id => ids}, :order => 'dotted_ids DESC')
+ else
+ node, nodes = self, []
+ nodes << node = node.parent while node.parent
+ nodes
+ end
+ end
+
+ #
+ def self_and_ancestors
+ [self] + ancestors
+ end
+
+ # Returns the root node of the tree.
+ def root
+ if self.dotted_ids
+ self.class.find(self.dotted_ids.split('.').first)
+ else
+ node = self
+ node = node.parent while node.parent
+ node
+ end
+ 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
+ self.class.find(:all, :conditions => {:parent_id => self.parent_id})
+ end
+
+ #
+ # root.ancestor_of?(subchild1) # => true
+ # subchild1.ancestor_of?(child1) # => false
+ def ancestor_of?(node)
+ node.dotted_ids.length > self.dotted_ids.length && node.dotted_ids.starts_with?(self.dotted_ids)
+ end
+
+ #
+ # subchild1.descendant_of?(child1) # => true
+ # root.descendant_of?(subchild1) # => false
+ def descendant_of?(node)
+ self.dotted_ids.length > node.dotted_ids.length && self.dotted_ids.starts_with?(node.dotted_ids)
+ end
+
+ # Returns all children of the current node
+ # root.all_children # => [child1, subchild1, subchild2]
+ def all_children
+ find_all_children_with_dotted_ids
+ end
+
+ # Returns all children of the current node
+ # root.self_and_all_children # => [root, child1, subchild1, subchild2]
+ def self_and_all_children
+ [self] + all_children
+ end
+
+ # Returns the depth of the node, root nodes have a depth of 0
+ def depth
+ self.dotted_ids.scan(/\./).size
+ end
+
+ protected
+
+ # Tranforms a dotted_id string into a pattern usable with a SQL LIKE statement
+ def dotted_id_like_pattern(prefix = nil)
+ (prefix || self.dotted_ids) + '.%'
+ end
+
+ # Find all children with the given dotted_id prefix
+ # *options* will be passed to to find(:all)
+ # FIXME: use merge_conditions when it will be part of the public API
+ def find_all_children_with_dotted_ids(prefix = nil, options = {})
+ self.class.find(:all, options.update(:conditions => ['dotted_ids LIKE ?', dotted_id_like_pattern(prefix)]))
+ end
+
+ # Generates the dotted_ids for this node
+ def build_dotted_ids
+ self.parent ? "#{self.parent.dotted_ids}.#{self.id}" : self.id.to_s
+ end
+
+ # After create, adds the dotted id's
+ def assign_dotted_ids
+ self.update_attribute(:dotted_ids, build_dotted_ids) if self.dotted_ids.blank?
+ end
+
+ # After validation on update, rebuild dotted ids if necessary
+ def update_dotted_ids
+ return unless parent_foreign_key_changed?
+ old_dotted_ids = self.dotted_ids
+ old_dotted_ids_regex = Regexp.new("^#{Regexp.escape(old_dotted_ids)}(.*)")
+ self.dotted_ids = build_dotted_ids
+ replace_pattern = "#{self.dotted_ids}\\1"
+ find_all_children_with_dotted_ids(old_dotted_ids).each do |node|
+ new_dotted_ids = node.dotted_ids.gsub(old_dotted_ids_regex, replace_pattern)
+ node.update_attribute(:dotted_ids, new_dotted_ids)
+ end
+ end
+
+ end
+ end
+ end
+end
373 test/acts_as_tree_test.rb
@@ -0,0 +1,373 @@
+require 'test/unit'
+
+require 'rubygems'
+require 'active_record'
+
+$:.unshift File.dirname(__FILE__) + '/../lib'
+
+require 'active_record/acts/tree_with_dotted_ids'
+
+require File.dirname(__FILE__) + '/../init'
+
+class Test::Unit::TestCase
+ 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
+
+ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => ":memory:")
+
+# AR keeps printing annoying schema statements
+$stdout = StringIO.new
+
+def setup_db
+ ActiveRecord::Base.logger
+ ActiveRecord::Schema.define(:version => 1) do
+ create_table :mixins do |t|
+ t.column :type, :string
+ t.column :parent_id, :integer
+ t.column :dotted_ids, :string
+ t.column :name, :string
+ end
+ end
+end
+
+def teardown_db
+ ActiveRecord::Base.connection.tables.each do |table|
+ ActiveRecord::Base.connection.drop_table(table)
+ end
+end
+
+class Mixin < ActiveRecord::Base
+end
+
+class TreeMixin < Mixin
+ acts_as_tree_with_dotted_ids :foreign_key => "parent_id", :order => "id"
+end
+
+class TreeMixinWithoutOrder < Mixin
+ acts_as_tree_with_dotted_ids :foreign_key => "parent_id"
+end
+
+class RecursivelyCascadedTreeMixin < Mixin
+ acts_as_tree_with_dotted_ids :foreign_key => "parent_id"
+ has_one :first_child, :class_name => 'RecursivelyCascadedTreeMixin', :foreign_key => :parent_id
+end
+
+class TreeTest < Test::Unit::TestCase
+
+ def setup
+ setup_db
+ @root1 = TreeMixin.create!
+ @root_child1 = TreeMixin.create! :parent_id => @root1.id
+ @child1_child = TreeMixin.create! :parent_id => @root_child1.id
+ @root_child2 = TreeMixin.create! :parent_id => @root1.id
+ @root2 = TreeMixin.create!
+ @root3 = TreeMixin.create!
+ end
+
+ def teardown
+ teardown_db
+ end
+
+ def test_children
+ assert_equal @root1.children, [@root_child1, @root_child2]
+ assert_equal @root_child1.children, [@child1_child]
+ assert_equal @child1_child.children, []
+ assert_equal @root_child2.children, []
+ end
+
+ def test_parent
+ assert_equal @root_child1.parent, @root1
+ assert_equal @root_child1.parent, @root_child2.parent
+ assert_nil @root1.parent
+ end
+
+ def test_delete
+ assert_equal 6, TreeMixin.count
+ @root1.destroy
+ assert_equal 2, TreeMixin.count
+ @root2.destroy
+ @root3.destroy
+ assert_equal 0, TreeMixin.count
+ end
+
+ def test_insert
+ @extra = @root1.children.create
+
+ assert @extra
+
+ assert_equal @extra.parent, @root1
+
+ assert_equal 3, @root1.children.size
+ assert @root1.children.include?(@extra)
+ assert @root1.children.include?(@root_child1)
+ assert @root1.children.include?(@root_child2)
+ end
+
+ def test_ancestors
+ assert_equal [], @root1.ancestors
+ assert_equal [@root1], @root_child1.ancestors
+ assert_equal [@root_child1, @root1], @child1_child.ancestors
+ assert_equal [@root1], @root_child2.ancestors
+ assert_equal [], @root2.ancestors
+ assert_equal [], @root3.ancestors
+ end
+
+ def test_root
+ assert_equal @root1, TreeMixin.root
+ assert_equal @root1, @root1.root
+ assert_equal @root1, @root_child1.root
+ assert_equal @root1, @child1_child.root
+ assert_equal @root1, @root_child2.root
+ assert_equal @root2, @root2.root
+ assert_equal @root3, @root3.root
+ end
+
+ def test_roots
+ assert_equal [@root1, @root2, @root3], TreeMixin.roots
+ end
+
+ def test_siblings
+ assert_equal [@root2, @root3], @root1.siblings
+ assert_equal [@root_child2], @root_child1.siblings
+ assert_equal [], @child1_child.siblings
+ assert_equal [@root_child1], @root_child2.siblings
+ assert_equal [@root1, @root3], @root2.siblings
+ assert_equal [@root1, @root2], @root3.siblings
+ end
+
+ def test_self_and_siblings
+ assert_equal [@root1, @root2, @root3], @root1.self_and_siblings
+ assert_equal [@root_child1, @root_child2], @root_child1.self_and_siblings
+ assert_equal [@child1_child], @child1_child.self_and_siblings
+ assert_equal [@root_child1, @root_child2], @root_child2.self_and_siblings
+ assert_equal [@root1, @root2, @root3], @root2.self_and_siblings
+ assert_equal [@root1, @root2, @root3], @root3.self_and_siblings
+ end
+end
+
+class TreeTestWithEagerLoading < Test::Unit::TestCase
+
+ def setup
+ teardown_db
+ setup_db
+ @root1 = TreeMixin.create!
+ @root_child1 = TreeMixin.create! :parent_id => @root1.id
+ @child1_child = TreeMixin.create! :parent_id => @root_child1.id
+ @root_child2 = TreeMixin.create! :parent_id => @root1.id
+ @root2 = TreeMixin.create!
+ @root3 = TreeMixin.create!
+
+ @rc1 = RecursivelyCascadedTreeMixin.create!
+ @rc2 = RecursivelyCascadedTreeMixin.create! :parent_id => @rc1.id
+ @rc3 = RecursivelyCascadedTreeMixin.create! :parent_id => @rc2.id
+ @rc4 = RecursivelyCascadedTreeMixin.create! :parent_id => @rc3.id
+ end
+
+ def teardown
+ teardown_db
+ end
+
+ def test_eager_association_loading
+ roots = TreeMixin.find(:all, :include => :children, :conditions => "mixins.parent_id IS NULL", :order => "mixins.id")
+ assert_equal [@root1, @root2, @root3], 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 @rc4, 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 @rc4, 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 @rc1, assert_no_queries { leaf_node.parent.parent.parent }
+ end
+end
+
+class TreeTestWithoutOrder < Test::Unit::TestCase
+
+ def setup
+ setup_db
+ @root1 = TreeMixinWithoutOrder.create!
+ @root2 = TreeMixinWithoutOrder.create!
+ end
+
+ def teardown
+ teardown_db
+ end
+
+ def test_root
+ assert [@root1, @root2].include?(TreeMixinWithoutOrder.root)
+ end
+
+ def test_roots
+ assert_equal [], [@root1, @root2] - TreeMixinWithoutOrder.roots
+ end
+end
+
+class TestDottedIdTree < Test::Unit::TestCase
+ # Replace this with your real tests.
+
+ def setup
+ setup_db
+ @tree = TreeMixin.create(:name => 'Root')
+ @child = @tree.children.create(:name => 'Child')
+ @subchild = @child.children.create(:name => 'Subchild')
+ @new_root = TreeMixin.create!(:name => 'New Root')
+ end
+
+ def teardown
+ teardown_db
+ end
+
+ def test_build_dotted_ids
+ assert_equal "#{@tree.id}", @tree.dotted_ids
+ assert_equal "#{@tree.id}.#{@child.id}", @child.dotted_ids
+ assert_equal "#{@tree.id}.#{@child.id}.#{@subchild.id}", @subchild.dotted_ids
+ end
+
+ def test_ancestor_of
+
+ assert @tree.ancestor_of?(@child)
+ assert @child.ancestor_of?(@subchild)
+ assert @tree.ancestor_of?(@subchild)
+
+ assert !@tree.ancestor_of?(@tree)
+ assert !@child.ancestor_of?(@child)
+ assert !@subchild.ancestor_of?(@subchild)
+
+ assert !@child.ancestor_of?(@tree)
+ assert !@subchild.ancestor_of?(@tree)
+ assert !@subchild.ancestor_of?(@child)
+
+ end
+
+ def test_descendant_of
+
+ assert @child.descendant_of?(@tree)
+ assert @subchild.descendant_of?(@child)
+ assert @subchild.descendant_of?(@tree)
+
+ assert !@tree.descendant_of?(@tree)
+ assert !@child.descendant_of?(@child)
+ assert !@subchild.descendant_of?(@subchild)
+
+ assert !@tree.descendant_of?(@child)
+ assert !@child.descendant_of?(@subchild)
+ assert !@tree.descendant_of?(@subchild)
+
+ end
+
+
+ def test_all_children
+
+ kids = @tree.all_children
+ assert_kind_of Array, kids
+ assert kids.size == 2
+ assert !kids.include?(@tree)
+ assert kids.include?(@child)
+ assert kids.include?(@subchild)
+
+ kids = @child.all_children
+ assert_kind_of Array, kids
+ assert kids.size == 1
+ assert !kids.include?(@child)
+ assert kids.include?(@subchild)
+
+ kids = @subchild.all_children
+ assert_kind_of Array, kids
+ assert kids.empty?
+
+ end
+
+ def test_rebuild
+
+ @tree.parent_id = @new_root.id
+ @tree.save
+
+ @new_root.reload
+ @root = @new_root.children.first
+ @child = @root.children.first
+ @subchild = @child.children.first
+
+ assert_equal "#{@new_root.id}", @new_root.dotted_ids
+ assert_equal "#{@new_root.id}.#{@tree.id}", @tree.dotted_ids
+ assert_equal "#{@new_root.id}.#{@tree.id}.#{@child.id}", @child.dotted_ids
+ assert_equal "#{@new_root.id}.#{@tree.id}.#{@child.id}.#{@subchild.id}", @subchild.dotted_ids
+ assert @tree.ancestor_of?(@subchild)
+ assert @new_root.ancestor_of?(@tree)
+
+ @subchild.parent = @tree
+ @subchild.save
+
+ assert_equal "#{@new_root.id}", @new_root.dotted_ids
+ assert_equal "#{@new_root.id}.#{@tree.id}", @tree.dotted_ids
+ assert_equal "#{@new_root.id}.#{@tree.id}.#{@child.id}", @child.dotted_ids
+ assert_equal "#{@new_root.id}.#{@tree.id}.#{@subchild.id}", @subchild.dotted_ids
+
+ @child.parent = nil
+ @child.save!
+
+ assert_equal "#{@new_root.id}", @new_root.dotted_ids
+ assert_equal "#{@new_root.id}.#{@tree.id}", @tree.dotted_ids
+ assert_equal "#{@child.id}", @child.dotted_ids
+ assert_equal "#{@new_root.id}.#{@tree.id}.#{@subchild.id}", @subchild.dotted_ids
+
+ end
+
+ def test_ancestors
+ assert @tree.ancestors.empty?
+ assert_equal [@tree], @child.ancestors
+ assert_equal [@child, @tree], @subchild.ancestors
+ end
+
+ def test_root
+ assert_equal @tree, @tree.root
+ assert_equal @tree, @child.root
+ assert_equal @tree, @subchild.root
+ end
+
+ def test_traverse
+
+ traversed_nodes = []
+ TreeMixin.traverse { |node| traversed_nodes << node }
+
+ assert_equal [@tree, @child, @subchild, @new_root], traversed_nodes
+
+ end
+
+ def test_rebuild_dotted_ids
+
+ # TreeMixin.rebuild_dotted_ids!
+ # assert !TreeMixin.find(:all).any? { |n| n.dotted_ids.blank? }
+ #
+ # test
+
+ end
+
+ def test_depth
+ assert_equal 0, @tree.depth
+ assert_equal 1, @child.depth
+ assert_equal 2, @subchild.depth
+ end
+
+end
+
4 test/database.yml
@@ -0,0 +1,4 @@
+test:
+ adapter: sqlite3
+ database: db/test.sqlite3
+ timeout: 5000
0 test/schema.rb
No changes.

0 comments on commit 2adace6

Please sign in to comment.