├── rails └── init.rb ├── test ├── fixtures │ ├── departments.yml │ ├── notes.yml │ ├── category.rb │ └── categories.yml ├── db │ ├── database.yml │ └── schema.rb ├── test_helper.rb ├── benchmarks.rb ├── nested_set │ └── helper_test.rb └── nested_set_test.rb ├── lib ├── nested_set │ ├── version.rb │ ├── descendants.rb │ ├── railtie.rb │ ├── depth.rb │ ├── helper.rb │ └── base.rb └── nested_set.rb ├── init.rb ├── .gitignore ├── .autotest ├── .travis.yml ├── Gemfile ├── Rakefile ├── MIT-LICENSE ├── nested_set.gemspec └── README.md /rails/init.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "nested_set" 3 | -------------------------------------------------------------------------------- /test/fixtures/departments.yml: -------------------------------------------------------------------------------- 1 | top: 2 | id: 1 3 | name: Top -------------------------------------------------------------------------------- /lib/nested_set/version.rb: -------------------------------------------------------------------------------- 1 | module NestedSet 2 | VERSION = "1.7.1" 3 | end 4 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "rails", "init") 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## MAC OS 2 | .DS_Store 3 | 4 | ## TEXTMATE 5 | *.tmproj 6 | tmtags 7 | 8 | ## EMACS 9 | *~ 10 | \#* 11 | .\#* 12 | 13 | ## VIM 14 | *.swp 15 | 16 | ## PROJECT::GENERAL 17 | coverage 18 | rdoc 19 | pkg 20 | 21 | # PROJECT::SPECIFIC 22 | awesome_nested_set.sqlite3.db 23 | test/debug.log 24 | rdoc 25 | *.sw? 26 | .bundle 27 | Gemfile.lock 28 | -------------------------------------------------------------------------------- /.autotest: -------------------------------------------------------------------------------- 1 | Autotest.add_hook :initialize do |at| 2 | at.clear_mappings 3 | 4 | at.add_mapping %r%^lib/(.*)\.rb$% do |_, m| 5 | at.files_matching %r%^test/#{m[1]}_test.rb$% 6 | end 7 | 8 | at.add_mapping(%r%^test/.*\.rb$%) {|filename, _| filename } 9 | 10 | at.add_mapping %r%^test/fixtures/(.*)s.yml% do |_, _| 11 | at.files_matching %r%^test/.*\.rb$% 12 | end 13 | end -------------------------------------------------------------------------------- /lib/nested_set.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module CollectiveIdea 3 | module Acts 4 | module NestedSet 5 | autoload :Base, 'nested_set/base' 6 | autoload :Depth, 'nested_set/depth' 7 | autoload :Descendants, 'nested_set/descendants' 8 | autoload :Helper, 'nested_set/helper' 9 | end 10 | end 11 | end 12 | 13 | require 'nested_set/railtie' 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.8.7 3 | - 1.9.2 4 | - 1.9.3 5 | - ruby-head 6 | 7 | env: 8 | - RAILS_VERSION=3.0.11 9 | - RAILS_VERSION=3.1.3 10 | - RAILS_VERSION=3.2.1 11 | - RAILS_VERSION=3-0-stable 12 | - RAILS_VERSION=3-1-stable 13 | - RAILS_VERSION=3-2-stable 14 | #- RAILS_VERSION=master 15 | 16 | matrix: 17 | exclude: 18 | - rvm: 1.9.2 19 | env: RAILS_VERSION=master 20 | - rvm: 1.8.7 21 | env: RAILS_VERSION=master 22 | -------------------------------------------------------------------------------- /test/db/database.yml: -------------------------------------------------------------------------------- 1 | sqlite3: 2 | adapter: sqlite3 3 | database: awesome_nested_set.sqlite3.db 4 | sqlite3mem: 5 | adapter: sqlite3 6 | database: ":memory:" 7 | postgresql: 8 | adapter: postgresql 9 | username: postgres 10 | password: postgres 11 | database: awesome_nested_set_plugin_test 12 | min_messages: ERROR 13 | mysql: 14 | adapter: mysql 15 | host: localhost 16 | username: root 17 | password: 18 | database: awesome_nested_set_plugin_test -------------------------------------------------------------------------------- /test/fixtures/notes.yml: -------------------------------------------------------------------------------- 1 | scope1: 2 | id: 1 3 | body: Top Level 4 | lft: 1 5 | rgt: 10 6 | notable_id: 1 7 | notable_type: Category 8 | child_1: 9 | id: 2 10 | body: Child 1 11 | parent_id: 1 12 | lft: 2 13 | rgt: 3 14 | notable_id: 1 15 | notable_type: Category 16 | child_2: 17 | id: 3 18 | body: Child 2 19 | parent_id: 1 20 | lft: 4 21 | rgt: 7 22 | notable_id: 1 23 | notable_type: Category 24 | child_3: 25 | id: 4 26 | body: Child 3 27 | parent_id: 1 28 | lft: 8 29 | rgt: 9 30 | notable_id: 1 31 | notable_type: Category 32 | scope2: 33 | id: 5 34 | body: Top Level 2 35 | lft: 1 36 | rgt: 2 37 | notable_id: 1 38 | notable_type: Departments 39 | -------------------------------------------------------------------------------- /lib/nested_set/descendants.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module CollectiveIdea #:nodoc: 3 | module Acts #:nodoc: 4 | module NestedSet #:nodoc: 5 | module Descendants 6 | # Returns the number of nested children of this object. 7 | def descendants_count 8 | return (right - left - 1)/2 9 | end 10 | 11 | def has_descendants? 12 | !descendants_count.zero? 13 | end 14 | 15 | def move_by_direction(ditection) 16 | return if ditection.blank? 17 | 18 | case ditection.to_sym 19 | when :up, :left then move_left 20 | when :down, :right then move_right 21 | end 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/nested_set/railtie.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'nested_set' 3 | require 'rails/railtie' 4 | 5 | module CollectiveIdea 6 | module Acts 7 | module NestedSet 8 | class Railtie < ::Rails::Railtie 9 | config.before_initialize do 10 | ActiveSupport.on_load :active_record do 11 | include CollectiveIdea::Acts::NestedSet::Base 12 | end 13 | 14 | ActiveSupport.on_load :action_view do 15 | include CollectiveIdea::Acts::NestedSet::Helper 16 | end 17 | end 18 | 19 | def self.extend_active_record! 20 | ::ActiveRecord::Base.send :include, CollectiveIdea::Acts::NestedSet::Base 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gemspec 4 | 5 | case version = ENV['RAILS_VERSION'] || ">= 3.0.0" 6 | when /3-0-stable/ 7 | gem "rails", :git => "git://github.com/rails/rails.git", :branch => "3-0-stable" 8 | gem "arel", :git => "git://github.com/rails/arel.git", :branch => "2-0-stable" 9 | when /3-1-stable/ 10 | gem "rails", :git => "git://github.com/rails/rails.git", :branch => "3-1-stable" 11 | when /3-2-stable/ 12 | gem "rails", :git => "git://github.com/rails/rails.git", :branch => "3-2-stable" 13 | gem "journey", '~> 1.0.4' 14 | when /master/ 15 | gem "rails", :git => "git://github.com/rails/rails.git" 16 | gem "arel", :git => "git://github.com/rails/arel.git" 17 | gem "journey", '~> 1.0.4' 18 | else 19 | gem "rails", version 20 | end 21 | -------------------------------------------------------------------------------- /test/fixtures/category.rb: -------------------------------------------------------------------------------- 1 | class Category < ActiveRecord::Base 2 | self.primary_key = 'not_default_id_name' 3 | acts_as_nested_set 4 | 5 | def to_s 6 | name 7 | end 8 | 9 | def recurse(&block) 10 | block.call self, lambda{ 11 | self.children.each do |child| 12 | child.recurse(&block) 13 | end 14 | } 15 | end 16 | 17 | end 18 | 19 | class Category_NoToArray < Category 20 | def to_a 21 | raise 'to_a called' 22 | end 23 | end 24 | 25 | class Category_DefaultScope < Category 26 | default_scope order('categories.not_default_id_name ASC') 27 | end 28 | 29 | class Category_WithCustomDestroy < ActiveRecord::Base 30 | self.table_name = 'categories' 31 | self.primary_key = 'not_default_id_name' 32 | acts_as_nested_set 33 | 34 | private :destroy 35 | def custom_destroy 36 | destroy 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /test/fixtures/categories.yml: -------------------------------------------------------------------------------- 1 | # top_level -- 2 | # |\ 3 | # | child_1 4 | # \ 5 | # | child_2-- 6 | # | \ 7 | # \ child_2_1 8 | # child_3 9 | # 10 | # top_level_2 -- 11 | 12 | top_level: 13 | not_default_id_name: 1 14 | name: Top Level 15 | lft: 1 16 | rgt: 10 17 | #depth: NULL 18 | child_1: 19 | not_default_id_name: 2 20 | name: Child 1 21 | parent_id: 1 22 | lft: 2 23 | rgt: 3 24 | depth: 1 25 | child_2: 26 | not_default_id_name: 3 27 | name: Child 2 28 | parent_id: 1 29 | lft: 4 30 | rgt: 7 31 | depth: 1 32 | child_2_1: 33 | not_default_id_name: 4 34 | name: Child 2.1 35 | parent_id: 3 36 | lft: 5 37 | rgt: 6 38 | depth: 2 39 | child_3: 40 | not_default_id_name: 5 41 | name: Child 3 42 | parent_id: 1 43 | lft: 8 44 | rgt: 9 45 | depth: 1 46 | top_level_2: 47 | not_default_id_name: 6 48 | name: Top Level 2 49 | lft: 11 50 | rgt: 12 51 | depth: 0 52 | -------------------------------------------------------------------------------- /test/db/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define(:version => 0) do 2 | 3 | create_table :categories, :force => true, :id => false do |t| 4 | t.primary_key :not_default_id_name 5 | t.column :name, :string 6 | t.column :parent_id, :integer 7 | t.column :lft, :integer 8 | t.column :rgt, :integer 9 | t.column :organization_id, :integer 10 | t.column :depth, :integer 11 | end 12 | 13 | create_table :departments, :force => true do |t| 14 | t.column :name, :string 15 | end 16 | 17 | create_table :notes, :force => true do |t| 18 | t.column :body, :text 19 | t.column :parent_id, :integer 20 | t.column :lft, :integer 21 | t.column :rgt, :integer 22 | t.column :notable_id, :integer 23 | t.column :notable_type, :string 24 | end 25 | 26 | create_table :renamed_columns, :force => true do |t| 27 | t.column :name, :string 28 | t.column :mother_id, :integer 29 | t.column :red, :integer 30 | t.column :black, :integer 31 | t.column :level, :integer 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'rubygems' 3 | begin 4 | require 'bundler/setup' 5 | rescue LoadError 6 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 7 | end 8 | 9 | require 'rake' 10 | require 'rake/testtask' 11 | Rake::TestTask.new(:test) do |test| 12 | test.libs << 'lib' << 'test' 13 | test.pattern = 'test/**/*_test.rb' 14 | test.verbose = true 15 | end 16 | 17 | begin 18 | require 'rcov/rcovtask' 19 | Rcov::RcovTask.new do |test| 20 | test.libs << 'test' 21 | test.pattern = 'test/**/*_test.rb' 22 | test.verbose = true 23 | end 24 | rescue LoadError 25 | task :rcov do 26 | abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov" 27 | end 28 | end 29 | 30 | task :default => :test 31 | 32 | require 'rdoc/task' 33 | Rake::RDocTask.new do |rdoc| 34 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 35 | 36 | rdoc.rdoc_dir = 'rdoc' 37 | rdoc.title = "nested_set #{version}" 38 | rdoc.rdoc_files.include('README*') 39 | rdoc.rdoc_files.include('lib/**/*.rb') 40 | end 41 | 42 | require "bundler/gem_tasks" 43 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2007 [name of plugin creator] 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /nested_set.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "nested_set/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "nested_set" 7 | s.version = NestedSet::VERSION 8 | s.authors = ["Brandon Keepers", "Daniel Morrison"] 9 | s.email = ["info@collectiveidea.com"] 10 | s.homepage = "http://github.com/skyeagle/nested_set" 11 | s.summary = %q{An awesome nested set implementation for Active Record} 12 | s.description = %q{An awesome nested set implementation for Active Record} 13 | 14 | s.files = `git ls-files`.split("\n") 15 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 16 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 17 | s.require_paths = ["lib"] 18 | 19 | s.add_runtime_dependency(%q, [">= 3.0.0"]) 20 | s.add_runtime_dependency(%q, [">= 3.0.0"]) 21 | 22 | s.add_development_dependency(%q, [">= 3.0.0"]) 23 | s.add_development_dependency(%q, [">= 0"]) 24 | s.add_development_dependency(%q, [">= 0.3.1"]) 25 | end 26 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] ||= "test" 2 | 3 | plugin_test_dir = File.dirname(__FILE__) 4 | 5 | $:.unshift(plugin_test_dir + '/../lib') 6 | 7 | require 'rubygems' 8 | require 'test/unit' 9 | require 'rails/all' 10 | if Rails.version < '3.1.0' 11 | require 'action_view/base' 12 | require 'action_view/template/handlers/erb' 13 | end 14 | require 'nested_set' 15 | 16 | class NestedSetApplication < Rails::Application 17 | end 18 | 19 | CollectiveIdea::Acts::NestedSet::Railtie.extend_active_record! 20 | 21 | ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/debug.log") 22 | 23 | ActiveRecord::Base.configurations = YAML::load(IO.read(plugin_test_dir + "/db/database.yml")) 24 | ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3mem") 25 | ActiveRecord::Migration.verbose = false 26 | load(File.join(plugin_test_dir, "db", "schema.rb")) 27 | 28 | Dir["#{plugin_test_dir}/fixtures/*.rb"].each {|file| require file } 29 | 30 | class ActiveSupport::TestCase #:nodoc: 31 | include ActiveRecord::TestFixtures 32 | 33 | self.fixture_path = File.dirname(__FILE__) + "/fixtures/" 34 | self.use_transactional_fixtures = true 35 | self.use_instantiated_fixtures = false 36 | 37 | fixtures :categories, :notes, :departments 38 | end 39 | -------------------------------------------------------------------------------- /test/benchmarks.rb: -------------------------------------------------------------------------------- 1 | if $0 == __FILE__ 2 | 3 | plugin_test_dir = File.dirname(__FILE__) 4 | 5 | $:.unshift(plugin_test_dir + '/../lib') 6 | 7 | require 'rails/all' 8 | require 'nested_set' 9 | require 'bench_press' 10 | 11 | CollectiveIdea::Acts::NestedSet::Railtie.extend_active_record! 12 | #ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/debug.log") 13 | ActiveRecord::Base.configurations = YAML::load(IO.read(plugin_test_dir + "/db/database.yml")) 14 | ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3mem") 15 | ActiveRecord::Migration.verbose = false 16 | ActiveRecord::Schema.define(:version => 0) do 17 | create_table :categories, :force => true do |t| 18 | t.column :name, :string 19 | t.column :parent_id, :integer 20 | t.column :lft, :integer 21 | t.column :rgt, :integer 22 | end 23 | end 24 | 25 | class Category < ActiveRecord::Base 26 | acts_as_nested_set 27 | end 28 | 29 | Category.delete_all 30 | Category.create(:name => "Root Node 1") 31 | Category.create(:name => "Root Node 2") 32 | 50.times do |i| 33 | node = Category.create(:name => "Node #{i}") 34 | set = Category.roots.map{|root| root.self_and_descendants}.flatten 35 | random_node = set[rand(set.size-1)] 36 | node.move_to_child_of(random_node) 37 | end 38 | 39 | include CollectiveIdea::Acts::NestedSet::Helper 40 | extend BenchPress 41 | 42 | reps 100 43 | 44 | measure "nested_set_options" do 45 | nested_set_options(Category){|i, level| "#{'-' * level} #{i.name}" } 46 | end 47 | 48 | measure "sorted_nested_set_options" do 49 | sorted_nested_set_options(Category, lambda{|x| x.name }){|i, level| "#{'-' * level} #{i.name}" } 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/nested_set/depth.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module CollectiveIdea #:nodoc: 3 | module Acts #:nodoc: 4 | module NestedSet #:nodoc: 5 | module Depth 6 | # Model scope conditions 7 | def scope_condition(table_name=nil) 8 | table_name ||= self.class.quoted_table_name 9 | 10 | scope_string = Array(acts_as_nested_set_options[:scope]).map do |c| 11 | "#{table_name}.#{connection.quote_column_name(c)} = #{self.send(c)}" 12 | end.join(" AND ") 13 | 14 | scope_string.blank? ? "1 = 1" : scope_string 15 | end 16 | 17 | 18 | def has_depth_column? 19 | respond_to?(depth_column_name) 20 | end 21 | 22 | # Update cached_level attribute 23 | def update_depth 24 | send :"#{depth_column_name}=", level 25 | depth_changed = send :"#{depth_column_name}_changed?" 26 | if depth_changed 27 | depth_change = send :"#{depth_column_name}_change" 28 | self.self_and_descendants. 29 | update_all(["#{self.class.quoted_depth_column_name} = COALESCE(#{self.class.quoted_depth_column_name}, 0) + ?", 30 | depth_change[1] - depth_change[0].to_i]) 31 | end 32 | end 33 | 34 | # Update cached_level attribute for all record tree 35 | def update_all_depth 36 | if has_depth_column? 37 | self.class.connection.execute("UPDATE #{self.class.quoted_table_name} a SET a.#{self.class.quoted_depth_column_name} = \ 38 | (SELECT count(*) - 1 FROM (SELECT * FROM #{self.class.quoted_table_name} WHERE #{scope_condition}) AS b \ 39 | WHERE #{scope_condition('a')} AND \ 40 | (a.#{quoted_left_column_name} BETWEEN b.#{quoted_left_column_name} AND b.#{quoted_right_column_name})) 41 | WHERE #{scope_condition('a')} 42 | ") 43 | end 44 | end 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://secure.travis-ci.org/skyeagle/nested_set.png)](http://travis-ci.org/skyeagle/nested_set) 2 | 3 | ### WARNING!!! 4 | #### The maintenance of the gem stopped from August 2013. You should move on to [awesome_nested_set](https://github.com/collectiveidea/awesome_nested_set.git) as a replacement for this one. 5 | 6 | # NestedSet 7 | 8 | Nested Set is an implementation of the nested set pattern for ActiveRecord models. It is replacement for acts_as_nested_set and BetterNestedSet, but awesomer. It supports Rails 3.0 and later. 9 | 10 | ## See, it's Rails 3 only. 11 | 12 | ## Installation 13 | 14 | The plugin is available as a gem: 15 | 16 | gem 'nested_set' 17 | 18 | Or install as a plugin: 19 | 20 | rails plugin install git://github.com/skyeagle/nested_set.git 21 | 22 | ## Usage 23 | 24 | To make use of nested_set, your model needs to have 3 fields: lft, rgt, and parent_id: 25 | 26 | class CreateCategories < ActiveRecord::Migration 27 | def self.up 28 | create_table :categories do |t| 29 | t.string :name 30 | t.integer :parent_id 31 | t.integer :lft 32 | t.integer :rgt 33 | 34 | # Uncomment it to store item level 35 | # t.integer :depth 36 | end 37 | end 38 | 39 | def self.down 40 | drop_table :categories 41 | end 42 | end 43 | 44 | ###NB: There is no reason to use depth column. It's only add additional queries to DB without benefit. If you need level you should use `each_with_level` instead. 45 | 46 | Enable the nested set functionality by declaring acts_as_nested_set on your model 47 | 48 | class Category < ActiveRecord::Base 49 | acts_as_nested_set 50 | end 51 | 52 | Run `rake rdoc` to generate the API docs and see CollectiveIdea::Acts::NestedSet::Base::SingletonMethods for more info. 53 | 54 | ### Conversion from other trees 55 | 56 | Coming from acts_as_tree or another system where you only have a parent_id? No problem. Simply add the lft & rgt fields as above, and then run 57 | 58 | Category.rebuild! 59 | 60 | Your tree be converted to a valid nested set. 61 | 62 | ## View Helper 63 | 64 | The view helper is called #nested_set_options. 65 | 66 | Example usage: 67 | 68 | <%= f.select :parent_id, nested_set_options(Category, @category) {|i, level| "#{'-' * level} #{i.name}" } %> 69 | 70 | <%= select_tag 'parent_id', options_for_select(nested_set_options(Category) {|i, level| "#{'-' * level} #{i.name}" } ) %> 71 | 72 | or sorted select: 73 | 74 | <%= f.select :parent_id, sorted_nested_set_options(Category, lambda(&:name)) {|i, level| "#{'-' * level} #{i.name}" } %> 75 | 76 | <% sort_method = lambda{|x| x.name.downcase} %> 77 | 78 | NOTE: to sort UTF-8 strings you should use `x.name.mb_chars.downcase` 79 | 80 | <%= select_tag 'parent_id', options_for_select(sorted_nested_set_options(Category, sort_method){|i, level| "#{'-' * level} #{i.name}" } ) %> 81 | 82 | See CollectiveIdea::Acts::NestedSet::Helper for more information about the helpers. 83 | 84 | ## Development 85 | 86 | bundle install 87 | 88 | Running tests 89 | 90 | bundle exec rake test 91 | 92 | Benchmark tests 93 | 94 | bundle exec ruby test/benchmark.rb 95 | 96 | ### References 97 | 98 | You can learn more about nested sets at: 99 | 100 | [1](http://en.wikipedia.org/wiki/Nested_set_model) 101 | [2](http://www.ibase.ru/devinfo/DBMSTrees/9603d06.html) 102 | [3](http://threebit.net/tutorials/nestedset/tutorial1.html) 103 | [4](http://rdoc.info/github/rails/acts_as_nested_set/master/ActiveRecord/Acts/NestedSet/ClassMethods) 104 | [5](http://agilewebdevelopment.com/plugins/betternestedset) 105 | 106 | ## How to contribute 107 | 108 | If you find what you might think is a bug: 109 | 110 | 1. Check the GitHub issue tracker to see if anyone else has had the same issue. 111 | [Issues tracker](http://github.com/skyeagle/nested_set/issues) 112 | 2. If you don't see anything, create an issue with information on how to reproduce it. 113 | 114 | If you want to contribute an enhancement or a fix: 115 | 116 | 1. Fork the project on github. [http://github.com/skyeagle/nested_set](http://github.com/skyeagle/nested_set) 117 | 2. Make your changes with tests. 118 | 3. Commit the changes without making changes to the Rakefile, VERSION, or any other files that aren't related to your enhancement or fix 119 | 4. Send a pull request. 120 | 121 | Copyright ©2010 Collective Idea, released under the MIT license 122 | -------------------------------------------------------------------------------- /test/nested_set/helper_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class HelperTest < ActionView::TestCase 4 | include CollectiveIdea::Acts::NestedSet::Helper 5 | fixtures :categories 6 | 7 | def test_nested_set_options 8 | expected = [ 9 | [" Top Level", 1], 10 | ["- Child 1", 2], 11 | ['- Child 2', 3], 12 | ['-- Child 2.1', 4], 13 | ['- Child 3', 5], 14 | [" Top Level 2", 6] 15 | ] 16 | actual = nested_set_options(Category) do |c, level| 17 | "#{'-' * level} #{c.name}" 18 | end 19 | assert_equal expected, actual 20 | end 21 | 22 | def test_nested_set_options_from_a_query_with_roots_filter 23 | expected = [ 24 | [" Top Level", 1], 25 | ["- Child 1", 2], 26 | ["- Child 2", 3], 27 | ["-- Child 2.1", 4], 28 | ["- Child 3", 5], 29 | [" Top Level 2", 6] 30 | ] 31 | actual = nested_set_options(Category.roots) do |c, level| 32 | "#{'-' * level} #{c.name}" 33 | end 34 | assert_equal expected, actual 35 | end 36 | 37 | def test_nested_set_options_from_one_node 38 | expected = [ 39 | [" Top Level", 1], 40 | ["- Child 1", 2], 41 | ["- Child 2", 3], 42 | ["-- Child 2.1", 4], 43 | ["- Child 3", 5] 44 | ] 45 | actual = nested_set_options(Category.find 1) do |c, level| 46 | "#{'-' * level} #{c.name}" 47 | end 48 | assert_equal expected, actual 49 | end 50 | def test_nested_set_options_without_root 51 | expected = [ 52 | [" Child 1", 2], 53 | [' Child 2', 3], 54 | ['- Child 2.1', 4], 55 | [' Child 3', 5] 56 | ] 57 | actual = nested_set_options(categories(:top_level), nil, :include_root => false) do |c, level| 58 | "#{'-' * level} #{c.name}" 59 | end 60 | assert_equal expected, actual 61 | end 62 | 63 | def test_nested_set_options_with_mover 64 | expected = [ 65 | [" Top Level", 1], 66 | ["- Child 1", 2], 67 | ['- Child 3', 5], 68 | [" Top Level 2", 6] 69 | ] 70 | actual = nested_set_options(Category, categories(:child_2)) do |c, level| 71 | "#{'-' * level} #{c.name}" 72 | end 73 | assert_equal expected, actual 74 | end 75 | 76 | def test_build_node 77 | set = categories(:top_level).self_and_descendants 78 | expected = set.map{|i| [i.name, i.id]} 79 | 80 | hash = set.arrange 81 | actual = build_node(hash, lambda(&:lft)){|i, level| i.name } 82 | assert_equal expected, actual 83 | end 84 | 85 | def test_build_node_with_back_id_order 86 | expected = [ 87 | ["Top Level", 1], 88 | ["Child 3", 5], 89 | ["Child 2", 3], 90 | ["Child 2.1", 4], 91 | ["Child 1", 2] 92 | ] 93 | 94 | hash = categories(:top_level).self_and_descendants.arrange 95 | actual = build_node(hash, lambda{|x| -x.id}){|i, level| i.name } 96 | assert_equal expected, actual 97 | end 98 | 99 | def test_sorted_nested_set 100 | expected = [ 101 | [" Top Level 2", 6], 102 | [" Top Level", 1], 103 | ['- Child 3', 5], 104 | ['- Child 2', 3], 105 | ['-- Child 2.1', 4], 106 | ["- Child 1", 2] 107 | ] 108 | 109 | actual = sorted_nested_set_options(Category, lambda{|x| -x.id}) do |c, level| 110 | "#{'-' * level} #{c.name}" 111 | end 112 | assert_equal expected, actual 113 | end 114 | 115 | def test_sorted_nested_set_with_mover 116 | expected = [ 117 | [" Top Level 2", 6], 118 | [" Top Level", 1], 119 | ['- Child 3', 5], 120 | ["- Child 1", 2] 121 | ] 122 | 123 | actual = sorted_nested_set_options(Category, lambda{|x| -x.id}, categories(:child_2)) do |c, level| 124 | "#{'-' * level} #{c.name}" 125 | end 126 | assert_equal expected, actual 127 | end 128 | def test_render_tree 129 | html = render_tree(Category.arrange) do |category, child| 130 | concat content_tag(:li, category) 131 | concat child 132 | end 133 | assert_equal html, "
  • Top Level
    • Child 1
    • Child 2
      • Child 2.1
    • Child 3
  • Top Level 2
" 134 | end 135 | 136 | def test_sorted_render_tree 137 | html = render_tree(Category.arrange, :sort => lambda{|x| -x.id}) do |category, child| 138 | concat content_tag(:li, category) 139 | concat child 140 | end 141 | assert_equal html, "
  • Top Level 2
  • Top Level
    • Child 3
    • Child 2
      • Child 2.1
    • Child 1
" 142 | end 143 | 144 | def test_nested_set_options_does_not_call_to_a 145 | expected = [ 146 | ['Child 2', 3], 147 | ['Child 2.1', 4] 148 | ] 149 | actual = nested_set_options Category_NoToArray.find(3) do |c, l| 150 | c.name 151 | end 152 | assert_equal expected, actual 153 | end 154 | 155 | end 156 | -------------------------------------------------------------------------------- /lib/nested_set/helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module CollectiveIdea #:nodoc: 3 | module Acts #:nodoc: 4 | module NestedSet #:nodoc: 5 | # This module provides some helpers for the model classes using acts_as_nested_set. 6 | # It is included by default in all views. 7 | # 8 | module Helper 9 | # Returns options for select. 10 | # You can exclude some items from the tree. 11 | # You can pass a block receiving an item and returning the string displayed in the select. 12 | # 13 | # == Params 14 | # * +class_or_items+ - Class name or top level items 15 | # * +mover+ - The item that is being move, used to exclude impossible moves 16 | # * +options+ - hash of additional options 17 | # * +&block+ - a block that will be used to display: { |item| ... item.name } 18 | # 19 | # == Options 20 | # * +include_root+ - Include root object(s) in output. Default: true 21 | # 22 | # == Usage 23 | # 24 | # <%= f.select :parent_id, nested_set_options(Category, @category) {|i, level| 25 | # "#{'–' * level} #{i.name}" 26 | # } %> 27 | # 28 | def nested_set_options(class_or_items, mover = nil, options = {}) 29 | items = case 30 | when class_or_items.is_a?(Class) 31 | class_or_items.roots 32 | when class_or_items.respond_to?(:each) 33 | class_or_items 34 | else 35 | [class_or_items] 36 | end 37 | 38 | options.assert_valid_keys :include_root 39 | options.reverse_merge! :include_root => true 40 | 41 | result = [] 42 | items.each do |item| 43 | objects = options[:include_root] ? item.self_and_descendants : item.descendants 44 | objects.each_with_level do |i, level| 45 | if mover.nil? || mover.new_record? || mover.move_possible?(i) 46 | result.push([yield(i, level), i.id]) 47 | end 48 | end 49 | end 50 | result 51 | end 52 | 53 | # Returns options for select. 54 | # You can sort node's child by any method 55 | # You can exclude some items from the tree. 56 | # You can pass a block receiving an item and returning the string displayed in the select. 57 | # 58 | # == Params 59 | # * +class_or_item+ - Class name or top level times 60 | # * +sort_proc+ sorting proc for node's child, ex. lambda{|x| x.name} 61 | # * +mover+ - The item that is being move, used to exlude impossible moves 62 | # * +level+ - start level, :default => 0 63 | # * +&block+ - a block that will be used to display: { |itemi, level| "#{'–' * level} #{i.name}" } 64 | # == Usage 65 | # 66 | # <%= f.select :parent_id, sorted_nested_set_options(Category, lambda(&:name)) {|i, level| 67 | # "#{'–' * level} #{i.name}" 68 | # }) %> 69 | # 70 | # OR 71 | # 72 | # sort_method = lambda{|x| x.name.mb_chars.downcase } 73 | # 74 | # <%= f.select :parent_id, nested_set_options(Category, sort_method) {|i, level| 75 | # "#{'–' * level} #{i.name}" 76 | # }) %> 77 | # 78 | def sorted_nested_set_options(class_or_item, sort_proc, mover = nil, level = 0) 79 | hash = if class_or_item.is_a?(Class) 80 | class_or_item 81 | else 82 | class_or_item.self_and_descendants 83 | end.arrange 84 | build_node(hash, sort_proc, mover, level){|x, lvl| yield(x, lvl)} 85 | end 86 | 87 | def build_node(hash, sort_proc, mover = nil, level = nil) 88 | result = [] 89 | hash.keys.sort_by(&sort_proc).each do |node| 90 | if mover.nil? || mover.new_record? || mover.move_possible?(node) 91 | result.push([yield(node, level.to_i), node.id]) 92 | result.push(*build_node(hash[node], sort_proc, mover, level.to_i + 1){|x, lvl| yield(x, lvl)}) 93 | end 94 | end if hash.present? 95 | result 96 | end 97 | 98 | # Recursively render arranged nodes hash 99 | # 100 | # == Params 101 | # * +hash+ - Hash or arranged nodes, i.e. Category.arrange 102 | # * +options+ - HTML options for root ul node. 103 | # Given options with ex. :sort => lambda{|x| x.name} 104 | # you allow node sorting by analogy with sorted_nested_set_options helper method 105 | # * +&block+ - A block that will be used to display node 106 | # 107 | # == Usage 108 | # 109 | # arranged_nodes = Category.arrange 110 | # 111 | # <%= render_tree arranged_nodes do |node, child| %> 112 | #
  • <%= node.name %>
  • 113 | # <%= child %> 114 | # <% end %> 115 | # 116 | def render_tree hash, options = {}, &block 117 | sort_proc = options.delete :sort 118 | tag = options.delete(:tag) || :ul 119 | 120 | content_tag tag, options do 121 | hash.keys.sort_by(&sort_proc).each do |node| 122 | block.call node, render_tree(hash[node], :sort => sort_proc, &block) 123 | end 124 | end if hash.present? 125 | end 126 | 127 | end 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/nested_set/base.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module CollectiveIdea #:nodoc: 3 | module Acts #:nodoc: 4 | module NestedSet #:nodoc: 5 | module Base 6 | def self.included(base) 7 | base.extend(SingletonMethods) 8 | end 9 | 10 | # This acts provides Nested Set functionality. Nested Set is a smart way to implement 11 | # an _ordered_ tree, with the added feature that you can select the children and all of their 12 | # descendants with a single query. The drawback is that insertion or move need some complex 13 | # sql queries. But everything is done here by this module! 14 | # 15 | # Nested sets are appropriate each time you want either an orderd tree (menus, 16 | # commercial categories) or an efficient way of querying big trees (threaded posts). 17 | # 18 | # == API 19 | # 20 | # Methods names are aligned with acts_as_tree as much as possible to make replacment from one 21 | # by another easier. 22 | # 23 | # item.children.create(:name => "child1") 24 | # 25 | module SingletonMethods 26 | # Configuration options are: 27 | # 28 | # * +:primary_key_column+ - specifies the column name to use for keeping the position integer (default: id) 29 | # * +:parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id) 30 | # * +:left_column+ - column name for left boundry data, default "lft" 31 | # * +:right_column+ - column name for right boundry data, default "rgt" 32 | # * +:depth_column+ - column name for level cache data, default "depth" 33 | # * +:scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id" 34 | # (if it hasn't been already) and use that as the foreign key restriction. You 35 | # can also pass an array to scope by multiple attributes. 36 | # Example: acts_as_nested_set :scope => [:notable_id, :notable_type] 37 | # * +:dependent+ - behavior for cascading destroy. If set to :destroy, all the 38 | # child objects are destroyed alongside this object by calling their destroy 39 | # method. If set to :delete_all (default), all the child objects are deleted 40 | # without calling their destroy method. 41 | # 42 | # See CollectiveIdea::Acts::NestedSet::ClassMethods for a list of class methods and 43 | # CollectiveIdea::Acts::NestedSet::InstanceMethods for a list of instance methods added 44 | # to acts_as_nested_set models 45 | def acts_as_nested_set(options = {}) 46 | options = { 47 | :primary_key_column => self.primary_key, 48 | :parent_column => 'parent_id', 49 | :left_column => 'lft', 50 | :right_column => 'rgt', 51 | :depth_column => 'depth', 52 | :dependent => :delete_all 53 | }.merge(options) 54 | 55 | if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/ 56 | options[:scope] = "#{options[:scope]}_id".intern 57 | end 58 | 59 | class_attribute :acts_as_nested_set_options 60 | self.acts_as_nested_set_options = options 61 | 62 | unless self.is_a?(ClassMethods) 63 | include Comparable 64 | include Columns 65 | include InstanceMethods 66 | 67 | include Depth 68 | include Descendants 69 | 70 | extend Columns 71 | extend ClassMethods 72 | 73 | belongs_to :parent, :class_name => self.base_class.to_s, 74 | :foreign_key => parent_column_name 75 | has_many :children, :class_name => self.base_class.to_s, 76 | :foreign_key => parent_column_name, :order => quoted_left_column_name 77 | 78 | attr_accessor :skip_before_destroy 79 | 80 | # no bulk assignment 81 | if accessible_attributes.blank? 82 | attr_protected left_column_name.intern, right_column_name.intern 83 | end 84 | 85 | before_create :set_default_left_and_right 86 | before_save :store_new_parent 87 | after_save :move_to_new_parent 88 | before_destroy :destroy_descendants 89 | 90 | # no assignment to structure fields 91 | [left_column_name, right_column_name].each do |column| 92 | module_eval <<-"end_eval", __FILE__, __LINE__ 93 | def #{column}=(x) 94 | raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead." 95 | end 96 | end_eval 97 | end 98 | 99 | scope :roots, lambda { 100 | where(parent_column_name => nil).order(quoted_left_column_name) 101 | } 102 | scope :leaves, lambda { 103 | where("#{quoted_right_column_name} - #{quoted_left_column_name} = 1"). 104 | order(quoted_left_column_name) 105 | } 106 | scope :nodes, lambda { 107 | where("(#{quoted_right_column_name} - #{quoted_left_column_name} - 1) / 2 != 0"). 108 | order(quoted_left_column_name) 109 | } 110 | scope :with_depth, proc {|level| where(:"#{depth_column_name}" => level).order(quoted_left_column_name) } 111 | 112 | define_callbacks :move, :terminator => "result == false" 113 | end 114 | end 115 | end 116 | 117 | module ClassMethods 118 | 119 | # Returns the first root 120 | def root 121 | roots.first 122 | end 123 | 124 | # Returns arranged nodes hash. 125 | # I.e. you have this tree: 126 | # 127 | # 1 128 | # 2 129 | # 3 130 | # 4 131 | # 5 132 | # 6 133 | # 7 134 | # 135 | # Hash will looks like: 136 | # 137 | # {1 => {2 => {}, 3 => {4 => {5 => {}}, 6 => {}}}, 7 => {}} 138 | # 139 | # == Usage: 140 | # 141 | # Categories.arrange 142 | # Categories.find(42).children.arrange 143 | # Categories.find(42).descendants.arrange 144 | # Categories.find(42).self_and_descendants.arrange 145 | # 146 | # This arranged hash can be rendered with recursive render_tree helper 147 | def arrange 148 | arranged = ActiveSupport::OrderedHash.new 149 | insertion_points = [arranged] 150 | depth = 0 151 | order("#{quoted_table_name}.#{quoted_left_column_name}").each_with_level do |node, level| 152 | next if level > depth && insertion_points.last.keys.last && node.parent_id != insertion_points.last.keys.last.id 153 | insertion_points.push insertion_points.last.values.last if level > depth 154 | (depth - level).times { insertion_points.pop } if level < depth 155 | insertion_points.last.merge! node => ActiveSupport::OrderedHash.new 156 | depth = level 157 | end 158 | arranged 159 | end 160 | 161 | def valid? 162 | left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid? 163 | end 164 | 165 | def left_and_rights_valid? 166 | joins("LEFT OUTER JOIN #{quoted_table_name} AS parent ON " + 167 | "#{quoted_table_name}.#{quoted_parent_column_name} = parent.#{primary_key}"). 168 | where( 169 | "#{quoted_table_name}.#{quoted_left_column_name} IS NULL OR " + 170 | "#{quoted_table_name}.#{quoted_right_column_name} IS NULL OR " + 171 | "#{quoted_table_name}.#{quoted_left_column_name} >= " + 172 | "#{quoted_table_name}.#{quoted_right_column_name} OR " + 173 | "(#{quoted_table_name}.#{quoted_parent_column_name} IS NOT NULL AND " + 174 | "(#{quoted_table_name}.#{quoted_left_column_name} <= parent.#{quoted_left_column_name} OR " + 175 | "#{quoted_table_name}.#{quoted_right_column_name} >= parent.#{quoted_right_column_name}))" 176 | ).exists? == false 177 | end 178 | 179 | def no_duplicates_for_columns? 180 | scope_string = scope_column_names.map do |c| 181 | connection.quote_column_name(c) 182 | end.push(nil).join(", ") 183 | [quoted_left_column_name, quoted_right_column_name].all? do |column| 184 | # No duplicates 185 | unscoped.first( 186 | :select => "#{scope_string}#{column}, COUNT(#{column})", 187 | :group => "#{scope_string}#{column}", 188 | :having => "COUNT(#{column}) > 1" 189 | ).nil? 190 | end 191 | end 192 | 193 | # Wrapper for each_root_valid? that can deal with scope. 194 | def all_roots_valid? 195 | if acts_as_nested_set_options[:scope] 196 | roots.group_by{|record| scope_column_names.collect{|col| record.send(col.to_sym)}}.all? do |scope, grouped_roots| 197 | each_root_valid?(grouped_roots) 198 | end 199 | else 200 | each_root_valid?(roots) 201 | end 202 | end 203 | 204 | def each_root_valid?(roots_to_validate) 205 | left = right = 0 206 | roots_to_validate.all? do |root| 207 | (root.left > left && root.right > right).tap do 208 | left = root.left 209 | right = root.right 210 | end 211 | end 212 | end 213 | 214 | # Rebuilds the left & rights if unset or invalid. Also very useful for converting from acts_as_tree. 215 | def rebuild! 216 | # Don't rebuild a valid tree. 217 | return true if valid? 218 | 219 | indices = {} 220 | 221 | set_left_and_rights = lambda do |node| 222 | node_scope = scope_for_rebuild(node) 223 | # set left 224 | node[left_column_name] = indices[node_scope] += 1 225 | # find 226 | nodes_for_rebuild(node, node_scope).each{ |n| set_left_and_rights.call(n) } 227 | # set right 228 | node[right_column_name] = indices[node_scope] += 1 229 | node.save(:validate => false) 230 | end 231 | 232 | # Find root node(s) 233 | root_nodes_for_rebuild.each do |root_node| 234 | node_scope = scope_for_rebuild(root_node) 235 | # setup index for this scope 236 | indices[node_scope] ||= 0 237 | set_left_and_rights.call(root_node) 238 | end 239 | end 240 | 241 | # Iterates over tree elements and determines the current level in the tree. 242 | # Only accepts default ordering, odering by an other column than lft 243 | # does not work. This method is much more efficent than calling level 244 | # because it doesn't require any additional database queries. 245 | # 246 | # Example: 247 | # Category.each_with_level(Category.root.self_and_descendants) do |o, level| 248 | # 249 | 250 | def each_with_level(objects = nil) 251 | levels = [] 252 | (objects || scoped).each do |i| 253 | if level = levels.index(i.parent_id) 254 | levels.slice!((level + 1)..-1) 255 | else 256 | levels << i.parent_id 257 | level = levels.size - 1 258 | end 259 | yield(i, level) 260 | end 261 | end 262 | 263 | def map_with_level(objects = nil) 264 | result = [] 265 | each_with_level objects do |object, level| 266 | result << yield(object, level) 267 | end 268 | result 269 | end 270 | 271 | def before_move(*args, &block) 272 | set_callback :move, :before, *args, &block 273 | end 274 | 275 | def after_move(*args, &block) 276 | set_callback :move, :after, *args, &block 277 | end 278 | 279 | private 280 | 281 | def scope_for_rebuild(node) 282 | scope_column_names.inject({}) do |hash, column_name| 283 | hash[column_name] = node.send(column_name.to_sym) 284 | hash 285 | end 286 | end 287 | 288 | def nodes_for_rebuild(node, node_scope) 289 | where(parent_column_name => node.id). 290 | where(node_scope). 291 | order(order_for_rebuild). 292 | all 293 | end 294 | 295 | def root_nodes_for_rebuild 296 | where(parent_column_name => nil). 297 | order(order_for_rebuild). 298 | all 299 | end 300 | 301 | def order_for_rebuild 302 | "#{quoted_left_column_name}, #{quoted_right_column_name}, #{primary_key_column_name}" 303 | end 304 | 305 | end 306 | 307 | # Mixed into both classes and instances to provide easy access to the column names 308 | module Columns 309 | def left_column_name 310 | acts_as_nested_set_options[:left_column] 311 | end 312 | 313 | def right_column_name 314 | acts_as_nested_set_options[:right_column] 315 | end 316 | 317 | def parent_column_name 318 | acts_as_nested_set_options[:parent_column] 319 | end 320 | 321 | def scope_column_names 322 | Array(acts_as_nested_set_options[:scope]) 323 | end 324 | 325 | def depth_column_name 326 | acts_as_nested_set_options[:depth_column] 327 | end 328 | 329 | def primary_key_column_name 330 | acts_as_nested_set_options[:primary_key_column] 331 | end 332 | 333 | def quoted_left_column_name 334 | connection.quote_column_name(left_column_name) 335 | end 336 | 337 | def quoted_right_column_name 338 | connection.quote_column_name(right_column_name) 339 | end 340 | 341 | def quoted_parent_column_name 342 | connection.quote_column_name(parent_column_name) 343 | end 344 | 345 | def quoted_scope_column_names 346 | scope_column_names.collect {|column_name| connection.quote_column_name(column_name) } 347 | end 348 | 349 | def quoted_depth_column_name 350 | connection.quote_column_name(depth_column_name) 351 | end 352 | 353 | def quoted_primary_key_column_name 354 | connection.quote_column_name(primary_key_column_name) 355 | end 356 | end 357 | 358 | # Any instance method that returns a collection makes use of Rails 2.1's named_scope (which is bundled for Rails 2.0), so it can be treated as a finder. 359 | # 360 | # category.self_and_descendants.count 361 | # category.ancestors.find(:all, :conditions => "name like '%foo%'") 362 | module InstanceMethods 363 | # Value of the parent column 364 | def parent_id 365 | self[parent_column_name] 366 | end 367 | 368 | # Value of the left column 369 | def left 370 | self[left_column_name] 371 | end 372 | 373 | # Value of the right column 374 | def right 375 | self[right_column_name] 376 | end 377 | 378 | # Returns true if this is a root node. 379 | def root? 380 | parent_id.nil? 381 | end 382 | 383 | def leaf? 384 | !new_record? && right - left == 1 385 | end 386 | 387 | # Returns true is this is a child node 388 | def child? 389 | !parent_id.nil? 390 | end 391 | 392 | # order by left column 393 | def <=>(x) 394 | left <=> x.left 395 | end 396 | 397 | # Redefine to act like active record 398 | def ==(comparison_object) 399 | comparison_object.equal?(self) || 400 | (comparison_object.instance_of?(self.class) && 401 | comparison_object.id == id && 402 | !comparison_object.new_record?) 403 | end 404 | 405 | # Returns root 406 | def root 407 | self_and_ancestors.first 408 | end 409 | 410 | # Returns the array of all children and self 411 | def self_and_children 412 | nested_set_scope.where("#{q_parent} = :id or #{q_primary_key} = :id", :id => self) 413 | end 414 | 415 | # Returns the array of all parents and self 416 | def self_and_ancestors 417 | nested_set_scope.where("#{q_left} <= ? AND #{q_right} >= ?", left, right) 418 | end 419 | 420 | # Returns an array of all parents 421 | def ancestors 422 | without_self self_and_ancestors 423 | end 424 | 425 | # Returns the array of all children of the parent, including self 426 | def self_and_siblings 427 | nested_set_scope.where(parent_column_name => parent_id) 428 | end 429 | 430 | # Returns the array of all children of the parent, except self 431 | def siblings 432 | without_self self_and_siblings 433 | end 434 | 435 | # Returns a set of all of its nested children which do not have children 436 | def leaves 437 | descendants.where("#{q_right} - #{q_left} = 1") 438 | end 439 | 440 | # Returns the level of this object in the tree 441 | # root level is 0 442 | def level 443 | parent_id.nil? ? 0 : ancestors.count 444 | end 445 | 446 | # Returns a set of itself and all of its nested children 447 | def self_and_descendants 448 | nested_set_scope.where("#{q_left} >= ? AND #{q_right} <= ?", left, right) 449 | end 450 | 451 | # Returns a set of all of its children and nested children 452 | def descendants 453 | without_self self_and_descendants 454 | end 455 | 456 | def is_descendant_of?(other) 457 | other.left < self.left && self.left < other.right && same_scope?(other) 458 | end 459 | 460 | def is_or_is_descendant_of?(other) 461 | other.left <= self.left && self.left < other.right && same_scope?(other) 462 | end 463 | 464 | def is_ancestor_of?(other) 465 | self.left < other.left && other.left < self.right && same_scope?(other) 466 | end 467 | 468 | def is_or_is_ancestor_of?(other) 469 | self.left <= other.left && other.left < self.right && same_scope?(other) 470 | end 471 | 472 | # Check if other model is in the same scope 473 | def same_scope?(other) 474 | scope_column_names.all? do |attr| 475 | self.send(attr) == other.send(attr) 476 | end 477 | end 478 | 479 | # Find the first sibling to the left 480 | def left_sibling 481 | siblings.where("#{q_left} < ?", left).last 482 | end 483 | 484 | # Find the first sibling to the right 485 | def right_sibling 486 | siblings.where("#{q_left} > ?", left).first 487 | end 488 | 489 | # Lock rows whose lfts and rgts are to be updated 490 | def lock_check(cond=nil) 491 | nested_set_scope.select(primary_key_column_name).where(cond).lock 492 | end 493 | 494 | # Shorthand method for finding the left sibling and moving to the left of it. 495 | def move_left 496 | move_to_left_of left_sibling 497 | end 498 | 499 | # Shorthand method for finding the right sibling and moving to the right of it. 500 | def move_right 501 | move_to_right_of right_sibling 502 | end 503 | 504 | # Move the node to the left of another node (you can pass id only) 505 | def move_to_left_of(node) 506 | move_to node, :left 507 | end 508 | 509 | # Move the node to the left of another node (you can pass id only) 510 | def move_to_right_of(node) 511 | move_to node, :right 512 | end 513 | 514 | # Move the node to the child of another node (you can pass id only) 515 | def move_to_child_of(node) 516 | move_to node, :child 517 | end 518 | 519 | # Move the node to root nodes 520 | def move_to_root 521 | move_to nil, :root 522 | end 523 | 524 | def move_possible?(target) 525 | self != target && # Can't target self 526 | same_scope?(target) && # can't be in different scopes 527 | # !(left..right).include?(target.left..target.right) # this needs tested more 528 | # detect impossible move 529 | !((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right)) 530 | end 531 | 532 | def to_text 533 | self.class.map_with_level(self_and_descendants) do |node,level| 534 | "#{'*'*(level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})" 535 | end.join("\n") 536 | end 537 | 538 | protected 539 | 540 | def q_left 541 | "#{self.class.quoted_table_name}.#{quoted_left_column_name}" 542 | end 543 | 544 | def q_right 545 | "#{self.class.quoted_table_name}.#{quoted_right_column_name}" 546 | end 547 | 548 | def q_parent 549 | "#{self.class.quoted_table_name}.#{quoted_parent_column_name}" 550 | end 551 | 552 | def q_primary_key 553 | "#{self.class.quoted_table_name}.#{quoted_primary_key_column_name}" 554 | end 555 | 556 | def without_self(scope) 557 | scope.where("#{q_primary_key} != ?", self) 558 | end 559 | 560 | # All nested set queries should use this nested_set_scope, which performs finds on 561 | # the base ActiveRecord class, using the :scope declared in the acts_as_nested_set 562 | # declaration. 563 | def nested_set_scope 564 | conditions = scope_column_names.inject({}) do |cnd, attr| 565 | cnd.merge attr => self[attr] 566 | end 567 | 568 | self.class.base_class.order(q_left).where(conditions) 569 | end 570 | 571 | def store_new_parent 572 | @move_to_new_parent_id = send("#{parent_column_name}_changed?") ? parent_id : false 573 | true # force callback to return true 574 | end 575 | 576 | def move_to_new_parent 577 | if @move_to_new_parent_id.nil? 578 | move_to_root 579 | elsif @move_to_new_parent_id 580 | move_to_child_of(@move_to_new_parent_id) 581 | end 582 | end 583 | 584 | # on creation, set automatically lft and rgt to the end of the tree 585 | def set_default_left_and_right 586 | maxright = nested_set_scope.maximum(right_column_name) || 0 587 | # adds the new node to the right of all existing nodes 588 | self[left_column_name] = maxright + 1 589 | self[right_column_name] = maxright + 2 590 | end 591 | 592 | # Prunes a branch off of the tree, shifting all of the elements on the right 593 | # back to the left so the counts still work. 594 | def destroy_descendants 595 | return if right.nil? || left.nil? || skip_before_destroy 596 | self.class.base_class.transaction do 597 | lock_check(["#{quoted_right_column_name} > ?", right]) 598 | reload_nested_set 599 | case destroy_method 600 | when :delete_all then 601 | nested_set_scope.delete_all(["#{q_left} > ? AND #{q_right} < ?", left, right]) 602 | 603 | else 604 | descendants.each do |model| 605 | model.skip_before_destroy = true 606 | model.respond_to?(destroy_method) ? model.send(destroy_method) : raise(NoMethodError, "#{model} does not have a method #{destroy_method}") 607 | end 608 | 609 | end 610 | 611 | # update lefts and rights for remaining nodes 612 | diff = right - left + 1 613 | nested_set_scope.update_all( 614 | ["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff], 615 | ["#{quoted_left_column_name} > ?", right] 616 | ) 617 | nested_set_scope.update_all( 618 | ["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff], 619 | ["#{quoted_right_column_name} > ?", right] 620 | ) 621 | 622 | # Don't allow multiple calls to destroy to corrupt the set 623 | self.skip_before_destroy = true 624 | end 625 | end 626 | 627 | # just a shortcut 628 | def destroy_method 629 | acts_as_nested_set_options[:dependent] 630 | end 631 | 632 | # reload left, right, and parent 633 | def reload_nested_set 634 | reload(:select => "#{quoted_left_column_name}, " + 635 | "#{quoted_right_column_name}, #{quoted_parent_column_name}") 636 | end 637 | 638 | def move_to(target, position) 639 | raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record? 640 | 641 | res = run_callbacks :move do 642 | transaction do 643 | if target.is_a? self.class.base_class 644 | target.reload_nested_set 645 | elsif position != :root 646 | # load object if node is not an object 647 | target = nested_set_scope.find(target) 648 | end 649 | self.reload_nested_set 650 | 651 | unless position == :root || move_possible?(target) 652 | raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree." 653 | end 654 | 655 | bound = case position 656 | when :child; target[right_column_name] 657 | when :left; target[left_column_name] 658 | when :right; target[right_column_name] + 1 659 | when :root; 1 660 | else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)." 661 | end 662 | 663 | if bound > self[right_column_name] 664 | bound = bound - 1 665 | other_bound = self[right_column_name] + 1 666 | else 667 | other_bound = self[left_column_name] - 1 668 | end 669 | 670 | # there would be no change 671 | return if bound == self[right_column_name] || bound == self[left_column_name] 672 | 673 | # we have defined the boundaries of two non-overlapping intervals, 674 | # so sorting puts both the intervals and their boundaries in order 675 | a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort 676 | 677 | # select the rows in the model between a and d, and apply a lock 678 | cond = ["#{quoted_left_column_name} >= :a and #{quoted_right_column_name} <= :d", { :a => a, :d => d }] 679 | lock_check(cond) 680 | 681 | new_parent = case position 682 | when :child; target.id 683 | when :root; nil 684 | else target[parent_column_name] 685 | end 686 | 687 | nested_set_scope.update_all([ 688 | "#{quoted_left_column_name} = CASE " + 689 | "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " + 690 | "THEN #{quoted_left_column_name} + :d - :b " + 691 | "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " + 692 | "THEN #{quoted_left_column_name} + :a - :c " + 693 | "ELSE #{quoted_left_column_name} END, " + 694 | "#{quoted_right_column_name} = CASE " + 695 | "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " + 696 | "THEN #{quoted_right_column_name} + :d - :b " + 697 | "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " + 698 | "THEN #{quoted_right_column_name} + :a - :c " + 699 | "ELSE #{quoted_right_column_name} END, " + 700 | "#{quoted_parent_column_name} = CASE " + 701 | "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " + 702 | "ELSE #{quoted_parent_column_name} END", 703 | {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent} 704 | ]) 705 | end 706 | target.reload_nested_set if target 707 | self.reload_nested_set 708 | self.update_depth if has_depth_column? 709 | end 710 | end 711 | end 712 | end # Base 713 | end # NestedSet 714 | end # Acts 715 | end # CollectiveIdea 716 | -------------------------------------------------------------------------------- /test/nested_set_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class Note < ActiveRecord::Base 4 | acts_as_nested_set :scope => [:notable_id, :notable_type] 5 | end 6 | 7 | class Default < ActiveRecord::Base 8 | self.table_name = 'categories' 9 | self.primary_key = 'not_default_id_name' 10 | acts_as_nested_set 11 | end 12 | 13 | class ScopedCategory < ActiveRecord::Base 14 | self.table_name = 'categories' 15 | self.primary_key = 'not_default_id_name' 16 | acts_as_nested_set :scope => :organization 17 | end 18 | 19 | class RenamedColumns < ActiveRecord::Base 20 | acts_as_nested_set :parent_column => 'mother_id', :left_column => 'red', :right_column => 'black', :depth_column => 'level' 21 | end 22 | 23 | class NestedSetTest < ActiveSupport::TestCase 24 | 25 | def test_left_column_default 26 | assert_equal 'lft', Default.acts_as_nested_set_options[:left_column] 27 | end 28 | 29 | def test_right_column_default 30 | assert_equal 'rgt', Default.acts_as_nested_set_options[:right_column] 31 | end 32 | 33 | def test_parent_column_default 34 | assert_equal 'parent_id', Default.acts_as_nested_set_options[:parent_column] 35 | end 36 | 37 | def test_depth_column_default 38 | assert_equal 'depth', Default.acts_as_nested_set_options[:depth_column] 39 | end 40 | 41 | def test_scope_default 42 | assert_nil Default.acts_as_nested_set_options[:scope] 43 | end 44 | 45 | def test_left_column_default 46 | assert_equal 'lft', Default.left_column_name 47 | assert_equal 'lft', Default.new.left_column_name 48 | assert_equal 'red', RenamedColumns.left_column_name 49 | assert_equal 'red', RenamedColumns.new.left_column_name 50 | end 51 | 52 | def test_right_column_name 53 | assert_equal 'rgt', Default.right_column_name 54 | assert_equal 'rgt', Default.new.right_column_name 55 | assert_equal 'black', RenamedColumns.right_column_name 56 | assert_equal 'black', RenamedColumns.new.right_column_name 57 | end 58 | 59 | def test_parent_column_name 60 | assert_equal 'parent_id', Default.parent_column_name 61 | assert_equal 'parent_id', Default.new.parent_column_name 62 | assert_equal 'mother_id', RenamedColumns.parent_column_name 63 | assert_equal 'mother_id', RenamedColumns.new.parent_column_name 64 | end 65 | 66 | def test_depth_column_name 67 | assert_equal 'depth', Default.depth_column_name 68 | assert_equal 'depth', Default.new.depth_column_name 69 | assert_equal 'level', RenamedColumns.depth_column_name 70 | assert_equal 'level', RenamedColumns.new.depth_column_name 71 | end 72 | 73 | def test_creation_with_altered_column_names 74 | assert_nothing_raised do 75 | RenamedColumns.create!() 76 | end 77 | end 78 | 79 | def test_quoted_left_column_name 80 | quoted = Default.connection.quote_column_name('lft') 81 | assert_equal quoted, Default.quoted_left_column_name 82 | assert_equal quoted, Default.new.quoted_left_column_name 83 | end 84 | 85 | def test_quoted_right_column_name 86 | quoted = Default.connection.quote_column_name('rgt') 87 | assert_equal quoted, Default.quoted_right_column_name 88 | assert_equal quoted, Default.new.quoted_right_column_name 89 | end 90 | 91 | def test_quoted_depth_column_name 92 | quoted = Default.connection.quote_column_name('depth') 93 | assert_equal quoted, Default.quoted_depth_column_name 94 | assert_equal quoted, Default.new.quoted_depth_column_name 95 | end 96 | 97 | def test_left_column_protected_from_assignment 98 | assert_raises(ActiveRecord::ActiveRecordError) { Category.new.lft = 1 } 99 | end 100 | 101 | def test_right_column_protected_from_assignment 102 | assert_raises(ActiveRecord::ActiveRecordError) { Category.new.rgt = 1 } 103 | end 104 | 105 | def test_colums_protected_on_initialize 106 | c = Category.new(:lft => 1, :rgt => 2) 107 | assert_nil c.lft 108 | assert_nil c.rgt 109 | end 110 | 111 | def test_scoped_appends_id 112 | assert_equal :organization_id, ScopedCategory.acts_as_nested_set_options[:scope] 113 | end 114 | 115 | def test_roots_class_method 116 | assert_equal Category.find_all_by_parent_id(nil), Category.roots 117 | end 118 | 119 | def test_root_class_method 120 | assert_equal categories(:top_level), Category.root 121 | end 122 | 123 | def test_root 124 | assert_equal categories(:top_level), categories(:child_3).root 125 | end 126 | 127 | def test_root? 128 | assert categories(:top_level).root? 129 | assert categories(:top_level_2).root? 130 | end 131 | 132 | def test_leaves_class_method 133 | assert_equal Category.find(:all, :conditions => "#{Category.right_column_name} - #{Category.left_column_name} = 1"), Category.leaves 134 | assert_equal Category.leaves.count, 4 135 | assert Category.leaves.include?(categories(:child_1)) 136 | assert Category.leaves.include?(categories(:child_2_1)) 137 | assert Category.leaves.include?(categories(:child_3)) 138 | assert Category.leaves.include?(categories(:top_level_2)) 139 | end 140 | 141 | def test_nodes_class_method 142 | assert_equal Category.find(:all, :conditions => "(#{Category.right_column_name} - #{Category.left_column_name} - 1) / 2 != 0"), Category.nodes 143 | assert_equal Category.nodes.count, 2 144 | assert Category.nodes.include?(categories(:top_level)) 145 | assert Category.nodes.include?(categories(:child_2)) 146 | end 147 | 148 | def test_leaf 149 | assert categories(:child_1).leaf? 150 | assert categories(:child_2_1).leaf? 151 | assert categories(:child_3).leaf? 152 | assert categories(:top_level_2).leaf? 153 | 154 | assert !categories(:top_level).leaf? 155 | assert !categories(:child_2).leaf? 156 | assert !Category.new.leaf? 157 | end 158 | 159 | def test_parent 160 | assert_equal categories(:child_2), categories(:child_2_1).parent 161 | end 162 | 163 | def test_self_and_chilren 164 | node = categories(:top_level) 165 | self_and_children = [node, categories(:child_1), categories(:child_2), categories(:child_3)] 166 | assert_equal self_and_children, node.self_and_children 167 | end 168 | 169 | def test_self_and_ancestors 170 | child = categories(:child_2_1) 171 | self_and_ancestors = [categories(:top_level), categories(:child_2), child] 172 | assert_equal self_and_ancestors, child.self_and_ancestors 173 | end 174 | 175 | def test_ancestors 176 | child = categories(:child_2_1) 177 | ancestors = [categories(:top_level), categories(:child_2)] 178 | assert_equal ancestors, child.ancestors 179 | end 180 | 181 | def test_self_and_siblings 182 | child = categories(:child_2) 183 | self_and_siblings = [categories(:child_1), child, categories(:child_3)] 184 | assert_equal self_and_siblings, child.self_and_siblings 185 | assert_nothing_raised do 186 | tops = [categories(:top_level), categories(:top_level_2)] 187 | assert_equal tops, categories(:top_level).self_and_siblings 188 | end 189 | end 190 | 191 | def test_siblings 192 | child = categories(:child_2) 193 | siblings = [categories(:child_1), categories(:child_3)] 194 | assert_equal siblings, child.siblings 195 | end 196 | 197 | def test_leaves 198 | leaves = [categories(:child_1), categories(:child_2_1), categories(:child_3)] 199 | assert_equal categories(:top_level).leaves, leaves 200 | end 201 | 202 | def test_level 203 | assert_equal 0, categories(:top_level).level 204 | assert_equal 1, categories(:child_1).level 205 | assert_equal 2, categories(:child_2_1).level 206 | end 207 | 208 | def test_depth 209 | assert_equal nil, categories(:top_level).depth 210 | assert_equal 1, categories(:child_1).depth 211 | assert_equal 2, categories(:child_2_1).depth 212 | end 213 | 214 | def test_depth_after_move 215 | assert_equal nil, categories(:top_level).depth 216 | assert_equal 1, categories(:child_2).depth 217 | 218 | categories(:top_level).move_to_child_of(categories(:top_level_2)) 219 | 220 | assert_equal 1, categories(:top_level).reload.depth 221 | assert_equal 2, categories(:child_2).reload.depth 222 | end 223 | 224 | def test_has_children? 225 | assert categories(:child_2_1).children.empty? 226 | assert !categories(:child_2).children.empty? 227 | assert !categories(:top_level).children.empty? 228 | end 229 | 230 | def test_self_and_descendents 231 | parent = categories(:top_level) 232 | self_and_descendants = [parent, categories(:child_1), categories(:child_2), 233 | categories(:child_2_1), categories(:child_3)] 234 | assert_equal self_and_descendants, parent.self_and_descendants 235 | assert_equal self_and_descendants, parent.self_and_descendants.count 236 | end 237 | 238 | def test_descendents 239 | lawyers = Category.create!(:name => "lawyers") 240 | us = Category.create!(:name => "United States") 241 | us.move_to_child_of(lawyers) 242 | patent = Category.create!(:name => "Patent Law") 243 | patent.move_to_child_of(us) 244 | lawyers.reload 245 | 246 | assert_equal 1, lawyers.children.size 247 | assert_equal 1, us.children.size 248 | assert_equal 2, lawyers.descendants.size 249 | end 250 | 251 | def test_self_and_descendents 252 | parent = categories(:top_level) 253 | descendants = [categories(:child_1), categories(:child_2), 254 | categories(:child_2_1), categories(:child_3)] 255 | assert_equal descendants, parent.descendants 256 | end 257 | 258 | def test_children 259 | category = categories(:top_level) 260 | category.children.each {|c| assert_equal category.id, c.parent_id } 261 | end 262 | 263 | def test_order_of_children 264 | categories(:child_2).move_left 265 | assert_equal categories(:child_2), categories(:top_level).children[0] 266 | assert_equal categories(:child_1), categories(:top_level).children[1] 267 | assert_equal categories(:child_3), categories(:top_level).children[2] 268 | end 269 | 270 | def test_is_or_is_ancestor_of? 271 | assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_1)) 272 | assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_2_1)) 273 | assert categories(:child_2).is_or_is_ancestor_of?(categories(:child_2_1)) 274 | assert !categories(:child_2_1).is_or_is_ancestor_of?(categories(:child_2)) 275 | assert !categories(:child_1).is_or_is_ancestor_of?(categories(:child_2)) 276 | assert categories(:child_1).is_or_is_ancestor_of?(categories(:child_1)) 277 | end 278 | 279 | def test_is_ancestor_of? 280 | assert categories(:top_level).is_ancestor_of?(categories(:child_1)) 281 | assert categories(:top_level).is_ancestor_of?(categories(:child_2_1)) 282 | assert categories(:child_2).is_ancestor_of?(categories(:child_2_1)) 283 | assert !categories(:child_2_1).is_ancestor_of?(categories(:child_2)) 284 | assert !categories(:child_1).is_ancestor_of?(categories(:child_2)) 285 | assert !categories(:child_1).is_ancestor_of?(categories(:child_1)) 286 | end 287 | 288 | def test_is_or_is_ancestor_of_with_scope 289 | root = ScopedCategory.root 290 | child = root.children.first 291 | assert root.is_or_is_ancestor_of?(child) 292 | child.update_attribute :organization_id, 'different' 293 | assert !root.is_or_is_ancestor_of?(child) 294 | end 295 | 296 | def test_is_or_is_descendant_of? 297 | assert categories(:child_1).is_or_is_descendant_of?(categories(:top_level)) 298 | assert categories(:child_2_1).is_or_is_descendant_of?(categories(:top_level)) 299 | assert categories(:child_2_1).is_or_is_descendant_of?(categories(:child_2)) 300 | assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_2_1)) 301 | assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_1)) 302 | assert categories(:child_1).is_or_is_descendant_of?(categories(:child_1)) 303 | end 304 | 305 | def test_is_descendant_of? 306 | assert categories(:child_1).is_descendant_of?(categories(:top_level)) 307 | assert categories(:child_2_1).is_descendant_of?(categories(:top_level)) 308 | assert categories(:child_2_1).is_descendant_of?(categories(:child_2)) 309 | assert !categories(:child_2).is_descendant_of?(categories(:child_2_1)) 310 | assert !categories(:child_2).is_descendant_of?(categories(:child_1)) 311 | assert !categories(:child_1).is_descendant_of?(categories(:child_1)) 312 | end 313 | 314 | def test_is_or_is_descendant_of_with_scope 315 | root = ScopedCategory.root 316 | child = root.children.first 317 | assert child.is_or_is_descendant_of?(root) 318 | child.update_attribute :organization_id, 'different' 319 | assert !child.is_or_is_descendant_of?(root) 320 | end 321 | 322 | def test_same_scope? 323 | root = ScopedCategory.root 324 | child = root.children.first 325 | assert child.same_scope?(root) 326 | child.update_attribute :organization_id, 'different' 327 | assert !child.same_scope?(root) 328 | end 329 | 330 | def test_left_sibling 331 | assert_equal categories(:child_1), categories(:child_2).left_sibling 332 | assert_equal categories(:child_2), categories(:child_3).left_sibling 333 | end 334 | 335 | def test_left_sibling_of_root 336 | assert_nil categories(:top_level).left_sibling 337 | end 338 | 339 | def test_left_sibling_without_siblings 340 | assert_nil categories(:child_2_1).left_sibling 341 | end 342 | 343 | def test_left_sibling_of_leftmost_node 344 | assert_nil categories(:child_1).left_sibling 345 | end 346 | 347 | def test_right_sibling 348 | assert_equal categories(:child_3), categories(:child_2).right_sibling 349 | assert_equal categories(:child_2), categories(:child_1).right_sibling 350 | end 351 | 352 | def test_right_sibling_of_root 353 | assert_equal categories(:top_level_2), categories(:top_level).right_sibling 354 | assert_nil categories(:top_level_2).right_sibling 355 | end 356 | 357 | def test_right_sibling_without_siblings 358 | assert_nil categories(:child_2_1).right_sibling 359 | end 360 | 361 | def test_right_sibling_of_rightmost_node 362 | assert_nil categories(:child_3).right_sibling 363 | end 364 | 365 | def test_move_left 366 | categories(:child_2).move_left 367 | assert_nil categories(:child_2).left_sibling 368 | assert_equal categories(:child_1), categories(:child_2).right_sibling 369 | assert Category.valid? 370 | end 371 | 372 | def test_move_right 373 | categories(:child_2).move_right 374 | assert_nil categories(:child_2).right_sibling 375 | assert_equal categories(:child_3), categories(:child_2).left_sibling 376 | assert Category.valid? 377 | end 378 | 379 | def test_move_to_left_of 380 | categories(:child_3).move_to_left_of(categories(:child_1)) 381 | assert_nil categories(:child_3).left_sibling 382 | assert_equal categories(:child_1), categories(:child_3).right_sibling 383 | assert Category.valid? 384 | end 385 | 386 | def test_move_to_right_of 387 | categories(:child_1).move_to_right_of(categories(:child_3)) 388 | assert_nil categories(:child_1).right_sibling 389 | assert_equal categories(:child_3), categories(:child_1).left_sibling 390 | assert Category.valid? 391 | end 392 | 393 | def test_move_to_root 394 | categories(:child_2).move_to_root 395 | assert_nil categories(:child_2).parent 396 | assert_equal 0, categories(:child_2).level 397 | assert_equal 1, categories(:child_2_1).level 398 | assert_equal 1, categories(:child_2).left 399 | assert_equal 4, categories(:child_2).right 400 | assert Category.valid? 401 | end 402 | 403 | def test_move_to_child_of 404 | categories(:child_1).move_to_child_of(categories(:child_3)) 405 | assert_equal categories(:child_3).id, categories(:child_1).parent_id 406 | assert Category.valid? 407 | end 408 | 409 | def test_move_to_child_of_appends_to_end 410 | child = Category.create! :name => 'New Child' 411 | child.move_to_child_of categories(:top_level) 412 | assert_equal child, categories(:top_level).children.last 413 | end 414 | 415 | def test_subtree_move_to_child_of 416 | assert_equal 4, categories(:child_2).left 417 | assert_equal 7, categories(:child_2).right 418 | 419 | assert_equal 2, categories(:child_1).left 420 | assert_equal 3, categories(:child_1).right 421 | 422 | categories(:child_2).move_to_child_of(categories(:child_1)) 423 | assert Category.valid? 424 | assert_equal categories(:child_1).id, categories(:child_2).parent_id 425 | 426 | assert_equal 3, categories(:child_2).left 427 | assert_equal 6, categories(:child_2).right 428 | assert_equal 2, categories(:child_1).left 429 | assert_equal 7, categories(:child_1).right 430 | end 431 | 432 | def test_slightly_difficult_move_to_child_of 433 | assert_equal 11, categories(:top_level_2).left 434 | assert_equal 12, categories(:top_level_2).right 435 | 436 | # create a new top-level node and move single-node top-level tree inside it. 437 | new_top = Category.create(:name => 'New Top') 438 | assert_equal 13, new_top.left 439 | assert_equal 14, new_top.right 440 | 441 | categories(:top_level_2).move_to_child_of(new_top) 442 | 443 | assert Category.valid? 444 | assert_equal new_top.id, categories(:top_level_2).parent_id 445 | 446 | assert_equal 12, categories(:top_level_2).left 447 | assert_equal 13, categories(:top_level_2).right 448 | assert_equal 11, new_top.left 449 | assert_equal 14, new_top.right 450 | end 451 | 452 | def test_difficult_move_to_child_of 453 | assert_equal 1, categories(:top_level).left 454 | assert_equal 10, categories(:top_level).right 455 | assert_equal 5, categories(:child_2_1).left 456 | assert_equal 6, categories(:child_2_1).right 457 | 458 | # create a new top-level node and move an entire top-level tree inside it. 459 | new_top = Category.create(:name => 'New Top') 460 | categories(:top_level).move_to_child_of(new_top) 461 | categories(:child_2_1).reload 462 | assert Category.valid? 463 | assert_equal new_top.id, categories(:top_level).parent_id 464 | 465 | assert_equal 4, categories(:top_level).left 466 | assert_equal 13, categories(:top_level).right 467 | assert_equal 8, categories(:child_2_1).left 468 | assert_equal 9, categories(:child_2_1).right 469 | end 470 | 471 | #rebuild swaps the position of the 2 children when added using move_to_child twice onto same parent 472 | def test_move_to_child_more_than_once_per_parent_rebuild 473 | root1 = Category.create(:name => 'Root1') 474 | root2 = Category.create(:name => 'Root2') 475 | root3 = Category.create(:name => 'Root3') 476 | 477 | root2.move_to_child_of root1 478 | root3.move_to_child_of root1 479 | 480 | output = Category.roots.last.to_text 481 | Category.update_all('lft = null, rgt = null') 482 | Category.rebuild! 483 | 484 | assert_equal Category.roots.last.to_text, output 485 | end 486 | 487 | # doing move_to_child twice onto same parent from the furthest right first 488 | def test_move_to_child_more_than_once_per_parent_outside_in 489 | node1 = Category.create(:name => 'Node-1') 490 | node2 = Category.create(:name => 'Node-2') 491 | node3 = Category.create(:name => 'Node-3') 492 | 493 | node2.move_to_child_of node1 494 | node3.move_to_child_of node1 495 | 496 | output = Category.roots.last.to_text 497 | Category.update_all('lft = null, rgt = null') 498 | Category.rebuild! 499 | 500 | assert_equal Category.roots.last.to_text, output 501 | end 502 | 503 | 504 | def test_valid_with_null_lefts 505 | assert Category.valid? 506 | Category.update_all('lft = null') 507 | assert !Category.valid? 508 | end 509 | 510 | def test_valid_with_null_rights 511 | assert Category.valid? 512 | Category.update_all('rgt = null') 513 | assert !Category.valid? 514 | end 515 | 516 | def test_valid_with_missing_intermediate_node 517 | # Even though child_2_1 will still exist, it is a sign of a sloppy delete, not an invalid tree. 518 | assert Category.valid? 519 | Category.delete(categories(:child_2).id) 520 | assert Category.valid? 521 | end 522 | 523 | def test_valid_with_overlapping_and_rights 524 | assert Category.valid? 525 | categories(:top_level_2)['lft'] = 0 526 | categories(:top_level_2).save 527 | assert !Category.valid? 528 | end 529 | 530 | def test_rebuild 531 | assert Category.valid? 532 | before_text = Category.root.to_text 533 | Category.update_all('lft = null, rgt = null') 534 | Category.rebuild! 535 | assert Category.valid? 536 | assert_equal before_text, Category.root.to_text 537 | end 538 | 539 | def test_move_possible_for_sibling 540 | assert categories(:child_2).move_possible?(categories(:child_1)) 541 | end 542 | 543 | def test_move_not_possible_to_self 544 | assert !categories(:top_level).move_possible?(categories(:top_level)) 545 | end 546 | 547 | def test_move_not_possible_to_parent 548 | categories(:top_level).descendants.each do |descendant| 549 | assert !categories(:top_level).move_possible?(descendant) 550 | assert descendant.move_possible?(categories(:top_level)) 551 | end 552 | end 553 | 554 | def test_is_or_is_ancestor_of? 555 | [:child_1, :child_2, :child_2_1, :child_3].each do |c| 556 | assert categories(:top_level).is_or_is_ancestor_of?(categories(c)) 557 | end 558 | assert !categories(:top_level).is_or_is_ancestor_of?(categories(:top_level_2)) 559 | end 560 | 561 | def test_left_and_rights_valid_with_blank_left 562 | assert Category.left_and_rights_valid? 563 | categories(:child_2)[:lft] = nil 564 | categories(:child_2).save(:validate => false) 565 | assert !Category.left_and_rights_valid? 566 | end 567 | 568 | def test_left_and_rights_valid_with_blank_right 569 | assert Category.left_and_rights_valid? 570 | categories(:child_2)[:rgt] = nil 571 | categories(:child_2).save(:validate => false) 572 | assert !Category.left_and_rights_valid? 573 | end 574 | 575 | def test_left_and_rights_valid_with_equal 576 | assert Category.left_and_rights_valid? 577 | categories(:top_level_2)[:lft] = categories(:top_level_2)[:rgt] 578 | categories(:top_level_2).save(:validate => false) 579 | assert !Category.left_and_rights_valid? 580 | end 581 | 582 | def test_left_and_rights_valid_with_left_equal_to_parent 583 | assert Category.left_and_rights_valid? 584 | categories(:child_2)[:lft] = categories(:top_level)[:lft] 585 | categories(:child_2).save(:validate => false) 586 | assert !Category.left_and_rights_valid? 587 | end 588 | 589 | def test_left_and_rights_valid_with_right_equal_to_parent 590 | assert Category.left_and_rights_valid? 591 | categories(:child_2)[:rgt] = categories(:top_level)[:rgt] 592 | categories(:child_2).save(:validate => false) 593 | assert !Category.left_and_rights_valid? 594 | end 595 | 596 | def test_moving_dirty_objects_doesnt_invalidate_tree 597 | r1 = Category.create 598 | r2 = Category.create 599 | r3 = Category.create 600 | r4 = Category.create 601 | nodes = [r1, r2, r3, r4] 602 | 603 | r2.move_to_child_of(r1) 604 | assert Category.valid? 605 | 606 | r3.move_to_child_of(r1) 607 | assert Category.valid? 608 | 609 | r4.move_to_child_of(r2) 610 | assert Category.valid? 611 | end 612 | 613 | def test_multi_scoped_no_duplicates_for_columns? 614 | assert_nothing_raised do 615 | Note.no_duplicates_for_columns? 616 | end 617 | end 618 | 619 | def test_multi_scoped_all_roots_valid? 620 | assert_nothing_raised do 621 | Note.all_roots_valid? 622 | end 623 | end 624 | 625 | def test_multi_scoped 626 | note1 = Note.create!(:body => "A", :notable_id => 2, :notable_type => 'Category') 627 | note2 = Note.create!(:body => "B", :notable_id => 2, :notable_type => 'Category') 628 | note3 = Note.create!(:body => "C", :notable_id => 2, :notable_type => 'Default') 629 | 630 | assert_equal [note1, note2], note1.self_and_siblings 631 | assert_equal [note3], note3.self_and_siblings 632 | end 633 | 634 | def test_multi_scoped_rebuild 635 | root = Note.create!(:body => "A", :notable_id => 3, :notable_type => 'Category') 636 | child1 = Note.create!(:body => "B", :notable_id => 3, :notable_type => 'Category') 637 | child2 = Note.create!(:body => "C", :notable_id => 3, :notable_type => 'Category') 638 | 639 | child1.move_to_child_of root 640 | child2.move_to_child_of root 641 | 642 | Note.update_all('lft = null, rgt = null') 643 | Note.rebuild! 644 | 645 | assert_equal Note.roots.find_by_body('A'), root 646 | assert_equal [child1, child2], Note.roots.find_by_body('A').children 647 | end 648 | 649 | def test_same_scope_with_multi_scopes 650 | assert_nothing_raised do 651 | notes(:scope1).same_scope?(notes(:child_1)) 652 | end 653 | assert notes(:scope1).same_scope?(notes(:child_1)) 654 | assert notes(:child_1).same_scope?(notes(:scope1)) 655 | assert !notes(:scope1).same_scope?(notes(:scope2)) 656 | end 657 | 658 | def test_quoting_of_multi_scope_column_names 659 | assert_equal ["\"notable_id\"", "\"notable_type\""], Note.quoted_scope_column_names 660 | end 661 | 662 | def test_equal_in_same_scope 663 | assert_equal notes(:scope1), notes(:scope1) 664 | assert_not_equal notes(:scope1), notes(:child_1) 665 | end 666 | 667 | def test_equal_in_different_scopes 668 | assert_not_equal notes(:scope1), notes(:scope2) 669 | end 670 | 671 | def test_delete_does_not_invalidate 672 | Category.acts_as_nested_set_options[:dependent] = :delete 673 | categories(:child_2).destroy 674 | assert Category.valid? 675 | end 676 | 677 | def test_destroy_does_not_invalidate 678 | Category.acts_as_nested_set_options[:dependent] = :destroy 679 | categories(:child_2).destroy 680 | assert Category.valid? 681 | end 682 | 683 | def test_destroy_multiple_times_does_not_invalidate 684 | Category.acts_as_nested_set_options[:dependent] = :destroy 685 | categories(:child_2).destroy 686 | categories(:child_2).destroy 687 | assert Category.valid? 688 | end 689 | 690 | def test_destroy_on_multiple_records_without_reload_does_not_invalidate 691 | Category.acts_as_nested_set_options[:dependent] = :destroy 692 | [categories(:child_1), categories(:child_2)].each(&:destroy) 693 | assert Category.valid? 694 | end 695 | 696 | def test_custom_destroy_method_does_not_invalidate 697 | Category_WithCustomDestroy.acts_as_nested_set_options[:dependent] = :custom_destroy 698 | Category_WithCustomDestroy.find_by_name('Child 2').custom_destroy 699 | assert Category_WithCustomDestroy.valid? 700 | end 701 | 702 | def test_custom_destroy_raises_an_error_if_method_does_not_exist 703 | Category.acts_as_nested_set_options[:dependent] = :custom_destroy 704 | assert_raise(NoMethodError) { categories(:child_2).destroy } 705 | end 706 | 707 | def test_assigning_parent_id_on_create 708 | category = Category.create!(:name => "Child", :parent_id => categories(:child_2).id) 709 | assert_equal categories(:child_2), category.parent 710 | assert_equal categories(:child_2).id, category.parent_id 711 | assert_not_nil category.left 712 | assert_not_nil category.right 713 | assert Category.valid? 714 | end 715 | 716 | def test_assigning_parent_on_create 717 | category = Category.create!(:name => "Child", :parent => categories(:child_2)) 718 | assert_equal categories(:child_2), category.parent 719 | assert_equal categories(:child_2).id, category.parent_id 720 | assert_not_nil category.left 721 | assert_not_nil category.right 722 | assert Category.valid? 723 | end 724 | 725 | def test_assigning_parent_id_to_nil_on_create 726 | category = Category.create!(:name => "New Root", :parent_id => nil) 727 | assert_nil category.parent 728 | assert_nil category.parent_id 729 | assert_not_nil category.left 730 | assert_not_nil category.right 731 | assert Category.valid? 732 | end 733 | 734 | def test_assigning_parent_id_on_update 735 | category = categories(:child_2_1) 736 | category.parent_id = categories(:child_3).id 737 | category.save 738 | assert_equal categories(:child_3), category.parent 739 | assert_equal categories(:child_3).id, category.parent_id 740 | assert Category.valid? 741 | end 742 | 743 | def test_assigning_parent_on_update 744 | category = categories(:child_2_1) 745 | category.parent = categories(:child_3) 746 | category.save 747 | assert_equal categories(:child_3), category.parent 748 | assert_equal categories(:child_3).id, category.parent_id 749 | assert Category.valid? 750 | end 751 | 752 | def test_assigning_parent_id_to_nil_on_update 753 | category = categories(:child_2_1) 754 | category.parent_id = nil 755 | category.save 756 | assert_nil category.parent 757 | assert_nil category.parent_id 758 | assert Category.valid? 759 | end 760 | 761 | def test_creating_child_from_parent 762 | category = categories(:child_2).children.create!(:name => "Child") 763 | assert_equal categories(:child_2), category.parent 764 | assert_equal categories(:child_2).id, category.parent_id 765 | assert_not_nil category.left 766 | assert_not_nil category.right 767 | assert Category.valid? 768 | end 769 | 770 | def check_structure(entries, structure) 771 | structure = structure.dup 772 | Category.each_with_level(entries) do |category, level| 773 | expected_level, expected_name = structure.shift 774 | assert_equal expected_name, category.name, "wrong category" 775 | assert_equal expected_level, level, "wrong level for #{category.name}" 776 | end 777 | end 778 | 779 | def check_scoped_structure(entries, structure) 780 | structure = structure.dup 781 | entries.each_with_level do |category, level| 782 | expected_level, expected_name = structure.shift 783 | assert_equal expected_name, category.name, "wrong category" 784 | assert_equal expected_level, level, "wrong level for #{category.name}" 785 | end 786 | end 787 | 788 | def test_valid_with_default_scope 789 | assert Category_DefaultScope.valid? 790 | end 791 | 792 | def test_each_with_level 793 | levels = [ 794 | [0, "Top Level"], 795 | [1, "Child 1"], 796 | [1, "Child 2"], 797 | [2, "Child 2.1"], 798 | [1, "Child 3" ]] 799 | 800 | check_structure(Category.root.self_and_descendants, levels) 801 | check_scoped_structure(Category.root.self_and_descendants, levels) 802 | 803 | # test some deeper structures 804 | category = Category.find_by_name("Child 1") 805 | c1 = Category.new(:name => "Child 1.1") 806 | c2 = Category.new(:name => "Child 1.1.1") 807 | c3 = Category.new(:name => "Child 1.1.1.1") 808 | c4 = Category.new(:name => "Child 1.2") 809 | [c1, c2, c3, c4].each(&:save!) 810 | 811 | c1.move_to_child_of(category) 812 | c2.move_to_child_of(c1) 813 | c3.move_to_child_of(c2) 814 | c4.move_to_child_of(category) 815 | 816 | levels = [ 817 | [0, "Top Level"], 818 | [1, "Child 1"], 819 | [2, "Child 1.1"], 820 | [3, "Child 1.1.1"], 821 | [4, "Child 1.1.1.1"], 822 | [2, "Child 1.2"], 823 | [1, "Child 2"], 824 | [2, "Child 2.1"], 825 | [1, "Child 3" ]] 826 | 827 | check_scoped_structure(Category.root.self_and_descendants, levels) 828 | end 829 | 830 | def test_map_with_level 831 | expected = [ 832 | [0, "Top Level"], 833 | [1, "Child 1"], 834 | [1, "Child 2"], 835 | [2, "Child 2.1"], 836 | [1, "Child 3" ] 837 | ] 838 | actual = Category.map_with_level Category.root.self_and_descendants do |i, level| 839 | [level, i.name] 840 | end 841 | assert_equal expected, actual 842 | end 843 | 844 | def test_model_with_attr_accessible 845 | model = Class.new(ActiveRecord::Base) 846 | model.table_name = 'categories' 847 | model.attr_accessible :name 848 | assert_nothing_raised do 849 | model.acts_as_nested_set 850 | model.new(:name => 'foo') 851 | end 852 | end 853 | 854 | def test_before_move_callback 855 | $called = false 856 | Category.before_move { |r| $called = true } 857 | 858 | categories(:child_2).move_to_root 859 | assert $called 860 | ensure 861 | Category.class_eval { reset_callbacks :move } 862 | end 863 | 864 | def test_before_move_callback_returning_false_stops_move 865 | Category.before_move { |r| false } 866 | 867 | assert !categories(:child_3).move_to_root 868 | assert !categories(:child_3).root? 869 | ensure 870 | Category.class_eval { reset_callbacks :move } 871 | end 872 | 873 | # NOTE this feature is hard to implement given current callbacks 874 | # architecture. Since node is moved in an after_save hook, 875 | # we can't stop moving by before filter. The only thing we can do 876 | # is raise an exception preventing Model#save to commit transaction. 877 | 878 | #def test_before_move_callback_returning_false_halts_save 879 | #Category.before_move { |r| false } 880 | 881 | #categories(:child_3).parent_id = nil 882 | #assert !categories(:child_3).save 883 | #ensure 884 | #Category.class_eval { reset_callbacks :move } 885 | #end 886 | 887 | def test_calls_after_move_when_moving 888 | $called = false 889 | Category.after_move { $called = true } 890 | categories(:child_3).parent = categories(:child_2) 891 | assert categories(:child_3).save 892 | assert $called 893 | ensure 894 | Category.class_eval { reset_callbacks :move } 895 | end 896 | 897 | # helper to turn arranged hash to levels array 898 | def hash_to_array hash, level = 0 899 | array = [] 900 | hash.each do |key, value| 901 | array.push [level, "#{key}"] 902 | array += hash_to_array(value, level.next) 903 | end 904 | array 905 | end 906 | 907 | def test_arrangement 908 | levels = [ 909 | [0, "Top Level"], 910 | [1, "Child 1"], 911 | [1, "Child 2"], 912 | [2, "Child 2.1"], 913 | [1, "Child 3"], 914 | [0, "Top Level 2"]] 915 | 916 | assert_equal hash_to_array(Category.arrange), levels 917 | 918 | # some deeper structure 919 | 920 | category = Category.find_by_name("Child 1") 921 | c1 = Category.new(:name => "Child 1.1") 922 | c2 = Category.new(:name => "Child 1.1.1") 923 | c3 = Category.new(:name => "Child 1.1.1.1") 924 | c4 = Category.new(:name => "Child 1.2") 925 | [c1, c2, c3, c4].each(&:save!) 926 | 927 | c1.move_to_child_of(category) 928 | c2.move_to_child_of(c1) 929 | c3.move_to_child_of(c2) 930 | c4.move_to_child_of(category) 931 | 932 | levels = [ 933 | [0, "Top Level"], 934 | [1, "Child 1"], 935 | [2, "Child 1.1"], 936 | [3, "Child 1.1.1"], 937 | [4, "Child 1.1.1.1"], 938 | [2, "Child 1.2"], 939 | [1, "Child 2"], 940 | [2, "Child 2.1"], 941 | [1, "Child 3" ], 942 | [0, "Top Level 2"]] 943 | 944 | assert_equal hash_to_array(Category.arrange), levels 945 | end 946 | 947 | end 948 | --------------------------------------------------------------------------------