diff options
author | Toshi MARUYAMA <marutosijp2@yahoo.co.jp> | 2014-05-24 10:16:38 +0000 |
---|---|---|
committer | Toshi MARUYAMA <marutosijp2@yahoo.co.jp> | 2014-05-24 10:16:38 +0000 |
commit | 43e84c6c1070671ee0e86f52e58ebb6f8d83b938 (patch) | |
tree | 44e25f88bc11f3c757ec28e503934dec2a7fb8d9 | |
parent | 5368af48479d146cd8559eeeab2b96ca546feff0 (diff) | |
download | redmine-43e84c6c1070671ee0e86f52e58ebb6f8d83b938.tar.gz redmine-43e84c6c1070671ee0e86f52e58ebb6f8d83b938.zip |
awesome_nested_set: import git 2-1-stable branch revision 606847769 (#6579)
https://github.com/collectiveidea/awesome_nested_set/commit/606847769
git-svn-id: http://svn.redmine.org/redmine/trunk@13143 e93f8b46-1217-0410-a6f0-8f06a7374b81
26 files changed, 1278 insertions, 918 deletions
diff --git a/lib/plugins/awesome_nested_set/.travis.yml b/lib/plugins/awesome_nested_set/.travis.yml index 23152319f..0456da4fa 100644 --- a/lib/plugins/awesome_nested_set/.travis.yml +++ b/lib/plugins/awesome_nested_set/.travis.yml @@ -1,7 +1,4 @@ language: ruby -notifications: - email: - - parndt@gmail.com script: bundle exec rspec spec env: - DB=sqlite3 @@ -11,9 +8,9 @@ env: rvm: - 2.0.0 - 1.9.3 - - 1.8.7 - rbx-19mode - jruby-19mode + - 1.8.7 - rbx-18mode - jruby-18mode gemfile: diff --git a/lib/plugins/awesome_nested_set/CONTRIBUTING.md b/lib/plugins/awesome_nested_set/CONTRIBUTING.md new file mode 100644 index 000000000..112b942ef --- /dev/null +++ b/lib/plugins/awesome_nested_set/CONTRIBUTING.md @@ -0,0 +1,14 @@ +# Contributing to AwesomeNestedSet + +If you find what you might think is a bug: + +1. Check the [GitHub issue tracker](https://github.com/collectiveidea/awesome_nested_set/issues/) to see if anyone else has had the same issue. +2. If you don't see anything, create an issue with information on how to reproduce it. + +If you want to contribute an enhancement or a fix: + +1. Fork [the project on GitHub](https://github.com/collectiveidea/awesome_nested_set) +2. Make your changes with tests. +3. Commit the changes without making changes to the [Rakefile](Rakefile) or any other files that aren't related to your enhancement or fix. +4. Write an entry in the [CHANGELOG](CHANGELOG) +5. Send a pull request. diff --git a/lib/plugins/awesome_nested_set/Gemfile b/lib/plugins/awesome_nested_set/Gemfile index c644eb148..cf7cf11fe 100644 --- a/lib/plugins/awesome_nested_set/Gemfile +++ b/lib/plugins/awesome_nested_set/Gemfile @@ -1,4 +1,4 @@ -gem 'combustion', :github => 'pat/combustion' +gem 'combustion', :github => 'pat/combustion', :branch => 'master' source 'https://rubygems.org' @@ -7,6 +7,7 @@ gemspec :path => File.expand_path('../', __FILE__) platforms :jruby do gem 'activerecord-jdbcsqlite3-adapter' gem 'activerecord-jdbcmysql-adapter' + gem 'jdbc-mysql' gem 'activerecord-jdbcpostgresql-adapter' gem 'jruby-openssl' end @@ -27,5 +28,5 @@ gem 'actionpack', RAILS_VERSION # gem 'activerecord-oracle_enhanced-adapter' # Debuggers -# gem 'pry' -# gem 'pry-nav' +gem 'pry' +gem 'pry-nav' diff --git a/lib/plugins/awesome_nested_set/README.md b/lib/plugins/awesome_nested_set/README.md new file mode 100644 index 000000000..58bb86ee5 --- /dev/null +++ b/lib/plugins/awesome_nested_set/README.md @@ -0,0 +1,163 @@ +# AwesomeNestedSet + +Awesome Nested Set is an implementation of the nested set pattern for ActiveRecord models. +It is a replacement for acts_as_nested_set and BetterNestedSet, but more awesome. + +Version 2 supports Rails 3. Gem versions prior to 2.0 support Rails 2. + +## What makes this so awesome? + +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. + +[![Code Climate](https://codeclimate.com/github/collectiveidea/awesome_nested_set.png)](https://codeclimate.com/github/collectiveidea/awesome_nested_set) + +## Installation + +Add to your Gemfile: + +```ruby +gem 'awesome_nested_set' +``` + +## Usage + +To make use of `awesome_nested_set`, your model needs to have 3 fields: +`lft`, `rgt`, and `parent_id`. The names of these fields are configurable. +You can also have an optional field, `depth`: + +```ruby +class CreateCategories < ActiveRecord::Migration + def self.up + create_table :categories do |t| + t.string :name + t.integer :parent_id + t.integer :lft + t.integer :rgt + t.integer :depth # this is optional. + end + end + + def self.down + drop_table :categories + end +end +``` + +Enable the nested set functionality by declaring `acts_as_nested_set` on your model + +```ruby +class Category < ActiveRecord::Base + acts_as_nested_set +end +``` + +Run `rake rdoc` to generate the API docs and see [CollectiveIdea::Acts::NestedSet](lib/awesome_nested_set/awesome_nested_set.rb) for more information. + +## Callbacks + +There are three callbacks called when moving a node: +`before_move`, `after_move` and `around_move`. + +```ruby +class Category < ActiveRecord::Base + acts_as_nested_set + + after_move :rebuild_slug + around_move :da_fancy_things_around + + private + + def rebuild_slug + # do whatever + end + + def da_fancy_things_around + # do something... + yield # actually moves + # do something else... + end +end +``` + +Beside this there are also hooks to act on the newly added or removed children. + +```ruby +class Category < ActiveRecord::Base + acts_as_nested_set :before_add => :do_before_add_stuff, + :after_add => :do_after_add_stuff, + :before_remove => :do_before_remove_stuff, + :after_remove => :do_after_remove_stuff + + private + + def do_before_add_stuff(child_node) + # do whatever with the child + end + + def do_after_add_stuff(child_node) + # do whatever with the child + end + + def do_before_remove_stuff(child_node) + # do whatever with the child + end + + def do_after_remove_stuff(child_node) + # do whatever with the child + end +end +``` + +## Protecting attributes from mass assignment + +It's generally best to "whitelist" the attributes that can be used in mass assignment: + +```ruby +class Category < ActiveRecord::Base + acts_as_nested_set + attr_accessible :name, :parent_id +end +``` + +If for some reason that is not possible, you will probably want to protect the `lft` and `rgt` attributes: + +```ruby +class Category < ActiveRecord::Base + acts_as_nested_set + attr_protected :lft, :rgt +end +``` + +## Conversion from other trees + +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: + +```ruby +Category.rebuild! +``` + +Your tree will be converted to a valid nested set. Awesome! + +## View Helper + +The view helper is called #nested_set_options. + +Example usage: + +```erb +<%= f.select :parent_id, nested_set_options(Category, @category) {|i| "#{'-' * i.level} #{i.name}" } %> + +<%= select_tag 'parent_id', options_for_select(nested_set_options(Category) {|i| "#{'-' * i.level} #{i.name}" } ) %> +``` + +See [CollectiveIdea::Acts::NestedSet::Helper](lib/awesome_nested_set/helper.rb) for more information about the helpers. + +## References + +You can learn more about nested sets at: http://threebit.net/tutorials/nestedset/tutorial1.html + +## How to contribute + +Please see the ['Contributing' document](CONTRIBUTING.md). + +Copyright © 2008 - 2013 Collective Idea, released under the MIT license diff --git a/lib/plugins/awesome_nested_set/README.rdoc b/lib/plugins/awesome_nested_set/README.rdoc deleted file mode 100644 index 2ba2128ec..000000000 --- a/lib/plugins/awesome_nested_set/README.rdoc +++ /dev/null @@ -1,153 +0,0 @@ -= AwesomeNestedSet - -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 more awesome. - -Version 2 supports Rails 3. Gem versions prior to 2.0 support Rails 2. - -== What makes this so awesome? - -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. - -== Installation - - Add to your Gemfile: - - gem 'awesome_nested_set' - -== Usage - -To make use of awesome_nested_set, your model needs to have 3 fields: lft, rgt, and parent_id. -You can also have an optional field: depth: - - class CreateCategories < ActiveRecord::Migration - def self.up - create_table :categories do |t| - t.string :name - t.integer :parent_id - t.integer :lft - t.integer :rgt - t.integer :depth # this is optional. - end - end - - def self.down - drop_table :categories - end - end - -Enable the nested set functionality by declaring acts_as_nested_set on your model - - class Category < ActiveRecord::Base - acts_as_nested_set - end - -Run `rake rdoc` to generate the API docs and see CollectiveIdea::Acts::NestedSet for more info. - -== Callbacks - -There are three callbacks called when moving a node. `before_move`, `after_move` and `around_move`. - - class Category < ActiveRecord::Base - acts_as_nested_set - - after_move :rebuild_slug - around_move :da_fancy_things_around - - private - - def rebuild_slug - # do whatever - end - - def da_fancy_things_around - # do something... - yield # actually moves - # do something else... - end - end - -Beside this there are also hooks to act on the newly added or removed children. - - class Category < ActiveRecord::Base - acts_as_nested_set :before_add => :do_before_add_stuff, - :after_add => :do_after_add_stuff, - :before_remove => :do_before_remove_stuff, - :after_remove => :do_after_remove_stuff - - private - - def do_before_add_stuff(child_node) - # do whatever with the child - end - - def do_after_add_stuff(child_node) - # do whatever with the child - end - - def do_before_remove_stuff(child_node) - # do whatever with the child - end - - def do_after_remove_stuff(child_node) - # do whatever with the child - end - end - - -== Protecting attributes from mass assignment - -It's generally best to "white list" the attributes that can be used in mass assignment: - - class Category < ActiveRecord::Base - acts_as_nested_set - attr_accessible :name, :parent_id - end - -If for some reason that is not possible, you will probably want to protect the lft and rgt attributes: - - class Category < ActiveRecord::Base - acts_as_nested_set - attr_protected :lft, :rgt - end - -== Conversion from other trees - -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 - - Category.rebuild! - -Your tree will be converted to a valid nested set. Awesome! - -== View Helper - -The view helper is called #nested_set_options. - -Example usage: - - <%= f.select :parent_id, nested_set_options(Category, @category) {|i| "#{'-' * i.level} #{i.name}" } %> - - <%= select_tag 'parent_id', options_for_select(nested_set_options(Category) {|i| "#{'-' * i.level} #{i.name}" } ) %> - -See CollectiveIdea::Acts::NestedSet::Helper for more information about the helpers. - -== References - -You can learn more about nested sets at: http://threebit.net/tutorials/nestedset/tutorial1.html - -== How to contribute - -If you find what you might think is a bug: - -1. Check the GitHub issue tracker to see if anyone else has had the same issue. - https://github.com/collectiveidea/awesome_nested_set/issues/ -2. If you don't see anything, create an issue with information on how to reproduce it. - -If you want to contribute an enhancement or a fix: - -1. Fork the project on GitHub. - https://github.com/collectiveidea/awesome_nested_set/ -2. Make your changes with tests. -3. Commit the changes without making changes to the Rakefile, VERSION, or any other files that aren't related to your enhancement or fix -4. Send a pull request. - -Copyright ©2008 Collective Idea, released under the MIT license diff --git a/lib/plugins/awesome_nested_set/awesome_nested_set.gemspec b/lib/plugins/awesome_nested_set/awesome_nested_set.gemspec index f30dcc611..f4f858efc 100644 --- a/lib/plugins/awesome_nested_set/awesome_nested_set.gemspec +++ b/lib/plugins/awesome_nested_set/awesome_nested_set.gemspec @@ -7,10 +7,9 @@ Gem::Specification.new do |s| s.authors = ["Brandon Keepers", "Daniel Morrison", "Philip Arndt"] s.description = %q{An awesome nested set implementation for Active Record} s.email = %q{info@collectiveidea.com} - s.extra_rdoc_files = %w[README.rdoc] - s.files = Dir.glob("lib/**/*") + %w(MIT-LICENSE README.rdoc CHANGELOG) + s.files = Dir.glob("lib/**/*") + %w(MIT-LICENSE README.md CHANGELOG) s.homepage = %q{http://github.com/collectiveidea/awesome_nested_set} - s.rdoc_options = ["--main", "README.rdoc", "--inline-source", "--line-numbers"] + s.rdoc_options = ["--inline-source", "--line-numbers"] s.require_paths = ["lib"] s.rubygems_version = %q{1.3.6} s.summary = %q{An awesome nested set implementation for Active Record} @@ -21,4 +20,5 @@ Gem::Specification.new do |s| s.add_development_dependency 'rspec-rails', '~> 2.12' s.add_development_dependency 'rake', '~> 10' s.add_development_dependency 'combustion', '>= 0.3.3' + s.add_development_dependency 'database_cleaner' end diff --git a/lib/plugins/awesome_nested_set/init.rb b/lib/plugins/awesome_nested_set/init.rb deleted file mode 100644 index 8e2cf36ef..000000000 --- a/lib/plugins/awesome_nested_set/init.rb +++ /dev/null @@ -1 +0,0 @@ -require File.dirname(__FILE__) + '/lib/awesome_nested_set' diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set.rb index a3df9e467..37ae5170d 100644 --- a/lib/plugins/awesome_nested_set/lib/awesome_nested_set.rb +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set.rb @@ -5,4 +5,4 @@ ActiveRecord::Base.send :extend, CollectiveIdea::Acts::NestedSet if defined?(ActionView) require 'awesome_nested_set/helper' ActionView::Base.send :include, CollectiveIdea::Acts::NestedSet::Helper -end
\ No newline at end of file +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/awesome_nested_set.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/awesome_nested_set.rb index 4cb527762..95c3516c1 100644 --- a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/awesome_nested_set.rb +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/awesome_nested_set.rb @@ -1,3 +1,6 @@ +require 'awesome_nested_set/columns' +require 'awesome_nested_set/model' + module CollectiveIdea #:nodoc: module Acts #:nodoc: module NestedSet #:nodoc: @@ -42,731 +45,89 @@ module CollectiveIdea #:nodoc: # CollectiveIdea::Acts::NestedSet::Model for a list of instance methods added # to acts_as_nested_set models def acts_as_nested_set(options = {}) - options = { - :parent_column => 'parent_id', - :left_column => 'lft', - :right_column => 'rgt', - :depth_column => 'depth', - :dependent => :delete_all, # or :destroy - :polymorphic => false, - :counter_cache => false - }.merge(options) - - if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/ - options[:scope] = "#{options[:scope]}_id".intern - end - - class_attribute :acts_as_nested_set_options - self.acts_as_nested_set_options = options + acts_as_nested_set_parse_options! options - include CollectiveIdea::Acts::NestedSet::Model + include Model include Columns extend Columns - belongs_to :parent, :class_name => self.base_class.to_s, - :foreign_key => parent_column_name, - :counter_cache => options[:counter_cache], - :inverse_of => (:children unless options[:polymorphic]), - :polymorphic => options[:polymorphic] - - has_many_children_options = { - :class_name => self.base_class.to_s, - :foreign_key => parent_column_name, - :order => order_column, - :inverse_of => (:parent unless options[:polymorphic]), - } - - # Add callbacks, if they were supplied.. otherwise, we don't want them. - [:before_add, :after_add, :before_remove, :after_remove].each do |ar_callback| - has_many_children_options.update(ar_callback => options[ar_callback]) if options[ar_callback] - end - - has_many :children, has_many_children_options + acts_as_nested_set_relate_parent! + acts_as_nested_set_relate_children! attr_accessor :skip_before_destroy + acts_as_nested_set_prevent_assignment_to_reserved_columns! + acts_as_nested_set_define_callbacks! + end + + private + def acts_as_nested_set_define_callbacks! + # on creation, set automatically lft and rgt to the end of the tree before_create :set_default_left_and_right before_save :store_new_parent after_save :move_to_new_parent, :set_depth! before_destroy :destroy_descendants - # no assignment to structure fields - [left_column_name, right_column_name, depth_column_name].each do |column| - module_eval <<-"end_eval", __FILE__, __LINE__ - def #{column}=(x) - raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead." - end - end_eval - end - define_model_callbacks :move end - module Model - extend ActiveSupport::Concern - - included do - delegate :quoted_table_name, :to => self - end - - module ClassMethods - # Returns the first root - def root - roots.first - end - - def roots - where(parent_column_name => nil).order(quoted_left_column_full_name) - end - - def leaves - where("#{quoted_right_column_full_name} - #{quoted_left_column_full_name} = 1").order(quoted_left_column_full_name) - end - - def valid? - left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid? - end - - def left_and_rights_valid? - ## AS clause not supported in Oracle in FROM clause for aliasing table name - joins("LEFT OUTER JOIN #{quoted_table_name}" + - (connection.adapter_name.match(/Oracle/).nil? ? " AS " : " ") + - "parent ON " + - "#{quoted_parent_column_full_name} = parent.#{primary_key}"). - where( - "#{quoted_left_column_full_name} IS NULL OR " + - "#{quoted_right_column_full_name} IS NULL OR " + - "#{quoted_left_column_full_name} >= " + - "#{quoted_right_column_full_name} OR " + - "(#{quoted_parent_column_full_name} IS NOT NULL AND " + - "(#{quoted_left_column_full_name} <= parent.#{quoted_left_column_name} OR " + - "#{quoted_right_column_full_name} >= parent.#{quoted_right_column_name}))" - ).count == 0 - end - - def no_duplicates_for_columns? - scope_string = Array(acts_as_nested_set_options[:scope]).map do |c| - connection.quote_column_name(c) - end.push(nil).join(", ") - [quoted_left_column_full_name, quoted_right_column_full_name].all? do |column| - # No duplicates - select("#{scope_string}#{column}, COUNT(#{column})"). - group("#{scope_string}#{column}"). - having("COUNT(#{column}) > 1"). - first.nil? - end - end - - # Wrapper for each_root_valid? that can deal with scope. - def all_roots_valid? - if acts_as_nested_set_options[:scope] - roots.group_by {|record| scope_column_names.collect {|col| record.send(col.to_sym) } }.all? do |scope, grouped_roots| - each_root_valid?(grouped_roots) - end - else - each_root_valid?(roots) - end - end - - def each_root_valid?(roots_to_validate) - left = right = 0 - roots_to_validate.all? do |root| - (root.left > left && root.right > right).tap do - left = root.left - right = root.right - end - end - end - - # Rebuilds the left & rights if unset or invalid. - # Also very useful for converting from acts_as_tree. - def rebuild!(validate_nodes = true) - # default_scope with order may break database queries so we do all operation without scope - unscoped do - # Don't rebuild a valid tree. - return true if valid? - - scope = lambda{|node|} - if acts_as_nested_set_options[:scope] - scope = lambda{|node| - scope_column_names.inject(""){|str, column_name| - str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name.to_sym))} " - } - } - end - indices = {} - - set_left_and_rights = lambda do |node| - # set left - node[left_column_name] = indices[scope.call(node)] += 1 - # find - where(["#{quoted_parent_column_full_name} = ? #{scope.call(node)}", node]).order("#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, id").each{|n| set_left_and_rights.call(n) } - # set right - node[right_column_name] = indices[scope.call(node)] += 1 - node.save!(:validate => validate_nodes) - end - - # Find root node(s) - root_nodes = where("#{quoted_parent_column_full_name} IS NULL").order("#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, id").each do |root_node| - # setup index for this scope - indices[scope.call(root_node)] ||= 0 - set_left_and_rights.call(root_node) - end - end - end - - # Iterates over tree elements and determines the current level in the tree. - # Only accepts default ordering, odering by an other column than lft - # does not work. This method is much more efficent than calling level - # because it doesn't require any additional database queries. - # - # Example: - # Category.each_with_level(Category.root.self_and_descendants) do |o, level| - # - def each_with_level(objects) - path = [nil] - objects.each do |o| - if o.parent_id != path.last - # we are on a new level, did we descend or ascend? - if path.include?(o.parent_id) - # remove wrong wrong tailing paths elements - path.pop while path.last != o.parent_id - else - path << o.parent_id - end - end - yield(o, path.length - 1) - end - end - - # Same as each_with_level - Accepts a string as a second argument to sort the list - # Example: - # Category.each_with_level(Category.root.self_and_descendants, :sort_by_this_column) do |o, level| - def sorted_each_with_level(objects, order) - path = [nil] - children = [] - objects.each do |o| - children << o if o.leaf? - if o.parent_id != path.last - if !children.empty? && !o.leaf? - children.sort_by! &order - children.each { |c| yield(c, path.length-1) } - children = [] - end - # we are on a new level, did we decent or ascent? - if path.include?(o.parent_id) - # remove wrong wrong tailing paths elements - path.pop while path.last != o.parent_id - else - path << o.parent_id - end - end - yield(o,path.length-1) if !o.leaf? - end - if !children.empty? - children.sort_by! &order - children.each { |c| yield(c, path.length-1) } - end - end - - def associate_parents(objects) - if objects.all?{|o| o.respond_to?(:association)} - id_indexed = objects.index_by(&:id) - objects.each do |object| - if !(association = object.association(:parent)).loaded? && (parent = id_indexed[object.parent_id]) - association.target = parent - association.set_inverse_instance(parent) - end - end - else - objects - end - end - end - - # 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. - # - # category.self_and_descendants.count - # category.ancestors.find(:all, :conditions => "name like '%foo%'") - # Value of the parent column - def parent_id - self[parent_column_name] - end - - # Value of the left column - def left - self[left_column_name] - end - - # Value of the right column - def right - self[right_column_name] - end - - # Returns true if this is a root node. - def root? - parent_id.nil? - end - - # Returns true if this is the end of a branch. - def leaf? - persisted? && right.to_i - left.to_i == 1 - end - - # Returns true is this is a child node - def child? - !root? - end - - # Returns root - def root - if persisted? - self_and_ancestors.where(parent_column_name => nil).first - else - if parent_id && current_parent = nested_set_scope.find(parent_id) - current_parent.root - else - self - end - end - end - - # Returns the array of all parents and self - def self_and_ancestors - nested_set_scope.where([ - "#{quoted_left_column_full_name} <= ? AND #{quoted_right_column_full_name} >= ?", left, right - ]) - end - - # Returns an array of all parents - def ancestors - without_self self_and_ancestors - end - - # Returns the array of all children of the parent, including self - def self_and_siblings - nested_set_scope.where(parent_column_name => parent_id) - end - - # Returns the array of all children of the parent, except self - def siblings - without_self self_and_siblings - end - - # Returns a set of all of its nested children which do not have children - def leaves - descendants.where("#{quoted_right_column_full_name} - #{quoted_left_column_full_name} = 1") - end - - # Returns the level of this object in the tree - # root level is 0 - def level - parent_id.nil? ? 0 : compute_level - end - - # Returns a set of itself and all of its nested children - def self_and_descendants - nested_set_scope.where([ - "#{quoted_left_column_full_name} >= ? AND #{quoted_left_column_full_name} < ?", left, right - # using _left_ for both sides here lets us benefit from an index on that column if one exists - ]) - end - - # Returns a set of all of its children and nested children - def descendants - without_self self_and_descendants - end - - def is_descendant_of?(other) - other.left < self.left && self.left < other.right && same_scope?(other) - end - - def is_or_is_descendant_of?(other) - other.left <= self.left && self.left < other.right && same_scope?(other) - end - - def is_ancestor_of?(other) - self.left < other.left && other.left < self.right && same_scope?(other) - end - - def is_or_is_ancestor_of?(other) - self.left <= other.left && other.left < self.right && same_scope?(other) - end - - # Check if other model is in the same scope - def same_scope?(other) - Array(acts_as_nested_set_options[:scope]).all? do |attr| - self.send(attr) == other.send(attr) - end - end - - # Find the first sibling to the left - def left_sibling - siblings.where(["#{quoted_left_column_full_name} < ?", left]). - order("#{quoted_left_column_full_name} DESC").last - end - - # Find the first sibling to the right - def right_sibling - siblings.where(["#{quoted_left_column_full_name} > ?", left]).first - end - - # Shorthand method for finding the left sibling and moving to the left of it. - def move_left - move_to_left_of left_sibling - end - - # Shorthand method for finding the right sibling and moving to the right of it. - def move_right - move_to_right_of right_sibling - end - - # Move the node to the left of another node (you can pass id only) - def move_to_left_of(node) - move_to node, :left - end - - # Move the node to the left of another node (you can pass id only) - def move_to_right_of(node) - move_to node, :right - end - - # Move the node to the child of another node (you can pass id only) - def move_to_child_of(node) - move_to node, :child - end - - # Move the node to the child of another node with specify index (you can pass id only) - def move_to_child_with_index(node, index) - if node.children.empty? - move_to_child_of(node) - elsif node.children.count == index - move_to_right_of(node.children.last) - else - move_to_left_of(node.children[index]) - end - end - - # Move the node to root nodes - def move_to_root - move_to nil, :root - end - - # Order children in a nested set by an attribute - # Can order by any attribute class that uses the Comparable mixin, for example a string or integer - # Usage example when sorting categories alphabetically: @new_category.move_to_ordered_child_of(@root, "name") - def move_to_ordered_child_of(parent, order_attribute, ascending = true) - self.move_to_root and return unless parent - left = nil # This is needed, at least for the tests. - parent.children.each do |n| # Find the node immediately to the left of this node. - if ascending - left = n if n.send(order_attribute) < self.send(order_attribute) - else - left = n if n.send(order_attribute) > self.send(order_attribute) - end - end - self.move_to_child_of(parent) - return unless parent.children.count > 1 # Only need to order if there are multiple children. - if left # Self has a left neighbor. - self.move_to_right_of(left) - else # Self is the left most node. - self.move_to_left_of(parent.children[0]) - end - end - - def move_possible?(target) - self != target && # Can't target self - same_scope?(target) && # can't be in different scopes - # !(left..right).include?(target.left..target.right) # this needs tested more - # detect impossible move - !((left <= target.left && right >= target.left) or (left <= target.right && right >= target.right)) - end - - def to_text - self_and_descendants.map do |node| - "#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})" - end.join("\n") - end - - protected - def compute_level - node, nesting = self, 0 - while (association = node.association(:parent)).loaded? && association.target - nesting += 1 - node = node.parent - end if node.respond_to? :association - node == self ? ancestors.count : node.level + nesting - end - - def without_self(scope) - scope.where(["#{self.class.quoted_table_name}.#{self.class.primary_key} != ?", self]) - end - - # All nested set queries should use this nested_set_scope, which performs finds on - # the base ActiveRecord class, using the :scope declared in the acts_as_nested_set - # declaration. - def nested_set_scope(options = {}) - options = {:order => quoted_left_column_full_name}.merge(options) - scopes = Array(acts_as_nested_set_options[:scope]) - options[:conditions] = scopes.inject({}) do |conditions,attr| - conditions.merge attr => self[attr] - end unless scopes.empty? - self.class.base_class.unscoped.scoped options - end - - def store_new_parent - @move_to_new_parent_id = send("#{parent_column_name}_changed?") ? parent_id : false - true # force callback to return true - end - - def move_to_new_parent - if @move_to_new_parent_id.nil? - move_to_root - elsif @move_to_new_parent_id - move_to_child_of(@move_to_new_parent_id) - end - end - - def set_depth! - if nested_set_scope.column_names.map(&:to_s).include?(depth_column_name.to_s) - in_tenacious_transaction do - reload - - nested_set_scope.where(:id => id).update_all(["#{quoted_depth_column_name} = ?", level]) - end - self[depth_column_name.to_sym] = self.level - end - end - - def right_most_bound - right_most_node = - self.class.base_class.unscoped. - order("#{quoted_right_column_full_name} desc").limit(1).lock(true).first - right_most_node ? (right_most_node[right_column_name] || 0) : 0 - end - - # on creation, set automatically lft and rgt to the end of the tree - def set_default_left_and_right - # adds the new node to the right of all existing nodes - self[left_column_name] = right_most_bound + 1 - self[right_column_name] = right_most_bound + 2 - end - - def in_tenacious_transaction(&block) - retry_count = 0 - begin - transaction(&block) - rescue ActiveRecord::StatementInvalid => error - raise unless connection.open_transactions.zero? - raise unless error.message =~ /Deadlock found when trying to get lock|Lock wait timeout exceeded/ - raise unless retry_count < 10 - retry_count += 1 - logger.info "Deadlock detected on retry #{retry_count}, restarting transaction" - sleep(rand(retry_count)*0.1) # Aloha protocol - retry - end - end - - # Prunes a branch off of the tree, shifting all of the elements on the right - # back to the left so the counts still work. - def destroy_descendants - return if right.nil? || left.nil? || skip_before_destroy - - in_tenacious_transaction do - reload_nested_set - # select the rows in the model that extend past the deletion point and apply a lock - nested_set_scope.where(["#{quoted_left_column_full_name} >= ?", left]). - select(id).lock(true) - - if acts_as_nested_set_options[:dependent] == :destroy - descendants.each do |model| - model.skip_before_destroy = true - model.destroy - end - else - nested_set_scope.where(["#{quoted_left_column_name} > ? AND #{quoted_right_column_name} < ?", left, right]). - delete_all - end - - # update lefts and rights for remaining nodes - diff = right - left + 1 - nested_set_scope.where(["#{quoted_left_column_full_name} > ?", right]).update_all( - ["#{quoted_left_column_name} = (#{quoted_left_column_name} - ?)", diff] - ) - - nested_set_scope.where(["#{quoted_right_column_full_name} > ?", right]).update_all( - ["#{quoted_right_column_name} = (#{quoted_right_column_name} - ?)", diff] - ) - - # Don't allow multiple calls to destroy to corrupt the set - self.skip_before_destroy = true - end - end - - # reload left, right, and parent - def reload_nested_set - reload( - :select => "#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, #{quoted_parent_column_full_name}", - :lock => true - ) - end - - def move_to(target, position) - raise ActiveRecord::ActiveRecordError, "You cannot move a new node" if self.new_record? - run_callbacks :move do - in_tenacious_transaction do - if target.is_a? self.class.base_class - target.reload_nested_set - elsif position != :root - # load object if node is not an object - target = nested_set_scope.find(target) - end - self.reload_nested_set - - unless position == :root || move_possible?(target) - raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree." - end - - bound = case position - when :child; target[right_column_name] - when :left; target[left_column_name] - when :right; target[right_column_name] + 1 - when :root; 1 - else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)." - end - - if bound > self[right_column_name] - bound = bound - 1 - other_bound = self[right_column_name] + 1 - else - other_bound = self[left_column_name] - 1 - end - - # there would be no change - return if bound == self[right_column_name] || bound == self[left_column_name] - - # we have defined the boundaries of two non-overlapping intervals, - # so sorting puts both the intervals and their boundaries in order - a, b, c, d = [self[left_column_name], self[right_column_name], bound, other_bound].sort - - # select the rows in the model between a and d, and apply a lock - self.class.base_class.select('id').lock(true).where( - ["#{quoted_left_column_full_name} >= :a and #{quoted_right_column_full_name} <= :d", {:a => a, :d => d}] - ) - - new_parent = case position - when :child; target.id - when :root; nil - else target[parent_column_name] - end - - where_statement = ["not (#{quoted_left_column_name} = CASE " + - "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " + - "THEN #{quoted_left_column_name} + :d - :b " + - "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " + - "THEN #{quoted_left_column_name} + :a - :c " + - "ELSE #{quoted_left_column_name} END AND " + - "#{quoted_right_column_name} = CASE " + - "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " + - "THEN #{quoted_right_column_name} + :d - :b " + - "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " + - "THEN #{quoted_right_column_name} + :a - :c " + - "ELSE #{quoted_right_column_name} END AND " + - "#{quoted_parent_column_name} = CASE " + - "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " + - "ELSE #{quoted_parent_column_name} END)" , - {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent} ] - - - + def acts_as_nested_set_relate_children! + has_many_children_options = { + :class_name => self.base_class.to_s, + :foreign_key => parent_column_name, + :order => quoted_order_column_name, + :inverse_of => (:parent unless acts_as_nested_set_options[:polymorphic]), + } - self.nested_set_scope.where(*where_statement).update_all([ - "#{quoted_left_column_name} = CASE " + - "WHEN #{quoted_left_column_name} BETWEEN :a AND :b " + - "THEN #{quoted_left_column_name} + :d - :b " + - "WHEN #{quoted_left_column_name} BETWEEN :c AND :d " + - "THEN #{quoted_left_column_name} + :a - :c " + - "ELSE #{quoted_left_column_name} END, " + - "#{quoted_right_column_name} = CASE " + - "WHEN #{quoted_right_column_name} BETWEEN :a AND :b " + - "THEN #{quoted_right_column_name} + :d - :b " + - "WHEN #{quoted_right_column_name} BETWEEN :c AND :d " + - "THEN #{quoted_right_column_name} + :a - :c " + - "ELSE #{quoted_right_column_name} END, " + - "#{quoted_parent_column_name} = CASE " + - "WHEN #{self.class.base_class.primary_key} = :id THEN :new_parent " + - "ELSE #{quoted_parent_column_name} END", - {:a => a, :b => b, :c => c, :d => d, :id => self.id, :new_parent => new_parent} - ]) - end - target.reload_nested_set if target - self.set_depth! - self.descendants.each(&:save) - self.reload_nested_set - end + # Add callbacks, if they were supplied.. otherwise, we don't want them. + [:before_add, :after_add, :before_remove, :after_remove].each do |ar_callback| + has_many_children_options.update(ar_callback => acts_as_nested_set_options[ar_callback]) if acts_as_nested_set_options[ar_callback] end + has_many :children, has_many_children_options end - # Mixed into both classes and instances to provide easy access to the column names - module Columns - def left_column_name - acts_as_nested_set_options[:left_column] - end - - def right_column_name - acts_as_nested_set_options[:right_column] - end - - def depth_column_name - acts_as_nested_set_options[:depth_column] - end - - def parent_column_name - acts_as_nested_set_options[:parent_column] - end - - def order_column - acts_as_nested_set_options[:order_column] || left_column_name - end - - def scope_column_names - Array(acts_as_nested_set_options[:scope]) - end - - def quoted_left_column_name - connection.quote_column_name(left_column_name) - end - - def quoted_right_column_name - connection.quote_column_name(right_column_name) - end - - def quoted_depth_column_name - connection.quote_column_name(depth_column_name) - end + def acts_as_nested_set_relate_parent! + belongs_to :parent, :class_name => self.base_class.to_s, + :foreign_key => parent_column_name, + :counter_cache => acts_as_nested_set_options[:counter_cache], + :inverse_of => (:children unless acts_as_nested_set_options[:polymorphic]), + :polymorphic => acts_as_nested_set_options[:polymorphic] + end - def quoted_parent_column_name - connection.quote_column_name(parent_column_name) - end + def acts_as_nested_set_default_options + { + :parent_column => 'parent_id', + :left_column => 'lft', + :right_column => 'rgt', + :depth_column => 'depth', + :dependent => :delete_all, # or :destroy + :polymorphic => false, + :counter_cache => false + }.freeze + end - def quoted_scope_column_names - scope_column_names.collect {|column_name| connection.quote_column_name(column_name) } - end + def acts_as_nested_set_parse_options!(options) + options = acts_as_nested_set_default_options.merge(options) - def quoted_left_column_full_name - "#{quoted_table_name}.#{quoted_left_column_name}" + if options[:scope].is_a?(Symbol) && options[:scope].to_s !~ /_id$/ + options[:scope] = "#{options[:scope]}_id".intern end - def quoted_right_column_full_name - "#{quoted_table_name}.#{quoted_right_column_name}" - end + class_attribute :acts_as_nested_set_options + self.acts_as_nested_set_options = options + end - def quoted_parent_column_full_name - "#{quoted_table_name}.#{quoted_parent_column_name}" + def acts_as_nested_set_prevent_assignment_to_reserved_columns! + # no assignment to structure fields + [left_column_name, right_column_name, depth_column_name].each do |column| + module_eval <<-"end_eval", __FILE__, __LINE__ + def #{column}=(x) + raise ActiveRecord::ActiveRecordError, "Unauthorized assignment to #{column}: it's an internal field handled by acts_as_nested_set code, use move_to_* methods instead." + end + end_eval end end - end end end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/columns.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/columns.rb new file mode 100644 index 000000000..9b6a733c8 --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/columns.rb @@ -0,0 +1,68 @@ +# Mixed into both classes and instances to provide easy access to the column names +module CollectiveIdea #:nodoc: + module Acts #:nodoc: + module NestedSet #:nodoc: + module Columns + def left_column_name + acts_as_nested_set_options[:left_column] + end + + def right_column_name + acts_as_nested_set_options[:right_column] + end + + def depth_column_name + acts_as_nested_set_options[:depth_column] + end + + def parent_column_name + acts_as_nested_set_options[:parent_column] + end + + def order_column + acts_as_nested_set_options[:order_column] || left_column_name + end + + def scope_column_names + Array(acts_as_nested_set_options[:scope]) + end + + def quoted_left_column_name + connection.quote_column_name(left_column_name) + end + + def quoted_right_column_name + connection.quote_column_name(right_column_name) + end + + def quoted_depth_column_name + connection.quote_column_name(depth_column_name) + end + + def quoted_parent_column_name + connection.quote_column_name(parent_column_name) + end + + def quoted_scope_column_names + scope_column_names.collect {|column_name| connection.quote_column_name(column_name) } + end + + def quoted_order_column_name + connection.quote_column_name(order_column) + end + + def quoted_left_column_full_name + "#{quoted_table_name}.#{quoted_left_column_name}" + end + + def quoted_right_column_full_name + "#{quoted_table_name}.#{quoted_right_column_name}" + end + + def quoted_parent_column_full_name + "#{quoted_table_name}.#{quoted_parent_column_name}" + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/helper.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/helper.rb index 98d2688fa..bd8d40df1 100644 --- a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/helper.rb +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/helper.rb @@ -38,51 +38,6 @@ module CollectiveIdea #:nodoc: end result end - - # Returns options for select as nested_set_options, sorted by an specific column - # It requires passing a string with the name of the column to sort the set with - # You can exclude some items from the tree. - # You can pass a block receiving an item and returning the string displayed in the select. - # - # == Params - # * +class_or_item+ - Class name or top level times - # * +:column+ - Column to sort the set (this will sort each children for all root elements) - # * +mover+ - The item that is being move, used to exlude impossible moves - # * +&block+ - a block that will be used to display: { |item| ... item.name } - # - # == Usage - # - # <%= f.select :parent_id, nested_set_options(Category, :sort_by_this_column, @category) {|i| - # "#{'–' * i.level} #{i.name}" - # }) %> - # - def sorted_nested_set_options(class_or_item, order, mover = nil) - if class_or_item.is_a? Array - items = class_or_item.reject { |e| !e.root? } - else - class_or_item = class_or_item.roots if class_or_item.is_a?(Class) - items = Array(class_or_item) - end - result = [] - children = [] - items.each do |root| - root.class.associate_parents(root.self_and_descendants).map do |i| - if mover.nil? || mover.new_record? || mover.move_possible?(i) - if !i.leaf? - children.sort_by! &order - children.each { |c| result << [yield(c), c.id] } - children = [] - result << [yield(i), i.id] - else - children << i - end - end - end.compact - end - children.sort_by! &order - children.each { |c| result << [yield(c), c.id] } - result - end end end end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/iterator.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/iterator.rb new file mode 100644 index 000000000..94aca41ea --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/iterator.rb @@ -0,0 +1,29 @@ +module CollectiveIdea #:nodoc: + module Acts #:nodoc: + module NestedSet #:nodoc: + class Iterator + attr_reader :objects + + def initialize(objects) + @objects = objects + end + + def each_with_level + path = [nil] + objects.each do |o| + if o.parent_id != path.last + # we are on a new level, did we descend or ascend? + if path.include?(o.parent_id) + # remove wrong tailing paths elements + path.pop while path.last != o.parent_id + else + path << o.parent_id + end + end + yield(o, path.length - 1) + end + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model.rb new file mode 100644 index 000000000..e8d1e1844 --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model.rb @@ -0,0 +1,212 @@ +require 'awesome_nested_set/model/prunable' +require 'awesome_nested_set/model/movable' +require 'awesome_nested_set/model/transactable' +require 'awesome_nested_set/model/relatable' +require 'awesome_nested_set/model/rebuildable' +require 'awesome_nested_set/model/validatable' +require 'awesome_nested_set/iterator' + +module CollectiveIdea #:nodoc: + module Acts #:nodoc: + module NestedSet #:nodoc: + + module Model + extend ActiveSupport::Concern + + included do + delegate :quoted_table_name, :arel_table, :to => self + extend Validatable + extend Rebuildable + include Movable + include Prunable + include Relatable + include Transactable + end + + module ClassMethods + def associate_parents(objects) + return objects unless objects.all? {|o| o.respond_to?(:association)} + + id_indexed = objects.index_by(&:id) + objects.each do |object| + association = object.association(:parent) + parent = id_indexed[object.parent_id] + + if !association.loaded? && parent + association.target = parent + association.set_inverse_instance(parent) + end + end + end + + def children_of(parent_id) + where arel_table[parent_column_name].eq(parent_id) + end + + # Iterates over tree elements and determines the current level in the tree. + # Only accepts default ordering, odering by an other column than lft + # does not work. This method is much more efficent than calling level + # because it doesn't require any additional database queries. + # + # Example: + # Category.each_with_level(Category.root.self_and_descendants) do |o, level| + # + def each_with_level(objects, &block) + Iterator.new(objects).each_with_level(&block) + end + + def leaves + nested_set_scope.where "#{quoted_right_column_full_name} - #{quoted_left_column_full_name} = 1" + end + + def left_of(node) + where arel_table[left_column_name].lt(node) + end + + def left_of_right_side(node) + where arel_table[right_column_name].lteq(node) + end + + def right_of(node) + where arel_table[left_column_name].gteq(node) + end + + def nested_set_scope(options = {}) + options = {:order => quoted_order_column_name}.merge(options) + + order(options.delete(:order)).scoped options + end + + def primary_key_scope(id) + where arel_table[primary_key].eq(id) + end + + def root + roots.first + end + + def roots + nested_set_scope.children_of nil + end + end # end class methods + + # 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. + # + # category.self_and_descendants.count + # category.ancestors.find(:all, :conditions => "name like '%foo%'") + # Value of the parent column + def parent_id(target = self) + target[parent_column_name] + end + + # Value of the left column + def left(target = self) + target[left_column_name] + end + + # Value of the right column + def right(target = self) + target[right_column_name] + end + + # Returns true if this is a root node. + def root? + parent_id.nil? + end + + # Returns true is this is a child node + def child? + !root? + end + + # Returns true if this is the end of a branch. + def leaf? + persisted? && right.to_i - left.to_i == 1 + end + + # All nested set queries should use this nested_set_scope, which + # performs finds on the base ActiveRecord class, using the :scope + # declared in the acts_as_nested_set declaration. + def nested_set_scope(options = {}) + if (scopes = Array(acts_as_nested_set_options[:scope])).any? + options[:conditions] = scopes.inject({}) do |conditions,attr| + conditions.merge attr => self[attr] + end + end + + self.class.nested_set_scope options + end + + def to_text + self_and_descendants.map do |node| + "#{'*'*(node.level+1)} #{node.id} #{node.to_s} (#{node.parent_id}, #{node.left}, #{node.right})" + end.join("\n") + end + + protected + + def without_self(scope) + return scope if new_record? + scope.where(["#{self.class.quoted_table_name}.#{self.class.primary_key} != ?", self]) + end + + def store_new_parent + @move_to_new_parent_id = send("#{parent_column_name}_changed?") ? parent_id : false + true # force callback to return true + end + + def has_depth_column? + nested_set_scope.column_names.map(&:to_s).include?(depth_column_name.to_s) + end + + def right_most_node + @right_most_node ||= self.class.base_class.unscoped.nested_set_scope( + :order => "#{quoted_right_column_full_name} desc" + ).first + end + + def right_most_bound + @right_most_bound ||= begin + return 0 if right_most_node.nil? + + right_most_node.lock! + right_most_node[right_column_name] || 0 + end + end + + def set_depth! + return unless has_depth_column? + + in_tenacious_transaction do + reload + nested_set_scope.primary_key_scope(id). + update_all(["#{quoted_depth_column_name} = ?", level]) + end + self[depth_column_name] = self.level + end + + def set_default_left_and_right + # adds the new node to the right of all existing nodes + self[left_column_name] = right_most_bound + 1 + self[right_column_name] = right_most_bound + 2 + end + + # reload left, right, and parent + def reload_nested_set + reload( + :select => "#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, #{quoted_parent_column_full_name}", + :lock => true + ) + end + + def reload_target(target) + if target.is_a? self.class.base_class + target.reload + else + nested_set_scope.find(target) + end + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/movable.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/movable.rb new file mode 100644 index 000000000..7b94ca22a --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/movable.rb @@ -0,0 +1,137 @@ +require 'awesome_nested_set/move' + +module CollectiveIdea #:nodoc: + module Acts #:nodoc: + module NestedSet #:nodoc: + module Model + module Movable + + def move_possible?(target) + self != target && # Can't target self + same_scope?(target) && # can't be in different scopes + # detect impossible move + within_bounds?(target.left, target.left) && + within_bounds?(target.right, target.right) + end + + # Shorthand method for finding the left sibling and moving to the left of it. + def move_left + move_to_left_of left_sibling + end + + # Shorthand method for finding the right sibling and moving to the right of it. + def move_right + move_to_right_of right_sibling + end + + # Move the node to the left of another node + def move_to_left_of(node) + move_to node, :left + end + + # Move the node to the left of another node + def move_to_right_of(node) + move_to node, :right + end + + # Move the node to the child of another node + def move_to_child_of(node) + move_to node, :child + end + + # Move the node to the child of another node with specify index + def move_to_child_with_index(node, index) + if node.children.empty? + move_to_child_of(node) + elsif node.children.count == index + move_to_right_of(node.children.last) + else + move_to_left_of(node.children[index]) + end + end + + # Move the node to root nodes + def move_to_root + move_to_right_of(root) + end + + # Order children in a nested set by an attribute + # Can order by any attribute class that uses the Comparable mixin, for example a string or integer + # Usage example when sorting categories alphabetically: @new_category.move_to_ordered_child_of(@root, "name") + def move_to_ordered_child_of(parent, order_attribute, ascending = true) + self.move_to_root and return unless parent + + left_neighbor = find_left_neighbor(parent, order_attribute, ascending) + self.move_to_child_of(parent) + + return unless parent.children.many? + + if left_neighbor + self.move_to_right_of(left_neighbor) + else # Self is the left most node. + self.move_to_left_of(parent.children[0]) + end + end + + # Find the node immediately to the left of this node. + def find_left_neighbor(parent, order_attribute, ascending) + left = nil + parent.children.each do |n| + if ascending + left = n if n.send(order_attribute) < self.send(order_attribute) + else + left = n if n.send(order_attribute) > self.send(order_attribute) + end + end + left + end + + def move_to(target, position) + prevent_unpersisted_move + + run_callbacks :move do + in_tenacious_transaction do + target = reload_target(target) + self.reload_nested_set + + Move.new(target, position, self).move + end + after_move_to(target, position) + end + end + + protected + + def after_move_to(target, position) + target.reload_nested_set if target + self.set_depth! + self.descendants.each(&:save) + self.reload_nested_set + end + + def move_to_new_parent + if @move_to_new_parent_id.nil? + move_to_root + elsif @move_to_new_parent_id + move_to_child_of(@move_to_new_parent_id) + end + end + + def out_of_bounds?(left_bound, right_bound) + left <= left_bound && right >= right_bound + end + + def prevent_unpersisted_move + if self.new_record? + raise ActiveRecord::ActiveRecordError, "You cannot move a new node" + end + end + + def within_bounds?(left_bound, right_bound) + !out_of_bounds?(left_bound, right_bound) + end + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/prunable.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/prunable.rb new file mode 100644 index 000000000..501b311e3 --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/prunable.rb @@ -0,0 +1,58 @@ +module CollectiveIdea #:nodoc: + module Acts #:nodoc: + module NestedSet #:nodoc: + module Model + module Prunable + + # Prunes a branch off of the tree, shifting all of the elements on the right + # back to the left so the counts still work. + def destroy_descendants + return if right.nil? || left.nil? || skip_before_destroy + + in_tenacious_transaction do + reload_nested_set + # select the rows in the model that extend past the deletion point and apply a lock + nested_set_scope.right_of(left).select(id).lock(true) + + destroy_or_delete_descendants + + # update lefts and rights for remaining nodes + update_siblings_for_remaining_nodes + + # Don't allow multiple calls to destroy to corrupt the set + self.skip_before_destroy = true + end + end + + def destroy_or_delete_descendants + if acts_as_nested_set_options[:dependent] == :destroy + descendants.each do |model| + model.skip_before_destroy = true + model.destroy + end + else + descendants.delete_all + end + end + + def update_siblings_for_remaining_nodes + update_siblings(:left) + update_siblings(:right) + end + + def update_siblings(direction) + full_column_name = send("quoted_#{direction}_column_full_name") + column_name = send("quoted_#{direction}_column_name") + + nested_set_scope.where(["#{full_column_name} > ?", right]). + update_all(["#{column_name} = (#{column_name} - ?)", diff]) + end + + def diff + right - left + 1 + end + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/rebuildable.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/rebuildable.rb new file mode 100644 index 000000000..553c308c1 --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/rebuildable.rb @@ -0,0 +1,41 @@ +require 'awesome_nested_set/tree' + +module CollectiveIdea + module Acts + module NestedSet + module Model + module Rebuildable + + + # Rebuilds the left & rights if unset or invalid. + # Also very useful for converting from acts_as_tree. + def rebuild!(validate_nodes = true) + # default_scope with order may break database queries so we do all operation without scope + unscoped do + Tree.new(self, validate_nodes).rebuild! + end + end + + private + def scope_for_rebuild + scope = proc {} + + if acts_as_nested_set_options[:scope] + scope = proc {|node| + scope_column_names.inject("") {|str, column_name| + str << "AND #{connection.quote_column_name(column_name)} = #{connection.quote(node.send(column_name))} " + } + } + end + scope + end + + def order_for_rebuild + "#{quoted_left_column_full_name}, #{quoted_right_column_full_name}, #{primary_key}" + end + end + + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/relatable.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/relatable.rb new file mode 100644 index 000000000..21de61a9d --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/relatable.rb @@ -0,0 +1,121 @@ +module CollectiveIdea + module Acts + module NestedSet + module Model + module Relatable + + # Returns an collection of all parents + def ancestors + without_self self_and_ancestors + end + + # Returns the collection of all parents and self + def self_and_ancestors + nested_set_scope. + where(arel_table[left_column_name].lteq(left)). + where(arel_table[right_column_name].gteq(right)) + end + + # Returns the collection of all children of the parent, except self + def siblings + without_self self_and_siblings + end + + # Returns the collection of all children of the parent, including self + def self_and_siblings + nested_set_scope.children_of parent_id + end + + # Returns a set of all of its nested children which do not have children + def leaves + descendants.where( + "#{quoted_right_column_full_name} - #{quoted_left_column_full_name} = 1" + ) + end + + # Returns the level of this object in the tree + # root level is 0 + def level + parent_id.nil? ? 0 : compute_level + end + + # Returns a collection including all of its children and nested children + def descendants + without_self self_and_descendants + end + + # Returns a collection including itself and all of its nested children + def self_and_descendants + # using _left_ for both sides here lets us benefit from an index on that column if one exists + nested_set_scope.right_of(left).left_of(right) + end + + def is_descendant_of?(other) + within_node?(other, self) && same_scope?(other) + end + + def is_or_is_descendant_of?(other) + (other == self || within_node?(other, self)) && same_scope?(other) + end + + def is_ancestor_of?(other) + within_node?(self, other) && same_scope?(other) + end + + def is_or_is_ancestor_of?(other) + (self == other || within_node?(self, other)) && same_scope?(other) + end + + # Check if other model is in the same scope + def same_scope?(other) + Array(acts_as_nested_set_options[:scope]).all? do |attr| + self.send(attr) == other.send(attr) + end + end + + # Find the first sibling to the left + def left_sibling + siblings.left_of(left).last + end + + # Find the first sibling to the right + def right_sibling + siblings.right_of(left).first + end + + def root + return self_and_ancestors.children_of(nil).first if persisted? + + if parent_id && current_parent = nested_set_scope.find(parent_id) + current_parent.root + else + self + end + end + + protected + + def compute_level + node, nesting = determine_depth + + node == self ? ancestors.count : node.level + nesting + end + + def determine_depth(node = self, nesting = 0) + while (association = node.association(:parent)).loaded? && association.target + nesting += 1 + node = node.parent + end if node.respond_to?(:association) + + [node, nesting] + end + + def within_node?(node, within) + node.left < within.left && within.left < node.right + end + + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/transactable.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/transactable.rb new file mode 100644 index 000000000..bef62d369 --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/transactable.rb @@ -0,0 +1,27 @@ +module CollectiveIdea #:nodoc: + module Acts #:nodoc: + module NestedSet #:nodoc: + module Model + module Transactable + + protected + def in_tenacious_transaction(&block) + retry_count = 0 + begin + transaction(&block) + rescue ActiveRecord::StatementInvalid => error + raise unless connection.open_transactions.zero? + raise unless error.message =~ /Deadlock found when trying to get lock|Lock wait timeout exceeded/ + raise unless retry_count < 10 + retry_count += 1 + logger.info "Deadlock detected on retry #{retry_count}, restarting transaction" + sleep(rand(retry_count)*0.1) # Aloha protocol + retry + end + end + + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/validatable.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/validatable.rb new file mode 100644 index 000000000..0b84673f1 --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/model/validatable.rb @@ -0,0 +1,69 @@ +require 'awesome_nested_set/set_validator' + +module CollectiveIdea + module Acts + module NestedSet + module Model + module Validatable + + def valid? + left_and_rights_valid? && no_duplicates_for_columns? && all_roots_valid? + end + + def left_and_rights_valid? + SetValidator.new(self).valid? + end + + def no_duplicates_for_columns? + [quoted_left_column_full_name, quoted_right_column_full_name].all? do |column| + # No duplicates + select("#{scope_string}#{column}, COUNT(#{column})"). + group("#{scope_string}#{column}"). + having("COUNT(#{column}) > 1"). + first.nil? + end + end + + # Wrapper for each_root_valid? that can deal with scope. + def all_roots_valid? + if acts_as_nested_set_options[:scope] + all_roots_valid_by_scope?(roots) + else + each_root_valid?(roots) + end + end + + def all_roots_valid_by_scope?(roots_to_validate) + roots_grouped_by_scope(roots_to_validate).all? do |scope, grouped_roots| + each_root_valid?(grouped_roots) + end + end + + def each_root_valid?(roots_to_validate) + left = right = 0 + roots_to_validate.all? do |root| + (root.left > left && root.right > right).tap do + left = root.left + right = root.right + end + end + end + + private + def roots_grouped_by_scope(roots_to_group) + roots_to_group.group_by {|record| + scope_column_names.collect {|col| record.send(col) } + } + end + + def scope_string + Array(acts_as_nested_set_options[:scope]).map do |c| + connection.quote_column_name(c) + end.push(nil).join(", ") + end + + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/move.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/move.rb new file mode 100644 index 000000000..afc1152de --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/move.rb @@ -0,0 +1,117 @@ +module CollectiveIdea #:nodoc: + module Acts #:nodoc: + module NestedSet #:nodoc: + class Move + attr_reader :target, :position, :instance + + def initialize(target, position, instance) + @target = target + @position = position + @instance = instance + end + + def move + prevent_impossible_move + + bound, other_bound = get_boundaries + + # there would be no change + return if bound == right || bound == left + + # we have defined the boundaries of two non-overlapping intervals, + # so sorting puts both the intervals and their boundaries in order + a, b, c, d = [left, right, bound, other_bound].sort + + lock_nodes_between! a, d + + nested_set_scope.where(where_statement(a, d)). + update_all(conditions(a, b, c, d)) + end + + private + + delegate :left, :right, :left_column_name, :right_column_name, + :quoted_left_column_name, :quoted_right_column_name, + :quoted_parent_column_name, :parent_column_name, :nested_set_scope, + :to => :instance + + delegate :arel_table, :class, :to => :instance, :prefix => true + delegate :base_class, :to => :instance_class, :prefix => :instance + + def where_statement(left_bound, right_bound) + instance_arel_table[left_column_name].in(left_bound..right_bound). + or(instance_arel_table[right_column_name].in(left_bound..right_bound)) + end + + def conditions(a, b, c, d) + [ + case_condition_for_direction(:quoted_left_column_name) + + case_condition_for_direction(:quoted_right_column_name) + + case_condition_for_parent, + {:a => a, :b => b, :c => c, :d => d, :id => instance.id, :new_parent => new_parent} + ] + end + + def case_condition_for_direction(column_name) + column = send(column_name) + "#{column} = CASE " + + "WHEN #{column} BETWEEN :a AND :b " + + "THEN #{column} + :d - :b " + + "WHEN #{column} BETWEEN :c AND :d " + + "THEN #{column} + :a - :c " + + "ELSE #{column} END, " + end + + def case_condition_for_parent + "#{quoted_parent_column_name} = CASE " + + "WHEN #{instance_base_class.primary_key} = :id THEN :new_parent " + + "ELSE #{quoted_parent_column_name} END" + end + + def lock_nodes_between!(left_bound, right_bound) + # select the rows in the model between a and d, and apply a lock + instance_base_class.right_of(left_bound).left_of_right_side(right_bound). + select(:id).lock(true) + end + + def root + position == :root + end + + def new_parent + case position + when :child + target.id + else + target[parent_column_name] + end + end + + def get_boundaries + if (bound = target_bound) > right + bound -= 1 + other_bound = right + 1 + else + other_bound = left - 1 + end + [bound, other_bound] + end + + def prevent_impossible_move + if !root && !instance.move_possible?(target) + raise ActiveRecord::ActiveRecordError, "Impossible move, target node cannot be inside moved tree." + end + end + + def target_bound + case position + when :child; right(target) + when :left; left(target) + when :right; right(target) + 1 + else raise ActiveRecord::ActiveRecordError, "Position should be :child, :left, :right or :root ('#{position}' received)." + end + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/set_validator.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/set_validator.rb new file mode 100644 index 000000000..7cb9d3c30 --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/set_validator.rb @@ -0,0 +1,63 @@ +module CollectiveIdea #:nodoc: + module Acts #:nodoc: + module NestedSet #:nodoc: + class SetValidator + + def initialize(model) + @model = model + @scope = model.scoped + @parent = arel_table.alias('parent') + end + + def valid? + query.count == 0 + end + + private + + attr_reader :model, :parent + attr_accessor :scope + + delegate :parent_column_name, :primary_key, :left_column_name, :right_column_name, :arel_table, + :quoted_table_name, :quoted_parent_column_full_name, :quoted_left_column_full_name, :quoted_right_column_full_name, :quoted_left_column_name, :quoted_right_column_name, + :to => :model + + def query + join_scope + filter_scope + end + + def join_scope + join_arel = arel_table.join(parent, Arel::Nodes::OuterJoin).on(parent[primary_key].eq(arel_table[parent_column_name])) + self.scope = scope.joins(join_arel.join_sql) + end + + def filter_scope + self.scope = scope.where( + bound_is_null(left_column_name). + or(bound_is_null(right_column_name)). + or(left_bound_greater_than_right). + or(parent_not_null.and(bounds_outside_parent)) + ) + end + + def bound_is_null(column_name) + arel_table[column_name].eq(nil) + end + + def left_bound_greater_than_right + arel_table[left_column_name].gteq(arel_table[right_column_name]) + end + + def parent_not_null + arel_table[parent_column_name].not_eq(nil) + end + + def bounds_outside_parent + arel_table[left_column_name].lteq(parent[left_column_name]).or(arel_table[right_column_name].gteq(parent[right_column_name])) + end + + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/tree.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/tree.rb new file mode 100644 index 000000000..4a032c2a2 --- /dev/null +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/tree.rb @@ -0,0 +1,63 @@ +module CollectiveIdea #:nodoc: + module Acts #:nodoc: + module NestedSet #:nodoc: + class Tree + attr_reader :model, :validate_nodes + attr_accessor :indices + + delegate :left_column_name, :right_column_name, :quoted_parent_column_full_name, + :order_for_rebuild, :scope_for_rebuild, + :to => :model + + def initialize(model, validate_nodes) + @model = model + @validate_nodes = validate_nodes + @indices = {} + end + + def rebuild! + # Don't rebuild a valid tree. + return true if model.valid? + + root_nodes.each do |root_node| + # setup index for this scope + indices[scope_for_rebuild.call(root_node)] ||= 0 + set_left_and_rights(root_node) + end + end + + private + + def increment_indice!(node) + indices[scope_for_rebuild.call(node)] += 1 + end + + def set_left_and_rights(node) + set_left!(node) + # find + node_children(node).each { |n| set_left_and_rights(n) } + set_right!(node) + + node.save!(:validate => validate_nodes) + end + + def node_children(node) + model.where(["#{quoted_parent_column_full_name} = ? #{scope_for_rebuild.call(node)}", node]). + order(order_for_rebuild) + end + + def root_nodes + model.where("#{quoted_parent_column_full_name} IS NULL").order(order_for_rebuild) + end + + def set_left!(node) + node[left_column_name] = increment_indice!(node) + end + + def set_right!(node) + node[right_column_name] = increment_indice!(node) + end + end + end + end +end diff --git a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/version.rb b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/version.rb index 74cac55b8..c7058f7f4 100644 --- a/lib/plugins/awesome_nested_set/lib/awesome_nested_set/version.rb +++ b/lib/plugins/awesome_nested_set/lib/awesome_nested_set/version.rb @@ -1,3 +1,3 @@ module AwesomeNestedSet - VERSION = '2.1.6' unless defined?(::AwesomeNestedSet::VERSION) + VERSION = '2.1.7' unless defined?(::AwesomeNestedSet::VERSION) end diff --git a/lib/plugins/awesome_nested_set/spec/awesome_nested_set/helper_spec.rb b/lib/plugins/awesome_nested_set/spec/awesome_nested_set/helper_spec.rb index 0be767f6c..9b2857e04 100644 --- a/lib/plugins/awesome_nested_set/spec/awesome_nested_set/helper_spec.rb +++ b/lib/plugins/awesome_nested_set/spec/awesome_nested_set/helper_spec.rb @@ -77,7 +77,10 @@ describe "Helper" do actual = nested_set_options(Category.all) do |c| "#{'-' * c.level} #{c.name}" end - actual.should == expected + actual.length.should == expected.length + expected.flatten.each do |node| + actual.flatten.should include(node) + end end it "test_nested_set_options_with_array_as_argument_with_mover" do @@ -90,7 +93,10 @@ describe "Helper" do actual = nested_set_options(Category.all, categories(:child_2)) do |c| "#{'-' * c.level} #{c.name}" end - actual.should == expected + actual.length.should == expected.length + expected.flatten.each do |node| + actual.flatten.should include(node) + end end end end diff --git a/lib/plugins/awesome_nested_set/spec/awesome_nested_set_spec.rb b/lib/plugins/awesome_nested_set/spec/awesome_nested_set_spec.rb index 105bac1ca..60cb888b2 100644 --- a/lib/plugins/awesome_nested_set/spec/awesome_nested_set_spec.rb +++ b/lib/plugins/awesome_nested_set/spec/awesome_nested_set_spec.rb @@ -81,6 +81,12 @@ describe "AwesomeNestedSet" do Default.new.quoted_depth_column_name.should == quoted end + it "quoted_order_column_name" do + quoted = Default.connection.quote_column_name('lft') + Default.quoted_order_column_name.should == quoted + Default.new.quoted_order_column_name.should == quoted + end + it "left_column_protected_from_assignment" do lambda { Category.new.lft = 1 @@ -104,7 +110,12 @@ describe "AwesomeNestedSet" do end it "roots_class_method" do - Category.roots.should == Category.find_all_by_parent_id(nil) + found_by_us = Category.where(:parent_id => nil).to_a + found_by_roots = Category.roots.to_a + found_by_us.length.should == found_by_roots.length + found_by_us.each do |root| + found_by_roots.should include(root) + end end it "root_class_method" do @@ -131,7 +142,6 @@ describe "AwesomeNestedSet" do end it "leaves_class_method" do - Category.find(:all, :conditions => "#{Category.right_column_name} - #{Category.left_column_name} = 1").should == Category.leaves Category.leaves.count.should == 4 Category.leaves.should include(categories(:child_1)) Category.leaves.should include(categories(:child_2_1)) @@ -158,7 +168,7 @@ describe "AwesomeNestedSet" do it "self_and_ancestors" do child = categories(:child_2_1) self_and_ancestors = [categories(:top_level), categories(:child_2), child] - self_and_ancestors.should == child.self_and_ancestors + child.self_and_ancestors.should == self_and_ancestors end it "ancestors" do @@ -433,8 +443,8 @@ describe "AwesomeNestedSet" do categories(:child_2).parent.should be_nil categories(:child_2).level.should == 0 categories(:child_2_1).level.should == 1 - categories(:child_2).left.should == 1 - categories(:child_2).right.should == 4 + categories(:child_2).left.should == 7 + categories(:child_2).right.should == 10 Category.valid?.should be_true end @@ -775,14 +785,14 @@ describe "AwesomeNestedSet" do it "quoting_of_multi_scope_column_names" do ## Proper Array Assignment for different DBs as per their quoting column behavior - if Note.connection.adapter_name.match(/Oracle/) + if Note.connection.adapter_name.match(/oracle/i) expected_quoted_scope_column_names = ["\"NOTABLE_ID\"", "\"NOTABLE_TYPE\""] - elsif Note.connection.adapter_name.match(/Mysql/) + elsif Note.connection.adapter_name.match(/mysql/i) expected_quoted_scope_column_names = ["`notable_id`", "`notable_type`"] else expected_quoted_scope_column_names = ["\"notable_id\"", "\"notable_type\""] end - expected_quoted_scope_column_names.should == Note.quoted_scope_column_names + Note.quoted_scope_column_names.should == expected_quoted_scope_column_names end it "equal_in_same_scope" do diff --git a/lib/plugins/awesome_nested_set/spec/spec_helper.rb b/lib/plugins/awesome_nested_set/spec/spec_helper.rb index d28de5663..2e637bfc8 100644 --- a/lib/plugins/awesome_nested_set/spec/spec_helper.rb +++ b/lib/plugins/awesome_nested_set/spec/spec_helper.rb @@ -2,6 +2,7 @@ plugin_test_dir = File.dirname(__FILE__) require 'rubygems' require 'bundler/setup' +require 'pry' require 'logger' require 'active_record' @@ -22,6 +23,7 @@ require 'support/models' require 'action_controller' require 'rspec/rails' +require 'database_cleaner' RSpec.configure do |config| config.fixture_path = "#{plugin_test_dir}/fixtures" config.use_transactional_fixtures = true |