Browse files

initial commit

  • Loading branch information...
1 parent 7384a2f commit e43fe018fcedb356ace3444bcd236c2f6e03fac0 @kristianmandrup committed Jun 19, 2010
Showing with 558 additions and 56 deletions.
  1. +0 −2 LICENSE
  2. +27 −0 README.markdown
  3. +0 −17 README.rdoc
  4. +30 −20 Rakefile
  5. +1 −0 init.rb
  6. +186 −0 lib/active_record/acts/tree.rb
  7. +2 −0 lib/acts_as_tree_rails3.rb
  8. +1 −0 rails/init.rb
  9. +0 −7 spec/acts_as_tree_rails3_spec.rb
  10. +0 −1 spec/spec.opts
  11. +0 −9 spec/spec_helper.rb
  12. +311 −0 test/acts_as_tree_test.rb
View
2 LICENSE
@@ -1,5 +1,3 @@
-Copyright (c) 2009 Kristian Mandrup
-
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
View
27 README.markdown
@@ -0,0 +1,27 @@
+# acts_as_tree for Rails 3 #
+
+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
+
+Includes patch from http://dev.rubyonrails.org/ticket/1924
View
17 README.rdoc
@@ -1,17 +0,0 @@
-= acts_as_tree_rails3
-
-Description goes here.
-
-== Note on Patches/Pull Requests
-
-* Fork the project.
-* Make your feature addition or bug fix.
-* Add tests for it. This is important so I don't break it in a
- future version unintentionally.
-* Commit, do not mess with rakefile, version, or history.
- (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
-* Send me a pull request. Bonus points for topic branches.
-
-== Copyright
-
-Copyright (c) 2010 Kristian Mandrup. See LICENSE for details.
View
50 Rakefile
@@ -5,41 +5,51 @@ begin
require 'jeweler'
Jeweler::Tasks.new do |gem|
gem.name = "acts_as_tree_rails3"
- gem.summary = %Q{TODO: one-line summary of your gem}
- gem.description = %Q{TODO: longer description of your gem}
- gem.email = "kmandrup@gmail.com"
- gem.homepage = "http://github.com/kristianmandrup/acts_as_tree_rails3"
- gem.authors = ["Kristian Mandrup"]
- gem.add_development_dependency "rspec", ">= 1.2.9"
- # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
+ gem.summary = %Q{Make your model act as a tree structure}
+ gem.description = %Q{Model gets: root, siblings, ancestors, descendants and other methods for tree navigation}
+ gem.email = "jim@saturnflyer.com"
+ gem.homepage = "http://github.com/saturnflyer/acts_as_tree"
+ gem.authors = ["David Heinemeier Hansson",'and others']
end
Jeweler::GemcutterTasks.new
rescue LoadError
puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
end
-require 'spec/rake/spectask'
-Spec::Rake::SpecTask.new(:spec) do |spec|
- spec.libs << 'lib' << 'spec'
- spec.spec_files = FileList['spec/**/*_spec.rb']
+require 'rake/testtask'
+Rake::TestTask.new(:test) do |test|
+ test.libs << 'lib' << 'test'
+ test.pattern = 'test/**/*_test.rb'
+ test.verbose = true
end
-Spec::Rake::SpecTask.new(:rcov) do |spec|
- spec.libs << 'lib' << 'spec'
- spec.pattern = 'spec/**/*_spec.rb'
- spec.rcov = true
+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 :spec => :check_dependencies
+task :test => :check_dependencies
-task :default => :spec
+task :default => :test
require 'rake/rdoctask'
Rake::RDocTask.new do |rdoc|
- version = File.exist?('VERSION') ? File.read('VERSION') : ""
+ if File.exist?('VERSION')
+ version = File.read('VERSION')
+ else
+ version = ""
+ end
rdoc.rdoc_dir = 'rdoc'
- rdoc.title = "acts_as_tree_rails3 #{version}"
+ rdoc.title = "acts_as_tree #{version}"
rdoc.rdoc_files.include('README*')
rdoc.rdoc_files.include('lib/**/*.rb')
-end
+end
View
1 init.rb
@@ -0,0 +1 @@
+require File.dirname(__FILE__) + "/rails/init"
View
186 lib/active_record/acts/tree.rb
@@ -0,0 +1,186 @@
+module ActiveRecord
+ module Acts
+ module Tree
+ 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>)
+ # * <tt>descendants</tt> - Returns a flat list of the descendants of the current node (<tt>[child1, subchild1, subchild2]</tt> when called on <tt>root</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,
+ :dependent => :destroy,
+ :touch => false
+ }
+ configuration.update(options) if options.is_a?(Hash)
+
+ # facebooker seems unhappy with :touch
+ if configuration[:touch]
+ belongs_to :parent,
+ :class_name => name,
+ :foreign_key => configuration[:foreign_key],
+ :counter_cache => configuration[:counter_cache],
+ :touch => configuration[:touch]
+ else
+ belongs_to :parent,
+ :class_name => name,
+ :foreign_key => configuration[:foreign_key],
+ :counter_cache => configuration[:counter_cache]
+ end
+
+ has_many :children,
+ :class_name => name,
+ :foreign_key => configuration[:foreign_key],
+ :order => configuration[:order],
+ :dependent => configuration[:dependent]
+
+ class_eval <<-EOV
+ include ActiveRecord::Acts::Tree::InstanceMethods
+
+ scope :roots,
+ :conditions => "#{configuration[:foreign_key]} IS NULL",
+ :order => #{configuration[:order].nil? ? "nil" : %Q{"#{configuration[:order]}"}}
+
+ after_save :update_level_cache
+ after_update :update_parents_counter_cache
+
+ def self.root
+ roots.first
+ end
+
+ def self.childless
+ nodes = []
+
+ find(:all).each do |node|
+ nodes << node if node.children.empty?
+ end
+
+ nodes
+ end
+
+ validates_each "#{configuration[:foreign_key]}" do |record, attr, value|
+ if value
+ if record.id == value
+ record.errors.add attr, "cannot be it's own id"
+ elsif record.descendants.map {|c| c.id}.include?(value)
+ record.errors.add attr, "cannot be a descendant's id"
+ end
+ end
+ 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 until node.parent.nil? and return nodes
+ end
+
+ def root?
+ parent == nil
+ end
+
+ def leaf?
+ children.length == 0
+ end
+
+ # Returns the root node of the tree.
+ def root
+ node = self
+ node = node.parent until node.parent.nil? and return 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
+
+ # Returns a flat list of the descendants of the current node.
+ #
+ # root.descendants # => [child1, subchild1, subchild2]
+ def descendants(node=self)
+ nodes = []
+ nodes << node unless node == self
+
+ node.children.each do |child|
+ nodes += descendants(child)
+ end
+
+ nodes.compact
+ end
+
+ def childless
+ self.descendants.collect{|d| d.children.empty? ? d : nil}.compact
+ end
+
+ private
+
+ def update_parents_counter_cache
+ if self.respond_to?(:children_count) && parent_id_changed?
+ self.class.decrement_counter(:children_count, parent_id_was)
+ self.class.increment_counter(:children_count, parent_id)
+ end
+ end
+
+ def update_level_cache
+ if respond_to?(:level_cache) && parent_id_changed?
+ _level_cache = ancestors.length
+
+ if level_cache != _level_cache
+ self.class.update_all("level_cache = #{_level_cache}", ['id = ?', id])
+ end
+ end
+ end
+ end
+ end
+ end
+end
View
2 lib/acts_as_tree_rails3.rb
@@ -0,0 +1,2 @@
+require 'active_record/acts/tree'
+ActiveRecord::Base.send :include, ActiveRecord::Acts::Tree
View
1 rails/init.rb
@@ -0,0 +1 @@
+require 'acts_as_tree_rails3'
View
7 spec/acts_as_tree_rails3_spec.rb
@@ -1,7 +0,0 @@
-require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
-
-describe "ActsAsTreeRails3" do
- it "fails" do
- fail "hey buddy, you should probably rename this file and start specing for real"
- end
-end
View
1 spec/spec.opts
@@ -1 +0,0 @@
---color
View
9 spec/spec_helper.rb
@@ -1,9 +0,0 @@
-$LOAD_PATH.unshift(File.dirname(__FILE__))
-$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
-require 'acts_as_tree_rails3'
-require 'spec'
-require 'spec/autorun'
-
-Spec::Runner.configure do |config|
-
-end
View
311 test/acts_as_tree_test.rb
@@ -0,0 +1,311 @@
+require 'test/unit'
+
+require 'rubygems'
+require 'active_record'
+
+$:.unshift File.dirname(__FILE__) + '/../lib'
+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", :database => ":memory:")
+
+# AR keeps printing annoying schema statements
+$stdout_orig = $stdout
+$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 :children_count, :integer, :default => 0
+ t.column :level_cache, :integer, :default => 0
+ 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 TreeMixinWithCounterCache < Mixin
+ acts_as_tree :foreign_key => "parent_id", :order => "id", :counter_cache => :children_count
+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 TreeMixinNullify < Mixin
+ acts_as_tree :foreign_key => "parent_id", :order => "id", :dependent => :nullify
+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.reload.children, [@root_child1, @root_child2]
+ assert_equal @root_child1.reload.children, [@child1_child]
+ assert_equal @child1_child.reload.children, []
+ assert_equal @root_child2.reload.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_nullify
+ root4 = TreeMixinNullify.create!
+ root4_child = TreeMixinNullify.create! :parent_id => root4.id
+ assert_equal 2, TreeMixinNullify.count
+ assert_equal root4.id, root4_child.parent_id
+ root4.destroy
+ assert_equal 1, TreeMixinNullify.count
+ assert_nil root4_child.reload.parent_id
+ 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.reload.children.count
+ 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
+
+ def test_root
+ assert_equal true, @root1.root?
+ assert_equal false, @child1_child.root?
+ end
+
+ def test_leaf
+ assert_equal false, @root1.leaf?
+ assert_equal true, @child1_child.leaf?
+ end
+end
+
+class TreeTestWithCounterCache < Test::Unit::TestCase
+ def setup
+ teardown_db
+ setup_db
+ @root = TreeMixinWithCounterCache.create!
+ @child1 = TreeMixinWithCounterCache.create! :parent_id => @root.id
+ @child1_child1 = TreeMixinWithCounterCache.create! :parent_id => @child1.id
+ @child2 = TreeMixinWithCounterCache.create! :parent_id => @root.id
+ end
+
+ def teardown
+ teardown_db
+ end
+
+ def test_counter_cache
+ assert_equal 2, @root.reload.children_count
+ assert_equal 1, @child1.reload.children_count
+ end
+
+ def test_update_parents_counter_cache
+ @child1_child1.update_attributes(:parent_id => @root.id)
+ assert_equal 3, @root.reload.children_count
+ assert_equal 0, @child1.reload.children_count
+ end
+
+end
+
+class TreeTestWithLevelCache < 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!
+ end
+
+ def teardown
+ teardown_db
+ end
+
+ def test_level_cache
+ assert_equal 0, @root1.reload.level_cache
+ assert_equal 1, @root_child1.reload.level_cache
+ assert_equal 2, @child1_child.reload.level_cache
+ assert_equal 0, @root2.reload.level_cache
+ end
+
+ def test_level_cache_are_updated
+ @child1_child.reload.parent_id = nil
+ @child1_child.save
+
+ @root2.reload.parent_id = @root_child2.reload.id
+ @root2.save
+
+ assert_equal 0, @root1.reload.level_cache
+ assert_equal 1, @root_child1.reload.level_cache
+ assert_equal 0, @child1_child.reload.level_cache
+ assert_equal 2, @root2.reload.level_cache
+ 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.count
+ assert_equal 0, roots[1].children.count
+ assert_equal 0, roots[2].children.count
+ 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

0 comments on commit e43fe01

Please sign in to comment.