From 57e4c988e80cc8a84fc0e57ffdecdc3fb92f5e57 Mon Sep 17 00:00:00 2001 From: Erik Dahlstrand Date: Fri, 9 Oct 2009 20:01:16 +0200 Subject: [PATCH] Initial commit --- .document | 5 + .gitignore | 5 + README.rdoc | 31 ++++++ Rakefile | 54 ++++++++++ VERSION | 1 + acts_as_tree.gemspec | 45 ++++++++ lib/acts_as_tree.rb | 94 ++++++++++++++++ test/acts_as_tree_test.rb | 218 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 453 insertions(+) create mode 100644 .document create mode 100644 .gitignore create mode 100644 README.rdoc create mode 100644 Rakefile create mode 100644 VERSION create mode 100644 acts_as_tree.gemspec create mode 100644 lib/acts_as_tree.rb create mode 100644 test/acts_as_tree_test.rb diff --git a/.document b/.document new file mode 100644 index 0000000..ecf3673 --- /dev/null +++ b/.document @@ -0,0 +1,5 @@ +README.rdoc +lib/**/*.rb +bin/* +features/**/*.feature +LICENSE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00c0b86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.sw? +.DS_Store +coverage +rdoc +pkg diff --git a/README.rdoc b/README.rdoc new file mode 100644 index 0000000..e62ee8f --- /dev/null +++ b/README.rdoc @@ -0,0 +1,31 @@ += ActsAsTree + +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+. + +== Install + + gem install acts_as_tree --source http://gemcutter.org + +== Example + + 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 \ No newline at end of file diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b61254b --- /dev/null +++ b/Rakefile @@ -0,0 +1,54 @@ +require 'rubygems' +require 'rake' + +begin + require 'jeweler' + Jeweler::Tasks.new do |gem| + gem.name = "acts_as_tree" + gem.summary = %Q{Gem version of acts_as_tree Rails plugin.} + gem.description = %Q{Specify this acts_as extension if you want to model a tree structure by providing a parent association and a children association.} + gem.email = "erik.dahlstrand@gmail.com" + gem.homepage = "http://github.com/erdah/acts_as_tree" + gem.authors = ["Erik Dahlstrand", "Rails Core"] + end +rescue LoadError + puts "Jeweler (or a dependency) not available. Install it with: sudo gem install jeweler" +end + +require 'rake/testtask' +Rake::TestTask.new(:test) do |test| + test.libs << 'lib' << 'test' + test.pattern = 'test/**/*_test.rb' + test.verbose = true +end + +begin + require 'rcov/rcovtask' + Rcov::RcovTask.new do |test| + test.libs << 'test' + test.pattern = 'test/**/*_test.rb' + test.verbose = true + end +rescue LoadError + task :rcov do + abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov" + end +end + +task :test => :check_dependencies + +task :default => :test + +require 'rake/rdoctask' +Rake::RDocTask.new do |rdoc| + if File.exist?('VERSION') + version = File.read('VERSION') + else + version = "" + end + + rdoc.rdoc_dir = 'rdoc' + rdoc.title = "acts_as_tree #{version}" + rdoc.rdoc_files.include('README*') + rdoc.rdoc_files.include('lib/**/*.rb') +end diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/acts_as_tree.gemspec b/acts_as_tree.gemspec new file mode 100644 index 0000000..ae813f5 --- /dev/null +++ b/acts_as_tree.gemspec @@ -0,0 +1,45 @@ +# Generated by jeweler +# DO NOT EDIT THIS FILE +# Instead, edit Jeweler::Tasks in Rakefile, and run `rake gemspec` +# -*- encoding: utf-8 -*- + +Gem::Specification.new do |s| + s.name = %q{acts_as_tree} + s.version = "0.1.0" + + s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= + s.authors = ["Erik Dahlstrand", "Rails Core"] + s.date = %q{2009-10-09} + s.description = %q{Specify this acts_as extension if you want to model a tree structure by providing a parent association and a children association.} + s.email = %q{erik.dahlstrand@gmail.com} + s.extra_rdoc_files = [ + "README.rdoc" + ] + s.files = [ + ".gitignore", + "README.rdoc", + "Rakefile", + "VERSION", + "acts_as_tree.gemspec", + "lib/acts_as_tree.rb", + "test/acts_as_tree_test.rb" + ] + s.homepage = %q{http://github.com/erdah/acts_as_tree} + s.rdoc_options = ["--charset=UTF-8"] + s.require_paths = ["lib"] + s.rubygems_version = %q{1.3.5} + s.summary = %q{Gem version of acts_as_tree Rails plugin.} + s.test_files = [ + "test/acts_as_tree_test.rb" + ] + + if s.respond_to? :specification_version then + current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION + s.specification_version = 3 + + if Gem::Version.new(Gem::RubyGemsVersion) >= Gem::Version.new('1.2.0') then + else + end + else + end +end diff --git a/lib/acts_as_tree.rb b/lib/acts_as_tree.rb new file mode 100644 index 0000000..2870012 --- /dev/null +++ b/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 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) + 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(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.class_eval { include ActsAsTree } \ No newline at end of file diff --git a/test/acts_as_tree_test.rb b/test/acts_as_tree_test.rb new file mode 100644 index 0000000..7965d5e --- /dev/null +++ b/test/acts_as_tree_test.rb @@ -0,0 +1,218 @@ +require 'test/unit' + +require 'rubygems' +require 'active_record' + +require "#{File.dirname(__FILE__)}/../lib/acts_as_tree" + +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 + 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 :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 + +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 \ No newline at end of file