├── test ├── fixtures │ ├── departments.yml │ ├── category.rb │ ├── categories.yml │ └── notes.yml ├── application.rb ├── db │ ├── database.yml │ └── schema.rb ├── test_helper.rb ├── awesome_nested_set │ └── helper_test.rb └── awesome_nested_set_test.rb ├── .gitignore ├── init.rb ├── lib ├── awesome_nested_set │ ├── version.rb │ ├── railtie.rb │ ├── descendants.rb │ ├── depth.rb │ ├── helper.rb │ └── base.rb └── awesome_nested_set.rb ├── .autotest ├── MIT-LICENSE ├── Rakefile ├── awesome_nested_set.gemspec └── README.rdoc /test/fixtures/departments.yml: -------------------------------------------------------------------------------- 1 | top: 2 | id: 1 3 | name: Top -------------------------------------------------------------------------------- /test/application.rb: -------------------------------------------------------------------------------- 1 | # This file is here to satisfy test_help from Rails < 2.3 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | awesome_nested_set.sqlite3.db 2 | test/debug.log 3 | rdoc 4 | coverage 5 | pkg 6 | *.sw? 7 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require File.join(File.dirname(__FILE__), "lib", "awesome_nested_set") 3 | -------------------------------------------------------------------------------- /test/fixtures/category.rb: -------------------------------------------------------------------------------- 1 | class Category < ActiveRecord::Base 2 | acts_as_nested_set 3 | 4 | def to_s 5 | name 6 | end 7 | 8 | def recurse &block 9 | block.call self, lambda{ 10 | self.children.each do |child| 11 | child.recurse &block 12 | end 13 | } 14 | end 15 | end -------------------------------------------------------------------------------- /lib/awesome_nested_set/version.rb: -------------------------------------------------------------------------------- 1 | module CollectiveIdea 2 | module Acts 3 | module NestedSet 4 | module Version 5 | MAJOR = 1 6 | MINOR = 5 7 | RELEASE = 0 8 | 9 | def self.dup 10 | "#{MAJOR}.#{MINOR}.#{RELEASE}" 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /lib/awesome_nested_set.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | module CollectiveIdea 3 | module Acts 4 | module NestedSet 5 | autoload :Base, 'awesome_nested_set/base' 6 | autoload :Depth, 'awesome_nested_set/depth' 7 | autoload :Descendants, 'awesome_nested_set/descendants' 8 | autoload :Helper, 'awesome_nested_set/helper' 9 | autoload :Version, 'awesome_nested_set/version' 10 | end 11 | end 12 | end 13 | 14 | require 'awesome_nested_set/railtie' 15 | -------------------------------------------------------------------------------- /test/fixtures/categories.yml: -------------------------------------------------------------------------------- 1 | top_level: 2 | id: 1 3 | name: Top Level 4 | lft: 1 5 | rgt: 10 6 | child_1: 7 | id: 2 8 | name: Child 1 9 | parent_id: 1 10 | lft: 2 11 | rgt: 3 12 | child_2: 13 | id: 3 14 | name: Child 2 15 | parent_id: 1 16 | lft: 4 17 | rgt: 7 18 | child_2_1: 19 | id: 4 20 | name: Child 2.1 21 | parent_id: 3 22 | lft: 5 23 | rgt: 6 24 | child_3: 25 | id: 5 26 | name: Child 3 27 | parent_id: 1 28 | lft: 8 29 | rgt: 9 30 | top_level_2: 31 | id: 6 32 | name: Top Level 2 33 | lft: 11 34 | rgt: 12 35 | -------------------------------------------------------------------------------- /lib/awesome_nested_set/railtie.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'awesome_nested_set' 3 | require 'rails' 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 | ActiveRecord::Base.send(:include, CollectiveIdea::Acts::NestedSet::Base) 12 | end 13 | 14 | ActiveSupport.on_load :action_view do 15 | ActionView::Base.send(:include, CollectiveIdea::Acts::NestedSet::Helper) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /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/awesome_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(direction) 16 | return if direction.blank? 17 | 18 | case direction.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 | -------------------------------------------------------------------------------- /test/db/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define(:version => 0) do 2 | 3 | create_table :categories, :force => true do |t| 4 | t.column :name, :string 5 | t.column :parent_id, :integer 6 | t.column :lft, :integer 7 | t.column :rgt, :integer 8 | t.column :organization_id, :integer 9 | end 10 | 11 | create_table :departments, :force => true do |t| 12 | t.column :name, :string 13 | end 14 | 15 | create_table :notes, :force => true do |t| 16 | t.column :body, :text 17 | t.column :parent_id, :integer 18 | t.column :lft, :integer 19 | t.column :rgt, :integer 20 | t.column :notable_id, :integer 21 | t.column :notable_type, :string 22 | end 23 | 24 | create_table :renamed_columns, :force => true do |t| 25 | t.column :name, :string 26 | t.column :mother_id, :integer 27 | t.column :red, :integer 28 | t.column :black, :integer 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | plugin_test_dir = File.dirname(__FILE__) 2 | 3 | $:.unshift(plugin_test_dir + '/../lib') 4 | 5 | require 'rubygems' 6 | require 'test/unit' 7 | require 'active_support' 8 | require 'active_support/test_case' 9 | require 'active_record' 10 | require 'action_pack' 11 | require 'awesome_nested_set' 12 | 13 | CollectiveIdea::Acts::NestedSet::Railtie.extend_active_record 14 | 15 | ActiveRecord::Base.logger = Logger.new(plugin_test_dir + "/debug.log") 16 | 17 | ActiveRecord::Base.configurations = YAML::load(IO.read(plugin_test_dir + "/db/database.yml")) 18 | ActiveRecord::Base.establish_connection(ENV["DB"] || "sqlite3mem") 19 | ActiveRecord::Migration.verbose = false 20 | load(File.join(plugin_test_dir, "db", "schema.rb")) 21 | 22 | Dir["#{plugin_test_dir}/fixtures/*.rb"].each {|file| require file } 23 | 24 | class ActiveSupport::TestCase #:nodoc: 25 | include ActiveRecord::TestFixtures 26 | 27 | self.fixture_path = File.dirname(__FILE__) + "/fixtures/" 28 | self.use_transactional_fixtures = true 29 | self.use_instantiated_fixtures = false 30 | 31 | fixtures :categories, :notes, :departments 32 | end 33 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/awesome_nested_set/helper_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | module CollectiveIdea 4 | module Acts #:nodoc: 5 | module NestedSet #:nodoc: 6 | class AwesomeNestedSetTest < ActiveSupport::TestCase 7 | include Helper 8 | fixtures :categories 9 | 10 | def test_nested_set_options 11 | expected = [ 12 | [" Top Level", 1], 13 | ["- Child 1", 2], 14 | ['- Child 2', 3], 15 | ['-- Child 2.1', 4], 16 | ['- Child 3', 5], 17 | [" Top Level 2", 6] 18 | ] 19 | actual = nested_set_options(Category) do |c| 20 | "#{'-' * c.level} #{c.name}" 21 | end 22 | assert_equal expected, actual 23 | end 24 | 25 | def test_nested_set_options_with_mover 26 | expected = [ 27 | [" Top Level", 1], 28 | ["- Child 1", 2], 29 | ['- Child 3', 5], 30 | [" Top Level 2", 6] 31 | ] 32 | actual = nested_set_options(Category, categories(:child_2)) do |c| 33 | "#{'-' * c.level} #{c.name}" 34 | end 35 | assert_equal expected, actual 36 | end 37 | 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/awesome_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 | # Check is model has depth column 18 | def depth? 19 | self.respond_to?(:depth) 20 | end 21 | 22 | # Update cached_level attribute 23 | def update_depth 24 | self.update_attribute(:depth, level) 25 | end 26 | 27 | # Update cached_level attribute for all record tree 28 | def update_all_depth 29 | if depth? 30 | self.class.connection.execute("UPDATE #{self.class.quoted_table_name} a SET a.depth = \ 31 | (SELECT count(*) - 1 FROM (SELECT * FROM #{self.class.quoted_table_name} WHERE #{scope_condition}) AS b \ 32 | WHERE #{scope_condition('a')} AND \ 33 | (a.#{quoted_left_column_name} BETWEEN b.#{quoted_left_column_name} AND b.#{quoted_right_column_name})) 34 | WHERE #{scope_condition('a')} 35 | ") 36 | end 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/awesome_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_item+ - Class name or top level times 15 | # * +mover+ - The item that is being move, used to exlude impossible moves 16 | # * +&block+ - a block that will be used to display: { |item| ... item.name } 17 | # 18 | # == Usage 19 | # 20 | # <%= f.select :parent_id, nested_set_options(Category, @category) {|i| 21 | # "#{'–' * i.level} #{i.name}" 22 | # }) %> 23 | # 24 | def nested_set_options(class_or_item, mover = nil) 25 | class_or_item = class_or_item.roots if class_or_item.is_a?(Class) 26 | items = Array(class_or_item) 27 | result = [] 28 | items.each do |root| 29 | result += root.self_and_descendants.map do |i| 30 | if mover.nil? || mover.new_record? || mover.move_possible?(i) 31 | [yield(i), i.id] 32 | end 33 | end.compact 34 | end 35 | result 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require 'rake' 3 | require 'rake/testtask' 4 | require 'rake/rdoctask' 5 | require File.join(File.dirname(__FILE__), 'lib', 'awesome_nested_set', 'version') 6 | 7 | desc 'Default: run unit tests.' 8 | task :default => :test 9 | 10 | desc 'Test the awesome_nested_set plugin.' 11 | Rake::TestTask.new(:test) do |t| 12 | t.libs += ['lib', 'test'] 13 | t.pattern = 'test/**/*_test.rb' 14 | t.verbose = true 15 | end 16 | 17 | desc 'Generate documentation for the awesome_nested_set plugin.' 18 | Rake::RDocTask.new(:rdoc) do |rdoc| 19 | rdoc.rdoc_dir = 'rdoc' 20 | rdoc.title = 'AwesomeNestedSet' 21 | rdoc.options << '--line-numbers' << '--inline-source' 22 | rdoc.rdoc_files.include('README.rdoc') 23 | rdoc.rdoc_files.include('lib/**/*.rb') 24 | end 25 | 26 | begin 27 | require 'jeweler' 28 | Jeweler::Tasks.new do |gemspec| 29 | gemspec.name = "awesome_nested_set" 30 | gemspec.version = CollectiveIdea::Acts::NestedSet::Version.dup 31 | gemspec.summary = "An awesome nested set implementation for Active Record" 32 | gemspec.description = "An awesome nested set implementation for Active Record" 33 | gemspec.email = "galeta.igor@gmail.com" 34 | gemspec.homepage = "https://github.com/galetahub/awesome_nested_set" 35 | gemspec.authors = ["Brandon Keepers", "Daniel Morrison", "Igor Galeta"] 36 | gemspec.files = FileList["[A-Z]*", "lib/**/*"] 37 | gemspec.rubyforge_project = "awesome_nested_set" 38 | 39 | gemspec.add_dependency('activerecord', '>= 3.0.0') 40 | end 41 | 42 | Jeweler::GemcutterTasks.new 43 | rescue LoadError 44 | puts "Jeweler not available. Install it with: gem install jeweler" 45 | end 46 | -------------------------------------------------------------------------------- /awesome_nested_set.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run the gemspec command 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = %q{awesome_nested_set} 8 | s.version = "1.5.0" 9 | 10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 11 | s.authors = ["Brandon Keepers", "Daniel Morrison", "Igor Galeta"] 12 | s.date = %q{2010-11-18} 13 | s.description = %q{An awesome nested set implementation for Active Record} 14 | s.email = %q{galeta.igor@gmail.com} 15 | s.extra_rdoc_files = [ 16 | "README.rdoc" 17 | ] 18 | s.files = [ 19 | "MIT-LICENSE", 20 | "README.rdoc", 21 | "Rakefile", 22 | "lib/awesome_nested_set.rb", 23 | "lib/awesome_nested_set/base.rb", 24 | "lib/awesome_nested_set/depth.rb", 25 | "lib/awesome_nested_set/descendants.rb", 26 | "lib/awesome_nested_set/helper.rb", 27 | "lib/awesome_nested_set/railtie.rb", 28 | "lib/awesome_nested_set/version.rb" 29 | ] 30 | s.homepage = %q{https://github.com/galetahub/awesome_nested_set} 31 | s.rdoc_options = ["--charset=UTF-8"] 32 | s.require_paths = ["lib"] 33 | s.rubyforge_project = %q{awesome_nested_set} 34 | s.rubygems_version = %q{1.3.7} 35 | s.summary = %q{An awesome nested set implementation for Active Record} 36 | s.test_files = [ 37 | "test/awesome_nested_set/helper_test.rb", 38 | "test/application.rb", 39 | "test/db/schema.rb", 40 | "test/awesome_nested_set_test.rb", 41 | "test/fixtures/category.rb", 42 | "test/test_helper.rb" 43 | ] 44 | 45 | if s.respond_to? :specification_version then 46 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 47 | s.specification_version = 3 48 | 49 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 50 | s.add_runtime_dependency(%q, [">= 3.0.0"]) 51 | else 52 | s.add_dependency(%q, [">= 3.0.0"]) 53 | end 54 | else 55 | s.add_dependency(%q, [">= 3.0.0"]) 56 | end 57 | end 58 | 59 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = AwesomeNestedSet 2 | 3 | Awesome 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 2.1 and later. 4 | 5 | == What makes this so awesome? 6 | 7 | This is a new implementation of nested set based off of BetterNestedSet that fixes some bugs, removes tons of duplication, adds a few useful methods, and adds STI support. 8 | 9 | == Installation 10 | 11 | Install as a plugin: 12 | 13 | rails plugin install git://github.com/galetahub/awesome_nested_set.git 14 | 15 | as a gem: 16 | 17 | gem 'awesome_nested_set', :git => 'git://github.com/galetahub/awesome_nested_set.git' 18 | 19 | == Usage 20 | 21 | To make use of awesome_nested_set, your model needs to have 3 fields: lft, rgt, and parent_id: 22 | 23 | class CreateCategories < ActiveRecord::Migration 24 | def self.up 25 | create_table :categories do |t| 26 | t.string :name 27 | t.integer :parent_id 28 | t.integer :lft 29 | t.integer :rgt 30 | 31 | # Uncomment it to store item level 32 | # t.integer :depth 33 | end 34 | end 35 | 36 | def self.down 37 | drop_table :categories 38 | end 39 | end 40 | 41 | Enable the nested set functionality by declaring acts_as_nested_set on your model 42 | 43 | class Category < ActiveRecord::Base 44 | acts_as_nested_set 45 | end 46 | 47 | Run `rake rdoc` to generate the API docs and see CollectiveIdea::Acts::NestedSet::Base::SingletonMethods for more info. 48 | 49 | == Conversion from other trees 50 | 51 | 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 52 | 53 | Category.rebuild! 54 | 55 | Your tree be converted to a valid nested set. Awesome! 56 | 57 | == View Helper 58 | 59 | The view helper is called #nested_set_options. 60 | 61 | Example usage: 62 | 63 | <%= f.select :parent_id, nested_set_options(Category, @category) {|i| "#{'-' * i.level} #{i.name}" } %> 64 | 65 | <%= select_tag 'parent_id', options_for_select(nested_set_options(Category) {|i| "#{'-' * i.level} #{i.name}" } ) %> 66 | 67 | See CollectiveIdea::Acts::NestedSet::Helper for more information about the helpers. 68 | 69 | == References 70 | 71 | You can learn more about nested sets at: 72 | 73 | http://www.dbmsmag.com/9603d06.html 74 | http://threebit.net/tutorials/nestedset/tutorial1.html 75 | http://api.rubyonrails.com/classes/ActiveRecord/Acts/NestedSet/ClassMethods.html 76 | http://opensource.symetrie.com/trac/better_nested_set/ 77 | 78 | == How to contribute 79 | 80 | If you find what you might think is a bug: 81 | 82 | 1. Check the GitHub issue tracker to see if anyone else has had the same issue. 83 | http://github.com/collectiveidea/awesome_nested_set/issues/ 84 | 2. If you don't see anything, create an issue with information on how to reproduce it. 85 | 86 | If you want to contribute an enhancement or a fix: 87 | 88 | 1. Fork the project on github. 89 | http://github.com/collectiveidea/awesome_nested_set/ 90 | 2. Make your changes with tests. 91 | 3. Commit the changes without making changes to the Rakefile, VERSION, or any other files that aren't related to your enhancement or fix 92 | 4. Send a pull request. 93 | 94 | Copyright ©2010 Collective Idea, released under the MIT license 95 | -------------------------------------------------------------------------------- /lib/awesome_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 | # * +:parent_column+ - specifies the column name to use for keeping the position integer (default: parent_id) 29 | # * +:left_column+ - column name for left boundry data, default "lft" 30 | # * +:right_column+ - column name for right boundry data, default "rgt" 31 | # * +:scope+ - restricts what is to be considered a list. Given a symbol, it'll attach "_id" 32 | # (if it hasn't been already) and use that as the foreign key restriction. You 33 | # can also pass an array to scope by multiple attributes. 34 | # Example: acts_as_nested_set :scope => [:notable_id, :notable_type] 35 | # * +:dependent+ - behavior for cascading destroy. If set to :destroy, all the 36 | # child objects are destroyed alongside this object by calling their destroy 37 | # method. If set to :delete_all (default), all the child objects are deleted 38 | # without calling their destroy method. 39 | # 40 | # See CollectiveIdea::Acts::NestedSet::ClassMethods for a list of class methods and 41 | # CollectiveIdea::Acts::NestedSet::InstanceMethods for a list of instance methods added 42 | # to acts_as_nested_set models 43 | def acts_as_nested_set(options = {}) 44 | options = { 45 | :parent_column => 'parent_id', 46 | :left_column => 'lft', 47 | :right_column => 'rgt', 48 | :dependent => :delete_all, # or :destroy 49 | }.merge(options) 50 | 51 | if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/ 52 | options[:scope] = "#{options[:scope]}_id".intern 53 | end 54 | 55 | class_attribute :acts_as_nested_set_options, :instance_writer => false 56 | self.acts_as_nested_set_options = options 57 | 58 | unless self.is_a?(ClassMethods) 59 | include Comparable 60 | include Columns 61 | include InstanceMethods 62 | 63 | include Depth 64 | include Descendants 65 | 66 | extend Columns 67 | extend ClassMethods 68 | 69 | belongs_to_options = { 70 | :class_name => self.base_class.to_s, 71 | :foreign_key => parent_column_name, 72 | } 73 | belongs_to_options[:counter_cache] = acts_as_nested_set_options[:counter_cache] if acts_as_nested_set_options[:counter_cache] 74 | 75 | has_many_options = { 76 | :class_name => self.base_class.to_s, 77 | :foreign_key => parent_column_name, 78 | :order => quoted_left_column_name, 79 | } 80 | 81 | belongs_to :parent, belongs_to_options 82 | has_many :children, has_many_options 83 | 84 | attr_accessor :skip_before_destroy 85 | 86 | # no bulk assignment 87 | if accessible_attributes.blank? 88 | attr_protected left_column_name.intern, right_column_name.intern 89 | end 90 | 91 | before_create :set_default_left_and_right 92 | before_save :store_new_parent 93 | after_save :move_to_new_parent 94 | before_destroy :destroy_descendants 95 | 96 | # no assignment to structure fields 97 | [left_column_name, right_column_name].each do |column| 98 | module_eval <<-"end_eval", __FILE__, __LINE__ 99 | def #{column}=(x) 100 | raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead." 101 | end 102 | end_eval 103 | end 104 | 105 | scope :roots, lambda { 106 | where(parent_column_name => nil).order(quoted_left_column_name) 107 | } 108 | scope :leaves, lambda { 109 | where("#{quoted_right_column_name} - #{quoted_left_column_name} = 1"). 110 | order(quoted_left_column_name) 111 | } 112 | scope :with_depth, proc {|level| where(:depth => level).order("lft") } 113 | scope :with_descendants, lambda { 114 | joins("LEFT OUTER JOIN #{quoted_table_name} AS descendants ON `descendants`.#{quoted_left_column_name} >= #{quoted_table_name}.#{quoted_left_column_name} AND `descendants`.#{quoted_right_column_name} <= #{quoted_table_name}.#{quoted_right_column_name}") 115 | } 116 | 117 | define_callbacks :move, :terminator => "result == false" 118 | end 119 | end 120 | end 121 | 122 | module ClassMethods 123 | 124 | # Returns the first root 125 | def root 126 | roots.first 127 | end 128 | 129 | def valid? 130 | left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid? 131 | end 132 | 133 | def left_and_rights_valid? 134 | count( 135 | :joins => "LEFT OUTER JOIN #{quoted_table_name} AS parent ON " + 136 | "#{quoted_table_name}.#{quoted_parent_column_name} = parent.#{primary_key}", 137 | :conditions => 138 | "#{quoted_table_name}.#{quoted_left_column_name} IS NULL OR " + 139 | "#{quoted_table_name}.#{quoted_right_column_name} IS NULL OR " + 140 | "#{quoted_table_name}.#{quoted_left_column_name} >= " + 141 | "#{quoted_table_name}.#{quoted_right_column_name} OR " + 142 | "(#{quoted_table_name}.#{quoted_parent_column_name} IS NOT NULL AND " + 143 | "(#{quoted_table_name}.#{quoted_left_column_name} <= parent.#{quoted_left_column_name} OR " + 144 | "#{quoted_table_name}.#{quoted_right_column_name} >= parent.#{quoted_right_column_name}))" 145 | ) == 0 146 | end 147 | 148 | def no_duplicates_for_columns? 149 | scope_string = Array(acts_as_nested_set_options[:scope]).map do |c| 150 | connection.quote_column_name(c) 151 | end.push(nil).join(", ") 152 | [quoted_left_column_name, quoted_right_column_name].all? do |column| 153 | # No duplicates 154 | first( 155 | :select => "#{scope_string}#{column}, COUNT(#{column})", 156 | :group => "#{scope_string}#{column} 157 | HAVING COUNT(#{column}) > 1").nil? 158 | end 159 | end 160 | 161 | # Wrapper for each_root_valid? that can deal with scope. 162 | def all_roots_valid? 163 | if acts_as_nested_set_options[:scope] 164 | roots(:group => scope_column_names).group_by{|record| scope_column_names.collect{|col| record.send(col.to_sym)}}.all? do |scope, grouped_roots| 165 | each_root_valid?(grouped_roots) 166 | end 167 | else 168 | each_root_valid?(roots) 169 | end 170 | end 171 | 172 | def each_root_valid?(roots_to_validate) 173 | left = right = 0 174 | roots_to_validate.all? do |root| 175 | (root.left > left && root.right > right).tap do 176 | left = root.left 177 | right = root.right 178 | end 179 | end 180 | end 181 | 182 | # Rebuilds the left & rights if unset or invalid. Also very useful for converting from acts_as_tree. 183 | def rebuild!(options = {}) 184 | # Don't rebuild a valid tree. 185 | return true if valid? 186 | 187 | scope = lambda{|node|} 188 | if acts_as_nested_set_options[:scope] 189 | scope = lambda{|node| 190 | scope_column_names.inject(""){|str, column_name| 191 | str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name.to_sym))} " 192 | } 193 | } 194 | end 195 | indices = {} 196 | 197 | set_left_and_rights = lambda do |node| 198 | # set left 199 | node[left_column_name] = indices[scope.call(node)] += 1 200 | # find 201 | where("#{quoted_parent_column_name} = ? #{scope.call(node)}", node). 202 | order("#{quoted_left_column_name}, #{quoted_right_column_name}, id"). 203 | all.each{|n| set_left_and_rights.call(n) } 204 | # set right 205 | node[right_column_name] = indices[scope.call(node)] += 1 206 | node.save! 207 | end 208 | 209 | # Find root node(s) 210 | root_nodes = where(parent_column_name => nil). 211 | order("#{quoted_left_column_name}, #{quoted_right_column_name}, id"). 212 | find(:all, options).each do |root_node| 213 | # setup index for this scope 214 | indices[scope.call(root_node)] ||= 0 215 | set_left_and_rights.call(root_node) 216 | end 217 | end 218 | 219 | def scope_condition_by_options(options, table_name = nil) 220 | table_name ||= self.quoted_table_name 221 | 222 | scope_string = Array(acts_as_nested_set_options[:scope]).reject{|s| !options.has_key?(s) }.map do |c| 223 | "#{table_name}.#{connection.quote_column_name(c)} = #{options[c]}" 224 | end.join(" AND ") 225 | 226 | scope_string.blank? ? "1 = 1" : scope_string 227 | end 228 | 229 | def rebuild_level!(options={}) 230 | scope_t = scope_condition_by_options(options) 231 | scope_a = scope_condition_by_options(options, 'a') 232 | 233 | query = "UPDATE #{quoted_table_name} a SET a.depth = \ 234 | (SELECT count(*) - 1 FROM (SELECT * FROM #{quoted_table_name} WHERE #{scope_t}) AS b \ 235 | WHERE #{scope_a} AND \ 236 | a.`user_id` = b.`user_id` AND \ 237 | (a.#{quoted_left_column_name} BETWEEN b.#{quoted_left_column_name} AND b.#{quoted_right_column_name})) 238 | WHERE #{scope_a} 239 | " 240 | 241 | connection.execute(query) 242 | end 243 | 244 | # Iterates over tree elements and determines the current level in the tree. 245 | # Only accepts default ordering, odering by an other column than lft 246 | # does not work. This method is much more efficent than calling level 247 | # because it doesn't require any additional database queries. 248 | # 249 | # Example: 250 | # Category.each_with_level(Category.root.self_and_descendants) do |o, level| 251 | # 252 | def each_with_level(objects) 253 | path = [nil] 254 | objects.each do |o| 255 | if o.parent_id != path.last 256 | # we are on a new level, did we decent or ascent? 257 | if path.include?(o.parent_id) 258 | # remove wrong wrong tailing paths elements 259 | path.pop while path.last != o.parent_id 260 | else 261 | path << o.parent_id 262 | end 263 | end 264 | yield(o, path.length - 1) 265 | end 266 | end 267 | 268 | # Provides a chainable relation to select all descendants of a set of records, excluding the record set itself. 269 | # Similar to parent.descendants, except this allows you to find all descendants of a set of nodes, 270 | # rather than being restricted to find the descendants of only a single node. 271 | # 272 | # Example: 273 | # parents = Category.roots.all 274 | # parents_descendants = Category.where(:deleted => false).descendants_of(parents) 275 | # 276 | def descendants_of(parents) 277 | where_sql = returning [] do |sql| 278 | parents.each do |parent| 279 | sql << "(#{quoted_table_name}.#{quoted_left_column_name} >= #{parent.left} AND #{quoted_table_name}.#{quoted_right_column_name} <= #{parent.right})" 280 | end 281 | end 282 | scoped.where(where_sql.join(" OR ")) 283 | end 284 | 285 | def before_move(*args, &block) 286 | set_callback :move, :before, *args, &block 287 | end 288 | 289 | def after_move(*args, &block) 290 | set_callback :move, :after, *args, &block 291 | end 292 | end 293 | 294 | # Mixed into both classes and instances to provide easy access to the column names 295 | module Columns 296 | def left_column_name 297 | acts_as_nested_set_options[:left_column] 298 | end 299 | 300 | def right_column_name 301 | acts_as_nested_set_options[:right_column] 302 | end 303 | 304 | def parent_column_name 305 | acts_as_nested_set_options[:parent_column] 306 | end 307 | 308 | def scope_column_names 309 | Array(acts_as_nested_set_options[:scope]) 310 | end 311 | 312 | def quoted_left_column_name 313 | connection.quote_column_name(left_column_name) 314 | end 315 | 316 | def quoted_right_column_name 317 | connection.quote_column_name(right_column_name) 318 | end 319 | 320 | def quoted_parent_column_name 321 | connection.quote_column_name(parent_column_name) 322 | end 323 | 324 | def quoted_scope_column_names 325 | scope_column_names.collect {|column_name| connection.quote_column_name(column_name) } 326 | end 327 | end 328 | 329 | # 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. 330 | # 331 | # category.self_and_descendants.count 332 | # category.ancestors.find(:all, :conditions => "name like '%foo%'") 333 | module InstanceMethods 334 | # Value of the parent column 335 | def parent_id 336 | self[parent_column_name] 337 | end 338 | 339 | # Value of the left column 340 | def left 341 | self[left_column_name] 342 | end 343 | 344 | # Value of the right column 345 | def right 346 | self[right_column_name] 347 | end 348 | 349 | # Returns true if this is a root node. 350 | def root? 351 | parent_id.nil? 352 | end 353 | 354 | def leaf? 355 | !new_record? && right - left == 1 356 | end 357 | 358 | # Returns true is this is a child node 359 | def child? 360 | !parent_id.nil? 361 | end 362 | 363 | # order by left column 364 | def <=>(x) 365 | left <=> x.left 366 | end 367 | 368 | # Redefine to act like active record 369 | def ==(comparison_object) 370 | comparison_object.equal?(self) || 371 | (comparison_object.instance_of?(self.class) && 372 | comparison_object.id == id && 373 | !comparison_object.new_record?) 374 | end 375 | 376 | # Returns root 377 | def root 378 | self_and_ancestors.first 379 | end 380 | 381 | # Returns the array of all parents and self 382 | def self_and_ancestors 383 | nested_set_scope.scoped.where( 384 | "#{self.class.quoted_table_name}.#{quoted_left_column_name} <= ? AND #{self.class.quoted_table_name}.#{quoted_right_column_name} >= ?", left, right 385 | ) 386 | end 387 | 388 | # Returns an array of all parents 389 | def ancestors 390 | without_self self_and_ancestors 391 | end 392 | 393 | # Returns the array of all children of the parent, including self 394 | def self_and_siblings 395 | nested_set_scope.scoped.where(parent_column_name => parent_id) 396 | end 397 | 398 | # Returns the array of all children of the parent, except self 399 | def siblings 400 | without_self self_and_siblings 401 | end 402 | 403 | # Returns a set of all of its nested children which do not have children 404 | def leaves 405 | descendants.scoped.where("#{self.class.quoted_table_name}.#{quoted_right_column_name} - #{self.class.quoted_table_name}.#{quoted_left_column_name} = 1") 406 | end 407 | 408 | # Returns the level of this object in the tree 409 | # root level is 0 410 | def level 411 | parent_id.nil? ? 0 : ancestors.count 412 | end 413 | 414 | # Returns a set of itself and all of its nested children 415 | def self_and_descendants 416 | nested_set_scope.scoped.where( 417 | "#{self.class.quoted_table_name}.#{quoted_left_column_name} >= ? AND #{self.class.quoted_table_name}.#{quoted_right_column_name} <= ?", left, right 418 | ) 419 | end 420 | 421 | # Returns a set of all of its children and nested children 422 | def descendants 423 | without_self self_and_descendants 424 | end 425 | 426 | def is_descendant_of?(other) 427 | other.left < self.left && self.left < other.right && same_scope?(other) 428 | end 429 | 430 | def is_or_is_descendant_of?(other) 431 | other.left <= self.left && self.left < other.right && same_scope?(other) 432 | end 433 | 434 | def is_ancestor_of?(other) 435 | self.left < other.left && other.left < self.right && same_scope?(other) 436 | end 437 | 438 | def is_or_is_ancestor_of?(other) 439 | self.left <= other.left && other.left < self.right && same_scope?(other) 440 | end 441 | 442 | # Check if other model is in the same scope 443 | def same_scope?(other) 444 | Array(acts_as_nested_set_options[:scope]).all? do |attr| 445 | self.send(attr) == other.send(attr) 446 | end 447 | end 448 | 449 | # Find the first sibling to the left 450 | def left_sibling 451 | siblings.first(:conditions => ["#{self.class.quoted_table_name}.#{quoted_left_column_name} < ?", left], 452 | :order => "#{self.class.quoted_table_name}.#{quoted_left_column_name} DESC") 453 | end 454 | 455 | # Find the first sibling to the right 456 | def right_sibling 457 | siblings.first(:conditions => ["#{self.class.quoted_table_name}.#{quoted_left_column_name} > ?", left]) 458 | end 459 | 460 | # Shorthand method for finding the left sibling and moving to the left of it. 461 | def move_left 462 | move_to_left_of left_sibling 463 | end 464 | 465 | # Shorthand method for finding the right sibling and moving to the right of it. 466 | def move_right 467 | move_to_right_of right_sibling 468 | end 469 | 470 | # Move the node to the left of another node (you can pass id only) 471 | def move_to_left_of(node) 472 | move_to node, :left 473 | end 474 | 475 | # Move the node to the left of another node (you can pass id only) 476 | def move_to_right_of(node) 477 | move_to node, :right 478 | end 479 | 480 | # Move the node to the child of another node (you can pass id only) 481 | def move_to_child_of(node) 482 | move_to node, :child 483 | end 484 | 485 | # Move the node to root nodes 486 | def move_to_root 487 | move_to nil, :root 488 | end 489 | 490 | def move_possible?(target) 491 | self != target && # Can't target self 492 | same_scope?(target) && # can't be in different scopes 493 | # !(left..right).include?(target.left..target.right) # this needs tested more 494 | # detect impossible move 495 | !((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right)) 496 | end 497 | 498 | def to_text 499 | self_and_descendants.map do |node| 500 | "#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})" 501 | end.join("\n") 502 | end 503 | 504 | protected 505 | 506 | def without_self(scope) 507 | scope.where("#{self.class.quoted_table_name}.#{self.class.primary_key} != ?", self) 508 | end 509 | 510 | # All nested set queries should use this nested_set_scope, which performs finds on 511 | # the base ActiveRecord class, using the :scope declared in the acts_as_nested_set 512 | # declaration. 513 | def nested_set_scope 514 | options = {:order => quoted_left_column_name} 515 | scopes = Array(acts_as_nested_set_options[:scope]) 516 | options[:conditions] = scopes.inject({}) do |conditions,attr| 517 | conditions.merge attr => self[attr] 518 | end unless scopes.empty? 519 | self.class.base_class.scoped options 520 | end 521 | 522 | def store_new_parent 523 | @move_to_new_parent_id = send("#{parent_column_name}_changed?") ? parent_id : false 524 | true # force callback to return true 525 | end 526 | 527 | def move_to_new_parent 528 | if @move_to_new_parent_id.nil? 529 | move_to_root 530 | elsif @move_to_new_parent_id 531 | move_to_child_of(@move_to_new_parent_id) 532 | end 533 | end 534 | 535 | # on creation, set automatically lft and rgt to the end of the tree 536 | def set_default_left_and_right 537 | maxright = nested_set_scope.maximum(right_column_name) || 0 538 | # adds the new node to the right of all existing nodes 539 | self[left_column_name] = maxright + 1 540 | self[right_column_name] = maxright + 2 541 | end 542 | 543 | # Prunes a branch off of the tree, shifting all of the elements on the right 544 | # back to the left so the counts still work. 545 | def destroy_descendants 546 | return if right.nil? || left.nil? || skip_before_destroy 547 | 548 | self.class.base_class.transaction do 549 | if acts_as_nested_set_options[:dependent] == :destroy 550 | descendants.each do |model| 551 | model.skip_before_destroy = true 552 | model.destroy 553 | end 554 | else 555 | nested_set_scope.delete_all( 556 | ["#{quoted_left_column_name} > ? AND #{quoted_right_column_name} < ?", 557 | left, right] 558 | ) 559 | end 560 | 561 | # update lefts and rights for remaining nodes 562 | diff = right - left + 1 563 | nested_set_scope.update_all( 564 | ["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff], 565 | ["#{quoted_left_column_name} > ?", right] 566 | ) 567 | nested_set_scope.update_all( 568 | ["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff], 569 | ["#{quoted_right_column_name} > ?", right] 570 | ) 571 | 572 | # Don't allow multiple calls to destroy to corrupt the set 573 | self.skip_before_destroy = true 574 | end 575 | end 576 | 577 | # reload left, right, and parent 578 | def reload_nested_set 579 | reload(:select => "#{quoted_left_column_name}, " + 580 | "#{quoted_right_column_name}, #{quoted_parent_column_name}") 581 | end 582 | 583 | def move_to(target, position) 584 | raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record? 585 | 586 | res = run_callbacks :move do 587 | transaction do 588 | if target.is_a? self.class.base_class 589 | target.reload_nested_set 590 | elsif position != :root 591 | # load object if node is not an object 592 | target = nested_set_scope.find(target) 593 | end 594 | self.reload_nested_set 595 | 596 | unless position == :root || move_possible?(target) 597 | raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree." 598 | end 599 | 600 | bound = case position 601 | when :child; target[right_column_name] 602 | when :left; target[left_column_name] 603 | when :right; target[right_column_name] + 1 604 | when :root; 1 605 | else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)." 606 | end 607 | 608 | if bound > self[right_column_name] 609 | bound = bound - 1 610 | other_bound = self[right_column_name] + 1 611 | else 612 | other_bound = self[left_column_name] - 1 613 | end 614 | 615 | # there would be no change 616 | return if bound == self[right_column_name] || bound == self[left_column_name] 617 | 618 | # we have defined the boundaries of two non-overlapping intervals, 619 | # so sorting puts both the intervals and their boundaries in order 620 | a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort 621 | 622 | new_parent = case position 623 | when :child; target.id 624 | when :root; nil 625 | else target[parent_column_name] 626 | end 627 | 628 | nested_set_scope.update_all([ 629 | "#{quoted_left_column_name} = CASE " + 630 | "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " + 631 | "THEN #{quoted_left_column_name} + :d - :b " + 632 | "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " + 633 | "THEN #{quoted_left_column_name} + :a - :c " + 634 | "ELSE #{quoted_left_column_name} END, " + 635 | "#{quoted_right_column_name} = CASE " + 636 | "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " + 637 | "THEN #{quoted_right_column_name} + :d - :b " + 638 | "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " + 639 | "THEN #{quoted_right_column_name} + :a - :c " + 640 | "ELSE #{quoted_right_column_name} END, " + 641 | "#{quoted_parent_column_name} = CASE " + 642 | "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " + 643 | "ELSE #{quoted_parent_column_name} END", 644 | {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent} 645 | ]) 646 | end 647 | target.reload_nested_set if target 648 | self.reload_nested_set 649 | self.update_depth if depth? 650 | end 651 | end 652 | end 653 | end # Base 654 | end # NestedSet 655 | end # Acts 656 | end # CollectiveIdea 657 | -------------------------------------------------------------------------------- /test/awesome_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 | acts_as_nested_set 9 | set_table_name 'categories' 10 | end 11 | 12 | class ScopedCategory < ActiveRecord::Base 13 | acts_as_nested_set :scope => :organization 14 | set_table_name 'categories' 15 | end 16 | 17 | class RenamedColumns < ActiveRecord::Base 18 | acts_as_nested_set :parent_column => 'mother_id', :left_column => 'red', :right_column => 'black' 19 | end 20 | 21 | class AwesomeNestedSetTest < ActiveSupport::TestCase 22 | 23 | def test_left_column_default 24 | assert_equal 'lft', Default.acts_as_nested_set_options[:left_column] 25 | end 26 | 27 | def test_right_column_default 28 | assert_equal 'rgt', Default.acts_as_nested_set_options[:right_column] 29 | end 30 | 31 | def test_parent_column_default 32 | assert_equal 'parent_id', Default.acts_as_nested_set_options[:parent_column] 33 | end 34 | 35 | def test_scope_default 36 | assert_nil Default.acts_as_nested_set_options[:scope] 37 | end 38 | 39 | def test_left_column_name 40 | assert_equal 'lft', Default.left_column_name 41 | assert_equal 'lft', Default.new.left_column_name 42 | assert_equal 'red', RenamedColumns.left_column_name 43 | assert_equal 'red', RenamedColumns.new.left_column_name 44 | end 45 | 46 | def test_right_column_name 47 | assert_equal 'rgt', Default.right_column_name 48 | assert_equal 'rgt', Default.new.right_column_name 49 | assert_equal 'black', RenamedColumns.right_column_name 50 | assert_equal 'black', RenamedColumns.new.right_column_name 51 | end 52 | 53 | def test_parent_column_name 54 | assert_equal 'parent_id', Default.parent_column_name 55 | assert_equal 'parent_id', Default.new.parent_column_name 56 | assert_equal 'mother_id', RenamedColumns.parent_column_name 57 | assert_equal 'mother_id', RenamedColumns.new.parent_column_name 58 | end 59 | 60 | def test_creation_with_altered_column_names 61 | assert_nothing_raised do 62 | RenamedColumns.create!() 63 | end 64 | end 65 | 66 | def test_quoted_left_column_name 67 | quoted = Default.connection.quote_column_name('lft') 68 | assert_equal quoted, Default.quoted_left_column_name 69 | assert_equal quoted, Default.new.quoted_left_column_name 70 | end 71 | 72 | def test_quoted_right_column_name 73 | quoted = Default.connection.quote_column_name('rgt') 74 | assert_equal quoted, Default.quoted_right_column_name 75 | assert_equal quoted, Default.new.quoted_right_column_name 76 | end 77 | 78 | def test_left_column_protected_from_assignment 79 | assert_raises(ActiveRecord::ActiveRecordError) { Category.new.lft = 1 } 80 | end 81 | 82 | def test_right_column_protected_from_assignment 83 | assert_raises(ActiveRecord::ActiveRecordError) { Category.new.rgt = 1 } 84 | end 85 | 86 | def test_colums_protected_on_initialize 87 | c = Category.new(:lft => 1, :rgt => 2) 88 | assert_nil c.lft 89 | assert_nil c.rgt 90 | end 91 | 92 | def test_scoped_appends_id 93 | assert_equal :organization_id, ScopedCategory.acts_as_nested_set_options[:scope] 94 | end 95 | 96 | def test_roots_class_method 97 | assert_equal Category.find_all_by_parent_id(nil), Category.roots 98 | end 99 | 100 | def test_root_class_method 101 | assert_equal categories(:top_level), Category.root 102 | end 103 | 104 | def test_root 105 | assert_equal categories(:top_level), categories(:child_3).root 106 | end 107 | 108 | def test_root? 109 | assert categories(:top_level).root? 110 | assert categories(:top_level_2).root? 111 | end 112 | 113 | def test_leaves_class_method 114 | assert_equal Category.find(:all, :conditions => "#{Category.right_column_name} - #{Category.left_column_name} = 1"), Category.leaves 115 | assert_equal Category.leaves.count, 4 116 | assert (Category.leaves.include? categories(:child_1)) 117 | assert (Category.leaves.include? categories(:child_2_1)) 118 | assert (Category.leaves.include? categories(:child_3)) 119 | assert (Category.leaves.include? categories(:top_level_2)) 120 | end 121 | 122 | def test_leaf 123 | assert categories(:child_1).leaf? 124 | assert categories(:child_2_1).leaf? 125 | assert categories(:child_3).leaf? 126 | assert categories(:top_level_2).leaf? 127 | 128 | assert !categories(:top_level).leaf? 129 | assert !categories(:child_2).leaf? 130 | assert !Category.new.leaf? 131 | end 132 | 133 | 134 | def test_parent 135 | assert_equal categories(:child_2), categories(:child_2_1).parent 136 | end 137 | 138 | def test_self_and_ancestors 139 | child = categories(:child_2_1) 140 | self_and_ancestors = [categories(:top_level), categories(:child_2), child] 141 | assert_equal self_and_ancestors, child.self_and_ancestors 142 | end 143 | 144 | def test_ancestors 145 | child = categories(:child_2_1) 146 | ancestors = [categories(:top_level), categories(:child_2)] 147 | assert_equal ancestors, child.ancestors 148 | end 149 | 150 | def test_self_and_siblings 151 | child = categories(:child_2) 152 | self_and_siblings = [categories(:child_1), child, categories(:child_3)] 153 | assert_equal self_and_siblings, child.self_and_siblings 154 | assert_nothing_raised do 155 | tops = [categories(:top_level), categories(:top_level_2)] 156 | assert_equal tops, categories(:top_level).self_and_siblings 157 | end 158 | end 159 | 160 | def test_siblings 161 | child = categories(:child_2) 162 | siblings = [categories(:child_1), categories(:child_3)] 163 | assert_equal siblings, child.siblings 164 | end 165 | 166 | def test_leaves 167 | leaves = [categories(:child_1), categories(:child_2_1), categories(:child_3), categories(:top_level_2)] 168 | assert categories(:top_level).leaves, leaves 169 | end 170 | 171 | def test_level 172 | assert_equal 0, categories(:top_level).level 173 | assert_equal 1, categories(:child_1).level 174 | assert_equal 2, categories(:child_2_1).level 175 | end 176 | 177 | def test_has_children? 178 | assert categories(:child_2_1).children.empty? 179 | assert !categories(:child_2).children.empty? 180 | assert !categories(:top_level).children.empty? 181 | end 182 | 183 | def test_self_and_descendents 184 | parent = categories(:top_level) 185 | self_and_descendants = [parent, categories(:child_1), categories(:child_2), 186 | categories(:child_2_1), categories(:child_3)] 187 | assert_equal self_and_descendants, parent.self_and_descendants 188 | assert_equal self_and_descendants, parent.self_and_descendants.count 189 | end 190 | 191 | def test_descendents 192 | lawyers = Category.create!(:name => "lawyers") 193 | us = Category.create!(:name => "United States") 194 | us.move_to_child_of(lawyers) 195 | patent = Category.create!(:name => "Patent Law") 196 | patent.move_to_child_of(us) 197 | lawyers.reload 198 | 199 | assert_equal 1, lawyers.children.size 200 | assert_equal 1, us.children.size 201 | assert_equal 2, lawyers.descendants.size 202 | end 203 | 204 | def test_self_and_descendents 205 | parent = categories(:top_level) 206 | descendants = [categories(:child_1), categories(:child_2), 207 | categories(:child_2_1), categories(:child_3)] 208 | assert_equal descendants, parent.descendants 209 | end 210 | 211 | def test_children 212 | category = categories(:top_level) 213 | category.children.each {|c| assert_equal category.id, c.parent_id } 214 | end 215 | 216 | def test_order_of_children 217 | categories(:child_2).move_left 218 | assert_equal categories(:child_2), categories(:top_level).children[0] 219 | assert_equal categories(:child_1), categories(:top_level).children[1] 220 | assert_equal categories(:child_3), categories(:top_level).children[2] 221 | end 222 | 223 | def test_is_or_is_ancestor_of? 224 | assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_1)) 225 | assert categories(:top_level).is_or_is_ancestor_of?(categories(:child_2_1)) 226 | assert categories(:child_2).is_or_is_ancestor_of?(categories(:child_2_1)) 227 | assert !categories(:child_2_1).is_or_is_ancestor_of?(categories(:child_2)) 228 | assert !categories(:child_1).is_or_is_ancestor_of?(categories(:child_2)) 229 | assert categories(:child_1).is_or_is_ancestor_of?(categories(:child_1)) 230 | end 231 | 232 | def test_is_ancestor_of? 233 | assert categories(:top_level).is_ancestor_of?(categories(:child_1)) 234 | assert categories(:top_level).is_ancestor_of?(categories(:child_2_1)) 235 | assert categories(:child_2).is_ancestor_of?(categories(:child_2_1)) 236 | assert !categories(:child_2_1).is_ancestor_of?(categories(:child_2)) 237 | assert !categories(:child_1).is_ancestor_of?(categories(:child_2)) 238 | assert !categories(:child_1).is_ancestor_of?(categories(:child_1)) 239 | end 240 | 241 | def test_is_or_is_ancestor_of_with_scope 242 | root = ScopedCategory.root 243 | child = root.children.first 244 | assert root.is_or_is_ancestor_of?(child) 245 | child.update_attribute :organization_id, 'different' 246 | assert !root.is_or_is_ancestor_of?(child) 247 | end 248 | 249 | def test_is_or_is_descendant_of? 250 | assert categories(:child_1).is_or_is_descendant_of?(categories(:top_level)) 251 | assert categories(:child_2_1).is_or_is_descendant_of?(categories(:top_level)) 252 | assert categories(:child_2_1).is_or_is_descendant_of?(categories(:child_2)) 253 | assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_2_1)) 254 | assert !categories(:child_2).is_or_is_descendant_of?(categories(:child_1)) 255 | assert categories(:child_1).is_or_is_descendant_of?(categories(:child_1)) 256 | end 257 | 258 | def test_is_descendant_of? 259 | assert categories(:child_1).is_descendant_of?(categories(:top_level)) 260 | assert categories(:child_2_1).is_descendant_of?(categories(:top_level)) 261 | assert categories(:child_2_1).is_descendant_of?(categories(:child_2)) 262 | assert !categories(:child_2).is_descendant_of?(categories(:child_2_1)) 263 | assert !categories(:child_2).is_descendant_of?(categories(:child_1)) 264 | assert !categories(:child_1).is_descendant_of?(categories(:child_1)) 265 | end 266 | 267 | def test_is_or_is_descendant_of_with_scope 268 | root = ScopedCategory.root 269 | child = root.children.first 270 | assert child.is_or_is_descendant_of?(root) 271 | child.update_attribute :organization_id, 'different' 272 | assert !child.is_or_is_descendant_of?(root) 273 | end 274 | 275 | def test_same_scope? 276 | root = ScopedCategory.root 277 | child = root.children.first 278 | assert child.same_scope?(root) 279 | child.update_attribute :organization_id, 'different' 280 | assert !child.same_scope?(root) 281 | end 282 | 283 | def test_left_sibling 284 | assert_equal categories(:child_1), categories(:child_2).left_sibling 285 | assert_equal categories(:child_2), categories(:child_3).left_sibling 286 | end 287 | 288 | def test_left_sibling_of_root 289 | assert_nil categories(:top_level).left_sibling 290 | end 291 | 292 | def test_left_sibling_without_siblings 293 | assert_nil categories(:child_2_1).left_sibling 294 | end 295 | 296 | def test_left_sibling_of_leftmost_node 297 | assert_nil categories(:child_1).left_sibling 298 | end 299 | 300 | def test_right_sibling 301 | assert_equal categories(:child_3), categories(:child_2).right_sibling 302 | assert_equal categories(:child_2), categories(:child_1).right_sibling 303 | end 304 | 305 | def test_right_sibling_of_root 306 | assert_equal categories(:top_level_2), categories(:top_level).right_sibling 307 | assert_nil categories(:top_level_2).right_sibling 308 | end 309 | 310 | def test_right_sibling_without_siblings 311 | assert_nil categories(:child_2_1).right_sibling 312 | end 313 | 314 | def test_right_sibling_of_rightmost_node 315 | assert_nil categories(:child_3).right_sibling 316 | end 317 | 318 | def test_move_left 319 | categories(:child_2).move_left 320 | assert_nil categories(:child_2).left_sibling 321 | assert_equal categories(:child_1), categories(:child_2).right_sibling 322 | assert Category.valid? 323 | end 324 | 325 | def test_move_right 326 | categories(:child_2).move_right 327 | assert_nil categories(:child_2).right_sibling 328 | assert_equal categories(:child_3), categories(:child_2).left_sibling 329 | assert Category.valid? 330 | end 331 | 332 | def test_move_to_left_of 333 | categories(:child_3).move_to_left_of(categories(:child_1)) 334 | assert_nil categories(:child_3).left_sibling 335 | assert_equal categories(:child_1), categories(:child_3).right_sibling 336 | assert Category.valid? 337 | end 338 | 339 | def test_move_to_right_of 340 | categories(:child_1).move_to_right_of(categories(:child_3)) 341 | assert_nil categories(:child_1).right_sibling 342 | assert_equal categories(:child_3), categories(:child_1).left_sibling 343 | assert Category.valid? 344 | end 345 | 346 | def test_move_to_root 347 | categories(:child_2).move_to_root 348 | assert_nil categories(:child_2).parent 349 | assert_equal 0, categories(:child_2).level 350 | assert_equal 1, categories(:child_2_1).level 351 | assert_equal 1, categories(:child_2).left 352 | assert_equal 4, categories(:child_2).right 353 | assert Category.valid? 354 | end 355 | 356 | def test_move_to_child_of 357 | categories(:child_1).move_to_child_of(categories(:child_3)) 358 | assert_equal categories(:child_3).id, categories(:child_1).parent_id 359 | assert Category.valid? 360 | end 361 | 362 | def test_move_to_child_of_appends_to_end 363 | child = Category.create! :name => 'New Child' 364 | child.move_to_child_of categories(:top_level) 365 | assert_equal child, categories(:top_level).children.last 366 | end 367 | 368 | def test_subtree_move_to_child_of 369 | assert_equal 4, categories(:child_2).left 370 | assert_equal 7, categories(:child_2).right 371 | 372 | assert_equal 2, categories(:child_1).left 373 | assert_equal 3, categories(:child_1).right 374 | 375 | categories(:child_2).move_to_child_of(categories(:child_1)) 376 | assert Category.valid? 377 | assert_equal categories(:child_1).id, categories(:child_2).parent_id 378 | 379 | assert_equal 3, categories(:child_2).left 380 | assert_equal 6, categories(:child_2).right 381 | assert_equal 2, categories(:child_1).left 382 | assert_equal 7, categories(:child_1).right 383 | end 384 | 385 | def test_slightly_difficult_move_to_child_of 386 | assert_equal 11, categories(:top_level_2).left 387 | assert_equal 12, categories(:top_level_2).right 388 | 389 | # create a new top-level node and move single-node top-level tree inside it. 390 | new_top = Category.create(:name => 'New Top') 391 | assert_equal 13, new_top.left 392 | assert_equal 14, new_top.right 393 | 394 | categories(:top_level_2).move_to_child_of(new_top) 395 | 396 | assert Category.valid? 397 | assert_equal new_top.id, categories(:top_level_2).parent_id 398 | 399 | assert_equal 12, categories(:top_level_2).left 400 | assert_equal 13, categories(:top_level_2).right 401 | assert_equal 11, new_top.left 402 | assert_equal 14, new_top.right 403 | end 404 | 405 | def test_difficult_move_to_child_of 406 | assert_equal 1, categories(:top_level).left 407 | assert_equal 10, categories(:top_level).right 408 | assert_equal 5, categories(:child_2_1).left 409 | assert_equal 6, categories(:child_2_1).right 410 | 411 | # create a new top-level node and move an entire top-level tree inside it. 412 | new_top = Category.create(:name => 'New Top') 413 | categories(:top_level).move_to_child_of(new_top) 414 | categories(:child_2_1).reload 415 | assert Category.valid? 416 | assert_equal new_top.id, categories(:top_level).parent_id 417 | 418 | assert_equal 4, categories(:top_level).left 419 | assert_equal 13, categories(:top_level).right 420 | assert_equal 8, categories(:child_2_1).left 421 | assert_equal 9, categories(:child_2_1).right 422 | end 423 | 424 | #rebuild swaps the position of the 2 children when added using move_to_child twice onto same parent 425 | def test_move_to_child_more_than_once_per_parent_rebuild 426 | root1 = Category.create(:name => 'Root1') 427 | root2 = Category.create(:name => 'Root2') 428 | root3 = Category.create(:name => 'Root3') 429 | 430 | root2.move_to_child_of root1 431 | root3.move_to_child_of root1 432 | 433 | output = Category.roots.last.to_text 434 | Category.update_all('lft = null, rgt = null') 435 | Category.rebuild! 436 | 437 | assert_equal Category.roots.last.to_text, output 438 | end 439 | 440 | # doing move_to_child twice onto same parent from the furthest right first 441 | def test_move_to_child_more_than_once_per_parent_outside_in 442 | node1 = Category.create(:name => 'Node-1') 443 | node2 = Category.create(:name => 'Node-2') 444 | node3 = Category.create(:name => 'Node-3') 445 | 446 | node2.move_to_child_of node1 447 | node3.move_to_child_of node1 448 | 449 | output = Category.roots.last.to_text 450 | Category.update_all('lft = null, rgt = null') 451 | Category.rebuild! 452 | 453 | assert_equal Category.roots.last.to_text, output 454 | end 455 | 456 | 457 | def test_valid_with_null_lefts 458 | assert Category.valid? 459 | Category.update_all('lft = null') 460 | assert !Category.valid? 461 | end 462 | 463 | def test_valid_with_null_rights 464 | assert Category.valid? 465 | Category.update_all('rgt = null') 466 | assert !Category.valid? 467 | end 468 | 469 | def test_valid_with_missing_intermediate_node 470 | # Even though child_2_1 will still exist, it is a sign of a sloppy delete, not an invalid tree. 471 | assert Category.valid? 472 | Category.delete(categories(:child_2).id) 473 | assert Category.valid? 474 | end 475 | 476 | def test_valid_with_overlapping_and_rights 477 | assert Category.valid? 478 | categories(:top_level_2)['lft'] = 0 479 | categories(:top_level_2).save 480 | assert !Category.valid? 481 | end 482 | 483 | def test_rebuild 484 | assert Category.valid? 485 | before_text = Category.root.to_text 486 | Category.update_all('lft = null, rgt = null') 487 | Category.rebuild! 488 | assert Category.valid? 489 | assert_equal before_text, Category.root.to_text 490 | end 491 | 492 | def test_move_possible_for_sibling 493 | assert categories(:child_2).move_possible?(categories(:child_1)) 494 | end 495 | 496 | def test_move_not_possible_to_self 497 | assert !categories(:top_level).move_possible?(categories(:top_level)) 498 | end 499 | 500 | def test_move_not_possible_to_parent 501 | categories(:top_level).descendants.each do |descendant| 502 | assert !categories(:top_level).move_possible?(descendant) 503 | assert descendant.move_possible?(categories(:top_level)) 504 | end 505 | end 506 | 507 | def test_is_or_is_ancestor_of? 508 | [:child_1, :child_2, :child_2_1, :child_3].each do |c| 509 | assert categories(:top_level).is_or_is_ancestor_of?(categories(c)) 510 | end 511 | assert !categories(:top_level).is_or_is_ancestor_of?(categories(:top_level_2)) 512 | end 513 | 514 | def test_left_and_rights_valid_with_blank_left 515 | assert Category.left_and_rights_valid? 516 | categories(:child_2)[:lft] = nil 517 | categories(:child_2).save(:validate => false) 518 | assert !Category.left_and_rights_valid? 519 | end 520 | 521 | def test_left_and_rights_valid_with_blank_right 522 | assert Category.left_and_rights_valid? 523 | categories(:child_2)[:rgt] = nil 524 | categories(:child_2).save(:validate => false) 525 | assert !Category.left_and_rights_valid? 526 | end 527 | 528 | def test_left_and_rights_valid_with_equal 529 | assert Category.left_and_rights_valid? 530 | categories(:top_level_2)[:lft] = categories(:top_level_2)[:rgt] 531 | categories(:top_level_2).save(:validate => false) 532 | assert !Category.left_and_rights_valid? 533 | end 534 | 535 | def test_left_and_rights_valid_with_left_equal_to_parent 536 | assert Category.left_and_rights_valid? 537 | categories(:child_2)[:lft] = categories(:top_level)[:lft] 538 | categories(:child_2).save(:validate => false) 539 | assert !Category.left_and_rights_valid? 540 | end 541 | 542 | def test_left_and_rights_valid_with_right_equal_to_parent 543 | assert Category.left_and_rights_valid? 544 | categories(:child_2)[:rgt] = categories(:top_level)[:rgt] 545 | categories(:child_2).save(:validate => false) 546 | assert !Category.left_and_rights_valid? 547 | end 548 | 549 | def test_moving_dirty_objects_doesnt_invalidate_tree 550 | r1 = Category.create 551 | r2 = Category.create 552 | r3 = Category.create 553 | r4 = Category.create 554 | nodes = [r1, r2, r3, r4] 555 | 556 | r2.move_to_child_of(r1) 557 | assert Category.valid? 558 | 559 | r3.move_to_child_of(r1) 560 | assert Category.valid? 561 | 562 | r4.move_to_child_of(r2) 563 | assert Category.valid? 564 | end 565 | 566 | def test_multi_scoped_no_duplicates_for_columns? 567 | assert_nothing_raised do 568 | Note.no_duplicates_for_columns? 569 | end 570 | end 571 | 572 | def test_multi_scoped_all_roots_valid? 573 | assert_nothing_raised do 574 | Note.all_roots_valid? 575 | end 576 | end 577 | 578 | def test_multi_scoped 579 | note1 = Note.create!(:body => "A", :notable_id => 2, :notable_type => 'Category') 580 | note2 = Note.create!(:body => "B", :notable_id => 2, :notable_type => 'Category') 581 | note3 = Note.create!(:body => "C", :notable_id => 2, :notable_type => 'Default') 582 | 583 | assert_equal [note1, note2], note1.self_and_siblings 584 | assert_equal [note3], note3.self_and_siblings 585 | end 586 | 587 | def test_multi_scoped_rebuild 588 | root = Note.create!(:body => "A", :notable_id => 3, :notable_type => 'Category') 589 | child1 = Note.create!(:body => "B", :notable_id => 3, :notable_type => 'Category') 590 | child2 = Note.create!(:body => "C", :notable_id => 3, :notable_type => 'Category') 591 | 592 | child1.move_to_child_of root 593 | child2.move_to_child_of root 594 | 595 | Note.update_all('lft = null, rgt = null') 596 | Note.rebuild! 597 | 598 | assert_equal Note.roots.find_by_body('A'), root 599 | assert_equal [child1, child2], Note.roots.find_by_body('A').children 600 | end 601 | 602 | def test_same_scope_with_multi_scopes 603 | assert_nothing_raised do 604 | notes(:scope1).same_scope?(notes(:child_1)) 605 | end 606 | assert notes(:scope1).same_scope?(notes(:child_1)) 607 | assert notes(:child_1).same_scope?(notes(:scope1)) 608 | assert !notes(:scope1).same_scope?(notes(:scope2)) 609 | end 610 | 611 | def test_quoting_of_multi_scope_column_names 612 | assert_equal ["\"notable_id\"", "\"notable_type\""], Note.quoted_scope_column_names 613 | end 614 | 615 | def test_equal_in_same_scope 616 | assert_equal notes(:scope1), notes(:scope1) 617 | assert_not_equal notes(:scope1), notes(:child_1) 618 | end 619 | 620 | def test_equal_in_different_scopes 621 | assert_not_equal notes(:scope1), notes(:scope2) 622 | end 623 | 624 | def test_delete_does_not_invalidate 625 | Category.acts_as_nested_set_options[:dependent] = :delete 626 | categories(:child_2).destroy 627 | assert Category.valid? 628 | end 629 | 630 | def test_destroy_does_not_invalidate 631 | Category.acts_as_nested_set_options[:dependent] = :destroy 632 | categories(:child_2).destroy 633 | assert Category.valid? 634 | end 635 | 636 | def test_destroy_multiple_times_does_not_invalidate 637 | Category.acts_as_nested_set_options[:dependent] = :destroy 638 | categories(:child_2).destroy 639 | categories(:child_2).destroy 640 | assert Category.valid? 641 | end 642 | 643 | def test_assigning_parent_id_on_create 644 | category = Category.create!(:name => "Child", :parent_id => categories(:child_2).id) 645 | assert_equal categories(:child_2), category.parent 646 | assert_equal categories(:child_2).id, category.parent_id 647 | assert_not_nil category.left 648 | assert_not_nil category.right 649 | assert Category.valid? 650 | end 651 | 652 | def test_assigning_parent_on_create 653 | category = Category.create!(:name => "Child", :parent => categories(:child_2)) 654 | assert_equal categories(:child_2), category.parent 655 | assert_equal categories(:child_2).id, category.parent_id 656 | assert_not_nil category.left 657 | assert_not_nil category.right 658 | assert Category.valid? 659 | end 660 | 661 | def test_assigning_parent_id_to_nil_on_create 662 | category = Category.create!(:name => "New Root", :parent_id => nil) 663 | assert_nil category.parent 664 | assert_nil category.parent_id 665 | assert_not_nil category.left 666 | assert_not_nil category.right 667 | assert Category.valid? 668 | end 669 | 670 | def test_assigning_parent_id_on_update 671 | category = categories(:child_2_1) 672 | category.parent_id = categories(:child_3).id 673 | category.save 674 | assert_equal categories(:child_3), category.parent 675 | assert_equal categories(:child_3).id, category.parent_id 676 | assert Category.valid? 677 | end 678 | 679 | def test_assigning_parent_on_update 680 | category = categories(:child_2_1) 681 | category.parent = categories(:child_3) 682 | category.save 683 | assert_equal categories(:child_3), category.parent 684 | assert_equal categories(:child_3).id, category.parent_id 685 | assert Category.valid? 686 | end 687 | 688 | def test_assigning_parent_id_to_nil_on_update 689 | category = categories(:child_2_1) 690 | category.parent_id = nil 691 | category.save 692 | assert_nil category.parent 693 | assert_nil category.parent_id 694 | assert Category.valid? 695 | end 696 | 697 | def test_creating_child_from_parent 698 | category = categories(:child_2).children.create!(:name => "Child") 699 | assert_equal categories(:child_2), category.parent 700 | assert_equal categories(:child_2).id, category.parent_id 701 | assert_not_nil category.left 702 | assert_not_nil category.right 703 | assert Category.valid? 704 | end 705 | 706 | def check_structure(entries, structure) 707 | structure = structure.dup 708 | Category.each_with_level(entries) do |category, level| 709 | expected_level, expected_name = structure.shift 710 | assert_equal expected_name, category.name, "wrong category" 711 | assert_equal expected_level, level, "wrong level for #{category.name}" 712 | end 713 | end 714 | 715 | def test_each_with_level 716 | levels = [ 717 | [0, "Top Level"], 718 | [1, "Child 1"], 719 | [1, "Child 2"], 720 | [2, "Child 2.1"], 721 | [1, "Child 3" ]] 722 | 723 | check_structure(Category.root.self_and_descendants, levels) 724 | 725 | # test some deeper structures 726 | category = Category.find_by_name("Child 1") 727 | c1 = Category.new(:name => "Child 1.1") 728 | c2 = Category.new(:name => "Child 1.1.1") 729 | c3 = Category.new(:name => "Child 1.1.1.1") 730 | c4 = Category.new(:name => "Child 1.2") 731 | [c1, c2, c3, c4].each(&:save!) 732 | 733 | c1.move_to_child_of(category) 734 | c2.move_to_child_of(c1) 735 | c3.move_to_child_of(c2) 736 | c4.move_to_child_of(category) 737 | 738 | levels = [ 739 | [0, "Top Level"], 740 | [1, "Child 1"], 741 | [2, "Child 1.1"], 742 | [3, "Child 1.1.1"], 743 | [4, "Child 1.1.1.1"], 744 | [2, "Child 1.2"], 745 | [1, "Child 2"], 746 | [2, "Child 2.1"], 747 | [1, "Child 3" ]] 748 | 749 | check_structure(Category.root.self_and_descendants, levels) 750 | end 751 | 752 | def test_model_with_attr_accessible 753 | model = Class.new(ActiveRecord::Base) 754 | model.set_table_name 'categories' 755 | model.attr_accessible :name 756 | assert_nothing_raised do 757 | model.acts_as_nested_set 758 | model.new(:name => 'foo') 759 | end 760 | end 761 | 762 | def test_before_move_callback 763 | $called = false 764 | Category.before_move { |r| $called = true } 765 | 766 | categories(:child_2).move_to_root 767 | assert $called 768 | ensure 769 | Category.class_eval { reset_callbacks :move } 770 | end 771 | 772 | def test_before_move_callback_returning_false_stops_move 773 | Category.before_move { |r| false } 774 | 775 | assert !categories(:child_3).move_to_root 776 | assert !categories(:child_3).root? 777 | ensure 778 | Category.class_eval { reset_callbacks :move } 779 | end 780 | 781 | def test_before_move_callback_returning_false_halts_save 782 | Category.before_move { |r| false } 783 | 784 | categories(:child_3).parent_id = nil 785 | assert !categories(:child_3).save 786 | ensure 787 | Category.class_eval { reset_callbacks :move } 788 | end 789 | 790 | def test_calls_after_move_when_moving 791 | $called = false 792 | Category.after_move { $called = true } 793 | categories(:child_3).parent = categories(:child_2) 794 | assert categories(:child_3).save 795 | assert $called 796 | ensure 797 | Category.class_eval { reset_callbacks :move } 798 | end 799 | end 800 | --------------------------------------------------------------------------------