diff --git a/README b/README new file mode 100644 index 0000000..a907097 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..2b1b91b --- /dev/null +++ b/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 diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..4b26452 --- /dev/null +++ b/init.rb @@ -0,0 +1,2 @@ +require 'active_record/acts/tree_with_dotted_ids' +ActiveRecord::Base.send :include, ActiveRecord::Acts::TreeWithDottedIds diff --git a/lib/active_record/acts/tree_with_dotted_ids.rb b/lib/active_record/acts/tree_with_dotted_ids.rb new file mode 100644 index 0000000..2856d6f --- /dev/null +++ b/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 acts_as_tree: + # * siblings - Returns all the children of the parent, excluding the current node ([subchild2] when called on subchild1) + # * self_and_siblings - Returns all the children of the parent, including the current node ([subchild1, subchild2] when called on subchild1) + # * ancestors - Returns all the ancestors of the current node ([child1, root] when called on subchild2) + # * root - Returns the root of the current node (root when called on subchild2) + # * depth - Returns the depth of the current node starting from 0 as the depth of root nodes. + module ClassMethods + # Configuration options are: + # + # * foreign_key - specifies the column name to use for tracking of the tree (default: +parent_id+) + # * order - makes it possible to sort the children according to this SQL snippet. + # * counter_cache - 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 diff --git a/test/acts_as_tree_test.rb b/test/acts_as_tree_test.rb new file mode 100644 index 0000000..bb3ccab --- /dev/null +++ b/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 + diff --git a/test/database.yml b/test/database.yml new file mode 100644 index 0000000..8148bec --- /dev/null +++ b/test/database.yml @@ -0,0 +1,4 @@ +test: + adapter: sqlite3 + database: db/test.sqlite3 + timeout: 5000 \ No newline at end of file diff --git a/test/schema.rb b/test/schema.rb new file mode 100644 index 0000000..e69de29